puppetToolCall: reserve the agent against turn start for the whole operation (#145) - #148
Conversation
…eration (anima-research#145) The idle guard was a point-in-time check before two awaits (tool execution, result build). A wake arriving in either gap started a real turn, and the synthetic tool_use/tool_result pair then wrote straight through the context manager into a turn that never saw it — the wire-order corruption the method's own docstring says it prevents. The puppet now holds a turn token for its duration: the same turn-alive marker the scheduler requeues wakes on and the addMessage guard defers cross-turn writers on. It also refuses while a turn is alive even if status reads idle (teardown pending), releases token-matched in finally (no idle+turn-alive wedge on a throwing tool), and flushes messages deferred during its hold after the pair, as driveStream's end-of-turn flush does. Residual, handled: a turn that passed the scheduler's busy check and was parked on provider admission re-enters startAgentStream without re-testing turn-alive and replaces the reservation. The side effect has already happened, so the pair is queued as a unit onto deferredMessages for that turn's end flush rather than split or written under it; the trace says `deferred: true`. Tests: unit (reservation spans execution; released on throw; refused on idle+turn-alive; mid-hold message lands after the pair; replaced reservation queues the pair as a unit) and a real-framework regression that holds the tool open while a wake runs the scheduler — 5 of 6 fail on main's framework.ts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CoQK2cP55YhezE6ajSx58h
Anarchid
left a comment
There was a problem hiding this comment.
🔴 BLOCKING
Reviewer: Codex (GPT-5.6 Sol)
Reviewed head: 116e16b978f8bbf9087071159c16358dac889a71
Finding
-
Blocking — a fast replacement turn strands the puppet pair after
puppetToolCallreports success (src/framework.ts:6108,src/framework.ts:7866,src/framework.ts:7893)The acknowledged provider-admission continuation re-enters without checking the puppet's token and replaces it:
await this.startAgentStream(agent, trigger, attempt, true);
After the tool finishes, any lost token is treated as a still-live replacement turn, so the pair is queued:
ownedToEnd = this.activeTurnTokens.get(agentName) === turnToken; // ... this.deferredMessages.push(toolUseEntry, toolResultEntry);
But the
finallyflush runs only when the map still contains the puppet's original token. If the replacement turn is short and completes before the puppeted tool, that turn has already cleared its token and performed its only end flush. The later pair is therefore queued after the flush, the puppet'sfinallyskips it, and the public promise returns success without persisting either half. With no later wake, the supposedly installed exemplar never reaches the agent's context.I reproduced the real provider-gate timing on this head: hold an auxiliary call so a primary turn parks at
waitForAuxiliaryIdle, start a held puppet call, release the auxiliary so the replacement turn starts and completes, then release the tool. The result was:{"puppetTokenBeforeResume":1,"turnFinishedBeforeTool":true,"providerCalls":1,"persistedTypes":["text"],"deferredCountAfterPuppetReturned":2}The unit at
test/puppet-tool-call.test.ts:246only substitutes a token that remains live through puppet completion, so it cannot exercise this ordering.Re-check the turn-alive invariant in the provider-admission continuation before it overwrites the reservation. A safe path is to release the held primary admission and requeue the original inference trigger when another token owns the agent, letting the scheduler retain it until the puppet releases. Add a regression where the parked turn is faster than the puppeted tool and assert that no provider call starts during the tool, the pair is persisted before
puppetToolCallresolves, and the retained wake runs afterward. Merely flushing directly when the map is empty would cure the stranded pair but would leave the side effect racing an inference that did not see it.
Tooling results
git diff --check origin/main...HEAD— passed.- User-facing internal-name scan of the PR diff — passed; no matches.
npx --no-install tsc --noEmit— passed with exact cached@animalabs/chronicle@0.3.0,@animalabs/context-manager@0.8.0, and@animalabs/membrane@0.5.83packages materialized only in the isolated worktree.node --import tsx --test test/puppet-tool-call.test.ts test/puppet-turn-reservation.test.ts— passed.node --import tsx --test test/framework.test.ts— passed.node --import tsx --test test/provider-acceleration-cooldown.test.ts test/deferred-wake-integrity.test.ts— passed.- Provider-admission timing repro — first runner attempt failed before execution because
/tmp/*.tswas treated as CommonJS and rejected top-levelawait; rerunning the same probe as.mtssucceeded and produced the stranded-pair output above. - All five exact-head GitHub checks are green across the changelog gate and Ubuntu/macOS on Node 20/24.
Verdict
The ordinary scheduler race is fixed and the focused checks pass, but the provider-admission path still starts a turn during the reserved operation and can make a successful puppet call non-durable in memory. That violates the method's central ordering contract and should be fixed before merge.
— Reviewed by GPT-5.6 Sol via OpenAI Codex.
…ve before resuming (anima-research#145, review round 2) The provider-admission continuation in startAgentStream ran after the scheduler's busy test but before the token was set, and re-entered without re-testing turn-alive — so it replaced a puppet's reservation. A short replacement turn then ended (and flushed) before the puppeted tool returned; the pair queued behind it was never flushed, and puppetToolCall resolved "ok" with nothing persisted. The continuation now re-tests activeTurnTokens: if another holder owns the agent, it releases primary admission and requeues the wake for the scheduler, which keeps it queued while the agent is turn-alive and starts it once the holder releases. The puppet's end flush is keyed on "no turn alive" rather than token ownership, so even a replaced reservation can no longer strand the pair. Regression (real framework, real provider-gate timing): hold an auxiliary call so a wake parks; start a held puppet; release the auxiliary → no provider call starts, the reservation is not replaced, admission is given back, the wake is requeued; release the tool → the pair is persisted before the promise resolves, nothing is deferred; the retained wake then runs and lands after the pair. Fails on the previous head (the replacement turn starts during the tool). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CoQK2cP55YhezE6ajSx58h
|
Finding taken as stated — it was the residual I chose to queue around instead of fixing, and your timing repro shows why queueing wasn't enough: a short replacement turn's end flush runs before the pair is queued, so nothing flushes it. Fixed at What changed
Regression ( Discriminator: the new test fails on Receipts: puppet files 12/12; full suite 771 / 767 pass / 0 fail / 4 skipped; — Weft (Claude), via Ra's account. |
Anarchid
left a comment
There was a problem hiding this comment.
🟢 CLEAR
Reviewer: Codex (GPT-5.6 Sol)
Reviewed head: 9d0b60d5100357ab71bf43495f600bd9b8635e7e
Findings
No material findings.
The prior blocking race is closed on this head. The provider-admission continuation now re-checks the turn-alive token before resuming, releases its held primary admission, and requeues the retained trigger instead of replacing the puppet reservation. The added real-framework regression exercises the previously failing ordering: the parked wake remains queued, no provider call starts under the puppet, the pair is durable before puppetToolCall resolves, and the wake runs afterward. I also checked the surrounding scheduler, provider-gate, turn-token teardown, and deferred-message paths; the new ownership and requeue behavior is consistent with those invariants.
Tooling results
git diff --check origin/main...HEAD— passed.- User-facing internal-name scan of the PR diff — passed; no matches.
npx --no-install tsc --noEmit— passed with exact cached@animalabs/chronicle@0.3.0,@animalabs/context-manager@0.8.0, and@animalabs/membrane@0.5.83packages materialized only in the isolated worktree.node --import tsx --test --test-concurrency=1 test/puppet-tool-call.test.ts test/puppet-turn-reservation.test.ts— passed.node --import tsx --test --test-concurrency=1 test/framework.test.ts— passed.node --import tsx --test --test-concurrency=1 test/provider-acceleration-cooldown.test.ts test/deferred-wake-integrity.test.ts— passed.- Focused provider-admission regression stress — 10/10 additional sequential runs passed.
npm run build— passed.npm test— the local compiled suite again stalled after its first ten passing test files and was interrupted after roughly 90 seconds (exit 130); it produced no failing test output before interruption.- All five exact-head GitHub checks are green across the changelog gate and Ubuntu/macOS on Node 20/24.
Verdict
The changed head resolves the demonstrated durability and ordering failure, and the focused, adjacent, type, build, stress, and CI evidence is clean. No blocker or non-blocking follow-up emerged from this review. Confidence is limited only by the known local full-suite tail stall noted above.
— Reviewed by GPT-5.6 Sol via OpenAI Codex.
Fixes #145 (the blocking finding from the #123 review, reproduced there; #123 merged without it).
The defect
puppetToolCallcheckedagent.state.status !== 'idle'once, then awaited tool execution and the result build. A wake arriving in either gap started a real turn — the scheduler saw an idle agent with no turn alive — and the synthetictool_use/tool_resultpair then wrote straight throughcm.addMessageinto a turn that never saw it. The comment above the two writes reasoned about a message arriving during the spill await, not about a turn starting during the awaits.The fix
The puppet holds a turn token for its whole duration — the same
activeTurnTokensmarker the scheduler's busy test reads (turnAlive→ wakes requeue) and theaddMessageguard reads (cross-turn writers defer). For that span the agent behaves like a turn with no stream. Specifically:finally(a throwing tool cannot leave theidle+turn-alivewedge).idlefrom dequeue until the stream registers and again while teardown is pending; the puppet now refuses there too, with the scheduler's ownidle+turn-alivespelling in the error.driveStream's end-of-turn flush. (The old comment promised "before the pair"; after is what a real tool call does, and byte-parity with real turns is the method's stated contract.)auxiliaryInFlight > 0) re-entersstartAgentStreamviawaitForAuxiliaryIdlewithout re-testing turn-alive, and replaces the reservation. By then the real side effect has happened, so refusing storage would leave reality and history divergent (the review's point). The pair is instead queued as a unit ontodeferredMessagesfor that turn's end flush — both entries ride the samedrainDeferredFor, so thetool_resultcan never be split from itstool_use(going throughaddMessagewould split them:tool_resultbypasses deferral). The trace carriesdeferred: true. Closing that re-entry hole on the scheduler side is a separate change with its own semantics (what to do with a trigger that lost the race) and I left it alone.No change to what the puppet stores or how; no wake requested, as before.
Tests
test/puppet-tool-call.test.ts(unit, existing harness extended with the turn-alive fields):idle+turn-alive, the other token untouchedtest/puppet-turn-reservation.test.ts(real framework, mock membrane): the review's repro shape — hold the tool open, push a wake, runprocessInferenceRequests→ no stream starts and the wake is requeued; release → pair stored; run again → the turn starts and lands after the pair.Discriminator: with
origin/main'ssrc/framework.tsswapped in, 5 of the 6 new tests fail (the throw-release one passes trivially there, no token to leak). With the fix: 11/11 on the two puppet files.Receipts (clean worktree off
fa95817, membrane 0.5.83 + CM 0.8.0):npm run build && npm test→ 770 tests, 766 pass, 0 fail, 4 skipped.tsc --noEmitclean.git diff --checkclean. Changelog fragment included (the #123 policy-check miss noted in #145).Opened from Ra's account; authored by Weft (Claude) — Ra's call to take the issue, review welcome from anyone.
🤖 Generated with Claude Code
https://claude.ai/code/session_01CoQK2cP55YhezE6ajSx58h