Repository navigation
fix(tune-out): a duration or cadence past 24.8 days holds, and enter refuses an option that gives no finite number - #444
Merged
nissa-seru merged 3 commits intoOct 17, 2026
Conversation
…refuses an option that gives no finite number Node fires a setTimeout longer than 2^31 - 1 ms (about 24.8 days) almost at once. On main at 7125cb8, on Node 24.22.0: - a durationSeconds over 2147483 ended the tune-out 1 to 4 ms after it began, as "duration elapsed", which delivers the backlog to the resident and wakes it; - a cadenceSeconds over 2147483 re-armed the cadence timer about every 1.3 ms, so the subconscious got a turn as soon as anything was diverted; - an option that gives no finite number ("abc", NaN, Infinity) made the store's append throw "Failed to convert js number to serde_json::Number" after ChannelRegistry.enterTuneOut had marked the channel tuned out in memory, so enter threw with no timer armed, and the channel's messages were diverted until the next restart; - a null durationSeconds gave a tune-out of 60 seconds. armAt (src/deadline-timer.ts) calls back once the clock reaches a deadline, through timers of at most 2^31 - 1 ms. When each timer fires, it reads the clock against the deadline and arms the next for what's left. The end time steps toward the stored expiresAtMs, and the cadence toward each tick's next time. Each timer goes into the coordinator's existing maps, so cancel and stop clear the pending one. armAt is a module of its own because nothing in it is about tune-outs. enter reads its four numeric options with Number() before anything changes, as the sleep tool reads its seconds, so a numeric string still works. It reads null as left out, as ?? already did for the three options with defaults. It refuses a value that gives no finite number, naming the option and the value, and a duration whose end time isn't finite (above about 1.8e305 seconds), which the store can't keep. The tests, in test/tune-out-timers.test.ts, mock Date and setTimeout and record each delay asked of setTimeout: a 30-day duration ends at its end and not before; a restart with more than one timer left reads the stored end time at each step; a cancel stops the end time's later steps; stop clears both timers' pending steps; a 30-day cadence gives one turn at the cadence; the refusals; null as left out. The numeric-string test is a control: it passes on main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ext enter clears an old end time's step From our internal review of the previous commit: - The cadence read the wall clock at each step (armAt toward Date.now() + period), so setting the clock back lengthened the period, where main's single setTimeout counted the period on the timer. armAfter (src/deadline-timer.ts) counts a delay on timers of at most 2^31 - 1 ms each, and the cadence now uses it. The end time keeps armAt, since it's a stored instant, so a running host and a restarted one aim at the same end. - armExpiry returned for a tune-out with no duration before it cleared the channel's pending end-time step. A tune-out can end in the registry without cancel, since a send into the channel or a close sets its state there, so its step stayed armed and ended the channel's next, open-ended tune-out at the old end time. armExpiry now clears first. main has the same defect with its single timer, for a duration short enough for one timer, which is what the new test uses (two days). - New tests: a restart ends the tune-out at its stored end time and not before, on a clock that runs normally; a cadence gives a second turn a period later; a restart re-arms a stored cadence past one timer; options below their floors are raised to them; armAt arms a deadline that has passed without a TimeoutNegativeWarning, which Node 23 and later give for a negative delay; the stop test shows both second steps armed before stop runs; a tune-out ended in the registry leaves no step to end the next one; and the cadence's turn comes on its timers while the wall clock moves only milliseconds. - The armAt doc now says a restarted process aims at the same time rather than that it ends at it, since each step's wait is a Node timer, which counts on a monotonic clock. - The changelog fragment says the same, and names the stale-step fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he channel From our internal review of the previous commit: - A tune-out that ends in the registry without cancel, as a send into the channel or a close ends it, left all three of its timers armed. The previous commit cleared the end time at the next enter, and startCadence already stopped the cadence, but a wake still in its 2-second coalescing window fired into the next tune-out. It counted a wake against that tune-out and gave the subconscious a turn for the old message. clearTimers clears the cadence, the end time and a coalescing wake together. cancel uses it in place of its own three clears, and enter calls it before the registry write. armExpiry is back to main's shape, since enter clears for it. - armAt's doc says when it sees a change to the wall clock. Each step's wait is a Node timer, which counts on a monotonic clock, so armAt sees a change made during a step when that step ends. - The expiryTimers comment says cancel clears the end time only while the registry still holds the tune-out, and the next enter clears it in any case. - The changelog fragment names the coalescing wake beside the end time. - The tests: the registry-ended test now covers all three timers, with a wake still coalescing and a new tune-out on another cadence, and it fails at the previous commit on the wake. Every test that arms a timer now checks that none is longer than one timer holds. The cancel test's title says what it shows, and two comments say what the mock clock does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The PR appears safe to merge; no actionable defects were found. SummaryFixes long tune-out waits and rejects non-finite options before changing the channel.
Reviews (2) · Last reviewed commit: "fix(tune-out): enter clears every timer ..." · Reviewed by Greptile |
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 17, 2026
tel-0s
added a commit
that referenced
this pull request
Oct 17, 2026
…ework meets the held message 228 commits from main, six conflict hunks in three files. The seam this time is the deferred queue: #286 rewrote the three sites that store a deferred write (writeDrainedInOrder), where the branch had added the held message's guard and the release of held agent mail. - At a tool boundary, main's drain stops at the first message that can't join the stream and leaves it, and what's behind it, for the turn's end. Kept, with the branch's two readings: "the agent's own voice" is every participant the agent presents as (isOwnParticipant: a derived agent's inherited voice renders as the assistant too), not its name alone; and a message held from the residents is stored with the boundary's other drained writes, as before, and left out of the injection, since the next compile hides it (#232 gap 5). - releaseLandedMail runs where main now stores: the tool boundary, the turn-start flush and addMessage (before noteStored). - The registry block and main's endEphemeralRunRefused were inserted at the same place; both stay. index.ts exports the union (ProviderWaitNotSleptError beside AgentStoppedError and DeriveAgentOptions). - The all-except hold takes #444: its end time and cadence run on deadline-timer's steps (a hold past 24.8 days held for a millisecond), and its options are read by the rule main gave `enter`, now one private readOptions for both; the duration stays mandatory. Three rows in tune-out-timers.test.ts, red without the change (3 of 17). - Main's two arrival tests in deferred-window-only read the branch's `addressed` field. Suite, fresh build, on the composed rig (chronicle 1c033f3, context-manager 0964876 linked, one membrane 0.5.87): 2738 tests / 2731 pass / 0 fail / 7 skipped (Node 26.5.0). The dendrite-derive rows run here now that the branch builds are linked; the skips are main's: code-execution's Python 3.12 row, two deferred-window-only rows that context-manager 0.15.0 makes moot, and four workspace-watcher rows. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 task done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR makes three fixes to the tune-out coordinator. A
durationSecondsorcadenceSecondslonger than about 24.8 days now waits that long, where before Node fired its single timer almost at once.tune_outrefuses an option that gives no finite number, such as"abc", before it changes anything, where before such a value left the channel diverted with no timers. And a tune-out that ends in the channel registry without the coordinator'scancelno longer leaves timers that act on the channel's next tune-out.Problem
One
setTimeoutholds a delay of at most 2^31 - 1 ms, about 24.8 days. Node fires a longer delay after about 1 ms, with aTimeoutOverflowWarning, and fires aNaNdelay the same way. The tune-out coordinator armed one timer for a tune-out's end time and one for each cadence period. Onmainat7125cb86(Linux, Node 24.22.0):durationSecondsabove 2147483, the tune-out ended 1 to 4 ms after it began, through the standard cancel ("duration elapsed"), which delivers the backlog to the resident and wakes it. With exactly 2147483, it didn't end in the 200 ms we watched.cadenceSecondsabove 2147483, the coordinator re-armed the cadence timer about every 1.3 ms (155 timers in 200 ms). The subconscious got a turn as soon as anything was diverted."abc",NaN,Infinity) went into the tune-out's params asNaNorInfinity, and the channel registry's store can't hold either. Its append threwFailed to convert js number to serde_json::NumberafterenterTuneOuthad already marked the channel tuned out in memory. Soenterthrew before arming any timer, and the channel's messages were diverted from then on. After a restart, the registry had no tune-out on record for the channel.nulldurationSecondsgave a tune-out of 60 seconds, the shortest duration.cancel, since the send path (openIfClosedForSend, throughopenChannelNow) and the close tool's handler (handleToolClose) set the channel's state there directly. Its timers stayed armed, and the nextentercleared only the cadence. So the channel's next tune-out, if it had no duration, ended as "duration elapsed" at the old one's end time, and a wake still in its 2-second coalescing window counted against it and gave the subconscious a turn for the old message.Changes
src/deadline-timer.tsholds two functions that wait through timers of at most 2^31 - 1 ms each. Each hands its timers to a callback, so the coordinator's existing timer maps hold the pending one, andcancelandstopclear it. Nothing in the module is about tune-outs.armAtwaits for a wall-clock deadline. When each timer fires, it reads the clock against the deadline and arms the next for what's left.armAfterwaits for a delay, counted on the timers, so setting the wall clock doesn't move it.armAttoward the storedexpiresAtMs, so a host that runs through a long tune-out and one that restarts during it both wait for the same stored end time.armAfter, so it counts its period on timers, as the singlesetTimeoutdid before.enterclears the timers an earlier tune-out left on the channel before it changes the registry: its cadence, its end time and a wake still coalescing.clearTimersdoes that forenterand forcancel, which cleared the same three before.enterreadsdurationSeconds,cadenceSeconds,backlogCapandmaxWakeswithNumber()before it changes anything, as thesleeptool reads itsseconds, so a numeric string such as"3600"keeps working. It readsnullas left out, as??already did for the three options with defaults, so anullduration now means the tune-out lasts until cancelled. It refuses a value that gives no finite number, naming the option and the value. It also refuses a duration whose end time isn't a finite number (a duration above about 1.8e305 seconds), which the store can't keep either. Each refusal says nothing was tuned out and what leaving the option out does.What each part costs, and what we didn't do instead:
armAtandarmAfterare about a dozen lines each. Clamping each delay to 2^31 - 1 ms would be shorter, but it would end a 30-day tune-out on day 24.8. Refusing durations and cadences over 24.8 days would put Node's timer limit into the tool's contract, and a refusal inenterwouldn't reach a long cadence already stored, which a restart resumes.framework.ts(MAX_TIMER_DELAY_MS,waitForRetry) andcode-execution/py-runner.ts(MAX_TIMER_MS) keep their own copies of the limit. This PR leaves them as they are.enteris one loop over the four options, with a four-entry map of their names and what leaving each out does. It's inenterbecause that's where the options become the stored params, so the code that reads each option checks it. The tool's handler inframework.ts,enter's only caller, would also work, but the check would then be in a different file from the params it guards.Tests
test/tune-out-timers.test.tsruns the coordinator with an in-memory registry andnode:test's mock timers. It records every delay the code asks ofsetTimeout, and the timer tests check that none is longer than 2^31 - 1 ms.stopclears both timers' pending steps, which the test shows were armed when it ran, so neither fires after it.setTimeoutmocked, the wall clock moves a few milliseconds while the timers run 30 minutes, and the turn comes.enterrefuses each option's forms that give no finite number, and an end time too far off to keep, with nothing entered and no timer armed.nulloption counts as left out.armAtarms a deadline that has already passed for the next turn of the loop, with noTimeoutNegativeWarning, which Node 23 and later give for a negative delay. This test uses real timers.On
main's source, withsrc/deadline-timer.tsadded so the file compiles, tests 1 to 7 and 9 to 11 fail. Tests 8, 12 and 13 pass there, since they guard behaviour this PR keeps. Test 14 passes there too, since it tests the new module alone (Linux, Node 24.22.0).Breaking each piece of the change on its own fails at least one of these tests, for 23 of 25 pieces (Linux, Node 24.22.0). Two fail none. Reading
cadenceSeconds,backlogCapandmaxWakesfrom the checked numbers rather than the raw options behaves the same, becauseMath.maxconverts strings the same way. AndclearTimers' stop of the cadence is also done bystartCadence, which stops the channel's cadence before it arms one.The full check ran on this branch merged onto
mainat3adbdb86(the merged tree isded593d0), on Linux, withmainrun beside it at the same time:npm testpasses 2,446 of 2,448 tests on the merge and 2,432 of 2,434 onmain. The merge has this PR's 14 tests more, and the same two tests fail on both. Vector 34c intest/mcpl-coalescing.test.tsbounds 300 coalesced messages at 30 s, and they took 37 to 41 s in the four forced runs on this loaded host; test(mcpl): vector 34c bounds its store syncs, not its time #431 replaces that bound. Intest/mcpl-coalesced-content-parity.test.ts, "a block whose type, text or source isn't a string converts on both lanes, coalesced or not, without stopping a batch" gave up after the coalescing fixture's 3 s wait for a reply; test(mcpl): the coalescing fixture's waits share one wide hang guard #427 widens that wait.npm testpasses 2,420 of 2,421 on the merge and 2,418 of 2,419 onmain, with vector 34c failing on both.--test-force-exitleft a different set of tests out of each of these two runs, so their totals don't compare. Run alone and unforced, each of the 153 test files passes on the merge (2,448 tests), and each has the same count as onmain(152 files, 2,434 tests), apart fromtune-out-timers.test.ts, which is new.mainalso merges cleanly with this PR. We merged all 55 with this PR in five trees and ran CI's suite steps on each tree, on Node 24.22.0. The four tune-out test files pass alone in every tree. Each test that failed in a tree's forced run either failed in the same tree without this PR too, or passed when its file ran alone, with this PR and without it. The first tree's typecheck fails with and without this PR.Not verified
node:test's mock timers. The check that no timer is longer than 2^31 - 1 ms doesn't depend on the mock reproducing the overflow.armAt's waits wasn't run on Node's real timers. Those run on libuv's loop time, which "increases monotonically" (uv_now), so by that designarmAtsees the change when the wait under way ends. Test 3 changes the clock ofnode:test's mock timers, which moves the mock's timers with it.Out of scope
sleeparms onesetTimeoutfor the agent'sseconds(src/gate/event-gate.ts:1715), with no limit on the delay. This PR doesn't change it.Overlapping PRs
src/tune-out/coordinator.ts, in other parts of it, and each merges cleanly with this PR. The refusals here use fix(tune-out): say what didn't happen, and what to do instead #411's "Nothing was tuned out" form.src/tune-out/coordinator.tsas this PR does, so the two conflict on that line, and keeping both imports resolves it. feat(mcpl): every channel item names its conversation in a stored source header #257 already conflicts withmainin other files.src/tune-out/coordinator.ts, and merges with this PR without a conflict there. ItsonDeferredWakedrops a debounced wake whose epoch has ended. This PR clears the coordinator's own coalescing wake when the channel's next tune-out begins.Written by Abigail-2459 (Claude Opus 5.5), contributing through nissa-seru, to a design by Ada-1017 (Claude Opus 5.5): steps from the stored end time, a module of its own, and
Number()for numeric strings. A probe of Node's timers by Hamish-2569 (Claude Opus 5.5) showed the overflow. Alfred-2451 (Claude Opus 5.5) reviewed the change, and it had an internal review before publication.