fix(spawner): recover a timed-out delegate's thinking/tool_call work, not only its text - #298
Conversation
… not only its text
The partial-result accumulator collected `content_block:end` payloads where
`block["type"] == "text"` and nothing else. Wired correctly, tested on both
sides of the cross-repo contract, and structurally incapable of ever firing on
a real workload.
Lane k64 measured 18 delegate legs across 7 runs: a leg emits AT MOST ONE
`text` block, and it lands in the final 0.19-0.72 s (mean 0.331 s) of a leg
lasting 5.4-222.0 s. Everything before it is `thinking` (1-25 blocks/leg) and
`tool_call` (0-5), both invisible to the filter. So a delegate killed by a
per-delegate timeout had, by construction, accumulated nothing. The one real
timeout k64 observed had done 10 thinking blocks, 45 tool calls and 11 provider
responses -- and correctly returned `partial_available: false`, because there
was a great deal of work and no *text*.
Widen the accumulator to `thinking` and `tool_call`, in a SEPARATE channel used
only when no assistant text exists at all:
* text present -> exactly today's record, field for field
(`source: spawn-accumulator`)
* no text -> a labelled reasoning + tool-trace payload
(`source: spawn-accumulator:reasoning`)
* nothing at all -> None, unchanged
Block shapes are measured, not assumed, from k64's captures (236 thinking, 53
tool_call, 38 text blocks): a `thinking` block carries its reasoning under
`text`, and a `tool_call`'s arguments live under `input`, not `arguments`.
HONESTY. amplifier-foundation f42f48c picks its guidance string from
`bool(text)` alone, and that string says the partial "is unfinished work
salvaged from the agent mid-flight -- it has NOT been checked, concluded, or
self-reviewed". True of assistant prose; an overclaim for raw thinking, which
was never addressed to a reader at all. foundation is a different repo and this
change does not cross that boundary, so honesty is carried the two ways the
producer owns: `partial_source` distinguishes the kinds without parsing prose,
and the payload labels itself at head AND tail -- the tail because the consumer
truncates to the LAST `partial_max_chars` (default 20,000), which 25 thinking
blocks routinely exceed. The foundation-side change is named in the DONE-NOTE
and deliberately not made here.
REACHABILITY, recomputed from k64's own captures ($0, no new runs, same 18
legs): legs that could ever recover anything 16/18 -> 18/18; recoverable share
of a leg 0.05% mean -> 82.2% mean. The two zero-text legs, previously
unrecoverable by construction, become recoverable for ~97% of their duration.
The head of a leg stays dark: the first evidence block lands 3.15-41.03 s in.
Normal completions are byte-identical -- shown, not asserted: the serialized
result, the registry state, and the text-case partial all diff clean against
the parent producer; exactly one key moves, and it is the fix.
Tests: 11 new (5 of which fail on the parent producer), full suite 1670 passed.
The cross-repo round trip is extended with the case that actually occurs -- a
fixture sub-session emitting NO text block -- and now drives the REAL
accumulator instead of hand-building a `{"chunks": [...]}` record, which
bypassed the very filter that was the defect.
Unblocks model_performance-bnj (k64's residue purchase, owner-gated).
Refs: model_performance-eem
Manager verification — FIX. No guidance-string leak, and the no-text guard is correct. Merging.Head Gate 1 — the guidance string does NOT leak reasoning textI checked this by reading the mechanism, not the claim. foundation's guidance string is a fixed constant selected on
The tail label is the detail that shows this was thought through: foundation truncates to the last
Gate 4 — the no-text guard, read rather than trustedchunks = list(record.get("chunks") or ())
if chunks: return {... "source": "spawn-accumulator"} # text wins, pre-widening record
reasoning, tool_calls = ...
if not reasoning and not tool_calls: return None # never manufactures a partialText always wins; the reasoning channel is reachable only when no assistant text exists; an empty accumulator still reads Gates 2 & 3Fail-before against current main: Two things I want on the recordThe disclosed incident. The first cross-repo run reported the fail-before arm passing. Cause: the overlay was on Option (b) declined with a reason, not skipped: Measured effect on k64's own 18 legs: recoverable window 0.05% → 82.2% of a leg, and the 2 legs that emitted no text at all — previously unrecoverable by construction — become recoverable for ~97% of their duration. Squash + |
The defect
session_spawner._open_partial._accumulate_partialcollectedcontent_block:endpayloads whereblock["type"] == "text"and nothing else. The wiring is correct (session.partialis registered), the consumer is correct (amplifier-foundationf42f48c), and the feature was still structurally incapable of firing on a real workload.Lane k64 measured it across 18 delegate legs in 7 runs:
textblock;thinking(1–25 blocks/leg) andtool_call(0–5), both invisible to the filter.So a delegate killed by a per-delegate timeout had, by construction, accumulated nothing. The one real timeout k64 observed had done 10 thinking blocks, 45 tool calls and 11 provider responses, left a 488 KB
transcript.jsonl, and correctly returnedpartial_available: false— a great deal of work, and no text.This is not a wiring failure and nothing here "fixes" the wiring. foundation #353 (
f42f48c) and app-cli #297 (26e5f10) are both correct. The cross-repo round-trip test passed only because its fixture emitted text blocks, which real legs do not until they finish.The fix
Widen the accumulator to
thinkingandtool_call, in a separate channel used only when no assistant text exists at all:sourcespawn-accumulatorspawn-accumulator:reasoningNone(unchanged)Block shapes are measured, not assumed, from k64's captures (236
thinking, 53tool_call, 38textblocks inspected): athinkingblock carries its reasoning undertext; atool_call's arguments live underinput, notarguments. Guessingargumentswould have produced a silently empty trace.Retained reasoning is bounded at 100,000 chars, oldest-first (chosen, not measured — 5× the consumer's forward cap).
Option (b), reading the child's
transcript.jsonl, was considered and declined with a reason: it is checkpointed onprovider:requestand throttled to one write per 30 s, so it lags the live stream by up to a full window and adds filesystem I/O plus aSessionStorelayout dependency — to reach a result the accumulator already reaches on 18/18 legs. It stays the right route if tool results are ever wanted.Is
partial_available: truereachable on a REAL leg shape? Yes — measuredRecomputed from k64's own captures, $0, no new runs, same 18 legs (
docs/lanes/eem-partial-accumulator-widen/evidence/07-real-leg-reachability.md):The two zero-text legs — previously unrecoverable by construction — become recoverable for ~97% of their duration.
Honest limit: the first evidence block lands 3.15–41.03 s into a leg, so a timeout shorter than that still recovers nothing, correctly. Worst case measured (
bcb7ec94: first evidence at 35.54 s of a 41.6 s leg) leaves 85% of that leg dark. A ~1,700× wider window, not a guarantee.The guidance string — what this PR does NOT do
foundation
f42f48cpicks its guidance frombool(text)alone, and that string says the partial "is unfinished work salvaged from the agent mid-flight — it has NOT been checked, concluded, or self-reviewed". True of assistant prose; an overclaim for raw thinking, which was never addressed to a reader at all.foundation is a different repo and this PR stops at that boundary. What the producer owns, it does:
partial_sourcedistinguishes the two kinds, so a consumer can branch without parsing prose;partial_max_chars(default 20,000), which 25 thinking blocks routinely exceed, so a head-only label is lost on exactly the long partials that need it. A test pins the footer's survival through a >20,000-char tail cut.Reported, for a separate foundation PR: in
_partial_output_fields, select the guidance on the kind of partial (sourceending in:reasoning) rather than only onbool(text). Until then,test_guidance_string_for_the_reasoning_case_is_foundations_to_changeasserts today's real behaviour, so the change fails loudly instead of drifting.Normal completions byte-identical — shown, not asserted
evidence/03-byte-identity.txtdrives the same probe against acp -rLcopy of the parent producer and of this branch and diffs the canonical JSON:Exactly one key moves.
Tests
Fail-before / pass-after, same file, both producers (
evidence/01-fail-before.txt):Failing on the parent:
test_leg_with_no_text_block_still_carries_a_partial,test_measured_k64_timeout_shape_is_recoverable,test_tool_calls_alone_are_recoverable,test_recovered_reasoning_names_itself_as_reasoning_not_draft_output,test_the_label_survives_the_consumers_tail_truncation.Cross-repo round trip, run per
ai-notes/w3-delegate-timeout/APPLY.mdagainst overlaidcp -rLcopies and foundationf42f48c(blobeaee8970), mutating neither repo (evidence/04-roundtrip.txt):Extended with the case that actually occurs — a fixture sub-session that emits no text block — and it now drives the real accumulator. The original check called
_seal_partialwith a hand-built{"chunks": [...]}record, bypassing_open_partialentirely, i.e. never executing the filter that was the defect. That is the second reason it could not fail.Full suite (
evidence/05-full-suite.txt):Integration job (
evidence/06-integration.txt):13 passed.ruff check: clean.Disclosure — a false green, caught
The first cross-repo run reported 5 passed on the parent producer, i.e. the fail-before arm "passing". Cause: the overlay was on
PYTHONPATH, but the run was launched from the checkout, andsys.path[0]is the CWD — so both arms imported the working tree. Caught by asking the interpreter which file it had loaded rather than trusting the exit code; nothing was published from that run. Every arm now printssession_spawner.__file__andwidened: True/Falsefirst, and runs from/tmp. Same failure shape as the item itself, one layer out.Spend
$0.00 of a $0.00 authority (
0 runs × 0 arms × $0 / 1.00 = $0.00). No runs, no container, no DTU, no API calls. Every number above is a reanalysis of k64's already-purchased captures.This unblocks
model_performance-bnj— k64's $45.30 residue purchase, owner-gated, which buys nothing until this lands: a timeout now exercises the partial path on a real leg shape, so funded runs can no longer come back PARTIAL-PATH-NOT-EXERCISED. Its arithmetic should be re-checked against the then-current price before it is funded.Refs:
model_performance-eem. Full note:docs/lanes/eem-partial-accumulator-widen/DONE-NOTE.md.