10 KiB
Orphan-sweep barrier semantics (gh-11)
Ticket: jjuraszek/pi-condense#11.
Partially supersedes doc/specs/2026-08-12-toolcall-id-collisions.md - the "C. Orphan sweep" section's barrier rule and its claim that C covers the stopReason: error|aborted orphan path (it does not - see Out of scope); its per-turn open-set tracking, no-op invariant, and diagnostics stay authoritative.
Problem
sweepOrphanToolResults (src/orphan-sweep.ts) is the last phase of pruneMessages and owns the structural post-condition: never hand the provider a toolResult whose tool_use is gone. Its open-call set is replaced only on role === "assistant"; every other non-toolResult role is silent pass-through.
Downstream, the barrier definition is different. convertToLlm (pi-coding-agent/dist/core/messages.js, wired into the agent via core/sdk.js; pi-agent-core/dist/harness/messages.js:57-96 is an equivalent duplicate, cited by the ticket) maps custom, branchSummary, compactionSummary, bashExecution to role: "user" (dropping bashExecution{excludeFromContext:true} and unknown roles). transformMessages (pi-ai/dist/api/transform-messages.js) flushes insertSyntheticToolResults() on both assistant and user boundaries.
So assistant(toolCall X) / custom(...) / toolResult(X) passes the sweep clean, but at the provider boundary pi-ai emits a synthetic {isError: true, "No result provided"} for X at the barrier and then still pushes the real result - a duplicate tool_use_id. Anthropic 400s permanently on that branch; the shape is unrepairable by pi-ai (it repairs orphan calls, not orphan results). Measured occurrence: pi-cohort spliced a subagent_control_notice mid-cycle (upstream cause: jjuraszek/pi-cohort#7); the session survived ~100 assistant turns on kimi-k3 and hard-bricked on the first Claude request. Corpus: 690 mid-cycle barriers across 2170 sessions (687 user, 3 context-prune-summary), of which 0 are followed by a real toolResult for a still-open id - the barrier reset sweeps nothing in today's corpus (the one known bricking instance was hand-repaired out). Rare shape, total failure mode.
This fix is defense-in-depth in pi-condense; it does not gate on, and is not substituted by, pi-cohort#7's steer-delivery fix.
Decision
Any message that is neither assistant nor toolResult is a barrier that resets the open-call set:
if (msg.role !== "assistant" && msg.role !== "toolResult") {
open = new Set();
continue;
}
placed as the fall-through branch in the existing forward pass of src/orphan-sweep.ts (equivalently: an else after the toolResult branch). Everything else is unchanged: per-turn replacement on assistant, single-consume on toolResult, swept-id ordering, and the same-array-reference no-op return (doc/specs/2026-08-04-pruner-noop-serialization.md).
Consequence, accepted: the interleaved real toolResult is swept, and pi-ai injects its repairable synthetic failure for the now-orphaned call. One visible synthetic tool failure instead of a bricked session; structurally the model already saw that synthetic (transformMessages emitted it before the barrier). The fix is retroactive - an already-broken branch un-bricks on the next render with no session surgery.
Deliberate over-sweep, not an exact mirror of convertToLlm: bashExecution{excludeFromContext:true} and unknown roles are dropped by convertToLlm, so strictly they need not be barriers - we treat them as barriers anyway. A role allowlist would have to track the open CustomAgentMessages interface for zero observed benefit (0 bashExecution entries in the corpus). Do not "fix" this into an allowlist.
Rejected alternative: hoisting the barrier message out of the cycle - the hoist index moves as the branch grows (breaks render-is-a-stable-function-of-prefix), reorders a message this extension does not own, and cannot be expressed in the single forward pass.
Change surface
Minimal by explicit user constraint; tests carry the regression guarantee.
src/orphan-sweep.ts- the barrier branch above; update the header comment's "Open-call tracking is PER TURN" paragraph to state the barrier reset. (The "immediately preceding assistant turn" phrasing does not appear in this file - it lives inPRUNING.md:1099,1103,src/pruner.ts:51, andAGENTS.md:97, handled in items 4 and 6.)src/test-support.ts-expectNoOrphanToolResultsgets the identical barrier reset. It copies the sweep's own orphan rule, over-sweep included (decision: do NOT delegate tosweepOrphanToolResults; that would make sweep tests tautological). One definition of "orphan", two lockstep call sites.src/orphan-sweep.test.ts- invert the line-63 test ("non-assistant messages between a call and its result do not close the open set") plus the new tests below. Add a minimalbashExecution-role fixture helper; a bare{role: "bashExecution"}message is sufficient - the sweep never readsexcludeFromContext, and plumbing it would imply a distinction the barrier deliberately ignores.PRUNING.md(Orphan Sweep section, ~lines 1095-1107 - re-verify at implementation time) - the barrier rule, described as tracking the post-convertToLlmuser/assistantflush points ofinsertSyntheticToolResultsplus the acceptedexcludeFromContext/unknown-role over-sweep (not an exact mirror), and the trade-off: a mid-cycle foreign message now costs one tool output and converts an unrepairable provider shape into pi-ai's repairable one. Fix the "immediately preceding assistant turn" phrasing at lines 1099 and 1103.doc/specs/2026-08-12-toolcall-id-collisions.md- supersession banner (per the marking convention): scope"C. Orphan sweep" section: barrier rule and stopReason error|aborted coverage claim. Already applied in this worktree; ships with the spec commit - do not add a second banner.- Comment-only touch-ups where the old rule is restated:
src/pruner.ts:51(Phase 4 JSDoc) andAGENTS.md:97(layout-table row fororphan-sweep.ts).
No behavior changes to src/pruner.ts (Phase 4 wiring, diagnostics, and the orphan-sweep diagnostic emission are untouched), no signature changes, no new modules.
Testing
TDD. Exactly one existing test goes red before the change (orphan-sweep.test.ts:63, verified by the roaster: patch applied -> 466 pass / 1 fail); new tests are written red-first. Bun describe/test/expect with the file's small message-factory style.
- AC1 - parametrized over
[custom, user, branchSummary, compactionSummary, bashExecution]plus one unknown role (e.g.{role: "future-role"}) so a role-allowlist implementation cannot pass:assistant(toolCall X) / <role> / toolResult(X)sweeps X and reports it insweptIds. The line-63 test is inverted to this. - AC2 - multi-call turn
assistant(X, Y) / toolResult(X) / custom / toolResult(Y)sweeps only Y (barrier clears the remainder of the open set;toolResultitself is not a barrier - keep the existing consecutive-results control test). - AC3 -
assistant(toolCall X) / toolResult(X) / customis untouched and returns the same array reference. - AC4 - pi-condense's own output never trips the barrier, via the existing
pruneWithZeroSweepAssertion/expectZeroOrphanSweephelpers (src/test-support.ts:31,47). Shape (a) - a compressed-chain fixture whose range contains a third-partycustommessage - already exists:chain-range-prune.test.ts:789-793("a third-party custom message inside a range survives"), driven throughexpectZeroOrphanSweepat:862; it must stay green, no new fixture. Shape (b) - acontext-prune-summaryfollowing a completed cycle sweeps zero - is genuinely missing and is the only new AC4 fixture; extend the nearest suite (chain-range-prune.test.tszero-sweep table orpruner.test.ts:941-1033) rather than authoring a fresh harness. Structural reason the invariant holds: summaries are delivered viadeliverAs: "steer"or atturn_end/final-assistantmessage_end, andapplyChainCompressionsinserts its synthetic strictly after auser-rolestartIndexand drops every in-rangeassistant/toolResult- no partial cycle survives to be split. - AC5 - one red-first test that directly exercises the helper's barrier reset:
expectNoOrphanToolResults([assistant(X), custom, toolResult(X)])must throw (today it passes - the helper still uses the pre-fix rule, and none of its 9 existing call sites acrosschain-range-prune.test.ts,range-compression.integration.test.ts,id-collision.integration.test.tscontains a mid-cycle foreign message, so "staying green" alone proves nothing). Those 9 call sites additionally stay green. - AC6 -
bun test src/green (baseline 467 pass / 0 fail).
Constraint: do not value-import or execute pi-ai's real transformMessages in the suite - it is reachable only via the ./api/* subpath and nothing else in this repo value-imports pi-ai; wiring it in would let unrelated pi-ai version bumps red this repo's CI. The end-to-end evidence (fixture -> real applyChainCompressions -> sweep -> real convertToLlm -> real transformMessages) is roast-time manual verification, not automated.
Out of scope
transformMessagesskipsstopReason: "error" | "aborted"assistant messages before recording their tool calls, so their real tool results become provider-side orphans the sweep (before and after this fix) keeps. 0 corpus occurrences; no code. The predecessor spec claimed the sweep covers this path; the supersession banner's scope explicitly includes that claim.- pi-cohort#7 (upstream steer-delivery fix) - separate repo, separate PR, no code dependency either way.
AGENTS.mdground-truth pointer citespi-ai/dist/providers/transform-messages.js; the actual path ispi-ai/dist/api/transform-messages.js. Known one-liner, not part of this ticket.
Documentation impact
- Feature / user-facing docs introduced: none
- Materially amended existing docs:
PRUNING.md(Orphan Sweep section - barrier rule, rationale, over-sweep trade-off) - Derived / memory docs invalidated:
doc/specs/2026-08-12-toolcall-id-collisions.md(supersession banner, section C barrier rule only)
Open questions
None.