Skip to content

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

Merged
nissa-seru merged 1 commit into
anima-research:mainfrom
nissa-seru:feat/838-rearm-exhausted-turns
Oct 28, 2026
Merged

nissa-seru merged 1 commit into
anima-research:mainfrom
nissa-seru:feat/838-rearm-exhausted-turns

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

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.exhaustedTurnRearmMs sets the delays, and false sends nothing again. The change also raises the provider-wait ops 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

  • An exhausted turn isn't sent again. As A turn that exhausts its retries is never re-armed: the agent stays silent until an unrelated wake #838 describes, when the error policy stops retrying a turn, the turn ends exhausted and nothing queues its trigger again. A message that wakes the agent during a provider outage longer than the retry schedule then goes unanswered until something else wakes the agent. Under rules that don't wake it on every event, that may take a long time: the issue's probe saw no further attempt for 120 minutes.
  • A run of stated waits raises no alert. On main at 5e93190cef, a single stated wait of more than an hour raises the provider-wait ops 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 with main's code.

This change's tests of both fail on main's code at 5e93190cef (Tests, below).

Changes

  • Which turns are sent again. A persistent agent's turn that ends exhausted on a transient failure is re-armed, and a conversation fork's isn't. 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. These failures end the turn as before:
    • one the provider refuses or says not to retry;
    • one membrane couldn't classify;
    • an error that isn't a MembraneError;
    • a deadline, whether the host set it or membrane marks one as run out.
  • The re-armed trigger is a wake of its own. A trigger that continued a turn (a budget restart, tool results ready, or a tool-result guard's retry) is queued with the reason exhausted-turn-rearm. In this change's test of a budget restart, no turn is open while the re-arm waits, and abortInference() has none to end.
  • 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 a re-armed turn's failure.
    • Another transient failure queues the next delay, until the list runs out. A failure of any other kind ends the re-arming.
    • A re-armed turn that got partway is an ordinary turn, and the policy retries its later failures.
    • A stated wait on a re-armed send is held as any turn's is, and the send after the hold is still the re-arm's one send.
    • A completed turn starts the count of re-arms again, and so does a message said to the agent. In this change's tests a user's message does, including one a tool boundary stores, and so does one in the residents' shared slot, for each resident that reads it. The agent's own schedules don't: the heartbeat's tick, a self-wake timer it set, and a script's wake. Nor does a message a tune-out holds from it. noteSpokenTo (src/framework.ts:26587-26589) also returns without starting the count where the agent's own view leaves the message out (agentSees), and where isConversationalInjection (:418-424) is false: for a message marked system: true, or one tagged as a reaction, an edit, a deletion or a chat:system notice. 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.
  • Where it opens. The re-arm opens where the exhausted turn's plain speech was going: its locus, where a message during the turn may have moved it, else its trigger channel.
    • Carrying that channel, it's marked addressed. When a newer ambient wake from another channel merges with a re-armed message's wake, budget restart or tool-result guard's retry, the merged turn keeps the re-arm's channel. Not verified names autoContinue's wake.
    • The re-arm of a turn's continuation drops its counterparty, so it names no author.
    • A re-arm that would have to guess a channel isn't sent: one with no channel id of its own, from a turn that never found a channel because the channel id it was woken for didn't name exactly one registered channel.
  • While it waits.
    • The wait is a hold on the agent, unless a provider hold is already on it (Not verified). The health snapshot shows it under providerAdmission (cooldownUntil, cooldownReason, heldRequests), and the reply judge's list of upcoming wakes names it with its count, such as "1 of 3".
    • A wake for the agent during the wait starts its turn at once, with the re-armed trigger merged in. That turn is the wake's: the error policy retries its failures, and if it ends exhausted too, the next re-arm follows it.
    • A judge rule's judge makes its calls during the wait.
    • stop() drops the wait and the count.
  • What the operator sees.
    • [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.
    • Each re-armed turn's failure counts toward the hard-down streak, so the hard-down alert comes at the third exhaustion, the second re-arm's, from a single wake.
  • What the agent sees. A re-armed turn's transient failure writes no [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.
  • The setting. FrameworkConfig.exhaustedTurnRearmMs defaults to [120000, 480000, 1920000]. false sends nothing again, and prints none of the [inference-rearm] lines. create refuses any other value: it must be false or a list of one or more finite numbers above 0.
  • The stated-wait alert. 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, saying how long and how many waits.
    • The run counts the holds of the turn's own failed calls whose stated wait still binds, held as a provider wait, an acceleration cooldown or a host's hold, from the first of them. It ends with the turn, or when one of the turn's rounds runs.
    • The alert comes at the run's first counted hold that begins past the hour. If none does, it comes when the run ends, if the run has been held for more than an hour by then, measured to the end of its latest stated wait or to the run's end, whichever comes first. So it can come as late as the run's end.
    • When an operator releases a stated wait on the model of the run's latest wait, the run counts that wait only up to the release.
    • A single stated wait of more than an hour keeps its own alert, and its run raises no second one.
    • The alert doesn't count toward the hard-down streak.
  • Texts. 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.

  • The re-arm as a hold on the agent. rearmExhaustedTurn queues the trigger in a timed hold (providerAccelerationCooldowns, with rearm set), or adds it to a provider hold already on the agent. The health snapshot, heldRequests, the judge's list of upcoming wakes and stop() 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.
    • What it costs: two exclusions for a hold with rearm set. cooldownBlocksAuxiliary lets 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 its retryable check, so would a server error the provider says not to retry, and without its deadline check, a deadline membrane marks as run out.
  • The re-arm mark (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.
  • A continuation's reason, exhausted-turn-rearm. Without it, a turn stays open while a re-armed continuation waits.
  • The re-arm's channel, addressed, and the dropped counterparty. 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. Without addressed, a newer ambient wake takes the merged turn's channel. Without dropping counterparty, 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.
  • exhaustingRearms and the marker's condition. They give the agent one [inference-failed] marker for the window and the re-arms after it.
  • Clearing the count on a completed turn. Without it, a later outage gets fewer re-arms.
  • Clearing the count on a message said (noteSpokenTo), called where a message for the agent is stored: by addMessage, 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.
  • Clearing at 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 and exhaustedTurnRearmMs. The delays and the off switch. Copying the list before checking it refuses a list with holes.
  • The judge's wording for a re-arm in its list of upcoming wakes, and the [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, :23369 and :23667), the re-arm call comes after the inference:exhausted trace. 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.
  • The stated-wait run. StatedWaitRun keeps 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. noteStatedWaitRun is 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. endStatedWaitRun ends 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.
    • Capping a wait at its release: without it, a wait an operator released at minute 41 would count to its stated end, and a turn held open to minute 71 would raise the alert.
    • Measuring to whichever comes first: measured to the time the run ends, a turn that runs a round more than an hour after a short wait would raise the alert. Measured to the latest wait's end, a turn stopped 45 minutes in, during a wait that would have ended at 90, would raise it.
  • The test harness: a module whose events wake the agent and that has one tool, so a re-armed turn can run a round; streams that fail at once, after a round, with a round's calls cut off, or ended by a deadline; a scripted membrane that sets each send's outcome by its number; the errors each test needs; and stalledByMembrane, which gets its deadline from membrane's own AnthropicAdapter. The tests run on node:test's mock timers (setTimeout, setInterval and Date), and a test waiting for a condition steps the clock 5 ms at a time. stalledByMembrane runs 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 unexported Symbol.for('membrane.deadlineExpiry') safe: if membrane renames the mark, that test fails, since its deadline would then be re-armed.
  • Cut while building:
    • a rearmed label on the failure's stderr line and in failures.log, which the re-arm's own lines make unnecessary;
    • an ephemeral-run test that no removal could fail;
    • a timer at the hour for the stated-wait run, which made the alert exact for long waits but needed a second timer per run and a check of two other states when it fired. Without it, the alert can come as late as the run's end.

Tests

  • On this head's files, as a clean checkout of 4463662892 holds them, on Linux with Node 24.22.1, context-manager 0.23.1 and membrane 0.8.0:
    • test/exhausted-turn-rearm.test.ts passes 54 of 54 tests;
    • the parts above were taken out in 59 pieces, one at a time, and each removal fails at least one of those tests;
    • the full suite runs 8,634 tests: 8,572 pass, none fail and 62 are skipped.
  • The same test file with main's code at 5e93190cef, 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

  • No live provider: every test drives a scripted membrane, apart from the one deadline case built with membrane's own AnthropicAdapter against a local server that stops sending.
  • A re-arm that comes while a provider hold is already on the agent: no test makes one. In rearmExhaustedTurn, src/framework.ts:5991 counts the re-arm, and :6004-6006 add its trigger to that hold's held requests, with no delay of its own.
  • A restart during the wait: no test restarts the framework while a re-arm waits. The wait and the count of re-arms are held in providerAccelerationCooldowns and turnRearms, 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.
  • The kinds of message noteSpokenTo passes over, besides the agent's own schedules and a message a tune-out holds: no test stores a message marked system: true, one tagged as a reaction, an edit, a deletion or a chat:system notice, or another that the agent's view leaves out, after the re-arms are spent.
  • A heartbeat-mcpl server registered under an id other than heartbeat: no test runs one. isOwnScheduleWrite (src/framework.ts:412-416) matches a heartbeat tick by serverId: 'heartbeat' alone, and the changelog fragment says each of such a server's ticks starts the count again.
  • Ephemeral runs: no test runs one into an exhausted turn. src/framework.ts:5972 returns from rearmExhaustedTurn for a turn that doesn't own provider admission, and :20176-20177 give that ownership to no ephemeral run's turn.
  • A channel id that two servers' channels answer to: no test wakes a turn with one. The test's channel id names no registered channel. noChannel's doc names both cases (src/framework.ts:1318-1324), and the re-arm's check at :5981 reads it.
  • A re-armed autoContinue wake merged with another wake: no test makes one. src/framework.ts:5992 copies the trigger into the re-arm, its auto-continue reason included, and :17071-17081 take a merged start's channel from any other request that names one before an autoContinue wake's.

  • Changelog fragment added: 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.

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 rearm field, the hard-down streak, and the health snapshot's providerAdmission. It does not add a separate mechanism. (README.md (Overview; Abort / Cancellation))
  • The new public setting exhaustedTurnRearmMs is on by default, so every host's behavior changes unless it opts out. The change files this correctly as a .changed.md fragment and lets hosts opt out with false. 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 by abortInference(), by quiesce abandon and by stop(). The re-arm appears under providerAdmission as a cooldown. The changelog fragment says abortInference() 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-wait alert 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:

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, rearmExhaustedTurn runs after emitTrace('inference:exhausted'), the event-gate end and action.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 keeps counterparty, wakeChannelId and ambient/systemNotice, then overwrites channelId with the locus and forces addressed = 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 both ambient: true and addressed: true. TURN_CARRY is ignored today only because the copy no longer continuesTurn. 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. isTransientFailure reads Symbol.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.error is reassigned at module scope so it pushes into stderr, 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. Use t.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.

Comment thread src/framework.ts
Comment on lines 23642 to 23648
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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/framework.ts
Comment on lines +5976 to +5979
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/framework.ts
Comment on lines +3490 to +3493
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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/framework.ts
Comment on lines +5995 to +5998
const existing = this.providerAccelerationCooldowns.get(agent.name);
if (existing) {
this.holdRequest(existing.heldRequests, request);
} else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@nissa-seru
nissa-seru force-pushed the feat/838-rearm-exhausted-turns branch from 66c8ca5 to 4463662 Compare October 27, 2026 22:43
@nissa-seru nissa-seru changed the title feat(turns): send a turn whose retries ran out again after 2, 8 and 32 minutes, and alert on an hour of stated waits 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 Oct 27, 2026
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 27, 2026
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 27, 2026
@nissa-seru
nissa-seru merged commit 40f218c into anima-research:main Oct 28, 2026
12 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 28, 2026
…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.
tel-0s added a commit that referenced this pull request Oct 28, 2026
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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 28, 2026
…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.
nissa-seru pushed a commit to rufalupa666-netizen/agent-framework that referenced this pull request Oct 28, 2026
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.
nissa-seru pushed a commit to rufalupa666-netizen/agent-framework that referenced this pull request Oct 28, 2026
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.
tel-0s added a commit that referenced this pull request Oct 28, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 29, 2026
…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.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 29, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A turn that exhausts its retries is never re-armed: the agent stays silent until an unrelated wake

1 participant