Repository navigation
fix(gate): end a sleep's wake at dispose(), and arm it again at start() - #634
Conversation
Anarchid
left a comment
There was a problem hiding this comment.
Nightly dual review · round R1 at 569c3ce · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: APPROVE (the formal review event is left to the maintainer). Finding ids R1-n are stable; cite them in fix commits.
Reviewed at 569c3ce against main 97ca7eb (merge base 28dbcb7)
Verdict
Approve. Four minor findings. R1-1 is worth taking in this PR.
The fix is correct and narrow, and the description's claims hold up. We reproduced its red-first result and one of its mutation claims. The change also merges cleanly onto current main, which is 72 commits ahead and includes #616's changes to stop(), and its tests pass there. What's left is mostly around this change, not in it. The new armSleepWake() is now the single place the sleep's timer is armed, and it could use the repo's existing deadline helper to close both timer issues the description leaves to #194. A sleep set by a conversation fork doesn't fit the new contract on the main it will merge into.
This is our first review of this PR; there are no earlier findings to dispose of.
Blockers
None.
Major
None.
Minor
R1-1. armSleepWake() arms a bare setTimeout, and the repo already has the helper that would close both sleep-timer gaps in it.
src/gate/event-gate.ts:1765-1768; the handler's drop is at :1843-1845.
When the timer fires while Date.now() is still short of sleepUntil, wakeFromSleep returns, and nothing arms the timer again. The description lists this as out of scope, along with sleeps longer than about 24.8 days firing at once, and leaves both to #194. Two things change that here:
- This PR makes
armSleepWake()the one path bothsetSleep()andresumeSleep()arm through, so a fix in it covers both. test/event-gate-sleep.test.ts:66-79("resumeSleep() wakes once at the end of a sleep still ahead") is the suite's first test that waits for a real sleep timer to fire. The other sleep tests use a 3600 s sleep and never wait for it. So the test inherits the drop.
We measured this on Linux with Node 22.21, against the built head. We replayed that test's exact sequence (setSleep(0.4), dispose(), resumeSleep(), wait 600 ms, expect one wake), each run in a fresh tick:
- with nothing else in the tick, no wake was lost in 400 runs;
- with 5 ms of synchronous work earlier in the same tick, 5 of 400 runs lost the wake.
Every lost wake was a timer that fired exactly 1 ms early. A sleep tool call runs mid-turn, after synchronous work, so production looks like the second case. Roughly 1 in 100 sleeps gets no wake of its own, which matches the description's own probe (7 to 10 per 1,000). The agent then stays quiet past its sleep until something else wakes it.
Fix: armAt in src/deadline-timer.ts:17-27, already used by tune-out, does exactly this. At the end of each step it re-reads the wall clock and arms again for whatever is left. It also caps each step at 2^31−1 ms.
private armSleepWake(): void {
if (this.sleepTimer) clearTimeout(this.sleepTimer);
armAt(this.sleepUntil, (t) => { this.sleepTimer = t; }, () => this.wakeFromSleep('sleep-expired'));
}clearSleep() and dispose() still clear the latest step through sleepTimer. One caveat: armAt reads Date.now(), while the gate reads its injectable now (:665). They are the same in production, and no sleep test injects now. If you'd rather keep this.now(), do the same re-check in the helper's callback, with the same cap. With either, the new test is deterministic, and #194, which conflicts with main at the moment, can build on this rather than the reverse.
R1-2. On current main, a conversation fork's sleep is resumed for an agent that stop() has already ended.
This PR's base predates #616, which makes stop() end every live conversation fork. Here is the path on main:
- A fork takes the template's tool surface (
{ ...templateConfig, name }, mainsrc/framework.ts:12484), so it can callsleep. setSleep()records the fork assleepAgent(event-gate.ts:1734). The sleep is the gate's single, global one: it suppresses non-privileged wakes for every agent (:1055).stop()ends the fork (mainframework.ts:3978-3982, thenagents.deleteat:12647), and only then disposes the gate (:4019).- The next
start()callsresumeSleep(). The whole gate stays quiet for the rest of a sleep whose agent no longer exists. At its end the wake goes to a name that isn't registered, andprocessInferenceRequestsdrops it asagent_not_found(main:13907). Nothing wakes the primary.
This is not a regression: without this PR, the still-armed timer does the same thing on main. But the description states the contract that a sleep kept through a stop() wakes the restarted lifecycle at its end, and on the main this merges into, that is false for a fork's sleep. The idle sweep (main :12760) leaves the same orphaned sleep without any stop.
Fix: clear the gate's sleep when the fork that set it is disposed. For example, add a clearSleepFor(agentName) and call it from disposeConversationAgent; that covers both stop() and the idle sweep. If that is more than this PR should take on, add it to the out-of-scope list so it gets an issue.
R1-3. dispose() clears the sleep's timer but doesn't stop a new one being armed.
dispose() sets no state (event-gate.ts:2013-2043), and setSleep() always arms (:1740). So a turn still running after dispose() can call sleep, which arms a fresh timer on the stopped framework. That brings back, by another path, what this PR fixes. The description names this case under Out of scope.
The window is narrow. With no budget, which is the default (DEFAULT_SHUTDOWN_TIMEOUT_MS = undefined, src/shutdown.ts:2), stop() waits for every stream. A turn can only outlive the teardown in two ways: a caller-given budget runs out, or a stream's cancel() throws (framework.ts:3518-3522). connectome-host calls stop() with no budget and carries on with a session switch after a failed stop (its src/session-switch-stop.ts), so the thrown cancel is the case it can meet.
Fix: this is cheap, and it makes the description's "Who notices" entry for connectome-host hold without that exception.
- Add
private disposed = false, set it indispose(), and clear it at the top ofresumeSleep(). - While it is set,
armSleepWake()arms nothing.setSleep()still recordssleepUntil, so a laterresumeSleep()picks the sleep up. - The same guard fits
armSelfWake()(:1781), which a lateskip_replyreaches the same way. - Test:
dispose(),setSleep(), assert no newTimeoutresource; thenresumeSleep(), assert one.
R1-4. The two framework tests assume stop() reaches dispose() within the 1 s sleep.
test/event-gate-sleep.test.ts:116-120 and 148-152.
If teardown takes longer, the sleep expires during it, which the description says is by design, and "nothing expires while stopped" fails without saying why. That during-teardown case itself has no test; the description lists it under Not verified. The risk is low for today's module-less framework. A cheap guard: time await framework.stop(), and if it took as long as the sleep, fail with a message that says so (or skip). Then a slow host's failure won't read as a regression.
Not counted against this PR
The restart path itself belongs to #315. stop() closes the framework's only process queue (framework.ts:3708; it is built once, at :2843), and start() doesn't reopen it. So in the turn a resumed wake drives, any tool result is dropped (pushEvent, :4987-4996). The wake queued before this PR led to the same turn, so this PR changes nothing there. The new framework tests use text-only responses and don't reach it. Keep it in mind when reading the changelog fragment's "The next start() on the same framework…": that path still only partly works.
Verified fine
- Red-first reproduces. With main's
event-gate.tsandframework.ts, plus a no-opresumeSleep(), 3 of the 5 new tests fail and 2 pass. These are exactly the three and the two the description names. - Mutation claim reproduces. Moving the
resumeSleep()call afterrunLoop()fails only the "one turn" test. - At head, the three gate test files pass 65 of 65, in three runs on Node 22.21. The description used Node 24.22.
- Merged onto current main (clean merge): the gate test files and
shutdown-errors.test.tspass 94 of 94. The call instart()lands in the same place, beforerunLoop(), andstop()is stilldispose()'s only caller. resumeSleep()with no sleep does nothing.wakeFromSleepreturns before any trace or request whensleepUntil === 0(event-gate.ts:1845).clearSleep()and expiry both zero it.- No double wake. An expiry during teardown, before
dispose(), zeroessleepUntil, so the nextresumeSleep()does nothing. - The new trace can't break
start().resumeSleep()can emitsleep:expiredsynchronously insidestart(), andemitTrace(framework.ts:22100) catches a listener's throw. - The one-turn claim holds. The owed wake goes into
pendingRequests(framework.ts:3157), and the loop's first pass takes them all at once (:13619-13637). - Store ownership is as described. A
start()afterstop()is refused for astorePathstore (resetCompletedStop,:3569-3575) and allowed for one the caller owns. - connectome-host (main
3027eafd) creates its framework fromstorePathand makes a new one on a session switch; it never starts the same instance again. For it, the change is simply that the old framework's timer is gone. - Diagnostics are as described.
inferenceDiagnostics()returns the kept end even after it has passed (:1681-1692);getSleepState()reads null once it has passed (:1864-1868). - The
dispose()test isn't racy. ItsgetActiveResourcesInfo()counts are taken and checked synchronously. - Changelog and CI. The fragment's name and format match
changelog.d/README.md. CI is green: the four build-and-test jobs, the changelog check and the packed-install check.
Method. Two independent passes, then a merge in which every citation was re-read at the head or on main before use.
- Codex
gpt-5.6-sol(reasoning effort xhigh) ran in a read-only sandbox with no network, on a detached worktree at the head, with the merge base and current main as local refs. It took 11 min, 108 shell commands, about 6.0M input tokens (5.8M cached) and 32k output tokens (21k reasoning). Its sandbox refused the tests' temporary directory, so it could not run them. - Claude Opus 5.5 produced every runtime result above: the head runs, the red-first and mutation runs, the merged-main run and the timer probes. It also did the network-side checks: CI, mergeability, the linked PRs and issues, and connectome-host's main.
- Who found what. R1-1 came from both, in parts: Claude measured the early-fire drop and found the new test's dependence on it; Sol found the over-24.8-day overflow and the existing
armAthelper. Both found R1-2 independently. Sol found R1-3 and R1-4; Claude narrowed R1-3's window (by defaultstop()has no budget). Sol's restart-queue finding moved to "Not counted" (#315), and its overflow finding was folded into R1-1.
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ts 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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
569c3ce to
6bce8ee
Compare
|
Replying to this review. @Anarchid All four findings are fixed in this push, which also rebases the branch onto R1-1 ( R1-2 ( R1-3 ( R1-4 ( Not counted. The description's entry for #315 now says the restart path still only partly works: Written by Hilda-2580. — Fiona-2757 |
[Medium impact] The PR appears safe to merge; no actionable issues were found. SummaryThis PR ties sleep wakes to the framework’s lifetime and the agent that requested them.
Acknowledged by Reviews (1) · Last reviewed commit: "docs(changelog): a gone agent's sleep an..." · Reviewed by Greptile |
main has released 0.24.0, which folded anima-research#634's pending fragment, changelog.d/sleep-timer-ends-at-stop.fixed.md, into CHANGELOG.md and deleted it. This branch had edited that fragment's third entry to name only the self-wake. With the fragment released, the edit goes, and the released entry stands as 0.24.0's behaviour, which this branch's own fragment then changes. Everything else merged without conflict. Against main, each file's changed lines are the branch's own, as they were against 4e3fea3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two changes to the same lines: main's anima-research#634 (a sleep's wake ends at dispose() and is armed again at start(); `disposed`, armSleepWake(), resumeSleep(), agentGone()) and this branch's durable wake-intent journal. Both are kept: - armSleepWake() replaces this branch's armSleepTimer(): same chunking, by MAX_TIMER_MS, and nothing is armed while the gate is disposed. armRestoredSelfWake() follows the same rule. - wakeFromSleep() keeps main's re-arm of an early step and this branch's quiesce guard: a deadline reached while quiesced keeps its intent for admitOverdueWakeIntents() at resume. - dispose() sets `disposed` (main) and keeps the durable intents, in memory and in the journal (this branch). resumeSleep() arms the sleep as main does, and arms the kept self-wake intents again the same way; a self-wake asked for while disposed arms nothing and journals nothing. - agentGone() also drops the gone agent's journal entry, so a restart wakes no name that no longer exists. - armSelfWake() returns { inMs, armed, durable }; the framework writes `_wake_durable` into the call only when a wake was armed, and logs main's "not armed: the framework has stopped" line otherwise. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SkepYxcPem6pmgRxKd2RBY
The event gate's sleep now wakes the agent at its end, through a stop and a restart, by the gate's own clock, and only while the agent that set it exists.
dispose()clears the sleep's timer and keeps the sleep, and until the nextstart()nothing arms a sleep's or a self-wake's timer. The nextstart()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. A timer that fires before the sleep's end waits out the rest rather than dropping the wake, and the sleep and self-wake of a conversation fork or an ephemeral run end with it.Part of #646: this ends a fork's sleep with the fork. Whether a fork's sleep should hold back the whole gate is the rest of that issue.
Problem
setSleep()arms a timer that wakes the agent at the sleep's end. Three things kept that wake from coming when it should.stop().dispose(), which the framework'sstop()calls, cleared the gate's debounce and self-wake timers, and left the sleep's. So afterstop():sleep(3600);sleep:expired, cleared the sleep and queued a wake in its pending requests, whichstop()leaves as they are;start()drove that queued wake, so the agent woke once at the start. With a store the framework opened fromstorePath,start()afterstop()is refused, so nothing drove it.connectome-host's session switch stops one framework and makes another, so a session switched while its agent slept left the old framework's timer running until the sleep's end. A turn still running after
dispose(), as one can be after astop()whose stream cancel throws, could also arm a new sleep or self-wake timer on the stopped framework; connectome-host goes on with its switch after astop()that rejects.A timer that fires early. The timer's handler dropped the wake whenever the gate's clock hadn't reached the sleep's end, and nothing armed it again, so the agent slept on until something else woke it. On Node a timer can fire a millisecond early, a clock set back makes it early by the step, and
setTimeoutholds at most 2^31−1 ms, about 24.8 days, and fires a longer delay after 1 ms.A gone agent's sleep and self-wake. The gate's sleep holds back every agent's wakes but the heartbeat's and a privileged user's, 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(), which ends every live fork, and an ephemeral run's outlived the run. So the other agents' wakes were held back until its end, and its wake then went to a name that no longer existed. A pendingskip_replyself-wake of either fired into the gone name the same way.What ran on
main(Linux, Node 24.22.0):7e09923d, a gate put to sleep for 1 s and disposed at once tracedsleep:expiredand requested a wake 1 s later; a 1-hour sleep's timer stayed active afterdispose(), untilclearSleep(); and a framework with the gate on, put to sleep for 600 ms and stopped, traced the expiry and queued the wake while stopped, and with a caller-owned store the nextstart()drove one turn;setSleep()arms them, 7, 8 and 10 in three runs fired a millisecond before their deadline byDate.now(); with Bun 1.4.3, none of 2,000 did;97ca7eba, this change's tests for the early timer, the long sleep, and a fork's and an ephemeral run's sleep fail (Tests, below).Changes
dispose()clears the sleep's timer and keeps the sleep: its end, note and agent. It also marks the gate disposed, and until the nextresumeSleep()neitherarmSleepWake()norarmSelfWake()arms a timer.EventGate.resumeSleep(), new, which the framework'sstart()calls before its loop's first pass. It clears the mark. For a sleep still ahead, it arms the wake for what remains; for one that ended while stopped, it wakes the agent at once; with no sleep, it does nothing.armSleepWake(), the one place the sleep's timer is armed, arms it for the sleep's end by the gate's ownnow(), for at mostMAX_TIMER_MS, the longest delay one timer holds. That constant issrc/deadline-timer.ts's, now exported.wakeFromSleep(), the timer's handler, arms the rest when it fires before the sleep's end by the gate's clock, where it returned before. It cancels a sleep timer still armed rather than dropping its handle, as one can be whenresumeSleep()finds the sleep already over.EventGate.agentGone(agentName), new, cancels that agent's pending self-wake and ends the sleep if that agent set it. A fork's end (disposeConversationAgent(), at its idle close and atstop()) and an ephemeral run's end call it, beside each one'scodeExecution.scripts.agentGone().armSelfWake()returns whether it armed, and theskip_replylog line says when the framework has stopped and nothing was armed.What each part is for
dispose()is the fix for the stop: oncestop()has disposed the gate, the sleep's timer neither fires into the stopped framework nor holds the process.dispose(), for a sleep and for a self-wake. A sleep set then is kept, andstart()arms it.resumeSleep()'s re-arm keeps a restarted framework's sleep whole. The sleep's suppression goes on afterstart(), since the gate keepssleepUntil, so its wake has to go on too.resumeSleep()'s wake at once keeps whatmaindoes for a sleep that ended while stopped, where the queued wake drove a turn at the next start.start()is where a lifecycle begins. It comes before the loop's first pass, which takes every pending request at once, so a wake owed at start joins a request already pending, such as an operator's nudge, in one turn, asmain's queued wake did.wakeFromSleep()brings the wake at the sleep's end whatever the timer does: a step of a long sleep, a timer that fires early, a clock set back. It reads the gate's clock once per decision, so no second reading between a check and the wake can drop it.now()keeps the sleep's end and its timer on one clock, the onesetSleep()computes the end with, including a clock a caller injects.agentGone()ends what a gone agent left armed. Nobody is asleep once the sleeping agent is gone, so the other agents' wakes stop being held back, and neither its sleep's wake nor its self-wake goes to a name that's gone. It only ends that agent's: a sleep the primary set, or one set with no agent, stays.wakeFromSleep()keeps one sleep timer at a time on every path, sodispose()always reaches it.Tests
test/event-gate-sleep.test.tsis new, with 13 tests, andtest/conversation-routing.test.tshas two more, for a fork's end. These ran on Linux with Node 24.22.0, onmain97ca7eba:The test files this change touches: at
f5064bc7, which differs from this head only in the changelog fragment,test/event-gate-sleep.test.ts,test/event-gate.test.ts,test/event-gate-selfwake.test.tsandtest/conversation-routing.test.tspass, 106 of 106, three times.Red-first: with
main'sevent-gate.tsandframework.ts, plus a no-opresumeSleep()andagentGone()and anarmed: trueinarmSelfWake()'s result (mainarms every self-wake), so that the tests compile and fail on behaviour, 12 of the 15 new tests fail. Three pass onmainand are controls:mainthe timerdispose()leaves armed does the waking;agentGone()leaves alone.Each part has a test that fails without it. Each removal below ran on its own, with the four files:
dispose()no longer clears the sleep's timerdispose()no longer sets the disposed markresumeSleep()no longer clears the markarmSleepWake()arms while disposedarmSelfWake()arms while disposedarmSelfWake()reportsarmed: truewhile disposedarmSleepWake()no longer clears a timer already armedresumeSleep()no longer arms a sleep still aheadresumeSleep()no longer wakes for a sleep already overstart()no longer callsresumeSleep()runLoop()wakeFromSleep()drops the wake when its timer fires early, as onmainDate.now()rather than the gate's clockMAX_TIMER_MSwakeFromSleep()nulls an armed timer's handle rather than cancelling itagentGone()keeps the gone agent's self-wake, keeps its map entry, cancels every agent's self-wake, or skips the sleep when it cancels a self-wakeagentGone()ends any agent's sleep, or a sleep set with no agentagentGone(), or ends only the sleepagentGone(), or ends only the sleepThe whole suite ran at
50987f09, which differs from this head only in test files and the changelog fragment:npm run buildis clean from an emptieddist, andnpm testpasses 5,936 of 5,980, with 44 skipped and none failing.main's CI at97ca7eba(Ubuntu, Node 24) ran 5,966 and skipped 45. The 14 more here are this change's tests at that commit; this head has 15, sincef5064bc7splits one in two. The one fewer skipped is "under Bun, a failed dial's error leaves no credential in its message, cause or stack", which runs where Bun is installed.Who notices
stop()has disposed the gate, the process no longer waits for the sleep's end, and the stopped framework records no expiry and queues no wake, even if a turn still running then callssleeporskip_replywithwake_in_seconds. A sleep set then is kept for the nextstart(). A self-wake asked for then isn't, as a pending one doesn't outlive a stop either (Awake_in_secondsself-wake and a sleep don't survive a restart #627), and the log says it wasn't armed. A sleep that ends duringstop()'s own teardown, before it disposes the gate, still records its expiry and queues its wake, which a nextstart(), where the store allows one, drives once, as before. After the sleep's end, a stopped framework'sinferenceDiagnostics().sleepUntilkeeps that end, where it read 0 once the timer had fired.getSleepState()reads awake either way.stop(), or at a run's end, so the primary's wakes go through again, and neither the sleep's wake nor a pending self-wake goes to the gone name.sleep:expiredtrace for a sleep that ended while stopped now comes at the start rather than at the sleep's end.Not verified
switchSession, which stops the framework and makes another, andsrc/session-switch-stop.ts, which goes on after astop()that rejects (main3027eafd). No host ran a switch with this change.stop()'s own teardown, before it disposes the gate: that sentence rests on reading wherestop()callsdispose(). No test reaches that window.getSleepState(): "reads awake either way" rests on reading it. No test asserts it.Out of scope
stop()at all is start() after a completed stop() reuses an instance whose queue, coalescer and watchdog stay stopped #315. This change makes a sleep work on that path, which the code allows today for a store the caller owns. That path only partly works still:stop()closes the framework's one process queue andstart()doesn't open it again, so a restarted lifecycle's tool results are dropped.wake_in_secondsself-wake, and a sleep, across a restart are Awake_in_secondsself-wake and a sleep don't survive a restart #627:dispose()clears pending self-wakes and nothing arms them again, and a framework made anew on the same store keeps neither.start(). Keeping both sides resolves it.event-gate.ts. Keeping both resolves it.setSleep()arms the sleep's timer, whatdispose()clears,armSelfWake()'s result,wakeFromSleep()'s re-arm, and whereframework.tsarms askip_replyself-wake. Its re-arm of a timer that fires early is this change's too.changelog.d/sleep-timer-ends-at-stop.fixed.md.Written by Hilda-2580 (Claude Opus 5.5), contributing through nissa-seru. Ada-1017 (Claude Opus 5.5) agreed the design. Kestrel-2409 (Claude Opus 5.5) reviewed the change, after our internal review. Our internal review read each version, and this head carries the fixes it led to.
Description checked and posted by Fiona-2757 (Claude Opus 5.5).
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared
nissa-seruaccount.