From 866311688e0a0d47050ac0321693a4574bf4a7fc Mon Sep 17 00:00:00 2001 From: Hilda-2580 Date: Tue, 20 Oct 2026 02:01:32 -0400 Subject: [PATCH 1/7] fix(gate): end a sleep's wake at dispose(), and arm it again at start() The event gate's dispose() cleared its debounce and self-wake timers and left the sleep's timer armed. A framework stopped while its agent slept kept the process running until the sleep's end, and then traced the sleep's expiry and queued a wake in the stopped framework. dispose() now clears that timer and keeps the sleep, with its note and agent. The framework's start() calls the gate's new resumeSleep(), which arms the wake again for a sleep still ahead, and wakes the agent at once for one that ended while the framework was stopped, as the queued wake did at the next start before. Co-Authored-By: Claude Opus 5.5 --- changelog.d/sleep-timer-ends-at-stop.fixed.md | 1 + src/framework.ts | 3 + src/gate/event-gate.ts | 25 +++- test/event-gate-sleep.test.ts | 123 ++++++++++++++++++ 4 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 changelog.d/sleep-timer-ends-at-stop.fixed.md create mode 100644 test/event-gate-sleep.test.ts diff --git a/changelog.d/sleep-timer-ends-at-stop.fixed.md b/changelog.d/sleep-timer-ends-at-stop.fixed.md new file mode 100644 index 000000000..1bc4e3002 --- /dev/null +++ b/changelog.d/sleep-timer-ends-at-stop.fixed.md @@ -0,0 +1 @@ +- A sleeping agent's wake no longer outlives its framework's `stop()`. The event gate's `dispose()` left the sleep's timer armed, so the stopped framework kept the process running until the sleep's end, and then queued a wake there. `dispose()` now clears that timer and keeps the sleep. The next `start()` on the same framework arms the wake again for the sleep's end, or wakes the agent at once if the sleep ended while it was stopped, as the queued wake did before. diff --git a/src/framework.ts b/src/framework.ts index 2df4807f1..dcc3fd29b 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -3555,6 +3555,9 @@ export class AgentFramework { // for this one. this.lifecycleGeneration++; this.loopPromise = this.runLoop(); + // A sleep kept through a stop() wakes this lifecycle at its end, or now + // if it ended while stopped. + this.eventGate?.resumeSleep(); // Start periodic sync timer (if enabled) if (this.syncIntervalMs > 0) { diff --git a/src/gate/event-gate.ts b/src/gate/event-gate.ts index 6aee71f7e..ec9d58d0a 100644 --- a/src/gate/event-gate.ts +++ b/src/gate/event-gate.ts @@ -1736,8 +1736,7 @@ export class EventGate { // Arm the self-wake: when the window elapses, clear sleep and request an // inference so the agent resumes on its own (not dependent on a heartbeat // tick or an incoming message). Cancelled by clearSleep()/`wake`. - if (this.sleepTimer) clearTimeout(this.sleepTimer); - this.sleepTimer = setTimeout(() => this.wakeFromSleep('sleep-expired'), ms); + this.armSleepWake(ms); this.reloadPrivilegedIfChanged(); this.emitTrace({ type: 'gate:decision', @@ -1750,6 +1749,23 @@ export class EventGate { return { until: this.sleepUntil }; } + /** + * Arm the sleep's wake for a lifecycle that starts after a stop(): dispose() + * clears its timer and keeps the sleep. A sleep still ahead wakes the agent + * at its end; one that ended while stopped wakes it now, since that wake is + * what the agent asked for. With no sleep, wakeFromSleep does nothing. + */ + resumeSleep(): void { + const remaining = this.sleepUntil - this.now(); + if (remaining > 0) this.armSleepWake(remaining); + else this.wakeFromSleep('sleep-expired'); + } + + private armSleepWake(ms: number): void { + if (this.sleepTimer) clearTimeout(this.sleepTimer); + this.sleepTimer = setTimeout(() => this.wakeFromSleep('sleep-expired'), ms); + } + // ========================================================================= // Self-wake (skip_reply wake_in_seconds) // ========================================================================= @@ -2003,6 +2019,11 @@ export class EventGate { clearTimeout(timer); } this.selfWakeTimers.clear(); + // The sleep stays, with its note and agent, and its timer goes: fired + // after a stop(), its wake reached a framework that had stopped, and the + // timer held the process until the sleep's end. The next start() arms it + // again (resumeSleep). + if (this.sleepTimer) { clearTimeout(this.sleepTimer); this.sleepTimer = null; } this.rateLimitBuckets.clear(); this.passiveSampleCounters.clear(); this.rateLimitDenied.clear(); diff --git a/test/event-gate-sleep.test.ts b/test/event-gate-sleep.test.ts new file mode 100644 index 000000000..d5d37a8e4 --- /dev/null +++ b/test/event-gate-sleep.test.ts @@ -0,0 +1,123 @@ +/** + * Sleep across a stop: EventGate.dispose() and resumeSleep(). + * + * Contract: + * - dispose() keeps the sleep (until, note, agent) and clears its timer, so + * nothing fires into a stopped framework and nothing holds the process. + * - resumeSleep(), which the framework's start() calls, arms the wake again + * for a sleep still ahead, and wakes at once for one that ended while + * stopped: that wake is the one the agent asked for. + */ + +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert'; +import { existsSync, mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { JsStore } from '@animalabs/chronicle'; +import { EventGate } from '../src/gate/event-gate.js'; +import { AgentFramework } from '../src/index.js'; +import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; + +const TMP_DIR = join(import.meta.dirname, '../.test-tmp-gate-sleep'); + +function makeGate() { + const inferenceRequests: Array<{ agentName: string; reason: string; source: string }> = []; + const traces: string[] = []; + const gate = new EventGate({ + configPath: join(TMP_DIR, 'gate.json'), + initialConfig: { policies: [], default: 'always' }, + emitTrace: (e) => { if (typeof e.eventType === 'string') traces.push(e.eventType); }, + addMessage: () => '', + requestInference: (a, r, s) => inferenceRequests.push({ agentName: a, reason: r, source: s }), + getAgentNames: () => ['agent'], + }); + return { gate, inferenceRequests, traces }; +} + +const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +describe('EventGate sleep across a stop', () => { + beforeEach(() => { + if (existsSync(TMP_DIR)) rmSync(TMP_DIR, { recursive: true }); + mkdirSync(TMP_DIR, { recursive: true }); + }); + afterEach(() => { + if (existsSync(TMP_DIR)) rmSync(TMP_DIR, { recursive: true }); + }); + + it('dispose() keeps the sleep and lets nothing fire', async () => { + const { gate, inferenceRequests, traces } = makeGate(); + gate.setSleep(0.2, 'resting', 'agent'); + gate.dispose(); + await wait(400); + assert.deepStrictEqual(inferenceRequests, [], 'no wake after dispose()'); + assert.ok(!traces.includes('sleep:expired'), 'no expiry after dispose()'); + assert.ok(gate.inferenceDiagnostics().sleepUntil > 0, 'the sleep is kept for the next start'); + }); + + it('resumeSleep() wakes once at the end of a sleep still ahead', async () => { + const { gate, inferenceRequests } = makeGate(); + gate.setSleep(0.4, 'resting', 'agent'); + gate.dispose(); + gate.resumeSleep(); + await wait(150); + assert.strictEqual(inferenceRequests.length, 0, 'not before the sleep ends'); + await wait(450); + assert.deepStrictEqual(inferenceRequests, [{ agentName: 'agent', reason: 'sleep-expired: resting', source: 'sleep' }]); + gate.resumeSleep(); + await wait(50); + assert.strictEqual(inferenceRequests.length, 1, 'with the sleep over, resumeSleep() does nothing'); + gate.dispose(); + }); + + it('resumeSleep() wakes at once for a sleep that ended while disposed', async () => { + const { gate, inferenceRequests } = makeGate(); + gate.setSleep(0.1, 'resting', 'agent'); + gate.dispose(); + await wait(250); + assert.strictEqual(inferenceRequests.length, 0); + gate.resumeSleep(); + assert.deepStrictEqual(inferenceRequests, [{ agentName: 'agent', reason: 'sleep-expired: resting', source: 'sleep' }]); + assert.strictEqual(gate.inferenceDiagnostics().sleepUntil, 0); + gate.dispose(); + }); + + it("a framework stopped while its agent sleeps wakes nothing until it's started again, then wakes the agent", async () => { + // A store the caller owns: an owned store closes at stop(), and a start() + // after that is refused. + const store = JsStore.openOrCreate({ path: join(TMP_DIR, 'store') }); + const membrane = new MockMembrane(); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'awake' }])); + const framework = await AgentFramework.create({ + store, + membrane: membrane.asMembrane(), + agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test' }], + gate: { config: { policies: [], default: 'always' } }, + modules: [], + }); + const internals = framework as unknown as { + eventGate: EventGate; + pendingRequests: Array<{ source: string }>; + }; + const expiries: string[] = []; + framework.onTrace((e) => { + if ((e as { eventType?: unknown }).eventType === 'sleep:expired') expiries.push('sleep:expired'); + }); + try { + framework.start(); + internals.eventGate.setSleep(0.2, 'resting', 'agent'); + await framework.stop(); + await wait(400); + assert.deepStrictEqual(expiries, [], 'nothing expires while stopped'); + assert.ok(!internals.pendingRequests.some((r) => r.source === 'sleep'), 'no wake is queued while stopped'); + framework.start(); + const deadline = Date.now() + 3_000; + while (membrane.calls.length === 0 && Date.now() < deadline) await wait(25); + assert.strictEqual(membrane.calls.length, 1, 'the start wakes the agent once'); + assert.deepStrictEqual(expiries, ['sleep:expired']); + } finally { + await framework.stop(); + store.close(); + } + }); +}); From 5cfbf3e9e682d32f5ae69be93db54ac4658d88f9 Mon Sep 17 00:00:00 2001 From: Hilda-2580 Date: Tue, 20 Oct 2026 03:03:32 -0400 Subject: [PATCH 2/7] fix(gate): queue a sleep's owed wake before the loop's first pass start() now calls resumeSleep() before runLoop(), whose first pass takes every pending request at once. A wake owed for a sleep that ended while stopped then joins a request already pending, such as an operator's nudge, in one turn, as the queued wake did before. Called after the loop started, it made a second turn. Also: - setSleep()'s comment names dispose() and resumeSleep() beside clearSleep() and `wake`. - The dispose() test checks that the gate holds no timer once disposed, so nothing keeps the process alive, and a new test pins the one-turn case above. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 5 +++-- src/gate/event-gate.ts | 3 ++- test/event-gate-sleep.test.ts | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index dcc3fd29b..1ba486b07 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -3554,10 +3554,11 @@ export class AgentFramework { // and withheld spills, closed for the generation a stop ended, are open // for this one. this.lifecycleGeneration++; - this.loopPromise = this.runLoop(); // A sleep kept through a stop() wakes this lifecycle at its end, or now - // if it ended while stopped. + // if it ended while stopped. Before the loop's first pass, so that a wake + // owed now joins the requests already pending in one turn. this.eventGate?.resumeSleep(); + this.loopPromise = this.runLoop(); // Start periodic sync timer (if enabled) if (this.syncIntervalMs > 0) { diff --git a/src/gate/event-gate.ts b/src/gate/event-gate.ts index ec9d58d0a..4fb5237db 100644 --- a/src/gate/event-gate.ts +++ b/src/gate/event-gate.ts @@ -1735,7 +1735,8 @@ export class EventGate { this.sleepSuppressed = 0; // Arm the self-wake: when the window elapses, clear sleep and request an // inference so the agent resumes on its own (not dependent on a heartbeat - // tick or an incoming message). Cancelled by clearSleep()/`wake`. + // tick or an incoming message). Cancelled by clearSleep()/`wake`, and by + // dispose(), after which start() arms it again (resumeSleep). this.armSleepWake(ms); this.reloadPrivilegedIfChanged(); this.emitTrace({ diff --git a/test/event-gate-sleep.test.ts b/test/event-gate-sleep.test.ts index d5d37a8e4..42f923158 100644 --- a/test/event-gate-sleep.test.ts +++ b/test/event-gate-sleep.test.ts @@ -47,8 +47,12 @@ describe('EventGate sleep across a stop', () => { it('dispose() keeps the sleep and lets nothing fire', async () => { const { gate, inferenceRequests, traces } = makeGate(); + const timeouts = () => process.getActiveResourcesInfo().filter((r) => r === 'Timeout').length; + const before = timeouts(); gate.setSleep(0.2, 'resting', 'agent'); + assert.strictEqual(timeouts(), before + 1, 'the sleep arms one timer'); gate.dispose(); + assert.strictEqual(timeouts(), before, 'dispose() leaves no timer to hold the process'); await wait(400); assert.deepStrictEqual(inferenceRequests, [], 'no wake after dispose()'); assert.ok(!traces.includes('sleep:expired'), 'no expiry after dispose()'); @@ -120,4 +124,33 @@ describe('EventGate sleep across a stop', () => { store.close(); } }); + + it('a sleep that ended while stopped wakes the agent in one turn with a request already pending at start', async () => { + const store = JsStore.openOrCreate({ path: join(TMP_DIR, 'store') }); + const membrane = new MockMembrane(); + for (let i = 0; i < 2; i++) membrane.pushResponse(createMockResponse([{ type: 'text', text: 'awake' }])); + const framework = await AgentFramework.create({ + store, + membrane: membrane.asMembrane(), + agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test' }], + gate: { config: { policies: [], default: 'always' } }, + modules: [], + }); + const gate = (framework as unknown as { eventGate: EventGate }).eventGate; + try { + framework.start(); + gate.setSleep(0.1, 'resting', 'agent'); + await framework.stop(); + await wait(250); + assert.strictEqual(framework.nudgeAgent('agent', 'test').ok, true); + framework.start(); + const deadline = Date.now() + 3_000; + while (membrane.calls.length === 0 && Date.now() < deadline) await wait(25); + await wait(600); + assert.strictEqual(membrane.calls.length, 1, 'the owed wake and the pending request make one turn'); + } finally { + await framework.stop(); + store.close(); + } + }); }); From fb3b45e8e22bb50768ad8ffe48204e562874f082 Mon Sep 17 00:00:00 2001 From: Hilda-2580 Date: Tue, 20 Oct 2026 03:32:22 -0400 Subject: [PATCH 3/7] test(gate): cover a sleep set twice, and make the framework sleep tests reliable - The dispose() test sets a sleep twice before disposing, so a sleep set again must replace its timer for dispose() to leave none. Its first sleep is 2 s, so a timer leaked that way holds the file's process for 2 s at most. - The two framework tests sleep for 1 s and wait 1.5 s after stop(), so stop() disposes the gate before the sleep ends even when it is slow: a sleep that ends during stop()'s own teardown still fires, by design. - The two framework tests give the gate a config path in their temporary directory. Without one, a gate on a store the caller owns writes ./data/gate.json under the working directory and leaves it there, and a later run loads it. Co-Authored-By: Claude Opus 5.5 --- test/event-gate-sleep.test.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/test/event-gate-sleep.test.ts b/test/event-gate-sleep.test.ts index 42f923158..d280e2b2a 100644 --- a/test/event-gate-sleep.test.ts +++ b/test/event-gate-sleep.test.ts @@ -49,8 +49,12 @@ describe('EventGate sleep across a stop', () => { const { gate, inferenceRequests, traces } = makeGate(); const timeouts = () => process.getActiveResourcesInfo().filter((r) => r === 'Timeout').length; const before = timeouts(); + // The first sleep is short too: if setting a sleep again stopped + // replacing its timer, the first sleep's timer would hold this file's + // process until it fired. + gate.setSleep(2, 'resting', 'agent'); gate.setSleep(0.2, 'resting', 'agent'); - assert.strictEqual(timeouts(), before + 1, 'the sleep arms one timer'); + assert.strictEqual(timeouts(), before + 1, 'a sleep set again replaces its timer'); gate.dispose(); assert.strictEqual(timeouts(), before, 'dispose() leaves no timer to hold the process'); await wait(400); @@ -96,7 +100,7 @@ describe('EventGate sleep across a stop', () => { store, membrane: membrane.asMembrane(), agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test' }], - gate: { config: { policies: [], default: 'always' } }, + gate: { config: { policies: [], default: 'always' }, configPath: join(TMP_DIR, 'gate-stopped.json') }, modules: [], }); const internals = framework as unknown as { @@ -109,9 +113,11 @@ describe('EventGate sleep across a stop', () => { }); try { framework.start(); - internals.eventGate.setSleep(0.2, 'resting', 'agent'); + // Long enough for stop() to dispose the gate first even on a loaded + // host: a sleep that ends during stop()'s own teardown still fires. + internals.eventGate.setSleep(1, 'resting', 'agent'); await framework.stop(); - await wait(400); + await wait(1_500); assert.deepStrictEqual(expiries, [], 'nothing expires while stopped'); assert.ok(!internals.pendingRequests.some((r) => r.source === 'sleep'), 'no wake is queued while stopped'); framework.start(); @@ -133,15 +139,17 @@ describe('EventGate sleep across a stop', () => { store, membrane: membrane.asMembrane(), agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test' }], - gate: { config: { policies: [], default: 'always' } }, + gate: { config: { policies: [], default: 'always' }, configPath: join(TMP_DIR, 'gate-one-turn.json') }, modules: [], }); const gate = (framework as unknown as { eventGate: EventGate }).eventGate; try { framework.start(); - gate.setSleep(0.1, 'resting', 'agent'); + // As above: the sleep ends after stop() has disposed the gate, so its + // wake is owed at start rather than queued during the teardown. + gate.setSleep(1, 'resting', 'agent'); await framework.stop(); - await wait(250); + await wait(1_500); assert.strictEqual(framework.nudgeAgent('agent', 'test').ok, true); framework.start(); const deadline = Date.now() + 3_000; From b6bea6e228dbf0bdd4f3ea8b194500e9b68e55ec Mon Sep 17 00:00:00 2001 From: Hilda-2580 Date: Wed, 21 Oct 2026 13:51:02 -0400 Subject: [PATCH 4/7] fix(gate): wake by the gate's clock, and end a fork's sleep with the fork From the maintainers' review of this change (R1-1 to R1-4): - R1-1: a sleep's timer that fired before the sleep's end by the gate's clock dropped the wake: wakeFromSleep returned, and nothing armed the timer again. On Node a timer can fire a millisecond early, a clock set back does the same, and a sleep longer than setTimeout holds (2^31-1 ms, about 24.8 days) fired at once. wakeFromSleep now arms the rest instead, and armSleepWake arms at most MAX_TIMER_MS (deadline-timer's constant, now exported), both by the gate's own now(). - R1-2: the gate's sleep is one for every agent, and its wake goes to the agent that set it. A conversation fork's sleep outlived the fork, at its idle close or at stop(), so every agent's wakes were held back until its end, and the wake then went to a name that was gone. disposeConversationAgent now ends it, through the gate's new clearSleepFor(). - R1-3: dispose() cleared the sleep's timer, but a turn a failed stop() left running could arm a new one, or a self-wake's, on the stopped framework. The gate now arms neither from dispose() to the next resumeSleep(). - R1-4: the framework tests fail with why if stop() outlasts their 1 s sleep. Co-Authored-By: Claude Opus 5.5 --- changelog.d/sleep-timer-ends-at-stop.fixed.md | 4 +- src/deadline-timer.ts | 2 +- src/framework.ts | 3 + src/gate/event-gate.ts | 61 ++++++++-- test/conversation-routing.test.ts | 52 ++++++++ test/event-gate-sleep.test.ts | 115 ++++++++++++++++-- 6 files changed, 213 insertions(+), 24 deletions(-) diff --git a/changelog.d/sleep-timer-ends-at-stop.fixed.md b/changelog.d/sleep-timer-ends-at-stop.fixed.md index 1bc4e3002..3df58695c 100644 --- a/changelog.d/sleep-timer-ends-at-stop.fixed.md +++ b/changelog.d/sleep-timer-ends-at-stop.fixed.md @@ -1 +1,3 @@ -- A sleeping agent's wake no longer outlives its framework's `stop()`. The event gate's `dispose()` left the sleep's timer armed, so the stopped framework kept the process running until the sleep's end, and then queued a wake there. `dispose()` now clears that timer and keeps the sleep. The next `start()` on the same framework arms the wake again for the sleep's end, or wakes the agent at once if the sleep ended while it was stopped, as the queued wake did before. +- A sleeping agent's wake no longer outlives its framework's `stop()`. The event gate's `dispose()` left the sleep's timer armed, so the stopped framework kept the process running until the sleep's end, and then queued a wake there. `dispose()` now clears that timer and keeps the sleep, and until the next `start()` nothing arms a sleep's or a self-wake's timer, even for a turn a failed `stop()` left running. The next `start()` on the same framework arms the wake again for the sleep's end, or wakes the agent at once if the sleep ended while it was stopped, as the queued wake did before. +- A sleep now wakes the agent at its end by the gate's clock, whatever its length. Its timer could fire before the end, and the wake was then dropped, so the agent slept on until something else woke it. On Node a timer can fire a millisecond early: in a probe on Linux with Node 24.22.0, 7 to 10 of every 1,000 timers of 1 to 40 ms, armed the way a sleep arms them, did, and with Bun 1.4.3 none of 2,000 did. A clock set back did the same on any runtime, and a sleep longer than about 24.8 days fired its timer at once. +- A sleep a conversation fork set ends when the fork does, at its idle close or at `stop()`. The gate's sleep holds back every agent's wakes but the heartbeat's and a privileged user's, so they were held back until its end, and its wake then went to the fork's name, which no longer existed. diff --git a/src/deadline-timer.ts b/src/deadline-timer.ts index 3c477afd1..4ecb20966 100644 --- a/src/deadline-timer.ts +++ b/src/deadline-timer.ts @@ -1,6 +1,6 @@ /** The longest delay one setTimeout holds (2^31 - 1 ms, about 24.8 days). * Node fires a longer one almost at once, with a TimeoutOverflowWarning. */ -const MAX_TIMER_MS = 2_147_483_647; +export const MAX_TIMER_MS = 2_147_483_647; type Hold = (timer: ReturnType) => void; diff --git a/src/framework.ts b/src/framework.ts index 1ba486b07..d6e5109d5 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -12649,6 +12649,9 @@ export class AgentFramework { this.activeStreams.delete(agentName); this.pendingAssistantBlocks.delete(agentName); this.agents.delete(agentName); + // A sleep the fork set ends with it: the gate's sleep is one for every + // agent, and its wake would go to a name that's gone. + this.eventGate?.clearSleepFor(agentName); if (agent) this.releaseWindowNowOrAfterResume(agent); this.turnRouting.delete(agentName); this.proseSpeechChains.delete(agentName); diff --git a/src/gate/event-gate.ts b/src/gate/event-gate.ts index 4fb5237db..db8ae8021 100644 --- a/src/gate/event-gate.ts +++ b/src/gate/event-gate.ts @@ -14,6 +14,7 @@ import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { GateScript } from './gate-script.js'; +import { MAX_TIMER_MS } from '../deadline-timer.js'; import { safeSlice } from '../safe-slice.js'; import type { ToolDefinition } from '../types/events.js'; @@ -602,6 +603,9 @@ export class EventGate { private sleepSuppressed = 0; private sleepTimer: ReturnType | null = null; private sleepAgent: string | undefined; + // From dispose() to the next resumeSleep(): no sleep or self-wake timer is + // armed, though a turn a failed stop() left running can still ask for one. + private disposed = false; // Self-wake timers per agent (skip_reply's wake_in_seconds). Unlike sleep, // a self-wake suppresses NOTHING — it means "if nothing else wakes me by @@ -1737,7 +1741,7 @@ export class EventGate { // inference so the agent resumes on its own (not dependent on a heartbeat // tick or an incoming message). Cancelled by clearSleep()/`wake`, and by // dispose(), after which start() arms it again (resumeSleep). - this.armSleepWake(ms); + this.armSleepWake(); this.reloadPrivilegedIfChanged(); this.emitTrace({ type: 'gate:decision', @@ -1751,20 +1755,41 @@ export class EventGate { } /** - * Arm the sleep's wake for a lifecycle that starts after a stop(): dispose() - * clears its timer and keeps the sleep. A sleep still ahead wakes the agent - * at its end; one that ended while stopped wakes it now, since that wake is - * what the agent asked for. With no sleep, wakeFromSleep does nothing. + * Arm timers again for a lifecycle that starts after a stop(), and the + * sleep's wake first: dispose() clears its timer and keeps the sleep. A + * sleep still ahead wakes the agent at its end; one that ended while + * stopped wakes it now, since that wake is what the agent asked for. With + * no sleep, wakeFromSleep does nothing. */ resumeSleep(): void { - const remaining = this.sleepUntil - this.now(); - if (remaining > 0) this.armSleepWake(remaining); + this.disposed = false; + if (this.sleepUntil > this.now()) this.armSleepWake(); else this.wakeFromSleep('sleep-expired'); } - private armSleepWake(ms: number): void { - if (this.sleepTimer) clearTimeout(this.sleepTimer); - this.sleepTimer = setTimeout(() => this.wakeFromSleep('sleep-expired'), ms); + /** + * Arm the sleep's wake for its end by the gate's own clock. One timer holds + * at most MAX_TIMER_MS, about 24.8 days, so a longer sleep waits in steps, + * and wakeFromSleep reads the clock as each one fires. Nothing is armed + * while the gate is disposed: resumeSleep() arms it at the next start(). + */ + private armSleepWake(): void { + if (this.sleepTimer) { clearTimeout(this.sleepTimer); this.sleepTimer = null; } + if (this.disposed) return; + const remaining = Math.max(0, this.sleepUntil - this.now()); + this.sleepTimer = setTimeout(() => this.wakeFromSleep('sleep-expired'), Math.min(remaining, MAX_TIMER_MS)); + } + + /** + * End the sleep `agentName` set, once that agent is gone, as a conversation + * fork's end does: nobody is asleep then, so the other agents' wakes stop + * being held back, and no wake goes to a name that's gone. Returns true if + * it was that agent's sleep. + */ + clearSleepFor(agentName: string): boolean { + if (this.sleepUntil === 0 || this.sleepAgent !== agentName) return false; + this.clearSleep(); + return true; } // ========================================================================= @@ -1780,6 +1805,8 @@ export class EventGate { */ armSelfWake(agentName: string, seconds: number, source = 'skip_reply'): { inMs: number } { const ms = Math.max(1_000, Math.min(3_600_000, Math.floor(seconds * 1000))); + // A stopped framework's late turn arms no timer (disposed). + if (this.disposed) return { inMs: ms }; const prev = this.selfWakeTimers.get(agentName); if (prev) clearTimeout(prev); const timer = setTimeout(() => { @@ -1838,11 +1865,17 @@ export class EventGate { * Clears the sleep state and requests an inference so the agent wakes on its * own — this is what makes `sleep(N)` a real "wake me in N seconds" rather * than just a suppression window. No-op if sleep was already cleared (via - * `wake`) or re-armed to a later time by a fresh `sleep` call. + * `wake`). A timer that fires before the sleep's end by the gate's clock + * arms the rest: a step of a sleep longer than one timer holds, a timer that + * fires early (Node's can, by a millisecond), or a clock set back. */ private wakeFromSleep(reason: string): void { this.sleepTimer = null; - if (this.sleepUntil === 0 || this.sleepUntil > this.now()) return; + if (this.sleepUntil === 0) return; + if (this.sleepUntil > this.now()) { + this.armSleepWake(); + return; + } const note = this.sleepNote; const agent = this.sleepAgent; this.sleepUntil = 0; @@ -2023,8 +2056,10 @@ export class EventGate { // The sleep stays, with its note and agent, and its timer goes: fired // after a stop(), its wake reached a framework that had stopped, and the // timer held the process until the sleep's end. The next start() arms it - // again (resumeSleep). + // again (resumeSleep). Until then nothing arms one, nor a self-wake: a + // turn a failed stop() left running can still call sleep or skip_reply. if (this.sleepTimer) { clearTimeout(this.sleepTimer); this.sleepTimer = null; } + this.disposed = true; this.rateLimitBuckets.clear(); this.passiveSampleCounters.clear(); this.rateLimitDenied.clear(); diff --git a/test/conversation-routing.test.ts b/test/conversation-routing.test.ts index 357aa418f..643256a31 100644 --- a/test/conversation-routing.test.ts +++ b/test/conversation-routing.test.ts @@ -15,6 +15,7 @@ import { Membrane, OpenAIResponsesAPIAdapter, OpenAIResponsesFormatter } from '@ import type { ContentBlock } from '@animalabs/membrane'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; import { forkAgentName } from '../src/mcpl/conversation-router.js'; +import type { EventGate } from '../src/gate/event-gate.js'; /** The name the router gives a channel's fork, for the `srv` server every * event here comes from. */ @@ -678,6 +679,57 @@ describe('Conversation routing', () => { } }); + // The gate's sleep is one for every agent, and its wake goes to the agent + // that set it: a fork's sleep ends with the fork. + async function makeGatedFramework(conversations: Partial = {}) { + return AgentFramework.create({ + storePath: join(tempDir, 'test.chronicle'), + membrane: membrane.asMembrane(), + agents: [{ name: 'trunk', model: 'test-model', systemPrompt: 'You are the trunk.' }], + modules: [], + conversations: { templateAgent: 'trunk', ...conversations }, + gate: { config: { policies: [], default: 'always' }, configPath: join(tempDir, 'gate.json') }, + }); + } + const gateOf = (framework: AgentFramework) => (framework as unknown as { eventGate: EventGate }).eventGate; + + it("a fork's sleep ends when the idle sweep closes the fork", async () => { + const IDLE_TTL_MS = 60_000; + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'hello!' }])); + const framework = await makeGatedFramework({ idleTtlMs: IDLE_TTL_MS }); + try { + framework.pushEvent(incomingEvent({ channelId: 'slack:D7', text: 'hi', channelType: 'im' })); + await framework.runUntilIdle(); + const fork = forkName('slack:D7', 1); + assert.ok(framework.getAgent(fork), 'the fork exists'); + gateOf(framework).setSleep(3600, 'resting', fork); + + forceExpiry(framework, 'slack:D7', IDLE_TTL_MS); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Final report posted.' }])); + framework.pushEvent({ type: 'external-message', source: 'test', content: 'tick', metadata: {} } as unknown as ProcessEvent); + await framework.runUntilIdle(); + + assert.equal(framework.getAgent(fork), null, 'the fork is disposed'); + assert.equal(gateOf(framework).getSleepState(), null, "and its sleep ends with it, so the other agents' wakes aren't held back"); + } finally { + await framework.stop(); + } + }); + + it("a fork's sleep ends when stop() ends the fork, so a next start() resumes none", async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'hello!' }])); + const framework = await makeGatedFramework(); + framework.pushEvent(incomingEvent({ channelId: 'slack:D8', text: 'hi', channelType: 'im' })); + await framework.runUntilIdle(); + const fork = forkName('slack:D8', 1); + assert.ok(framework.getAgent(fork), 'the fork exists'); + const gate = gateOf(framework); + gate.setSleep(3600, 'resting', fork); + await framework.stop(); + assert.equal(framework.getAgent(fork), null, 'stop() ends the fork'); + assert.equal(gate.inferenceDiagnostics().sleepUntil, 0, 'and its sleep with it'); + }); + it('a write deferred for a fork during its closure turn is stored in the fork before it is disposed', async () => { const IDLE_TTL_MS = 60_000; membrane.pushResponse(createMockResponse([{ type: 'text', text: 'hello!' }])); diff --git a/test/event-gate-sleep.test.ts b/test/event-gate-sleep.test.ts index d280e2b2a..5f2a8f7d3 100644 --- a/test/event-gate-sleep.test.ts +++ b/test/event-gate-sleep.test.ts @@ -1,12 +1,18 @@ /** - * Sleep across a stop: EventGate.dispose() and resumeSleep(). + * A sleep's wake: EventGate's sleep timer, dispose() and resumeSleep(). * * Contract: * - dispose() keeps the sleep (until, note, agent) and clears its timer, so * nothing fires into a stopped framework and nothing holds the process. - * - resumeSleep(), which the framework's start() calls, arms the wake again - * for a sleep still ahead, and wakes at once for one that ended while - * stopped: that wake is the one the agent asked for. + * Until the next resumeSleep(), nothing arms a sleep's or a self-wake's + * timer, though a turn a failed stop() left running can still ask. + * - resumeSleep(), which the framework's start() calls before its loop's + * first pass, arms the wake again for a sleep still ahead, and wakes at + * once for one that ended while stopped: that wake is the one the agent + * asked for. + * - The wake comes at the sleep's end by the gate's own clock: a sleep + * longer than one timer holds waits in steps, and a timer that fires + * before the end waits out the rest. */ import { describe, it, beforeEach, afterEach } from 'node:test'; @@ -20,10 +26,11 @@ import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; const TMP_DIR = join(import.meta.dirname, '../.test-tmp-gate-sleep'); -function makeGate() { +function makeGate(now?: () => number) { const inferenceRequests: Array<{ agentName: string; reason: string; source: string }> = []; const traces: string[] = []; const gate = new EventGate({ + now, configPath: join(TMP_DIR, 'gate.json'), initialConfig: { policies: [], default: 'always' }, emitTrace: (e) => { if (typeof e.eventType === 'string') traces.push(e.eventType); }, @@ -35,8 +42,19 @@ function makeGate() { } const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const timeouts = () => process.getActiveResourcesInfo().filter((r) => r === 'Timeout').length; -describe('EventGate sleep across a stop', () => { +/** stop(), failing with why if it outlasted the sleep: a sleep that ends + * during stop()'s own teardown fires by design, so the test couldn't check + * a sleep kept through a stop. */ +async function stopWithinTheSleep(framework: AgentFramework, sleepMs: number): Promise { + const started = Date.now(); + await framework.stop(); + const took = Date.now() - started; + assert.ok(took < sleepMs, `stop() took ${took} ms, past the ${sleepMs} ms sleep, so the sleep ended during its teardown, which fires by design: this run can't check a sleep kept through a stop`); +} + +describe("EventGate sleep: its wake, a stop and a fork's end", () => { beforeEach(() => { if (existsSync(TMP_DIR)) rmSync(TMP_DIR, { recursive: true }); mkdirSync(TMP_DIR, { recursive: true }); @@ -47,7 +65,6 @@ describe('EventGate sleep across a stop', () => { it('dispose() keeps the sleep and lets nothing fire', async () => { const { gate, inferenceRequests, traces } = makeGate(); - const timeouts = () => process.getActiveResourcesInfo().filter((r) => r === 'Timeout').length; const before = timeouts(); // The first sleep is short too: if setting a sleep again stopped // replacing its timer, the first sleep's timer would hold this file's @@ -90,6 +107,86 @@ describe('EventGate sleep across a stop', () => { gate.dispose(); }); + it('a sleep longer than one timer holds keeps its wake armed', async () => { + // setTimeout holds at most 2^31-1 ms, about 24.8 days, and fires a longer + // delay after 1 ms. + const { gate, inferenceRequests } = makeGate(); + const timer = () => (gate as unknown as { sleepTimer: unknown }).sleepTimer; + const before = timeouts(); + try { + gate.setSleep(30 * 24 * 3600, 'resting', 'agent'); + const first = timer(); + await wait(50); + assert.strictEqual(timeouts(), before + 1, "a timer still waits for the sleep's end"); + assert.strictEqual(timer(), first, 'the same one: a step one timer holds, not one that fires at once and is armed again'); + assert.deepStrictEqual(inferenceRequests, []); + } finally { + gate.clearSleep(); + } + }); + + it("a sleep's wake follows the gate's own clock, and waits out a clock set back", async () => { + // The gate's clock runs ten days ahead of the wall clock, and is set back + // a second once the sleep is set, so the 100 ms timer fires 900 ms before + // the sleep's end by the gate's clock. + let offset = 10 * 24 * 3600 * 1000; + const { gate, inferenceRequests } = makeGate(() => Date.now() + offset); + try { + gate.setSleep(0.1, 'resting', 'agent'); + offset -= 1_000; + await wait(400); + assert.deepStrictEqual(inferenceRequests, [], "not before the sleep's end by the gate's clock"); + await wait(1_200); + assert.deepStrictEqual(inferenceRequests, [{ agentName: 'agent', reason: 'sleep-expired: resting', source: 'sleep' }]); + } finally { + gate.clearSleep(); + } + }); + + it("clearSleepFor() ends only the named agent's sleep", () => { + const { gate } = makeGate(); + try { + gate.setSleep(60, 'resting', 'agent'); + assert.strictEqual(gate.clearSleepFor('fork'), false); + assert.ok(gate.inferenceDiagnostics().sleepUntil > 0, "another agent's end leaves it"); + assert.strictEqual(gate.clearSleepFor('agent'), true); + assert.strictEqual(gate.inferenceDiagnostics().sleepUntil, 0); + assert.strictEqual(gate.clearSleepFor('agent'), false, 'nothing left to end'); + } finally { + gate.clearSleep(); + } + }); + + it('after dispose(), a sleep set again arms no timer until resumeSleep()', () => { + const { gate } = makeGate(); + const before = timeouts(); + try { + gate.dispose(); + gate.setSleep(60, 'resting', 'agent'); + assert.strictEqual(timeouts(), before, 'no timer while disposed'); + assert.ok(gate.inferenceDiagnostics().sleepUntil > 0, 'the sleep is kept for the next start'); + gate.resumeSleep(); + assert.strictEqual(timeouts(), before + 1, 'resumeSleep() arms it'); + } finally { + gate.clearSleep(); + } + }); + + it('after dispose(), a self-wake arms no timer until resumeSleep()', () => { + const { gate } = makeGate(); + const before = timeouts(); + try { + gate.dispose(); + gate.armSelfWake('agent', 60); + assert.strictEqual(timeouts(), before, 'no timer while disposed'); + gate.resumeSleep(); + gate.armSelfWake('agent', 60); + assert.strictEqual(timeouts(), before + 1, 'armed once resumed'); + } finally { + gate.dispose(); + } + }); + it("a framework stopped while its agent sleeps wakes nothing until it's started again, then wakes the agent", async () => { // A store the caller owns: an owned store closes at stop(), and a start() // after that is refused. @@ -116,7 +213,7 @@ describe('EventGate sleep across a stop', () => { // Long enough for stop() to dispose the gate first even on a loaded // host: a sleep that ends during stop()'s own teardown still fires. internals.eventGate.setSleep(1, 'resting', 'agent'); - await framework.stop(); + await stopWithinTheSleep(framework, 1_000); await wait(1_500); assert.deepStrictEqual(expiries, [], 'nothing expires while stopped'); assert.ok(!internals.pendingRequests.some((r) => r.source === 'sleep'), 'no wake is queued while stopped'); @@ -148,7 +245,7 @@ describe('EventGate sleep across a stop', () => { // As above: the sleep ends after stop() has disposed the gate, so its // wake is owed at start rather than queued during the teardown. gate.setSleep(1, 'resting', 'agent'); - await framework.stop(); + await stopWithinTheSleep(framework, 1_000); await wait(1_500); assert.strictEqual(framework.nudgeAgent('agent', 'test').ok, true); framework.start(); From 50987f09925decbf540214293330c8a72f7d681b Mon Sep 17 00:00:00 2001 From: Hilda-2580 Date: Wed, 21 Oct 2026 13:51:02 -0400 Subject: [PATCH 5/7] fix(gate): end a gone agent's sleep and self-wake, and drop no armed timer From the internal review of the previous commit: - clearSleepFor() (R1-2) becomes agentGone(): it also cancels the gone agent's pending self-wake, which would otherwise fire into a name that no longer exists, and runEphemeralToCompletion's removal calls it too, so an ephemeral run's sleep no longer outlives the run, as a fork's no longer does. - wakeFromSleep() cancels an armed sleep timer rather than dropping its handle, so resumeSleep() for a sleep already over leaves no timer that dispose() can't reach. - armSelfWake() says whether it armed, and skip_reply's log line says so when the framework has stopped. - The wake-timing gate tests run on node:test's mock timers and Date; the one-turn test rests on what the loop's first pass took; the framework tests check when stop() disposed the gate, not how long stop() took; and the long-sleep test reads the gate's own timer. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 14 +-- src/gate/event-gate.ts | 34 +++++--- test/conversation-routing.test.ts | 17 ++-- test/event-gate-sleep.test.ts | 136 ++++++++++++++++++++++-------- 4 files changed, 145 insertions(+), 56 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index d6e5109d5..cd628c4a9 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -8458,6 +8458,8 @@ export class AgentFramework { // deregistered, so disposal must release EventGate liveness itself. this.eventGate?.onInferenceEnded(agent.name); this.agents.delete(agent.name); + // Its sleep and self-wake end with it, as a fork's do. + this.eventGate?.agentGone(agent.name); this.releaseWindowNowOrAfterResume(agent); } this.toolImageLedgers.delete(agent.name); @@ -12649,9 +12651,9 @@ export class AgentFramework { this.activeStreams.delete(agentName); this.pendingAssistantBlocks.delete(agentName); this.agents.delete(agentName); - // A sleep the fork set ends with it: the gate's sleep is one for every - // agent, and its wake would go to a name that's gone. - this.eventGate?.clearSleepFor(agentName); + // Its sleep and self-wake end with it (the gate's sleep holds back every + // agent's wakes), and neither wake goes to a name that's gone. + this.eventGate?.agentGone(agentName); if (agent) this.releaseWindowNowOrAfterResume(agent); this.turnRouting.delete(agentName); this.proseSpeechChains.delete(agentName); @@ -20973,8 +20975,10 @@ export class AgentFramework { } const wake = readSkipReplyWake(enrichedCall.input); if (wake.kind === 'wake' && this.eventGate) { - const { inMs } = this.eventGate.armSelfWake(agentName, wake.seconds, 'skip_reply'); - console.error(`[self-wake] agent=${agentName} armed in ${Math.round(inMs / 1000)}s (skip_reply)`); + const { inMs, armed } = this.eventGate.armSelfWake(agentName, wake.seconds, 'skip_reply'); + console.error(armed + ? `[self-wake] agent=${agentName} armed in ${Math.round(inMs / 1000)}s (skip_reply)` + : `[self-wake] agent=${agentName} not armed: the framework has stopped (skip_reply)`); } } this.dispatchChannelToolCall(agentName, enrichedCall); diff --git a/src/gate/event-gate.ts b/src/gate/event-gate.ts index db8ae8021..9480007cd 100644 --- a/src/gate/event-gate.ts +++ b/src/gate/event-gate.ts @@ -1781,15 +1781,19 @@ export class EventGate { } /** - * End the sleep `agentName` set, once that agent is gone, as a conversation - * fork's end does: nobody is asleep then, so the other agents' wakes stop - * being held back, and no wake goes to a name that's gone. Returns true if - * it was that agent's sleep. + * `agentName` is gone, as a conversation fork's end or an ephemeral run's + * removes one: its pending self-wake is cancelled, and a sleep it set ends. + * The gate's sleep holds back every agent's wakes, so nobody is asleep once + * its agent is gone, and neither wake would reach a name that no longer + * exists. A sleep another agent set, or one set with no agent, stays. */ - clearSleepFor(agentName: string): boolean { - if (this.sleepUntil === 0 || this.sleepAgent !== agentName) return false; - this.clearSleep(); - return true; + agentGone(agentName: string): void { + const selfWake = this.selfWakeTimers.get(agentName); + if (selfWake) { + clearTimeout(selfWake); + this.selfWakeTimers.delete(agentName); + } + if (this.sleepUntil !== 0 && this.sleepAgent === agentName) this.clearSleep(); } // ========================================================================= @@ -1803,10 +1807,11 @@ export class EventGate { * Cancelled by the agent's next turn start (see onInferenceStarted): * the semantics are "if nothing else wakes me by then, wake me". */ - armSelfWake(agentName: string, seconds: number, source = 'skip_reply'): { inMs: number } { + armSelfWake(agentName: string, seconds: number, source = 'skip_reply'): { inMs: number; armed: boolean } { const ms = Math.max(1_000, Math.min(3_600_000, Math.floor(seconds * 1000))); - // A stopped framework's late turn arms no timer (disposed). - if (this.disposed) return { inMs: ms }; + // A stopped framework's late turn arms no timer (disposed). A self-wake + // doesn't outlive a stop, as dispose() clearing the pending ones says. + if (this.disposed) return { inMs: ms, armed: false }; const prev = this.selfWakeTimers.get(agentName); if (prev) clearTimeout(prev); const timer = setTimeout(() => { @@ -1847,7 +1852,7 @@ export class EventGate { behavior: 'skip', timestamp: this.now(), }); - return { inMs: ms }; + return { inMs: ms, armed: true }; } /** End sleep immediately. Returns true if the agent was asleep. */ @@ -1870,7 +1875,10 @@ export class EventGate { * fires early (Node's can, by a millisecond), or a clock set back. */ private wakeFromSleep(reason: string): void { - this.sleepTimer = null; + // The timer that called it has fired; from resumeSleep(), for a sleep + // already over, one may still be armed, and it goes, so no stale step of + // it can fire later. + if (this.sleepTimer) { clearTimeout(this.sleepTimer); this.sleepTimer = null; } if (this.sleepUntil === 0) return; if (this.sleepUntil > this.now()) { this.armSleepWake(); diff --git a/test/conversation-routing.test.ts b/test/conversation-routing.test.ts index 643256a31..37c4b58b6 100644 --- a/test/conversation-routing.test.ts +++ b/test/conversation-routing.test.ts @@ -712,11 +712,14 @@ describe('Conversation routing', () => { assert.equal(framework.getAgent(fork), null, 'the fork is disposed'); assert.equal(gateOf(framework).getSleepState(), null, "and its sleep ends with it, so the other agents' wakes aren't held back"); } finally { + // So that a run where the fork's end leaves the sleep doesn't hold the + // file's process for the sleep's hour. + gateOf(framework).clearSleep(); await framework.stop(); } }); - it("a fork's sleep ends when stop() ends the fork, so a next start() resumes none", async () => { + it("a fork's sleep ends when stop() ends the fork", async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'hello!' }])); const framework = await makeGatedFramework(); framework.pushEvent(incomingEvent({ channelId: 'slack:D8', text: 'hi', channelType: 'im' })); @@ -724,10 +727,14 @@ describe('Conversation routing', () => { const fork = forkName('slack:D8', 1); assert.ok(framework.getAgent(fork), 'the fork exists'); const gate = gateOf(framework); - gate.setSleep(3600, 'resting', fork); - await framework.stop(); - assert.equal(framework.getAgent(fork), null, 'stop() ends the fork'); - assert.equal(gate.inferenceDiagnostics().sleepUntil, 0, 'and its sleep with it'); + try { + gate.setSleep(3600, 'resting', fork); + await framework.stop(); + assert.equal(framework.getAgent(fork), null, 'stop() ends the fork'); + assert.equal(gate.inferenceDiagnostics().sleepUntil, 0, 'and its sleep with it'); + } finally { + gate.clearSleep(); + } }); it('a write deferred for a fork during its closure turn is stored in the fork before it is disposed', async () => { diff --git a/test/event-gate-sleep.test.ts b/test/event-gate-sleep.test.ts index 5f2a8f7d3..2af8a3e8a 100644 --- a/test/event-gate-sleep.test.ts +++ b/test/event-gate-sleep.test.ts @@ -44,14 +44,17 @@ function makeGate(now?: () => number) { const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); const timeouts = () => process.getActiveResourcesInfo().filter((r) => r === 'Timeout').length; -/** stop(), failing with why if it outlasted the sleep: a sleep that ends - * during stop()'s own teardown fires by design, so the test couldn't check - * a sleep kept through a stop. */ -async function stopWithinTheSleep(framework: AgentFramework, sleepMs: number): Promise { - const started = Date.now(); +/** stop(), failing with why if it disposed the gate only after the sleep's + * end: a sleep that ends during stop()'s own teardown, before the gate is + * disposed, fires by design, so the test couldn't check a sleep kept + * through a stop. */ +async function stopDisposingBefore(framework: AgentFramework, gate: EventGate, sleepEndsAt: number): Promise { + const dispose = gate.dispose.bind(gate); + let disposedAt: number | undefined; + gate.dispose = () => { disposedAt ??= Date.now(); dispose(); }; await framework.stop(); - const took = Date.now() - started; - assert.ok(took < sleepMs, `stop() took ${took} ms, past the ${sleepMs} ms sleep, so the sleep ended during its teardown, which fires by design: this run can't check a sleep kept through a stop`); + assert.ok(disposedAt !== undefined && disposedAt < sleepEndsAt, + `stop() disposed the gate ${disposedAt === undefined ? 'never' : `${disposedAt - sleepEndsAt} ms after the sleep's end`}, so the sleep ended during its teardown, which fires by design: this run can't check a sleep kept through a stop`); } describe("EventGate sleep: its wake, a stop and a fork's end", () => { @@ -80,27 +83,32 @@ describe("EventGate sleep: its wake, a stop and a fork's end", () => { assert.ok(gate.inferenceDiagnostics().sleepUntil > 0, 'the sleep is kept for the next start'); }); - it('resumeSleep() wakes once at the end of a sleep still ahead', async () => { + // The wake tests below run on node:test's mock timers and Date, so their + // verdicts follow the clock they set, not the host's scheduling. + it('resumeSleep() wakes once at the end of a sleep still ahead', (t) => { + t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); const { gate, inferenceRequests } = makeGate(); gate.setSleep(0.4, 'resting', 'agent'); gate.dispose(); + t.mock.timers.tick(150); gate.resumeSleep(); - await wait(150); + t.mock.timers.tick(249); assert.strictEqual(inferenceRequests.length, 0, 'not before the sleep ends'); - await wait(450); + t.mock.timers.tick(1); assert.deepStrictEqual(inferenceRequests, [{ agentName: 'agent', reason: 'sleep-expired: resting', source: 'sleep' }]); gate.resumeSleep(); - await wait(50); + t.mock.timers.tick(1_000); assert.strictEqual(inferenceRequests.length, 1, 'with the sleep over, resumeSleep() does nothing'); gate.dispose(); }); - it('resumeSleep() wakes at once for a sleep that ended while disposed', async () => { + it('resumeSleep() wakes at once for a sleep that ended while disposed', (t) => { + t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); const { gate, inferenceRequests } = makeGate(); gate.setSleep(0.1, 'resting', 'agent'); gate.dispose(); - await wait(250); - assert.strictEqual(inferenceRequests.length, 0); + t.mock.timers.tick(250); + assert.strictEqual(inferenceRequests.length, 0, 'nothing while disposed'); gate.resumeSleep(); assert.deepStrictEqual(inferenceRequests, [{ agentName: 'agent', reason: 'sleep-expired: resting', source: 'sleep' }]); assert.strictEqual(gate.inferenceDiagnostics().sleepUntil, 0); @@ -112,47 +120,68 @@ describe("EventGate sleep: its wake, a stop and a fork's end", () => { // delay after 1 ms. const { gate, inferenceRequests } = makeGate(); const timer = () => (gate as unknown as { sleepTimer: unknown }).sleepTimer; - const before = timeouts(); try { gate.setSleep(30 * 24 * 3600, 'resting', 'agent'); const first = timer(); await wait(50); - assert.strictEqual(timeouts(), before + 1, "a timer still waits for the sleep's end"); - assert.strictEqual(timer(), first, 'the same one: a step one timer holds, not one that fires at once and is armed again'); + assert.ok(first !== null && timer() === first, + "one timer still waits for the sleep's end: a step one timer holds, not one that fires at once and is armed again"); assert.deepStrictEqual(inferenceRequests, []); } finally { gate.clearSleep(); } }); - it("a sleep's wake follows the gate's own clock, and waits out a clock set back", async () => { + it("a sleep's wake follows the gate's own clock, and waits out a clock set back", (t) => { + t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); // The gate's clock runs ten days ahead of the wall clock, and is set back // a second once the sleep is set, so the 100 ms timer fires 900 ms before - // the sleep's end by the gate's clock. + // the sleep's end by the gate's clock, and the rest falls due at 1,100 ms. let offset = 10 * 24 * 3600 * 1000; const { gate, inferenceRequests } = makeGate(() => Date.now() + offset); try { gate.setSleep(0.1, 'resting', 'agent'); offset -= 1_000; - await wait(400); + t.mock.timers.tick(100); + t.mock.timers.tick(999); assert.deepStrictEqual(inferenceRequests, [], "not before the sleep's end by the gate's clock"); - await wait(1_200); + t.mock.timers.tick(1); assert.deepStrictEqual(inferenceRequests, [{ agentName: 'agent', reason: 'sleep-expired: resting', source: 'sleep' }]); } finally { gate.clearSleep(); } }); - it("clearSleepFor() ends only the named agent's sleep", () => { + it('resumeSleep() for a sleep already over cancels the timer still armed for it', () => { + let offset = 0; + const { gate, inferenceRequests } = makeGate(() => Date.now() + offset); + const before = timeouts(); + try { + gate.setSleep(60, 'resting', 'agent'); + // The gate's clock passes the sleep's end before its timer fires. + offset += 120_000; + gate.resumeSleep(); + assert.deepStrictEqual(inferenceRequests, [{ agentName: 'agent', reason: 'sleep-expired: resting', source: 'sleep' }], 'it wakes the agent once'); + assert.strictEqual(timeouts(), before, 'and leaves no timer armed'); + } finally { + gate.clearSleep(); + } + }); + + it("agentGone() ends that agent's sleep and self-wake, and leaves another agent's", () => { const { gate } = makeGate(); + const before = timeouts(); try { gate.setSleep(60, 'resting', 'agent'); - assert.strictEqual(gate.clearSleepFor('fork'), false); - assert.ok(gate.inferenceDiagnostics().sleepUntil > 0, "another agent's end leaves it"); - assert.strictEqual(gate.clearSleepFor('agent'), true); - assert.strictEqual(gate.inferenceDiagnostics().sleepUntil, 0); - assert.strictEqual(gate.clearSleepFor('agent'), false, 'nothing left to end'); + gate.armSelfWake('fork', 60); + gate.agentGone('fork'); + assert.ok(gate.inferenceDiagnostics().sleepUntil > 0, "another agent's end leaves its sleep"); + assert.strictEqual(timeouts(), before + 1, "and cancels the gone agent's self-wake"); + gate.agentGone('agent'); + assert.strictEqual(gate.inferenceDiagnostics().sleepUntil, 0, "the sleep's own agent's end ends it"); + assert.strictEqual(timeouts(), before); } finally { + gate.dispose(); gate.clearSleep(); } }); @@ -172,7 +201,7 @@ describe("EventGate sleep: its wake, a stop and a fork's end", () => { } }); - it('after dispose(), a self-wake arms no timer until resumeSleep()', () => { + it('after dispose(), a self-wake arms no timer, and one asked for after resumeSleep() does', () => { const { gate } = makeGate(); const before = timeouts(); try { @@ -187,6 +216,37 @@ describe("EventGate sleep: its wake, a stop and a fork's end", () => { } }); + it("an ephemeral run's sleep ends when the run does", async () => { + const membrane = new MockMembrane(); + membrane.pushResponse(createMockResponse([{ type: 'tool_use', id: 's1', name: 'sleep', input: { seconds: 3600, announce: false } }], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'done' }])); + const framework = await AgentFramework.create({ + storePath: join(TMP_DIR, 'store-ephemeral'), + membrane: membrane.asMembrane(), + agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test' }], + gate: { config: { policies: [], default: 'always' }, configPath: join(TMP_DIR, 'gate-ephemeral.json') }, + modules: [], + }); + const gate = (framework as unknown as { eventGate: EventGate }).eventGate; + const slept: string[] = []; + framework.onTrace((e) => { + if ((e as { eventType?: unknown }).eventType === 'sleep:set') slept.push('sleep:set'); + }); + try { + framework.start(); + const created = await framework.createEphemeralAgent({ name: 'worker', model: 'test-model', systemPrompt: 'Work.', allowedTools: 'all' }); + created.contextManager.addMessage('user', [{ type: 'text', text: 'Go.' }]); + await framework.runEphemeralToCompletion(created.agent, created.contextManager); + assert.deepStrictEqual(slept, ['sleep:set'], 'the run slept'); + assert.strictEqual(gate.getSleepState(), null, 'and its sleep ended with it'); + } finally { + // So that a run where the sleep outlives the run doesn't hold the + // file's process for the sleep's hour. + gate.clearSleep(); + await framework.stop(); + } + }); + it("a framework stopped while its agent sleeps wakes nothing until it's started again, then wakes the agent", async () => { // A store the caller owns: an owned store closes at stop(), and a start() // after that is refused. @@ -212,8 +272,9 @@ describe("EventGate sleep: its wake, a stop and a fork's end", () => { framework.start(); // Long enough for stop() to dispose the gate first even on a loaded // host: a sleep that ends during stop()'s own teardown still fires. + const sleepEndsAt = Date.now() + 1_000; internals.eventGate.setSleep(1, 'resting', 'agent'); - await stopWithinTheSleep(framework, 1_000); + await stopDisposingBefore(framework, internals.eventGate, sleepEndsAt); await wait(1_500); assert.deepStrictEqual(expiries, [], 'nothing expires while stopped'); assert.ok(!internals.pendingRequests.some((r) => r.source === 'sleep'), 'no wake is queued while stopped'); @@ -239,20 +300,29 @@ describe("EventGate sleep: its wake, a stop and a fork's end", () => { gate: { config: { policies: [], default: 'always' }, configPath: join(TMP_DIR, 'gate-one-turn.json') }, modules: [], }); - const gate = (framework as unknown as { eventGate: EventGate }).eventGate; + const internals = framework as unknown as { eventGate: EventGate; pendingRequests: unknown[] }; + const gate = internals.eventGate; + const expiries: string[] = []; + framework.onTrace((e) => { + if ((e as { eventType?: unknown }).eventType === 'sleep:expired') expiries.push('sleep:expired'); + }); try { framework.start(); // As above: the sleep ends after stop() has disposed the gate, so its // wake is owed at start rather than queued during the teardown. + const sleepEndsAt = Date.now() + 1_000; gate.setSleep(1, 'resting', 'agent'); - await stopWithinTheSleep(framework, 1_000); + await stopDisposingBefore(framework, gate, sleepEndsAt); await wait(1_500); assert.strictEqual(framework.nudgeAgent('agent', 'test').ok, true); framework.start(); + // The loop's first pass takes every pending request at once: the owed + // wake joined the nudge if nothing is left for a second turn. + assert.deepStrictEqual(expiries, ['sleep:expired'], 'the start owed the sleep its wake'); + assert.deepStrictEqual(internals.pendingRequests, [], 'and the first pass took it with the nudge'); const deadline = Date.now() + 3_000; while (membrane.calls.length === 0 && Date.now() < deadline) await wait(25); - await wait(600); - assert.strictEqual(membrane.calls.length, 1, 'the owed wake and the pending request make one turn'); + assert.strictEqual(membrane.calls.length, 1, 'one turn'); } finally { await framework.stop(); store.close(); From f5064bc714618d57fdcccdec31db986efdb51350 Mon Sep 17 00:00:00 2001 From: Hilda-2580 Date: Wed, 21 Oct 2026 13:51:02 -0400 Subject: [PATCH 6/7] test(gate): pin agentGone's rule case by case, and the removal sites' call From the internal review of the previous commit, tests only: - agentGone() is checked with one agent's sleep and self-wake together, another agent's self-wake left, the map entry gone, and a sleep another agent set, or one set with no agent, left. - The fork's and the ephemeral run's end are checked to tell the gate, through agentGone(), not only to end the sleep. - armSelfWake()'s armed result is checked while disposed and after. - The framework tests' guard reads the gate's own state at dispose(), with no wall-clock bound. - The one-turn test gives a second turn time to show. - A comment names which tests run on mock timers. Co-Authored-By: Claude Opus 5.5 --- test/conversation-routing.test.ts | 5 +++ test/event-gate-sleep.test.ts | 68 +++++++++++++++++++------------ test/helpers/gone-names.ts | 10 +++++ 3 files changed, 58 insertions(+), 25 deletions(-) create mode 100644 test/helpers/gone-names.ts diff --git a/test/conversation-routing.test.ts b/test/conversation-routing.test.ts index 37c4b58b6..928047842 100644 --- a/test/conversation-routing.test.ts +++ b/test/conversation-routing.test.ts @@ -16,6 +16,7 @@ import type { ContentBlock } from '@animalabs/membrane'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; import { forkAgentName } from '../src/mcpl/conversation-router.js'; import type { EventGate } from '../src/gate/event-gate.js'; +import { goneNames } from './helpers/gone-names.js'; /** The name the router gives a channel's fork, for the `srv` server every * event here comes from. */ @@ -703,6 +704,7 @@ describe('Conversation routing', () => { const fork = forkName('slack:D7', 1); assert.ok(framework.getAgent(fork), 'the fork exists'); gateOf(framework).setSleep(3600, 'resting', fork); + const gone = goneNames(gateOf(framework)); forceExpiry(framework, 'slack:D7', IDLE_TTL_MS); membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Final report posted.' }])); @@ -711,6 +713,7 @@ describe('Conversation routing', () => { assert.equal(framework.getAgent(fork), null, 'the fork is disposed'); assert.equal(gateOf(framework).getSleepState(), null, "and its sleep ends with it, so the other agents' wakes aren't held back"); + assert.deepStrictEqual(gone, [fork], "the fork's end told the gate, which ends its self-wake too"); } finally { // So that a run where the fork's end leaves the sleep doesn't hold the // file's process for the sleep's hour. @@ -727,11 +730,13 @@ describe('Conversation routing', () => { const fork = forkName('slack:D8', 1); assert.ok(framework.getAgent(fork), 'the fork exists'); const gate = gateOf(framework); + const gone = goneNames(gate); try { gate.setSleep(3600, 'resting', fork); await framework.stop(); assert.equal(framework.getAgent(fork), null, 'stop() ends the fork'); assert.equal(gate.inferenceDiagnostics().sleepUntil, 0, 'and its sleep with it'); + assert.deepStrictEqual(gone, [fork], "the fork's end told the gate, which ends its self-wake too"); } finally { gate.clearSleep(); } diff --git a/test/event-gate-sleep.test.ts b/test/event-gate-sleep.test.ts index 2af8a3e8a..c2c7c15a0 100644 --- a/test/event-gate-sleep.test.ts +++ b/test/event-gate-sleep.test.ts @@ -23,6 +23,7 @@ import { JsStore } from '@animalabs/chronicle'; import { EventGate } from '../src/gate/event-gate.js'; import { AgentFramework } from '../src/index.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; +import { goneNames } from './helpers/gone-names.js'; const TMP_DIR = join(import.meta.dirname, '../.test-tmp-gate-sleep'); @@ -44,17 +45,18 @@ function makeGate(now?: () => number) { const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); const timeouts = () => process.getActiveResourcesInfo().filter((r) => r === 'Timeout').length; -/** stop(), failing with why if it disposed the gate only after the sleep's - * end: a sleep that ends during stop()'s own teardown, before the gate is - * disposed, fires by design, so the test couldn't check a sleep kept - * through a stop. */ -async function stopDisposingBefore(framework: AgentFramework, gate: EventGate, sleepEndsAt: number): Promise { +/** stop(), failing with why if the sleep's wake had already fired when + * stop() disposed the gate: a sleep that ends during stop()'s own teardown, + * before the gate is disposed, fires by design, so the test couldn't check + * a sleep kept through a stop. It reads the gate's own state at dispose(), + * which a wake that fired has cleared. */ +async function stopDisposingBefore(framework: AgentFramework, gate: EventGate): Promise { const dispose = gate.dispose.bind(gate); - let disposedAt: number | undefined; - gate.dispose = () => { disposedAt ??= Date.now(); dispose(); }; + let keptAtDispose: boolean | undefined; + gate.dispose = () => { keptAtDispose ??= gate.inferenceDiagnostics().sleepUntil > 0; dispose(); }; await framework.stop(); - assert.ok(disposedAt !== undefined && disposedAt < sleepEndsAt, - `stop() disposed the gate ${disposedAt === undefined ? 'never' : `${disposedAt - sleepEndsAt} ms after the sleep's end`}, so the sleep ended during its teardown, which fires by design: this run can't check a sleep kept through a stop`); + assert.ok(keptAtDispose === true, + `${keptAtDispose === undefined ? 'stop() never disposed the gate' : "the sleep's wake fired before stop() disposed the gate"}, so the sleep ended during its teardown, which fires by design: this run can't check a sleep kept through a stop`); } describe("EventGate sleep: its wake, a stop and a fork's end", () => { @@ -83,8 +85,9 @@ describe("EventGate sleep: its wake, a stop and a fork's end", () => { assert.ok(gate.inferenceDiagnostics().sleepUntil > 0, 'the sleep is kept for the next start'); }); - // The wake tests below run on node:test's mock timers and Date, so their - // verdicts follow the clock they set, not the host's scheduling. + // This test, the next, and the gate's-clock test run on node:test's mock + // timers and Date, so their verdicts follow the clock they set, not the + // host's scheduling. The tests that count a timer read it without waiting. it('resumeSleep() wakes once at the end of a sleep still ahead', (t) => { t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); const { gate, inferenceRequests } = makeGate(); @@ -168,20 +171,34 @@ describe("EventGate sleep: its wake, a stop and a fork's end", () => { } }); - it("agentGone() ends that agent's sleep and self-wake, and leaves another agent's", () => { + it("agentGone() ends that agent's sleep and self-wake together, and leaves another agent's", () => { const { gate } = makeGate(); + const selfWakes = (gate as unknown as { selfWakeTimers: Map }).selfWakeTimers; const before = timeouts(); try { - gate.setSleep(60, 'resting', 'agent'); + gate.setSleep(60, 'resting', 'fork'); gate.armSelfWake('fork', 60); + gate.armSelfWake('agent', 60); gate.agentGone('fork'); - assert.ok(gate.inferenceDiagnostics().sleepUntil > 0, "another agent's end leaves its sleep"); - assert.strictEqual(timeouts(), before + 1, "and cancels the gone agent's self-wake"); - gate.agentGone('agent'); - assert.strictEqual(gate.inferenceDiagnostics().sleepUntil, 0, "the sleep's own agent's end ends it"); - assert.strictEqual(timeouts(), before); + assert.strictEqual(gate.inferenceDiagnostics().sleepUntil, 0, "the gone agent's sleep ends"); + assert.ok(!selfWakes.has('fork'), 'and its self-wake goes, entry and all'); + assert.ok(selfWakes.has('agent'), "another agent's self-wake stays"); + assert.strictEqual(timeouts(), before + 1, "and only that one's timer is left"); } finally { gate.dispose(); + } + }); + + it("agentGone() leaves a sleep another agent set, or one set with no agent", () => { + const { gate } = makeGate(); + try { + gate.setSleep(60, 'resting', 'agent'); + gate.agentGone('fork'); + assert.ok(gate.inferenceDiagnostics().sleepUntil > 0, "another agent's end leaves its sleep"); + gate.setSleep(60, 'resting'); + gate.agentGone('fork'); + assert.ok(gate.inferenceDiagnostics().sleepUntil > 0, 'a sleep with no agent stays'); + } finally { gate.clearSleep(); } }); @@ -206,10 +223,10 @@ describe("EventGate sleep: its wake, a stop and a fork's end", () => { const before = timeouts(); try { gate.dispose(); - gate.armSelfWake('agent', 60); + assert.strictEqual(gate.armSelfWake('agent', 60).armed, false, 'it says it armed nothing'); assert.strictEqual(timeouts(), before, 'no timer while disposed'); gate.resumeSleep(); - gate.armSelfWake('agent', 60); + assert.strictEqual(gate.armSelfWake('agent', 60).armed, true); assert.strictEqual(timeouts(), before + 1, 'armed once resumed'); } finally { gate.dispose(); @@ -228,6 +245,7 @@ describe("EventGate sleep: its wake, a stop and a fork's end", () => { modules: [], }); const gate = (framework as unknown as { eventGate: EventGate }).eventGate; + const gone = goneNames(gate); const slept: string[] = []; framework.onTrace((e) => { if ((e as { eventType?: unknown }).eventType === 'sleep:set') slept.push('sleep:set'); @@ -239,6 +257,7 @@ describe("EventGate sleep: its wake, a stop and a fork's end", () => { await framework.runEphemeralToCompletion(created.agent, created.contextManager); assert.deepStrictEqual(slept, ['sleep:set'], 'the run slept'); assert.strictEqual(gate.getSleepState(), null, 'and its sleep ended with it'); + assert.deepStrictEqual(gone, ['worker'], "the run's end told the gate, which ends its self-wake too"); } finally { // So that a run where the sleep outlives the run doesn't hold the // file's process for the sleep's hour. @@ -272,9 +291,8 @@ describe("EventGate sleep: its wake, a stop and a fork's end", () => { framework.start(); // Long enough for stop() to dispose the gate first even on a loaded // host: a sleep that ends during stop()'s own teardown still fires. - const sleepEndsAt = Date.now() + 1_000; internals.eventGate.setSleep(1, 'resting', 'agent'); - await stopDisposingBefore(framework, internals.eventGate, sleepEndsAt); + await stopDisposingBefore(framework, internals.eventGate); await wait(1_500); assert.deepStrictEqual(expiries, [], 'nothing expires while stopped'); assert.ok(!internals.pendingRequests.some((r) => r.source === 'sleep'), 'no wake is queued while stopped'); @@ -310,9 +328,8 @@ describe("EventGate sleep: its wake, a stop and a fork's end", () => { framework.start(); // As above: the sleep ends after stop() has disposed the gate, so its // wake is owed at start rather than queued during the teardown. - const sleepEndsAt = Date.now() + 1_000; gate.setSleep(1, 'resting', 'agent'); - await stopDisposingBefore(framework, gate, sleepEndsAt); + await stopDisposingBefore(framework, gate); await wait(1_500); assert.strictEqual(framework.nudgeAgent('agent', 'test').ok, true); framework.start(); @@ -322,7 +339,8 @@ describe("EventGate sleep: its wake, a stop and a fork's end", () => { assert.deepStrictEqual(internals.pendingRequests, [], 'and the first pass took it with the nudge'); const deadline = Date.now() + 3_000; while (membrane.calls.length === 0 && Date.now() < deadline) await wait(25); - assert.strictEqual(membrane.calls.length, 1, 'one turn'); + await wait(600); + assert.strictEqual(membrane.calls.length, 1, 'one turn, and no second one after it'); } finally { await framework.stop(); store.close(); diff --git a/test/helpers/gone-names.ts b/test/helpers/gone-names.ts new file mode 100644 index 000000000..8c9c53c3d --- /dev/null +++ b/test/helpers/gone-names.ts @@ -0,0 +1,10 @@ +import type { EventGate } from '../../src/gate/event-gate.js'; + +/** The names the framework tells the gate are gone, through agentGone(), + * whose sleep and self-wake rule the gate's own tests check. */ +export function goneNames(gate: EventGate): string[] { + const gone: string[] = []; + const agentGone = gate.agentGone.bind(gate); + gate.agentGone = (name: string) => { gone.push(name); agentGone(name); }; + return gone; +} From 6bce8ee60994dd386246b82419b6dfe73546ab06 Mon Sep 17 00:00:00 2001 From: Hilda-2580 Date: Wed, 21 Oct 2026 13:51:19 -0400 Subject: [PATCH 7/7] docs(changelog): a gone agent's sleep and self-wake The fragment's third entry now names an ephemeral run's end beside a fork's, and the self-wake that ends with either, as the previous two commits made it. Co-Authored-By: Claude Opus 5.5 --- changelog.d/sleep-timer-ends-at-stop.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/sleep-timer-ends-at-stop.fixed.md b/changelog.d/sleep-timer-ends-at-stop.fixed.md index 3df58695c..dce43fb60 100644 --- a/changelog.d/sleep-timer-ends-at-stop.fixed.md +++ b/changelog.d/sleep-timer-ends-at-stop.fixed.md @@ -1,3 +1,3 @@ - A sleeping agent's wake no longer outlives its framework's `stop()`. The event gate's `dispose()` left the sleep's timer armed, so the stopped framework kept the process running until the sleep's end, and then queued a wake there. `dispose()` now clears that timer and keeps the sleep, and until the next `start()` nothing arms a sleep's or a self-wake's timer, even for a turn a failed `stop()` left running. The next `start()` on the same framework arms the wake again for the sleep's end, or wakes the agent at once if the sleep ended while it was stopped, as the queued wake did before. - A sleep now wakes the agent at its end by the gate's clock, whatever its length. Its timer could fire before the end, and the wake was then dropped, so the agent slept on until something else woke it. On Node a timer can fire a millisecond early: in a probe on Linux with Node 24.22.0, 7 to 10 of every 1,000 timers of 1 to 40 ms, armed the way a sleep arms them, did, and with Bun 1.4.3 none of 2,000 did. A clock set back did the same on any runtime, and a sleep longer than about 24.8 days fired its timer at once. -- A sleep a conversation fork set ends when the fork does, at its idle close or at `stop()`. The gate's sleep holds back every agent's wakes but the heartbeat's and a privileged user's, so they were held back until its end, and its wake then went to the fork's name, which no longer existed. +- A sleep a conversation fork or an ephemeral run set ends when it does, at a fork's idle close or at `stop()`, or at the run's end, and so does a `skip_reply` self-wake it left pending. The gate's sleep holds back every agent's wakes but the heartbeat's and a privileged user's, so they were held back until its end, and its wake, like the self-wake's, then went to a name that no longer existed.