packages/agent/docs/work-packages/09-lane-snapshot-settled-tools.md
earendil-works/pidevd14d6b22327d545d6a253f932165b63e48d7f9c8packages/agent/docs/harness.md remains normative.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.
A call disappeared between real-effect completion and source-ordered tree placement:
packages/agent/src/harness/runtime/reducer.ts: tool_end spliced the call out of runningTools.packages/agent/src/harness/runtime/lane.ts: captureLaneSnapshot(), case "tools", projected only effect_pending calls and skipped outcome_ready calls.pendingEntry(resultEntryId), but a fresh/reconnected snapshot could not display it.entry_added placed the immutable toolResult entry.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.
Mini does not replicate structural object deltas.
packages/coding-agent/src/experimental/mini/worker/lane-service.ts sends one initial full snapshot and then forwards individual HarnessEvent objects.packages/coding-agent/src/experimental/mini/tui/session.ts folds those events through reduceLaneSnapshot().tool_update currently 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.
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.
intent commit
→ tool_start
→ tool_update*
→ execute/finalize
→ TX[pendingEntry + outcome_ready + cleanup]
→ tool_end
→ source-ordered placement
→ entry_added
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:
before_tool denial or invalid replacement arguments;length/truncated call handling;planned or after intent but before effect admission.Historical lifecycle events are not replayed.
effect_pending call is already represented by the initial snapshot.tool_start from the checkpoint-clear commit and tool_end from the later outcome-staging commit.tool_end without a newly emitted tool_start; the initial snapshot supplied the running row.outcome_ready appears as settled in the initial snapshot and needs no replayed end event before placement.tool_endAfter 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.
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.
File: packages/agent/src/harness/runtime/reducer.ts
tool_startmatchingOperation(snapshot, event.runId).toolCallId; do not blindly push.status: "running", toolName, args.tool_update values.Upsert is required because a watch may capture durable effect_pending state before the buffered tool_start event is delivered.
tool_updatematchingOperation(snapshot, event.runId), not snapshot.operation directly.result with event.partialResult.tool_endmatchingOperation(snapshot, event.runId).toolCallId; only one tool batch is presentation-active at a time.status: "settled", preserving its arguments and using event.result and event.isError.result; there is no separate partialResult to delete.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_addedIf 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.
File: packages/agent/src/harness/runtime/lane.ts, captureLaneSnapshot(), case "tools".
The assistant entry is already loaded once. For each batch call:
plannedSkip. It has not become presentation-active yet.
completedSkip. Its toolResult entry must already be in the captured transcript.
effect_pendingassistant.message.content[sourceIndex] is the matching toolCall block.operationToolArgs(operationId, turnId, sourceIndex); it is required for effect-pending calls.pendingToolOutput(operationId, resultEntryId).{
status: "running",
toolCallId: block.id,
toolName: block.name,
args: persistedArgs,
...(checkpoint === undefined ? {} : { result: checkpoint })
}
outcome_readypendingEntry(call.resultEntryId).toolResult.toolCallId and toolName against the source block.operationToolArgs(...) when present.persistedArgs ?? block.arguments. Immediate synthetic calls may never have written operationToolArgs, and this absence is legal only for the outcome-ready projection.AgentToolResult from the staged ToolResultMessage and the durable call termination flag as needed.status: "settled", result, and isError.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.
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.
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:
ToolResultMessage;tool_end.result;tool_end.isError;terminate after 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.
tool_startFor 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.
tool_endRemove 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;result;isError;terminate;recovery: true where 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
Every publishToolOutcome() call must supply the source tool call and recovery context correctly:
startToolInvocation();performToolInvocation() completion;planned;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.
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.
Apply equivalent handling in:
packages/coding-agent/src/experimental/client-tui-chat.tsRead packages/coding-agent/src/modes/interactive/components/tool-execution.ts before editing to confirm updateResult(result, isPartial) semantics.
File: packages/agent/test/harness/runtime/reducer.test.ts
Add a parallel batch event-fold test with calls 0, 1, 2:
operation.runningTools; ortoolResult entries.status:"settled" and its final result while blocked by earlier calls.entry_added removes only its matching active row.Add focused coverage for:
tool_update from a stale/wrong runId does not mutate the current operation;tool_start upserts rather than duplicates a row captured from durable intent;tool_end settles the existing batch-local row.File: packages/agent/test/harness/runtime/watch.test.ts
Construct a durable tools state containing:
planned (omitted);effect_pending with checkpoint (status:"running", checkpoint exposed as result);effect_pending without checkpoint;outcome_ready with persisted effective args;outcome_ready without operationToolArgs, falling back to source block arguments;Assert staged settled content, isError, arguments, and absence of duplicates. Add missing/mismatched pendingEntry corruption assertions.
File: packages/agent/test/harness/runtime/drive-tools.test.ts
Update/add ordering assertions proving:
intent commit < tool_start < tool_update* < staging commit < tool_end < entry_added;staging commit < tool_start < tool_end < entry_added, no tool effect and no after_tool;The baseline normative tests/documentation required tool_end before staging; the implementation reverses those expectations deliberately.
Audit:
packages/agent/test/harness/types.test.tspackages/agent/src/harness/telemetry.tsNo new event name is added. tool_end omits arguments and its semantics change.
After unit tests, run the real mini abort smoke test used previously:
sleep 20;Command aborted and elapsed time appear;toolErrorBg (48;2;60;40;40);isError:true tool result and operation status aborted.Also exercise a parallel batch where a later tool finishes first and verify its final result remains visible until in-order placement.
Read both documents completely before editing:
packages/agent/docs/harness.md (normative)packages/agent/docs/tool-durability.mdThe implementation updates baseline statements that required:
tool_end before staging;tool_start/tool_end only for real effects;outcome_ready being omitted from runningTools;partialResult.Important known locations from the baseline:
harness.md §3.8 around lines 784–796;harness.md §5.4 LaneSnapshot around lines 1101–1134;harness.md §5.5 events around lines 1140–1158;harness.md tool phases around lines 1214–1224;harness.md conformance requirements around lines 1386–1400;tool-durability.md finalization around lines 255–280;tool-durability.md snapshots/events around lines 568–584;tool-durability.md test requirements around lines 671–679.The revised docs must state:
tool_end is post-staging durability evidence for a final result;outcome_ready remains projected as settled until placement;entry_added moves settled presentation into transcript;result is provisional when running and final when settled.Do not modify the separate response.ts/tool-placement.ts recovery turn_end discrepancy unless separately requested.
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.tsTests:
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.tsMini/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.tsBefore editing, run git status --short and inspect current diffs because other Pi sessions may share the worktree.
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.
tool_end and again in entry_added.turn_end.after_tool: it still runs only for actual fresh/safely replayed effects under its existing cancellation contract.turn_end discrepancy between response.ts and tool-placement.ts.