Repository navigation
feat(turns): send a turn's trigger again when its retries run out on a transient failure, with delays of 2, 8 and 32 minutes, and alert on an hour of stated waits - #846
Conversation
There was a problem hiding this comment.
Automated review
Recommendation: needs a maintainer's decision. The change belongs, but part of it needs a maintainer's call.
Reviewed commit 0091806 with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.
Does this change belong in the project?
It fits, with concerns. Re-sending an exhausted turn's trigger fits: it extends the framework's existing error policy, provider holds and ops alerts to cover an issue (#838) and sits where that machinery already lives. A maintainer should still decide on three things: the change also carries a separate stated-wait alert, it turns re-sending on by default, and the re-arm wait is not ended by abortInference() even though the README says that call ends held retries.
5 reasons with sources
- The re-arm fits the framework's job of running agent turns through inference, retries and provider holds. The README says the framework handles the whole inference lifecycle, including the error policy's retries, provider-capacity holds and stated waits. The diff builds on that machinery in src/framework.ts: it reuses ProviderAccelerationCooldown with a
rearmfield, the hard-down streak, and the health snapshot'sproviderAdmission. It does not add a separate mechanism. (README.md (Overview; Abort / Cancellation)) - The new public setting
exhaustedTurnRearmMsis on by default, so every host's behavior changes unless it opts out. The change files this correctly as a.changed.mdfragment and lets hosts opt out withfalse. Still, a default-on 2/8/32-minute re-send adds a public interface and a new concept maintainers will have to keep working. Whether it should be on by default is the maintainers' call. (CONTRIBUTING.md (Changelog); changelog.d/README.md) - The re-arm wait is listed as a hold, but
abortInference()doesn't end it, which differs from what the README promises for held retries. The README says a turn whose failed call a provider hold keeps for its retry is ended byabortInference(), by quiesce abandon and bystop(). The re-arm appears underproviderAdmissionas a cooldown. The changelog fragment saysabortInference()doesn't end it, because the re-arm counts as a new wake rather than an open turn. That choice is defensible, but it changes the cancellation contract, and the README isn't updated to say so. (README.md (Abort / Cancellation, line 545)) - The PR bundles a second feature, the alert for a run of stated waits longer than an hour, which could be proposed on its own. The
provider-waitalert for back-to-back stated waits (statedWaitRuns,noteStatedWaitRun/endStatedWaitRun) is related to outages, but it is independent of re-sending exhausted turns. It has its own tests and its own changelog bullet. (CONTRIBUTING.md (How changes land: keep commits coherent)) - The PR follows the house conventions for shape: a Problem/Changes/Tests body, a changelog fragment and a dedicated test file. It adds changelog.d/838-rearm-exhausted-turns.changed.md and test/exhausted-turn-rearm.test.ts, and the body says the new tests fail on main's code. The README is not updated for the new config option. (CONTRIBUTING.md (What a PR should contain))
Findings
5 findings of medium or high severity, see the inline comments:
- Medium severity. Stream-throw path never marks a re-armed transient failure, so each re-arm writes another [inference-failed] marker.
- Medium severity. Once re-arms are spent, every later exhaustion stays unanswered until some turn completes, which is the #838 failure.
- Medium severity. turnRearms and statedWaitRuns are not cleared when stop() tears down provider admission.
- Medium severity, test coverage. No test covers a re-arm that joins a provider hold already on the agent.
- Medium severity, test coverage. The stream-throw failure mode is tested only with non-transient errors.
4 more findings, less severe
- Low severity, maintainability,
src/framework.ts:23645-23646. rearmExhaustedTurn is called before the inference:exhausted trace on two paths and after it on the other two. At 21440 and 23279,rearmExhaustedTurnruns afteremitTrace('inference:exhausted'), the event-gate end andaction.emit. At 23349 (stream aborted) and 23645 (stream threw), it runs before the trace. So the stderr lines come in a different order on each path ('[inference-rearm] ... sent again' before '[inference-failed]'). A listener of inference:exhausted also sees a re-arm hold on some paths and not on others. Running the same teardown step in a different order per path makes later changes error-prone. One shared ordering, or one helper, would avoid this. - Low severity, maintainability,
src/framework.ts:5983-5994. Re-armed request copies the whole trigger, including TURN_CARRY and the author fields, while its channel is overwritten.{ ...trigger, [REARMED]: true }removes TURN_RETRY and MERGED_BATCH but keeps TURN_CARRY. For a wake (not a continuation) it also keepscounterparty,wakeChannelIdandambient/systemNotice, then overwriteschannelIdwith the locus and forcesaddressed = true. After a mid-turn re-pin, the request can name one channel as its locus and an author or wake channel from another, and can carry bothambient: trueandaddressed: true. TURN_CARRY is ignored today only because the copy no longercontinuesTurn. Building the request from an explicit list of fields would remove these hidden dependencies. - Low severity, maintainability,
src/framework.ts:1178-1181. Transient check depends on a private, unexported membrane symbol.isTransientFailurereadsSymbol.for('membrane.deadlineExpiry'). The comment itself says membrane 'doesn't export its reader'. If a membrane release renames or drops this internal mark, typecheck still passes, and deadline expiries that membrane reports as retryable timeouts would start being re-armed. Only the test using a live socket would notice. Better to ask membrane to export a predicate, or to pin the dependency, than to depend on an internal registry key. - Low severity, test coverage,
test/exhausted-turn-rearm.test.ts:735-736. Test file replaces console.error for the whole process and never restores it.console.erroris reassigned at module scope so it pushes intostderr, and nothing restores it. Every framework diagnostic in the file, including '[inference-hard-down]' lines and unexpected errors, is swallowed into an array that grows without bound and is only partly cleared. A failing test then shows no stderr context. Uset.mock.method(console, 'error', ...)per test so it is restored automatically.
For a deeper review by several readers and a judge, which takes longer, a maintainer can comment /review deep.
| speech: '', | ||
| ...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: err.message }), | ||
| }, streamRouting); | ||
| this.rearmExhaustedTurn(agent, err, trigger, ownsProviderGate, streamRouting); | ||
| this.emitTrace({ | ||
| type: 'inference:exhausted', | ||
| agentName: agent.name, |
There was a problem hiding this comment.
Medium severity. Stream-throw path never marks a re-armed transient failure, so each re-arm writes another [inference-failed] marker.
The marker is skipped only when exhaustingRearms holds the agent (noteInferenceExhausted, line 28475). The new code adds the agent to that set at the start-failure site (21420) and the stream-error-event site (23261), but not at the catch path where the stream itself throws (23639-23651). There it only calls rearmExhaustedTurn. So when a re-armed turn's stream throws a transient MembraneError (server/network/timeout), the turn is re-armed again, and every exhaustion also writes a fresh [inference-failed] marker into the agent's context. That breaks the stated rule that the agent's context keeps only the first exhaustion's marker. The tests run the 'throw' mode only with non-transient errors, so they miss this.
| if (done >= this.turnRearmMs.length) { | ||
| notSent(`its ${this.turnRearmMs.length} re-arms are spent until a turn completes; it waits for the next wake`); | ||
| return; | ||
| } |
There was a problem hiding this comment.
Medium severity. Once re-arms are spent, every later exhaustion stays unanswered until some turn completes, which is the #838 failure.
turnRearms is reset only on inference:completed (line 28001). During an outage longer than about 42 minutes, the three re-arms are used up. After that, any new message whose turn exhausts logs 'its 3 re-arms are spent until a turn completes' and nothing queues it again. A turn can't complete during the outage, so every such message goes unanswered until an unrelated wake comes after recovery. That is the exact symptom #838 reports, so the fix covers only the first ~42 minutes of an outage. One possible fix is to reset the count by wall-clock time, or per new external wake.
| private turnRearmMs: readonly number[] = DEFAULT_TURN_REARM_MS; | ||
| private turnRearms: Map<string, number> = new Map(); | ||
| private exhaustingRearms: Set<string> = new Set(); | ||
| private statedWaitRuns: Map<string, StatedWaitRun> = new Map(); |
There was a problem hiding this comment.
Medium severity. turnRearms and statedWaitRuns are not cleared when stop() tears down provider admission.
cancelProviderAdmission (line 6419) clears every cooldown, including re-arm holds, and every recovery. It leaves the new per-agent maps turnRearms, exhaustingRearms and statedWaitRuns in place. If the framework is stopped and started again in the same process, which the framework already plans for (RETRY_ACROSS_RESTART), the re-arm count carries over. An agent whose re-arms were spent before the stop gets no re-arm after start() until a turn completes. A stated-wait run left open by a held turn that stop() dropped also keeps its old since, so the first hold in the new lifecycle can raise a false 'over an hour' provider-wait alert. These maps should be cleared together with the cooldowns.
| const existing = this.providerAccelerationCooldowns.get(agent.name); | ||
| if (existing) { | ||
| this.holdRequest(existing.heldRequests, request); | ||
| } else { |
There was a problem hiding this comment.
Medium severity, test coverage. No test covers a re-arm that joins a provider hold already on the agent.
When a cooldown already exists, rearmExhaustedTurn adds the re-armed request to existing.heldRequests, uses up one re-arm count, and logs 'when the provider hold already on the agent ends'. The re-arm's own delay is ignored, and the hold carries no rearm metadata, so admission does not let a wake through early. No test in test/exhausted-turn-rearm.test.ts reaches this branch: none checks the log line, that the count is used up, or that the request is released with the existing hold.
| await waitFor(() => r.count('inference:completed') === 1, 'the silent turn to end'); | ||
| assert.equal(alerts().length, 1); | ||
| assert.equal(alerts()[0]!.data?.holds, 4); | ||
| }); |
There was a problem hiding this comment.
Medium severity, test coverage. The stream-throw failure mode is tested only with non-transient errors.
ScriptedMembrane's 'throw' mode (ThrowingStream) appears only with a plain Error, in the 'not transient' loop. No test sends a transient MembraneError through the thrown-stream catch path (framework.ts 23603-23651). That path has its own rearmExhaustedTurn call and skips the policy check and the exhaustingRearms bookkeeping. A test there would catch the extra [inference-failed] markers and confirm that this path re-arms at all.
…a transient failure, with delays of 2, 8 and 32 minutes, and alert on an hour of stated waits When a persistent agent's turn ends exhausted on a transient failure, its trigger is now sent again after 2 minutes, or with the agent's next wake if that comes first. If that turn also ends exhausted on a transient failure, the trigger goes again after 8 minutes, and on the same terms after 32 more. A failure is transient when membrane marks it retryable, as a MembraneError of type server, network, timeout or rate_limit, and doesn't mark it as a deadline that ran out. Before, nothing sent the trigger again, so a message that woke the agent during a provider outage longer than the retry schedule went unanswered until something else woke the agent. - The re-armed trigger is a wake of its own. It opens where the exhausted turn's plain speech was going: its locus, else its trigger channel. - Each re-arm is one send: until one of its rounds has run or had its tool calls cut off, the error policy doesn't retry its failure. Another transient failure queues the next delay, until the list runs out, and a failure of any other kind ends the re-arming. - A completed turn starts the count of re-sends again, and so does a message a user says to the agent, including one a tool boundary stores, and one in the residents' shared slot, for each resident that reads it. The agent's own schedules (the heartbeat's tick, a self-wake timer, a script's wake) don't, nor does a message a tune-out holds from it. - When no provider hold is on the agent, the wait is a hold on it, shown in the health snapshot and in the reply judge's upcoming wakes. A wake during it starts its turn at once, with the re-armed trigger merged in. stop() drops the wait and the count. - [inference-rearm] stderr lines say when each re-send is queued and when it goes, why none is coming, and when a message starts the count again. A re-armed turn's transient failure writes no [inference-failed] marker unless it got partway. Each re-armed turn's failure counts toward the hard-down streak. - FrameworkConfig.exhaustedTurnRearmMs sets the delays, by default [120000, 480000, 1920000]; false sends nothing again. - A turn that a provider's stated waits, one after another, have held for more than an hour raises the provider-wait ops alert once for that run of waits. A conversation fork's turn isn't re-armed.
66c8ca5 to
4463662
Compare
…ve a deadline expiry one fresh call (anima-research#378) A provider that keeps a resident's connection open and never answers holds the resident's turn with no end (anima-research#268). On main, run on Bun 1.4.4 against a stand-in provider, a stream kept alive by SSE pings or keepalive comments, or trickling a character every 20 s, was still waiting after 45 minutes, and so was a non-streamed call whose body trickled. A call that went silent ended at Bun's own fetch timeout, 360 s, before the Anthropic adapter's 600 s watchdogs, and a turn sent it three times, so a resident waited about 18 minutes. A stalled compression holds the resident's next turn behind it. What changes: - A resident's streamed call, and an MCPL server's streamed inference request, get membrane 0.8.0's progress bounds (`progressBounds`), which apply where the adapter's stream reports content (Anthropic, Bedrock). Before content: 60 s from the send, or, once a thinking block has started, the later of that and the block's allowance, its `max_tokens` at 87 tokens a second. After content: 150 s from the last provider event, or the allowance when that event was a thinking block's start and the allowance is longer. - Every streamed call the framework makes also gets a flat `progressTimeoutMs` from its last provider event: the larger of a setup term plus that allowance and 1,862 s (`FLAT_MINIMUM_MS`). The setup term is the larger of the before-content and between-events bounds, 150 s by default, so the flat bound doesn't run out before a phase bound whatever values a host sets, as long as the request's `maxTokens` is what's sent. It's the progress bound on an adapter without progress bounds, such as OpenAI-compatible, where a keepalive comment doesn't renew it. - A whole non-streamed call gets `progressTimeoutMs` of the setup term plus its allowance from its send, or the larger of the setup term and the flat minimum when it names no `max_tokens`: compression's and the reply judge's calls, through the membrane wrapper every context manager and the judge already call through, and an MCPL server's non-streamed request. A caller that passes its own `progressTimeoutMs` keeps it; `complete()` doesn't read `progressBounds`, so a caller's are passed on beside the framework's bound. Where an adapter's `complete()` streams under the hood, such as membrane's OpenAI Responses adapter in subscription mode, membrane renews the bound at each parsed provider event. - A cut arrives from membrane as an `aborted` event with reason `timeout`; a turn's stream now reports it as an `error` whose `MembraneError` has type `timeout`, `retryable: false`, membrane's message naming the bound followed by the host setting for that bound ("(the host's setting for this bound: `inferenceBounds.progressBounds.beforeContentMs`)", picked by the message's opening; one membrane doesn't word that way is left as it is), and membrane's deadline mark (`Symbol.for('membrane.deadlineExpiry')`, read the same way, since 0.8.0 exports no reader). - `DefaultErrorPolicy` gives a deadline expiry its own schedule, `deadline`: 2 tries, 1 s apart. That covers a bound's cut and the expiries membrane throws marked (the Anthropic adapter's watchdogs, the SDK's request timeout, a runtime's fetch timeout), which went to the base schedule's 3 tries and stay retryable. For a deadline expiry the framework passes an `errorPolicy` the call's consecutive expiries as `attempt`, in place of the turn's failures, reset by a completed provider call, by another failure, at a turn's start and when the agent goes, so each cut call gets its fresh call whatever else the turn met. A refusal membrane sends again replays the same request and doesn't reset it. Settings with no `deadline` schedule give an expiry their base schedule, counted the same way. - The `[inference-failed]` note for a timeout says the model call timed out (or the turn's last call did, after rounds ran), and, when that call was a fresh call made for an earlier cut, that the call was made once more and timed out too, or how many more times under a host's own `deadline` schedule ("sent" stays for the reply); the cause in parentheses is left out when it's only `timeout`. None of the bounds depends on how much the agent writes, so the note gives no advice about output: it closes with "If a reply is still owed from that turn, you can give it now.", since the agent reads it in a later turn. Other failures keep main's wording and advice. - An MCPL server's streamed request that ends early is answered with membrane's message beside the reason, so a cut names its bound, and for a cut the host setting for it too. - A turn sent again after its retries ran out (anima-research#846's re-arm) gets no fresh call for a cut before one of its rounds has run or had its tool calls cut off, as for any failure there. anima-research#846's check of whether a failure is transient reads membrane's deadline mark through the same isDeadlineExpiry, so a bound's cut and a thrown expiry stay out of the re-arm, as before. - `FrameworkConfig.inferenceBounds` sets the values: `progressBounds` (membrane's `beforeContentMs`, `betweenEventsMs`, `thinkTokensPerSecond`, which the flat and whole-call bounds derive from) and `flatMinimumMs`. `false` sets none of these bounds. One setting covers every agent. A value it can't use is refused at `create()`, naming the field under `inferenceBounds`, before the store opens. These bounds sit above the runtime's fetch timeout and the Anthropic adapter's watchdogs, which still end a silent connection first when a bound is raised past them. Where the values come from: membrane's README gives the progress bounds' measurements, on a production router's streamed Anthropic calls. The flat minimum is the longest silence between frames of any completed call in that router's OpenAI calls (the Responses API over a WebSocket), standing in for an OpenAI-compatible stream. The whole-call bound's setup term is five times the longest wait from the send to content or a thinking block's start in those calls (a wait that router cuts at 30 s), and its allowance is generation at membrane's 87 tokens a second; no completed streamed call there ran past it. Bedrock, other models, OpenAI-compatible streams and non-streamed calls weren't measured. Not changed: a stream that keeps sending content isn't ended by any bound, since content is progress. The direct Agent API (`Agent.runInference`) reads no framework setting and gets none of these bounds. Tests: test/inference-bounds.test.ts runs real turns and MCPL requests, and complete() calls through the membrane a resident's compression and its reply judge are handed, against a local stand-in provider with short bounds, asserting which bound ran out by membrane's message, and records the options each call is sent. It runs on a stepped clock: node:test's mocked timers and Date, with performance.now, which membrane's bounds compare against, read from the same Date, while the sockets stay real; the clock moves 5 ms per event-loop turn and not while a file or lookup request is in flight, so the file takes a few seconds rather than the minute and more its bounds take in real time. With main's sources for the four files this change wires, 48 of its 55 tests fail; the 7 that pass pin what doesn't change: a stream that keeps sending content, a caller's own deadline, `inferenceBounds: false` on a turn, on compression and on both kinds of MCPL request, and a host's settings with no deadline schedule. Four existing test cases used an `aborted` event with reason `timeout` to stand for an abort that isn't a stop, a path a timeout no longer takes: one each in end-turn-usage and tool-result-guard, and two through prose-drafts' shared helper. They now use reason `error`, which still takes it. The provider-hold test's hand-built framework gains the new map; the policy test that called an unmarked timeout a stream deadline now calls it an HTTP 408; the router test's aborted answer carries membrane's message.
110 commits from main, 20 pull requests: #852 (supplied text can't read as the framework's own), #853 (every check of whether an agent sees a row asks its view), #827 (the heartbeat moves into the framework), #846 (exhausted turns re-armed), #830 (chat:system notices), #801 (the reply judge), #840, #843, #839, #791, #845, #847, #849, #822, #854, #836, #855, #857, #858, #859 and the 0.28.0 release. package.json takes main's version and ranges (context-manager ^0.23.1, membrane ^0.8.0). Conflicts: - src/framework.ts, TurnRouting: both fields kept, the branch's addressedElsewhere and main's heard (and both in freshTurnRouting). - src/framework.ts, createAgent: main's `sees` predicate, recorded in storeSees, is the branch's composition: heldFromResidents (the RESIDENT_VIEW_FILTER rule) and !isAnotherAgentsInjection; the view filter reads it. - src/framework.ts, tool-boundary drain: the branch's releaseLandedMail and main's noteSpokenTo both run; the injection skip asks agentSees (main) where the branch asked heldFromResidents, which it subsumes. - src/framework.ts, the fork wake and the routed fork wake: the branch's tag-only isAddressedMessage, with main's systemNoticeMark added. - src/framework.ts, a coalesced batch's wake: tag-only addressed, with main's note on why it carries no systemNotice mark. - src/framework.ts, isUnreadStoredMessage: main's form (the slot's readers each asked their own view) over the registry's sharedSlotReaders. - src/framework.ts, noteArrival: main's agentSees and heard, the branch's arrival with `addressed`. - src/framework.ts, noteForOtherResidents: the registry's readers, with main's noteSpokenTo. - src/framework.ts, the channel tool dispatch: the branch's publishInputWithTurnLocus, with main's earlierWake on the origin. - src/framework.ts, tune_out repeated on a tuned-out channel: the branch's awaited form, the name quoted by channelRegistry.quotedShown (#852). - src/mcpl/channel-registry.ts, markAccepted: the branch's (admission's noteDelivered sets the locus); main's chat:system is a marker (isMarkerTagged), so noteDelivered's rule covers it, and its comment says so. - src/mcpl/channel-registry.ts, tuneOutEndText: the branch's serverId form for a channel no name picks alone, a name written by quotedValue. - src/tune-out/coordinator.ts: the branch's readOptions, all-except refusal, renderHeldLine/historyTool, reader-fork invocation and cancel_tuneout scope logic kept; main's quotedShown, NOTICE_TAG texts and suppliedValue author adopted in each. - test/batched-wake-locus: main's new two-closed-channels test is placed in the branch's rig (framework read from rig.framework()); the branch's ambient-only tests kept. - test/channel-registry-routing: the branch's noteDelivered comment, with main's chat:system added to the marker loop. Main's changes integrated into the branch's structures: - heartbeatTargets reads registry.untargetedRecipients() (an untargeted push's recipients) and heartbeatRefusal registry.homeChannel, in place of conversationAgentHomes. - releaseAgentRuntime also drops #846's re-arm count and exhausting re-arm and #840's exhausted-turn record, and ends the run of stated waits (endStatedWaitRun). - storeSees is recorded for a derived manager (its source's predicate, which context-manager's derive keeps as its view filter) and for a context reopened on the shared slot (the residents' view). - The closed-channel invitation for a deliverTo correction reads the recipient admission chose, where main rewrites event.targetAgents. - #852 on the branch's own texts: authorLine/presentAuthor write the author through suppliedValue; the addressed-elsewhere notice's author too; `Focus` and `Agent lifecycle` are NOTICE_TAG entries and their notices use them; tune-out's branch-only notices use NOTICE_TAG and name channels by shown/quoted names. Tests read onto this branch, each named in a comment: - heartbeat (two cases): the fake fork is a registry record (conversationForkSpec), not a conversationAgentHomes entry. - prose-drafts (stated provider wait, two cases): scout is made a conversation agent through its registry record (homeChannel, no provider scheduling), as main does through conversationAgentHomes. - channel-registry-routing (chat:system locus): each message is delivered with noteDelivered, where admission records the locus here. - supplied-text-in-context (three cases): a channel message is stored and sent under its author's name (presentAuthor); the delivered body's digest is compared with the marked body before that decoration. - tune-out-reader-queued: its fake registry gains quotedShown. Tests: built with zero errors; 29 stale compiled tests removed. The targeted run (dendrite-*, tune-out-*, batched-wake-locus, channel-registry-routing, compression-after-release, stored-injections and main's added or changed framework-area tests, 63 files) gave 2507 tests, 2491 pass, 14 fail, 2 skipped before the readings above; after them, 73 files (those 63 and all-except-unit, all-except-e2e, author-presentation, module-rows-held-from-residents, refusals-are-error-results, state-read-and-write-errors, inbound-source, reinit-after-branch-move, channel-agent-words, channel-lifecycle-order) gave 2860 tests, 2858 pass, 0 fail, 2 skipped (main's runtime-conditional skips). The full suite was not run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ve a deadline expiry one fresh call (anima-research#378) A provider that keeps a resident's connection open and never answers holds the resident's turn with no end (anima-research#268). On main, run on Bun 1.4.4 against a stand-in provider, a stream kept alive by SSE pings or keepalive comments, or trickling a character every 20 s, was still waiting after 45 minutes, and so was a non-streamed call whose body trickled. A call that went silent ended at Bun's own fetch timeout, 360 s, before the Anthropic adapter's 600 s watchdogs, and a turn sent it three times, so a resident waited about 18 minutes. A stalled compression holds the resident's next turn behind it. What changes: - A resident's streamed call, and an MCPL server's streamed inference request, get membrane 0.8.0's progress bounds (`progressBounds`), which apply where the adapter's stream reports content (Anthropic, Bedrock). Before content: 60 s from the send, or, once a thinking block has started, the later of that and the block's allowance, its `max_tokens` at 87 tokens a second. After content: 150 s from the last provider event, or the allowance when that event was a thinking block's start and the allowance is longer. - Every streamed call the framework makes also gets a flat `progressTimeoutMs` from its last provider event: the larger of a setup term plus that allowance and 1,862 s (`FLAT_MINIMUM_MS`). The setup term is the larger of the before-content and between-events bounds, 150 s by default, so the flat bound doesn't run out before a phase bound whatever values a host sets, as long as the request's `maxTokens` is what's sent. It's the progress bound on an adapter without progress bounds, such as OpenAI-compatible, where a keepalive comment doesn't renew it. - A whole non-streamed call gets `progressTimeoutMs` of the setup term plus its allowance from its send, or the larger of the setup term and the flat minimum when it names no `max_tokens`: compression's and the reply judge's calls, through the membrane wrapper every context manager and the judge already call through, and an MCPL server's non-streamed request. A caller that passes its own `progressTimeoutMs` keeps it; `complete()` doesn't read `progressBounds`, so a caller's are passed on beside the framework's bound. Where an adapter's `complete()` streams under the hood, such as membrane's OpenAI Responses adapter in subscription mode, membrane renews the bound at each parsed provider event. - A cut arrives from membrane as an `aborted` event with reason `timeout`; a turn's stream now reports it as an `error` whose `MembraneError` has type `timeout`, `retryable: false`, membrane's message naming the bound followed by the host setting for that bound ("(the host's setting for this bound: `inferenceBounds.progressBounds.beforeContentMs`)", picked by the message's opening; one membrane doesn't word that way is left as it is), and membrane's deadline mark (`Symbol.for('membrane.deadlineExpiry')`, read the same way, since 0.8.0 exports no reader). - `DefaultErrorPolicy` gives a deadline expiry its own schedule, `deadline`: 2 tries, 1 s apart. That covers a bound's cut and the expiries membrane throws marked (the Anthropic adapter's watchdogs, the SDK's request timeout, a runtime's fetch timeout), which went to the base schedule's 3 tries and stay retryable. For a deadline expiry the framework passes an `errorPolicy` the call's consecutive expiries as `attempt`, in place of the turn's failures, reset by a completed provider call, by another failure, at a turn's start and when the agent goes, so each cut call gets its fresh call whatever else the turn met. A refusal membrane sends again replays the same request and doesn't reset it. Settings with no `deadline` schedule give an expiry their base schedule, counted the same way. - The `[inference-failed]` note for a timeout says the model call timed out (or the turn's last call did, after rounds ran), and, when that call was a fresh call made for an earlier cut and a deadline ended it too, that the call was made again and timed out again, with no count, since another failure between two cuts resets the count ("sent" stays for the reply); the cause in parentheses is left out when it's only `timeout`. None of the bounds depends on how much the agent writes, so the note gives no advice about output: it closes with "If a reply is still owed from that turn, you can give it now.", since the agent reads it in a later turn. Other failures keep main's wording and advice. - An MCPL server's streamed request that ends early is answered with membrane's message beside the reason, so a cut names its bound, and for a cut the host setting for it too. - A turn sent again after its retries ran out (anima-research#846's re-arm) gets no fresh call for a cut before one of its rounds has run or had its tool calls cut off, as for any failure there. anima-research#846's check of whether a failure is transient reads membrane's deadline mark through the same isDeadlineExpiry, so a bound's cut and a thrown expiry stay out of the re-arm, as before. - `FrameworkConfig.inferenceBounds` sets the values: `progressBounds` (membrane's `beforeContentMs`, `betweenEventsMs`, `thinkTokensPerSecond`, which the flat and whole-call bounds derive from) and `flatMinimumMs`. `false` sets none of these bounds. One setting covers every agent. A value it can't use is refused at `create()`, naming the field under `inferenceBounds`, before the store opens. These bounds sit above the runtime's fetch timeout and the Anthropic adapter's watchdogs, which still end a silent connection first when a bound is raised past them. Where the values come from: membrane's README gives the progress bounds' measurements, on a production router's streamed Anthropic calls. The flat minimum is the longest silence between frames of any completed call in that router's OpenAI calls (the Responses API over a WebSocket), standing in for an OpenAI-compatible stream. The whole-call bound's setup term is five times the longest wait from the send to content or a thinking block's start in those calls (a wait that router cuts at 30 s), and its allowance is generation at membrane's 87 tokens a second; no completed streamed call there ran past it. Bedrock, other models, OpenAI-compatible streams and non-streamed calls weren't measured. Not changed: a stream that keeps sending content isn't ended by any bound, since content is progress. The direct Agent API (`Agent.runInference`) reads no framework setting and gets none of these bounds. Tests: test/inference-bounds.test.ts runs real turns and MCPL requests, and complete() calls through the membrane a resident's compression and its reply judge are handed, against a local stand-in provider with short bounds, asserting which bound ran out by membrane's message, and records the options each call is sent. It runs on a stepped clock: node:test's mocked timers and Date, with performance.now, which membrane's bounds compare against, read from the same Date, while the sockets stay real; the clock moves 5 ms per event-loop turn and not while a file or lookup request is in flight, so the file takes a few seconds rather than the minute and more its bounds take in real time. With main's sources for the four files this change wires, 49 of its 56 tests fail; the 7 that pass pin what doesn't change: a stream that keeps sending content, a caller's own deadline, `inferenceBounds: false` on a turn, on compression and on both kinds of MCPL request, and a host's settings with no deadline schedule. Four existing test cases used an `aborted` event with reason `timeout` to stand for an abort that isn't a stop, a path a timeout no longer takes: one each in end-turn-usage and tool-result-guard, and two through prose-drafts' shared helper. They now use reason `error`, which still takes it. The provider-hold test's hand-built framework gains the new map; the policy test that called an unmarked timeout a stream deadline now calls it an HTTP 408; the router test's aborted answer carries membrane's message.
main moved thirteen merges past 8ff287d. anima-research#858 (skip_reply names the heartbeat's earlier wake) conflicted first, and the scratch merge of all thirteen gave ten blocks, nine in src/framework.ts and one in test/present-while-acting.test.ts. Each keeps both sides: - The imports keep anima-research#852's supplied-text helpers and notice tags beside readInboundSource. - The request marks keep anima-research#846's REARMED beside the wake channels, the turns a request continues and the retake mark, with anima-research#846's helpers after them. - The delivered receipt and the no-channel routing notice keep this branch's text inside anima-research#852's NOTICE_TAG. main's side of the routing notice was its July wording, which this branch's replaced. - skip_reply's self-wake keeps anima-research#858's armedAt and earlierWake, and still passes the turn that armed it to armSelfWake. - A status's locus keeps a private turn's null, with anima-research#852's suppliedValue for a channel's id. - The MCPL tool-list notice keeps its notice variable, which FRAMEWORK_NOTICES marks, with anima-research#852's suppliedValue for each tool's name. - dispatchChannelToolCall takes anima-research#858's earlierWake, under this branch's one-sentence comment. - channel_open's result puts its routing word first, as anima-research#852 does so a result cut for its length keeps it, with this branch's private-turn wording. - The test file keeps main's "channel_open's word ... survives a result cut" beside this branch's private channel_open tests.
main's anima-research#846 sends a turn again after 2, 8 and 32 minutes when its retries run out on a transient failure, as a copy of the turn's trigger. A turn that private input made private at a tool boundary keeps its trigger, the room's wake, so when the operator's message woke nothing, which leaves no private wake queued for the re-send to merge with, the copy started a channel turn and answered the private input in the room. The copy now carries the turn it continues, as every request a turn derives does (continuesTurns), so its start is judged by that turn as it stands: a private turn's re-send speaks in no channel, and a channel turn's goes where anima-research#846 sends it. Tests (turn-trigger-provenance), each a turn whose stream makes a tool call and then fails with a server error, with no retries and one re-send: - private at a tool boundary by an operator message that wakes nothing: sent again in no channel; it answered in the room before this change; - the same with a message that wakes the agent, whose own wake the re-send merges with: the merged start stays private; - opened by a private surface: sent again in no channel; - a channel turn: sent again in its channel, as on main; - a room message that comes while the turn waits starts it at once, as anima-research#846 does: for a private turn it gets no reply in the room, which only the re-send's own mark gives, since the room's wake is a channel's; for a channel turn it's answered in the room, as on main. surfaceRig takes create options for the error policy and the re-send delays. The changed fragment names the re-send among what a private turn causes.
…atch-up main's 0.28.0 (f14e7fb) and its twenty pull requests, by way of feat/dendrite's eleventh catch-up, meet the presentation index. Fifteen paths conflicted: package.json, src/framework.ts (23 regions), src/held-writes.ts, src/tune-out/coordinator.ts, four tests and seven changelog fragments. Conflicts: - package.json: the Dendrite side's version (0.28.0) and the rest of main's manifest; this branch's floors kept (chronicle ^0.5.0, context-manager ^0.24.0, membrane ^0.8.0), which test/dependency-ranges pins. - src/framework.ts, imports and fields: unions (this branch's WritePlacement beside main's reply judge and heartbeat; the presentation module's construction beside #846's turnRearmMs). - src/framework.ts, the stop marker: main's handedOn (calls cut off, live speech, speech still routing) and this branch's ownsAgent, both used by the marker's storeOwnRow. - src/framework.ts, reopenAgentContext: this branch's three arms (a derived context through liveViewFilterOf(viewOwnerOf), its own slot the residents' through residentViewFilter, isolated through liveViewFilterOf), each recording the view it opens with in storeSees, where the Dendrite side recorded only the derived and shared arms. - src/framework.ts, createAgent and createSubconsciousAgent: this branch's residentContextOptions/subconsciousContextOptions; storeSees records options.viewFilter, the same predicate the compile reads, in place of the Dendrite side's re-composed `sees`. - src/framework.ts, the tool boundary: this branch's presentAtBoundary; the deferral drain main changed (agentSees, noteSpokenTo) stays deleted. Its comments say what the agent's own view leaves out is never its arrival (bearingOf), so nothing heard is what a compile hides. - src/framework.ts, a module response's writes: this branch's audience and WritePlacement with main's markNoticeOpenings on local input. - src/framework.ts, isUnreadStoredMessage: this branch's readers (registry.readersOf the slot, plus the windows that hold the row) with main's rule that each is asked its own view (agentSees), which replaces the held-row special case; a window holder, a reader fork's framing among them, still counts unfiltered. - src/framework.ts, a coalesced batch's wake: readsRow/rowDone with main's systemNotice exemption. - src/framework.ts, noteForOtherResidents and the deferral flushes: stay deleted (main added noteSpokenTo to them); see noteStored below. - src/framework.ts, addMessage: this branch's (stored at once); main's change there was a comment in the deleted deferral block. - src/framework.ts, isNewestContextEntry: the window tail with main's agentSees filter. - src/framework.ts, the recent-image scan: main's agentSees with this branch's window count. - src/held-writes.ts: this branch's asModuleRow comment (a tool boundary never presents a held row to a resident); main's change was wording. - src/tune-out/coordinator.ts: residentMessages and main's quotedShown both; the cancel notices keep this branch's unseenNote and stored-only reader handoff, with NOTICE_TAG.tuneOutCancelled; main's authorOf, used only for queued messages this branch no longer has, stays deleted (renderHeldLine writes authors through authorLine). - test/batched-wake-line: main's view-left-out case beside this branch's queue-less turn-alive case. - test/mcpl-coalescing: this branch's ReadMark and readsRow with main's chat:system notice case. - test/mid-turn-injection-as-compiled: both sides' header sentences. - test/save-recent-image: main's tuned-out case and this branch's another-agent's-own-row case. - changelog.d/326-reinit-after-branch-move.fixed, 326-undo-redo-reserve- store.{breaking,changed}, 326-undo-redo-settled.added, continuation-starts-alone.changed, stored-message-wake.{added,fixed}: deleted, as 0.28.0 released them. storeSees: every manager this branch opens with a view records it (recordView): createAgent (residentViewFilter), the subconscious (subconsciousViewFilter), reopenAgentContext's three arms; a derived manager (shared inheritance, reader forks among them) takes its source's record (the Dendrite side's, kept); an inspection snapshot is no live agent's and records none. Conversation forks and copy-path workers open with no view and record none, as agentSees reads one with none. One rule where this branch asked on its own: viewKeepsFor (ModuleContext.viewKeeps) and shedNewestTurn's passed-over rows now ask agentSees; defaultAudience asks it for a held write that names an agent (the subconscious's view keeps held traffic, so such a write is for it, where it was for nobody and never entered its window); bearingOf, which reads registry facts for live and released readers alike, keeps its own reading of the view, now also leaving out another agent's stored injection on a stamped row (it did on an unstamped one). Main's changes read onto this branch past the conflicts: - deliverHeartbeat marks its wake with readsRowFor (main's readsWriteFor). - noteSpokenTo (#846) runs in noteStored for each agent the row's audience names, the delivery agent where it has none: a write is stored at once here, so its audience stands for main's noteForOtherResidents and the flushes. - nameUnpostedSpeech's [speech-not-posted] notice is the agent's own row (storeOwnRow), presented as it is written, held back after a dropped start whose agent may have been replaced; written past it, retried and continued requests never carried it. Notice tags: NOTICE_TAG.noText ('no text') for foreign-rounds' empty-row line; a transcript line of another agent's call or result opens with the tool's name, as a toolset label does, and the call's input and the result's text in it are marked (markedText), the cut at cutToFit. notice-tags.ts's header says so. This branch writes no other bracketed context text: undo/redo and presentation report by traces, holds by logs and alerts. Changelog: from the consumed fragments, 291-deferral-queue carries what the primary takes from an agent that ends while its manager refuses its work, the store-held-long alert's restart sentence and the disposal alert's counts; 291-presentation-mandatory, that an undo or redo takes none of 0.28.0's agent-busy refusals for another busy agent or a fork being created, holds back no coalesced update, and judges no fork for disposal; 291-reads-row.breaking, readsStoredMessage (0.28.0) folded into readsRow. References to the consumed entries now name 0.28.0. 291-chronicle-0.5 says context-manager was ^0.23.0; context-manager-floor-0.23.1 says the range is ^0.24.0, which carries 0.23.1's changes; membrane-0.8.breaking is deleted, as 0.28.0 released membrane ^0.8.0; 291-audience-stamp.added says a held write naming an agent whose view keeps held traffic is for it. Tests adapted: - audience-stamp-writers lists nameUnpostedSpeech as an own-row writer that holds back. - supplied-text-in-context's dump case gives its in-memory message by getSurgeryHeldMessages: no queue holds one here. - stored-injections' held-row case presents the row (presentAll) before the marks: a row is read where the reader's window holds it. - foreign-rounds gains a case for the marking. - main's tune-out-reader-queued change (quotedShown on its fake registry) reached this branch's tune-out-reader-backlog, its renamed form, by rename detection. Tests: built with zero errors; 3 stale compiled tests removed. The targeted run (the four conflicted tests; presentation*, window-*, read-marks, presented-watermarks, held-for-another-agent, history-window-marks, stored-injections, stored-removal, audience-stamp*, dependency-ranges, dendrite-*, tune-out-*, compression-after-release, context-options, subconscious-window-anchor, offline-recovery-presented, rollback-cut-accounting, shed-window; heartbeat, prose-drafts, channel-registry-routing, supplied-text-in-context, batched-wake-locus; foreign-rounds, supplied-text, release-changelog, 57 files) gave 1276 tests, 9 failing before the readings above; after them, 76 files (those and all-except-*, author-presentation, chat-system-notices, deferred-import*, discord-held-writes, exhausted-turn-rearm, heard-mid-turn-wake, history-messages, history-module, inbound-source, inference-exhaustion-rewind, module-held-writes, module-rows-held-from-residents, refusal-rewind, reinit-after-branch-move, silent-heartbeat*, subconscious-model-fields) gave 1748 tests, 1748 pass. The full suite was not run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ollows the deadline-cut rule Main now reports a call cut by a bound the host sets (inferenceBounds) as a deadline marked as run out, as membrane marks its own. Such a cut is the same kind as membrane's watchdog, a provider that stopped making progress, so in a re-armed turn it follows the same rule: the chain's first cut is re-armed as a transient failure is, and a second ends the re-arming. The doc and the changelog fragment no longer except a deadline the host sets, and the fragment's note on 0.29.0 quotes that line's words on which deadlines it meant; a host that wants no such bound still has inferenceBounds: false. Tests follow: the host's deadline leaves anima-research#846's list of failures that end the re-arming for a test of its own under the rule; under the default error policy a re-armed turn that got partway no longer ends on its chain's first cut, since the policy's deadline schedule calls again after it, so that test goes, the two-cut test pinning what happens instead; and a turn that isn't a re-arm makes the calls the deadline schedule makes again before it ends on the cut, still not re-armed.
…he exception; a test comes back under a host's policy - exhaustedTurnRearmMs's doc said a deadline "ends the turn as before", which, read beside the exception two sentences on, contradicted it now that the host's bound is a marked deadline too. It now says a turn that ends on a deadline, the host's or membrane's, isn't re-armed, save the exception; and the agent's end joins what clears an earlier cut. - A re-armed turn that got partway and ends on its chain's first deadline cut is re-armed, and writes the marker a partway turn does. Under the default error policy that case doesn't arise, since its deadline schedule makes a fresh call after the first cut; under a host's policy that makes none, it does, so the test comes back under such a policy. - A stream that ends aborted for a reason other than a timeout or the caller's cancel still ends a re-armed turn's re-arming. anima-research#846's list of failures that do had pinned that with the host's deadline, which main now reports as a marked error instead, so the list gets a stream aborted with another reason. - Two test titles say what they pin: the release guard's ends at what it asserts, and the not-transient test's deadline cases are in a turn that isn't itself a re-arm.
This answers #838: when a persistent agent's turn ends exhausted on a transient failure, its trigger is now sent again after 2 minutes, or with the agent's next wake if that comes first. If that turn also ends exhausted on a transient failure, the trigger goes again after 8 minutes, and on the same terms after 32 more.
FrameworkConfig.exhaustedTurnRearmMssets the delays, andfalsesends nothing again. The change also raises theprovider-waitops alert when a provider's stated waits, one after another, have held one turn for more than an hour. Since the re-arm is on by default, the changelog fragment files it as a change.Closes #838.
Problem
mainat5e93190cef, a single stated wait of more than an hour raises theprovider-waitops alert, and a turn held by shorter stated waits, one after another, raises none: in this change's test, four such holds spanning 75 minutes raise no alert withmain's code.This change's tests of both fail on
main's code at5e93190cef(Tests, below).Changes
MembraneErrorof typeserver,network,timeoutorrate_limit, and doesn't mark it as a deadline that ran out. These failures end the turn as before:MembraneError;exhausted-turn-rearm. In this change's test of a budget restart, no turn is open while the re-arm waits, andabortInference()has none to end.noteSpokenTo(src/framework.ts:26587-26589) also returns without starting the count where the agent's own view leaves the message out (agentSees), and whereisConversationalInjection(:418-424) is false: for a message markedsystem: true, or one tagged as a reaction, an edit, a deletion or achat:systemnotice.isMarkerTagged(src/mcpl/tags.ts:56-58) leaves the edit tag out where the host marks a message as the first version the agent sees. Any other wake leaves the count going on. Once the re-arms are spent, the next exhaustion isn't sent again until a turn completes or a message is said to the agent.counterparty, so it names no author.providerAdmission(cooldownUntil,cooldownReason,heldRequests), and the reply judge's list of upcoming wakes names it with its count, such as "1 of 3".stop()drops the wait and the count.[inference-rearm]stderr lines say when each re-arm is queued and when it goes, by its timer or with a wake. They also say why none is coming: a failure that isn't transient, once the agent has been re-armed and neither a turn has completed nor a message been said to it since; re-arms that are spent; or a turn that would have to guess a channel. Another says when a message said to the agent starts its count again.[inference-failed]marker unless it got partway, so the agent's context keeps the one the first exhaustion wrote. A failure of another kind writes its own.FrameworkConfig.exhaustedTurnRearmMsdefaults to[120000, 480000, 1920000].falsesends nothing again, and prints none of the[inference-rearm]lines.createrefuses any other value: it must befalseor a list of one or more finite numbers above 0.provider-waitops alert once for that run, saying how long and how many waits.exhaustedTurnRearmMs's doc and the changelog fragment,838-rearm-exhausted-turns.changed.md, say the above for hosts and operators.PROVIDER_WAIT_ALERT_MS's comment said shorter waits are pacing. It now says they are pacing until waits in a row hold one turn longer than an hour.What each part is for
Each part, with what goes wrong without it.
rearmExhaustedTurnqueues the trigger in a timed hold (providerAccelerationCooldowns, withrearmset), or adds it to a provider hold already on the agent. The health snapshot,heldRequests, the judge's list of upcoming wakes andstop()then cover it with no code of their own. It's called at both places a turn ends exhausted: a failure at the turn's start, and one its stream reports.rearmset.cooldownBlocksAuxiliarylets the judge make its calls during the wait, and the scheduler lets a wake through at once rather than holding it for the delay.isTransientFailure. Without its class rule, a request the provider refused would be re-armed. Without itsretryablecheck, so would a server error the provider says not to retry, and without its deadline check, a deadline membrane marks as run out.REARMED,isRearmed) and the policy skip at both failure sites. Without the mark, the error policy takes a re-armed start for a turn's first attempt and gives it a whole retry window. The merged start keeps the mark only when every request it takes in has it, so a wake's turn merged with a re-armed trigger keeps the policy's retries.gotPartway. One fact, read by the policy skip and by the failed-turn marker, so the two can't disagree about a turn whose tool calls were cut off.exhausted-turn-rearm. Without it, a turn stays open while a re-armed continuation waits.addressed, and the droppedcounterparty. Without the channel, a re-armed continuation's turn doesn't open on its turn's channel, and a re-arm after a re-pin opens where its turn started. Withoutaddressed, a newer ambient wake takes the merged turn's channel. Without droppingcounterparty, a re-armed tool-result guard's retry names the old turn's author. The return for a re-arm that would have to guess a channel keeps it from being sent.exhaustingRearmsand the marker's condition. They give the agent one[inference-failed]marker for the window and the re-arms after it.noteSpokenTo), called where a message for the agent is stored: byaddMessage, by the writes deferred until a turn starts, and at a tool boundary, and for each resident that reads the residents' shared slot, where a message is stored there. Without it, a message that arrives once the re-arms are spent goes unanswered until something else wakes the agent. Without its exclusion of the agent's own schedules, the heartbeat's tick, a self-wake timer or a script's wake starts the count again, and the next exhaustion is re-armed.stop()the count and the agents whose re-armed turn is failing. Without it, a framework stopped and started again in one process carries a spent count into its next lifecycle.turnRearmDelays, the default andexhaustedTurnRearmMs. The delays and the off switch. Copying the list before checking it refuses a list with holes.[inference-rearm]stderr lines. The line that says why no re-arm is coming needs two more calls: where a re-armed turn's stream is aborted, and where its stream throws. At each of the four (src/framework.ts:21455,:23294,:23369and:23667), the re-arm call comes after theinference:exhaustedtrace. Except at the aborted stream's (:23369), the line noting the agent as one whose re-armed turn is failing comes before the trace. In the three failure modes the tests run, each trace comes before its turn's re-arm is held.StatedWaitRunkeeps the run's first hold, its count of holds, whether it has alerted, the latest wait's model and reason, and the latest end of its waits.noteStatedWaitRunis called after a failed call's hold for a provider wait, and after an acceleration cooldown's or a host's hold whose stated wait still binds.endStatedWaitRunends the run at the turn's end, in the outer catch of a start that throws, and where a round's results are flushed. It alerts there if nothing has alerted yet and the run has been held for more than an hour, measured to the end of its latest wait or to that moment, whichever comes first.waitAlertsOnItsOwn: one measure for the single wait's own alert and for marking its run alerted, so a run whose wait raised that alert raises no second one.stalledByMembrane, which gets its deadline from membrane's ownAnthropicAdapter. The tests run onnode:test's mock timers (setTimeout,setIntervalandDate), and a test waiting for a condition steps the clock 5 ms at a time.stalledByMembraneruns before a test's clock is mocked, since the adapter times its idle watchdog on the real clock. That last one is what makes reading membrane's unexportedSymbol.for('membrane.deadlineExpiry')safe: if membrane renames the mark, that test fails, since its deadline would then be re-armed.rearmedlabel on the failure's stderr line and in failures.log, which the re-arm's own lines make unnecessary;Tests
4463662892holds them, on Linux with Node 24.22.1, context-manager 0.23.1 and membrane 0.8.0:test/exhausted-turn-rearm.test.tspasses 54 of 54 tests;main's code at5e93190cef, the commit this change is built on, on Linux with Node 24.22.1, context-manager 0.23.1 and membrane 0.8.0: 40 of 54 tests fail. 23 time out waiting for a re-armed send that doesn't come, 16 fail at an assertion, and 1 fails reading a hold that isn't there. The 14 that pass are cases this change leaves as they were: failures that aren't transient,false, a conversation fork, a single long wait's own alert, and runs of stated waits that raise no alert.Out of scope
Not verified
AnthropicAdapteragainst a local server that stops sending.rearmExhaustedTurn,src/framework.ts:5991counts the re-arm, and:6004-6006add its trigger to that hold's held requests, with no delay of its own.providerAccelerationCooldownsandturnRearms, maps on the framework object (src/framework.ts:3350,:3499). A test stops and starts a framework in one process after its re-arms are spent, and the next exhaustion waits the first delay again.noteSpokenTopasses over, besides the agent's own schedules and a message a tune-out holds: no test stores a message markedsystem: true, one tagged as a reaction, an edit, a deletion or achat:systemnotice, or another that the agent's view leaves out, after the re-arms are spent.heartbeat: no test runs one.isOwnScheduleWrite(src/framework.ts:412-416) matches a heartbeat tick byserverId: 'heartbeat'alone, and the changelog fragment says each of such a server's ticks starts the count again.src/framework.ts:5972returns fromrearmExhaustedTurnfor a turn that doesn't own provider admission, and:20176-20177give that ownership to no ephemeral run's turn.noChannel's doc names both cases (src/framework.ts:1318-1324), and the re-arm's check at:5981reads it.src/framework.ts:5992copies the trigger into the re-arm, itsauto-continuereason included, and:17071-17081take a merged start's channel from any other request that names one before an autoContinue wake's.changelog.d/838-rearm-exhausted-turns.changed.md.Effie-2756 wrote this change, and Ada-1017 made its design calls. Desmond-2579 read the whole change, in the stages it was built in, and eight rounds of internal review read earlier versions of it. Prudence-2578 weighed each part against the least change that would answer the issue, and Laurel-2581 read it as the operator of a resident host would. Both read an earlier version of it.
Fiona-2757 wrote this description from Effie-2756's facts.