fix(server): don't call a running sub-agent finished, and keep its steps out of the parent transcript (#725, #727) - #750
Conversation
…eps out of the parent transcript (#725, #727) Two `/messages` payload bugs in `subagents.ts` where the live WebSocket path and the history/rehydration REST path disagreed, and the history path was wrong. #725 cause A — the paired branch stamped a duration onto a LIVE sub-agent. `subagentDurationMs` is the client's finished signal (`useRunningSubagents` drops any card carrying one) and it is derived from a transcript that may still be growing. `attachSubagentFields`' `pending` branch already withheld it for exactly that reason (#622); the `paired` branch below stamped one unconditionally. Since the SDK backgrounds sub-agents, the launching `Task` tool_result pairs within milliseconds while the sub-agent keeps working, so a live sub-agent reached the paired branch as a matter of course — and was dropped from the running bar for the rest of the run, unrecoverably, because a reload re-derives from the same code. The tell was a "final" duration that kept climbing: 9211 → 11296 → 13368. The gate now lives at the source, so both branches are covered by construction: a duration is published only once the sub-agent's own transcript has settled — its last line is a terminal assistant `end_turn`, or it has gone quiet for ten minutes (which keeps an interrupted sub-agent, which never writes an `end_turn`, from claiming "running" forever). The issue proposed keying on a terminal `result` line; that does not work. A sub-agent transcript has none — zero of the 483 real ones measured carried one, so it would have withheld every duration, always. `end_turn` is the marker that actually appears (392 of 483, and mid-file exactly once). #727 — sidechain steps leaked as top-level transcript rows on reload. The live path filters `isSidechainMessage` in five places; the history path had no equivalent, because `@herdctl/core` treats `isSidechain` as a whole-SESSION property and drops the per-line marker from the messages it returns. Sidechain lines written into a main transcript therefore came back out of `/messages` as first-class rows, rendering as siblings of the Task card instead of inside it. The markers are recovered from the raw transcript (on `isSidechain` OR `parent_tool_use_id` — the writers disagree about which they stamp), in the pass that already scans it, and the rows are dropped before anything counts positions. Both file-order joins downstream — the sub-agent join and the per-tool detail join — skip the same lines, since a scan that keeps a row the message list has dropped shifts the whole join by one. A transcript that is sidechain top to bottom is a sub-agent's own session file, so a drop that would empty it is refused. Tests: a rehydration arm on the existing sidechain integration test (it asserted the invariant only on the live path, which is precisely why this shipped), a new integration test driving `/messages` against live / settled / stale sidecars, and unit coverage for the detail-join alignment. All fail on main. Closes #727. #725 stays OPEN for cause B (the client-side `armedRef` gate). Co-Authored-By: Claude <noreply@anthropic.com>
Deploying paddock with
|
| Latest commit: |
840910c
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://3a1f56d9.paddock-7u2.pages.dev |
| Branch Preview URL: | https://fix-subagent-messages-payloa.paddock-7u2.pages.dev |
Reviewed — and two corrections to me that I want on the record1. The fix I suggested in the brief would have broken things. I said to gate on "no terminal 2. #727 is very likely fake-only, and that is my error, not yours. Your measurement — 0 of 1752 real main transcripts containing inline I agree with landing it as defensive: the invariant should hold on both paths by construction rather than by accident of the writer's layout, the The pattern this makes visibleThat is now the fourth finding in this workstream shaped by the harness rather than the product: #730's mechanism was batch-only; mid-turn fork/revert transcripts are invalid only to the real Messages API; #725 needed a state the fake couldn't produce; and now #727 reproduces only because the fake inlines what real CC separates. My audit treated "reproduced on a live instance" as equivalent to "users hit this", and for anything touching transcript shape that inference doesn't hold — the on-disk layout is Claude Code's, not Paddock's. Your point that the shape is the writer's rather than the runtime's is the sharper version of the drive-mode caveat I gave you, and I'm adopting it. On the fix itselfThe three details beyond the obvious filter are the valuable part: accepting both markers (filtering on Also right to change the one existing unit test whose fixture modelled a live sub-agent while asserting a finished one's duration — that fixture was asserting the bug. All five checks green, both new tests confirmed failing on |
… releases (#762) * chore(docs): start the 0.56-0.66 What's New pass * docs(website): split What's New, archiving 0.52 and older The page had grown to 1,150 lines and 30 releases, which is past the point where anyone scrolls it. Everything from 0.52 back to 0.29 moves verbatim to a new 'What's New — earlier releases' page; the main page keeps 0.53 and newer. Entries are moved unchanged — the archive is append-only and its entries are never rewritten, which is the same promise the page already makes about describing each release as it shipped. Image paths are unchanged because both pages sit in the same content directory. Adds the sidebar entry (Starlight does not auto-discover, so an unlisted page is invisible), a cross-link in both directions, and a note in the maintainer footer describing how to trim next time. * docs(website): What's New for 0.63 through 0.66.1 Five new entries. 0.66.0 leads on the Config screen redesign (#740) — the largest UX change in the range and the one a user meets every time — with a screenshot of the rail, live filter, env-override legend and the dirty dot. Also carries the breaking default-port move 4000 -> 7233 (#741) with the operator action spelled out, schemaVersion (#735), the import -> adopt rename (#748), the destructive-op interlock (#743) and the sub-agent bar fixes (#750). 0.66.1 is the queued-message release (#751): three silent-loss paths and the Stop-hands-it-back decision. 0.65 is promote_project (#668). 0.64 leads on path:/managed replacing repoBacked (#709) plus the Changes tab finally reporting on the checkout (#597). 0.63 is host plugin inheritance and MCP fidelity (#705), including the batch-mode argv credential disclosure (#702). Two claims deliberately qualified against the changelog's framing: the delete-then-send fix (#742) is called out as batch-only, since the default session mode was never affected; and the sub-agent bar is not described as absolute, because the settle heuristic can hold an interrupted sub-agent for ten minutes. Also corrects a live error: the 0.59.1-0.60 entry claimed in bold that --here leaves your ~/.claude alone. That was false for exactly those versions — --here linked ~/.claude/projects/<dir> at the workspace, and one report lost 30 transcripts to it before 0.61.1 stopped it. Replaced with a caution box pointing forward. * docs(website): a recorded demo of the 0.66 Config screen The filter and the rail only read in motion, so the entry gets both a still (rail counts, env chips, legend, dirty dot) and a 22s clip: filter by env-var name, Modified-only lens, then a rail jump that scrolls rather than swapping tabs. Shot on a v0.66.1 rig with no credentials. Two leaks were caught by looking at the frames rather than trusting the launcher: PADDOCK_GIT_AUTHOR_EMAIL was inherited and put a real internal address in the Git identity section, and the Advanced section printed the rig's scratch paths. The launcher is now an ALLOW-list — it drops every inherited PADDOCK_* and sets only what it needs — which is the fix issue #567 argues for, and the frames now show stock defaults and a ~/.paddock install path. * docs(website): backfill 0.56, 0.57, 0.58 and 0.61.0 These four were never written up — a gap in the middle of the page, not a tail. 0.61.0 in particular was only reachable by reading the 0.62 entry backwards, where it appears as the thing being removed. 0.61.0 leads on Paddock taking ownership of its Claude home, with a caution box narrating the whiplash: 0.61.0 isolates the home, 0.61.1 un-isolates the CLI because an isolated home cannot see a macOS Keychain login, and 0.62 replaces the mechanism entirely. Without that sentence the three entries read as contradicting each other. 0.58 carries its own caution: everything in it shipped in a CLI that, installed through npm, printed nothing and exited zero, and stayed that way across 0.57 and 0.59.0 until 0.59.1. An entry recommending it without that note would be recommending a broken install. The cause is described only as the run-directly guard, not attributed to a specific PR, because the changelog's own attribution does not line up with the release it shipped in. 0.57 is the environment system prompt, with the audit numbers that justified it and a note that three candidate rules were measured and cut. 0.56 is the npm publish, plus the correction that the claude CLI was never a prerequisite for chats. Also corrects 'several hundred boot log lines' in the 0.59.1-0.60 entry to match the changelog's actual figure (about thirty, down to nine). * docs(website): cut the new What's New entries roughly in half The nine entries added this pass ran to 314 lines. They read as changelog archaeology rather than release notes — explaining how each bug worked before saying what changed, and carrying detail nobody scanning a What's New page needs. Now 180 lines, of which 12 are the image and video markup. Each bullet is one idea in three or four lines: what a user notices first, then only the context that makes it land. What survives unchanged is the material that is genuinely load-bearing — the port change's operator action, the 'tabs partition' argument, the batch-only qualifier on the delete-then- send fix, and the two caution boxes, since those exist to stop a reader believing something false. * docs(website): plainer titles, and another pass on length Titles were making claims rather than saying what changed — 'Nothing you typed while it was busy goes missing', 'A Config screen you can navigate', 'A command-line worth running'. They now name the subject: Queued messages; Config screen, and a new default port; CLI output and flags; Environment system prompt; npm package. Prose down from 168 lines to 137 (originally 302). Mostly by cutting restatement — 0.61.0's four small turn-level fixes were four bullets saying the same shape of thing and are now one. * docs(website): bring the older entries into the same style Retitles every remaining entry to name its subject rather than make a claim, and cuts them to the same length as the new ones. 0.62's title becomes 'Granular host Claude inheritance options' as requested. Five levers instead of one -> Granular host Claude inheritance options Your ~/.claude, left alone and ... -> CLI login, and symlinks into your Claude home One command, on your own history -> npx install, --here, and confirmed adoption Bring your terminal history with you -> Adopting Claude Code CLI chats Claude, not "the keeper" -> The "keeper" rename, and Home's attention feeds Home says what it's holding -> Unread badge on the Home link Lengths: 0.62 115->51, 0.61.1 57->36, 0.59.1-0.60 45->29, 0.55 81->29, 0.54 70->42, 0.53 62->14. The page is 379 lines, down from 1,150. Everything load-bearing is kept: the breaking env-var table, the instructions: own warning, the leftover-symlink check with its shell command, and both caution boxes. All five media assets verified present in the built output. Frontmatter description rewritten — it still described 0.62 as 'five independent levers'. --------- Co-authored-by: HomeLab Agent <homelab-infra@valfenda.net>
Two
/messagespayload bugs inpackages/server/src/subagents.ts, both instances of the same pattern: the live WebSocket path and the history/rehydration REST path disagree, and the history path is wrong.Closes #727. #725 stays OPEN for cause B — the client-side
armedRefgate inuseSubagentActivity.ts, which is assigned elsewhere and blocked on[[BGSUBAGENT]]. Nothing underpackages/web/is touched here.#725 cause A — the paired branch stamped a duration onto a live sub-agent
subagentDurationMsis not decoration. It is the client's finished signal (useRunningSubagentsdrops any card that carries one), and it is computed from the first→last timestamp of a transcript that may still be growing.attachSubagentFields'pendingbranch already knew that and withheld the field (#622). Thepairedbranch immediately below stamped one unconditionally. Because the SDK backgrounds sub-agents, the launchingTasktool_result pairs within milliseconds while the sub-agent keeps working — so a live sub-agent reached the paired branch as a matter of course, was declared finished, and was dropped from the running-sub-agents bar for the rest of the run. A reload did not recover it: it re-derives from the same code. The tell was a "final" duration that kept climbing — 9211 → 11296 → 13368.The gate now lives at the source (
subagentDurations), so both branches are covered by construction. A duration is published only for a sub-agent whose own transcript has settled:end_turn, orend_turn) from claiming "running" forever.The fix the issue proposed does not work
The issue suggested gating on "no terminal
resultline". A sub-agent transcript never has one — zero of the 516 real sidecars on this box carry atype: "result"line, so that rule would have withheld every duration, always, and quietly broken finished cards instead.end_turnis the marker that actually appears. Measured over the 483 parseable sub-agent transcripts in/data/projects/paddock/.chats(2026-07-03 → today, Claude Code 2.0.77–2.1.216, produced by this box under the defaultsessionruntime):assistant/stop_reason: end_turnassistant/stop_reason: null(text only)user(interrupted mid tool_result)type: "result"line, anywhere in the fileend_turnappears mid-file in exactly 1 of 483 files, so it is a strong terminal marker. "Last line is a text-only assistant message" is not — it occurs mid-file 11,315 times across all 483 files (streaming writes text and tool_use as separate entries), which is why the check is specifically onstop_reason.#727 — sidechain steps leaked as top-level transcript rows on reload
The live path filters
isSidechainMessagein five places. The history path had no equivalent, because@herdctl/coretreatsisSidechainas a whole-session property (a sidechain gets its own file) and drops the per-line marker from theChatMessageit returns. Any sidechain line written into a main transcript therefore came back out of/messagesas a first-class row, rendering as a sibling of the Task card rather than inside it.The markers are now recovered from the raw transcript — the only place they survive — in the pass that already scans it, so this costs no extra I/O and short-circuits to the identity for a transcript with no sidechain lines (which is all of them, in practice; see below).
Three things beyond the obvious filter:
isSidechain: trueon the line; the SDK message stream (whatisSidechainMessagereads live, and what the test harness mirrors to disk) carriesparent_tool_use_id. Either is conclusive, soisSidechainLineaccepts both. Filtering onparent_tool_use_idalone — the live path's rule — would have matched the fake and missed every real transcript.attachToolDetailsjoins per-tool detail positionally, and it was collecting sidechainReadcalls. Dropping the messages without also skipping the scan would have shifted every following tool's detail by one — a top-levelReadinheriting the sub-agent's file. Same for a nestedTaska sub-agent launches on its own lane. Both are now excluded, with a unit test that catches the shift.agent-<hex>.jsonl), not a parent with leaked steps. herdctl keeps those out of session discovery, but if one is ever reached directly it should render its content rather than an empty chat, so a drop that would remove every row is refused.You asked me to sanity-check the drive-mode assumption rather than inherit it. These are REST payload-shape bugs downstream of the runtime, so they are drive-mode independent as expected — but for a sharper reason than "downstream of the runtime": the on-disk shape is Claude Code's, not the runtime's, and current CC already splits sidechains out. Measured over 1752 real main transcripts here:
isSidechain: truelines. The 33 files that do are legacy standaloneagent-*.jsonlsidechain session files, not main transcripts.parent_tool_use_idat all.subagents/dir has 0 sidechain lines and 0agentIdlines in the parent.So on real data — under either runtime — the history path was already clean; the
[[SUBAGENT]]fake is what inlines sidechain lines into the parent transcript. I'd call this fix defensive rather than something users are hitting today. It is still worth landing: the invariant should hold on both paths rather than by accident of the writer's layout, the marker mismatch is a live trap for anyone who reaches for the live path's rule, and the positional-join hazard is real either way.Tests
All fail on
main— verified explicitly by checking outorigin/main'ssubagents.ts+tooldetails.tsagainst the new tests:ws-subagent-sidechain.test.tsgains a rehydration arm asserting the same invariant against/messages. That it covered only the live path is precisely why this shipped, so the file now asserts both and its header says so.subagent-live-duration.test.ts(new) drives a realTask-bearing turn, then hand-writes the sidecar in three states with an explicitutimesmtime so "how long has this been quiet" is a fact of the fixture rather than of test timing: live (no duration), settled onend_turn(duration), stale/interrupted (duration).withholds the run time while the sub-agent is still workingcase alongside the existing duration test.One existing unit test changed: its fixture's sub-agent transcript had no
stop_reason, i.e. it modelled a live sub-agent while asserting a finished one's duration. Addingstop_reason: "end_turn"makes the fixture mean what the test says.Full suite green: 157 files, 1978 tests.
🤖 Generated with Claude Code