Skip to content

fix(spawner): recover a timed-out delegate's thinking/tool_call work, not only its text - #298

Merged
Brian Krabach (bkrabach) merged 1 commit into
mainfrom
lane/eem-partial-accumulator-widen
Sep 3, 2026
Merged

fix(spawner): recover a timed-out delegate's thinking/tool_call work, not only its text#298
Brian Krabach (bkrabach) merged 1 commit into
mainfrom
lane/eem-partial-accumulator-widen

Conversation

@bkrabach

Copy link
Copy Markdown
Collaborator

The defect

session_spawner._open_partial._accumulate_partial collected content_block:end payloads where block["type"] == "text" and nothing else. The wiring is correct (session.partial is registered), the consumer is correct (amplifier-foundation f42f48c), and the feature was still structurally incapable of firing on a real workload.

Lane k64 measured it across 18 delegate legs in 7 runs:

  • a leg emits at most one text block;
  • it lands in the final 0.19–0.72 s (mean 0.331 s) of a leg lasting 5.4–222.0 s — about 0.5% of the leg;
  • 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, left a 488 KB transcript.jsonl, and correctly returned partial_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 thinking and tool_call, in a separate channel used only when no assistant text exists at all:

leg produced returns source
assistant text exactly today's record, field for field spawn-accumulator
no text, but thinking / tool calls a labelled reasoning + tool-trace payload 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 inspected): a thinking block carries its reasoning under text; a tool_call's arguments live under input, not arguments. Guessing arguments would 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 on provider:request and 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 a SessionStore layout 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: true reachable on a REAL leg shape? Yes — measured

Recomputed from k64's own captures, $0, no new runs, same 18 legs (docs/lanes/eem-partial-accumulator-widen/evidence/07-real-leg-reachability.md):

before (text only) after (widened)
legs that could ever recover anything 16/18 18/18
recoverable share of a leg, mean 0.05% 82.2%
range 0.00–0.24% 0.3–98.6%

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 f42f48c picks its guidance 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 PR stops at that boundary. What the producer owns, it does:

  1. partial_source distinguishes the two kinds, so a consumer can branch without parsing prose;
  2. 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, 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 (source ending in :reasoning) rather than only on bool(text). Until then, test_guidance_string_for_the_reasoning_case_is_foundations_to_change asserts today's real behaviour, so the change fails loudly instead of drifting.

Normal completions byte-identical — shown, not asserted

evidence/03-byte-identity.txt drives the same probe against a cp -rL copy of the parent producer and of this branch and diffs the canonical JSON:

IDENTICAL  normal_completion_result
IDENTICAL  normal_completion_registry_after
IDENTICAL  normal_completion_partial
IDENTICAL  timeout_with_text_partial      <- text case: same bytes => same guidance string
CHANGED    timeout_no_text_partial        <- null -> a record. This is the fix.

Exactly one key moves.

Tests

Fail-before / pass-after, same file, both producers (evidence/01-fail-before.txt):

A. PARENT PRODUCER (26e5f10)      5 failed, 18 passed
B. PATCHED PRODUCER (this branch)          23 passed

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.md against overlaid cp -rL copies and foundation f42f48c (blob eaee8970), mutating neither repo (evidence/04-roundtrip.txt):

A parent  -> /tmp/eem-parent/...  | widened: False    2 failed, 3 passed
B patched -> /tmp/eem-patched/... | widened: True             5 passed

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_partial with a hand-built {"chunks": [...]} record, bypassing _open_partial entirely, 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):

1670 passed, 1 skipped, 13 deselected, 1 xfailed in 7.96s

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, and sys.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 prints session_spawner.__file__ and widened: True/False first, 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.

… 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
@bkrabach
Brian Krabach (bkrabach) marked this pull request as ready for review September 3, 2026 13:21
@bkrabach

Copy link
Copy Markdown
Collaborator Author

Manager verification — FIX. No guidance-string leak, and the no-text guard is correct. Merging.

Head 7d137a0, base 26e5f101 = current origin/main (#297 in). CI green on all 9 jobs.

Gate 1 — the guidance string does NOT leak reasoning text

I checked this by reading the mechanism, not the claim. foundation's guidance string is a fixed constant selected on bool(text); it never embeds the partial's content (that goes in partial_response). So raw reasoning cannot end up inside the guidance string. The real risk was the other half — foundation applying "unfinished work salvaged from the agent mid-flight" to private reasoning — and the producer carries that honestly at its own boundary:

  • source: "spawn-accumulator:reasoning", distinct from "spawn-accumulator", so a consumer branches without parsing prose;
  • the payload self-labels at head AND tail: "[RECOVERED FROM AN UNFINISHED DELEGATE -- NOT DRAFT OUTPUT] … it is the agent's own private reasoning and the trace of the tool calls it made … never as a partial answer."

The tail label is the detail that shows this was thought through: foundation truncates to the last partial_max_chars, so a head-only label is lost on exactly the long partials that need it.

test_guidance_string_is_unchanged_for_the_text_case proves byte-identity for the text case, and test_guidance_string_for_the_reasoning_case_is_foundations_to_change pins the remaining foundation-side improvement without crossing the repo boundary. Correct call.

Gate 4 — the no-text guard, read rather than trusted

chunks = 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 partial

Text always wins; the reasoning channel is reachable only when no assistant text exists; an empty accumulator still reads None. And the diff does not touch the _discard_partial / _seal_partial call sites, so a normally-completing delegate is unaffected — confirmed by the lane's own byte-identity evidence: normal_completion_result, normal_completion_registry_after, normal_completion_partial and timeout_with_text_partial all IDENTICAL, with exactly one key changed (timeout_no_text_partial: null -> a record), which is the fix.

Gates 2 & 3

Fail-before against current main: 5 failed, 18 passed — the no-text leg, the measured k64 timeout shape, tool-calls-alone, the self-labelling assertion, and the tail-truncation survival. Full suite 1670 passed (main baseline 1659 + 11 new). The no-text round-trip fixture is present and was run against overlaid cp -rL copies at foundation f42f48c.

Two things I want on the record

The disclosed incident. The first cross-repo run reported the fail-before arm passing. Cause: the overlay was on PYTHONPATH but the run launched from the checkout, and sys.path[0] is the CWD, so both arms imported the working tree. The lane caught it by asking the interpreter which file it had loaded rather than trusting the exit code, published nothing from that run, and every arm now prints session_spawner.__file__ + widened True/False first and runs from /tmp. That is the failure mode that silently invalidates cross-repo evidence, self-reported.

Option (b) declined with a reason, not skipped: transcript.jsonl is checkpointed on provider:request and throttled to one write per 30 s, so it lags the live stream by up to a full window — to reach a result (a) already reaches on 18/18 legs.

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 + --admin per the base-branch policy.

@bkrabach
Brian Krabach (bkrabach) merged commit 8c83a9b into main Sep 3, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants