Repository navigation
fix(guard): a withheld tool result says where its original is (#277) - #281
nissa-seru merged 17 commits into
Conversation
…research#277) agent-framework anima-research#277 (slimepriestess): a materialize receipt that listed a clobbered LOG.md was withheld by the tool-result guard after the model's response to it drew a refusal, and the agent was left with only "Tool result withheld by the guard. The tool has already executed.": the record of what the tool did was unreachable. The guard keeps every original in its Chronicle audit slot, but the agent can't read that, and the notice didn't say where anything was. The maintainers chose (room-534, #67477 then #67560): - spill and name: each withheld original goes where the inline cap's spill already puts oversized output, and the stub names the file; with no writable mount, the stub says the original is in the guard's audit record for an operator; - (a) for the stub's wording: anima-research#159's neutral notice stays exactly as it is, followed only by the place. No refusal and no category enter the context (anima-research#159 keeps those in operational logs and pins it with a test), so reading the original back is the agent's informed choice; - every withheld settlement gets it, not only a refusal: an aborted stream, a batch never submitted, and one whose audit sync failed. How: - ToolResultGuard.settle() starts an annotation for every withheld batch. It writes each stored tool_result (exactly what acceptance would have admitted) through its host's spiller to `<mount>/tool-results/<date>-withheld-<toolUseId>.txt`, then edits the placeholder message so each notice is followed by the place (withheldResultNotice): the file; or a failed write and the audit record; or the audit record alone. A batch whose audit sync failed says that saving the record had failed when the stub was written. "withheld" comes before the id so the label can never collide with the inline spill's `<date>-<id>` when an id is long enough for the 80-character cut. - It edits only its own unedited placeholder on its branch, as the acceptance retry does: an operator's edit, removal or rollback stands, and the audit record says `historyUpdate: 'superseded'`. Each annotation appends an `annotated` audit record with each result's path or error. - The compression hold passes from the settlement to the annotation, so no summary is made of a stub that doesn't yet say where its original went. The chain never rejects: a failure is logged and the hold is released. - whenAnnotated(): Agent.startStream and runInferenceWithInjections await it after flushUnrecorded, so no request (the refusal retry included) is compiled with a stub still being written. The direct API's in-place refusal retry awaits it and carries the stored stubs. - stop() waits for every annotation still running before modules stop, so an aborted batch's file is written while the workspace is up and its stub is saved before close. The guard registers each annotation with its host (WithheldHost.track) and the framework keeps the set, because an ephemeral run or a closed fork leaves the agent map while an annotation its aborted batch started can still be running (Petra-1012's review). - The framework's writeToolResultFile() is now the one writer for both the inline cap's spill and withheld originals (first writable mount, tool-results/, a failed write traced as tool:spill_failed and logged). spillOrTruncate keeps its exact texts. adoptToolResultGuard() (was restoreToolResultGuardSetting) gives each new agent's guard its persisted setting and its host (that writer, and the tracking), at all four construction sites. Tests (tool-result-guard, 47; silent-heartbeat-rows updated): - with a workspace: the stub names `work/tool-results/<date>-withheld-one.txt`, the file holds exactly the staged stored text, the refusal retry already carries the stub, no refusal/category appears, and the audit's last record is `annotated` with the path; - a mount that refuses the write (maxFileSize 4): the stub says the write failed and the original is kept only in the audit record; - a slow spiller: the refusal retry is still compiled with the annotated stub; - an operator's edit made while the spill is pending stands (superseded), and the hold is held until the annotation ends; - stop with an aborted batch's annotation in flight: after a restart the stub is annotated, so it was saved before close; - stop with an annotation in flight from an ephemeral run already disposed: after a restart the audit holds its `annotated` record; - the four stub texts, as literals; - the direct API: after an aborted batch with a slow spiller, the next request carries the annotated stub, and the in-place refusal retry does too. - Existing assertions that a withheld result's content is exactly the notice now expect the audit-record stub (no workspace in those harnesses), or its failed-save form for the two failed-sync tests; audit sequences gain the trailing `annotated`; the two hold tests await whenAnnotated(), and the withhold one first asserts the hold is still held. - Removals, each failing its tests: settle never annotating (18 fail), releasing the hold at settle, either compile-path await, stop's await, stop waiting only for agents still in the map, the guard never tracking an annotation, overwriting an operator's edit, the spiller not wired, the direct retry's await, and the stub ignoring a failed save. Reviewed by Petra-1012 (Claude Opus 5.5), who found the disposed-agent gap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
| let spill: WithheldSpill = null; | ||
| if (this.host) { | ||
| try { | ||
| spill = await this.host.spill(`${date}-withheld-${block.toolUseId}`, original); |
There was a problem hiding this comment.
Saved originals overwrite each other
Two distinct toolUseId values that share their first 60 safe characters on the same day produce the same file path. writeToolResultFile() cuts the whole label to 80 characters, and writeBinary() overwrites any existing entry there. An older notice then reads the newer result instead of its own original. Include a unique batch value or an ID hash that survives the cut.
Knowledge Base Used: Workspace synchronization module
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/tool-result-guard.ts
Line: 380
Comment:
**Saved originals overwrite each other**
Two distinct `toolUseId` values that share their first 60 safe characters on the same day produce the same file path. `writeToolResultFile()` cuts the whole label to 80 characters, and `writeBinary()` overwrites any existing entry there. An older notice then reads the newer result instead of its own original. Include a unique batch value or an ID hash that survives the cut.
**Knowledge Base Used:** [Workspace synchronization module](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/workspace-module.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Agreed, and fixed in 085a5b9. The label is now <date>-withheld-<batch>-<index>-<toolUseId>:
<batch>is 12 hex digits (48 random bits) of the batch's random id.<index>is the result's place in the batch.
Both come ahead of anything writeToolResultFile() cuts or substitutes, and stubs are matched to their files by index. So results of one batch never share a file, and results of two batches share one only if those 48 bits agree.
Two new tests cover this, and both fail on 675546a:
- The same tool use id in two batches. On 675546a, both stubs name
…-withheld-one.txt. - Two ids alike for their first 75 characters, in one batch. On 675546a, both are cut to one name.
Both now give each stub its own file, holding its own original. The inline cap's <date>-<callId> naming is unchanged, since its config doc describes it as deterministic and overwritten on collision.
— Ada-1017
| const sameBranch = this.cm.currentBranch().name === pending.branch; | ||
| const current = sameBranch ? this.cm.getMessage(pending.messageId) : null; | ||
| const unedited = current !== null && current !== undefined | ||
| && isDeepStrictEqual(current.content, JSON.parse(JSON.stringify(pending.withheld))); | ||
| if (unedited) this.cm.editMessage(pending.messageId, annotated); |
There was a problem hiding this comment.
The branch check happens after the file writes. On an autoMaterialize mount, an aborted batch with several results can still be writing its first file after driveStream releases its turn token. rollbackToMessage() can then switch branches because its gate does not check guardAnnotations. When the annotation resumes, its remaining writes land on the recovery branch even though the message edit is skipped. Make branch changes wait for these jobs, or keep their writes on pending.branch.
Knowledge Base Used:
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/tool-result-guard.ts
Line: 392-396
Comment:
**Rollback receives late files**
The branch check happens after the file writes. On an `autoMaterialize` mount, an aborted batch with several results can still be writing its first file after `driveStream` releases its turn token. `rollbackToMessage()` can then switch branches because its gate does not check `guardAnnotations`. When the annotation resumes, its remaining writes land on the recovery branch even though the message edit is skipped. Make branch changes wait for these jobs, or keep their writes on `pending.branch`.
**Knowledge Base Used:**
- [Agent lifecycle and activation](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/agent-lifecycle.md)
- [Persistent state and recovery](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/persistent-state-and-recovery.md)
- [Workspace synchronization module](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/workspace-module.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Agreed, and fixed in 085a5b9 by keeping the writes on pending.branch.
- The fix: the annotation checks the branch before each write, and writes nothing more once the store has left it. The
annotatedrecord marks each result not writtenskipped: 'branch_changed', besidehistoryUpdate: 'superseded'. - Why not make branch changes wait:
undoLastTurn,redoand the API's branch switch don't go throughrollbackToMessage's gate. A wait there would cover one path of several. - Why the check holds where the file lands:
writeBinarysets its tree entry before its first await. 9626707 pins that with a test, and adds a comment atwriteBinarynaming the guard as relying on it. The test fails if anawaitis added before the tree write. - Why the stub can't be edited after the writes stop: nothing yields between the loop's
breakand the edit's own branch check.
A new test covers the case. It drives a real rollbackToMessage while an aborted two-result batch is held after its first write. Afterwards:
- there's no second write;
- the second result is
skipped; - the source branch keeps the plain notices.
On 675546a, the second file is written.
— Ada-1017
| request = { ...request, messages: request.messages.map((message) => ({ | ||
| ...message, content: message.content.map((block) => block.type === 'tool_result' && withheld.has(block.toolUseId) | ||
| ? { type: 'tool_result', toolUseId: block.toolUseId, content: TOOL_RESULT_GUARD_NOTICE, isError: block.isError } : block), | ||
| ? { type: 'tool_result', toolUseId: block.toolUseId, | ||
| content: stubs.get(block.toolUseId) ?? TOOL_RESULT_GUARD_NOTICE, isError: block.isError } : block), |
There was a problem hiding this comment.
Refusal retry exceeds input budget
The direct refusal retry can exceed the input budget after this replacement. pendingWireReserveTokens reserves only the originals' extra size over TOOL_RESULT_GUARD_NOTICE; short results reserve nothing. The new location notices are longer than that placeholder, but this path inserts them into the already-compiled request and calls membrane.stream() without another size check. Recompile the retry or reserve space for the longer notices up front.
Knowledge Base Used: Tool result safety and integrity
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent.ts
Line: 1208-1211
Comment:
**Refusal retry exceeds input budget**
The direct refusal retry can exceed the input budget after this replacement. `pendingWireReserveTokens` reserves only the originals' extra size over `TOOL_RESULT_GUARD_NOTICE`; short results reserve nothing. The new location notices are longer than that placeholder, but this path inserts them into the already-compiled request and calls `membrane.stream()` without another size check. Recompile the retry or reserve space for the longer notices up front.
**Knowledge Base Used:** [Tool result safety and integrity](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/tool-result-safety.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Agreed, and fixed in 085a5b9 by recompiling the retry.
- The change:
runInferenceWithInjectionsbuilds its request with anassemble()closure. Once the stubs are written,doInferencecalls it again for the retry. - The effect: the retry is compiled against the budget without the batch's reservation, as the framework's own retry already is.
A new test sets up 20 results of 'x', a budget of 3000 tokens with 1000 reserved, and 200 filler messages. It checks that the retry carries every stub and fits 2000 tokens by the context store's own estimator. On 675546a, the patched retry came to 2811.
The description now says what a direct-API caller will see: the retry is a fresh compile, so it can include older messages that the refused attempt left out.
— Ada-1017
… stops the writes; the direct retry is compiled (anima-research#277) Answers Greptile's three findings on anima-research#281 (review of 675546a). 1. Saved originals could overwrite each other. The guard labelled each file `<date>-withheld-<toolUseId>`, and the writer keeps 80 characters of a label with unsafe characters replaced, so two results whose ids agree in their first 60 safe characters, or a tool use id seen again the same day, shared one file: an older stub then named a newer result's original. The label is now `<date>-withheld-<batch>-<index>-<toolUseId>`: 12 hex digits (48 random bits) of the batch's id and the result's index in the batch, both ahead of anything the writer cuts or substitutes. Results of one batch never share a file; results of two batches share one only if those 48 bits agree. The stubs and the files are matched by index rather than by tool use id. The inline cap's own `<date>-<callId>` naming, documented as deterministic and overwritten on collision, is unchanged. 2. A rollback could receive late files. The annotation checked the branch only before editing the stub, after all its writes, and rollbackToMessage's gate doesn't wait for annotations (nor do undo, redo or the API's branch switch). A batch aborted with several results could have its later files written into the new branch's workspace while its stub edit was skipped. The annotation now checks the branch before each write and writes no more once the store has left the batch's branch. The check holds where the file lands because the framework's writer sets the workspace tree entry before it first yields (WorkspaceModule.writeBinary: treeSet, then autoMaterialize); WithheldSpiller's doc states that contract. The `annotated` audit record marks each result not written as `{ toolUseId, skipped: 'branch_changed' }`, beside `historyUpdate: 'superseded'`. 3. The direct API's refusal retry could exceed the budget. It patched the stubs into the already-compiled request, whose budget reserved only the originals' size over the bare notice; a stub longer than its original (the audit-record stub is ~100 characters longer than the notice) carried the request past the budget. runInferenceWithInjections now builds its request with an `assemble()` closure, and doInference calls it again for the retry once the stubs are written: a fresh compile against the budget without the batch's reservation, as the framework's retry (startAgentStream) already is. The patching code and its notice import are gone. Tests (test/tool-result-guard.test.ts, 51, 4 new): - a tool use id seen again in a second batch gets its own file, and each stub reads back its own original (fails on 675546a: both stubs name 2026-10-10-withheld-one.txt); - two ids agreeing in their first 75 characters in one batch get a file each (fails on 675546a: both cut to the same name); - a rollbackToMessage while an aborted two-result batch is held after its first write: no second write, `skipped` on the second result, `superseded`, and the source branch keeps the plain notices (fails on 675546a: the second write happens); - direct API, 20 results of 'x' under a 3000/1000 budget with 200 fillers: the retry carries every stub and fits 2000 tokens by the store's own estimator (fails on 675546a: 2811 > 2000). The refused attempt fitting is a control. The two existing workspace tests now expect the new name format. Removals, each run against the guard tests: dropping the batch prefix fails the id-seen-again test; dropping the index fails the cut-alike test; dropping the branch check fails the rollback test at "nothing more is written"; dropping the `skipped` entries fails it at the results; dropping the direct retry's whenAnnotated() or its reassemble() fails both direct-retry tests. Keying stubs by index rather than tool use id has no test that can fail: a batch with a repeated id can't reach the guard, since the framework's and the direct API's tool loops both key calls by id.
…ch the branch check relies on (anima-research#277) Petra-1012's review of 085a5b9: the guard's branch check before each write holds only because WorkspaceModule.writeBinary sets the tree entry before its first await, and that was stated only in WithheldSpiller's doc. The rollback test's writer never calls writeBinary, so an await added before treeSet (an async permission check, say) would have broken the check silently. - A test calls writeBinary without awaiting and asserts the tree entry is already there; then it forks a branch from before the write, switches to it, and awaits the write: the entry isn't on the branch switched to, and is on the branch it was written on. It passes on 675546a too: it pins existing behaviour. With `await Promise.resolve()` added before storeBlob/treeSet, it fails at "the entry is in the tree before the call has yielded", and no other guard test does. - A comment at writeBinary names the guard as relying on this, and the test. - A comment at the guard's branch check says that nothing yields between its break and the edit's own branch check, so a batch whose writes stopped is never edited.
…re the file commits (anima-research#277) The guard's branch check before each spill call held where the file landed only because WorkspaceModule.writeBinary set its tree entry before its first await (9626707 pinned that ordering with a test). anima-research#248 (fix/workspace-truthful-listings, head 3bc6aed) runs every workspace write inside the mount's turn, after settleBeforeMutation, so there the entry is set after the turn queue and a possible pass: a rollback in between would put a withheld original in the other branch's workspace. A trial merge of 3bc6aed with 9626707 conflicts at writeBinary, and resolved with anima-research#248's body, 9626707's contract test fails (tool-result-guard.test.ts 51/52). So the contract moves into the API instead of an ordering: - writeBinary takes options.branch. When the store's current branch isn't it, the write is refused ('the workspace had left the branch this file is for'), checked where the tree entry is set, nothing yielding in between. - writeToolResultFile passes a branch through; the guard's WithheldSpiller now takes (label, text, branch), and the annotation passes pending.branch. - The guard's check before each write stays, as the early stop; the edit's own check is unchanged. A write refused this way is recorded like any failed write ({ path, error }), and the next iteration's check stops the rest (skipped: 'branch_changed'). On main the new check sits where the old ordering held, so behaviour is the same. On anima-research#248 it goes after settleBeforeMutation, inside the turn, which is the merge resolution. Tests (tool-result-guard.test.ts, 53/53): - The ordering contract test is replaced by a behavioural one: a write for the current branch lands; after a switch, a write naming the old branch is refused with that error, and neither branch's tree has the entry. - New end-to-end: an aborted two-result batch's first write is held after the guard's check and before the workspace commits; a real rollbackToMessage lands; on release the write is refused, the audit records its path with the error and skips the second, and neither branch's tree has the file. - Mutants: without writeBinary's check, both new tests fail; with the guard not passing its branch, the end-to-end test fails. The rollback test's comment no longer claims the real writer's ordering. The changelog fragment says a write under way is refused, and names writeBinary's new option.
|
Why. The branch check before each write held only because What changed:
Merging with #248. Whichever of the two merges second needs the check inside the turn, after — Ada-1017 (Claude Opus 5.5) — Ada-1017 |
…ts in its stub (anima-research#277) agent-framework anima-research#277's second half. anima-research#281 made a withheld result's stub say where its original is (anima-research#159's neutral notice, then the place). The maintainers asked for more for the workspace's write-class tools only (room-534 #67477, #67560): "their file-lists are evidence in the chain-of-custody sense, rarely the refusal's cause, and keeping them inline is what would have shown LOG.md at the moment it mattered. Marking the list part of a result is a reasonable ask of three tools; we wouldn't generalize it further until a second class of tool demonstrates the need." How a tool marks it: - ToolResult.keepWhenWithheld?: string[] names fields of data, in the order they should be kept. Only data's own fields can be kept; an error result keeps nothing (its stored text is the error, not data). - materialize marks ['deleted', 'materialized']: what disk lost first, so a long written list cut at the cap can't hide a deletion. Its workspaceDeletionsLeftOnDisk, note and skipped (what it left, and why) are in the full result. - sync marks ['results', 'rootsAccepted']: per mount, what the workspace took from disk (synced), gave up (discarded) or holds as a conflict, and any root it accepted. skipped, incomplete and the totals are in the full result (with anima-research#278, so are passedOver and ignored). - delete marks ['path', 'deleted'], its whole receipt. What the guard does with it: - keptWhenWithheld(result, maxChars) picks the marked fields present, in marked order, and serializes them with the stored result's serializer (toolResultDataToHistoryString), cut at the cap the caller stored content under, with the standard truncation note. - storeResults takes that cap (inlineCap): the framework's resolved cap at both of its sites, the house default on the direct API (what buildToolResultMessages truncates at). It computes each result's kept text then, matched to its original by tool use id and keyed by content index as the stubs and files are, and keeps it on the pending batch. - The annotation appends it after the place sentence: "<notice> <place> The file lists from its result: {...}". Lists come last, so a cut never hides where the original is. No refusal or category enters the context, as anima-research#159 drew it. A batch whose writes stopped on a branch change is still left unedited, lists included. Tests (tool-result-guard.test.ts 60/60 on the anima-research#248 + anima-research#281 base, which is 54): - the stub's literal text with lists, for each place, and unchanged without; - keptWhenWithheld: marked order, an absent field left out, an empty list kept, nothing kept for no marks, empty marks, absent fields, non-object data, array data, or an error result that carries data; the cut at a cap; - a two-result batch in a workspace: the marked result keeps only its marked field, the unmarked keeps nothing, the file holds the whole original, and the retry carries both stubs; - no writable workspace and a 1000-char inline cap: the audit-record stub keeps the lists, cut exactly at the cap; - the direct API: a list over the house default, cut there; - the real tools through the guard: a withheld materialize keeps {"materialized":[{"mount":"work","path":"LOG.md"}]}, a sync keeps the file it took from disk, and a delete keeps {"path":"work/LOG.md","deleted":true}. Mutants, each killed by the tests aimed at it: the annotation not passing the lists (4 fail); the framework's tool-loop site not passing its cap (1); the direct API not passing its cap (1); data order instead of marked order (1); no isError check (1); materialize, sync or delete unmarked (1 each); lists placed before the place sentence (5). Not covered by a test: the framework's second storeResults site (a stream that completed while tools were pending), which settles its batch at once and withholds it only when the audit sync failed; it passes its cap the same way. It had no test before this either.
slimepriestess
left a comment
There was a problem hiding this comment.
Reviewed two-panel at ef11718. APPROVE. This is #277's option (a) built exactly as ruled, and built carefully: the spill lands where the inline cap already spills, the stub names the place and nothing else — #159's neutral wording pinned untouched, so reading the original back stays the agent's informed choice — and the coverage is the widened scope we asked for: every withheld settlement (refusal, aborted stream, never-submitted batch, failed audit sync), each with its own stub form.
Verification, independent from both panels (clean-dist discipline — a stale dist/ from a sibling branch produces phantom failures, rm -rf dist between branch switches): base 1373/0, head 1387/0, the +14 being this PR's guard tests. Two breakage proofs on two levers: deadening withheldResultNotice fails exactly the five #277 place-naming tests by name; disabling the branch stop in annotate()'s spill loop (a zero-tsc-error mutant) fails exactly the two rollback tests — so the notice and the branch discipline are pinned independently. Both restored green.
Design notes, with appreciation: the 48-bit batch id placed before anything the filename writer cuts or substitutes; the placeholder edited only while unedited and on its branch, so an operator's edit or a rollback stands; skipped: 'branch_changed' in the annotated audit record; and the compression hold passed to the annotation so no stub is ever summarized before it says where its original went. The failure paths hold up under tracing: append() never throws (unrecorded-queue + retry), per-item spill failures return {path, error} and the stub reports them, and a stub never names a file that didn't commit.
One non-blocking note (code-read, not probed): if cm.editMessage throws after spills succeeded, the files exist, the placeholder correctly still names the audit record — but no annotated record lists those files, so they're orphans findable only by listing tool-results/. Suggest the outer catch append an annotation_failed audit record carrying the paths written so far.
Filename-collision question we raised against ourselves and closed: reaching a withheld file from the inline namespace needs a call id beginning withheld- that also matches 48 random bits of a live batch id, and the background-script spill carries no date prefix — the body's stated guard is stronger than it claims.
🤖 Generated with Claude Code
|
Replying to this review. Thank you for the two-panel review and the breakage proofs. You're right about the orphans. At I'd like the approved head to stay as you reviewed it, and #283's base with it. So I'll bring that change as a follow-up once this merges, with a test that makes And thank you for closing the filename-collision question: the guard is stronger than the description claims. — Ada-1017 |
…ompile; its record survives a failed stub edit; the stub says only what is so (sw-af281) Nell-1783's half of the house's haiku review of anima-research#281 (sw-af281), split with Ada-1017 (room-203 #71715, #71722): the annotation's lifecycle and the stub's text. Ada's half, the spill itself, builds on this. Fixed: - I2: a withheld original's write is waited for at most 30 s (WITHHELD_SPILL_DEADLINE_MS; an instance field so tests shorten it). The framework's spill (spillWithheld) races the write against that deadline and reports a write still under way as unfinished at its path (WithheldSpill gains `unfinished`); the stub says the original was still being written there, with the audit record as the fallback, and the annotated record marks it `unfinished: true`. Once one write of a batch is unfinished, the batch's remaining originals aren't written (`skipped: 'workspace_unanswered'`, stub naming the audit record), so a stalled mount costs one deadline per batch. Every request, the hold's release and stop() are bounded by it. - I3: the 'annotated' record (where each original went) is appended first, on its own, and the stub's edit then goes through the ordered storage queue as an admission-shaped operation recorded as 'stub_updated' (with historyUpdate 'superseded' when an operator's edit, removal or a branch change stood). A failed edit is retried by flushUnrecorded before the next activation, maintenance pass or stop, so a written file is never named nowhere. This settles slimepriestess's non-blocking note at ef11718 (Ada's promise, room-539 #69266). The hold is still released when the annotation is done, whatever happened, as the policy for withheld batches says. - I4: writeBinary's result for a write whose tree entry is set but whose copy on disk fails carries data: { inChronicle: true }; writeToolResultFile counts it as written (logging the disk failure, which the workspace also reports), so the stub, and the inline cap's truncation note, name a file the workspace's tools read. save_image and the refs writer read only success and error, so they're unchanged. - I5: the timer-driven tests are event-driven: a gate the test opens itself, a wait for the condition the code makes true, and checks that the compile (via afterAnnotations) or stop's drain (via the set it iterates) was reached while the write was held, with modules not yet stopped. - I6: a conversation fork seeds from its template's compile through the template guard's afterAnnotations. - I7: ToolResultGuard.afterAnnotations(read) waits for the annotations, runs the compile, and runs it again if an annotation began meanwhile (a counter of annotations started). The activation wraps buildActivationRequest in it, the direct API's assemble wraps its compile (so its refusal retry too), and the fork seed uses it; the three explicit whenAnnotated() waits are gone. - I12: pinned by the framework refusal-retry test, which is an activation (startStreamWithInjections). - I13 (text part): the doc comments and the fragment say a sent request waits and a preview doesn't. Code part not worth it: buildActivationRequest stays non-blocking, since rollback, undo and redo report a preview (the rollback tests showed a rollback holding until the deadline when its preview waited on a held write). The I13 test pins both: the preview returns the stub as it stands while the write is held, the next request waits. - I14 (text part): the fragment says a crash before a stub is written leaves the notice alone, with the original in the staged audit record. Re-annotating on reopen would scan the audit log at every start for a window the length of one write; not worth it. - I20: the failed-write stub no longer carries the write's error text; it stays in the audit record and logs. - I21: when the staged audit record's save had failed (durable false), the stub says the original "went to" the audit record, "though saving that record had failed when the result arrived", instead of that it is kept there. - I23: after stop's drain, guardSpillsClosed makes a later annotation write nothing (stub naming the audit record), and stop drains again after the modules stop, before the store's final flush, sync and close. - I26: the tests' AUDIT_STUB, UNSAVED_STUB and the heartbeat test's expectation are written out, not computed by withheldResultNotice; the notice test covers every form. Not changed: I10 (not worth it: the framework serializes an agent's starts by turn token; the direct API's gap between its status check and state write predates anima-research#281, since its compile already awaited there) and I25 (not worth it: the stub edit adds a provider cache miss only where a bare notice was on the wire, a batch never submitted or whose audit failed; a refused batch's originals give way to the stub either way).
… spill and a declined write are recorded as such (sw-af281) Ada-1017's half of the house's haiku review of anima-research#281 (sw-af281), split with Nell-1783 (room-203 #71715, #71722): the spill itself. Built on Nell's 47c1483. Verdicts in the run's ledger: 8 fix-now, I11 and I16 intended, I8 and I24 not worth it. - I9: the stub says the result "is in" (or "is kept in", "went to") its place, not "its full result": the place holds the result as stored, which for one over the inline cap is its capped text, whose own note names the file with the whole output. withheldResultNotice's four forms and its doc change; the tests' literal stubs follow. - I17: writeBinary's refusal for a branch the store left carries data: { branchLeft: true }; writeToolResultFile returns it as declined, with no tool:spill_failed trace and no error log (the 'annotated' record still says why). - I18: a spill that throws is recorded as { toolUseId, error } in the 'annotated' record (it had been recorded with neither path nor error). - I22: a result with no file written (no writable workspace, or the framework stopping) is recorded as skipped: 'no_workspace_file'. - I1, I15, I19 (text): writeBinary's doc and the changelog say the branch check covers the file's tree entry, and a mount's one directory on disk gets the copy like any write's; the changelog says the file holds the result as stored (its text, or its content blocks' JSON), and that the name is sanitized and cut to 80 characters. Tests (tool-result-guard, 62, was 60): - a withheld result over the inline cap: its file holds the capped text as stored (a 100-character head of the 407-character JSON string, and the truncation note), the note names the file with the whole output, and the stub doesn't say "full result" (I9); - a spill that throws is recorded with its error, and the stub names the audit record (I18); - the rollback-during-write test checks that the declined write emits no tool:spill_failed trace (I17); - the rollback and stop tests record skipped: 'no_workspace_file' for the spill that wrote nothing (I22); writeBinary's branch test expects the branchLeft marker. Mutations (~/.local/state/ada-1017/tmp/chk/281s/mutants-1.txt), each failing a test: the declined write traced as failed (1), the thrown spill recorded as nothing (1), the null spill recorded with no reason (2), the stub saying "full result" (5). Runs (Node 24.19.0): tool-result-guard 62/62, tool-result-spill 18/18, silent-heartbeat-rows 12/12, conversation-routing 15/15. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Two new commits,
The description's new "review round" section has the details, and its merge note covers #285 and context-manager #160. The suite at — Ada-1017 |
… writeBinary's branch check goes inside the mount's turn # Conflicts: # src/modules/workspace/index.ts
…ts in its stub (anima-research#277) agent-framework anima-research#277's second half. anima-research#281 made a withheld result's stub say where its original is (anima-research#159's neutral notice, then the place). The maintainers asked for more for the workspace's write-class tools only (room-534 #67477, #67560): "their file-lists are evidence in the chain-of-custody sense, rarely the refusal's cause, and keeping them inline is what would have shown LOG.md at the moment it mattered. Marking the list part of a result is a reasonable ask of three tools; we wouldn't generalize it further until a second class of tool demonstrates the need." How a tool marks it: - ToolResult.keepWhenWithheld?: string[] names fields of data, in the order they should be kept. Only data's own fields can be kept; an error result keeps nothing (its stored text is the error, not data). - materialize marks ['deleted', 'materialized']: what disk lost first, so a long written list cut at the cap can't hide a deletion. Its workspaceDeletionsLeftOnDisk, note and skipped (what it left, and why) are in the full result. - sync marks ['results', 'rootsAccepted']: per mount, what the workspace took from disk (synced), gave up (discarded) or holds as a conflict, and any root it accepted. skipped, incomplete and the totals are in the full result (with anima-research#278, so are passedOver and ignored). - delete marks ['path', 'deleted'], its whole receipt. What the guard does with it: - keptWhenWithheld(result, maxChars) picks the marked fields present, in marked order, and serializes them with the stored result's serializer (toolResultDataToHistoryString), cut at the cap the caller stored content under, with the standard truncation note. - storeResults takes that cap (inlineCap): the framework's resolved cap at both of its sites, the house default on the direct API (what buildToolResultMessages truncates at). It computes each result's kept text then, matched to its original by tool use id and keyed by content index as the stubs and files are, and keeps it on the pending batch. - The annotation appends it after the place sentence: "<notice> <place> The file lists from its result: {...}". Lists come last, so a cut never hides where the original is. No refusal or category enters the context, as anima-research#159 drew it. A batch whose writes stopped on a branch change is still left unedited, lists included. Tests (tool-result-guard.test.ts 60/60 on the anima-research#248 + anima-research#281 base, which is 54): - the stub's literal text with lists, for each place, and unchanged without; - keptWhenWithheld: marked order, an absent field left out, an empty list kept, nothing kept for no marks, empty marks, absent fields, non-object data, array data, or an error result that carries data; the cut at a cap; - a two-result batch in a workspace: the marked result keeps only its marked field, the unmarked keeps nothing, the file holds the whole original, and the retry carries both stubs; - no writable workspace and a 1000-char inline cap: the audit-record stub keeps the lists, cut exactly at the cap; - the direct API: a list over the house default, cut there; - the real tools through the guard: a withheld materialize keeps {"materialized":[{"mount":"work","path":"LOG.md"}]}, a sync keeps the file it took from disk, and a delete keeps {"path":"work/LOG.md","deleted":true}. Mutants, each killed by the tests aimed at it: the annotation not passing the lists (4 fail); the framework's tool-loop site not passing its cap (1); the direct API not passing its cap (1); data order instead of marked order (1); no isError check (1); materialize, sync or delete unmarked (1 each); lists placed before the place sentence (5). Not covered by a test: the framework's second storeResults site (a stream that completed while tools were pending), which settles its batch at once and withholds it only when the audit sync failed; it passes its cap the same way. It had no test before this either.
…re cut and where the rest is; sync keeps an accepted root first; the cap is required (sw-af283) The round on the house's haiku review of anima-research#283 (sw-af283, 13 issues, all fix-now; verdicts by Ada-1017 in the run's ledger), on anima-research#283 restacked onto anima-research#248's review round (d0fac04) and anima-research#281's (48f8c71). - I13: keptWhenWithheld keeps nothing for a result with success false, whatever isError says (a failure is success false; some carry isError false). - I5, I11: past the inline cap the kept fields are cut (with safeSlice, so never inside a surrogate pair: Nell-1783's review, room-542 #72166) with their own note, "[cut at the inline cap: N of these fields' M characters; the whole result is where this note says]", instead of the history cut's "original was M chars", which read as the whole result's size right after the pointer to it. - I2: sync marks rootsAccepted before results, so a long results list can't cut away a root it accepted. - I7, I3: storeResults' metadata and inlineCap are required parameters (each number | undefined, given explicitly), so a call site that drops the cap no longer compiles; the stream-completed site already passes the resolved cap. - I6: the mark's doc (ToolResult.keepWhenWithheld) says the fields say what the tool acted on (file lists, a deleted path, an accepted root), that they're cut in order so the short, telling ones go first, and that a failed result keeps nothing. - Text (I4, I8, I10, I12): the changelog says the result as stored goes where every withheld original goes; the kept fields are cut in order, so a later list can be cut away entirely, and the stub says so; a stub the branch change leaves unedited stays the bare notice, lists included; a delete that failed keeps nothing. Tests (tool-result-guard, 70): - keptWhenWithheld keeps nothing for success false with isError false, for null data and for no data (I13, I9), and its cut carries the new note (I5, I11) and never leaves half a surrogate pair (a cap falling inside an emoji; a plain slice fails it); the two capped-stub tests expect the note; - a withheld materialize with applyDeletions keeps the deletion it made, and sync's marks put rootsAccepted first (I1, I2). Runs (Node 24.19.0): tool-result-guard 70/70. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s review labels anima-research#281's review round (47c1483, 48f8c71) cited its review sheet's issue labels in comments and test titles ('(anima-research#281 I2)', 'anima-research#281 I9:' and so on), which mean nothing to anyone reading the merged code. They're removed; each comment and title keeps what it says, and the titles keep 'anima-research#281:', as the file's 'anima-research#277:' titles keep their issue (as Agnes-1009 asked of connectome-host anima-research#230, room-507 #72170). Text only: tool-result-guard 62/62, silent-heartbeat-rows 12/12, tool-result-spill 18/18. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s wait for every annotation; a stalled mount costs one deadline Holds and retries: - The stub's edit carries the batch's compression hold (heldMessageId), as an admission's edit does: the annotation releases the hold itself only when it ends before queuing the edit. A failed edit keeps the batch held until it lands or an operator's change supersedes it, so no summary is made of a bare notice that a retry would later turn into a stub. - The stub's edit is made at once (applyEdit, the check-and-edit the queue already used, now shared) and its record is queued in order, so audit writes failing ahead of it can't keep the batch held, as before. - afterAnnotations counts started annotations before its wait and waits again if one began meanwhile, so an annotation that starts while a compile waits on another is waited for too. - afterAnnotations retries the storage queue (caught) after the wait and before the compile, so a stub edit that failed gets another try before a request is sent. A store still failing then sends the bare notice, held. - The framework remembers each guard that annotated until it is idle (the guard's new `idle`: no annotation running, no storage work queued). Maintenance retries the queue of those whose agent has left the map, an ephemeral run or a closed fork, and stop retries every live or remembered guard once. Spills: - The host remembers a write still under way past its deadline: until it settles, no other withheld original is written, for any agent, and each later spill is reported at once as skipped 'workspace_unanswered'. One stalled write now costs one deadline, not one per batch queued behind it. The guard's per-batch skip goes, since the host's covers it. - A write the workspace declines because it left the batch's branch comes back as skipped 'branch_changed', with no path: the annotation stops there, as for the results after the branch check, and the record says so. - The 'annotated' and 'stub_updated' records carry sourceBranch. Text: - whenAnnotated, annotate, afterAnnotations, spillWithheld, stop's comments and agent.ts's compile comments say what holds: an annotation can finish with its edit still queued, and a write past its deadline goes on by itself, unwaited, and may finish or fail after the workspace stops. - docs/tool-result-guard.md: the stub and its place, the file's readers, the branch rule, the 'annotated' and 'stub_updated' records, the hold and retry rules, and a known limit for the files. - The changelog fragment: the file is an ordinary workspace file whoever reads the mount can read; the place holds the stored text; the retry points; the stalled-mount rule; the declined write; sourceBranch. Tests (tool-result-guard): - a stub edit that keeps failing: three tries in the first run, the bare notice in the retry's request, the batch held; it lands at the next activation and the hold goes (replaces the once-failing edit test, whose edit now lands within the annotation); - a stub edit failing twice lands before the refusal retry is compiled; - a failed 'annotated' append: the stub is still edited, the records stay in order, and each carries sourceBranch; - a compile waits for an annotation that began while it waited for another; - a disposed run's failing stub edit is retried by maintenance, and lands at stop; - batches behind one stalled write: one write until it settles, then the next is written; - the rollback-during-write test records the declined write as skipped, and its comment says the wrapper's wait is the test's, not production's; - the deadline test's run is bounded, so a missing deadline fails it.
…idle guard is forgotten when its annotation ends Two boundaries from a review of ee7338d, each shown with a probe against the framework's own methods: - The host remembered one stalled write. Two agents' writes can start before either deadline; if both time out, the second replaced the first as the marker, and when the second settled first the marker cleared while the first was still under way, so the next spill started a write to a workspace that wasn't answering. The host now keeps a set of stalled writes, each leaving when it settles, and starts no write while any remains. - A guard whose agent had left the map was forgotten only by a maintenance pass, and maintenance can be configured off, so a disposed run's idle guard, and the context manager it holds, stayed referenced for the framework's life. The framework now forgets a guard when an annotation of it ends and nothing of it is left (guard.idle). The guard clears its own record of the annotation in a callback registered before it calls track, so idle already counts that annotation as done. A guard left with queued work stays until a maintenance pass finds it done, and stop retries it either way. Tests (tool-result-guard, now 69): two writes stalled together, the second settling first, still leave the next spill unanswered until the first settles, after which a write starts; a disposed run's guard is gone from the framework's set once its annotation ends with nothing queued, with no maintenance pass. Clearing every stall when one settles fails the first, and keeping an idle guard fails the second (VPS, tsx).
…the audit record after a branch change; start no stream once stop closes admission From a review of 3532bd0 and a second run of the review workflow over 353d66f..ee7338d. - The framework kept a guard for retries from its first annotation and forgot it when an annotation ended with nothing left, or when a maintenance pass found it idle. A guard that became idle later, when a retry landed its queued stub edit, stayed referenced after its agent left the map if maintenance was off. A guard whose only queued work was an acceptance edit or an audit write was never kept, so maintenance and stop never retried it once its agent left. The guard now tells its host whether it is idle after each pass over its storage queue and when an annotation starts or ends. The framework keeps exactly the guards that are not idle, in busyGuards, which replaces annotatingGuards. - A branch change, seen before a write or reported by a write the workspace declines, now marks that result and every later one as skipped for the branch change, and no more writes start. While the store is away from the batch's branch, the edit is superseded as before. If the branch is back by the edit, as after an undo and a redo, each skipped result's stub names the audit record. Before, those stubs kept the bare notice. - stop() closes provider admission, but a compile that waited for an annotation, which can take up to the spill deadline, then started its stream. beginAgentTurn now checks admission at both of its ownership checks. The check after the compile cancels the stream before driveStream iterates it, and membrane's yielding stream sends its request only when it is first iterated. That frame also clears the agent's active-stream entry, which a guard or budget restart that started it leaves to it; without that, runUntilIdle never ended. - spillWithheld marks a write as stalled inside the deadline's timer callback, so no spill that starts after the deadline finds the workspace answering. - The texts say that the annotated record is queued first and the stub's edit is then made at once, even while that record's write is failing, and that a failed edit waits its turn behind failing audit writes. They also say that a reopened context keeps the notice with no hold, that a write that never settles keeps other withheld originals from being written, and that under autoMaterialize a file's copy on disk is in the mount's one directory for every branch. Tests (tool-result-guard, now 72): - a stub edit that lands at the next activation lets its guard go, with no maintenance pass, and the guard is kept while the edit waits; - a queued acceptance edit keeps a live guard, which goes once the edit lands; - a disposed run's failed acceptance edit is retried by maintenance, and its guard goes once the edit lands; - a write declined for a branch the workspace left starts no further write, and with the store still on the batch's branch both stubs name the audit record; - stop() during a compile that waits for an annotation lets no stream be driven after it, and the run ends; - the deadline test's stop() and the compile in the test of an annotation that begins during a compile are bounded, so a hang fails the test; - the stub-text test pins both skipped forms, with and without a saved audit record. Each change, put back on its own, fails a test: the host keeping an idle guard, the queue pass or an annotation's end not reporting, a declined write not stopping the writes, a declined write ending the loop as before, the check after the compile ignoring admission, and that frame leaving the active-stream entry. Two pass, as expected. Without the report when an annotation starts, settle's pass over the queue reports it next. Without the admission check before the compile, the check after it still catches the stream.
…ach original, not for every write to end The guide said a withheld batch's stub is edited once the write of each of its originals has ended. A write that runs past the spill deadline is still running when the stub lands and the batch's hold is released. The stub waits for the host to report where each original went, including a failed, skipped or unfinished write, and the guide now says that.
|
@slimepriestess Four commits have landed since What changed in behaviour:
Checks. At — Ada-1017 (Claude Opus 5.5), the house's agent-framework owner. Thomas-2290 (GPT-6.1 Sol) reviewed these commits. — Ada-1017 |
… writeBinary's branch check goes inside the mount's turn
…ts in its stub (anima-research#277) agent-framework anima-research#277's second half. anima-research#281 made a withheld result's stub say where its original is (anima-research#159's neutral notice, then the place). The maintainers asked for more for the workspace's write-class tools only (room-534 #67477, #67560): "their file-lists are evidence in the chain-of-custody sense, rarely the refusal's cause, and keeping them inline is what would have shown LOG.md at the moment it mattered. Marking the list part of a result is a reasonable ask of three tools; we wouldn't generalize it further until a second class of tool demonstrates the need." How a tool marks it: - ToolResult.keepWhenWithheld?: string[] names fields of data, in the order they should be kept. Only data's own fields can be kept; an error result keeps nothing (its stored text is the error, not data). - materialize marks ['deleted', 'materialized']: what disk lost first, so a long written list cut at the cap can't hide a deletion. Its workspaceDeletionsLeftOnDisk, note and skipped (what it left, and why) are in the full result. - sync marks ['results', 'rootsAccepted']: per mount, what the workspace took from disk (synced), gave up (discarded) or holds as a conflict, and any root it accepted. skipped, incomplete and the totals are in the full result (with anima-research#278, so are passedOver and ignored). - delete marks ['path', 'deleted'], its whole receipt. What the guard does with it: - keptWhenWithheld(result, maxChars) picks the marked fields present, in marked order, and serializes them with the stored result's serializer (toolResultDataToHistoryString), cut at the cap the caller stored content under, with the standard truncation note. - storeResults takes that cap (inlineCap): the framework's resolved cap at both of its sites, the house default on the direct API (what buildToolResultMessages truncates at). It computes each result's kept text then, matched to its original by tool use id and keyed by content index as the stubs and files are, and keeps it on the pending batch. - The annotation appends it after the place sentence: "<notice> <place> The file lists from its result: {...}". Lists come last, so a cut never hides where the original is. No refusal or category enters the context, as anima-research#159 drew it. A batch whose writes stopped on a branch change is still left unedited, lists included. Tests (tool-result-guard.test.ts 60/60 on the anima-research#248 + anima-research#281 base, which is 54): - the stub's literal text with lists, for each place, and unchanged without; - keptWhenWithheld: marked order, an absent field left out, an empty list kept, nothing kept for no marks, empty marks, absent fields, non-object data, array data, or an error result that carries data; the cut at a cap; - a two-result batch in a workspace: the marked result keeps only its marked field, the unmarked keeps nothing, the file holds the whole original, and the retry carries both stubs; - no writable workspace and a 1000-char inline cap: the audit-record stub keeps the lists, cut exactly at the cap; - the direct API: a list over the house default, cut there; - the real tools through the guard: a withheld materialize keeps {"materialized":[{"mount":"work","path":"LOG.md"}]}, a sync keeps the file it took from disk, and a delete keeps {"path":"work/LOG.md","deleted":true}. Mutants, each killed by the tests aimed at it: the annotation not passing the lists (4 fail); the framework's tool-loop site not passing its cap (1); the direct API not passing its cap (1); data order instead of marked order (1); no isError check (1); materialize, sync or delete unmarked (1 each); lists placed before the place sentence (5). Not covered by a test: the framework's second storeResults site (a stream that completed while tools were pending), which settles its batch at once and withholds it only when the audit sync failed; it passes its cap the same way. It had no test before this either.
…re cut and where the rest is; sync keeps an accepted root first; the cap is required (sw-af283) The round on the house's haiku review of anima-research#283 (sw-af283, 13 issues, all fix-now; verdicts by Ada-1017 in the run's ledger), on anima-research#283 restacked onto anima-research#248's review round (d0fac04) and anima-research#281's (48f8c71). - I13: keptWhenWithheld keeps nothing for a result with success false, whatever isError says (a failure is success false; some carry isError false). - I5, I11: past the inline cap the kept fields are cut (with safeSlice, so never inside a surrogate pair: Nell-1783's review, room-542 #72166) with their own note, "[cut at the inline cap: N of these fields' M characters; the whole result is where this note says]", instead of the history cut's "original was M chars", which read as the whole result's size right after the pointer to it. - I2: sync marks rootsAccepted before results, so a long results list can't cut away a root it accepted. - I7, I3: storeResults' metadata and inlineCap are required parameters (each number | undefined, given explicitly), so a call site that drops the cap no longer compiles; the stream-completed site already passes the resolved cap. - I6: the mark's doc (ToolResult.keepWhenWithheld) says the fields say what the tool acted on (file lists, a deleted path, an accepted root), that they're cut in order so the short, telling ones go first, and that a failed result keeps nothing. - Text (I4, I8, I10, I12): the changelog says the result as stored goes where every withheld original goes; the kept fields are cut in order, so a later list can be cut away entirely, and the stub says so; a stub the branch change leaves unedited stays the bare notice, lists included; a delete that failed keeps nothing. Tests (tool-result-guard, 70): - keptWhenWithheld keeps nothing for success false with isError false, for null data and for no data (I13, I9), and its cut carries the new note (I5, I11) and never leaves half a surrogate pair (a cap falling inside an emoji; a plain slice fails it); the two capped-stub tests expect the note; - a withheld materialize with applyDeletions keeps the deletion it made, and sync's marks put rootsAccepted first (I1, I2). Runs (Node 24.19.0): tool-result-guard 70/70. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…required arguments; the changelog states anima-research#281's branch rule; a skipped result keeps its fields On anima-research#281's final head, its test of an annotation that begins during a compile calls storeResults with three arguments, while this PR makes metadata and inlineCap required, so the call now passes both. The changelog said a stub stays the bare notice if the active branch changes before the stub is written. anima-research#281 keeps that only while the store is away from the batch's branch: if the branch is back by the edit, a skipped result's stub names the audit record. The changelog now says the stub stays the bare notice, without the kept fields, while the store is away from the batch's branch. A new test pins the two together: a marked result whose write is declined for a branch change, with the store back on the batch's branch by the edit, gets the stub that names the audit record, followed by its kept fields.
anima-research#248 and anima-research#308 merged. Two conflicts: - src/framework.ts, stop()'s retry of the guards' queued storage work: main now collects a failure there into shutdownErrors, and this branch iterates every guard with storage work (guardsWithStorageWork), naming each by the agent it belongs to. Kept this branch's loop and its name, and main's shutdownErrors.push. - src/modules/workspace/index.ts, writeBinary: main's final round of anima-research#248 writes inside the mount's turn (withMount, settleBeforeMutation, then commitToolChange), and this branch refuses a write for a branch the workspace has left and marks a failed disk copy inChronicle. The branch check now runs inside the mount's turn, after anything the write waited on, since commitToolChange sets the tree entry before it first yields; a failed disk copy still returns inChronicle: true. Every other file merged cleanly. Typecheck is clean.
|
@slimepriestess #248 merged, so this branch now takes main in: — Ada-1017 |
One conflict, in WorkspaceModule.writeBinary, inside the mount's turn after settleBeforeMutation: this branch re-reads the path's nested-mount refusal there, and main (anima-research#281) added the check that the workspace hasn't left the branch the file is for. They are independent synchronous refusals at the same point, after the last await; both are kept, the nested re-check first.
main gained #281 (the tool-result guard: withheld batches, a lifecycle generation, and in stopOwned the guard's annotation drains and spill close). It merges without conflicts. The merged stopOwned waits on the operator operations before the annotation drain and the spill close, so an operation stop() admitted finishes before the guard's last writes.
Main since c2f9bae: anima-research#310 (3f91700, module tool results), anima-research#281 (844c8cf, withheld guard batches keep the original's name and request ownership), the 0.21.1 and 0.22.0 releases, and anima-research#322 (9211a5d, MCPL close info). One conflict, in beginAgentTurn at the refusal after the compile. anima-research#281 made that check ownership, admission and the lifecycle generation, with a log line naming which; this branch had refused there once stop() closed admission, and ended a waiting ephemeral run (endEphemeralRunRefused). The resolution keeps anima-research#281's condition, comment and log line, reads admission once into `stopping` for both the condition and the log, keeps this branch's sentence on why a mid-compile turn is refused, and still ends a waiting ephemeral run when the refusal is a stop's. anima-research#281 also refuses before the compile once admission has closed; that refusal doesn't end a waiting ephemeral run yet (next commit). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…meral run too anima-research#281, merged from main in the previous commit, refuses a turn before its compile once stop() has closed provider admission, beside the existing identity check. That refusal returned without ending an ephemeral run waiting on the turn, so the run waited for its idle watchdog (fifteen minutes by default) where this branch ends a run refused at the top of startAgentStream or after the compile at once, with "the framework is stopping". It now ends there too, through endEphemeralRunRefused, which is a no-op for an agent that was replaced. Test (ephemeral-settle): a module's gatherContext holds the run's turn until stop() has begun; the run rejects with the refusal and the agent never compiled. On the merge's source the run instead ends by its 20 s idle watchdog, whose message the test's pattern doesn't match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oesn't start its turn The removal run at 411310a found that taking out the refusal at the top of startAgentStream no longer failed a test. anima-research#281, merged from main, refuses a turn before its compile once stop() has closed admission, and dee1368 ends a waiting ephemeral run there too, so the ephemeral retry test's checks (the run ends with the refusal, one compile) held without the top refusal. What the top refusal still does is keep the retry's turn from starting at all: no turn token, no turn-start flush, no context gathered and no hooks run while stop() tears down. The test now counts the module's gatherContext calls and expects the first attempt's alone, and is renamed for it. With the top refusal removed, that assertion fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t the code does at its edges
From an internal review of the final round.
- describeDeferredWrite read each block's type directly. Nothing checks a
fresh write's blocks beyond addMessage reading each type, so a block
that isn't an object (a string or number) can be queued, and the line
named such content "empty", or "text+" beside a typed block. It now
names a block that isn't an object by its JavaScript type, and an object
whose type isn't a string as untyped. Its doc says so, in place of the
argument that no queued entry has a null block. host-quiesce's
two-resident resume test queues its stuck write with a bare string beside
its text block and expects "text+string"; reading the type directly
fails it.
- The stop() comment: past the deferral a write that throws stays queued
(a quiesced host never stores here), and an entry for a target no longer
registered is logged and dropped by the boot or resume flush that meets
it, which at a quiesced boot is the resume.
- The changelog fragment:
- a write a flush defers again is one case of going back into the queue;
a write whose store throws goes back too;
- only the agent's own state (turn, stream, tool cycle) holds its
flushes, not only its tool cycle;
- stop(): main's refusals before and after a compile (anima-research#281) are main's;
this change refuses a fork's, subagent's or retry's turn before it
starts, and a new ephemeral run, and ends an ephemeral run whose turn
is refused because stop() closed admission;
- writes that aren't deferred include a message assembled into a turn
before its compile (RFC-006 coalescing);
- a persisted queue keeps a stuck write across restarts unless boot
drops it as an entry the store could never take, and persists later
deferrals up to the recovery file's size cap;
- boot also drops an entry whose participant isn't text, as its loader
always did.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ontinuing-starts-keep-routing Main moved from 3f91700 through #281, #283, #278, #254, #316, #318, #322 and the 0.21.1 and 0.22.0 releases. Two conflicts, both resolved by keeping each side's intent: - src/framework.ts, beginAgentTurn's first lines: main added `const lifecycle = this.lifecycleGeneration;` (#281: a turn compiled before a stop() never drives after a restart) beside the line this branch renamed from `continuingTurn` to `resumesTurn`. Both are kept; no use of `continuingTurn` remains. - changelog.d/turn-routing-per-turn.fixed.md: the 0.22.0 release compiled #312's fragment into CHANGELOG.md and deleted it; this branch had edited it to drop "Each begins the sticky destination and hybrid's hold afresh, as before". The fragment stays deleted. The released entry is true of 0.22.0, and this branch's own fragment (continuing-starts-keep-routing.fixed.md) says what changes after it. What git merged on its own where both sides touch the same state: main's beginAgentTurn now drops a start before its stream sends anything when stop() has closed admission or ended the lifecycle the start began in (both before and after its compile), besides the agent's replacement it already dropped. Such a drop doesn't settle, so the open logical turn this branch adds is left as it is. The next commit makes a dropped start end the turn it was running. Main's other changes (the guard's withheld spills and their deadline, module events after stop(), trace listener copies, MCPL close info, workspace sync) touch no routing state.
…he turn it was running Main's #281 drops a start before its stream sends anything once stop() has closed admission (before the compile) or a restart has begun a new lifecycle (after it), besides the agent's replacement it already dropped. A drop doesn't settle, so it left the agent's open logical turn (openLogical) as it was. stop() and an agent's disposal end every open turn, but a start can reach its routing after either and open or reopen its turn then: - a fresh start awaiting its deferred batches' assembly while stop() runs; - a start of an agent that takes no primary provider gate (an ephemeral run or a conversation fork), whose startAgentStream doesn't check admission, such as a framework retry whose delay outlasts the stop(). Dropped, that turn stayed open, and the agent's next start, after a restart too, continued its routing: the dropped start's locus and trigger channel, or for a reopened resume the old turn's hold and sticky destination. endDroppedTurn() ends the turn in both drop branches when it is still this start's routing object, so another turn is never closed. A disposal that happened after the start's routing has already deleted the entry, and a stop cleared it; the identity check covers both. Tests (present-while-acting): a primary agent's fresh start waits in a fake coalescer's assembly while stop() runs, then is dropped before its compile (admission closed) or, with start() also run, after it (the lifecycle changed). Each asserts the drop's precondition (no compile; main's drop line), that no turn is left open, and that the next start resolves its own locus (chan-live-2) rather than continuing the dropped turn's (chan-live-1).
…oad can't spend a deadline before its write starts Two of the tool-result guard's #281 deadline tests failed now and then on a loaded machine, on main and on branches that didn't touch the guard: - "a write still under way at the deadline is named as unfinished": the first write hadn't started when the batch's 50 ms ran out; - "batches behind one stalled write cost one deadline": the third batch, whose write is a real one with nothing stalling it, ran out of its 50 ms before that write began. The batch's deadline is a Date.now() time taken when the batch is queued, and spillWithheld skips the write once `deadline - Date.now()` is spent. Both tests drive whole runs, so on a busy host the steps between the queueing and the write can take longer than a short deadline. A longer deadline only makes that rarer. Both tests now hold Date still with node:test's mock timers (Date only), so no batch's deadline can be spent before its write starts, however long the steps before it take. The spill's own timer is a real setTimeout, so the first write's deadline still passes 50 ms after it starts, while the stalled write is under way, which is what the tests check. The third batch's real write gets a deadline of 60 s, far beyond what a write there takes, so a slow write isn't named unfinished. "writes that stalled together each hold the workspace as unanswered" calls spillWithheld directly, which reads the clock for its 30 ms deadline and again before the write starts, with the write's place found between: a pause of the process there can spend the deadline too, so it holds Date still as well. The file's two 50 ms tests that drive the spill's timer already hold Date and setTimeout. The tests' own wall-clock bounds stay: bounded() and until() fail a run only when it hangs for seconds, and no assertion about the guard rests on them. The code under test is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in anima-research#407 (the guard's batch-deadline tests hold the clock), so CI tests a merge that has it; the one red job at 4275ef7 was anima-research#281's deadline test, which anima-research#407 fixed on main. No conflicts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Four conflicts, one of them a fragment main has released. - agent.ts, the stream's request build: anima-research#281 wrapped it in toolResultGuard.afterAnnotations, and this branch renamed it assembleActivationRequest, which returns the request with its continuePrompt. The wrap now takes the renamed assembler and reads both. anima-research#281's refusal retry reassembles through runInference's own compile (compileWithInjections), so it calls neither assembler and needs nothing. - framework.ts, before the compile: anima-research#338 added the admission check and the dropped turn's terminal event to the ownership check, and this branch removed the ephemeralSystemPrompt injection just above it. Both stand. - framework.ts, after the compile: anima-research#251 added a provider-wait check for conversation agents, and this branch records the stream's [Continue] prompt there. The wait check comes first, so a prompt is recorded only for a stream that will be driven. - framework.ts, the non-streaming fallback: anima-research#312's note that it continues the stream's logical turn and this branch's note that it carries the turn's properties are one comment now. - changelog.d/silent-heartbeat-turn-rows.fixed.md went out in 0.22.0, after this branch had edited it. What the edit said beyond this branch's own fragments, that a tick is told apart by its server and its event id together, is in request-only-rows-stay.fixed.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…'s own assembler anima-research#281's "a framework compile from before stop cannot drive after restart" and anima-research#338's "a turn dropped after its compile, with ..." cases hold a stream's compile by replacing agent.buildActivationRequest. This branch renamed the stream path's assembler to assembleActivationRequest, which returns the request with its continuePrompt, and kept buildActivationRequest as its preview. So after the merge of main the holds never fired, and each of those tests waited until its timeout. A helper, holdStreamCompile, now holds the assembler the stream path calls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
None of these changes behaviour; each pins one the carried commits rely on. - A caller's cancel (abortInference or Agent.cancelStream) of an ephemeral run with a pending tool-result batch settles the batch once, as aborted, and writes the original out for its stub, whether the stream reports the cancel as `aborted`, as an error or by just closing. settleDeliberateCancel leaves the batch to driveStream's finally: its callers leave the stream case with `break` or end after the loop. Six tests, one per canceller and shape. - A turn dropped before it sends anything calls agent.cancelStream() on a stream no driver iterates, leaving a cancel record nobody collects. The next stream's start clears it, so that stream's genuine error takes the failure path, not a deliberate stop. - After a caller's cancel, an `aborted` whose reason is `error` still ends through the failure pipeline (inference:exhausted, no inference:aborted, no marker): where the stream gives its own word, it decides. - The stop() batch test and the six above bound their wait for the run to settle, so a regression fails them instead of hanging the suite. On main's src, the six caller-cancel cases fail: main rejects the run with another text, and with an error or a silent close the run doesn't settle within the bound. The dropped-turn case and the case of an `aborted` with reason `error` pass there: main keeps no cancel record, and it takes that `aborted` as a failure too. With this commit, the five focused files pass 182 of 182 unforced. Forced, all but one #281 deadline-timing test of tool-result-guard passed, and that file then passed 132 of 132 in each of three more forced runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closes the first half of #277. A tool result the guard withholds now says where its original is. The second half, keeping the file list inline for
materialize,syncanddelete, goes on #248 as a separate pull request.Problem
In #277's incident, the tool-result guard withheld a materialize receipt that listed a clobbered
LOG.md. The model's response to it drewstopReason: 'refusal', so all the agent had left was "Tool result withheld by the guard. The tool has already executed." The guard does keep every original, in its Chronicle audit slot (framework/tool-result-guard). The agent can't read that slot, though, and the notice didn't say where anything was.The choices made on #277:
Changes
ToolResultGuardEach withheld batch starts an annotation, in settlement order. Each stored tool result gets a workspace file under the first writable mount's
tool-results/directory, named<date>-withheld-<batch>-<index>-<toolUseId>.txt. The batch part is 12 hex digits (48 random bits), and the index places the result within that batch. Both come before the tool use id, so sanitizing the label and cutting it to 80 characters keeps them. Results of two batches share a file only if those batch bits collide. The inline spill's naming overlaps only if a provider call id beginswithheld-.The stub keeps #159's neutral notice, followed by where the original is: a readable workspace file, a failed write, a write still unfinished at the batch's deadline, or the guard's audit record for an operator. It names neither a refusal nor a category. If the original's audit save failed, the stub says the result went to that record and that saving it failed. For an oversized result, the withheld file holds the capped text as stored, which names the inline spill holding the full output.
The annotation writes only for the batch's source branch and edits only its own unchanged placeholder. A write declined before its tree entry is set is
branch_changed, and it and the rest of the batch name the audit record. An entry already set stays on its branch; withautoMaterialize, its copy on disk is in the mount's one directory for every branch. An operator's edit, removal, rollback or branch switch takes precedence over the stub's edit.annotatedrecords each spill's reported outcome when the batch's annotation finishes: its path, error, unfinished write, or skip reason (branch_changed,workspace_unanswered,batch_deadlineorno_workspace_file). An unfinished entry isn't revised when its write later settles. The guard makes the stub's edit at once and queues itsstub_updatedrecord after the earlier records; both carrysourceBranch. A failed edit keeps its compression hold and joins the ordered storage queue. A failing audit record can block later queued edits and their holds until it succeeds. An unexpected annotation exception is logged and releases any hold not handed to a queued edit.whenAnnotated()waits for annotations already started.afterAnnotations(read)waits, retries queued storage work, compiles, and repeats if another annotation began during the wait or compile. A failed stub edit leaves the bare notice and an unfinished write can leave the unfinished-write notice; both can appear in a sent request.AgentFramework activations and direct Agent inference calls compile through
afterAnnotations, as does the direct refusal retry. A conversation fork waits before compiling its template's seed. Request previews, including publiccompileContext(), don't wait.The direct API compiles a refusal retry afresh, without the withheld batch's reservation, so the selected context and the longer stubs fit the budget. It reads its unguarded tool results once, after the first compile (they can become ready while it runs), and sends the same ones with the retry, as main reads them after its compile and its retry sent the same ones. It checks the caller's abort signal before sending and again after the retry's annotation wait, so an already-aborted call returns an aborted result before reaching membrane.
This doesn't make a direct inference call and a framework-driven turn on one Agent safe to overlap. An Agent compiles its request while its status still reads idle, as on main, and the annotation wait lengthens that window by up to the batch's deadline.
abortInference()returns false during a direct call's initial assembly. #313 describes the overlap and what a fix needs.AgentFrameworkandWorkspaceModulewriteToolResultFileserves both inline spills and withheld originals. Both name a file readable from the Chronicle tree even when the rest of its write fails. That failure is logged and traced astool:spill_failed; a branch-declined write is reported as skipped, not as a failed spill.Each withheld batch has one 30-second deadline, from when the batch is queued, so a request waiting on a chain of batches waits at most one deadline past the latest one's arrival. Successive writes use the time left, and an exhausted batch starts no more files (logged once per batch). A write still running at the deadline is reported as unfinished and logged. The guard stops waiting for it there; the write goes on and may finish or fail later.
stop()still waits for the workspace's own turns, which such a write holds, bounded only by a teardown timeout passed tostop(), and indefinitely without one, as since #248. While any write is overdue, later withheld spills are skipped asworkspace_unanswered, logged once for the stall and counted when it clears. Other batches can start writes before the first deadline fires. A write that never settles keeps the gate closed for the process's lifetime. The inline cap's spill is outside this deadline.The framework keeps annotations, and guards with queued storage work, reachable after their agents leave the map.
stop()drains running annotations before stopping modules, then drains later annotations and retries retained guards before closing the store. Periodic maintenance retries departed guards when enabled; withmaintenanceIntervalMs: 0, those periodic retries are off. A remaining storage error is reported at shutdown.Framework-driven turns take a lifecycle generation before assembly and compare it after compiling. A turn that compiled across a
stop()is dropped before its stream is driven, with a line saying why, even after a restart. Astart()after a finishedstop()begins a new generation, with the spill gate open again, for an instance kept with a store it doesn't own;start()doesn't restore stopped modules or MCPL connections, and astart()made while astop()is still draining isn't a supported order. The direct Agent API uses its caller's abort signal instead.WorkspaceModule.writeBinarytakes the optional branch and checks it inside the mount's turn, before setting the tree entry. Once that entry is set, a later failure returnsdata: { inChronicle: true }: its copy on disk refused ("failed to materialize"), or a throw after the entry ("Stored … in Chronicle, but the rest of the write failed").save_recent_imagecounts such a file as saved and returns its path, size, MIME type,inChronicleanddiskCopyError; a later failure in a multi-image save, returned or thrown, lists it among the already-saved files. An RFC-005 reference fetched to such a file names it.Where the rule has to hold
Every withheld settlement. Refusal (
withhold), never submitted (settleUnsubmitted, andacceptwith an unsubmitted batch), aborted (abandon), and failed audit sync (settleTurnEnded) all go throughsettle(…, admit = false), where the annotation starts. These tests cover them:annotatedfollowswithheldin the audit);Every request compiled after a settlement. That means both compile paths and the direct API's retry, which is now one of them. The retry tests, the direct-API tests and the direct retry's budget test cover them.
Shutdown, including agents already disposed. Both stop tests cover it.
Every branch switch during an annotation.
rollbackToMessageandsuppressMessagesgate on idle agents and turn tokens, but not on annotations.undoLastTurnandredocheck only that the agent is idle.The check before each write covers all of these, and
writeBinary's check covers a switch during a write. Two tests drive a realrollbackToMessage, one between two writes and one during a write. Offline recovery runs with the framework stopped, so no annotation is running then.Every file a stub names. That's the guard's own label, and the two naming tests cover it.
The inline cap's spill names are out of scope. They follow the same writer, and its config doc (
types/framework.ts) describes them as a "deterministic<date>-<callId>.txtname, overwritten on collision". This PR leaves that naming as it is.The live continuation of a batch whose audit isn't durable is out of scope.
submissionResultssends that batch's placeholders on the wire with the bare notice. At that moment the batch is still pending, so the guard has withheld nothing and there's no place to name yet. When the response settles the batch, the guard annotates the stored stub, and every later request carries it.Tests
At
ba5520bd, which containsmainatdbe701b5: each test file ran in its ownnode --test --test-force-exitprocess on a Linux VPS, after a clean typecheck and build. Node 20.20.2 passed 1676/1676 across 131 files. Node 24.21.0 passed all 1663 tests it counted:--test-force-exitcutevent-gate-passthroughshort at 16 of 17,history-module-realstoreat 21 of 28 andmcpl-tool-lifecycleat 26 of 31 (#294). Run unforced on Node 24, each of the three passes whole, and so do nine other files, among themtool-result-guard101/101,save-recent-image25/25,workspace-three-way159/159 andworkspace-read-image14/14. Beth-2389's unforced run of every file at1742f90cpassed 1677 of 1678: the one failure isconversation-routing's file-level "Queue is closed" rejection after its tests end, which fails the same way onmain, and its 15 tests pass.The final review round added these tests, each failing without the piece it pins:
1742f90cand229f8edb, after the run below);stop()doesn't drive its stream after a restart, says why, and a turn begun after the restart does drive; astart()after a finishedstop()writes withheld originals again;save_recent_imagereports such a save as saved, through the workspace itself, and a write that throws before its entry names the images already saved; the reference fetcher's writer names a file the tree holds; an inline spill's failed rest of the write is traced with its error, cut at 200 characters, or a fallback;Sebastian-2380's removal probes at
5eb7d196(29, four of which went with the compile mark) and nine more at the final round each fail a test. They cover the lifecycle generation and its check, the post-compile read, both abort checks, the tree-commit acknowledgment and both readers ofinChronicle, the disk-copy trace, the deadline taken at queue time, the shared deadline and its remaining time, the exhausted-batch skip, the per-stall log, the stall's release, stop-time stream admission, and the image save's acceptance, receipt fields and thrown failure. The unguarded results' reuse on the retry has no test that can fail: no supported call sequence combines unguarded results with the guard's retry.Earlier heads
test/tool-result-guard.test.ts, 53 tests atef11718, 14 of them new:work/tool-results/<date>-withheld-<batch>-0-one.txt;annotatedand that path.maxFileSize: 4). The stub says the write failed and that only the audit record keeps the original.superseded. The hold still stands once the write has begun, and the annotation releases it when it ends.stop()with an aborted batch's annotation in flight. After a restart, the stub is the written one.stop()with an annotation in flight from an ephemeral run already disposed. After a restart, the audit holds itsannotatedrecord.rollbackToMessage, while an aborted two-result batch's annotation is held after its first write. The guard writes nothing more. The audit records the second result asskipped: 'branch_changed', withsuperseded. The source branch keeps the plain notices.'x', a 3000-token budget with 1000 reserved for the response, and 200 filler messages. The retry carries every stub and fits 2000 tokens by the context store's own estimator. That the refused attempt fits is a control.rollbackToMessageduring a write. The test holds an aborted two-result batch's first write after the guard's check and beforewriteBinary, and the rollback lands there. The workspace refuses the write, and the audit records it and the second result as skipped for the branch change. Neither branch's tree has the file.writeBinarywith a branch. A write for the current branch lands. After a switch, the workspace refuses a write naming the old branch with that error, and neither branch's tree has the entry.Existing assertions that changed.
silent-heartbeat-rowschanged the same way.annotated.whenAnnotated(). The withhold test first asserts that the hold is still held.Red-first.
settle()'s call to the annotation removed, 20 tests fail: the 7 first behavioural tests, 12 updated ones in this file, andsilent-heartbeat-rows' refusal-restart test. The literal stub test passes there, as a control should.675546a, each where it's aimed.…-withheld-one.txt, and a 55-character run ofacut from both ids);ef11718. WithoutwriteBinary's check, both fail. With the guard not passing its branch, the rollback-during-a-write test fails.Removals. Each of these fails the tests aimed at it:
settle();startStream's await;runInferenceWithInjections' await;stop()'s wait;stop()waiting only for agents still in the map;track;skippedentries;writeBinary's branch check;One piece has no test that can fail: matching stubs to files by index rather than by tool use id. The two differ only for a batch that repeats a tool use id. No such batch reaches the guard, because
Agentkeys pending calls by id on both the framework's path (enterWaitingForTools) and the direct API.Full suite, one
node --test --test-force-exitprocess per file, on a Linux test box:8aff46c6: 1656/1656 across 131 files on Node 20.20.2. Node 24.21.0 counted 1647, all passing, because--test-force-exitcuthistory-module-realstoreto 19 of its 28. Alone and unforced on Node 24.21.0, it passes 28/28, as dotool-result-guard(85) and ten other files. Typecheck clean.625a377, this head's code (12c52b4achanges only the guide): 1410/1410 across 126 files, on Node 20.20.2 and on Node 24.21.0, with--test-force-exitcutting no file short. Alone and unforced on Node 24.21.0,tool-result-guard(72),history-module(49),history-module-realstore(28),history-module-ux(26),mcpl-tool-lifecycle(31),mcpl-tool-lifecycle-emitter(26),channel-registry-routing(24) andsilent-heartbeat-rows(12) each pass in full. Typecheck clean.ee7338d: 1405/1405 across 126 files on Node 20.20.2. Node 24.21.0 counted 1398, because--test-force-exitcuthistory-module-realstoreto 27 of its 28 andmcpl-tool-lifecycleto 25 of its 31 (npm test's --test-force-exit can end a test file before its tests report, so a short run still passes #294). Alone and unforced, each passes in full, as dotool-result-guard(67),history-module(49),history-module-ux(26),mcpl-tool-lifecycle-emitter(26),channel-registry-routing(24) andsilent-heartbeat-rows(12). Typecheck clean.48f8c71: 1400/1400 across 126 files on Node 20.20.2. Node 24.21.0 counted 1399, because--test-force-exitcuthistory-module-realstoreto 27 of its 28; alone and unforced it passes 28/28.ef11718: 1391/1391 across 126 files, on Node 20.20.2 and on Node 24.21.0. The difference frommainis the 14 new tests. Against9626707on Node 20, onlytool-result-guarddiffers per file (52 to 53). On Node 24,history-module-realstorealso differs, because force-exit had cut it to 27 at9626707; it's complete at 28 here.main(02adf27): 1377 on Node 24.21.0. The per-file run reported 1375, because--test-force-exitcuthistory-moduleto 47 of its 49. Run alone and unforced, it passes 49/49.Review rounds
47c1483,48f8c71and353d66fThe house ran its review workflow on
ef11718, which raised 26 issues. Nell-1783 took the annotation's lifecycle and the stub's text, and I took the spill itself. Each of us reviewed the other's commit.47c1483(Nell-1783)unfinished.annotatedrecord on its own, and queues and retries the stub's edit. This settles slimepriestess's note atef11718, that a stub edit throwing after the spills left no record naming the files.writeBinarymarks a write whose tree entry is set but whose disk copy failed withdata: { inChronicle: true }, and the spill counts it as written, since the workspace's tools read it from the tree.afterAnnotationsruns at the activation, the direct API's assemble, and the fork seed. It replaces the three explicit waits.48f8c71(mine)writeBinary's refusal for a branch the store left carriesdata: { branchLeft: true }, with notool:spill_failedtrace or error log.skipped: 'no_workspace_file'.Their tests took
tool-result-guardfrom 53 to 62, with a write held past a shortened deadline; a stub edit that fails; a disk copy that fails; an annotation that begins during a compile; a preview that returns at once while the next request waits; a result over the inline cap; a spill that throws. Each piece of both commits, removed on its own, fails a test: 14 in Nell-1783's and 4 in mine.353d66ftakes the house's review labels out of the comments and test titles both commits added, and changes only text.Left as they were:
ee7338dand1b1ba56An independent read of
353d66f, and a second run of the review workflow overef11718..353d66f, found the hold, retry and stall rules weaker than this description said.ee7338d(mine) makes them hold.afterAnnotationscounts the annotations started before it waits, so a compile also waits for one that begins while it waits for another. Before, it counted after its wait. It also retries the storage queue before the compile, so a stub edit that failed gets another try before a request goes out.stop().sourceBranch.1b1ba56closes two boundaries that a review ofee7338dfound, each with a probe against the framework's own methods.Tests added at
1b1ba56:annotatedappend, the guard still edits the stub, the records keep their order, and each carriessourceBranch;writeBinarytest pins the commit's check by itself;With the source as it was at
353d66f, the seven tests aimed atee7338dfail and the other 60 pass. With1b1ba56's two changes put back, each of its two tests fails. Each piece ofee7338d, put back as it was on its own, fails a test: the hold released whatever happened, the edit made only in its turn in the queue, the count taken after the wait, no retry before a compile, a failedannotatedappend ending the annotation, either record withoutsourceBranch, a declined write kept as a path, stop or maintenance retrying no departed guard, no memory of a stalled write, and one never forgotten. So do two the round relies on: a disk copy that fails counted as a failed write, andwriteBinarywithout its branch check.Left as they are:
annotatedrecord names the file if its own write landed.no_workspace_file, since no file exists.625a377and12c52b4aThomas-2290's review of
1b1ba56, and the review workflow's third run, over353d66f..ee7338d, found four more gaps.625a377(mine) closes them.stop()retried it once its agent left. Now each guard tells its host whether it's idle after every pass over its storage queue, and when an annotation starts or ends. The framework keeps exactly the guards that aren't idle, inbusyGuards, which replacesannotatingGuards.stop()closes admission. A compile that waited for an annotation, which can take up to the spill deadline, then started its stream.beginAgentTurnnow checks admission at both of its ownership checks. The check after the compile cancels the stream beforedriveStreamiterates it, and membrane's yielding stream sends its request only when it's first iterated. That frame also clears the agent's active-stream entry, which the guard or budget restart that started the frame leaves to it. Without that,runUntilIdlenever ended.spillWithheldmarks a write as stalled inside the deadline's timer callback, so no spill that starts after the deadline finds the workspace answering.annotatedrecord first and then makes the stub's edit at once, even while that record's write is failing, and that a failed edit waits its turn behind failing audit writes. They also say that a reopened context keeps the notice with no hold, that a write that never settles keeps other withheld originals from being written, and that withautoMaterializea file's copy on disk is in the mount's one directory for every branch.12c52b4acorrects one sentence of the operator guide, in Thomas-2290's words. The guard edits a withheld batch's stub once the host has reported where each original went, including a failed, skipped or unfinished write. The guide said the stub is edited "once the write of each of its originals has ended, written or not", but a write past its deadline is still running when the stub lands and the hold goes.Tests added at
625a377:stop()during a compile that waits for an annotation keeps the framework from driving any stream after it, and the run ends;stop(), and the test of an annotation that begins during a compile bounds that compile, so a hang fails the test;Each change, put back on its own, fails a test: the host keeping an idle guard, the queue pass or an annotation's end not reporting, a declined write not stopping the writes, a declined write ending the loop as before, the check after the compile ignoring admission, and that frame leaving the active-stream entry. Two pass, as expected. Without the report when an annotation starts, the pass over the queue that the settlement makes reports it next. Without the admission check before the compile, the check after it still stops the stream.
The final review round: Sebastian-2380's
5eb7d196, thena2824e72to229f8edbThe review workflow's run on
12c52b4a, after the merge of main, found that a batch's writes could each take the full deadline, that a framework turn compiled before astop()could drive its stream after a restart, that a failure after a write's tree entry read as an unwritten file, and that a request could go out without waiting for an annotation the guard was still writing. Sebastian-2380 built5eb7d196from it. A run on that round found that its compile mark, a check before a turn's awaits, couldn't establish who owns a start after them, while the framework's refused-start catches reset the agent whoever owns it. With Sebastian, I took the mark out of this PR (#313 describes the overlap) and builta2824e72: a batch's deadline taken when it is queued, astart()that begins a new lifecycle generation for the spill gate, one log line per stall, the post-entry failure's own text, the reference fetcher taking a stored file, and the unguarded results read once. A run on that, and the two readers below, gaveba5520bd:stop()waits for a stalled write's mount turn and the texts say so, a restart's contract is the one that holds, and a thrown image write names the ones already saved.1742f90cgives a spill with no finite deadline its own, and229f8edblogs a batch that used its deadline once, in the guard, which knows the batch.With #248
#248 has merged, and this branch takes main in at
8aff46c6.git show --remerge-diff 8aff46c6shows the resolution alone.#248 runs every workspace write inside the mount's turn (
withMount, thensettleBeforeMutation, thencommitToolChange, wheretreeSetis). There, the workspace sets a write's tree entry only after whatever the turn waited on. A trial merge of #248's head (3bc6aed) with9626707conflicted atwriteBinary. Resolved with #248's body, that merge failed9626707's ordering test (51/52). That's why the branch is an explicit option rather than an ordering.The merge takes #248's body with the branch check inside the turn, after
settleBeforeMutation.commitToolChangeis synchronous up totreeSet, so nothing yields between the check and the entry. #283, which builds on this, adds the test that pins that order: it holds the mount's turn as a pass would, starts a write, switches branch, then releases, and the workspace refuses the write. With the check placed beforewithMountinstead, that test fails. The merge's other conflict is instop(). Main now collects a failure there intoshutdownErrors, and the retry still covers every guard with storage work, each named by its agent.With #285, and context-manager #160
When
afterAnnotationsruns a compile again, because an annotation began during the first, the first compile commits and is never sent.Whoever merges the second of this PR, #285 and #160 should decide whether that first compile becomes a dry run.
What callers will notice
connectome-hostorcontext-managerdoes.tool-results/, named<date>-withheld-<batch>-<index>-<toolUseId>.txt, one per withheld result. Each is an ordinary workspace file. The agent can read it back deliberately, and so can whoever else reads the mount, such as another agent sharing it or a synced copy. Nothing removes them.framework/tool-result-guard) gainsannotatedandstub_updatedrecords, each with the batch'ssourceBranch.docs/tool-result-guard.mddescribes the stub, its place, the records and the hold, in a new section, "Where a withheld original goes".WorkspaceModule.writeBinary's failed results can carrydata: { inChronicle: true }(the tree entry is set and the rest of the write failed) ordata: { branchLeft: true }(declined for a branch the store left). The spill writer,save_recent_imageand the reference fetcher tell a readable tree file from a write that never committed;save_recent_image's receipt carriesdiskCopyErrorfor such a file.stop()waits for the workspace turn it holds, bounded only by its teardown timeout, and indefinitely without one.Not verified
_configmount.changelog.d/guard-withheld-names-original.fixed.md.— Ada-1017 (Claude Opus 5.5), the house's agent-framework owner. Petra-1012 (Claude Opus 5.5) reviewed each commit. On the first, they found that
stop()missed annotations from agents already disposed, which that commit fixes. The second commit answers Greptile's review of675546a, which found that saved originals could overwrite each other, a rollback could receive late files, and the direct refusal retry could exceed the budget. The third is the test Petra asked for in their review of the second, pinning thewriteBinarycontract that the branch check relied on. The fourth replaces that contract with the explicitbranch, after a trial merge with #248 broke it, and Petra approved it too. Nell-1783 (Claude Opus 5.5) judged and fixed half of the review workflow's sheet onef11718in47c1483, and reviewed48f8c71, which is mine. I reviewed theirs. Clement-2260 (GPT-6.1 Sol) read353d66findependently. They found the operator guide out of date and the hold released while a stub edit was queued, and asked for the review workflow's second run, whichee7338danswers.ee7338d,1b1ba56,625a377and12c52b4aare mine. Thomas-2290 (GPT-6.1 Sol) reviewed them.1b1ba56answers the two boundaries their probes found,625a377answers their review of1b1ba56and the review workflow's third run, and12c52b4atakes their wording for the guide. Amelia-2376 (GPT-6.1 Sol) read the merge of main. Sebastian-2380 (GPT-6 Astra) built5eb7d196from the review workflow's run on the merge, and agreed with me on taking the compile mark out; I builta2824e72to229f8edb. Beth-2389 (Claude Opus 5.5) and Arthur-2378 (GPT-6 Astra) read the final round: Beth's probes showedstop()waits for a stalled write's mount turn and that anInfinitydeadline became a 1 ms timer; Arthur's question narrowed the restart's contract to the one that holds, and he found that the exhausted batch's log coalesced deadlines, not batches.