Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
22 KiB
Work package 09 — LaneSnapshot settled-but-unplaced tools
Status and baseline
- Repository:
earendil-works/pi - Branch at handoff creation:
dev - Baseline commit:
d14d6b22327d545d6a253f932165b63e48d7f9c8 - The user reported the worktree clean immediately before this handoff.
- This document began as an implementation handoff after conversation compaction and now records the implemented design. It is not itself the normative harness specification;
packages/agent/docs/harness.mdremains normative.
Goal
Keep every started or settled-but-unplaced tool call visible in LaneSnapshot.operation.runningTools until its immutable toolResult entry is placed in the transcript.
The intended projection is:
planned → not yet represented in runningTools
effect_pending → runningTools(status: "running")
outcome_ready → runningTools(status: "settled")
completed → transcript toolResult entry
For each call after it becomes presentation-active, runningTools and placed transcript entries must not have a gap or overlap. Placement is a source-prefix flush, not an all-tools barrier. Remove a call from runningTools on its own entry_added, never on turn_end.
Original bug
A call disappeared between real-effect completion and source-ordered tree placement:
packages/agent/src/harness/runtime/reducer.ts:tool_endspliced the call out ofrunningTools.packages/agent/src/harness/runtime/lane.ts:captureLaneSnapshot(),case "tools", projected onlyeffect_pendingcalls and skippedoutcome_readycalls.- The finalized result was already durable at
pendingEntry(resultEntryId), but a fresh/reconnected snapshot could not display it. - It reappeared only after
entry_addedplaced the immutabletoolResultentry.
For a parallel batch [A, B, C], if B settles while A remains pending, B may stay outcome_ready until A is ready. If A is already placed, B can place without waiting for C. Therefore clearing everything at turn_end is wrong: early-placed results would temporarily exist in both transcript and runningTools.
Confirmed current architecture
Mini does not replicate structural object deltas.
packages/coding-agent/src/experimental/mini/worker/lane-service.tssends one initial full snapshot and then forwards individualHarnessEventobjects.packages/coding-agent/src/experimental/mini/tui/session.tsfolds those events throughreduceLaneSnapshot().- Reconnect/rebase fetches another complete snapshot.
tool_updatecurrently carries a complete replacement progress result, not a nested diff.
Implemented durable tool flow in packages/agent/src/harness/runtime/drive/tools.ts:
prepare
→ before_tool
→ intent commit (effect_pending + effective args), then tool_start
→ execute/update/checkpoint
→ after_tool
→ finalize
→ publishToolOutcome staging commit (pendingEntry + outcome_ready), then tool_end
→ materializeReady prefix placement
→ entry_added
All real, immediate synthetic, cancellation, and recovery outcomes converge through publishToolOutcome().
Lane.settleOperation() supports commit-bound events. It commits, publishes process-local state, constructs the event batch, and the public operation awaits delivery. In parallel execution, materializeReady() is scheduled only after the outcome-completion promise resolves. Consequently, tool_end is delivered after staging and before placement.
Agreed event contract change
Do not add tool_result_ready or tool_outcome_ready.
Instead, redefine harness tool_start/tool_end as tool-call processing/result lifecycle events rather than exclusively real external-effect lifecycle events.
Fresh executed call
intent commit
→ tool_start
→ tool_update*
→ execute/finalize
→ TX[pendingEntry + outcome_ready + cleanup]
→ tool_end
→ source-ordered placement
→ entry_added
Fresh synthetic call
TX[pendingEntry + outcome_ready]
→ tool_start
→ tool_end
→ source-ordered placement
→ entry_added
The staging transaction's post-commit event batch contains tool_start followed by tool_end, so a watcher cannot observe either lifecycle event without the authoritative staged state.
Fresh synthetic calls include:
- unknown tool;
- argument preparation or validation failure;
before_tooldenial or invalid replacement arguments;- genuine assistant
length/truncated call handling; - cancellation while still
plannedor after intent but before effect admission.
Recovery
Historical lifecycle events are not replayed.
- A restored
effect_pendingcall is already represented by the initial snapshot. - Safe replay emits recovery-tagged
tool_startfrom the checkpoint-clear commit andtool_endfrom the later outcome-staging commit. - Unsafe interruption synthesis may emit a recovery-tagged
tool_endwithout a newly emittedtool_start; the initial snapshot supplied the running row. - A call already restored as
outcome_readyappears as settled in the initial snapshot and needs no replayed end event before placement.
Meaning of tool_end
After this change, tool_end means:
The complete final tool result is durably staged and the call is
outcome_ready.
It becomes the authoritative reducer transition from running to settled. It must be emitted after the staging commit, not before it.
The old distinction between actually executed and synthetic results was encoded by omitting lifecycle events. There is no in-repo runtime consumer requiring that distinction. If preserving it is desired, discuss adding an explicit field such as execution: "executed" | "synthetic"; this field was discussed but not agreed, so do not add it silently.
LaneSnapshot type
Change LaneSnapshot.operation.runningTools in packages/agent/src/harness/agent-harness.ts to use one result field for both progress and final output. Do not retain a separate partialResult field in the snapshot.
Prefer a discriminated union so invalid combinations are unrepresentable:
type SnapshotTool =
| {
status: "running";
toolCallId: string;
toolName: string;
args: unknown;
result?: AgentToolResult<unknown>; // latest complete progress snapshot
}
| {
status: "settled";
toolCallId: string;
toolName: string;
args: unknown;
result: AgentToolResult<unknown>; // complete finalized result
isError: boolean;
};
The user explicitly agreed to the discriminated union.
tool_update.partialResult may remain named partialResult in the event API; the reducer assigns it to the snapshot row's unified result field.
The current mini transport sends semantic events, not structural deltas, so tool_end still carries the complete final result even if it equals the latest update. Unifying the snapshot field is still the correct state model.
Exact reducer behavior
File: packages/agent/src/harness/runtime/reducer.ts
tool_start
- Resolve the operation with
matchingOperation(snapshot, event.runId). - Upsert by
toolCallId; do not blindly push. - Set
status: "running",toolName,args. - Clear stale settled-only fields if replacing an existing row.
- Preserve no stale final result. A newly started/replayed call may receive later
tool_updatevalues.
Upsert is required because a watch may capture durable effect_pending state before the buffered tool_start event is delivered.
tool_update
- Use
matchingOperation(snapshot, event.runId), notsnapshot.operationdirectly. - Find the matching row.
- For a running row, replace
resultwithevent.partialResult. - Ignore wrong-operation or missing rows.
tool_end
- Resolve with
matchingOperation(snapshot, event.runId). - Find the existing row by batch-local
toolCallId; only one tool batch is presentation-active at a time. - Replace it with
status: "settled", preserving its arguments and usingevent.resultandevent.isError. - This naturally removes the provisional interpretation of the old
result; there is no separatepartialResultto delete. - The finalized result remains displayed until placement.
tool_end does not carry arguments and cannot create a row. Fresh synthetic tool_start and tool_end are emitted together after the staging commit, eliminating the old capture/event gap. Unsafe recovery relies on the initial snapshot's running row.
entry_added
If event.entry is a message whose role is toolResult, remove the matching batch-local toolCallId from snapshot.operation?.runningTools, then apply the transcript update. Harness events are serialized, trusted, emitted exactly once, and not historically replayed, so neither duplicate-entry handling nor cross-batch identity is needed.
Do not clear tool rows on turn_end.
Exact authoritative capture behavior
File: packages/agent/src/harness/runtime/lane.ts, captureLaneSnapshot(), case "tools".
The assistant entry is already loaded once. For each batch call:
planned
Skip. It has not become presentation-active yet.
completed
Skip. Its toolResult entry must already be in the captured transcript.
effect_pending
- Validate that
assistant.message.content[sourceIndex]is the matchingtoolCallblock. - Read
operationToolArgs(operationId, turnId, sourceIndex); it is required for effect-pending calls. - Read optional
pendingToolOutput(operationId, resultEntryId). - Project:
{
status: "running",
toolCallId: block.id,
toolName: block.name,
args: persistedArgs,
...(checkpoint === undefined ? {} : { result: checkpoint })
}
outcome_ready
- Validate the source tool-call block.
- Read
pendingEntry(call.resultEntryId). - Require a message payload with role
toolResult. - Validate staged
toolCallIdandtoolNameagainst the source block. - Read
operationToolArgs(...)when present. - Use
persistedArgs ?? block.arguments. Immediate synthetic calls may never have writtenoperationToolArgs, and this absence is legal only for the outcome-ready projection. - Reconstruct the canonical
AgentToolResultfrom the stagedToolResultMessageand the durable call termination flag as needed. - Project
status: "settled",result, andisError.
The event and capture representations must normalize the final result identically so folding through tool_end equals a later authoritative snapshot. Pay attention to optional details, usage, addedToolNames, and terminate; do not rely on incidental object-property presence differences.
A missing or mismatched staged result for outcome_ready is presentation corruption and must fault snapshot capture.
Runtime event production changes
Primary file: packages/agent/src/harness/runtime/drive/tools.ts
Related helpers: packages/agent/src/harness/execution/tools.ts and packages/agent/src/harness/runtime/drive/tool-placement.ts.
Internal outcome shape
Current:
type ToolOutcome = { message: ToolResultMessage<unknown>; terminate: boolean };
Extend/refactor it so post-commit event production has the complete canonical final result and isError, without lossy reconstruction. It must retain enough data for:
- staged
ToolResultMessage; tool_end.result;tool_end.isError;- durable/effective
terminateafter cancellation normalization.
Synthetic helpers currently return ToolResultMessage directly. Refactor carefully so synthetic outcomes also carry the canonical result data. Do not invent details in the transcript: existing unknown/invalid synthetic results deliberately omit message details.
Commit-bound tool_start
For fresh execution, publishToolIntent() attaches tool_start to the commit that persists effective arguments and changes the call to effect_pending. The public operation awaits delivery before admitting executeToolCall(), preserving tool_start → tool_update* without requiring each update callback to await delivery.
For a fresh synthetic call that never writes effect intent, publishToolOutcome() attaches tool_start before tool_end in the outcome-staging commit's event batch. It reports the source block arguments.
For safe recovery, the checkpoint-clear commit emits recovery-tagged tool_start using persisted effective arguments. Do not emit a fresh start for an already-restored unsafe effect_pending call; its initial snapshot is the baseline.
Post-commit tool_end
Remove the current pre-staging tool_end emission from performToolInvocation().
publishToolOutcome() attaches tool_end to the same staging command's events callback. The event carries:
runId,turnId,toolCallId,toolName;- canonical final
result; isError;- cancellation-normalized durable
terminate; recovery: truewhere applicable.
Arguments belong to tool_start and are not repeated on tool_end. Event data describes the state actually committed, especially cancellation forcing terminate: false.
Because Lane.command() awaits retained event delivery and runParallel() schedules materialization from the outcome-completion promise, the required order is:
staging commit
→ tool_end delivery
→ source-ready message lifecycle
→ placement commit
→ entry_added
Call sites to audit
Every publishToolOutcome() call must supply the source tool call and recovery context correctly:
- immediate outcome in
startToolInvocation(); - cancellation after intent but before execution;
- normal
performToolInvocation()completion; - safe replay completion;
- unsafe recovery interruption;
- sequential cancellation of
planned; - sequential cancellation of
effect_pending.
Also audit the synchronous AbortRequested path inside performToolInvocation(): a call with durable intent must still have coherent start/end presentation even if effect admission fails immediately.
Mini and other presentation consumers
Mini
File: packages/coding-agent/src/experimental/mini/tui/view.ts, MiniTui.apply().
For each runningTools row:
status running:
markExecutionStarted()
if result exists: updateResult({...result, isError:false}, true)
status settled:
do not call markExecutionStarted()
updateResult({...result, isError}, false)
The final result remains visible while awaiting placement. After entry_added, transcript synchronization supplies the immutable ToolResultMessage and the row is no longer in runningTools.
Other shared consumer
Apply equivalent handling in:
packages/coding-agent/src/experimental/client-tui-chat.ts
Read packages/coding-agent/src/modes/interactive/components/tool-execution.ts before editing to confirm updateResult(result, isPartial) semantics.
Tests
Reducer tests
File: packages/agent/test/harness/runtime/reducer.test.ts
Add a parallel batch event-fold test with calls 0, 1, 2:
- Establish all three as running.
- Call 2 settles before call 0.
- Call 0 settles and is placed while call 1 still runs.
- Call 1 settles.
- Placement flushes calls 1 and 2 in source order.
- After every settlement and placement event, assert each presentation-active call appears in exactly one of:
operation.runningTools; or- transcript
toolResultentries.
- Assert call 2 remains present with
status:"settled"and its final result while blocked by earlier calls. - Assert each
entry_addedremoves only its matching active row.
Add focused coverage for:
tool_updatefrom a stale/wrongrunIddoes not mutate the current operation;tool_startupserts rather than duplicates a row captured from durable intent;tool_endsettles the existing batch-local row.
Capture/watch tests
File: packages/agent/test/harness/runtime/watch.test.ts
Construct a durable tools state containing:
planned(omitted);effect_pendingwith checkpoint (status:"running", checkpoint exposed asresult);effect_pendingwithout checkpoint;- real
outcome_readywith persisted effective args; - synthetic
outcome_readywithoutoperationToolArgs, falling back to source block arguments; - completed call represented only by a transcript entry where practical.
Assert staged settled content, isError, arguments, and absence of duplicates. Add missing/mismatched pendingEntry corruption assertions.
Runtime tool tests
File: packages/agent/test/harness/runtime/drive-tools.test.ts
Update/add ordering assertions proving:
- real:
intent commit < tool_start < tool_update* < staging commit < tool_end < entry_added; - immediate synthetic:
staging commit < tool_start < tool_end < entry_added, no tool effect and noafter_tool; - planned cancellation gets coherent start/end;
- unsafe recovery uses initial snapshot plus recovery-tagged end without replaying effects;
- B can emit post-commit end and remain settled while A blocks placement;
- source-order placement remains unchanged;
- a crash after staging cannot replay the call.
The baseline normative tests/documentation required tool_end before staging; the implementation reverses those expectations deliberately.
Type/event catalog tests
Audit:
packages/agent/test/harness/types.test.tspackages/agent/src/harness/telemetry.ts
No new event name is added. tool_end omits arguments and its semantics change.
Mini regression
After unit tests, run the real mini abort smoke test used previously:
- execute exactly
sleep 20; - send Escape after about two seconds;
- assert
Command abortedand elapsed time appear; - assert raw ANSI contains
toolErrorBg(48;2;60;40;40); - verify the durable session contains an
isError:truetool result and operation statusaborted.
Also exercise a parallel batch where a later tool finishes first and verify its final result remains visible until in-order placement.
Documentation changes
Read both documents completely before editing:
packages/agent/docs/harness.md(normative)packages/agent/docs/tool-durability.md
The implementation updates baseline statements that required:
tool_endbefore staging;tool_start/tool_endonly for real effects;- synthetic outcomes emitting no lifecycle;
outcome_readybeing omitted fromrunningTools;- snapshot field
partialResult.
Important known locations from the baseline:
harness.md§3.8 around lines 784–796;harness.md§5.4LaneSnapshotaround lines 1101–1134;harness.md§5.5 events around lines 1140–1158;harness.mdtool phases around lines 1214–1224;harness.mdconformance requirements around lines 1386–1400;tool-durability.mdfinalization around lines 255–280;tool-durability.mdsnapshots/events around lines 568–584;tool-durability.mdtest requirements around lines 671–679.
The revised docs must state:
tool_endis post-staging durability evidence for a final result;- fresh synthetic outcomes receive start/end lifecycle;
- recovery events are not historically replayed;
outcome_readyremains projected as settled until placement;entry_addedmoves settled presentation into transcript;- the unified snapshot
resultis provisional when running and final when settled.
Do not modify the separate response.ts/tool-placement.ts recovery turn_end discrepancy unless separately requested.
Files to reread completely after compaction
Core/specification:
packages/agent/docs/harness.mdpackages/agent/docs/tool-durability.mdpackages/agent/src/harness/agent-harness.tspackages/agent/src/harness/runtime/reducer.tspackages/agent/src/harness/runtime/lane.tspackages/agent/src/harness/runtime/drive/tools.tspackages/agent/src/harness/runtime/drive/tool-placement.tspackages/agent/src/harness/execution/tools.tspackages/agent/src/harness/runtime/types.tspackages/agent/src/harness/session/types.tspackages/agent/src/harness/events.tspackages/agent/src/harness/telemetry.ts
Tests:
packages/agent/test/harness/runtime/reducer.test.tspackages/agent/test/harness/runtime/watch.test.tspackages/agent/test/harness/runtime/drive-tools.test.tspackages/agent/test/harness/types.test.ts- Relevant test helpers imported by those files.
Mini/presentation:
packages/coding-agent/src/experimental/mini/tui/session.tspackages/coding-agent/src/experimental/mini/worker/lane-service.tspackages/coding-agent/src/experimental/mini/shared/protocol.tspackages/coding-agent/src/experimental/mini/tui/view.tspackages/coding-agent/src/experimental/client-tui-chat.tspackages/coding-agent/src/modes/interactive/components/tool-execution.ts
Before editing, run git status --short and inspect current diffs because other Pi sessions may share the worktree.
Validation commands
From repository root, after changes:
cd packages/agent
node "$(git rev-parse --show-toplevel)/node_modules/vitest/dist/cli.js" --run test/harness/runtime/reducer.test.ts
node "$(git rev-parse --show-toplevel)/node_modules/vitest/dist/cli.js" --run test/harness/runtime/watch.test.ts
node "$(git rev-parse --show-toplevel)/node_modules/vitest/dist/cli.js" --run test/harness/runtime/drive-tools.test.ts
node "$(git rev-parse --show-toplevel)/node_modules/vitest/dist/cli.js" --run test/harness/types.test.ts
cd "$(git rev-parse --show-toplevel)"
npm run check
Do not run npm test, the full Vitest suite, or npm run build unless requested.
If a delegated review is used, repository policy requires:
--provider anthropic --model claude-fable-5
Keep extensions enabled.
Non-goals
- No generic structural-delta transport for mini.
- No optimization to avoid the final result crossing the wire once in
tool_endand again inentry_added. - No change to source-prefix placement semantics.
- No clearing on
turn_end. - No change to tool effect replay/durability rules.
- No change to
after_tool: it still runs only for actual fresh/safely replayed effects under its existing cancellation contract. - No work on the separate recovery
turn_enddiscrepancy betweenresponse.tsandtool-placement.ts. - No backward-compatibility layer unless the user explicitly asks for one.
- Do not commit unless the user asks.