Skip to content

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 into
anima-research:mainfrom
nissa-seru:fix/tune-out-long-timers
Oct 17, 2026
Merged

nissa-seru merged 3 commits into
anima-research:mainfrom
nissa-seru:fix/tune-out-long-timers

Conversation

@nissa-seru

Copy link
Copy Markdown
Contributor

This PR makes three fixes to the tune-out coordinator. A durationSeconds or cadenceSeconds longer than about 24.8 days now waits that long, where before Node fired its single timer almost at once. tune_out refuses 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's cancel no longer leaves timers that act on the channel's next tune-out.

Problem

One setTimeout holds a delay of at most 2^31 - 1 ms, about 24.8 days. Node fires a longer delay after about 1 ms, with a TimeoutOverflowWarning, and fires a NaN delay the same way. The tune-out coordinator armed one timer for a tune-out's end time and one for each cadence period. On main at 7125cb86 (Linux, Node 24.22.0):

  • With a durationSeconds above 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.
  • With a cadenceSeconds above 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.
  • An option that gives no finite number ("abc", NaN, Infinity) went into the tune-out's params as NaN or Infinity, and the channel registry's store can't hold either. Its append threw Failed to convert js number to serde_json::Number after enterTuneOut had already marked the channel tuned out in memory. So enter threw 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.
  • A null durationSeconds gave a tune-out of 60 seconds, the shortest duration.
  • A tune-out can end in the channel registry without the coordinator's cancel, since the send path (openIfClosedForSend, through openChannelNow) and the close tool's handler (handleToolClose) set the channel's state there directly. Its timers stayed armed, and the next enter cleared 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.ts holds 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, and cancel and stop clear it. Nothing in the module is about tune-outs.
    • armAt waits for a wall-clock deadline. When each timer fires, it reads the clock against the deadline and arms the next for what's left.
    • armAfter waits for a delay, counted on the timers, so setting the wall clock doesn't move it.
  • The end time uses armAt toward the stored expiresAtMs, so a host that runs through a long tune-out and one that restarts during it both wait for the same stored end time.
  • The cadence uses armAfter, so it counts its period on timers, as the single setTimeout did before.
  • enter clears 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. clearTimers does that for enter and for cancel, which cleared the same three before.
  • enter reads durationSeconds, cadenceSeconds, backlogCap and maxWakes with Number() before it changes anything, as the sleep tool reads its seconds, so a numeric string such as "3600" keeps working. It reads null as left out, as ?? already did for the three options with defaults, so a null duration 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:

  • armAt and armAfter are 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 in enter wouldn't reach a long cadence already stored, which a restart resumes.
  • The module has two functions rather than one because the end time is a stored instant and the cadence is a period. One function for both would have the cadence read the wall clock, so setting the clock back would lengthen the period.
  • framework.ts (MAX_TIMER_DELAY_MS, waitForRetry) and code-execution/py-runner.ts (MAX_TIMER_MS) keep their own copies of the limit. This PR leaves them as they are.
  • The check in enter is one loop over the four options, with a four-entry map of their names and what leaving each out does. It's in enter because that's where the options become the stored params, so the code that reads each option checks it. The tool's handler in framework.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.ts runs the coordinator with an in-memory registry and node:test's mock timers. It records every delay the code asks of setTimeout, and the timer tests check that none is longer than 2^31 - 1 ms.

  1. A 30-day duration ends at its end time and not before.
  2. After a restart with more than one timer's worth left, the tune-out ends at its stored end time and not before.
  3. After a restart, each step reads the stored end time again, so when the clock reaches it while a step waits, the next step ends the tune-out.
  4. After a cancel, a new tune-out on the same channel isn't ended at the old one's end time.
  5. A tune-out ended in the registry, as the send and close paths end it, leaves none of its timers to act on the channel's next one. No end time ends it, no wake counts against it, and the old cadence gives it no turn.
  6. stop clears both timers' pending steps, which the test shows were armed when it ran, so neither fires after it.
  7. A 30-day cadence gives the subconscious a turn each period: none before the first, a second a period later, none after a cancel.
  8. The cadence counts its period on its timers. With only setTimeout mocked, the wall clock moves a few milliseconds while the timers run 30 minutes, and the turn comes.
  9. A restart re-arms a stored 30-day cadence, and the turn comes at the cadence.
  10. enter refuses 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.
  11. A null option counts as left out.
  12. A numeric string is read as its number.
  13. An option below its floor is raised to it.
  14. armAt arms a deadline that has already passed for the next turn of the loop, with no TimeoutNegativeWarning, which Node 23 and later give for a negative delay. This test uses real timers.

On main's source, with src/deadline-timer.ts added 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, backlogCap and maxWakes from the checked numbers rather than the raw options behaves the same, because Math.max converts strings the same way. And clearTimers' stop of the cadence is also done by startCadence, which stops the channel's cadence before it arms one.

The full check ran on this branch merged onto main at 3adbdb86 (the merged tree is ded593d0), on Linux, with main run beside it at the same time:

  • The typecheck and the build pass on both.
  • On Node 20.20.2, npm test passes 2,446 of 2,448 tests on the merge and 2,432 of 2,434 on main. The merge has this PR's 14 tests more, and the same two tests fail on both. Vector 34c in test/mcpl-coalescing.test.ts bounds 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. In test/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.
  • On Node 24.22.0, npm test passes 2,420 of 2,421 on the merge and 2,418 of 2,419 on main, with vector 34c failing on both. --test-force-exit left 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 on main (152 files, 2,434 tests), apart from tune-out-timers.test.ts, which is new.
  • CI's unforced step passes 105 of 105 on both, on both Nodes.
  • CI's pack check passes on the merge, on Node 24.22.0 with bun 1.3.14.
  • Each of the 55 open PRs that merge cleanly with main also 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

  • No host ran a tune-out with these settings. The tests drive the coordinator directly, with an in-memory registry and mock timers.
  • The overflow was observed on Node 24.22.0, with real timers and with 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.
  • A wall-clock change during one of 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 design armAt sees the change when the wait under way ends. Test 3 changes the clock of node:test's mock timers, which moves the mock's timers with it.

Out of scope

  • The event gate's sleep arms one setTimeout for the agent's seconds (src/gate/event-gate.ts:1715), with no limit on the delay. This PR doesn't change it.

Overlapping PRs


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.

Abigail-2459 and others added 3 commits October 17, 2026 10:58
…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>
@greptile-apps

greptile-apps Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5 Tier: apex

[Medium impact] The PR appears safe to merge; no actionable defects were found.

Summary

Fixes long tune-out waits and rejects non-finite options before changing the channel.

  • Tune-outs longer than 24.8 days now wait for their scheduled times.
  • Tune-out rejects unusable settings before changing a channel.
  • A new tune-out drops the previous tune-out's pending timers.

Reviews (2) · Last reviewed commit: "fix(tune-out): enter clears every timer ..." · Reviewed by Greptile

@nissa-seru nissa-seru added the no-greptile For maintainers and the team: Greptile skips this PR. Not for general use. label Oct 17, 2026
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 17, 2026
@nissa-seru
nissa-seru merged commit 743aee3 into anima-research:main Oct 17, 2026
8 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

greptile no-greptile For maintainers and the team: Greptile skips this PR. Not for general use.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant