Back to Pi Mono

Work package 09 — LaneSnapshot settled-but-unplaced tools

packages/agent/docs/work-packages/09-lane-snapshot-settled-tools.md

0.85.121.9 KB
Original Source

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.md remains 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:

text
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:

  1. packages/agent/src/harness/runtime/reducer.ts: tool_end spliced the call out of runningTools.
  2. packages/agent/src/harness/runtime/lane.ts: captureLaneSnapshot(), case "tools", projected only effect_pending calls and skipped outcome_ready calls.
  3. The finalized result was already durable at pendingEntry(resultEntryId), but a fresh/reconnected snapshot could not display it.
  4. It reappeared only after 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.

Confirmed current architecture

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().
  • Reconnect/rebase fetches another complete snapshot.
  • 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:

text
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

text
intent commit
→ tool_start
→ tool_update*
→ execute/finalize
→ TX[pendingEntry + outcome_ready + cleanup]
→ tool_end
→ source-ordered placement
→ entry_added

Fresh synthetic call

text
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_tool denial or invalid replacement arguments;
  • genuine assistant length/truncated call handling;
  • cancellation while still planned or after intent but before effect admission.

Recovery

Historical lifecycle events are not replayed.

  • A restored effect_pending call is already represented by the initial snapshot.
  • Safe replay emits recovery-tagged tool_start from the checkpoint-clear commit and tool_end from the later outcome-staging commit.
  • Unsafe interruption synthesis may emit a recovery-tagged tool_end without a newly emitted tool_start; the initial snapshot supplied the running row.
  • A call already restored as outcome_ready appears 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:

ts
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_update values.

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), not snapshot.operation directly.
  • Find the matching row.
  • For a running row, replace result with event.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 using event.result and event.isError.
  • This naturally removes the provisional interpretation of the old result; there is no separate partialResult to 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 matching toolCall block.
  • Read operationToolArgs(operationId, turnId, sourceIndex); it is required for effect-pending calls.
  • Read optional pendingToolOutput(operationId, resultEntryId).
  • Project:
ts
{
  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 toolCallId and toolName against the source block.
  • Read operationToolArgs(...) when present.
  • Use persistedArgs ?? block.arguments. Immediate synthetic calls may never have written operationToolArgs, and this absence is legal only for the outcome-ready projection.
  • Reconstruct the canonical AgentToolResult from the staged ToolResultMessage and the durable call termination flag as needed.
  • Project 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.

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:

ts
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 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.

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: 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:

text
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:

text
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:

  1. Establish all three as running.
  2. Call 2 settles before call 0.
  3. Call 0 settles and is placed while call 1 still runs.
  4. Call 1 settles.
  5. Placement flushes calls 1 and 2 in source order.
  6. After every settlement and placement event, assert each presentation-active call appears in exactly one of:
    • operation.runningTools; or
    • transcript toolResult entries.
  7. Assert call 2 remains present with status:"settled" and its final result while blocked by earlier calls.
  8. Assert each 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.

Capture/watch tests

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;
  • real outcome_ready with persisted effective args;
  • synthetic outcome_ready without operationToolArgs, 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 no after_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.ts
  • packages/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 aborted and elapsed time appear;
  • assert raw ANSI contains toolErrorBg (48;2;60;40;40);
  • verify the durable session contains an 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.

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_end before staging;
  • tool_start/tool_end only for real effects;
  • synthetic outcomes emitting no lifecycle;
  • outcome_ready being omitted from runningTools;
  • snapshot field 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;
  • fresh synthetic outcomes receive start/end lifecycle;
  • recovery events are not historically replayed;
  • outcome_ready remains projected as settled until placement;
  • entry_added moves settled presentation into transcript;
  • the unified snapshot 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.

Files to reread completely after compaction

Core/specification:

  1. packages/agent/docs/harness.md
  2. packages/agent/docs/tool-durability.md
  3. packages/agent/src/harness/agent-harness.ts
  4. packages/agent/src/harness/runtime/reducer.ts
  5. packages/agent/src/harness/runtime/lane.ts
  6. packages/agent/src/harness/runtime/drive/tools.ts
  7. packages/agent/src/harness/runtime/drive/tool-placement.ts
  8. packages/agent/src/harness/execution/tools.ts
  9. packages/agent/src/harness/runtime/types.ts
  10. packages/agent/src/harness/session/types.ts
  11. packages/agent/src/harness/events.ts
  12. packages/agent/src/harness/telemetry.ts

Tests:

  1. packages/agent/test/harness/runtime/reducer.test.ts
  2. packages/agent/test/harness/runtime/watch.test.ts
  3. packages/agent/test/harness/runtime/drive-tools.test.ts
  4. packages/agent/test/harness/types.test.ts
  5. Relevant test helpers imported by those files.

Mini/presentation:

  1. packages/coding-agent/src/experimental/mini/tui/session.ts
  2. packages/coding-agent/src/experimental/mini/worker/lane-service.ts
  3. packages/coding-agent/src/experimental/mini/shared/protocol.ts
  4. packages/coding-agent/src/experimental/mini/tui/view.ts
  5. packages/coding-agent/src/experimental/client-tui-chat.ts
  6. packages/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:

bash
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:

text
--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_end and again in entry_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_end discrepancy between response.ts and tool-placement.ts.
  • No backward-compatibility layer unless the user explicitly asks for one.
  • Do not commit unless the user asks.