diff --git a/packages/agent/docs/runtime-simplification.md b/packages/agent/docs/runtime-simplification.md index 7d0f23c55..3859f1c37 100644 --- a/packages/agent/docs/runtime-simplification.md +++ b/packages/agent/docs/runtime-simplification.md @@ -30,9 +30,9 @@ Implemented by the simplification pass: At simplification completion, the largest files were `runtime/lane.ts` (~996), `runtime/drive/tools.ts` (~663), `runtime/drive/checkpoint.ts` (~452), and `runtime/drive/response.ts` (~417). -### M6 status +### WP05 structural status -M6 structural execution is underway on the simplified substrate. Its first coherent slice adds: +M6 established structural execution on the simplified substrate. Its first coherent slice added: - shared compaction-bounded transcript/context reads and committed-entry event decoration; - caller-owned one-provider-request seams for compaction and branch summaries while preserving retrying behavior for existing non-harness callers; @@ -41,7 +41,7 @@ M6 structural execution is underway on the simplified substrate. Its first coher - per-request structural intent and usage settlement, attempt retry/recovery, hook decisions, compaction/navigation publication, and terminal cleanup; - focused coverage for threshold/overflow entry, split-turn request accounting, generated and hook results, retry/cap/recovery, model absence, mid-request durable cancellation, navigation, and preparation corruption. -The runtime is currently 6,149 lines including the new 1,460-line structural procedure module. `npm run check` passes, as do 168 tests across 16 focused files. Remaining M6 work is broader crash/state coverage and any corrections it reveals; M7 reconciliation/total dispatch and M8 public surfaces have not started. +R1a, R2, and R3 then moved queued input to the lane, replaced hydrated family outcomes with immutable operation records, and collapsed the family cross-product to 13 neutral leaves. At the reviewed R3 checkpoint the runtime is 5,722 lines; `runtime/drive` is 3,757 lines; and the structural module is 1,141 lines, down 319 lines (21.8%) from its 1,460-line foundation. The focused runtime set passes 189 tests across 15 files, with 44 memory/JSONL and 30 SQLite repository-conformance tests also passing. R1b boundary planning, M7 reconciliation/total dispatch, and M8 public surfaces have not started. Public drive remains disabled. Keep the visible durable procedure order: `prepare → publish intent → perform effect → publish outcome`. @@ -51,7 +51,7 @@ Do not introduce a generic Procedure interface, runner, scheduler, graph, callba A Lane is one process-local actor over one durable lane projection. -- `Lane.state` is authoritative for tip, configuration, current operation, control, inbox IDs, and latest result. +- `Lane.state` is authoritative for tip, configuration, current operation, control, inbox IDs, and the latest operation ID. - Every supported mutation commits through the Session mutation line and publishes the matching `Lane.state` before releasing it. - One lane-owned Drive is the sole writer that advances an operation state. - Same-operation callers observe the same Drive. No caller owns it. @@ -63,32 +63,23 @@ A Lane is one process-local actor over one durable lane projection. ## Durable state -`OperationState` has one discriminator, `at`, with 22 direct leaves: +`OperationState` has one discriminator, `at`, with 13 direct leaves: -- `run.starting` -- `run.checkpoint` -- `run.assistant.ready` -- `run.assistant.effect_pending` -- `run.assistant.retry_wait` -- `run.tools` -- `run.deferred.suspended` -- `run.deferred.effect_pending` -- `run.compaction.deciding` -- `run.compaction.ready` -- `run.compaction.effect_pending` -- `run.compaction.retry_wait` -- `run.failure_drain` -- `compaction.deciding` -- `compaction.ready` -- `compaction.effect_pending` -- `compaction.retry_wait` +- `starting` +- `checkpoint` +- `assistant.ready` +- `assistant.effect_pending` +- `assistant.retry_wait` +- `tools` +- `deferred.suspended` +- `deferred.effect_pending` +- `summary.deciding` +- `summary.ready` +- `summary.effect_pending` +- `summary.retry_wait` - `navigation.ready_to_commit` -- `navigation.summary.deciding` -- `navigation.summary.ready` -- `navigation.summary.effect_pending` -- `navigation.summary.retry_wait` -Shared run, structural, and navigation data is factored with intersections. `Control` remains orthogonal. `ToolBatch` remains a child state machine because parallel tool children genuinely mutate sibling call statuses concurrently. +Every leaf carries one uniform `OperationScope`. The summary quadruple carries one `SummaryTask`; its closed `ResultBoundary` selects in-run checkpoint resumption, standalone finish, or navigation commit. The summary algorithm is derived from that boundary rather than duplicated in state. `Control` remains orthogonal. `ToolBatch` remains a child state machine because parallel tool children genuinely mutate sibling call statuses concurrently. ## State and content boundary @@ -99,7 +90,7 @@ Procedures never read these addresses to decide execution: - `operationState` - `branchTip` - `laneConfig` -- `laneLastResult` +- `operationResult` They use the current `Lane.state` supplied by the mutation line. @@ -186,7 +177,7 @@ Used after an admitted provider/tool/structural effect and for genuine parallel - Supplies current control/inbox fields and the process-local effect result to the semantic settlement planner. - Atomically commits payload, usage, tip movement, cleanup, and the classified next state. - Appends the canonical operation-state write and publishes the matching projection. -- When the planner returns a terminal decision, appends `laneLastResult`, idle `laneState`, and the idle process-local projection. +- When the planner returns a terminal decision, appends the immutable `operationResult`, idle `laneState` with `lastOperationId`, and the idle process-local projection. The caller supplies the typed outcome, cleanup/publication writes, last result, and event. Terminal business decisions remain visible in the owning procedure. diff --git a/packages/agent/docs/work-packages/05-direct-durable-drive.md b/packages/agent/docs/work-packages/05-direct-durable-drive.md index 481de2b72..47bd1e931 100644 --- a/packages/agent/docs/work-packages/05-direct-durable-drive.md +++ b/packages/agent/docs/work-packages/05-direct-durable-drive.md @@ -1,6 +1,6 @@ # WP05 — Direct durable drive -**Status: M5 complete; M6 structural foundation committed; redesign package R1–R3 pending, then M7 (reconciliation), M8 (public surfaces), M9 (documentation reconciliation), and M10 (provider KV-cache identity review).** +**Status: M5 complete; M6 structural foundation committed; R1a lane-owned inbox relocation, R2 record-only outcomes, and reviewed R3 family-neutral leaves implemented; R1b next, then M7 (reconciliation), M8 (public surfaces), M9 (documentation reconciliation), and M10 (provider KV-cache identity review).** WP06's Session/Branch/Lane separation is already part of the foundation. Public drive remains disabled until R1–R3, cancellation reconciliation, the total drive switch, every public execution surface, and the provider KV-cache identity review are complete. @@ -55,23 +55,22 @@ Every supported mutation commits on the one Session mutation line and publishes Queued input is lane-owned, never operation-owned. `LaneState` (durable and process-local) carries one ordered inbox of `{ entryId, kind }` items with `kind: "steer" | "followUp" | "nextRun" | "write"`. Payload staging is unchanged: enqueue mints the entry id and writes `pendingEntry(id)`; the inbox holds ids only. Item lifecycle is admission → consumption | cancellation; terminal cleanup never touches queue payloads. -Tags are consumption-eligibility markers, not ownership. Enqueue always succeeds — during runs, structural operations, cancellation, and idle. Every drain point **selects per tag (queue modes apply at selection), then places**; each selected group is internally in admission order: +Tags are consumption-eligibility markers, not ownership. Enqueue always succeeds — during runs, structural operations, cancellation, and idle. Every drain point selects eligible items per tag, applying queue modes during selection, but places all selected items in the inbox's single global admission order. Tag grouping must never reorder user input. At acceptance, request prompt entries follow the selected inbox items because the request is the newest admission. -| Drain point | Selection, in order | +| Drain point | Eligibility and decision order | | --- | --- | -| acceptance (idle lane) | writes (all) → nextRun (all) → steer (`steeringMode`: all or oldest) → followUp (`followUpMode`) → request prompt entries. The acceptance transaction itself places these and removes their ids; `starting` drains nothing. followUp is acceptance-eligible because an idle lane vacuously satisfies its "after current work" condition. | -| turn-end boundary pass (run) | writes (all) → threshold check → steer (`steeringMode`) → continuation-required assistant → followUp (`followUpMode`, only at `may_finish`) → `before_run_end` → finish. nextRun items are never eligible mid-run and never block finish. | -| failure_drain pass | writes → steer → followUp (modes, same order); projecting input rescues into an assistant turn; unprojected custom writes do not rescue; with no rescue input it finishes failed; cancelled control defers to reconciliation. | -| idle direct append | queued write items are placed first, then the new entry, in one commit | -| abort (M7) | steer + followUp removed from the inbox, payload values deleted, payloads returned; nextRun and write items stay | +| acceptance (idle lane) | write + nextRun (all), steer (`steeringMode`: all or oldest), and followUp (`followUpMode`) are eligible. Place selected items in global admission order, then request prompt entries. The acceptance transaction places them and removes their ids; `starting` drains nothing. followUp is eligible because an idle lane vacuously satisfies its "after current work" condition. | +| turn-end boundary pass (run) | write + steer are eligible before threshold/continuation planning and are placed in global admission order. followUp becomes eligible only at `may_finish`, before `before_run_end` and finish. nextRun is never eligible mid-run and never blocks finish. | +| idle direct append | queued write items are placed in admission order, then the new entry, in one commit | +| abort (M7) | steer + followUp are removed from the inbox, payload values deleted, and payloads returned; nextRun and write items stay | -**One decision, one commit.** Every boundary computes its complete decision on the mutation line and commits once, into a state that never drains (`assistant.ready`, `summary.deciding`, or a terminal transaction). A crash before that commit re-runs the whole decision with nothing consumed; a crash after it lands in a non-draining state. Consequently `skipInboxOnce` and `thresholdCheckedTriggerEntryId` are deleted: both existed only to defend the re-draining windows between today's stepwise checkpoint commits. Mode remainders stay queued and are consumed at later boundaries, giving the per-turn steer cadence and per-run-end followUp cadence. +**One decision, at most one commit (R1b).** A boundary pass may enter the mutation line several times for bounded reads and for `before_run_end` mediation, but it performs **at most one commit**, and that commit always lands in a state that neither drains nor rechecks (`assistant.ready`, `summary.deciding`, placed entries + `assistant.ready`, or a terminal transaction). No boundary decision ever commits back into `checkpoint`. A crash before the commit re-runs the whole decision with nothing consumed; a crash after it lands past the decision point. Consequently `skipInboxOnce` and `thresholdCheckedTriggerEntryId` are deleted: drains cannot re-fire because their target states do not drain, and the threshold marker is replaced by a guard derived from the branch itself — threshold fires only when `shouldCompact` holds **and** the branch's newest compaction entry is older than the trigger entry, so a committed threshold compaction is its own durable marker. Mode remainders stay queued and are consumed at later boundaries, giving the per-turn steer cadence and per-run-end followUp cadence. There is no terminal drain. `cancelQueued` triage is unchanged and single-location. Queued input survives operation termination (including abort, for nextRun/write) until consumed or cancelled; this is a deliberate product decision. ### Flat state (R3) -`OperationState` has one `at` discriminator with **14** direct leaves: +`OperationState` has one `at` discriminator with **13** direct leaves: - `starting` - `checkpoint` @@ -81,23 +80,22 @@ There is no terminal drain. `cancelQueued` triage is unchanged and single-locati - `tools` - `deferred.suspended` - `deferred.effect_pending` -- `failure_drain` - `summary.deciding` - `summary.ready` - `summary.effect_pending` - `summary.retry_wait` - `navigation.ready_to_commit` -Every leaf carries one uniform scope `{ control, settings, latestAssistantEntryId }`. There are no `run.`/`compaction.`/`navigation.` leaf prefixes, no per-family scope types, and no intersection zoo. The four `summary.*` leaves carry a `SummaryTask` whose `boundary` datum (a closed three-arm union, §8) decides what happens at the result boundary. `starting` and `navigation.ready_to_commit` are intent-locked entry leaves. `ToolBatch`/`ToolCall` remain the nested child collection state machine. `Control` stays orthogonal and loses its drained fields (R1). +Every leaf carries one uniform scope `{ control, settings, latestAssistantEntryId }`. There are no `run.`/`compaction.`/`navigation.` leaf prefixes, no per-family scope types, and no intersection zoo. The four `summary.*` leaves carry a `SummaryTask` whose `boundary` datum (a closed three-arm union, §8) decides what happens at the result boundary. `starting` and `navigation.ready_to_commit` are intent-locked entry leaves. `ToolBatch`/`ToolCall` remain the nested child collection state machine. `Control` stays orthogonal and loses its drained fields when M7 installs drain-and-return abort. ### Durable operation results (R2) Every terminal transaction writes one small immutable result record at `operationResult(operationId)` (namespace `pi.result`, operation id key). The record is lane-lived; keeping it outside `pi.op.*` keeps that namespace's "deleted no later than the terminal transaction" grammar total and exception-free. `LaneState.lastOperationId` points at the newest record. `laneLastResult` (value, address constructor, and type union) is deleted. -- `drive(id)` becomes total for settled operations: current id → install/join; record exists → return its hydrated outcome; neither → `OperationMismatch`. +- `drive(id)` becomes total for settled operations: current id → install/join; record exists → return the record itself; neither → `OperationMismatch`. - Recovery and attachment never read result records; they are observation only. - Terminal-control invariant: a terminal transaction that executes under durable `cancel_requested` always records `status: "aborted"`; equivalently, any other status implies running terminal control. (Every non-aborted terminal path goes through `continueOperation`, which diverts under cancellation, and the mutation line serializes the marker against the terminal commit.) -- There is no listing, filtering, pagination, or retention API for records. `getResult(operationId)` and drive hydration are the entire read surface. +- There is no listing, filtering, pagination, or retention API for records — and no hydration layer. `getResult(operationId)` and the drive settled arm are the entire read surface; both return the record with no entry dereference, so observation can never fault on missing entries. ### One lane-owned Drive @@ -198,36 +196,73 @@ No code implements promotion; nothing needs removal beyond this document's previ ### Goal -Move all queued input to one lane-owned tagged inbox; delete the operation inbox, the drained-control machinery, and the checkpoint patch fields. +Move all queued input to one lane-owned tagged inbox first (R1a, implemented), then — after R3 — replace the transitional stepwise checkpoint drains with one shared atomic boundary planner (R1b). `Control.drained*` remains until M7 introduces drain-and-return abort; it is not part of inbox ownership. -### Files +### R1a — Ownership relocation -- `src/harness/session/types.ts` — durable `LaneState { currentOperationId, inbox }` with `InboxItem { entryId, kind }` (replaces `pendingNextRun`); delete `Inbox`, `RunScope.inbox`, and the `Control` drained fields (`Control` becomes `{ status: "running" } | { status: "cancel_requested"; requestedAt: number }`); delete `skipInboxOnce` and `thresholdCheckedTriggerEntryId` from `CheckpointData`. -- `src/harness/runtime/types.ts` — process-local `LaneState.inbox` replaces `pendingNextRun`. -- `src/harness/runtime/lane.ts` — acceptance capture per the §1 selection row: the acceptance transaction places writes (all) → nextRun (all) → steer (`steeringMode`) → followUp (`followUpMode`) → request prompt entries, deletes their pending values, and removes their ids atomically with the `LaneBusy` check; acceptance validity counts conversational items only (a lone queued write never satisfies or triggers acceptance); `append` while any operation is open enqueues a write-tagged item (the structural-operation rejection is removed); idle `append` places queued write items first, then the new entry, in one commit; `watch` derives `queues`/`pendingWrites` from the lane inbox by tag; delete drained-payload snapshot reads. -- `src/harness/runtime/drive/checkpoint.ts` — replace the stepwise drain commits with the one-decision-one-commit boundary pass (§1): writes, threshold, steer, continuation, followUp, `before_run_end`, finish — committing once into `assistant.ready`, `summary.deciding`, a placed follow-up continuation, or the terminal transaction. The threshold check runs inside the boundary planner against the would-be branch (the existing overflow-preparation pattern). Implement the `failure_drain` pass with the same one-commit shape. -- `src/harness/runtime/drive/terminal.ts` — `operationCleanupWrites` no longer deletes queued or drained pending values; staged `outcome_ready` results and other operation-owned addresses are unchanged. -- `src/harness/runtime/drive/{generation,response,deferred,tools,structural}.ts` — `runScopeOf`/scope copies drop `inbox`; settlement classification no longer routes through drain flags; no other behavioral change. -- `src/harness/runtime/restore.ts` — restore the inbox from durable `laneState`. -- Focused tests for every file above. +- `src/harness/session/types.ts` — durable `LaneState { currentOperationId, inbox }` with `InboxItem { entryId, kind }` replaces `pendingNextRun`; delete `Inbox` and `RunScope.inbox`. Keep `Control.drained*`, `skipInboxOnce`, and `thresholdCheckedTriggerEntryId` temporarily. +- `src/harness/runtime/types.ts` — process-local `LaneState.inbox` replaces `pendingNextRun`; `LanePatch.inbox` pairs durable lane-state replacement with process-local publication. +- `src/harness/runtime/lane.ts` — acceptance selects per tag and mode but places selected items in global admission order, then request prompt entries; it deletes selected pending values and removes their ids atomically with the `LaneBusy` check. A lone queued write never validates acceptance. `append` during any operation enqueues a write-tagged lane item without touching operation state; idle `append` places queued writes first, then the new entry, in one commit. `watch` derives queues and pending writes from the lane inbox. +- `src/harness/runtime/drive/checkpoint.ts` — transitional stepwise drains read and replace the lane inbox rather than operation state. `skipInboxOnce` and `thresholdCheckedTriggerEntryId` continue to protect those temporary multi-commit boundaries. +- `src/harness/runtime/drive/terminal.ts` — operation cleanup never deletes lane-inbox payloads. Staged `outcome_ready` results, drained abort payloads, and other operation-owned addresses are unchanged. +- `src/harness/runtime/drive/{generation,response,deferred,tools,structural}.ts` — `runScopeOf` and scope copies drop `inbox`; ordinary operation-state transitions cannot clobber concurrent input. +- `src/harness/runtime/restore.ts`, fork/legacy normalization, and focused tests use the tagged lane inbox. + +### R1b — Shared atomic boundary planner + +**Lands after R3**, because pre-R3 the structural result boundary is spread across the triplicated publishers: fusing the planner into three arms and then collapsing them in R3 would be double work, while post-R3 the single `ResultBoundary` switch is exactly one planner call site. The two `CheckpointData` patch fields ride through R3's mechanical rename as inert baggage. + +**Core invariant: no boundary decision ever commits back into `checkpoint`.** `checkpoint` is the durable "boundary pending" resting leaf, entered only from settlements, run-start consumption, and deferred redemption. Every exit of its pass is `assistant.ready` (with any placed entries), `summary.deciding`, a placed follow-up + `assistant.ready`, or the terminal transaction. + +One shared boundary planner with a process-local `threshold: "check" | "skip"` parameter has exactly two callers: + +- the `checkpoint` pass (`threshold: "check"`): select write + steer (modes, global admission order) → threshold guard → continuation → followUp (at `may_finish`) → `before_run_end` → finish; one commit; +- the structural result boundary for `resume_checkpoint` (`threshold: "skip"`): the **same planner runs inside the publication commit** — compaction entry (on success) first, then selected write/steer items, then the routed continuation, all in one transaction. Any placed conversational item routes to `assistant.ready` with that item as trigger, overriding a `may_finish` resume continuation (steer semantics). With nothing queued: `need_assistant` resumes commit `assistant.ready` directly; `may_finish` resumes commit `checkpoint{may_finish}` as the resting leaf and the live pass continues into the finish phases. + +**Threshold without the durable marker.** The guard is derived from the branch: threshold fires only when `shouldCompact` holds and the newest compaction entry is older than the trigger entry. A committed threshold compaction is therefore its own marker — any crash re-entry sees the newer compaction and skips. A **declined** threshold compaction publishes no entry and needs no marker: decline never lands on `checkpoint`. The decline decision stays process-local at `summary.deciding` until its single continuation commit (`assistant.ready`, follow-up + ready, or the terminal after `before_run_end`); a crash in that window restores `deciding` and re-runs `before_compaction` under its documented repetition contract. A live decline/recheck loop is structurally impossible because no decline path re-enters the threshold-checking pass. + +**`before_run_end` without hooks on the mutation line.** The finish arm is the one multi-phase case: (1) on-line verdict, no commit — reached only at `may_finish` with no eligible write/steer/followUp; (2) hook runs off the line; (3) on-line **replan from current state** — if the inbox gained eligible items or control changed, the stale hook result is dropped and the planner takes the new decision; otherwise the single commit is the follow-up injection (entry + `assistant.ready`) or the terminal transaction. Stale-dropping is replanning, not a version check. + +Concrete control trace (post-R3 names; threshold compaction with `may_finish` resume and a steer arriving mid-compaction): + +```text +TX1 settlement: response n7 + usage + tip, S=checkpoint{may_finish, trigger n7} +pass (check): shouldCompact && newestCompaction < n7 → prepare +TX2: preparation, S=summary.deciding{boundary: resume_checkpoint{may_finish, n7}} +TX3 (any time): steer s1 → pendingEntry(s1), laneState.inbox += s1 +TX4..n: deciding → ready → effect_pending → nested request/usage settlements +TX-pub (skip): insert compaction c1, tip=c1, insert s1 entry (parent c1), + delete pendingEntry(s1), laneState.inbox -= s1, + S=assistant.ready{trigger s1} ← one commit, checkpoint never re-entered +…turn answers s1; next settlement → checkpoint{may_finish, trigger n9} +pass (check): newestCompaction c1 > … tokens small → no threshold; inbox empty; verdict finish + → before_run_end off-line → replan: still empty, still running +TX-final: record + cleanup + laneState{current: null, last: op} +``` + +Crash between TX4..n and TX-pub → structural recovery (attempt unknown, per existing rules); crash during the hook window → `checkpoint{may_finish}` restored, pass re-runs (threshold skipped by the recency guard), hook re-runs per contract. + +Slices, in order (the order is load-bearing — deleting the marker while decline still re-entered `checkpoint` would live-loop): + +1. **R1b-1**: extract the shared planner; fuse it into the structural `resume_checkpoint` boundary (`threshold: "skip"`); structural success/decline stop committing back into `checkpoint` except the success/`may_finish` resting case. Both patch fields still written and checked by the old checkpoint pass — harmless. +2. **R1b-2**: convert the `checkpoint` pass to single-commit exits with the recency guard, implement the three-phase finish arm, delete `skipInboxOnce` and `thresholdCheckedTriggerEntryId`. ### Rules -- The §1 selection table and the one-decision-one-commit boundary rule are normative. -- One transaction per boundary: insert entries from pending values, delete those values, remove the ids from the lane inbox, and write the next operation state together. The pending-value XOR entry invariant is unchanged. -- Queue modes apply at selection time at every drain point, acceptance included. Mode remainders are consumed only at later boundaries; no flag mediates this — the committed target state simply does not drain. +- The §1 eligibility table, global placement order, and one-decision-one-commit target are normative. +- Queue modes apply at selection time. Remainders preserve global admission order and are consumed only at later eligible boundaries. - nextRun items are never consumed mid-run and never block run finish. -- Deferred-suspension enqueue keeps working (items admit at any time; consumption happens at the post-redemption boundary). +- Deferred-suspension enqueue always admits; consumption happens at the post-redemption boundary. ### Focused validation -Enqueue during idle/run/structural/cancelled states always admits; acceptance places groups in the specified order with modes applied and remainders retained; `one-at-a-time` cadence — each remaining steer is consumed by a later boundary and answered in its own turn, across crash/reopen at every commit; followUp captured at idle acceptance and only at `may_finish` during a run; a boundary crash before its commit re-runs the whole decision with nothing consumed; threshold checked at most once per boundary by construction; idle append places queued writes first; items survive operation terminal (any status) and process loss; `cancelQueued` at every boundary; watch queues by tag; no queue payload deleted by any terminal transaction; a lone queued write never validates acceptance. +Enqueue during idle/run/structural/cancelled states always admits; acceptance preserves global order with modes applied and remainders retained; `one-at-a-time` cadence gives each remaining steer its own later boundary; followUp is captured at idle acceptance and only at `may_finish` during a run; a boundary crash before its commit consumes nothing; threshold is checked at most once per boundary by construction; idle append places queued writes first; items survive every terminal status and process loss; `cancelQueued` works at every boundary; watch groups by tag without changing order; no terminal transaction deletes a lane-inbox payload; a lone queued write never validates acceptance. ## 7. R2 — Neutral operation outcome and durable result records ### Goal -Replace the three per-family outcome unions and `laneLastResult` with one neutral outcome shape and one immutable per-operation result record. +Replace the three per-family outcome unions and `laneLastResult` with one immutable per-operation result record. The record **is** the public settled outcome; there is no separate outcome type, no embedded entries, and no hydration. ### Types @@ -246,29 +281,27 @@ interface OperationResultRecord { endedAt: number; // Unix ms, Date.now() at terminal planning } -/** Live/public outcome. "suspended" is never stored. */ -type OperationOutcome = - | (OperationResultRecord & { tipEntry?: Entry; deferred?: never }) - | { operationId: string; kind: "run"; status: "suspended"; fromTipId: string | null; - tipId: string | null; startedAt: number; tipEntry?: Entry; deferred: DeferredHandle }; +/** Convenience-only suspension observation for prompt()/resume() (M8). Never stored. */ +interface SuspendedRun { operationId: string; status: "suspended"; deferred: DeferredHandle } ``` -The result is a disposition plus a pointer to the transcript segment `(fromTipId, tipId]`. It never lists intermediate work (compactions, turns, tools); the events and the tree are that record. `tipEntry` is hydrated for live promises and `getResult`/drive hydration; it is never stored. +The record is a disposition plus a pointer to the transcript segment `(fromTipId, tipId]`. It never lists intermediate work (compactions, turns, tools) and never embeds entries. There is **no `tipEntry` and no `OperationOutcome` union**: a caller that wants the payload dereferences `tipId` with the already-public `getEntry`/`findEntry` (for runs, the final message was additionally delivered by `message_end`/`entry_added`). This buys three things: terminal planners perform zero outcome reads, observation cannot fault on a missing entry (a `getEntries` throw inside hydration was a harness-fault path for a read-only convenience), and the future protocol never serializes `Entry` inside results — a result frame is eight flat fields. `SuspendedRun` is the one non-terminal observation, exists only on convenience returns, and carries nothing derivable elsewhere. ### Files -- `src/harness/session/types.ts` — add the record; delete the `LaneLastResult` union; `LaneState` gains `lastOperationId: string | null`. +- `src/harness/session/types.ts` — add the record; delete the `LaneLastResult` union; `LaneState` gains `lastOperationId: string | null`; delete `failure_drain`, `RunFailureDrainOperation`, and `FailureProvenance`. - `src/harness/session/values.ts` — `operationResult(operationId) = value("pi.result", operationId)`; delete `laneLastResult`. The `pi.op.*` lifetime grammar stays total: no operation-lived namespace survives its terminal transaction. -- `src/harness/agent-harness.ts` — delete `RunOutcome`, `CompactionOutcome`, `NavigationOutcome`, `OptionalFinalAssistant`, `TerminalOperationOutcome`, `ResumeOutcome`; `DriveOutcome.settled` carries `OperationOutcome` (drop the duplicate `operationId` field); result aliases become - `RunResult = Result`, - `CompactionResult = Result<{ compaction: OperationOutcome; run?: OperationOutcome }, …>`, - `NavigationResult = Result<{ navigation: OperationOutcome; run?: OperationOutcome }, …>`; - `QueueResult` loses `NoActiveRun` and absorbs `NextRunResult`. +- `src/harness/agent-harness.ts` — delete `RunOutcome`, `CompactionOutcome`, `NavigationOutcome`, `OptionalFinalAssistant`, `TerminalOperationOutcome`, `ResumeOutcome`, and the transitional `OperationOutcome` union; `DriveOutcome.settled` carries `OperationResultRecord` directly (no duplicate `operationId` field); result aliases become + `RunResult = Result`, + `CompactionResult = Result<{ compaction: OperationResultRecord; run?: OperationResultRecord | SuspendedRun }, …>`, + `NavigationResult = Result<{ navigation: OperationResultRecord; run?: OperationResultRecord | SuspendedRun }, …>`, + `ResumeResult = Result`; + `QueueResult` loses `NoActiveRun` and absorbs `NextRunResult`. `SuspendedRun` is declared here but constructed only by M8 convenience paths. - End-event payloads, one shape per event type: `run_end` and `navigation_end` carry `{ runId, status, error?, fromTipId, tipId }`. `compaction_end` is a segment event, not a terminal event — it also closes in-run threshold/overflow brackets — and always carries `{ runId, reason, status: "completed" | "declined" | "failed" | "aborted", error?, entryId? }`, where `entryId` names the compaction entry on success. Embedded `entry`/`summaryEntry`/final-assistant fields are dropped everywhere (`entry_added`/`message_end` already delivered payloads). - `src/harness/runtime/types.ts` — `FinishDecision` carries the record instead of `lastResult`. -- `src/harness/runtime/lane.ts` — terminal suffix per §2; `getLastResult` is replaced by `getResult(operationId, context): Promise` (record read + one `getEntry` for `tipEntry`); `inspectExecution` reports `lastOperationId` (and may embed the hydrated last outcome). -- `src/harness/runtime/drive/terminal.ts` — `hydrateTerminalOutcome` collapses to record + `tipEntry` hydration. -- `src/harness/runtime/drive/{checkpoint,response,structural,recovery,deferred,tools}.ts` — every finish decision constructs the record; `runCompletion`, per-family last-result construction, and final-assistant plumbing in results are deleted. (`latestAssistantEntryId` stays in scope for cancellation classification and checkpoint logic; it just no longer feeds results.) +- `src/harness/runtime/lane.ts` — terminal suffix per §2; `getLastResult` is replaced by `getResult(operationId, context): Promise` — one `getValue`, nothing else; `inspectExecution` reports `lastOperationId` only. +- `src/harness/runtime/drive/terminal.ts` — `hydrateTerminalOutcome`/`hydrateOperationOutcome` are deleted outright; the file keeps only `operationCleanupWrites` and the `operationResultRecord` constructor. Finish decisions materialize `{ kind: "settled", outcome: record }` from the record they already built — no reader dereference inside any terminal planner. +- `src/harness/runtime/drive/{checkpoint,response,structural,recovery,deferred,tools}.ts` — every finish decision constructs the record; `runCompletion`, per-family last-result construction, and final-assistant plumbing in results are deleted. Every former `failure_drain` producer instead commits terminal failure directly: response publication and cleanup stay in the same transaction, in-run structural failure closes `compaction_end` then `run_end`, and queued lane input remains untouched for a later ordinary run. (`latestAssistantEntryId` stays in scope for cancellation classification and checkpoint logic; it just no longer feeds results.) - `src/harness/runtime/restore.ts` — restore `lastOperationId`; attachment never reads records. - Focused tests. @@ -277,17 +310,17 @@ The result is a disposition plus a pointer to the transcript segment `(fromTipId - Records are written exactly once, by the terminal transaction, and never deleted, updated, or read by recovery. - Terminal-control invariant (§1): a terminal transaction under durable `cancel_requested` records `aborted`; no path commits any other status under cancellation. M7 adds the enforcing test; M9 adds the Part 9 invariant. - Forks exclude `pi.result` values. -- `drive(id)` arms: current → install/join; record → hydrated outcome; neither → `OperationMismatch`. +- `drive(id)` arms: current → install/join; record → returned directly; neither → `OperationMismatch`. ### Focused validation -Record written for every terminal path of every operation kind and status; `drive(id)` returns settled outcomes for arbitrarily old ids across reopen; `getResult` hydration and unknown-id `undefined`; `lastOperationId` restore; suspended never stored; fork exclusion; segment pointers correct (`fromTipId` = pre-acceptance tip, including navigation); `compaction_end` closes both standalone and in-run brackets, including `aborted` via reconciliation. +Record written for every terminal path of every operation kind and status; `drive(id)` returns records for arbitrarily old ids across reopen; `getResult` is a plain value read returning the record or `undefined`; no terminal planner or observation path reads entries for the result; `lastOperationId` restore; suspension never stored; fork exclusion; segment pointers correct (`fromTipId` = pre-acceptance tip, including navigation); `compaction_end` closes both standalone and in-run brackets, including `aborted` via reconciliation; each former failure-drain producer finishes in one transaction and preserves every lane-inbox item for a later run. ## 8. R3 — Family-neutral leaves and structural rebuild ### Goal -Collapse 22 leaves to the 14 in §1; rebuild `structural.ts`'s state-shape layer on one summary quadruple with an explicit result-boundary datum. +Collapse 22 leaves to the 13 in §1; rebuild `structural.ts`'s state-shape layer on one summary quadruple with an explicit result-boundary datum. ### Types @@ -313,17 +346,17 @@ The summary algorithm kind is **derived from the boundary** (`resume_checkpoint` | Intent | Admissible leaves | | --- | --- | -| run | `starting`, `checkpoint`, `assistant.*`, `tools`, `deferred.*`, `failure_drain`, `summary.*` with boundary `resume_checkpoint` | +| run | `starting`, `checkpoint`, `assistant.*`, `tools`, `deferred.*`, `summary.*` with boundary `resume_checkpoint` | | compaction | `summary.*` with boundary `finish` | | navigation | `navigation.ready_to_commit`; `summary.*` with boundary `commit_navigation` | -Forbidden and unreachable by construction (assert in tests, never implement): any edge into `starting` or `navigation.ready_to_commit` other than acceptance; `summary.*` → `tools`/`assistant.*` directly; `failure_drain` → `summary.*`; `navigation.ready_to_commit` → `summary.*`; terminal → anything. +Forbidden and unreachable by construction (assert in tests, never implement): any edge into `starting` or `navigation.ready_to_commit` other than acceptance; `summary.*` → `tools`/`assistant.*` directly; `navigation.ready_to_commit` → `summary.*`; terminal → anything. ### Result-boundary rule Every summary boundary (hook decline, hook-supplied result, generated success, terminal generation failure, model unavailability) plans on the mutation line and switches on `boundary` in one visible place: -- `resume_checkpoint` → publish result (when any), restore the marked checkpoint (success/threshold-decline) or enter `failure_drain` (overflow-decline/failure) — current behavior, expressed once; +- `resume_checkpoint` → publish result and restore the marked checkpoint on success or threshold decline; overflow decline or structural failure closes the compaction bracket and terminal-fails the run in the same commit; - `finish` → publish the compaction entry and terminal-complete (or terminal declined/failed) in one commit; - `commit_navigation` → the single move/summary/label/terminal commit (or terminal declined/failed). @@ -331,7 +364,7 @@ A `cancel_requested` boundary defers to reconciliation and never takes its conti ### Files -- `src/harness/session/types.ts` — the 14-leaf union, `OperationScope`, `SummaryTask`, `ResultBoundary`; delete `RunScope`, `CompactionScope`, `NavigationScope`, `NavigationSummaryScope`, `RunCompactionScope`, `StructuralTask`, per-family `Extract` aliases, `isRunOperationState`. +- `src/harness/session/types.ts` — the 13-leaf union, `OperationScope`, `SummaryTask`, `ResultBoundary`; delete `RunScope`, `CompactionScope`, `NavigationScope`, `NavigationSummaryScope`, `RunCompactionScope`, `StructuralTask`, per-family `Extract` aliases, `isRunOperationState`. - `src/harness/runtime/drive/structural.ts` — rebuild: keep `durableCompactionPreparation`/readers, the nested request seam, `performStructuralAttempt`, `runCompactionThreshold`, `prepareOverflowCompaction`, `commitNavigation`; delete the seven union aliases, the `startsWith` guards, and the three-armed `effectPendingFromReady`/`retryWaitFromEffect`/`readyFromRetryWait`/`publishStructuralReady`/`publishStructuralDecline`/`publishStructuralFailure`/`publishCompactionResult`-vs-`publishNavigationSummary` split; one quadruple of converters plus one boundary switch replace them. - `src/harness/runtime/drive/{checkpoint,generation,response,recovery,deferred,tools,tool-placement,terminal}.ts` — leaf literal renames (`run.checkpoint` → `checkpoint`, …); `response.ts` overflow arm constructs `summary.deciding` with `resume_checkpoint`; no other behavior change. - `src/harness/runtime/progress.ts` — frame/checkpoint fences currently match the leaf literals `"run.assistant.effect_pending"`, `"run.deferred.effect_pending"`, and `"run.tools"`; retarget them to the neutral leaves. @@ -357,7 +390,7 @@ Create `src/harness/runtime/drive/reconcile.ts`, `src/harness/runtime/drive.ts`, Package-private expected-id primitive: 1. synchronously `beginAbort` on a matching live Drive; -2. one commit on the Session line: `control = cancel_requested` **plus** removal of steer/followUp-tagged ids from the lane inbox and deletion of their `pendingEntry` values; payloads are read in the same mutation and returned; nextRun/write items stay; +2. one commit on the Session line: `control = cancel_requested` (with no drained fields) **plus** removal of steer/followUp-tagged ids from the lane inbox and deletion of their `pendingEntry` values; payloads are read in the same mutation and returned; nextRun/write items stay; 3. `signalAbort` after the commit; 4. return once cancellation is durable. Repeat calls observe the marker, drain nothing further, and report `newlyRequested: false`. @@ -365,13 +398,13 @@ With no Drive it commits the same marker and starts no pass. There is no `contro ### Reconciliation -Checked before `before_drive` and ordinary dispatch; a single switch over the 14 leaves; never starts new ordinary work. It must handle: +Checked before `before_drive` and ordinary dispatch; a single switch over the 13 leaves; never starts new ordinary work. It must handle: - assistant and deferred effects with live or reconstructed results; - planned/effect-pending/outcome-ready tool calls; - structural process-local results (discarded unless already atomically published); - cancelled summary boundaries finishing `aborted` without taking their `ResultBoundary` continuation; -- retry waits, checkpoints, suspended deferred work, failure drain; +- retry waits, checkpoints, and suspended deferred work; - best-effort deferred-provider cancellation; - the aborted terminal transaction (result record `status: "aborted"`); - the terminal-control invariant test: no terminal path commits a non-aborted status under cancelled control (this keeps §10's continuation rule decidable from the record alone). @@ -380,7 +413,7 @@ Queued lane-inbox items are **not** applied or deleted by reconciliation; nextRu ### Total switch -`runtime/drive.ts` owns one direct `state.at` switch over 14 leaves. It imports only complete procedure modules. No graph table, action interpreter, ownership-loss arm, external-finalization arm, or storage-state reload. A `continue` result must correspond to a replaced `Lane.state` projection **or** to currently observable `cancel_requested` control, which the switch routes to reconciliation before ordinary dispatch; any other unchanged continuation is an invariant defect. +`runtime/drive.ts` owns one direct `state.at` switch over 13 leaves. It imports only complete procedure modules. No graph table, action interpreter, ownership-loss arm, external-finalization arm, or storage-state reload. A `continue` result must correspond to a replaced `Lane.state` projection **or** to currently observable `cancel_requested` control, which the switch routes to reconciliation before ordinary dispatch; any other unchanged continuation is an invariant defect. Public methods remain guarded through M7. @@ -391,7 +424,7 @@ Remove execution guards only after every leaf and reconciliation path is total. Order: 1. admit compaction/navigation requests (acceptance captures `settings`; writes the R3 entry leaves); -2. implement `drive` install/join/record hydration (three arms, §7); +2. implement `drive` install/join/record lookup (three arms, §7); 3. expose `requestAbort`; 4. add convenience compositions; 5. add queues/configuration/usage/idle surfaces; @@ -403,17 +436,17 @@ Order: ### Convenience compositions and continuation -- `prompt`/`skill`/`promptFromTemplate`: accept + drive; return `Result`. +- `prompt`/`skill`/`promptFromTemplate`: accept + drive; return `Result` — the record for terminal outcomes, `SuspendedRun` when the run defers. - `compact`/`navigateTree`: accept A + drive A. If A settled with status `completed`, `declined`, or `failed` and eligible steer/followUp/nextRun items exist, accept a continuation run B with the **public empty-prompt request** (`{ kind: "prompt", prompt: "" }`): acceptance places no request messages and is legal exactly when its capture places at least one conversational item; `OperationMeta.intent` is an ordinary run intent with empty `promptEntryIds`. Then drive B and return `{ compaction|navigation: A, run?: B }`. Never after `aborted`: abort already drained steer/followUp, and the terminal-control invariant (§7) makes `status ∈ {completed, declined, failed}` imply the operation was never durably cancelled, so the rule is decidable from the record alone. B is an ordinary run — it emits `run_start`, runs `before_run` (with `prompt: []`, the existing captured-only acceptance shape), and owns the full run graph with no continuation-aware special case. - Continuation races: a competing accept that wins the idle window captures the queued input into its own run; the continuation accept then fails empty (`InvalidMessage`) or `LaneBusy`, and the convenience returns A without `run`. Both histories are valid. A crash between A's terminal and B's acceptance leaves the items queued; no auto-start on reopen. -- `resume`: inspect + drive current id, one deferred-poll permit; returns `OperationOutcome`. +- `resume`: inspect + drive current id, one deferred-poll permit; returns `OperationResultRecord | SuspendedRun`. - `abort`: inspect + `requestAbort` + ensure a reconciliation pass; returns the drained steer/followUp payloads. - `getResult(operationId)`: public. - Equivalence: every convenience call ≡ its primitive composition using only public request kinds (`compact()` with continuation ≡ `accept(A); drive(A); accept({ kind: "prompt", prompt: "" }); drive(B)`), byte-identical writes and events — externally reproducible by any scheduler. ### Focused validation -One install and same-id joins; stale-id isolation; record hydration for old ids; caller cancellation before/after installation; close/fault during cooperative and non-cooperative effects; accept/drive vs convenience equivalence including continuation via the public empty-prompt request; steer-only, followUp-only, and nextRun-only continuation each start run B; both continuation race orders; abort drain-and-return including the both-orders abort/settlement race; enqueue during every state through public surfaces; full crash matrix across all 14 leaves; no unhandled detached rejection; no `SliceNotImplemented` except `watchSession`. +One install and same-id joins; stale-id isolation; record lookup for old ids; caller cancellation before/after installation; close/fault during cooperative and non-cooperative effects; accept/drive vs convenience equivalence including continuation via the public empty-prompt request; steer-only, followUp-only, and nextRun-only continuation each start run B; both continuation race orders; abort drain-and-return including the both-orders abort/settlement race; enqueue during every state through public surfaces; full crash matrix across all 13 leaves; no unhandled detached rejection; no `SliceNotImplemented` except `watchSession`. ## 11. M9 — Documentation reconciliation @@ -422,12 +455,12 @@ Update `docs/harness.md` to the implemented state; it must again be normative on - §1.3 address table and lifetime grammar: new lane-lived `pi.result` namespace, `laneLastResult` removed, `LaneState.inbox`/`lastOperationId`; `pi.op.*` stays strictly operation-lived; - §1.7 JSONL/SQLite examples referencing `pi.lane.lastResult`; state the bounded growth tradeoff — one small immutable record per operation, retained forever, carried through JSONL snapshot compaction; - §2.9 precise rewrite: decide and document the policy for records whose `tipId` the rewrite removes (retain-dangling or delete); -- §3.1–§3.2 state shapes: 14 neutral leaves, `OperationScope`, `SummaryTask`/`ResultBoundary` (kind derived from boundary), `Control` without drained fields, `CheckpointData` without `skipInboxOnce`/`thresholdCheckedTriggerEntryId`; +- §3.1–§3.2 state shapes: 13 neutral leaves, `OperationScope`, `SummaryTask`/`ResultBoundary` (kind derived from boundary), `Control` without drained fields, `CheckpointData` without `skipInboxOnce`/`thresholdCheckedTriggerEntryId`; - §3.5 graph (one summary quadruple, boundary arms); -- §3.6 acceptance: per-tag mode-respecting capture (writes → nextRun → steer → followUp → prompt), idle followUp eligibility, empty-prompt continuation acceptance; +- §3.6 acceptance: per-tag mode-respecting selection, global admission-order placement, prompt entries last, idle followUp eligibility, and empty-prompt continuation acceptance; - §3.9/§3.10 summary machinery expressed once over the boundary datum; - §3.11 complete rewrite: one lane inbox, the selection table, one-decision-one-commit boundaries, silent deferral of late steer (an unconsumed steer becomes future-run input rather than an error), and unbounded write pendency during structural operations with the `waitForIdle`-then-append escape hatch; -- §3.12 checkpoint procedure: the one-commit boundary decision replaces the stepwise algorithm; `failure_drain` drain-and-rescue; +- §3.12 checkpoint procedure: the one-commit boundary decision replaces the stepwise algorithm; failures terminalize while queued lane input remains for a later ordinary run; - §3.13 terminal transactions: result records, universal suffix, observation contract (`drive(id)` total, `getResult`); - §4.6 abort: drain-and-return, no drained control, and the client-visible consequence — drained steer/followUp payloads exist only in the returned result, so a crash or lost response in that window loses their content permanently; - §5.1 lane surface and all result types; §5.5 `queue_update` and end-event payloads (`compaction_end` as a segment event with `aborted`); @@ -516,7 +549,7 @@ npm run check Run each modified focused test file from the package root. Do not invoke the full Vitest suite directly. Run `./test.sh` only for final package validation or when explicitly requested. -Review checkpoints are mandatory at the end of R3, M7, M10, and final completion. Delegated reviews use provider `anthropic` and model `claude-fable-5`. +Review checkpoints are mandatory at the end of R3, R1b, M7, M10, and final completion. Delegated reviews use provider `anthropic` and model `claude-fable-5`. Final greps: diff --git a/packages/agent/src/harness/agent-harness.ts b/packages/agent/src/harness/agent-harness.ts index 1bc5d55a2..d4a9fb530 100644 --- a/packages/agent/src/harness/agent-harness.ts +++ b/packages/agent/src/harness/agent-harness.ts @@ -22,7 +22,6 @@ import type { InvalidNavigation, LaneBusy, NoActiveOperation, - NoActiveRun, NothingToCompact, NothingToResume, OperationMismatch, @@ -53,14 +52,12 @@ export { import { createAgentHarness } from "./runtime/harness.ts"; import type { BranchScan, - BranchSummaryEntry, - CompactionEntry, Entry, EntryProjector, EntryType, JsonValue, - LaneLastResult, OperationError, + OperationResultRecord, Session, SettledAssistantMessage, UsageRow, @@ -74,54 +71,27 @@ import type { Skill, } from "./types.ts"; -export type OptionalFinalAssistant = - | { finalEntryId: string; finalMessage: AssistantMessage } - | { finalEntryId?: never; finalMessage?: never }; - -export type RunOutcome = - | ({ kind: "completed"; tipId: string } & OptionalFinalAssistant) - | ({ kind: "aborted"; tipId: string } & OptionalFinalAssistant) - | ({ kind: "failed"; tipId: string; error: OperationError } & OptionalFinalAssistant) - | { - kind: "suspended"; - reason: "deferred"; - tipId: string; - finalEntryId: string; - deferred: DeferredHandle; - }; - -export type CompactionOutcome = - | { kind: "completed"; tipId: string; entry: CompactionEntry } - | { kind: "declined" | "aborted"; tipId: string } - | { kind: "failed"; tipId: string; error: OperationError }; - -export type NavigationOutcome = - | { - kind: "completed"; - oldTipId: string | null; - newTipId: string | null; - summaryEntry?: BranchSummaryEntry; - } - | { kind: "declined" | "aborted"; tipId: string | null } - | { kind: "failed"; tipId: string | null; error: OperationError }; - -export type RunOperationOutcome = { operation: "run"; runId: string } & RunOutcome; -export type CompactionOperationOutcome = { operation: "compaction"; runId: string } & CompactionOutcome; -export type NavigationOperationOutcome = { operation: "navigation"; runId: string } & NavigationOutcome; -export type ResumeOutcome = RunOperationOutcome | CompactionOperationOutcome | NavigationOperationOutcome; +/** Convenience-only suspended run observation, constructed when M8 exposes public drive. */ +export interface SuspendedRun { + operationId: string; + status: "suspended"; + deferred: DeferredHandle; +} export type RunResult = Result< - RunOperationOutcome, + OperationResultRecord | SuspendedRun, LaneBusy | InvalidMessage | UnknownSkill | UnknownTemplate | Closed >; -export type CompactionResult = Result; +export type CompactionResult = Result< + { compaction: OperationResultRecord; run?: OperationResultRecord | SuspendedRun }, + LaneBusy | NothingToCompact | Closed +>; export type NavigationResult = Result< - NavigationOperationOutcome, + { navigation: OperationResultRecord; run?: OperationResultRecord | SuspendedRun }, LaneBusy | InvalidNavigation | UnknownTarget | Closed >; -export type ResumeResult = Result; -export type QueueResult = Result<{ entryId: string }, NoActiveRun | InvalidMessage | Closed>; -export type NextRunResult = Result<{ entryId: string }, InvalidMessage | Closed>; +export type ResumeResult = Result; +export type QueueResult = Result<{ entryId: string }, InvalidMessage | Closed>; export type CancelQueuedResult = Result<{ kind: "cancelled" | "already_consumed" | "not_found" }, Closed>; export type AbortResult = Result< { runId: string; steer: AgentMessage[]; followUp: AgentMessage[] }, @@ -186,16 +156,11 @@ export interface LaneExecutionInfo { tipId: string | null; configuredModel: ModelIdentity; current: CurrentOperationInfo | null; - lastResult?: LaneLastResult; + lastOperationId: string | null; } -export type TerminalOperationOutcome = - | ({ operation: "run"; runId: string } & Exclude) - | ({ operation: "compaction"; runId: string } & CompactionOutcome) - | ({ operation: "navigation"; runId: string } & NavigationOutcome); - export type DriveOutcome = - | { kind: "settled"; operationId: string; outcome: TerminalOperationOutcome } + | { kind: "settled"; outcome: OperationResultRecord } | { kind: "waiting"; operationId: string; reason: "retry"; notBefore: number } | { kind: "waiting"; operationId: string; reason: "deferred"; deferred: DeferredHandle }; export type DriveResult = Result; @@ -239,7 +204,7 @@ export interface LaneSnapshot { lane: string; transcript: Entry[]; tipId: string | null; - lastResult?: LaneLastResult; + lastOperationId: string | null; operation: null | { id: string; kind: "run" | "compaction" | "navigation"; @@ -277,9 +242,9 @@ export type HarnessEventPayload = | { type: "run_resume"; runId: string } | { type: "run_suspend"; runId: string; reason: "deferred"; deferred: DeferredHandle } | { type: "run_abort"; runId: string; steer: AgentMessage[]; followUp: AgentMessage[] } - | ({ type: "run_end"; runId: string; tipId: string | null } & ( - | ({ outcome: "completed" | "aborted" } & OptionalFinalAssistant) - | ({ outcome: "failed"; error: OperationError } & OptionalFinalAssistant) + | ({ type: "run_end"; runId: string; fromTipId: string | null; tipId: string | null } & ( + | { status: "completed" | "aborted"; error?: never } + | { status: "failed"; error: OperationError } )) | { type: "fault"; code: string; message: string } | ({ type: "handler_error"; error: string; stack?: string } & ( @@ -375,20 +340,14 @@ export type HarnessEventPayload = )) | { type: "compaction_start"; runId: string; reason: "manual" | "threshold" | "overflow" } | ({ type: "compaction_end"; runId: string; reason: "manual" | "threshold" | "overflow" } & ( - | { outcome: "completed"; entry: CompactionEntry; fromHook: boolean } - | { outcome: "declined" | "aborted" } - | { outcome: "failed"; error: OperationError } + | { status: "completed"; entryId: string; error?: never } + | { status: "declined" | "aborted"; entryId?: never; error?: never } + | { status: "failed"; entryId?: never; error: OperationError } )) | { type: "navigation_start"; runId: string; targetId: string | null } - | ({ - type: "navigation_end"; - runId: string; - oldTipId: string | null; - newTipId: string | null; - } & ( - | { outcome: "completed"; summaryEntry?: BranchSummaryEntry } - | { outcome: "declined" | "aborted"; summaryEntry?: never; error?: never } - | { outcome: "failed"; error: OperationError; summaryEntry?: never } + | ({ type: "navigation_end"; runId: string; fromTipId: string | null; tipId: string | null } & ( + | { status: "completed" | "declined" | "aborted"; error?: never } + | { status: "failed"; error: OperationError } )) | { type: "lane_created"; at: string | null } | { type: "usage"; lane: string; row: UsageRow; totals: Usage }; @@ -549,7 +508,7 @@ export interface AgentLane { findEntry(query: BranchScan | undefined, context: Context): Promise; appendMessage(message: AgentMessage, context: Context): Promise; appendCustomEntry(customType: string, data: JsonValue | undefined, context: Context): Promise; - getLastResult(context: Context): Promise; + getResult(operationId: string, context: Context): Promise; accept(request: OperationRequest, context: Context): Promise; drive(options: DriveOptions, context: Context): Promise; requestAbort(operationId: string, context: Context): Promise; @@ -568,11 +527,7 @@ export interface AgentLane { abort(context: Context): Promise; steer(message: string | AgentMessage, images: ImageContent[] | undefined, context: Context): Promise; followUp(message: string | AgentMessage, images: ImageContent[] | undefined, context: Context): Promise; - nextRun( - message: string | AgentMessage, - images: ImageContent[] | undefined, - context: Context, - ): Promise; + nextRun(message: string | AgentMessage, images: ImageContent[] | undefined, context: Context): Promise; cancelQueued(entryId: string, context: Context): Promise; recordUsage( usage: Usage, diff --git a/packages/agent/src/harness/runtime/drive/checkpoint.ts b/packages/agent/src/harness/runtime/drive/checkpoint.ts index 5e1764255..bf4ec2a1d 100644 --- a/packages/agent/src/harness/runtime/drive/checkpoint.ts +++ b/packages/agent/src/harness/runtime/drive/checkpoint.ts @@ -1,16 +1,12 @@ -import type { AgentMessage } from "../../../types.ts"; -import type { TerminalOperationOutcome } from "../../agent-harness.ts"; import { insertEntry } from "../../session/commit.ts"; import { SessionInvariantError } from "../../session/session.ts"; import { - type LaneLastResult, + type AssistantReadyOperation, + type CheckpointOperation, type MessageEntry, type NewEntry, - type RunAssistantReadyOperation, - type RunCheckpointOperation, - type RunStartingOperation, - runScopeOf, - type SettledAssistantMessage, + operationScopeOf, + type StartingOperation, } from "../../session/types.ts"; import { branchTip, deleteValue, pendingEntry, setValue } from "../../session/values.ts"; import type { Lane } from "../lane.ts"; @@ -23,19 +19,15 @@ import { } from "../transcript.ts"; import type { Drive, ProcedureResult } from "../types.ts"; import { runCompactionThreshold } from "./structural.ts"; -import { operationCleanupWrites } from "./terminal.ts"; +import { operationCleanupWrites, operationResultRecord } from "./terminal.ts"; -type FinishContinuation = Extract; - -function isSettledAssistant(message: AgentMessage): message is SettledAssistantMessage { - return message.role === "assistant" && message.stopReason !== "pending"; -} +type FinishContinuation = Extract; /** Consume before_run and commit the initial checkpoint. */ export async function startRun( lane: Lane, drive: Drive, - run: RunStartingOperation, + run: StartingOperation, ): Promise { const prompt = await lane.continueOperation( run, @@ -78,9 +70,9 @@ export async function startRun( ); const triggerEntryId = entries.at(-1)?.id ?? state.tipId; if (triggerEntryId === null) throw new SessionInvariantError("Run start has no trigger entry"); - const nextState: RunCheckpointOperation = { - ...runScopeOf(current), - at: "run.checkpoint", + const nextState: CheckpointOperation = { + ...operationScopeOf(current), + at: "checkpoint", continuation: { kind: "need_assistant", overflowRecoveryUsed: false }, triggerEntryId, }; @@ -104,12 +96,13 @@ export async function startRun( async function applyPendingWrites( lane: Lane, drive: Drive, - checkpoint: RunCheckpointOperation, + checkpoint: CheckpointOperation, ): Promise { const result = await lane.continueOperation( checkpoint, async (state, current, _meta, reader) => { - const ids = current.inbox.writes; + const items = state.inbox.filter((item) => item.kind === "write"); + const ids = items.map((item) => item.entryId); if (ids.length === 0) return { kind: "return", result: { kind: "continue" } as const }; const pending = await Promise.all( ids.map(async (id) => { @@ -137,14 +130,13 @@ async function applyPendingWrites( } return entry; }); - const inbox = { ...current.inbox, writes: [] }; - const nextState: RunCheckpointOperation = + const inbox = state.inbox.filter((item) => item.kind !== "write"); + const nextState: CheckpointOperation = triggerEntryId === undefined - ? { ...current, inbox } + ? current : { - ...runScopeOf(current), - inbox, - at: "run.checkpoint", + ...operationScopeOf(current), + at: "checkpoint", continuation: { kind: "need_assistant", overflowRecoveryUsed: false }, triggerEntryId, skipInboxOnce: true, @@ -157,7 +149,7 @@ async function applyPendingWrites( setValue(branchTip(lane.name), parentId), ], operationState: nextState, - lane: { tipId: parentId }, + lane: { tipId: parentId, inbox }, materialize: () => ({ kind: "continue" }) as const, events: (commit) => committedEntryEvents(entries, commit, lane.name, drive.operationId), }; @@ -170,38 +162,40 @@ async function applyPendingWrites( async function consumeQueuedMessages( lane: Lane, drive: Drive, - checkpoint: RunCheckpointOperation, + checkpoint: CheckpointOperation, queue: "steer" | "followUp", ): Promise { const result = await lane.continueOperation( checkpoint, async (state, current, _meta, reader) => { - const allIds = current.inbox[queue]; + const candidates = state.inbox.filter((item) => item.kind === queue); const mode = queue === "steer" ? current.settings.steeringMode : current.settings.followUpMode; - const ids = mode === "all" ? allIds : allIds.slice(0, 1); + const selected = mode === "all" ? candidates : candidates.slice(0, 1); + const ids = selected.map((item) => item.entryId); if (ids.length === 0) return { kind: "return", result: { kind: "continue" } as const }; const messages = (await readPendingMessages(reader, ids, `Queued ${queue} entry`, drive.context)).map( ({ entryId, message }) => ({ id: entryId, message }), ); - const remainingSteerIds = queue === "steer" ? allIds.slice(ids.length) : current.inbox.steer; - const remainingFollowUpIds = queue === "followUp" ? allIds.slice(ids.length) : current.inbox.followUp; + const selectedIds = new Set(ids); + const inbox = state.inbox.filter((item) => !selectedIds.has(item.entryId)); + const remainingIds = (kind: "steer" | "followUp" | "nextRun") => + inbox.filter((item) => item.kind === kind).map((item) => item.entryId); const [remainingSteer, remainingFollowUp, nextRun] = await Promise.all([ - readPendingMessages(reader, remainingSteerIds, "Steer entry", drive.context), - readPendingMessages(reader, remainingFollowUpIds, "Follow-up entry", drive.context), - readPendingMessages(reader, state.pendingNextRun, "Pending next-run entry", drive.context), + readPendingMessages(reader, remainingIds("steer"), "Steer entry", drive.context), + readPendingMessages(reader, remainingIds("followUp"), "Follow-up entry", drive.context), + readPendingMessages(reader, remainingIds("nextRun"), "Pending next-run entry", drive.context), ]); const entries: NewEntry[] = chainEntries( state.tipId, messages.map(({ id, message }) => ({ id, type: "message" as const, message })), ); const triggerEntryId = messages[messages.length - 1]!.id; - const nextState: RunCheckpointOperation = { - ...runScopeOf(current), - at: "run.checkpoint", + const nextState: CheckpointOperation = { + ...operationScopeOf(current), + at: "checkpoint", continuation: { kind: "need_assistant", overflowRecoveryUsed: false }, triggerEntryId, skipInboxOnce: true, - inbox: { ...current.inbox, [queue]: allIds.slice(ids.length) }, }; return { kind: "commit", @@ -211,7 +205,7 @@ async function consumeQueuedMessages( setValue(branchTip(lane.name), triggerEntryId), ], operationState: nextState, - lane: { tipId: triggerEntryId }, + lane: { tipId: triggerEntryId, inbox }, materialize: () => ({ kind: "continue" }) as const, events: (commit) => [ ...committedEntryEvents(entries, commit, lane.name, drive.operationId), @@ -233,7 +227,7 @@ async function consumeQueuedMessages( async function finishRun( lane: Lane, drive: Drive, - checkpoint: RunCheckpointOperation, + checkpoint: CheckpointOperation, continuation: FinishContinuation, ): Promise { const context = await readBoundedContext(lane, drive, checkpoint); @@ -252,20 +246,16 @@ async function finishRun( message: { role: "user" as const, content: hook.followUp, timestamp: Date.now() }, }; - const result = await lane.continueOperation( + const result = await lane.continueOperation( checkpoint, - async (state, current, _meta, reader) => { - if ( - current.inbox.steer.length !== 0 || - current.inbox.followUp.length !== 0 || - current.inbox.writes.length !== 0 - ) { + async (state, current, meta, reader) => { + if (state.inbox.some((item) => item.kind !== "nextRun")) { return { kind: "return", result: { kind: "continue" } as const }; } if (followUp !== undefined) { - const nextState: RunCheckpointOperation = { - ...runScopeOf(current), - at: "run.checkpoint", + const nextState: CheckpointOperation = { + ...operationScopeOf(current), + at: "checkpoint", continuation: { kind: "need_assistant", overflowRecoveryUsed: false }, triggerEntryId: followUp.id, }; @@ -291,53 +281,24 @@ async function finishRun( } if (state.tipId === null) throw new SessionInvariantError("Completed run has no tip"); - const finalId = continuation.includeFinalAssistant ? current.latestAssistantEntryId : null; - if (continuation.includeFinalAssistant && finalId === null) { + if (continuation.includeFinalAssistant && current.latestAssistantEntryId === null) { throw new SessionInvariantError("Completed run is missing its final assistant"); } - let finalEntry: { id: string; message: SettledAssistantMessage } | undefined; - if (finalId !== null) { - const entry = (await reader.getEntries([finalId], drive.context)).get(finalId); - if (entry?.type !== "message") { - throw new SessionInvariantError(`Run finish references invalid assistant ${finalId}`); - } - const message = entry.message; - if (!isSettledAssistant(message)) { - throw new SessionInvariantError(`Run finish references invalid assistant ${finalId}`); - } - finalEntry = { id: finalId, message }; - } - const final = - finalEntry === undefined ? {} : { finalEntryId: finalEntry.id, finalMessage: finalEntry.message }; - const outcome: TerminalOperationOutcome = { - operation: "run", - runId: drive.operationId, - kind: "completed", - tipId: state.tipId, - ...final, - }; - const lastResult: LaneLastResult = { - operationId: drive.operationId, - kind: "run", - outcome: "completed", - tipId: state.tipId, - runCompletion: finalId === null ? "terminated_tools" : "assistant", - ...(finalId === null ? {} : { finalAssistantEntryId: finalId }), - }; + const record = operationResultRecord(meta, "completed", state.tipId); const cleanup = await operationCleanupWrites(reader, drive.operationId, current, drive.context); return { kind: "finish", writes: cleanup, - lastResult, - materialize: () => ({ kind: "settled", outcome }) as const, + record, + materialize: () => ({ kind: "settled", outcome: record }) as const, events: () => [ { type: "run_end", lane: lane.name, runId: drive.operationId, + status: "completed", + fromTipId: meta.sourceTipId, tipId: state.tipId, - outcome: "completed", - ...final, }, ], }; @@ -351,19 +312,19 @@ async function finishRun( export async function runCheckpoint( lane: Lane, drive: Drive, - run: RunCheckpointOperation, + run: CheckpointOperation, ): Promise { - if (run.skipInboxOnce !== true && run.inbox.writes.length !== 0) { + if (run.skipInboxOnce !== true && lane.state.inbox.some((item) => item.kind === "write")) { return applyPendingWrites(lane, drive, run); } - if (run.skipInboxOnce !== true && run.inbox.steer.length !== 0) { + if (run.skipInboxOnce !== true && lane.state.inbox.some((item) => item.kind === "steer")) { return consumeQueuedMessages(lane, drive, run, "steer"); } if (run.thresholdCheckedTriggerEntryId !== run.triggerEntryId) { const result = await runCompactionThreshold(lane, drive, run); if (result.kind !== "continue") return result; const current = lane.state.operation?.state; - if (current?.control.status === "cancel_requested" || current?.at !== "run.checkpoint") return result; + if (current?.control.status === "cancel_requested" || current?.at !== "checkpoint") return result; return runCheckpoint(lane, drive, current); } const continuation = run.continuation; @@ -379,13 +340,13 @@ export async function runCheckpoint( (state, current) => { if ( current.skipInboxOnce !== true && - (current.inbox.writes.length !== 0 || current.inbox.steer.length !== 0) + state.inbox.some((item) => item.kind === "write" || item.kind === "steer") ) { return { kind: "return", result: { kind: "continue" } as const }; } - const nextState: RunAssistantReadyOperation = { - ...runScopeOf(current), - at: "run.assistant.ready", + const nextState: AssistantReadyOperation = { + ...operationScopeOf(current), + at: "assistant.ready", generationContext: { stepId, triggerEntryId: current.triggerEntryId, @@ -407,7 +368,7 @@ export async function runCheckpoint( ); return result.kind === "cancel_requested" ? { kind: "continue" } : result.value; } - if (run.inbox.followUp.length !== 0) { + if (lane.state.inbox.some((item) => item.kind === "followUp")) { return consumeQueuedMessages(lane, drive, run, "followUp"); } return finishRun(lane, drive, run, continuation); diff --git a/packages/agent/src/harness/runtime/drive/deferred.ts b/packages/agent/src/harness/runtime/drive/deferred.ts index 060fdb737..fa4552e0f 100644 --- a/packages/agent/src/harness/runtime/drive/deferred.ts +++ b/packages/agent/src/harness/runtime/drive/deferred.ts @@ -4,12 +4,12 @@ import { consumeAssistantStream } from "../../execution/assistant.ts"; import { applyStreamOptionsPatch } from "../../hooks.ts"; import { SessionInvariantError } from "../../session/session.ts"; import { + type DeferredEffectPendingOperation, + type DeferredSuspendedOperation, type LaneConfiguration, type OperationError, - type RunDeferredEffectPendingOperation, - type RunDeferredSuspendedOperation, - type RunScope, - runScopeOf, + type OperationScope, + operationScopeOf, type SettledAssistantMessage, } from "../../session/types.ts"; import { deleteList, pendingAssistantFrames } from "../../session/values.ts"; @@ -18,9 +18,9 @@ import type { Lane } from "../lane.ts"; import type { ContinueOperationResult, Drive, ProcedureResult } from "../types.ts"; import { openAssistantResponse, publishConfigurationFailure, publishResponse } from "./response.ts"; -type DeferredLeaf = RunDeferredSuspendedOperation | RunDeferredEffectPendingOperation; +type DeferredLeaf = DeferredSuspendedOperation | DeferredEffectPendingOperation; /** Deferred effect payload without the run-wide scope fields. */ -type EffectPendingFields = Omit; +type EffectPendingFields = Omit; type PreparedDeferredPoll = { kind: "ready"; @@ -92,7 +92,7 @@ async function prepareDeferredPoll( const model = lane.models.getModel(identity.provider, identity.modelId); if (model === undefined) return { kind: "configuration_failure" }; const baseOptions: AgentHarnessStreamOptions = { ...expected.streamOptions, deferred: false }; - const poll = expected.at === "run.deferred.suspended" ? expected.poll + 1 : expected.poll; + const poll = expected.at === "deferred.suspended" ? expected.poll + 1 : expected.poll; const beforeRequest = await lane.hooks.runWithGate( "before_request", { @@ -126,10 +126,10 @@ async function publishPollIntent( deferred: DeferredLeaf, prepared: PreparedDeferredPoll, recovery: boolean, -): Promise> { +): Promise> { const at = Date.now(); const next: EffectPendingFields = { - at: "run.deferred.effect_pending", + at: "deferred.effect_pending", stepId: deferred.stepId, sourceEntryId: deferred.sourceEntryId, poll: prepared.poll, @@ -141,11 +141,11 @@ async function publishPollIntent( return lane.continueOperation( deferred, (_state, current) => { - const nextState: RunDeferredEffectPendingOperation = { ...runScopeOf(current), ...next }; + const nextState: DeferredEffectPendingOperation = { ...operationScopeOf(current), ...next }; return { kind: "commit", writes: - deferred.at === "run.deferred.effect_pending" + deferred.at === "deferred.effect_pending" ? [deleteList(pendingAssistantFrames(drive.operationId, deferred.responseEntryId))] : [], operationState: nextState, @@ -172,7 +172,7 @@ async function performDeferredPoll( lane: Lane, drive: Drive, prepared: PreparedDeferredPoll, - intent: RunDeferredEffectPendingOperation, + intent: DeferredEffectPendingOperation, recovery: boolean, ): Promise { const response = openAssistantResponse(lane, drive, intent.responseEntryId, recovery); @@ -234,15 +234,7 @@ async function pollDeferred( }; } if (prepared.kind === "configuration_failure") { - return publishConfigurationFailure( - lane, - drive, - expected, - configurationError(expected.configuration.model), - expected.at === "run.deferred.effect_pending" - ? [deleteList(pendingAssistantFrames(drive.operationId, expected.responseEntryId))] - : [], - ); + return publishConfigurationFailure(lane, drive, expected, configurationError(expected.configuration.model)); } const intent = await publishPollIntent(lane, drive, expected, prepared, recovery); @@ -255,7 +247,7 @@ async function pollDeferred( export function runDeferredSuspended( lane: Lane, drive: Drive, - deferred: RunDeferredSuspendedOperation, + deferred: DeferredSuspendedOperation, ): Promise { return pollDeferred(lane, drive, deferred, false); } @@ -264,7 +256,7 @@ export function runDeferredSuspended( export function recoverDeferredPoll( lane: Lane, drive: Drive, - deferred: RunDeferredEffectPendingOperation, + deferred: DeferredEffectPendingOperation, ): Promise { return pollDeferred(lane, drive, deferred, true); } @@ -273,9 +265,9 @@ export function recoverDeferredPoll( export function runDeferred( lane: Lane, drive: Drive, - deferred: RunDeferredSuspendedOperation | RunDeferredEffectPendingOperation, + deferred: DeferredSuspendedOperation | DeferredEffectPendingOperation, ): Promise { - return deferred.at === "run.deferred.suspended" + return deferred.at === "deferred.suspended" ? runDeferredSuspended(lane, drive, deferred) : recoverDeferredPoll(lane, drive, deferred); } diff --git a/packages/agent/src/harness/runtime/drive/generation.ts b/packages/agent/src/harness/runtime/drive/generation.ts index b9fb1748d..13260550c 100644 --- a/packages/agent/src/harness/runtime/drive/generation.ts +++ b/packages/agent/src/harness/runtime/drive/generation.ts @@ -5,13 +5,13 @@ import { type HarnessAssistantStreamConfig, streamHarnessAssistant } from "../.. import { applyStreamOptionsPatch } from "../../hooks.ts"; import { SessionInvariantError } from "../../session/session.ts"; import { + type AssistantEffectPendingOperation, + type AssistantReadyOperation, + type AssistantRetryWaitOperation, type JsonValue, type OperationError, - type RunAssistantEffectPendingOperation, - type RunAssistantReadyOperation, - type RunAssistantRetryWaitOperation, - type RunScope, - runScopeOf, + type OperationScope, + operationScopeOf, type SettledAssistantMessage, } from "../../session/types.ts"; import type { AgentHarnessStreamOptions } from "../../types.ts"; @@ -22,7 +22,7 @@ import { openAssistantResponse, publishConfigurationFailure, publishResponse } f import { waitUntil } from "./retry.ts"; /** Assistant effect-pending payload without the run-wide scope fields. */ -type AssistantEffectPending = Omit; +type AssistantEffectPending = Omit; type PreparedGeneration = { kind: "ready"; @@ -68,7 +68,7 @@ async function resolveSystemPrompt( async function prepareGeneration( lane: Lane, drive: Drive, - generation: RunAssistantReadyOperation, + generation: AssistantReadyOperation, ): Promise { const identity = generation.generationContext.configuration.model; const model = lane.models.getModel(identity.provider, identity.modelId); @@ -132,12 +132,12 @@ async function prepareGeneration( async function publishGenerationIntent( lane: Lane, drive: Drive, - ready: RunAssistantReadyOperation, + ready: AssistantReadyOperation, prepared: PreparedGeneration, -): Promise> { +): Promise> { const at = Date.now(); const pending: AssistantEffectPending = { - at: "run.assistant.effect_pending", + at: "assistant.effect_pending", generationContext: ready.generationContext, attempt: ready.nextAttempt, responseEntryId: lane.session.idGenerator.next(at), @@ -148,7 +148,7 @@ async function publishGenerationIntent( return lane.continueOperation( ready, (_state, current) => { - const nextState: RunAssistantEffectPendingOperation = { ...runScopeOf(current), ...pending }; + const nextState: AssistantEffectPendingOperation = { ...operationScopeOf(current), ...pending }; return { kind: "commit", writes: [], @@ -174,7 +174,7 @@ async function publishGenerationIntent( async function performGeneration( lane: Lane, drive: Drive, - intent: RunAssistantEffectPendingOperation, + intent: AssistantEffectPendingOperation, prepared: PreparedGeneration, ): Promise { const response = openAssistantResponse(lane, drive, intent.responseEntryId); @@ -233,7 +233,7 @@ async function performGeneration( export async function runRetryWait( lane: Lane, drive: Drive, - generation: RunAssistantRetryWaitOperation, + generation: AssistantRetryWaitOperation, ): Promise { if (Date.now() < generation.notBefore) { if (!drive.waitForRetry) { @@ -253,9 +253,9 @@ export async function runRetryWait( const result = await lane.continueOperation( generation, (_state, current) => { - const nextState: RunAssistantReadyOperation = { - ...runScopeOf(current), - at: "run.assistant.ready", + const nextState: AssistantReadyOperation = { + ...operationScopeOf(current), + at: "assistant.ready", generationContext: generation.generationContext, nextAttempt: generation.nextAttempt, }; @@ -284,9 +284,9 @@ export async function runRetryWait( export async function runGeneration( lane: Lane, drive: Drive, - generation: RunAssistantReadyOperation | RunAssistantRetryWaitOperation, + generation: AssistantReadyOperation | AssistantRetryWaitOperation, ): Promise { - if (generation.at === "run.assistant.retry_wait") return runRetryWait(lane, drive, generation); + if (generation.at === "assistant.retry_wait") return runRetryWait(lane, drive, generation); const prepared = await prepareGeneration(lane, drive, generation); if (prepared.kind === "configuration_failure") { diff --git a/packages/agent/src/harness/runtime/drive/recovery.ts b/packages/agent/src/harness/runtime/drive/recovery.ts index 8afa40f96..dcab871c3 100644 --- a/packages/agent/src/harness/runtime/drive/recovery.ts +++ b/packages/agent/src/harness/runtime/drive/recovery.ts @@ -1,5 +1,5 @@ import { type AssistantMessage, reduceAssistantMessageFrames } from "@earendil-works/pi-ai"; -import type { RunAssistantEffectPendingOperation, SettledAssistantMessage } from "../../session/types.ts"; +import type { AssistantEffectPendingOperation, SettledAssistantMessage } from "../../session/types.ts"; import type { Lane } from "../lane.ts"; import { readAssistantFrames } from "../progress.ts"; import type { Drive, ProcedureResult } from "../types.ts"; @@ -15,7 +15,7 @@ const ZERO_USAGE = { }; function interruptedAssistantMessage( - generation: RunAssistantEffectPendingOperation, + generation: AssistantEffectPendingOperation, partial: AssistantMessage | undefined, ): SettledAssistantMessage { const warning = @@ -39,7 +39,7 @@ function interruptedAssistantMessage( export async function recoverAssistantGeneration( lane: Lane, drive: Drive, - generation: RunAssistantEffectPendingOperation, + generation: AssistantEffectPendingOperation, ): Promise { const frames = await lane.continueOperation( generation, diff --git a/packages/agent/src/harness/runtime/drive/response.ts b/packages/agent/src/harness/runtime/drive/response.ts index e7433c687..b5c992edb 100644 --- a/packages/agent/src/harness/runtime/drive/response.ts +++ b/packages/agent/src/harness/runtime/drive/response.ts @@ -11,19 +11,18 @@ import type { AssistantResponseMetadata, AssistantStreamObserver } from "../../e import { insertEntry, insertUsage } from "../../session/commit.ts"; import { SessionInvariantError } from "../../session/session.ts"; import { + type AssistantEffectPendingOperation, + type CommitResult, + type DeferredEffectPendingOperation, type MessageEntry, type NewEntry, type OperationError, type OperationState, - type RunAssistantEffectPendingOperation, - type RunCompactionDecidingOperation, - type RunDeferredEffectPendingOperation, - type RunOperationState, - runScopeOf, + operationScopeOf, type SettledAssistantMessage, + type SummaryDecidingOperation, type ToolCall, type UsageRow, - type Write, } from "../../session/types.ts"; import { branchTip, deleteList, operationPreparation, pendingAssistantFrames, setValue } from "../../session/values.ts"; import type { Lane } from "../lane.ts"; @@ -31,6 +30,7 @@ import { openFrameProgress } from "../progress.ts"; import type { Drive, ProcedureResult } from "../types.ts"; import { retryDelay, retryNotBefore } from "./retry.ts"; import { prepareOverflowCompaction } from "./structural.ts"; +import { operationCleanupWrites, operationResultRecord } from "./terminal.ts"; export type AssistantResponseLifecycle = { observer: AssistantStreamObserver; @@ -95,32 +95,41 @@ export function openAssistantResponse( }; } -type ResponseIntent = RunAssistantEffectPendingOperation | RunDeferredEffectPendingOperation; +type ResponseIntent = AssistantEffectPendingOperation | DeferredEffectPendingOperation; +type ConfigurationFailureState = Extract< + OperationState, + { at: "assistant.ready" | "assistant.retry_wait" | "deferred.suspended" | "deferred.effect_pending" } +>; /** Publish a non-retryable request-configuration failure before reserving response ids. */ export async function publishConfigurationFailure< TContext extends object | undefined, - TState extends RunOperationState, ->( - lane: Lane, - drive: Drive, - capability: TState, - error: OperationError, - writes: Write[] = [], -): Promise { + TState extends ConfigurationFailureState, +>(lane: Lane, drive: Drive, capability: TState, error: OperationError): Promise { const result = await lane.continueOperation( capability, - (_state, current) => ({ - kind: "commit", - writes, - operationState: { - ...runScopeOf(current), - at: "run.failure_drain", - error, - provenance: { kind: "configuration" }, - }, - materialize: () => ({ kind: "continue" }) as const, - }), + async (state, current, meta, reader) => { + if (state.tipId === null) throw new SessionInvariantError("Failed run has no Branch tip"); + const record = operationResultRecord(meta, "failed", state.tipId, error); + const cleanup = await operationCleanupWrites(reader, drive.operationId, current, drive.context); + return { + kind: "finish", + writes: cleanup, + record, + materialize: () => ({ kind: "settled", outcome: record }) as const, + events: () => [ + { + type: "run_end", + lane: lane.name, + runId: drive.operationId, + status: "failed", + error, + fromTipId: meta.sourceTipId, + tipId: state.tipId, + }, + ], + }; + }, drive.context, ); return result.kind === "cancel_requested" ? { kind: "continue" } : result.value; @@ -154,10 +163,7 @@ function normalizeAborted(source: "assistant" | "deferred", message: SettledAssi }; } -function deferredHandleIsValid( - message: SettledAssistantMessage, - generation: RunAssistantEffectPendingOperation, -): boolean { +function deferredHandleIsValid(message: SettledAssistantMessage, generation: AssistantEffectPendingOperation): boolean { const handle = message.deferred; const identity = generation.generationContext.configuration.model; return ( @@ -179,34 +185,33 @@ export async function publishResponse( options: { recovery?: true } = {}, ): Promise { const overflow = - intent.at === "run.assistant.effect_pending" && + intent.at === "assistant.effect_pending" && (isContextOverflow(response, intent.contextWindow) || isRecoverableLength(response, intent.intendedOutputLimit)); const overflowPreparation = overflow && !intent.generationContext.overflowRecoveryUsed ? await prepareOverflowCompaction(lane, drive, intent) : undefined; - return lane.settleOperation( + return lane.settleOperation( intent, - (state, current) => { - const source = current.at === "run.assistant.effect_pending" ? "assistant" : "deferred"; + async (state, current, meta, reader) => { + const source = current.at === "assistant.effect_pending" ? "assistant" : "deferred"; const responseEntryId = intent.responseEntryId; const configuration = - current.at === "run.assistant.effect_pending" - ? current.generationContext.configuration - : current.configuration; + current.at === "assistant.effect_pending" ? current.generationContext.configuration : current.configuration; const turnId = - current.at === "run.assistant.effect_pending" + current.at === "assistant.effect_pending" ? current.generationContext.stepId : `${current.stepId}:poll:${current.poll}`; - const scope = { ...runScopeOf(current), latestAssistantEntryId: responseEntryId }; + const scope = { ...operationScopeOf(current), latestAssistantEntryId: responseEntryId }; let committed = response; - let settled: OperationState; + let settled: OperationState | undefined; + let failure: OperationError | undefined; if (current.control.status === "cancel_requested") { committed = normalizeAborted(source, response); settled = { ...scope, - at: "run.checkpoint", + at: "checkpoint", continuation: { kind: "may_finish", includeFinalAssistant: true }, triggerEntryId: responseEntryId, }; @@ -214,38 +219,38 @@ export async function publishResponse( throw new SessionInvariantError( `${source === "assistant" ? "Assistant" : "Deferred"} response is aborted while durable control is running`, ); - } else if (current.at === "run.assistant.effect_pending" && overflow) { + } else if (current.at === "assistant.effect_pending" && overflow) { committed = normalizeError( response, response.errorMessage ?? "Assistant request exceeded the context window", ); if (current.generationContext.overflowRecoveryUsed || overflowPreparation === undefined) { - settled = { - ...scope, - at: "run.failure_drain", - error: providerError(source, committed), - provenance: { kind: "response", entryId: responseEntryId }, - }; + failure = providerError(source, committed); } else { - const structural: RunCompactionDecidingOperation = { + const structural: SummaryDecidingOperation = { ...scope, - at: "run.compaction.deciding", - reason: "overflow", - resumeAfter: { - continuation: { kind: "need_assistant", overflowRecoveryUsed: true }, - triggerEntryId: current.generationContext.triggerEntryId, - thresholdCheckedTriggerEntryId: current.generationContext.triggerEntryId, + at: "summary.deciding", + task: { + taskId: overflowPreparation.taskId, + reason: "overflow", + boundary: { + kind: "resume_checkpoint", + resumeAfter: { + continuation: { kind: "need_assistant", overflowRecoveryUsed: true }, + triggerEntryId: current.generationContext.triggerEntryId, + thresholdCheckedTriggerEntryId: current.generationContext.triggerEntryId, + }, + }, }, - taskId: overflowPreparation.taskId, }; settled = structural; } } else if (response.stopReason === "deferred") { - if (current.at === "run.assistant.effect_pending") { + if (current.at === "assistant.effect_pending") { if (deferredHandleIsValid(response, current)) { settled = { ...scope, - at: "run.deferred.suspended", + at: "deferred.suspended", stepId: current.generationContext.stepId, sourceEntryId: responseEntryId, poll: 0, @@ -254,17 +259,12 @@ export async function publishResponse( }; } else { committed = normalizeError(response, "Provider returned an invalid deferred handle"); - settled = { - ...scope, - at: "run.failure_drain", - error: providerError(source, committed), - provenance: { kind: "response", entryId: responseEntryId }, - }; + failure = providerError(source, committed); } } else { settled = { ...scope, - at: "run.deferred.suspended", + at: "deferred.suspended", stepId: current.stepId, sourceEntryId: responseEntryId, poll: current.poll, @@ -274,25 +274,20 @@ export async function publishResponse( } } else if (response.stopReason === "error") { if ( - current.at === "run.assistant.effect_pending" && + current.at === "assistant.effect_pending" && (options.recovery === true || isRetryableAssistantError(response)) && current.attempt < current.generationContext.retryPolicy.maxAttempts ) { settled = { ...scope, - at: "run.assistant.retry_wait", + at: "assistant.retry_wait", generationContext: current.generationContext, nextAttempt: current.attempt + 1, notBefore: retryNotBefore(current.generationContext.retryPolicy.baseDelayMs, current.attempt), errorMessage: response.errorMessage ?? "Assistant request failed", }; } else { - settled = { - ...scope, - at: "run.failure_drain", - error: providerError(source, response), - provenance: { kind: "response", entryId: responseEntryId }, - }; + failure = providerError(source, response); } } else { const calls = response.content.flatMap((content, sourceIndex) => @@ -307,21 +302,16 @@ export async function publishResponse( })); settled = { ...scope, - at: "run.tools", + at: "tools", batch: { assistantEntryId: responseEntryId, configuration, turnId, calls: planned }, }; } else if (response.stopReason === "toolUse") { committed = normalizeError(response, "Provider reported tool use without any tool calls"); - settled = { - ...scope, - at: "run.failure_drain", - error: providerError(source, committed), - provenance: { kind: "response", entryId: responseEntryId }, - }; + failure = providerError(source, committed); } else { settled = { ...scope, - at: "run.checkpoint", + at: "checkpoint", continuation: { kind: "may_finish", includeFinalAssistant: true }, triggerEntryId: responseEntryId, }; @@ -340,119 +330,149 @@ export async function publishResponse( entryId: responseEntryId, adjustment: false, }; - return { - kind: "commit", - writes: [ - insertEntry(responseEntry), - insertUsage(usageRow), - setValue(branchTip(lane.name), responseEntryId), - deleteList(pendingAssistantFrames(drive.operationId, responseEntryId)), - ...(settled.at === "run.compaction.deciding" && overflowPreparation !== undefined - ? [ - setValue( - operationPreparation(drive.operationId, overflowPreparation.taskId), - overflowPreparation.preparation, - ), - ] - : []), - ], - operationState: settled, - lane: { tipId: responseEntryId }, - materialize: () => ({ kind: "continue" }) as const, - events: (commit) => { - const entry: MessageEntry = { - ...responseEntry, - seq: commit.seqs[0]!, - timestamp: commit.timestamp, - }; - const events: HarnessEvent[] = [ - { type: "entry_added", lane: lane.name, entry, ...options }, - { - type: "usage", + if (settled === undefined && failure === undefined) { + throw new SessionInvariantError("Response settlement has no durable disposition"); + } + const record = + failure === undefined ? undefined : operationResultRecord(meta, "failed", responseEntryId, failure); + const cleanup = + record === undefined ? [] : await operationCleanupWrites(reader, drive.operationId, current, drive.context); + const writes = [ + insertEntry(responseEntry), + insertUsage(usageRow), + setValue(branchTip(lane.name), responseEntryId), + ...(record === undefined + ? [deleteList(pendingAssistantFrames(drive.operationId, responseEntryId))] + : cleanup), + ...(settled?.at === "summary.deciding" && overflowPreparation !== undefined + ? [ + setValue( + operationPreparation(drive.operationId, overflowPreparation.taskId), + overflowPreparation.preparation, + ), + ] + : []), + ]; + const materializeEntry = (commit: CommitResult): MessageEntry => ({ + ...responseEntry, + seq: commit.seqs[0]!, + timestamp: commit.timestamp, + }); + const events = (commit: CommitResult): HarnessEvent[] => { + const entry = materializeEntry(commit); + const batch: HarnessEvent[] = [ + { type: "entry_added", lane: lane.name, entry, ...options }, + { + type: "usage", + lane: lane.name, + row: { ...usageRow, seq: commit.seqs[1]! }, + totals: commit.stats.usage, + }, + ]; + if (current.at === "assistant.effect_pending") { + if (options.recovery !== true && current.attempt > 1 && settled?.at !== "assistant.retry_wait") { + const success = committed.stopReason !== "error" && committed.stopReason !== "aborted"; + batch.push({ + type: "retry_end", lane: lane.name, - row: { ...usageRow, seq: commit.seqs[1]! }, - totals: commit.stats.usage, - }, - ]; - if (current.at === "run.assistant.effect_pending") { - if (options.recovery !== true && current.attempt > 1 && settled.at !== "run.assistant.retry_wait") { - const success = committed.stopReason !== "error" && committed.stopReason !== "aborted"; - events.push({ - type: "retry_end", - lane: lane.name, - runId: drive.operationId, - step: turnId, - attempt: current.attempt, - success, - ...(success - ? {} - : { - finalError: - committed.errorMessage ?? `Assistant request ended with ${committed.stopReason}`, - }), - }); - } - if (options.recovery !== true && settled.at === "run.assistant.retry_wait") { - events.push({ - type: "retry_scheduled", - lane: lane.name, - runId: drive.operationId, - step: turnId, - attempt: settled.nextAttempt, - maxAttempts: settled.generationContext.retryPolicy.maxAttempts, - delayMs: retryDelay(current.generationContext.retryPolicy.baseDelayMs, current.attempt), - errorMessage: settled.errorMessage, - }); - } - if ( - options.recovery !== true && - settled.at !== "run.tools" && - settled.at !== "run.assistant.retry_wait" - ) { - events.push({ - type: "turn_end", - lane: lane.name, - runId: drive.operationId, - turnId, - message: committed, - toolResults: [], - }); - } - if (settled.at === "run.compaction.deciding") { - events.push({ - type: "compaction_start", - lane: lane.name, - runId: drive.operationId, - reason: "overflow", - }); - } - } else if (settled.at !== "run.tools") { - events.push({ + runId: drive.operationId, + step: turnId, + attempt: current.attempt, + success, + ...(success + ? {} + : { + finalError: + committed.errorMessage ?? `Assistant request ended with ${committed.stopReason}`, + }), + }); + } + if (options.recovery !== true && settled?.at === "assistant.retry_wait") { + batch.push({ + type: "retry_scheduled", + lane: lane.name, + runId: drive.operationId, + step: turnId, + attempt: settled.nextAttempt, + maxAttempts: settled.generationContext.retryPolicy.maxAttempts, + delayMs: retryDelay(current.generationContext.retryPolicy.baseDelayMs, current.attempt), + errorMessage: settled.errorMessage, + }); + } + if (options.recovery !== true && settled?.at !== "tools" && settled?.at !== "assistant.retry_wait") { + batch.push({ type: "turn_end", lane: lane.name, runId: drive.operationId, turnId, message: committed, toolResults: [], - ...options, }); } - if ( - (current.at === "run.deferred.effect_pending" || options.recovery !== true) && - settled.at === "run.deferred.suspended" && - committed.deferred !== undefined - ) { - events.push({ - type: "run_suspend", + if (settled?.at === "summary.deciding") { + batch.push({ + type: "compaction_start", lane: lane.name, runId: drive.operationId, - reason: "deferred", - deferred: committed.deferred, - ...(current.at === "run.deferred.effect_pending" ? options : {}), + reason: "overflow", }); } - return events; - }, + } else if (settled?.at !== "tools") { + batch.push({ + type: "turn_end", + lane: lane.name, + runId: drive.operationId, + turnId, + message: committed, + toolResults: [], + ...options, + }); + } + if ( + (current.at === "deferred.effect_pending" || options.recovery !== true) && + settled?.at === "deferred.suspended" && + committed.deferred !== undefined + ) { + batch.push({ + type: "run_suspend", + lane: lane.name, + runId: drive.operationId, + reason: "deferred", + deferred: committed.deferred, + ...(current.at === "deferred.effect_pending" ? options : {}), + }); + } + if (record !== undefined && failure !== undefined) { + batch.push({ + type: "run_end", + lane: lane.name, + runId: drive.operationId, + status: "failed", + error: failure, + fromTipId: meta.sourceTipId, + tipId: responseEntryId, + }); + } + return batch; + }; + if (record !== undefined) { + return { + kind: "finish", + writes, + record, + lane: { tipId: responseEntryId }, + materialize: () => ({ kind: "settled", outcome: record }) as const, + events, + }; + } + if (settled === undefined) throw new SessionInvariantError("Response settlement is missing its next state"); + return { + kind: "commit", + writes, + operationState: settled, + lane: { tipId: responseEntryId }, + materialize: () => ({ kind: "continue" }) as const, + events, }; }, drive.context, diff --git a/packages/agent/src/harness/runtime/drive/structural.ts b/packages/agent/src/harness/runtime/drive/structural.ts index 37808a9de..f08a54e8f 100644 --- a/packages/agent/src/harness/runtime/drive/structural.ts +++ b/packages/agent/src/harness/runtime/drive/structural.ts @@ -5,7 +5,7 @@ import { type Model, type SimpleStreamOptions, } from "@earendil-works/pi-ai"; -import type { HarnessEvent, TerminalOperationOutcome } from "../../agent-harness.ts"; +import type { HarnessEvent } from "../../agent-harness.ts"; import type { BranchPreparation, BranchSummaryResult } from "../../compaction/branch-summarization.ts"; import { generateBranchSummaryWithRequest } from "../../compaction/branch-summarization.ts"; import type { CompactionPreparation, CompactResult, SummaryRequest } from "../../compaction/compaction.ts"; @@ -16,35 +16,27 @@ import { applyStreamOptionsPatch } from "../../hooks.ts"; import { insertEntry, insertUsage } from "../../session/commit.ts"; import { SessionInvariantError } from "../../session/session.ts"; import { + type AssistantEffectPendingOperation, type BranchSummaryEntry, + type CheckpointOperation, type CommitResult, - type CompactionDecidingOperation, - type CompactionEffectPendingOperation, type CompactionEntry, - type CompactionReadyOperation, - type CompactionRetryWaitOperation, type DurableFileOperations, type DurableStructuralPreparation, type JsonValue, type LaneConfiguration, - type LaneLastResult, type NavigationReadyToCommitOperation, - type NavigationSummaryDecidingOperation, - type NavigationSummaryEffectPendingOperation, - type NavigationSummaryReadyOperation, - type NavigationSummaryRetryWaitOperation, type NewEntry, type NormalizedRetryPolicy, type OperationError, - type RunAssistantEffectPendingOperation, - type RunCheckpointOperation, - type RunCompactionDecidingOperation, - type RunCompactionEffectPendingOperation, - type RunCompactionReadyOperation, - type RunCompactionRetryWaitOperation, - type RunFailureDrainOperation, - runScopeOf, + operationScopeOf, + type ResultBoundary, type SummaryContext, + type SummaryDecidingOperation, + type SummaryEffectPendingOperation, + type SummaryReadyOperation, + type SummaryRetryWaitOperation, + type SummaryTask, type UsageRow, type Write, } from "../../session/types.ts"; @@ -54,56 +46,7 @@ import type { Lane } from "../lane.ts"; import { committedEntryEvents, readBoundedEntries } from "../transcript.ts"; import type { ContinueOperationResult, Drive, ProcedureResult } from "../types.ts"; import { retryDelay, retryNotBefore, waitUntil } from "./retry.ts"; -import { operationCleanupWrites } from "./terminal.ts"; - -type StructuralDeciding = - | RunCompactionDecidingOperation - | CompactionDecidingOperation - | NavigationSummaryDecidingOperation; -type StructuralReady = RunCompactionReadyOperation | CompactionReadyOperation | NavigationSummaryReadyOperation; -type StructuralEffectPending = - | RunCompactionEffectPendingOperation - | CompactionEffectPendingOperation - | NavigationSummaryEffectPendingOperation; -type StructuralRetryWait = - | RunCompactionRetryWaitOperation - | CompactionRetryWaitOperation - | NavigationSummaryRetryWaitOperation; -type CompactionStructural = - | RunCompactionDecidingOperation - | RunCompactionReadyOperation - | RunCompactionEffectPendingOperation - | RunCompactionRetryWaitOperation - | CompactionDecidingOperation - | CompactionReadyOperation - | CompactionEffectPendingOperation - | CompactionRetryWaitOperation; -type NavigationSummaryStructural = - | NavigationSummaryDecidingOperation - | NavigationSummaryReadyOperation - | NavigationSummaryEffectPendingOperation - | NavigationSummaryRetryWaitOperation; -type RunCompactionStructural = Extract; -type StandaloneCompactionStructural = Extract; -type GeneratedStructural = StructuralReady | StructuralEffectPending; -type RunGeneratedCompaction = Extract; -type StandaloneGeneratedCompaction = Extract; - -function isRunGeneratedCompaction(state: GeneratedStructural): state is RunGeneratedCompaction { - return state.at.startsWith("run.compaction."); -} - -function isStandaloneGeneratedCompaction(state: GeneratedStructural): state is StandaloneGeneratedCompaction { - return state.at.startsWith("compaction."); -} - -function isRunCompaction(state: CompactionStructural): state is RunCompactionStructural { - return state.at.startsWith("run.compaction."); -} - -function isStandaloneCompaction(state: CompactionStructural): state is StandaloneCompactionStructural { - return state.at.startsWith("compaction."); -} +import { operationCleanupWrites, operationResultRecord } from "./terminal.ts"; class StructuralCancelled extends Error { constructor() { @@ -176,43 +119,54 @@ function branchPreparation( }; } -async function readCompactionPreparation( - lane: Lane, - drive: Drive, - deciding: RunCompactionDecidingOperation | CompactionDecidingOperation, -): Promise> { - return lane.continueOperation( - deciding, - async (_state, current, _meta, reader) => { - const stored = await reader.getValue(operationPreparation(drive.operationId, current.taskId), drive.context); - if (stored?.value.kind !== "compaction") { - throw new SessionInvariantError(`Structural task ${current.taskId} is missing its compaction preparation`); - } - return { kind: "return", result: compactionPreparation(stored.value) }; - }, - drive.context, - ); +function summaryKind(task: SummaryTask): DurableStructuralPreparation["kind"] { + return task.boundary.kind === "commit_navigation" ? "branch_summary" : "compaction"; } -async function readBranchPreparation( +function compactionReason(task: SummaryTask): "manual" | "threshold" | "overflow" { + if (task.reason !== undefined) return task.reason; + if (task.boundary.kind === "finish") return "manual"; + throw new SessionInvariantError(`In-run compaction task ${task.taskId} is missing its reason`); +} + +function navigationBoundary(task: SummaryTask): Extract { + if (task.boundary.kind !== "commit_navigation") { + throw new SessionInvariantError(`Summary task ${task.taskId} is not a navigation`); + } + return task.boundary; +} + +async function readStructuralPreparation( lane: Lane, drive: Drive, - deciding: NavigationSummaryDecidingOperation, -): Promise> { + deciding: SummaryDecidingOperation, +): Promise> { return lane.continueOperation( deciding, async (_state, current, _meta, reader) => { - const [stored, target] = await Promise.all([ - reader.getValue(operationPreparation(drive.operationId, current.taskId), drive.context), - reader.getEntries([current.targetId], drive.context), - ]); - if (!target.has(current.targetId)) { - throw new SessionInvariantError(`Navigation target ${current.targetId} is missing`); + const expected = summaryKind(current.task); + const stored = await reader.getValue( + operationPreparation(drive.operationId, current.task.taskId), + drive.context, + ); + if (stored?.value.kind !== expected) { + throw new SessionInvariantError( + `Structural task ${current.task.taskId} is missing its ${expected} preparation`, + ); } - if (stored?.value.kind !== "branch_summary") { - throw new SessionInvariantError(`Structural task ${current.taskId} is missing its branch preparation`); + if (current.task.boundary.kind === "commit_navigation") { + const targetId = current.task.boundary.targetId; + if (!(await reader.getEntries([targetId], drive.context)).has(targetId)) { + throw new SessionInvariantError(`Navigation target ${targetId} is missing`); + } } - return { kind: "return", result: branchPreparation(stored.value) }; + return { + kind: "return", + result: + stored.value.kind === "compaction" + ? compactionPreparation(stored.value) + : branchPreparation(stored.value), + }; }, drive.context, ); @@ -220,33 +174,17 @@ async function readBranchPreparation( function summaryContext( lane: Lane, - taskId: string, resultEntryId: string, - kind: SummaryContext["kind"], configuration: LaneConfiguration, - reason?: SummaryContext["reason"], ): SummaryContext { return { - taskId, resultEntryId, - kind, configuration, streamOptions: { ...lane.readConfig().streamOptions, deferred: false }, retryPolicy: normalizedRetryPolicy(lane), - ...(reason === undefined ? {} : { reason }), }; } -function structuralEntryEvents( - entry: NewEntry, - entryWriteIndex: number, - commit: CommitResult, - lane: string, - runId: string, -): HarnessEvent[] { - return committedEntryEvents([entry], commit, lane, runId, entryWriteIndex); -} - function usageEvent(row: Omit, writeIndex: number, commit: CommitResult, lane: string): HarnessEvent { return { type: "usage", @@ -256,302 +194,273 @@ function usageEvent(row: Omit, writeIndex: number, commit: Comm }; } -function compactionReason(state: CompactionStructural): "manual" | "threshold" | "overflow" { - return isRunCompaction(state) ? state.reason : "manual"; -} - -function compactionCustomInstructions(state: CompactionStructural): string | undefined { - return isStandaloneCompaction(state) ? state.customInstructions : undefined; -} - function operationError(code: string, message: string, details?: JsonValue): OperationError { return { code, message, ...(details === undefined ? {} : { details }) }; } -async function publishCompactionResult( +type StructuralOutcome = + | { kind: "compaction"; resultEntryId: string; result: CompactResult; fromHook: boolean } + | { kind: "branch_summary"; resultEntryId: string; result: BranchSummaryResult; fromHook: boolean } + | { kind: "declined" } + | { kind: "failed"; error: OperationError }; + +async function publishStructuralOutcome( lane: Lane, drive: Drive, - capability: CompactionStructural, - resultEntryId: string, - result: CompactResult, - fromHook: boolean, + capability: SummaryDecidingOperation | SummaryReadyOperation | SummaryEffectPendingOperation, + outcome: StructuralOutcome, ): Promise { - const hookUsageId = fromHook && result.usage !== undefined ? lane.session.idGenerator.next() : undefined; - const published = await lane.continueOperation( - capability, - async (state, current, _meta, reader) => { - const entry: NewEntry = { - id: resultEntryId, - parentId: state.tipId, - type: "compaction", - summary: result.summary, - retainedTail: result.retainedTail, - tokensBefore: result.tokensBefore, - ...(result.details === undefined ? {} : { details: result.details }), - ...(result.usage === undefined ? {} : { usage: result.usage }), - fromHook, - }; - const writes: Write[] = []; - let hookUsage: { row: Omit; writeIndex: number } | undefined; - if (hookUsageId !== undefined && result.usage !== undefined) { - const row: Omit = { id: hookUsageId, usage: result.usage, adjustment: false }; - hookUsage = { row, writeIndex: writes.length }; - writes.push(insertUsage(row)); - } - const entryWriteIndex = writes.length; - writes.push(insertEntry(entry), setValue(branchTip(lane.name), resultEntryId)); - const events = (commit: CommitResult): HarnessEvent[] => [ - ...(hookUsage === undefined ? [] : [usageEvent(hookUsage.row, hookUsage.writeIndex, commit, lane.name)]), - ...structuralEntryEvents(entry, entryWriteIndex, commit, lane.name, drive.operationId), - { - type: "compaction_end", - lane: lane.name, - runId: drive.operationId, - reason: compactionReason(current), - outcome: "completed", - entry: { ...entry, seq: commit.seqs[entryWriteIndex]!, timestamp: commit.timestamp }, - fromHook, - }, - ]; - - if (isRunCompaction(current)) { - const checkpoint: RunCheckpointOperation = { - ...runScopeOf(current), - at: "run.checkpoint", - ...current.resumeAfter, - }; - return { - kind: "commit", - writes, - operationState: checkpoint, - lane: { tipId: resultEntryId }, - materialize: () => ({ kind: "continue" }) as const, - events, - }; - } - - const cleanup = await operationCleanupWrites(reader, drive.operationId, current, drive.context); - const lastResult: LaneLastResult = { - operationId: drive.operationId, - kind: "compaction", - outcome: "completed", - tipId: resultEntryId, - }; - return { - kind: "finish", - writes: [...writes, ...cleanup], - lastResult, - lane: { tipId: resultEntryId }, - materialize: (commit) => - ({ - kind: "settled", - outcome: { - operation: "compaction", - runId: drive.operationId, - kind: "completed", - tipId: resultEntryId, - entry: { ...entry, seq: commit.seqs[entryWriteIndex]!, timestamp: commit.timestamp }, - }, - }) as const, - events, - }; - }, - drive.context, - ); - return published.kind === "cancel_requested" ? { kind: "continue" } : published.value; -} - -async function publishNavigationSummary( - lane: Lane, - drive: Drive, - capability: NavigationSummaryStructural, - resultEntryId: string, - result: BranchSummaryResult, - fromHook: boolean, -): Promise { - const hookUsageId = fromHook && result.usage !== undefined ? lane.session.idGenerator.next() : undefined; - const published = await lane.continueOperation( - capability, - async (_state, current, meta, reader) => { - const entry: NewEntry = { - id: resultEntryId, - parentId: current.targetId, - type: "branch_summary", - fromId: meta.sourceTipId, - summary: result.summary, - details: { readFiles: result.readFiles, modifiedFiles: result.modifiedFiles }, - ...(result.usage === undefined ? {} : { usage: result.usage }), - fromHook, - }; - const writes: Write[] = []; - let hookUsage: { row: Omit; writeIndex: number } | undefined; - if (hookUsageId !== undefined && result.usage !== undefined) { - const row: Omit = { id: hookUsageId, usage: result.usage, adjustment: false }; - hookUsage = { row, writeIndex: writes.length }; - writes.push(insertUsage(row)); - } - writes.push(setValue(branchTip(lane.name), current.targetId)); - const entryWriteIndex = writes.length; - writes.push(insertEntry(entry), setValue(branchTip(lane.name), resultEntryId)); - if (current.label !== undefined) writes.push(setValue(entryLabel(current.targetId), current.label)); - const cleanup = await operationCleanupWrites(reader, drive.operationId, current, drive.context); - const lastResult: LaneLastResult = { - operationId: drive.operationId, - kind: "navigation", - outcome: "completed", - oldTipId: meta.sourceTipId, - tipId: resultEntryId, - summaryEntryId: resultEntryId, - }; - const events = (commit: CommitResult): HarnessEvent[] => { - const committed = { ...entry, seq: commit.seqs[entryWriteIndex]!, timestamp: commit.timestamp }; - return [ - ...(hookUsage === undefined ? [] : [usageEvent(hookUsage.row, hookUsage.writeIndex, commit, lane.name)]), - ...structuralEntryEvents(entry, entryWriteIndex, commit, lane.name, drive.operationId), - { - type: "navigation_end", - lane: lane.name, - runId: drive.operationId, - oldTipId: meta.sourceTipId, - newTipId: resultEntryId, - outcome: "completed", - summaryEntry: committed, - }, - ]; - }; - return { - kind: "finish", - writes: [...writes, ...cleanup], - lastResult, - lane: { tipId: resultEntryId }, - materialize: (commit) => { - const summaryEntry: BranchSummaryEntry = { - ...entry, - seq: commit.seqs[entryWriteIndex]!, - timestamp: commit.timestamp, - }; - return { - kind: "settled", - outcome: { - operation: "navigation", - runId: drive.operationId, - kind: "completed", - oldTipId: meta.sourceTipId, - newTipId: resultEntryId, - summaryEntry, - }, - } as const; - }, - events, - }; - }, - drive.context, - ); - return published.kind === "cancel_requested" ? { kind: "continue" } : published.value; -} - -async function publishStructuralDecline( - lane: Lane, - drive: Drive, - capability: StructuralDeciding, -): Promise { - const published = await lane.continueOperation( + const hookUsageId = + (outcome.kind === "compaction" || outcome.kind === "branch_summary") && + outcome.fromHook && + outcome.result.usage !== undefined + ? lane.session.idGenerator.next() + : undefined; + const published = await lane.continueOperation< + SummaryDecidingOperation | SummaryReadyOperation | SummaryEffectPendingOperation, + ProcedureResult + >( capability, async (state, current, meta, reader) => { - if (current.at === "run.compaction.deciding") { - const operationState: RunCheckpointOperation | RunFailureDrainOperation = - current.reason === "threshold" - ? { ...runScopeOf(current), at: "run.checkpoint", ...current.resumeAfter } - : { - ...runScopeOf(current), - at: "run.failure_drain", - error: operationError("compaction_declined", "Overflow compaction was declined"), - provenance: { kind: "structural", taskId: current.taskId }, - }; - return { - kind: "commit", - writes: [], - operationState, - materialize: () => ({ kind: "continue" }) as const, - events: () => [ - { - type: "compaction_end", - lane: lane.name, - runId: drive.operationId, - reason: current.reason, - outcome: "declined", - }, - ], - }; + const expected = summaryKind(current.task); + if ((outcome.kind === "compaction" || outcome.kind === "branch_summary") && outcome.kind !== expected) { + throw new SessionInvariantError( + `Structural ${outcome.kind} result does not match ${expected} task ${current.task.taskId}`, + ); } - const cleanup = await operationCleanupWrites(reader, drive.operationId, current, drive.context); - if (current.at === "compaction.deciding") { - if (state.tipId === null) throw new SessionInvariantError("Standalone compaction has no Branch tip"); - const lastResult: LaneLastResult = { - operationId: drive.operationId, - kind: "compaction", - outcome: "declined", - tipId: state.tipId, - }; - return { - kind: "finish", - writes: cleanup, - lastResult, - materialize: () => - ({ - kind: "settled", - outcome: { - operation: "compaction", - runId: drive.operationId, - kind: "declined", - tipId: state.tipId!, - }, - }) as const, - events: () => [ - { - type: "compaction_end", - lane: lane.name, - runId: drive.operationId, - reason: "manual", - outcome: "declined", - }, - ], - }; + const writes: Write[] = []; + const baseEvents: ((commit: CommitResult) => HarnessEvent[])[] = []; + let terminalTipId = state.tipId; + if (hookUsageId !== undefined && (outcome.kind === "compaction" || outcome.kind === "branch_summary")) { + const usage = outcome.result.usage; + if (usage === undefined) throw new SessionInvariantError("Hook usage id exists without structural usage"); + const row: Omit = { id: hookUsageId, usage, adjustment: false }; + const writeIndex = writes.length; + writes.push(insertUsage(row)); + baseEvents.push((commit) => [usageEvent(row, writeIndex, commit, lane.name)]); } - const lastResult: LaneLastResult = { - operationId: drive.operationId, - kind: "navigation", - outcome: "declined", - oldTipId: meta.sourceTipId, - tipId: state.tipId, - }; - return { - kind: "finish", - writes: cleanup, - lastResult, - materialize: () => - ({ - kind: "settled", - outcome: { - operation: "navigation", - runId: drive.operationId, - kind: "declined", - tipId: state.tipId, - }, - }) as const, - events: () => [ + if (outcome.kind === "compaction") { + const entry: NewEntry = { + id: outcome.resultEntryId, + parentId: state.tipId, + type: "compaction", + summary: outcome.result.summary, + retainedTail: outcome.result.retainedTail, + tokensBefore: outcome.result.tokensBefore, + ...(outcome.result.details === undefined ? {} : { details: outcome.result.details }), + ...(outcome.result.usage === undefined ? {} : { usage: outcome.result.usage }), + fromHook: outcome.fromHook, + }; + const entryWriteIndex = writes.length; + writes.push(insertEntry(entry), setValue(branchTip(lane.name), outcome.resultEntryId)); + terminalTipId = outcome.resultEntryId; + baseEvents.push((commit) => [ + ...committedEntryEvents([entry], commit, lane.name, drive.operationId, entryWriteIndex), { - type: "navigation_end", + type: "compaction_end", lane: lane.name, runId: drive.operationId, - oldTipId: meta.sourceTipId, - newTipId: state.tipId, - outcome: "declined", + reason: compactionReason(current.task), + status: "completed", + entryId: outcome.resultEntryId, }, - ], - }; + ]); + } else if (outcome.kind === "branch_summary") { + const boundary = navigationBoundary(current.task); + const entry: NewEntry = { + id: outcome.resultEntryId, + parentId: boundary.targetId, + type: "branch_summary", + fromId: meta.sourceTipId, + summary: outcome.result.summary, + details: { readFiles: outcome.result.readFiles, modifiedFiles: outcome.result.modifiedFiles }, + ...(outcome.result.usage === undefined ? {} : { usage: outcome.result.usage }), + fromHook: outcome.fromHook, + }; + writes.push(setValue(branchTip(lane.name), boundary.targetId)); + const entryWriteIndex = writes.length; + writes.push(insertEntry(entry), setValue(branchTip(lane.name), outcome.resultEntryId)); + if (boundary.label !== undefined) writes.push(setValue(entryLabel(boundary.targetId), boundary.label)); + terminalTipId = outcome.resultEntryId; + baseEvents.push((commit) => + committedEntryEvents([entry], commit, lane.name, drive.operationId, entryWriteIndex), + ); + } + + const events = (commit: CommitResult): HarnessEvent[] => + baseEvents.flatMap((materialize) => materialize(commit)); + switch (current.task.boundary.kind) { + case "resume_checkpoint": { + if (outcome.kind === "branch_summary") { + throw new SessionInvariantError("Run compaction boundary received a branch summary"); + } + if (outcome.kind === "compaction") { + return { + kind: "commit", + writes, + operationState: { + ...operationScopeOf(current), + at: "checkpoint", + ...current.task.boundary.resumeAfter, + }, + lane: { tipId: terminalTipId }, + materialize: () => ({ kind: "continue" }) as const, + events, + }; + } + if (outcome.kind === "declined" && current.task.reason === "threshold") { + return { + kind: "commit", + writes: [], + operationState: { + ...operationScopeOf(current), + at: "checkpoint", + ...current.task.boundary.resumeAfter, + }, + materialize: () => ({ kind: "continue" }) as const, + events: () => [ + { + type: "compaction_end", + lane: lane.name, + runId: drive.operationId, + reason: "threshold", + status: "declined", + }, + ], + }; + } + if (state.tipId === null) throw new SessionInvariantError("Failed run has no Branch tip"); + const error = + outcome.kind === "declined" + ? operationError("compaction_declined", "Overflow compaction was declined") + : outcome.error; + const cleanup = await operationCleanupWrites(reader, drive.operationId, current, drive.context); + const record = operationResultRecord(meta, "failed", state.tipId, error); + const compactionEnd: HarnessEvent = + outcome.kind === "declined" + ? { + type: "compaction_end", + lane: lane.name, + runId: drive.operationId, + reason: compactionReason(current.task), + status: "declined", + } + : { + type: "compaction_end", + lane: lane.name, + runId: drive.operationId, + reason: compactionReason(current.task), + status: "failed", + error: outcome.error, + }; + return { + kind: "finish", + writes: [...writes, ...cleanup], + record, + materialize: () => ({ kind: "settled", outcome: record }) as const, + events: () => [ + compactionEnd, + { + type: "run_end", + lane: lane.name, + runId: drive.operationId, + status: "failed", + error, + fromTipId: meta.sourceTipId, + tipId: state.tipId, + }, + ], + }; + } + case "finish": { + if (outcome.kind === "branch_summary") { + throw new SessionInvariantError("Compaction finish boundary received a branch summary"); + } + if (outcome.kind !== "compaction" && state.tipId === null) { + throw new SessionInvariantError("Standalone compaction has no Branch tip"); + } + const error = outcome.kind === "failed" ? outcome.error : undefined; + const status = + outcome.kind === "declined" ? "declined" : outcome.kind === "failed" ? "failed" : "completed"; + const cleanup = await operationCleanupWrites(reader, drive.operationId, current, drive.context); + const record = operationResultRecord(meta, status, terminalTipId, error); + const compactionEnd: HarnessEvent | undefined = + outcome.kind === "compaction" + ? undefined + : outcome.kind === "declined" + ? { + type: "compaction_end", + lane: lane.name, + runId: drive.operationId, + reason: "manual", + status: "declined", + } + : { + type: "compaction_end", + lane: lane.name, + runId: drive.operationId, + reason: "manual", + status: "failed", + error: outcome.error, + }; + return { + kind: "finish", + writes: [...writes, ...cleanup], + record, + ...(outcome.kind === "compaction" ? { lane: { tipId: terminalTipId } } : {}), + materialize: () => ({ kind: "settled", outcome: record }) as const, + events: (commit) => [...events(commit), ...(compactionEnd === undefined ? [] : [compactionEnd])], + }; + } + case "commit_navigation": { + if (outcome.kind === "compaction") { + throw new SessionInvariantError("Navigation boundary received a compaction result"); + } + const error = outcome.kind === "failed" ? outcome.error : undefined; + const status = + outcome.kind === "declined" ? "declined" : outcome.kind === "failed" ? "failed" : "completed"; + const cleanup = await operationCleanupWrites(reader, drive.operationId, current, drive.context); + const record = operationResultRecord(meta, status, terminalTipId, error); + const navigationEnd: HarnessEvent = + outcome.kind === "branch_summary" + ? { + type: "navigation_end", + lane: lane.name, + runId: drive.operationId, + status: "completed", + fromTipId: meta.sourceTipId, + tipId: terminalTipId, + } + : outcome.kind === "declined" + ? { + type: "navigation_end", + lane: lane.name, + runId: drive.operationId, + status: "declined", + fromTipId: meta.sourceTipId, + tipId: terminalTipId, + } + : { + type: "navigation_end", + lane: lane.name, + runId: drive.operationId, + status: "failed", + error: outcome.error, + fromTipId: meta.sourceTipId, + tipId: terminalTipId, + }; + return { + kind: "finish", + writes: [...writes, ...cleanup], + record, + ...(outcome.kind === "branch_summary" ? { lane: { tipId: terminalTipId } } : {}), + materialize: () => ({ kind: "settled", outcome: record }) as const, + events: (commit) => [...events(commit), navigationEnd], + }; + } + } }, drive.context, ); @@ -561,49 +470,19 @@ async function publishStructuralDecline( async function publishStructuralReady( lane: Lane, drive: Drive, - deciding: StructuralDeciding, + deciding: SummaryDecidingOperation, ): Promise { - const taskId = deciding.taskId; const resultEntryId = lane.session.idGenerator.next(); - const published = await lane.continueOperation( + const published = await lane.continueOperation( deciding, (state, current) => { - let operationState: StructuralReady; - if (current.at === "run.compaction.deciding") { - operationState = { - ...runScopeOf(current), - at: "run.compaction.ready", - reason: current.reason, - resumeAfter: current.resumeAfter, - summaryContext: summaryContext( - lane, - taskId, - resultEntryId, - "compaction", - state.configuration, - current.reason, - ), - nextAttempt: 1, - }; - } else if (current.at === "compaction.deciding") { - operationState = { - at: "compaction.ready", - control: current.control, - ...(current.customInstructions === undefined ? {} : { customInstructions: current.customInstructions }), - summaryContext: summaryContext(lane, taskId, resultEntryId, "compaction", state.configuration, "manual"), - nextAttempt: 1, - }; - } else { - operationState = { - at: "navigation.summary.ready", - control: current.control, - targetId: current.targetId, - ...(current.label === undefined ? {} : { label: current.label }), - ...(current.customInstructions === undefined ? {} : { customInstructions: current.customInstructions }), - summaryContext: summaryContext(lane, taskId, resultEntryId, "branch_summary", state.configuration), - nextAttempt: 1, - }; - } + const operationState: SummaryReadyOperation = { + ...operationScopeOf(current), + at: "summary.ready", + task: current.task, + summaryContext: summaryContext(lane, resultEntryId, state.configuration), + nextAttempt: 1, + }; return { kind: "commit", writes: [], @@ -620,165 +499,107 @@ async function publishStructuralReady( export async function runStructuralDecision( lane: Lane, drive: Drive, - deciding: StructuralDeciding, + deciding: SummaryDecidingOperation, ): Promise { - if (deciding.at === "navigation.summary.deciding") { - const preparation = await readBranchPreparation(lane, drive, deciding); - if (preparation.kind === "cancel_requested") return { kind: "continue" }; + const preparation = await readStructuralPreparation(lane, drive, deciding); + if (preparation.kind === "cancel_requested") return { kind: "continue" }; + if (deciding.task.boundary.kind === "commit_navigation") { + if (!("messages" in preparation.value)) { + throw new SessionInvariantError("Navigation task has invalid durable preparation"); + } const hook = await lane.hooks.runWithGate( "before_navigation", { lane: lane.name, runId: drive.operationId, - targetId: deciding.targetId, + targetId: deciding.task.boundary.targetId, preparation: preparation.value, - ...(deciding.customInstructions === undefined ? {} : { customInstructions: deciding.customInstructions }), + ...(deciding.task.customInstructions === undefined + ? {} + : { customInstructions: deciding.task.customInstructions }), }, drive.gate, drive.context, ); - if (hook?.decline === true) return publishStructuralDecline(lane, drive, deciding); + if (hook?.decline === true) return publishStructuralOutcome(lane, drive, deciding, { kind: "declined" }); if (hook?.summary !== undefined) { - return publishNavigationSummary(lane, drive, deciding, lane.session.idGenerator.next(), hook.summary, true); + return publishStructuralOutcome(lane, drive, deciding, { + kind: "branch_summary", + resultEntryId: lane.session.idGenerator.next(), + result: hook.summary, + fromHook: true, + }); } return publishStructuralReady(lane, drive, deciding); } - const preparation = await readCompactionPreparation(lane, drive, deciding); - if (preparation.kind === "cancel_requested") return { kind: "continue" }; - const reason = deciding.at === "run.compaction.deciding" ? deciding.reason : "manual"; - const customInstructions = deciding.at === "compaction.deciding" ? deciding.customInstructions : undefined; + if (!("messagesToSummarize" in preparation.value)) { + throw new SessionInvariantError("Compaction task has invalid durable preparation"); + } const hook = await lane.hooks.runWithGate( "before_compaction", { lane: lane.name, runId: drive.operationId, - reason, + reason: compactionReason(deciding.task), preparation: preparation.value, - ...(customInstructions === undefined ? {} : { customInstructions }), + ...(deciding.task.customInstructions === undefined + ? {} + : { customInstructions: deciding.task.customInstructions }), }, drive.gate, drive.context, ); - if (hook?.decline === true) return publishStructuralDecline(lane, drive, deciding); + if (hook?.decline === true) return publishStructuralOutcome(lane, drive, deciding, { kind: "declined" }); if (hook?.compaction !== undefined) { - return publishCompactionResult(lane, drive, deciding, lane.session.idGenerator.next(), hook.compaction, true); + return publishStructuralOutcome(lane, drive, deciding, { + kind: "compaction", + resultEntryId: lane.session.idGenerator.next(), + result: hook.compaction, + fromHook: true, + }); } return publishStructuralReady(lane, drive, deciding); } -function effectPendingFromReady(ready: StructuralReady): StructuralEffectPending { - switch (ready.at) { - case "run.compaction.ready": - return { - ...runScopeOf(ready), - at: "run.compaction.effect_pending", - reason: ready.reason, - resumeAfter: ready.resumeAfter, - summaryContext: ready.summaryContext, - attempt: ready.nextAttempt, - usageIds: [], - }; - case "compaction.ready": - return { - at: "compaction.effect_pending", - control: ready.control, - ...(ready.customInstructions === undefined ? {} : { customInstructions: ready.customInstructions }), - summaryContext: ready.summaryContext, - attempt: ready.nextAttempt, - usageIds: [], - }; - case "navigation.summary.ready": - return { - at: "navigation.summary.effect_pending", - control: ready.control, - targetId: ready.targetId, - ...(ready.label === undefined ? {} : { label: ready.label }), - ...(ready.customInstructions === undefined ? {} : { customInstructions: ready.customInstructions }), - summaryContext: ready.summaryContext, - attempt: ready.nextAttempt, - usageIds: [], - }; - } +function effectPendingFromReady(ready: SummaryReadyOperation): SummaryEffectPendingOperation { + return { + ...operationScopeOf(ready), + at: "summary.effect_pending", + task: ready.task, + summaryContext: ready.summaryContext, + attempt: ready.nextAttempt, + usageIds: [], + }; } -function retryWaitFromEffect(effect: StructuralEffectPending, errorMessage: string): StructuralRetryWait { - const nextAttempt = effect.attempt + 1; - const notBefore = retryNotBefore(effect.summaryContext.retryPolicy.baseDelayMs, effect.attempt); - switch (effect.at) { - case "run.compaction.effect_pending": - return { - ...runScopeOf(effect), - at: "run.compaction.retry_wait", - reason: effect.reason, - resumeAfter: effect.resumeAfter, - summaryContext: effect.summaryContext, - nextAttempt, - notBefore, - errorMessage, - }; - case "compaction.effect_pending": - return { - at: "compaction.retry_wait", - control: effect.control, - ...(effect.customInstructions === undefined ? {} : { customInstructions: effect.customInstructions }), - summaryContext: effect.summaryContext, - nextAttempt, - notBefore, - errorMessage, - }; - case "navigation.summary.effect_pending": - return { - at: "navigation.summary.retry_wait", - control: effect.control, - targetId: effect.targetId, - ...(effect.label === undefined ? {} : { label: effect.label }), - ...(effect.customInstructions === undefined ? {} : { customInstructions: effect.customInstructions }), - summaryContext: effect.summaryContext, - nextAttempt, - notBefore, - errorMessage, - }; - } +function retryWaitFromEffect(effect: SummaryEffectPendingOperation, errorMessage: string): SummaryRetryWaitOperation { + return { + ...operationScopeOf(effect), + at: "summary.retry_wait", + task: effect.task, + summaryContext: effect.summaryContext, + nextAttempt: effect.attempt + 1, + notBefore: retryNotBefore(effect.summaryContext.retryPolicy.baseDelayMs, effect.attempt), + errorMessage, + }; } -function readyFromRetryWait(retry: StructuralRetryWait): StructuralReady { - switch (retry.at) { - case "run.compaction.retry_wait": - return { - ...runScopeOf(retry), - at: "run.compaction.ready", - reason: retry.reason, - resumeAfter: retry.resumeAfter, - summaryContext: retry.summaryContext, - nextAttempt: retry.nextAttempt, - }; - case "compaction.retry_wait": - return { - at: "compaction.ready", - control: retry.control, - ...(retry.customInstructions === undefined ? {} : { customInstructions: retry.customInstructions }), - summaryContext: retry.summaryContext, - nextAttempt: retry.nextAttempt, - }; - case "navigation.summary.retry_wait": - return { - at: "navigation.summary.ready", - control: retry.control, - targetId: retry.targetId, - ...(retry.label === undefined ? {} : { label: retry.label }), - ...(retry.customInstructions === undefined ? {} : { customInstructions: retry.customInstructions }), - summaryContext: retry.summaryContext, - nextAttempt: retry.nextAttempt, - }; - } +function readyFromRetryWait(retry: SummaryRetryWaitOperation): SummaryReadyOperation { + return { + ...operationScopeOf(retry), + at: "summary.ready", + task: retry.task, + summaryContext: retry.summaryContext, + nextAttempt: retry.nextAttempt, + }; } async function publishAttemptIntent( lane: Lane, drive: Drive, - ready: StructuralReady, -): Promise> { + ready: SummaryReadyOperation, +): Promise> { return lane.continueOperation( ready, (_state, current) => { @@ -797,10 +618,10 @@ async function publishAttemptIntent( async function publishNestedRequestIntent( lane: Lane, drive: Drive, - effect: StructuralEffectPending, + effect: SummaryEffectPendingOperation, index: number, usageId: string, -): Promise> { +): Promise> { return lane.continueOperation( effect, (_state, current) => { @@ -819,7 +640,7 @@ async function publishNestedRequestIntent( async function publishNestedRequestOutcome( lane: Lane, drive: Drive, - effect: StructuralEffectPending, + effect: SummaryEffectPendingOperation, usageId: string, response: AssistantMessage, ): Promise { @@ -866,7 +687,7 @@ function requestStreamOptions( async function performStructuralAttempt( lane: Lane, drive: Drive, - effect: StructuralEffectPending, + effect: SummaryEffectPendingOperation, model: Model, preparation: CompactionPreparation | BranchPreparation, ): Promise< @@ -886,7 +707,7 @@ async function performStructuralAttempt( lane: lane.name, runId: drive.operationId, model, - step: effect.summaryContext.kind, + step: summaryKind(effect.task), attempt: effect.attempt, streamOptions: baseOptions, }, @@ -937,10 +758,7 @@ async function performStructuralAttempt( }; try { - if (effect.summaryContext.kind === "compaction") { - if (effect.at === "navigation.summary.effect_pending") { - throw new SessionInvariantError("Compaction summary has a navigation operation state"); - } + if (summaryKind(effect.task) === "compaction") { if (!("messagesToSummarize" in preparation)) { throw new SessionInvariantError("Compaction summary has invalid durable preparation"); } @@ -948,7 +766,7 @@ async function performStructuralAttempt( preparation, { model, - customInstructions: compactionCustomInstructions(effect), + customInstructions: effect.task.customInstructions, thinkingLevel: effect.summaryContext.configuration.thinkingLevel, }, request, @@ -968,17 +786,12 @@ async function performStructuralAttempt( }; } - if (effect.at !== "navigation.summary.effect_pending") { - throw new SessionInvariantError("Branch summary has a compaction operation state"); - } if (!("messages" in preparation)) { throw new SessionInvariantError("Branch summary has invalid durable preparation"); } const result = await generateBranchSummaryWithRequest( preparation, - { - customInstructions: effect.customInstructions, - }, + { customInstructions: effect.task.customInstructions }, request, drive.context, ); @@ -1003,19 +816,18 @@ async function performStructuralAttempt( async function readAttemptPreparation( lane: Lane, drive: Drive, - ready: StructuralReady, + ready: SummaryReadyOperation, ): Promise> { return lane.continueOperation( ready, async (_state, current, _meta, reader) => { + const expected = summaryKind(current.task); const stored = await reader.getValue( - operationPreparation(drive.operationId, current.summaryContext.taskId), + operationPreparation(drive.operationId, current.task.taskId), drive.context, ); - if (stored === undefined || stored.value.kind !== current.summaryContext.kind) { - throw new SessionInvariantError( - `Structural task ${current.summaryContext.taskId} has invalid durable preparation`, - ); + if (stored === undefined || stored.value.kind !== expected) { + throw new SessionInvariantError(`Structural task ${current.task.taskId} has invalid durable preparation`); } return { kind: "return", @@ -1029,141 +841,28 @@ async function readAttemptPreparation( ); } -async function publishStructuralFailure( - lane: Lane, - drive: Drive, - capability: StructuralReady | StructuralEffectPending, - error: OperationError, - provenance: "structural" | "configuration" = "structural", -): Promise { - const published = await lane.continueOperation( - capability, - async (state, current, meta, reader) => { - if (isRunGeneratedCompaction(current)) { - const failure: RunFailureDrainOperation = { - ...runScopeOf(current), - at: "run.failure_drain", - error, - provenance: - provenance === "configuration" - ? { kind: "configuration" } - : { kind: "structural", taskId: current.summaryContext.taskId }, - }; - return { - kind: "commit", - writes: [], - operationState: failure, - materialize: () => ({ kind: "continue" }) as const, - events: () => [ - { - type: "compaction_end", - lane: lane.name, - runId: drive.operationId, - reason: current.reason, - outcome: "failed", - error, - }, - ], - }; - } - - const cleanup = await operationCleanupWrites(reader, drive.operationId, current, drive.context); - if (isStandaloneGeneratedCompaction(current)) { - if (state.tipId === null) throw new SessionInvariantError("Standalone compaction has no Branch tip"); - const lastResult: LaneLastResult = { - operationId: drive.operationId, - kind: "compaction", - outcome: "failed", - tipId: state.tipId, - error, - }; - return { - kind: "finish", - writes: cleanup, - lastResult, - materialize: () => - ({ - kind: "settled", - outcome: { - operation: "compaction", - runId: drive.operationId, - kind: "failed", - tipId: state.tipId!, - error, - }, - }) as const, - events: () => [ - { - type: "compaction_end", - lane: lane.name, - runId: drive.operationId, - reason: "manual", - outcome: "failed", - error, - }, - ], - }; - } - - const lastResult: LaneLastResult = { - operationId: drive.operationId, - kind: "navigation", - outcome: "failed", - oldTipId: meta.sourceTipId, - tipId: state.tipId, - error, - }; - return { - kind: "finish", - writes: cleanup, - lastResult, - materialize: () => - ({ - kind: "settled", - outcome: { - operation: "navigation", - runId: drive.operationId, - kind: "failed", - tipId: state.tipId, - error, - }, - }) as const, - events: () => [ - { - type: "navigation_end", - lane: lane.name, - runId: drive.operationId, - oldTipId: meta.sourceTipId, - newTipId: state.tipId, - outcome: "failed", - error, - }, - ], - }; - }, - drive.context, - ); - return published.kind === "cancel_requested" ? { kind: "continue" } : published.value; -} - async function publishAttemptResult( lane: Lane, drive: Drive, - effect: StructuralEffectPending, + effect: SummaryEffectPendingOperation, result: Awaited>>, ): Promise { if (result.kind === "cancel_requested") return { kind: "continue" }; if (result.kind === "compaction") { - if (effect.at === "navigation.summary.effect_pending") { - throw new SessionInvariantError("Compaction result has a navigation operation state"); - } - return publishCompactionResult(lane, drive, effect, effect.summaryContext.resultEntryId, result.result, false); + return publishStructuralOutcome(lane, drive, effect, { + kind: "compaction", + resultEntryId: effect.summaryContext.resultEntryId, + result: result.result, + fromHook: false, + }); } if (result.kind === "branch_summary") { - if (effect.at !== "navigation.summary.effect_pending") { - throw new SessionInvariantError("Branch summary result has a compaction operation state"); - } - return publishNavigationSummary(lane, drive, effect, effect.summaryContext.resultEntryId, result.result, false); + return publishStructuralOutcome(lane, drive, effect, { + kind: "branch_summary", + resultEntryId: effect.summaryContext.resultEntryId, + result: result.result, + fromHook: false, + }); } if (result.error.code === "aborted" && lane.state.operation!.state.control.status === "running") { throw new SessionInvariantError("Structural provider response is aborted while durable control is running"); @@ -1182,7 +881,7 @@ async function publishAttemptResult( type: "retry_scheduled", lane: lane.name, runId: drive.operationId, - step: effect.summaryContext.taskId, + step: effect.task.taskId, attempt: retryWait.nextAttempt, maxAttempts: effect.summaryContext.retryPolicy.maxAttempts, delayMs: retryDelay(effect.summaryContext.retryPolicy.baseDelayMs, effect.attempt), @@ -1194,27 +893,24 @@ async function publishAttemptResult( ); return published.kind === "cancel_requested" ? { kind: "continue" } : published.value; } - return publishStructuralFailure(lane, drive, effect, result.error); + return publishStructuralOutcome(lane, drive, effect, { kind: "failed", error: result.error }); } /** Execute one ready structural generation attempt. */ export async function runStructuralGeneration( lane: Lane, drive: Drive, - ready: StructuralReady, + ready: SummaryReadyOperation, ): Promise { const preparation = await readAttemptPreparation(lane, drive, ready); if (preparation.kind === "cancel_requested") return { kind: "continue" }; const identity = ready.summaryContext.configuration.model; const model = lane.models.getModel(identity.provider, identity.modelId); if (model === undefined) { - return publishStructuralFailure( - lane, - drive, - ready, - operationError("model_unavailable", "The configured model is unavailable in this process", identity), - "configuration", - ); + return publishStructuralOutcome(lane, drive, ready, { + kind: "failed", + error: operationError("model_unavailable", "The configured model is unavailable in this process", identity), + }); } const intent = await publishAttemptIntent(lane, drive, ready); if (intent.kind === "cancel_requested") return { kind: "continue" }; @@ -1226,7 +922,7 @@ export async function runStructuralGeneration( lane: Lane, drive: Drive, - retry: StructuralRetryWait, + retry: SummaryRetryWaitOperation, ): Promise { if (Date.now() < retry.notBefore) { if (!drive.waitForRetry) { @@ -1256,7 +952,7 @@ export async function runStructuralRetryWait( lane: Lane, drive: Drive, - effect: StructuralEffectPending, + effect: SummaryEffectPendingOperation, ): Promise { const error = operationError( "structural_interrupted", "Structural summary attempt was interrupted and its external outcome is unknown", ); if (effect.attempt >= effect.summaryContext.retryPolicy.maxAttempts) { - return publishStructuralFailure(lane, drive, effect, error); + return publishStructuralOutcome(lane, drive, effect, { kind: "failed", error }); } const retryWait = retryWaitFromEffect(effect, error.message); const published = await lane.continueOperation( @@ -1293,7 +989,7 @@ export async function recoverStructuralGeneration( lane: Lane, drive: Drive, - checkpoint: RunCheckpointOperation, + checkpoint: CheckpointOperation, ): Promise { const settings = checkpoint.settings.compaction; const identity = lane.state.configuration.model; @@ -1330,7 +1026,7 @@ export async function runCompactionThreshold { - const marked: RunCheckpointOperation = { + const marked: CheckpointOperation = { ...current, thresholdCheckedTriggerEntryId: current.triggerEntryId, }; @@ -1348,12 +1044,10 @@ export async function runCompactionThreshold( lane: Lane, drive: Drive, - generation: RunAssistantEffectPendingOperation, + generation: AssistantEffectPendingOperation, ): Promise<{ taskId: string; preparation: DurableStructuralPreparation } | undefined> { if (generation.generationContext.overflowRecoveryUsed) return undefined; const path = await readBoundedEntries(lane, drive, generation); @@ -1422,34 +1116,21 @@ export function commitNavigation( writes.push(setValue(entryLabel(current.targetId), current.label)); } const cleanup = await operationCleanupWrites(reader, drive.operationId, current, drive.context); - const lastResult: LaneLastResult = { - operationId: drive.operationId, - kind: "navigation", - outcome: "completed", - oldTipId: meta.sourceTipId, - tipId: current.targetId, - }; - const outcome: TerminalOperationOutcome = { - operation: "navigation", - runId: drive.operationId, - kind: "completed", - oldTipId: meta.sourceTipId, - newTipId: current.targetId, - }; + const record = operationResultRecord(meta, "completed", current.targetId); return { kind: "finish", writes: [...writes, ...cleanup], - lastResult, + record, lane: { tipId: current.targetId }, - materialize: () => ({ kind: "settled", outcome }) as const, + materialize: () => ({ kind: "settled", outcome: record }) as const, events: () => [ { type: "navigation_end", lane: lane.name, runId: drive.operationId, - oldTipId: meta.sourceTipId, - newTipId: current.targetId, - outcome: "completed", + status: "completed", + fromTipId: meta.sourceTipId, + tipId: current.targetId, }, ], }; diff --git a/packages/agent/src/harness/runtime/drive/terminal.ts b/packages/agent/src/harness/runtime/drive/terminal.ts index 0f4877940..8521af5f5 100644 --- a/packages/agent/src/harness/runtime/drive/terminal.ts +++ b/packages/agent/src/harness/runtime/drive/terminal.ts @@ -1,17 +1,13 @@ -import type { AgentMessage } from "../../../types.ts"; -import type { TerminalOperationOutcome } from "../../agent-harness.ts"; import type { Context } from "../../context.ts"; import { SessionInvariantError } from "../../session/session.ts"; -import { - type BranchSummaryEntry, - type CompactionEntry, - isRunOperationState, - type LaneLastResult, - type MessageEntry, - type OperationState, - type SessionReader, - type SettledAssistantMessage, - type Write, +import type { + OperationError, + OperationMeta, + OperationResultRecord, + OperationState, + SessionReader, + TerminalStatus, + Write, } from "../../session/types.ts"; import { deleteList, @@ -41,14 +37,9 @@ export async function operationCleanupWrites( ]); const pendingIds = new Set(); - if (isRunOperationState(state)) { - for (const id of state.inbox.steer) pendingIds.add(id); - for (const id of state.inbox.followUp) pendingIds.add(id); - for (const id of state.inbox.writes) pendingIds.add(id); - if (state.at === "run.tools") { - for (const call of state.batch.calls) { - if (call.status === "outcome_ready") pendingIds.add(call.resultEntryId); - } + if (state.at === "tools") { + for (const call of state.batch.calls) { + if (call.status === "outcome_ready") pendingIds.add(call.resultEntryId); } } if (state.control.status === "cancel_requested") { @@ -57,7 +48,7 @@ export async function operationCleanupWrites( } let frameDelete: Write | undefined; - if (state.at === "run.assistant.effect_pending" || state.at === "run.deferred.effect_pending") { + if (state.at === "assistant.effect_pending" || state.at === "deferred.effect_pending") { frameDelete = deleteList(pendingAssistantFrames(operationId, state.responseEntryId)); } @@ -73,169 +64,24 @@ export async function operationCleanupWrites( ]; } -function isSettledAssistantMessage(message: AgentMessage): message is SettledAssistantMessage { - return message.role === "assistant" && message.stopReason !== "pending"; -} - -async function requireMessageEntry( - reader: SessionReader, - entryId: string, - context: Context, -): Promise { - const entry = (await reader.getEntries([entryId], context)).get(entryId); - if (entry?.type !== "message") { - throw new SessionInvariantError(`Terminal result references invalid assistant ${entryId}`); - } - const message = entry.message; - if (!isSettledAssistantMessage(message)) { - throw new SessionInvariantError(`Terminal result references invalid assistant ${entryId}`); - } - return { ...entry, message }; -} - -async function requireCompactionEntry( - reader: SessionReader, - entryId: string, - context: Context, -): Promise { - const entry = (await reader.getEntries([entryId], context)).get(entryId); - if (entry?.type !== "compaction") { - throw new SessionInvariantError(`Terminal result references invalid compaction ${entryId}`); - } - return entry; -} - -async function requireSummaryEntry( - reader: SessionReader, - entryId: string, - context: Context, -): Promise { - const entry = (await reader.getEntries([entryId], context)).get(entryId); - if (entry?.type !== "branch_summary") { - throw new SessionInvariantError(`Terminal result references invalid branch summary ${entryId}`); - } - return entry; -} - -/** Hydrate one bounded public terminal outcome from the latest-result value's direct references. */ -export async function hydrateTerminalOutcome( - reader: SessionReader, - lastResult: LaneLastResult, - context: Context, -): Promise { - if (lastResult.kind === "run") { - if (lastResult.outcome === "completed") { - if (lastResult.runCompletion === "terminated_tools") { - if (lastResult.finalAssistantEntryId !== undefined) { - throw new SessionInvariantError("Terminated-tools result cannot reference a final assistant"); - } - return { - operation: "run", - runId: lastResult.operationId, - kind: "completed", - tipId: lastResult.tipId, - }; - } - if (lastResult.finalAssistantEntryId === undefined) { - throw new SessionInvariantError("Assistant-completed result is missing its final assistant"); - } - const entry = await requireMessageEntry(reader, lastResult.finalAssistantEntryId, context); - return { - operation: "run", - runId: lastResult.operationId, - kind: "completed", - tipId: lastResult.tipId, - finalEntryId: entry.id, - finalMessage: entry.message, - }; - } - - const final = - lastResult.finalAssistantEntryId === undefined - ? {} - : await requireMessageEntry(reader, lastResult.finalAssistantEntryId, context).then((entry) => ({ - finalEntryId: entry.id, - finalMessage: entry.message, - })); - return lastResult.outcome === "failed" - ? { - operation: "run", - runId: lastResult.operationId, - kind: "failed", - tipId: lastResult.tipId, - error: lastResult.error, - ...final, - } - : { - operation: "run", - runId: lastResult.operationId, - kind: "aborted", - tipId: lastResult.tipId, - ...final, - }; - } - - if (lastResult.kind === "compaction") { - if (lastResult.outcome === "completed") { - const entry = await requireCompactionEntry(reader, lastResult.tipId, context); - return { - operation: "compaction", - runId: lastResult.operationId, - kind: "completed", - tipId: lastResult.tipId, - entry, - }; - } - if (lastResult.outcome === "failed") { - return { - operation: "compaction", - runId: lastResult.operationId, - kind: "failed", - tipId: lastResult.tipId, - error: lastResult.error, - }; - } - return { - operation: "compaction", - runId: lastResult.operationId, - kind: lastResult.outcome, - tipId: lastResult.tipId, - }; - } - - if (lastResult.outcome === "completed") { - const summaryEntry = - lastResult.summaryEntryId === undefined - ? undefined - : await requireSummaryEntry(reader, lastResult.summaryEntryId, context); - if ( - summaryEntry !== undefined && - (summaryEntry.id !== lastResult.tipId || summaryEntry.fromId !== lastResult.oldTipId) - ) { - throw new SessionInvariantError("Completed navigation summary contradicts its terminal result"); - } - return { - operation: "navigation", - runId: lastResult.operationId, - kind: "completed", - oldTipId: lastResult.oldTipId, - newTipId: lastResult.tipId, - ...(summaryEntry === undefined ? {} : { summaryEntry }), - }; - } - if (lastResult.outcome === "failed") { - return { - operation: "navigation", - runId: lastResult.operationId, - kind: "failed", - tipId: lastResult.tipId, - error: lastResult.error, - }; +/** Construct the immutable observation record for one terminal decision. */ +export function operationResultRecord( + meta: OperationMeta, + status: TerminalStatus, + tipId: string | null, + error?: OperationError, +): OperationResultRecord { + if ((status === "failed") !== (error !== undefined)) { + throw new SessionInvariantError("Only a failed operation result may carry an error"); } return { - operation: "navigation", - runId: lastResult.operationId, - kind: lastResult.outcome, - tipId: lastResult.tipId, + operationId: meta.operationId, + kind: meta.intent.kind, + status, + ...(error === undefined ? {} : { error }), + fromTipId: meta.sourceTipId, + tipId, + startedAt: meta.startedAt, + endedAt: Date.now(), }; } diff --git a/packages/agent/src/harness/runtime/drive/tool-placement.ts b/packages/agent/src/harness/runtime/drive/tool-placement.ts index 267852f16..738168a53 100644 --- a/packages/agent/src/harness/runtime/drive/tool-placement.ts +++ b/packages/agent/src/harness/runtime/drive/tool-placement.ts @@ -4,14 +4,14 @@ import type { HarnessEvent } from "../../agent-harness.ts"; import { insertEntry, insertUsage } from "../../session/commit.ts"; import { SessionInvariantError } from "../../session/session.ts"; import { + type CheckpointOperation, type MessageEntry, type NewEntry, type OperationState, - type RunCheckpointOperation, - type RunToolsOperation, - runScopeOf, + operationScopeOf, type ToolBatch, type ToolCall, + type ToolsOperation, type UsageRow, type Write, } from "../../session/types.ts"; @@ -77,8 +77,8 @@ export function toolCallFor(sources: ToolBatchSource, call: ToolCall): AgentTool return source; } -export function withToolBatch(run: RunToolsOperation, batch: ToolBatch): RunToolsOperation { - return { ...runScopeOf(run), at: "run.tools", batch }; +export function withToolBatch(run: ToolsOperation, batch: ToolBatch): ToolsOperation { + return { ...operationScopeOf(run), at: "tools", batch }; } async function readPlacement( @@ -88,7 +88,7 @@ async function readPlacement( ): Promise { return lane.command(async (state, reader) => { const operation = state.operation; - if (operation?.state.at !== "run.tools") return { kind: "return", result: undefined }; + if (operation?.state.at !== "tools") return { kind: "return", result: undefined }; const current = operation.state.batch; let first = current.calls.findIndex((call) => call.status !== "completed"); if (first === -1) return { kind: "return", result: undefined }; @@ -144,13 +144,13 @@ async function readPlacement( async function commitPlacement( lane: Lane, drive: Drive, - capability: RunToolsOperation, + capability: ToolsOperation, read: PlacementRead, ): Promise { const usageIds = read.items.map((item) => item.message.usage === undefined ? undefined : lane.session.idGenerator.next(), ); - return lane.settleOperation( + return lane.settleOperation( capability, async (state, run, _meta, reader) => { const current = run.batch; @@ -219,9 +219,9 @@ async function commitPlacement( let nextRun: OperationState; if (complete) { const allTerminate = completedCalls.every((call) => call.status === "completed" && call.terminate); - const checkpoint: RunCheckpointOperation = { - ...runScopeOf(run), - at: "run.checkpoint", + const checkpoint: CheckpointOperation = { + ...operationScopeOf(run), + at: "checkpoint", continuation: allTerminate ? { kind: "may_finish", includeFinalAssistant: false } : { kind: "need_assistant", overflowRecoveryUsed: false }, @@ -280,7 +280,7 @@ async function commitPlacement( export async function materializeReady( lane: Lane, drive: Drive, - capability: RunToolsOperation, + capability: ToolsOperation, sources: ToolBatchSource, recovery: boolean, ): Promise { diff --git a/packages/agent/src/harness/runtime/drive/tools.ts b/packages/agent/src/harness/runtime/drive/tools.ts index de9623640..f55f1ca02 100644 --- a/packages/agent/src/harness/runtime/drive/tools.ts +++ b/packages/agent/src/harness/runtime/drive/tools.ts @@ -12,7 +12,7 @@ import { prepareToolCall, } from "../../execution/tools.ts"; import { SessionInvariantError } from "../../session/session.ts"; -import type { JsonValue, RunToolsOperation, ToolBatch, ToolCall } from "../../session/types.ts"; +import type { JsonValue, ToolBatch, ToolCall, ToolsOperation } from "../../session/types.ts"; import { deleteValue, operationToolArgs, @@ -52,9 +52,9 @@ class ToolInvocationEnded extends Error { function currentBatch( lane: Lane, -): { run: RunToolsOperation; batch: ToolBatch } | undefined { +): { run: ToolsOperation; batch: ToolBatch } | undefined { const operation = lane.state.operation; - if (operation?.state.at !== "run.tools") return undefined; + if (operation?.state.at !== "tools") return undefined; return { run: operation.state, batch: operation.state.batch }; } @@ -87,7 +87,7 @@ function invocationCapability( let active = true; const ownsEffect = (state: LaneState): boolean => { const operation = state.operation; - if (operation?.state.at !== "run.tools") return false; + if (operation?.state.at !== "tools") return false; return findCall(operation.state.batch, call.sourceIndex, call.resultEntryId)?.status === "effect_pending"; }; const ended = (): ToolInvocationEnded => new ToolInvocationEnded(); @@ -177,7 +177,7 @@ function outcomeFromFinalizedCall(finalized: FinalizedToolCall): ToolOutcome { async function publishToolIntent( lane: Lane, drive: Drive, - run: RunToolsOperation, + run: ToolsOperation, planned: Extract, args: Record, replay: "never" | "safe", @@ -205,7 +205,7 @@ async function publishToolIntent( async function publishToolOutcome( lane: Lane, drive: Drive, - capability: RunToolsOperation, + capability: ToolsOperation, call: ToolCall, message: ToolResultMessage, terminate: boolean, @@ -451,7 +451,7 @@ async function prepareToolInvocation( async function startToolInvocation( lane: Lane, drive: Drive, - run: RunToolsOperation, + run: ToolsOperation, sources: ToolBatchSource, call: Extract, tools: AgentHarnessTool[], @@ -493,7 +493,7 @@ async function startToolInvocation( async function recoverToolInvocation( lane: Lane, drive: Drive, - run: RunToolsOperation, + run: ToolsOperation, sources: ToolBatchSource, call: Extract, toolsByName: Map>, @@ -520,7 +520,7 @@ async function recoverToolInvocation( async function runSequential( lane: Lane, drive: Drive, - run: RunToolsOperation, + run: ToolsOperation, sources: ToolBatchSource, execution: | { @@ -582,7 +582,7 @@ async function runSequential( async function runParallel( lane: Lane, drive: Drive, - run: RunToolsOperation, + run: ToolsOperation, sources: ToolBatchSource, tools: AgentHarnessTool[], toolsByName: Map>, @@ -627,7 +627,7 @@ async function runParallel( export async function runTools( lane: Lane, drive: Drive, - run: RunToolsOperation, + run: ToolsOperation, ): Promise { const batch = run.batch; const recovery = batch.calls.some((call) => call.status === "effect_pending" || call.status === "outcome_ready"); diff --git a/packages/agent/src/harness/runtime/harness.ts b/packages/agent/src/harness/runtime/harness.ts index 846e9cebf..094770c20 100644 --- a/packages/agent/src/harness/runtime/harness.ts +++ b/packages/agent/src/harness/runtime/harness.ts @@ -130,16 +130,14 @@ export class Harness implements AgentHarnes const state: LaneState = { tipId, configuration: attachedConfiguration, - pendingNextRun: [], + inbox: [], + lastOperationId: null, operation: null, }; const writes = [ ...(stored.kind === "absent" ? [setValue(branchTip(name), tipId)] : []), setValue(laneConfig(name), attachedConfiguration), - setValue(laneState(name), { - currentOperationId: null, - pendingNextRun: [], - }), + setValue(laneState(name), { currentOperationId: null, lastOperationId: null, inbox: [] }), ]; await mutator.commit(writes, context); lane = this.buildLane(name, state); diff --git a/packages/agent/src/harness/runtime/lane.ts b/packages/agent/src/harness/runtime/lane.ts index 6fe0d60dd..b4d7882ad 100644 --- a/packages/agent/src/harness/runtime/lane.ts +++ b/packages/agent/src/harness/runtime/lane.ts @@ -32,27 +32,29 @@ import { } from "../result.ts"; import { insertEntry } from "../session/commit.ts"; import { SessionInvariantError, SessionPendingAssistantMessageError } from "../session/session.ts"; -import { - type BranchScan, - type Entry, - isRunOperationState, - type JsonValue, - type LaneLastResult, - type Operation, - type OperationMeta, - type OperationState, - type PendingEntry, - type RunStartingOperation, - type Session, - type SessionReader, +import type { + BranchScan, + Entry, + InboxItem, + InboxItemKind, + JsonValue, + NewEntry, + Operation, + OperationMeta, + OperationResultRecord, + OperationState, + PendingEntry, + Session, + SessionReader, + StartingOperation, } from "../session/types.ts"; import { branchTip, deleteValue, laneConfig, - laneLastResult as laneLastResultValue, laneState as laneStateValue, operationMeta as operationMetaValue, + operationResult as operationResultValue, operationState as operationStateValue, operationToolArgs, pendingEntry, @@ -85,27 +87,68 @@ function isPromiseLike(value: unknown): value is PromiseLike { return "then" in value && typeof value.then === "function"; } +function inboxItems(inbox: readonly InboxItem[], kind: InboxItemKind): InboxItem[] { + return inbox.filter((item) => item.kind === kind); +} + +function withoutInboxItems(inbox: readonly InboxItem[], removed: readonly InboxItem[]): InboxItem[] { + const removedIds = new Set(removed.map((item) => item.entryId)); + return inbox.filter((item) => !removedIds.has(item.entryId)); +} + +function selectAcceptedInbox( + inbox: readonly InboxItem[], + steeringMode: "all" | "one-at-a-time", + followUpMode: "all" | "one-at-a-time", +): { selected: InboxItem[]; remainder: InboxItem[] } { + let steerTaken = false; + let followUpTaken = false; + const selected: InboxItem[] = []; + const remainder: InboxItem[] = []; + for (const item of inbox) { + const eligible = + item.kind === "write" || + item.kind === "nextRun" || + (item.kind === "steer" && (steeringMode === "all" || !steerTaken)) || + (item.kind === "followUp" && (followUpMode === "all" || !followUpTaken)); + if (eligible) { + selected.push(item); + if (item.kind === "steer") steerTaken = true; + if (item.kind === "followUp") followUpTaken = true; + } else { + remainder.push(item); + } + } + return { selected, remainder }; +} + +function pendingEntryWrite(entryId: string, pending: PendingEntry): NewEntry { + return pending.type === "message" + ? { id: entryId, parentId: null, type: "message", message: pending.payload } + : { + id: entryId, + parentId: null, + type: "custom", + customType: pending.customType, + ...(pending.payload === undefined ? {} : { data: pending.payload }), + }; +} + function capturedModel(operation: Operation): ModelIdentity | undefined { const { state } = operation; switch (state.at) { - case "run.assistant.ready": - case "run.assistant.effect_pending": - case "run.assistant.retry_wait": + case "assistant.ready": + case "assistant.effect_pending": + case "assistant.retry_wait": return state.generationContext.configuration.model; - case "run.tools": + case "tools": return state.batch.configuration.model; - case "run.deferred.suspended": - case "run.deferred.effect_pending": + case "deferred.suspended": + case "deferred.effect_pending": return state.configuration.model; - case "run.compaction.ready": - case "run.compaction.effect_pending": - case "run.compaction.retry_wait": - case "compaction.ready": - case "compaction.effect_pending": - case "compaction.retry_wait": - case "navigation.summary.ready": - case "navigation.summary.effect_pending": - case "navigation.summary.retry_wait": + case "summary.ready": + case "summary.effect_pending": + case "summary.retry_wait": return state.summaryContext.configuration.model; default: return undefined; @@ -155,21 +198,21 @@ export class Lane implements AgentLane { return this.state.tipId; } - async getLastResult(_context: Context): Promise { + async getResult(operationId: string, context: Context): Promise { this.assertOpen(); - return this.state.lastResult; + return (await this.session.getValue(operationResultValue(operationId), context))?.value; } readConfig(): Config { return this.config(); } - mismatch(expected: string, currentOperationId: string | null, last: LaneLastResult | undefined): OperationMismatch { + mismatch(expected: string, currentOperationId: string | null, lastOperationId: string | null): OperationMismatch { return new OperationMismatch({ lane: this.name, expectedOperationId: expected, ...(currentOperationId === null ? {} : { currentOperationId }), - ...(last === undefined ? {} : { lastOperationId: last.operationId }), + ...(lastOperationId === null ? {} : { lastOperationId }), message: `Operation ${expected} does not own lane ${JSON.stringify(this.name)}`, }); } @@ -257,6 +300,15 @@ export class Lane implements AgentLane { writes: [ ...decision.writes, setValue(operationStateValue(operation.meta.operationId), decision.operationState), + ...(decision.lane?.inbox === undefined + ? [] + : [ + setValue(laneStateValue(this.name), { + currentOperationId: operation.meta.operationId, + lastOperationId: state.lastOperationId, + inbox: decision.lane.inbox, + }), + ]), ], next: { ...state, @@ -268,17 +320,25 @@ export class Lane implements AgentLane { }; } if (decision.kind !== "finish") return decision; + const inbox = decision.lane?.inbox ?? state.inbox; return { kind: "commit", writes: [ ...decision.writes, - setValue(laneLastResultValue(this.name), decision.lastResult), + setValue(operationResultValue(operation.meta.operationId), decision.record), setValue(laneStateValue(this.name), { currentOperationId: null, - pendingNextRun: state.pendingNextRun, + lastOperationId: operation.meta.operationId, + inbox, }), ], - next: { ...state, ...decision.lane, lastResult: decision.lastResult, operation: null }, + next: { + ...state, + ...decision.lane, + inbox, + lastOperationId: operation.meta.operationId, + operation: null, + }, materialize: decision.materialize, ...(decision.events === undefined ? {} : { events: decision.events }), }; @@ -413,17 +473,34 @@ export class Lane implements AgentLane { ), }; } - const capturedIds = [...state.pendingNextRun]; - const captured = (await readPendingMessages(reader, capturedIds, "Pending next-run entry", context)).map( - ({ entryId, message }) => ({ id: entryId, message }), + const { selected: selectedItems, remainder: inbox } = selectAcceptedInbox( + state.inbox, + acceptanceConfig.steeringMode, + acceptanceConfig.followUpMode, ); - for (const { id, message } of captured) { - if (message.role === "assistant" && message.stopReason === "pending") { - throw new SessionInvariantError(`Pending next-run entry ${id} contains a pending assistant message`); - } - } - const placed = [...captured, ...prompt]; - if (placed.length === 0) { + const captured = await Promise.all( + selectedItems.map(async (item) => { + const stored = await reader.getValue(pendingEntry(item.entryId), context); + if (stored === undefined) { + throw new SessionInvariantError(`Pending ${item.kind} entry ${item.entryId} is missing its payload`); + } + if (item.kind !== "write" && stored.value.type !== "message") { + throw new SessionInvariantError(`Pending ${item.kind} entry ${item.entryId} is not a message`); + } + if ( + stored.value.type === "message" && + stored.value.payload.role === "assistant" && + stored.value.payload.stopReason === "pending" + ) { + throw new SessionInvariantError( + `Pending ${item.kind} entry ${item.entryId} contains a pending assistant`, + ); + } + return { item, pending: stored.value }; + }), + ); + const hasCapturedConversation = selectedItems.some((item) => item.kind !== "write"); + if (prompt.length === 0 && !hasCapturedConversation) { return { kind: "return", result: Result.err( @@ -436,10 +513,10 @@ export class Lane implements AgentLane { }; } - const entries = chainEntries( - state.tipId, - placed.map(({ id, message }) => ({ id, type: "message" as const, message })), - ); + const entries = chainEntries(state.tipId, [ + ...captured.map(({ item, pending }) => pendingEntryWrite(item.entryId, pending)), + ...prompt.map(({ id, message }) => ({ id, parentId: null, type: "message" as const, message })), + ]); const parentId = entries[entries.length - 1]!.id; const meta = { operationId, @@ -448,8 +525,8 @@ export class Lane implements AgentLane { startedAt, intent: { kind: "run" as const, promptEntryIds: prompt.map(({ id }) => id) }, }; - const operationState: RunStartingOperation = { - at: "run.starting", + const operationState: StartingOperation = { + at: "starting", control: { status: "running" }, settings: { compaction: acceptanceConfig.compaction, @@ -457,24 +534,47 @@ export class Lane implements AgentLane { followUpMode: acceptanceConfig.followUpMode, toolExecution: acceptanceConfig.toolExecution, }, - inbox: { steer: [], followUp: [], writes: [] }, latestAssistantEntryId: null, }; + const [remainingSteer, remainingFollowUp, remainingNextRun] = await Promise.all([ + readPendingMessages( + reader, + inboxItems(inbox, "steer").map((item) => item.entryId), + "Steer entry", + context, + ), + readPendingMessages( + reader, + inboxItems(inbox, "followUp").map((item) => item.entryId), + "Follow-up entry", + context, + ), + readPendingMessages( + reader, + inboxItems(inbox, "nextRun").map((item) => item.entryId), + "Pending next-run entry", + context, + ), + ]); const next: LaneState = { ...state, tipId: parentId, - pendingNextRun: [], + inbox, operation: { meta, state: operationState }, }; return { kind: "commit", writes: [ ...entries.map((entry) => insertEntry(entry)), - ...capturedIds.map((id) => deleteValue(pendingEntry(id))), + ...selectedItems.map((item) => deleteValue(pendingEntry(item.entryId))), setValue(branchTip(this.name), parentId), setValue(operationMetaValue(operationId), meta), setValue(operationStateValue(operationId), operationState), - setValue(laneStateValue(this.name), { currentOperationId: operationId, pendingNextRun: [] }), + setValue(laneStateValue(this.name), { + currentOperationId: operationId, + lastOperationId: state.lastOperationId, + inbox, + }), ], next, materialize: () => Result.ok({ operationId, kind: "run", startedAt }), @@ -483,12 +583,12 @@ export class Lane implements AgentLane { { type: "run_start", runId: operationId, lane: this.name }, ...committedEntryEvents(entries, commit, this.name, operationId), ]; - if (captured.length !== 0) { + if (hasCapturedConversation) { events.push({ type: "queue_update", - steer: [], - followUp: [], - nextRun: [], + steer: remainingSteer, + followUp: remainingFollowUp, + nextRun: remainingNextRun, lane: this.name, }); } @@ -530,7 +630,7 @@ export class Lane implements AgentLane { tipId: state.tipId, configuredModel: state.configuration.model, current, - ...(state.lastResult === undefined ? {} : { lastResult: state.lastResult }), + lastOperationId: state.lastOperationId, }, }; }, context); @@ -671,42 +771,44 @@ export class Lane implements AgentLane { context, ) ).reverse(); - const nextRun = await readPendingMessages( - reader, - captured.pendingNextRun, - "Pending next-run entry", - context, + const [steer, followUp, nextRun] = await Promise.all([ + readPendingMessages( + reader, + inboxItems(captured.inbox, "steer").map((item) => item.entryId), + "Steer entry", + context, + ), + readPendingMessages( + reader, + inboxItems(captured.inbox, "followUp").map((item) => item.entryId), + "Follow-up entry", + context, + ), + readPendingMessages( + reader, + inboxItems(captured.inbox, "nextRun").map((item) => item.entryId), + "Pending next-run entry", + context, + ), + ]); + // TODO does a client really need pending writes? We wouldn't visualize those, any other uses for them? + const pendingWrites = await Promise.all( + inboxItems(captured.inbox, "write").map(async ({ entryId }) => { + const stored = await reader.getValue(pendingEntry(entryId), context); + if (stored === undefined) { + throw new SessionInvariantError(`Pending write ${entryId} is missing its payload`); + } + return stored.value.type === "message" + ? { entryId, type: "message" as const, message: stored.value.payload } + : { + entryId, + type: "custom" as const, + customType: stored.value.customType, + ...(stored.value.payload === undefined ? {} : { data: stored.value.payload }), + }; + }), ); const operation = captured.operation; - const runState = operation !== null && isRunOperationState(operation.state) ? operation.state : undefined; - const steer = - runState === undefined - ? [] - : await readPendingMessages(reader, runState.inbox.steer, "Steer entry", context); - const followUp = - runState === undefined - ? [] - : await readPendingMessages(reader, runState.inbox.followUp, "Follow-up entry", context); - // TODO does a client really need pending writes? We wouldn't visualize those, any other uses for them? - const pendingWrites = - runState !== undefined - ? await Promise.all( - runState.inbox.writes.map(async (entryId) => { - const stored = await reader.getValue(pendingEntry(entryId), context); - if (stored === undefined) { - throw new SessionInvariantError(`Pending write ${entryId} is missing its payload`); - } - return stored.value.type === "message" - ? { entryId, type: "message" as const, message: stored.value.payload } - : { - entryId, - type: "custom" as const, - customType: stored.value.customType, - ...(stored.value.payload === undefined ? {} : { data: stored.value.payload }), - }; - }), - ) - : []; let operationSnapshot: NonNullable | null = null; if (operation !== null) { @@ -720,18 +822,18 @@ export class Lane implements AgentLane { ); const state = operation.state; switch (state.at) { - case "run.assistant.retry_wait": + case "assistant.retry_wait": retry = { attempt: state.nextAttempt, maxAttempts: state.generationContext.retryPolicy.maxAttempts, nextAttemptAt: state.notBefore, }; break; - case "run.assistant.effect_pending": + case "assistant.effect_pending": streamingMessage = await readStreamingMessage(state.responseEntryId); break; - case "run.deferred.suspended": - case "run.deferred.effect_pending": { + case "deferred.suspended": + case "deferred.effect_pending": { const source = (await reader.getEntries([state.sourceEntryId], context)).get(state.sourceEntryId); if ( source?.type !== "message" || @@ -741,12 +843,12 @@ export class Lane implements AgentLane { throw new SessionInvariantError("Deferred source is missing its assistant handle"); } deferred = { handle: source.message.deferred, poll: state.poll }; - if (state.at === "run.deferred.effect_pending") { + if (state.at === "deferred.effect_pending") { streamingMessage = await readStreamingMessage(state.responseEntryId); } break; } - case "run.tools": { + case "tools": { const { batch } = state; const assistant = (await reader.getEntries([batch.assistantEntryId], context)).get( batch.assistantEntryId, @@ -782,9 +884,7 @@ export class Lane implements AgentLane { } break; } - case "run.compaction.retry_wait": - case "compaction.retry_wait": - case "navigation.summary.retry_wait": + case "summary.retry_wait": retry = { attempt: state.nextAttempt, maxAttempts: state.summaryContext.retryPolicy.maxAttempts, @@ -829,7 +929,7 @@ export class Lane implements AgentLane { lane: this.name, transcript, tipId: captured.tipId, - ...(captured.lastResult === undefined ? {} : { lastResult: captured.lastResult }), + lastOperationId: captured.lastOperationId, operation: operationSnapshot, queues: { steer, followUp, nextRun }, pendingWrites, @@ -894,78 +994,51 @@ export class Lane implements AgentLane { return Promise.reject(new SessionPendingAssistantMessageError()); } const id = this.session.idGenerator.next(); - return this.command((state) => { + return this.command(async (state, reader) => { if (state.operation === null) { + const queued = inboxItems(state.inbox, "write"); + const captured = await Promise.all( + queued.map(async (item) => { + const stored = await reader.getValue(pendingEntry(item.entryId), context); + if (stored === undefined) { + throw new SessionInvariantError(`Pending write ${item.entryId} is missing its payload`); + } + return pendingEntryWrite(item.entryId, stored.value); + }), + ); + const inbox = withoutInboxItems(state.inbox, queued); + const entries = chainEntries(state.tipId, [...captured, pendingEntryWrite(id, pending)]); return { kind: "commit", writes: [ - insertEntry( - pending.type === "message" - ? { id, parentId: state.tipId, type: "message", message: pending.payload } - : { - id, - parentId: state.tipId, - type: "custom", - customType: pending.customType, - ...(pending.payload === undefined ? {} : { data: pending.payload }), - }, - ), + ...entries.map((entry) => insertEntry(entry)), + ...queued.map((item) => deleteValue(pendingEntry(item.entryId))), setValue(branchTip(this.name), id), + setValue(laneStateValue(this.name), { + currentOperationId: null, + lastOperationId: state.lastOperationId, + inbox, + }), ], - next: { ...state, tipId: id }, + next: { ...state, tipId: id, inbox }, materialize: () => id, - events: (commit) => { - const entry: Entry = - pending.type === "message" - ? { - id, - parentId: state.tipId, - type: "message", - message: pending.payload, - seq: commit.seqs[0]!, - timestamp: commit.timestamp, - } - : { - id, - parentId: state.tipId, - type: "custom", - customType: pending.customType, - ...(pending.payload === undefined ? {} : { data: pending.payload }), - seq: commit.seqs[0]!, - timestamp: commit.timestamp, - }; - return pending.type === "message" - ? [ - { type: "message_start", message: pending.payload, lane: this.name }, - { type: "message_end", message: pending.payload, entryId: id, lane: this.name }, - { type: "entry_added", entry, lane: this.name }, - ] - : [{ type: "entry_added", entry, lane: this.name }]; - }, + events: (commit) => committedEntryEvents(entries, commit, this.name), }; } const operation = state.operation; - if (!isRunOperationState(operation.state)) { - return { - kind: "reject", - error: new Error(`Cannot append while structural operation ${operation.meta.operationId} is active`), - }; - } - const operationState = { - ...operation.state, - inbox: { - ...operation.state.inbox, - writes: [...operation.state.inbox.writes, id], - }, - }; + const inbox = [...state.inbox, { entryId: id, kind: "write" as const }]; return { kind: "commit", writes: [ setValue(pendingEntry(id), pending), - setValue(operationStateValue(operation.meta.operationId), operationState), + setValue(laneStateValue(this.name), { + currentOperationId: operation.meta.operationId, + lastOperationId: state.lastOperationId, + inbox, + }), ], - next: { ...state, operation: { meta: operation.meta, state: operationState } }, + next: { ...state, inbox }, materialize: () => id, events: () => [ { diff --git a/packages/agent/src/harness/runtime/progress.ts b/packages/agent/src/harness/runtime/progress.ts index 5c6858ac2..e5e5fc428 100644 --- a/packages/agent/src/harness/runtime/progress.ts +++ b/packages/agent/src/harness/runtime/progress.ts @@ -80,7 +80,7 @@ export function openFrameProgress( const run = state.operation?.state; if (run === undefined) return false; return ( - (run.at === "run.assistant.effect_pending" || run.at === "run.deferred.effect_pending") && + (run.at === "assistant.effect_pending" || run.at === "deferred.effect_pending") && run.responseEntryId === responseEntryId ); }, @@ -101,7 +101,7 @@ export function openToolProgress( (snapshot) => setValue(address, snapshot), (state) => { const operation = state.operation; - if (operation?.state.at !== "run.tools") return false; + if (operation?.state.at !== "tools") return false; const batch = operation.state.batch; return ( batch.turnId === turnId && diff --git a/packages/agent/src/harness/runtime/restore.ts b/packages/agent/src/harness/runtime/restore.ts index 92168e04b..ec0bd1d3d 100644 --- a/packages/agent/src/harness/runtime/restore.ts +++ b/packages/agent/src/harness/runtime/restore.ts @@ -3,7 +3,8 @@ import { SessionInvariantError } from "../session/session.ts"; import type { LaneState as DurableLaneState, LaneConfiguration, - LaneLastResult, + OperationMeta, + OperationState, Session, SessionReader, } from "../session/types.ts"; @@ -11,7 +12,6 @@ import { branchTip, branchTipInventoryPrefix, laneConfig, - laneLastResult, laneState as laneStateValue, operationMeta, operationState, @@ -19,11 +19,28 @@ import { } from "../session/values.ts"; import type { LaneState } from "./types.ts"; +function isSummaryState(state: OperationState): state is Extract { + return state.at.startsWith("summary."); +} + +function stateMatchesIntent(intent: OperationMeta["intent"], state: OperationState): boolean { + if (intent.kind === "compaction") return isSummaryState(state) && state.task.boundary.kind === "finish"; + if (intent.kind === "navigation") { + return ( + state.at === "navigation.ready_to_commit" || + (isSummaryState(state) && state.task.boundary.kind === "commit_navigation") + ); + } + return ( + state.at !== "navigation.ready_to_commit" && + (!isSummaryState(state) || state.task.boundary.kind === "resume_checkpoint") + ); +} + type LaneValues = { tip: StoredValue | undefined; configuration: StoredValue | undefined; laneState: StoredValue | undefined; - lastResult: StoredValue | undefined; }; export type ClassifiedLaneStorage = @@ -34,22 +51,21 @@ export type ClassifiedLaneStorage = tip: StoredValue; configuration: StoredValue; laneState: StoredValue; - lastResult: StoredValue | undefined; }; function classifyLaneStorage(lane: string, values: LaneValues): ClassifiedLaneStorage { - const { tip, configuration, laneState, lastResult } = values; - if (tip === undefined && configuration === undefined && laneState === undefined && lastResult === undefined) { + const { tip, configuration, laneState } = values; + if (tip === undefined && configuration === undefined && laneState === undefined) { return { kind: "absent" }; } - if (tip !== undefined && configuration === undefined && laneState === undefined && lastResult === undefined) { + if (tip !== undefined && configuration === undefined && laneState === undefined) { return { kind: "branch", tip }; } if (tip === undefined) throw new SessionInvariantError(`Lane ${JSON.stringify(lane)} is missing branch.tip`); if (configuration === undefined) throw new SessionInvariantError(`Lane ${JSON.stringify(lane)} is missing lane.config`); if (laneState === undefined) throw new SessionInvariantError(`Lane ${JSON.stringify(lane)} is missing lane.state`); - return { kind: "lane", tip, configuration, laneState, lastResult }; + return { kind: "lane", tip, configuration, laneState }; } export async function readLaneStorage( @@ -57,41 +73,32 @@ export async function readLaneStorage( lane: string, context: Context, ): Promise { - const [tip, configuration, laneState, lastResult] = await Promise.all([ + const [tip, configuration, laneState] = await Promise.all([ reader.getValue(branchTip(lane), context), reader.getValue(laneConfig(lane), context), reader.getValue(laneStateValue(lane), context), - reader.getValue(laneLastResult(lane), context), ]); - return classifyLaneStorage(lane, { tip, configuration, laneState, lastResult }); + return classifyLaneStorage(lane, { tip, configuration, laneState }); } /** Restore every complete configured AgentLane in one coherent Session read. */ export function restoreSession(session: Session, context: Context): Promise> { return session.mutate(async (reader) => { - const [tips, configurations, states, lastResults] = await Promise.all([ + const [tips, configurations, states] = await Promise.all([ reader.scanValues(branchTipInventoryPrefix(), context), reader.scanValues(laneConfig(""), context), reader.scanValues(laneStateValue(""), context), - reader.scanValues(laneLastResult(""), context), ]); const tipByLane = new Map(tips.map((value) => [value.address.key, value])); const configurationByLane = new Map(configurations.map((value) => [value.address.key, value])); const stateByLane = new Map(states.map((value) => [value.address.key, value])); - const lastResultByLane = new Map(lastResults.map((value) => [value.address.key, value])); - const names = new Set([ - ...tipByLane.keys(), - ...configurationByLane.keys(), - ...stateByLane.keys(), - ...lastResultByLane.keys(), - ]); + const names = new Set([...tipByLane.keys(), ...configurationByLane.keys(), ...stateByLane.keys()]); const restored = new Map(); for (const lane of names) { const stored = classifyLaneStorage(lane, { tip: tipByLane.get(lane), configuration: configurationByLane.get(lane), laneState: stateByLane.get(lane), - lastResult: lastResultByLane.get(lane), }); if (stored.kind !== "lane") continue; restored.set(lane, await restoreLaneState(reader, lane, stored, context)); @@ -139,7 +146,7 @@ export async function restoreLaneState( `Operation ${operationId} belongs to lane ${JSON.stringify(meta.value.lane)}, not ${JSON.stringify(lane)}`, ); } - if (!state.value.at.startsWith(`${meta.value.intent.kind}.`)) { + if (!stateMatchesIntent(meta.value.intent, state.value)) { throw new SessionInvariantError( `Operation ${operationId} intent ${meta.value.intent.kind} does not match state ${state.value.at}`, ); @@ -150,8 +157,8 @@ export async function restoreLaneState( return { tipId: stored.tip.value, configuration: stored.configuration.value, - pendingNextRun: stored.laneState.value.pendingNextRun, - ...(stored.lastResult === undefined ? {} : { lastResult: stored.lastResult.value }), + inbox: stored.laneState.value.inbox, + lastOperationId: stored.laneState.value.lastOperationId, operation, }; } diff --git a/packages/agent/src/harness/runtime/transcript.ts b/packages/agent/src/harness/runtime/transcript.ts index 80a4e419b..4eec5efb4 100644 --- a/packages/agent/src/harness/runtime/transcript.ts +++ b/packages/agent/src/harness/runtime/transcript.ts @@ -4,7 +4,7 @@ import type { Context } from "../context.ts"; import { materializeCommittedEntry } from "../session/commit.ts"; import { buildSessionContext } from "../session/context.ts"; import { SessionInvariantError } from "../session/session.ts"; -import type { CommitResult, Entry, NewEntry, RunOperationState, SessionReader } from "../session/types.ts"; +import type { CommitResult, Entry, NewEntry, OperationState, SessionReader } from "../session/types.ts"; import { pendingEntry } from "../session/values.ts"; import type { Lane } from "./lane.ts"; import type { ContinueOperationResult, Drive } from "./types.ts"; @@ -20,11 +20,12 @@ export function chainEntries( }); } -export function entryLifecycleEvents(entry: Entry, lane: string, runId: string): HarnessEvent[] { +export function entryLifecycleEvents(entry: Entry, lane: string, runId?: string): HarnessEvent[] { + const operation = runId === undefined ? {} : { runId }; return entry.type === "message" ? [ - { type: "message_start", lane, runId, message: entry.message }, - { type: "message_end", lane, runId, message: entry.message, entryId: entry.id }, + { type: "message_start", lane, ...operation, message: entry.message }, + { type: "message_end", lane, ...operation, message: entry.message, entryId: entry.id }, { type: "entry_added", lane, entry }, ] : [{ type: "entry_added", lane, entry }]; @@ -34,7 +35,7 @@ export function committedEntryEvents( entries: readonly NewEntry[], commit: CommitResult, lane: string, - runId: string, + runId?: string, firstWriteIndex = 0, ): HarnessEvent[] { return entries.flatMap((entry, index) => @@ -46,7 +47,7 @@ export function committedEntryEvents( ); } -export function readBoundedEntries( +export function readBoundedEntries( lane: Lane, drive: Drive, capability: TState, @@ -65,7 +66,7 @@ export function readBoundedEntries( +export async function readBoundedContext( lane: Lane, drive: Drive, capability: TState, diff --git a/packages/agent/src/harness/runtime/types.ts b/packages/agent/src/harness/runtime/types.ts index 4bee6a94e..aee60b72e 100644 --- a/packages/agent/src/harness/runtime/types.ts +++ b/packages/agent/src/harness/runtime/types.ts @@ -1,21 +1,15 @@ import type { RetryPolicy } from "@earendil-works/pi-ai"; import type { QueueMode } from "../../types.ts"; -import type { - AgentHarnessOptions, - DriveOptions, - DriveOutcome, - HarnessEvent, - Resources, - TerminalOperationOutcome, -} from "../agent-harness.ts"; +import type { AgentHarnessOptions, DriveOptions, DriveOutcome, HarnessEvent, Resources } from "../agent-harness.ts"; import type { CompactionSettings } from "../compaction/compaction.ts"; import { type Context, withoutAbortSignal } from "../context.ts"; import { createGate, type Gate, type GateControl } from "../execution/effect-gate.ts"; import type { CommitResult, + InboxItem, LaneConfiguration, - LaneLastResult, Operation, + OperationResultRecord, OperationState, Write, } from "../session/types.ts"; @@ -48,8 +42,8 @@ export interface Config { export interface LaneState { readonly tipId: string | null; readonly configuration: LaneConfiguration; - readonly pendingNextRun: string[]; - readonly lastResult?: LaneLastResult; + readonly inbox: InboxItem[]; + readonly lastOperationId: string | null; readonly operation: Operation | null; } @@ -71,12 +65,12 @@ export type LaneCommand = export type ContinueOperationResult = { kind: "cancel_requested" } | { kind: "result"; value: TResult }; /** A durable operation transition. The Lane pairs the state write with projection publication. */ -type LanePatch = Partial>; +type LanePatch = Partial>; interface FinishDecision { kind: "finish"; writes: Write[]; - lastResult: LaneLastResult; + record: OperationResultRecord; lane?: LanePatch; materialize(commit: CommitResult): Synchronous; events?(commit: CommitResult): HarnessEvent[]; @@ -143,4 +137,4 @@ export class Drive { export type ProcedureResult = | { kind: "continue" } | { kind: "waiting"; outcome: DriveOutcome } - | { kind: "settled"; outcome: TerminalOperationOutcome }; + | { kind: "settled"; outcome: OperationResultRecord }; diff --git a/packages/agent/src/harness/session/fork.ts b/packages/agent/src/harness/session/fork.ts index 259c9c9d9..347b1b570 100644 --- a/packages/agent/src/harness/session/fork.ts +++ b/packages/agent/src/harness/session/fork.ts @@ -4,7 +4,6 @@ import { branchTip, entryLabel, laneConfig, - laneLastResult, laneState, type StoredValue, sessionName, @@ -59,10 +58,7 @@ export function createForkSnapshot(source: ForkSourceSnapshot, options: ForkOpti store(branchTip(destination.name), destination.tipId); if (configuration !== undefined) { store(laneConfig(destination.name), configuration.value); - store(laneState(destination.name), { - currentOperationId: null, - pendingNextRun: [], - }); + store(laneState(destination.name), { currentOperationId: null, lastOperationId: null, inbox: [] }); } } const name = findStoredValue(source.scalarValues, sessionName); @@ -146,8 +142,7 @@ function validateForkSourceSnapshot( for (const stored of source.scalarValues) { if ( (stored.address.namespace === laneConfig("").namespace || - stored.address.namespace === laneState("").namespace || - stored.address.namespace === laneLastResult("").namespace) && + stored.address.namespace === laneState("").namespace) && !sourceTipKeys.has(stored.address.key) ) { throw new Error(`Source session branch ${JSON.stringify(stored.address.key)} is missing branch.tip`); @@ -156,13 +151,9 @@ function validateForkSourceSnapshot( for (const tip of sourceTips) { const configuration = findStoredValue(source.scalarValues, laneConfig(tip.address.key)); const state = findStoredValue(source.scalarValues, laneState(tip.address.key)); - const lastResult = findStoredValue(source.scalarValues, laneLastResult(tip.address.key)); if ((configuration === undefined) !== (state === undefined)) { throw new Error(`Source session branch ${JSON.stringify(tip.address.key)} has incomplete lane state`); } - if (configuration === undefined && lastResult !== undefined) { - throw new Error(`Source session branch ${JSON.stringify(tip.address.key)} has a result without lane state`); - } if ( (source.entriesComplete !== false || options.scope === "tree") && tip.value !== null && diff --git a/packages/agent/src/harness/session/jsonl/legacy-v3.ts b/packages/agent/src/harness/session/jsonl/legacy-v3.ts index 29ee4d8d6..4dfb0f980 100644 --- a/packages/agent/src/harness/session/jsonl/legacy-v3.ts +++ b/packages/agent/src/harness/session/jsonl/legacy-v3.ts @@ -493,7 +493,7 @@ function normalizeLegacyV3Values( seq: firstSeq + writes.length, namespace: stateAddress.namespace, key: stateAddress.key, - value: { currentOperationId: null, pendingNextRun: [] }, + value: { currentOperationId: null, lastOperationId: null, inbox: [] }, }); } return writes; diff --git a/packages/agent/src/harness/session/testing/conformance/session-repo.ts b/packages/agent/src/harness/session/testing/conformance/session-repo.ts index c5358827b..4e037f70a 100644 --- a/packages/agent/src/harness/session/testing/conformance/session-repo.ts +++ b/packages/agent/src/harness/session/testing/conformance/session-repo.ts @@ -16,11 +16,11 @@ import { branchTip, entryLabel, laneConfig, - laneLastResult, laneState, list, operationMeta, operationPreparation, + operationResult, operationState, operationToolArgs, pendingEntry, @@ -38,7 +38,8 @@ const OPERATION_ID = "00000000-0000-7000-8000-000000000005"; const PENDING_ID = "00000000-0000-7000-8000-000000000006"; const idleLaneState = { currentOperationId: null, - pendingNextRun: [], + lastOperationId: null, + inbox: [], } satisfies LaneState; const applicationValue = value("test.application.value"); const applicationList = list("test.application.list"); @@ -364,14 +365,17 @@ export function createSessionRepoForkBehaviorConformance; -export type CompactionOperationState = Extract; -export type NavigationOperationState = Extract; - -export function isRunOperationState(state: OperationState): state is RunOperationState { - return state.at.startsWith("run."); -} - -/** Copy only the run-wide scope fields when constructing a successor leaf. */ -export function runScopeOf(state: RunOperationState): RunScope { +/** Copy only the uniform operation scope when constructing a successor leaf. */ +export function operationScopeOf(state: OperationState): OperationScope { return { control: state.control, settings: state.settings, - inbox: state.inbox, latestAssistantEntryId: state.latestAssistantEntryId, }; } @@ -420,69 +345,10 @@ export type Operation = { meta: OperationMeta; state: OperationState }; export interface LaneState { currentOperationId: string | null; - pendingNextRun: string[]; + lastOperationId: string | null; + inbox: InboxItem[]; } -type FailedLaneLastResult = { - outcome: "failed"; - error: OperationError; - runCompletion?: never; -}; -type AbortedLaneLastResult = { - outcome: "aborted"; - error?: never; - runCompletion?: never; -}; -type StructuralLaneLastResultOutcome = - | FailedLaneLastResult - | AbortedLaneLastResult - | { outcome: "declined"; error?: never; runCompletion?: never } - | { outcome: "completed"; error?: never; runCompletion?: never }; - -export type LaneLastResult = - | ({ - operationId: string; - kind: "run"; - tipId: string; - finalAssistantEntryId?: string; - } & ( - | FailedLaneLastResult - | AbortedLaneLastResult - | { - outcome: "completed"; - error?: never; - runCompletion: "assistant" | "terminated_tools"; - } - )) - | ({ - operationId: string; - kind: "compaction"; - tipId: string; - finalAssistantEntryId?: never; - } & StructuralLaneLastResultOutcome) - | ({ - operationId: string; - kind: "navigation"; - tipId: string | null; - oldTipId: string | null; - finalAssistantEntryId?: never; - } & ( - | FailedLaneLastResult - | AbortedLaneLastResult - | { - outcome: "declined"; - error?: never; - runCompletion?: never; - summaryEntryId?: never; - } - | { - outcome: "completed"; - error?: never; - runCompletion?: never; - summaryEntryId?: string; - } - )); - export type PendingEntry = | { type: "message"; payload: AgentMessage } | { type: "custom"; customType: string; payload?: JsonValue }; diff --git a/packages/agent/src/harness/session/values.ts b/packages/agent/src/harness/session/values.ts index e9471262e..daad874ed 100644 --- a/packages/agent/src/harness/session/values.ts +++ b/packages/agent/src/harness/session/values.ts @@ -4,9 +4,9 @@ import type { DurableStructuralPreparation, JsonValue, LaneConfiguration, - LaneLastResult, LaneState, OperationMeta, + OperationResultRecord, OperationState, PendingEntry, } from "./types.ts"; @@ -159,7 +159,7 @@ export const branchTip = (branch: string) => value("pi.branch.tip export const branchTipInventoryPrefix = () => value("pi.branch.tip"); export const laneConfig = (lane: string) => value("pi.lane.config", lane); export const laneState = (lane: string) => value("pi.lane.state", lane); -export const laneLastResult = (lane: string) => value("pi.lane.lastResult", lane); +export const operationResult = (operationId: string) => value("pi.result", operationId); export const operationMeta = (operationId: string) => value("pi.op.meta", operationId); export const operationState = (operationId: string) => value("pi.op.state", operationId); diff --git a/packages/agent/src/harness/telemetry.ts b/packages/agent/src/harness/telemetry.ts index 1892bbb61..9eb6ee937 100644 --- a/packages/agent/src/harness/telemetry.ts +++ b/packages/agent/src/harness/telemetry.ts @@ -319,7 +319,7 @@ export const HARNESS_TELEMETRY_SCHEMA = { "pi.checkpoint.kind": { type: "string", required: true, - values: ["normal", "failure_drain", "abort_reconcile"], + values: ["normal", "abort_reconcile"], description: "Checkpoint purpose", }, }, diff --git a/packages/agent/test/harness/jsonl-storage.test.ts b/packages/agent/test/harness/jsonl-storage.test.ts index 22a033b85..3c181582b 100644 --- a/packages/agent/test/harness/jsonl-storage.test.ts +++ b/packages/agent/test/harness/jsonl-storage.test.ts @@ -46,7 +46,7 @@ function preparedSnapshot(): ForkDestinationSnapshot { ]), scalarValues: [ stored(storedValues.branchTip("main"), "root", 2), - stored(storedValues.laneState("main"), { currentOperationId: null, pendingNextRun: [] }, 3), + stored(storedValues.laneState("main"), { currentOperationId: null, lastOperationId: null, inbox: [] }, 3), stored(storedValues.sessionName, "forked", 4), ], nextSeq: 9, @@ -72,7 +72,8 @@ describe("JsonlStorage snapshot creation", () => { expect((await storage.getValue(storedValues.branchTip("main"), BACKGROUND_CONTEXT))?.value).toBe("root"); expect((await storage.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT))?.value).toEqual({ currentOperationId: null, - pendingNextRun: [], + lastOperationId: null, + inbox: [], }); expect((await storage.getValue(storedValues.sessionName, BACKGROUND_CONTEXT))?.value).toBe("forked"); expect(await storage.getStats(BACKGROUND_CONTEXT)).toEqual({ diff --git a/packages/agent/test/harness/jsonl-v3-migration.test.ts b/packages/agent/test/harness/jsonl-v3-migration.test.ts index bd6c8d33b..28a573139 100644 --- a/packages/agent/test/harness/jsonl-v3-migration.test.ts +++ b/packages/agent/test/harness/jsonl-v3-migration.test.ts @@ -701,7 +701,8 @@ describe("JSONL v3 migration", () => { }); expect((await session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT))?.value).toEqual({ currentOperationId: null, - pendingNextRun: [], + lastOperationId: null, + inbox: [], }); await session.close(BACKGROUND_CONTEXT); }); @@ -757,7 +758,8 @@ describe("JSONL v3 migration", () => { }); expect((await session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT))?.value).toEqual({ currentOperationId: null, - pendingNextRun: [], + lastOperationId: null, + inbox: [], }); await session.close(BACKGROUND_CONTEXT); }); @@ -913,7 +915,8 @@ describe("JSONL v3 migration", () => { }); expect((await session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT))?.value).toEqual({ currentOperationId: null, - pendingNextRun: [], + lastOperationId: null, + inbox: [], }); await session.close(BACKGROUND_CONTEXT); }); @@ -945,7 +948,8 @@ describe("JSONL v3 migration", () => { }); expect((await session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT))?.value).toEqual({ currentOperationId: null, - pendingNextRun: [], + lastOperationId: null, + inbox: [], }); await session.close(BACKGROUND_CONTEXT); }); diff --git a/packages/agent/test/harness/runtime/accept.test.ts b/packages/agent/test/harness/runtime/accept.test.ts index 260d855a1..7de08d799 100644 --- a/packages/agent/test/harness/runtime/accept.test.ts +++ b/packages/agent/test/harness/runtime/accept.test.ts @@ -37,7 +37,8 @@ async function createSession(storage = new InstrumentedStorage(new MemoryStorage storedValues.setValue(storedValues.laneConfig("main"), configuration), storedValues.setValue(storedValues.laneState("main"), { currentOperationId: null, - pendingNextRun: [], + lastOperationId: null, + inbox: [], }), ], BACKGROUND_CONTEXT, @@ -57,6 +58,7 @@ function options(session: Session) { async function createHarness( beforeCreate?: (session: Session) => Promise, resources: Parameters[0]["resources"] = {}, + queueModes: { steeringMode?: "all" | "one-at-a-time"; followUpMode?: "all" | "one-at-a-time" } = {}, ): Promise<{ harness: Harness; lane: Lane; @@ -66,7 +68,7 @@ async function createHarness( }> { const created = await createSession(); await beforeCreate?.(created.session); - const harnessOptions = { ...options(created.session), resources }; + const harnessOptions = { ...options(created.session), resources, ...queueModes }; const { harness } = await createAgentHarness(harnessOptions, BACKGROUND_CONTEXT); if (!(harness instanceof Harness)) throw new Error("Expected runtime Harness"); const lane = await harness.lane("main", BACKGROUND_CONTEXT); @@ -113,7 +115,7 @@ describe("runtime atomic run acceptance", () => { const admission = unwrap(await lane.accept(request, BACKGROUND_CONTEXT)); const operation = lane.state.operation; - if (operation?.state.at !== "run.starting") throw new Error("Expected accepted run"); + if (operation?.state.at !== "starting") throw new Error("Expected accepted run"); const entryId = operation.meta.intent.kind === "run" ? operation.meta.intent.promptEntryIds[0] : undefined; if (entryId === undefined) throw new Error("Expected prompt entry"); const entry = await session.getEntry(entryId, BACKGROUND_CONTEXT); @@ -198,7 +200,11 @@ describe("runtime atomic run acceptance", () => { }), storedValues.setValue(storedValues.laneState("main"), { currentOperationId: null, - pendingNextRun: [first, second], + lastOperationId: null, + inbox: [ + { entryId: first, kind: "nextRun" }, + { entryId: second, kind: "nextRun" }, + ], }), ], BACKGROUND_CONTEXT, @@ -214,12 +220,12 @@ describe("runtime atomic run acceptance", () => { unwrap(await lane.accept({ kind: "prompt", prompt: "" }, BACKGROUND_CONTEXT)); const operation = lane.state.operation; - if (operation?.meta.intent.kind !== "run" || operation.state.at !== "run.starting") { + if (operation?.meta.intent.kind !== "run" || operation.state.at !== "starting") { throw new Error("Expected accepted run"); } expect(operation.meta.intent.promptEntryIds).toEqual([]); expect(operation.meta.sourceTipId).toBeNull(); - expect(operation.state.inbox).toEqual({ steer: [], followUp: [], writes: [] }); + expect(lane.state.inbox).toEqual([]); expect( (await session.scanBranch({ start: second, order: "oldestFirst" }, BACKGROUND_CONTEXT)).map(({ id }) => id), ).toEqual([first, second]); @@ -233,6 +239,104 @@ describe("runtime atomic run acceptance", () => { expect(events).toEqual(["queue_update"]); }); + it("captures every eligible tag by mode and preserves admission order before the request", async () => { + const ids = { + write: "queued-write", + next: "queued-next", + steer1: "queued-steer-1", + steer2: "queued-steer-2", + follow1: "queued-follow-1", + follow2: "queued-follow-2", + }; + const { lane, session } = await createHarness( + async (source) => { + await source.mutate( + (mutator) => + mutator.commit( + [ + storedValues.setValue(storedValues.pendingEntry(ids.write), { + type: "custom", + customType: "queued-write", + }), + ...[ids.next, ids.steer1, ids.steer2, ids.follow1, ids.follow2].map((entryId) => + storedValues.setValue(storedValues.pendingEntry(entryId), { + type: "message", + payload: { role: "user", content: entryId, timestamp: 1 }, + }), + ), + storedValues.setValue(storedValues.laneState("main"), { + currentOperationId: null, + lastOperationId: null, + inbox: [ + { entryId: ids.steer1, kind: "steer" }, + { entryId: ids.write, kind: "write" }, + { entryId: ids.next, kind: "nextRun" }, + { entryId: ids.follow1, kind: "followUp" }, + { entryId: ids.steer2, kind: "steer" }, + { entryId: ids.follow2, kind: "followUp" }, + ], + }), + ], + BACKGROUND_CONTEXT, + ), + BACKGROUND_CONTEXT, + ); + }, + {}, + { steeringMode: "one-at-a-time", followUpMode: "one-at-a-time" }, + ); + + unwrap(await lane.accept({ kind: "prompt", prompt: "request" }, BACKGROUND_CONTEXT)); + + const tipId = lane.state.tipId; + if (tipId === null) throw new Error("Expected accepted tip"); + const entries = await session.scanBranch({ start: tipId, order: "oldestFirst" }, BACKGROUND_CONTEXT); + expect(entries.map((entry) => entry.id)).toEqual([ids.steer1, ids.write, ids.next, ids.follow1, tipId]); + expect(lane.state.inbox).toEqual([ + { entryId: ids.steer2, kind: "steer" }, + { entryId: ids.follow2, kind: "followUp" }, + ]); + expect((await session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT))?.value.inbox).toEqual( + lane.state.inbox, + ); + for (const entryId of [ids.write, ids.next, ids.steer1, ids.follow1]) { + expect(await session.getValue(storedValues.pendingEntry(entryId), BACKGROUND_CONTEXT)).toBeUndefined(); + } + for (const entryId of [ids.steer2, ids.follow2]) { + expect(await session.getValue(storedValues.pendingEntry(entryId), BACKGROUND_CONTEXT)).toBeDefined(); + } + }); + + it("does not let a lone queued write validate empty acceptance", async () => { + const entryId = "queued-write"; + const { lane, session } = await createHarness(async (source) => { + await source.mutate( + (mutator) => + mutator.commit( + [ + storedValues.setValue(storedValues.pendingEntry(entryId), { + type: "custom", + customType: "queued-write", + }), + storedValues.setValue(storedValues.laneState("main"), { + currentOperationId: null, + lastOperationId: null, + inbox: [{ entryId, kind: "write" }], + }), + ], + BACKGROUND_CONTEXT, + ), + BACKGROUND_CONTEXT, + ); + }); + + const result = await lane.accept({ kind: "prompt", prompt: "" }, BACKGROUND_CONTEXT); + + expect(result).toMatchObject({ ok: false, error: { _tag: "InvalidMessage", reason: "empty" } }); + expect(lane.state.inbox).toEqual([{ entryId, kind: "write" }]); + expect(await session.getValue(storedValues.pendingEntry(entryId), BACKGROUND_CONTEXT)).toBeDefined(); + }); + it("formats skills and templates before acceptance", async () => { const resources = { skills: [ @@ -312,7 +416,8 @@ describe("runtime atomic run acceptance", () => { storedValues.setValue(storedValues.laneConfig("main"), configuration), storedValues.setValue(storedValues.laneState("main"), { currentOperationId: null, - pendingNextRun: [], + lastOperationId: null, + inbox: [], }), ], BACKGROUND_CONTEXT, @@ -388,7 +493,8 @@ describe("runtime atomic run acceptance", () => { storedValues.setValue(storedValues.laneConfig("main"), configuration), storedValues.setValue(storedValues.laneState("main"), { currentOperationId: null, - pendingNextRun: [], + lastOperationId: null, + inbox: [], }), ], BACKGROUND_CONTEXT, diff --git a/packages/agent/test/harness/runtime/drive-generation.test.ts b/packages/agent/test/harness/runtime/drive-generation.test.ts index cbf57304f..20ae321da 100644 --- a/packages/agent/test/harness/runtime/drive-generation.test.ts +++ b/packages/agent/test/harness/runtime/drive-generation.test.ts @@ -7,7 +7,7 @@ import { type MutableModels, type Provider, } from "@earendil-works/pi-ai"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { HarnessEvent, WatchHandle } from "../../../src/harness/agent-harness.ts"; import { DEFAULT_COMPACTION_SETTINGS } from "../../../src/harness/compaction/compaction.ts"; import { BACKGROUND_CONTEXT, type Context } from "../../../src/harness/context.ts"; @@ -22,14 +22,13 @@ import { MemoryStorage } from "../../../src/harness/session/memory.ts"; import { StorageBackedSession } from "../../../src/harness/session/session.ts"; import { InstrumentedStorage } from "../../../src/harness/session/testing/instrumented-storage.ts"; import { - isRunOperationState, + type AssistantEffectPendingOperation, + type AssistantReadyOperation, + type CheckpointOperation, type LaneConfiguration, type MessageEntry, - type RunAssistantEffectPendingOperation, - type RunAssistantReadyOperation, - type RunCheckpointOperation, - type RunOperationState, - runScopeOf, + type OperationState, + operationScopeOf, type Session, type Write, } from "../../../src/harness/session/types.ts"; @@ -98,7 +97,8 @@ async function createFixture(backend: MemoryStorage = new MemoryStorage({ now: ( storedValues.setValue(storedValues.laneConfig("main"), configuration), storedValues.setValue(storedValues.laneState("main"), { currentOperationId: null, - pendingNextRun: [], + lastOperationId: null, + inbox: [], }), ], BACKGROUND_CONTEXT, @@ -148,28 +148,28 @@ async function createFixture(backend: MemoryStorage = new MemoryStorage({ now: ( return { lane, drive, session, storage, models, faux, hooks, events, config }; } -function currentRun(lane: Lane): RunOperationState { +function currentRun(lane: Lane): OperationState { const operation = lane.state.operation; - if (operation === null || !isRunOperationState(operation.state)) throw new Error("fixture has no run"); + if (operation === null) throw new Error("fixture has no operation"); return operation.state; } -function readyGeneration(lane: Lane): RunAssistantReadyOperation { +function readyGeneration(lane: Lane): AssistantReadyOperation { const run = currentRun(lane); - if (run.at !== "run.assistant.ready") throw new Error("fixture has no ready generation"); + if (run.at !== "assistant.ready") throw new Error("fixture has no ready generation"); return run; } async function startFixtureRun(fixture: Fixture) { const run = currentRun(fixture.lane); - if (run.at !== "run.starting") throw new Error("run did not start at its initial boundary"); + if (run.at !== "starting") throw new Error("run did not start at its initial boundary"); return startRun(fixture.lane, fixture.drive, run); } -async function advanceToReady(fixture: Fixture): Promise { +async function advanceToReady(fixture: Fixture): Promise { await startFixtureRun(fixture); const run = currentRun(fixture.lane); - if (run.at !== "run.checkpoint") throw new Error("run did not reach checkpoint"); + if (run.at !== "checkpoint") throw new Error("run did not reach checkpoint"); await runCheckpoint(fixture.lane, fixture.drive, run); return readyGeneration(fixture.lane); } @@ -187,11 +187,12 @@ function writesOperationState(writes: readonly Write[], status: string): boolean typeof write.value === "object" && write.value !== null && "at" in write.value && - write.value.at === `run.assistant.${status}`, + write.value.at === `assistant.${status}`, ); } afterEach(async () => { + vi.restoreAllMocks(); for (const session of sessions.splice(0)) await session.close(BACKGROUND_CONTEXT); }); @@ -203,14 +204,14 @@ describe("runtime generation checkpoint", () => { })); expect(await startFixtureRun(fixture)).toEqual({ kind: "continue" }); let run = currentRun(fixture.lane); - if (run.at !== "run.checkpoint") throw new Error("missing checkpoint"); + if (run.at !== "checkpoint") throw new Error("missing checkpoint"); expect(run.triggerEntryId).toBe(fixture.lane.state.tipId); expect(fixture.events.map((event) => event.type)).toContain("entry_added"); await expectProjectionRestores(fixture); expect(await runCheckpoint(fixture.lane, fixture.drive, run)).toEqual({ kind: "continue" }); run = currentRun(fixture.lane); - if (run.at !== "run.assistant.ready") { + if (run.at !== "assistant.ready") { throw new Error("missing ready generation"); } expect(run.generationContext).toMatchObject({ @@ -229,11 +230,12 @@ describe("runtime generation checkpoint", () => { const customId = "01950000-0000-7000-8000-000000000011"; await fixture.lane.command((state) => { const operation = state.operation; - if (operation === null || !isRunOperationState(operation.state)) throw new Error("missing run"); - const nextState: RunOperationState = { - ...operation.state, - inbox: { ...operation.state.inbox, writes: [messageId, customId] }, - }; + if (operation === null) throw new Error("missing operation"); + const inbox = [ + ...state.inbox, + { entryId: messageId, kind: "write" as const }, + { entryId: customId, kind: "write" as const }, + ]; return { kind: "commit", writes: [ @@ -246,26 +248,30 @@ describe("runtime generation checkpoint", () => { customType: "display-only", payload: { value: true }, }), - storedValues.setValue(storedValues.operationState(operationId), nextState), + storedValues.setValue(storedValues.laneState("main"), { + currentOperationId: operationId, + lastOperationId: null, + inbox, + }), ], - next: { ...state, operation: { meta: operation.meta, state: nextState } }, + next: { ...state, inbox }, materialize: () => undefined, }; }, BACKGROUND_CONTEXT); const run = currentRun(fixture.lane); - if (run.at !== "run.checkpoint") throw new Error("missing checkpoint"); + if (run.at !== "checkpoint") throw new Error("missing checkpoint"); await runCheckpoint(fixture.lane, fixture.drive, run); const drained = currentRun(fixture.lane); - if (drained.at !== "run.checkpoint") throw new Error("missing drained checkpoint"); + if (drained.at !== "checkpoint") throw new Error("missing drained checkpoint"); expect(fixture.lane.state.tipId).toBe(customId); expect(drained.triggerEntryId).toBe(messageId); expect(drained).toMatchObject({ continuation: { kind: "need_assistant", overflowRecoveryUsed: false }, skipInboxOnce: true, }); - expect(drained.inbox.writes).toEqual([]); + expect(fixture.lane.state.inbox).toEqual([]); await expectProjectionRestores(fixture); }); @@ -273,18 +279,18 @@ describe("runtime generation checkpoint", () => { const fixture = await createFixture(); await startFixtureRun(fixture); const current = currentRun(fixture.lane); - if (current.at !== "run.checkpoint") throw new Error("missing checkpoint"); + if (current.at !== "checkpoint") throw new Error("missing checkpoint"); const customId = "01950000-0000-7000-8000-000000000012"; - const checkpoint: RunCheckpointOperation = { + const checkpoint: CheckpointOperation = { ...current, continuation: { kind: "may_finish", includeFinalAssistant: false }, thresholdCheckedTriggerEntryId: current.triggerEntryId, skipInboxOnce: false, - inbox: { ...current.inbox, writes: [customId] }, }; await fixture.lane.command((state) => { const operation = state.operation; if (operation === null) throw new Error("missing operation"); + const inbox = [...state.inbox, { entryId: customId, kind: "write" as const }]; return { kind: "commit", writes: [ @@ -294,8 +300,13 @@ describe("runtime generation checkpoint", () => { payload: { value: true }, }), storedValues.setValue(storedValues.operationState(operationId), checkpoint), + storedValues.setValue(storedValues.laneState("main"), { + currentOperationId: operationId, + lastOperationId: null, + inbox, + }), ], - next: { ...state, operation: { meta: operation.meta, state: checkpoint } }, + next: { ...state, inbox, operation: { meta: operation.meta, state: checkpoint } }, materialize: () => undefined, }; }, BACKGROUND_CONTEXT); @@ -303,15 +314,15 @@ describe("runtime generation checkpoint", () => { await runCheckpoint(fixture.lane, fixture.drive, checkpoint); const drained = currentRun(fixture.lane); - if (drained.at !== "run.checkpoint") throw new Error("missing drained checkpoint"); + if (drained.at !== "checkpoint") throw new Error("missing drained checkpoint"); expect(fixture.lane.state.tipId).toBe(customId); expect(drained).toMatchObject({ continuation: checkpoint.continuation, triggerEntryId: checkpoint.triggerEntryId, thresholdCheckedTriggerEntryId: checkpoint.triggerEntryId, skipInboxOnce: false, - inbox: { writes: [] }, }); + expect(fixture.lane.state.inbox).toEqual([]); expect(await fixture.session.getValue(storedValues.pendingEntry(customId), BACKGROUND_CONTEXT)).toBeUndefined(); await expectProjectionRestores(fixture); }); @@ -332,7 +343,7 @@ describe("runtime assistant generation", () => { fixture.faux.setResponses([ async () => { const state = await fixture.session.getValue(storedValues.operationState(operationId), BACKGROUND_CONTEXT); - effectPendingAtProvider = state?.value.at === "run.assistant.effect_pending"; + effectPendingAtProvider = state?.value.at === "assistant.effect_pending"; return fauxAssistantMessage("answer", { timestamp: 5 }); }, ]); @@ -342,11 +353,8 @@ describe("runtime assistant generation", () => { const steerId = "01950000-0000-7000-8000-000000000020"; await fixture.lane.command((state) => { const operation = state.operation; - if (operation === null || !isRunOperationState(operation.state)) throw new Error("missing run"); - const nextState: RunOperationState = { - ...operation.state, - inbox: { ...operation.state.inbox, steer: [steerId] }, - }; + if (operation === null) throw new Error("missing operation"); + const inbox = [...state.inbox, { entryId: steerId, kind: "steer" as const }]; return { kind: "commit", writes: [ @@ -354,9 +362,13 @@ describe("runtime assistant generation", () => { type: "message", payload: { role: "user", content: "late steer", timestamp: 4 }, }), - storedValues.setValue(storedValues.operationState(operationId), nextState), + storedValues.setValue(storedValues.laneState("main"), { + currentOperationId: operationId, + lastOperationId: null, + inbox, + }), ], - next: { ...state, operation: { meta: operation.meta, state: nextState } }, + next: { ...state, inbox }, materialize: () => undefined, }; }, BACKGROUND_CONTEXT); @@ -365,8 +377,8 @@ describe("runtime assistant generation", () => { expect(await generating).toEqual({ kind: "continue" }); expect(effectPendingAtProvider).toBe(true); const settled = currentRun(fixture.lane); - expect(settled.inbox.steer).toEqual([steerId]); - if (settled.at !== "run.checkpoint") throw new Error("response did not reach checkpoint"); + expect(fixture.lane.state.inbox).toEqual([{ entryId: steerId, kind: "steer" }]); + if (settled.at !== "checkpoint") throw new Error("response did not reach checkpoint"); const responseId = settled.latestAssistantEntryId; if (responseId === null) throw new Error("missing response id"); const entry = await fixture.session.getEntry(responseId, BACKGROUND_CONTEXT); @@ -472,18 +484,21 @@ describe("runtime assistant generation", () => { const fixture = await createFixture(); await fixture.lane.setActiveTools(["missing"], BACKGROUND_CONTEXT); const ready = await advanceToReady(fixture); + const queuedId = await fixture.lane.appendCustomEntry("after-failure", { retained: true }, BACKGROUND_CONTEXT); fixture.storage.clearCommitAttempts(); - expect(await runGeneration(fixture.lane, fixture.drive, ready)).toEqual({ - kind: "continue", - }); - - const run = currentRun(fixture.lane); - expect(run).toMatchObject({ - at: "run.failure_drain", - error: { code: "configured_tools_unavailable", details: { tools: ["missing"] } }, - provenance: { kind: "configuration" }, + expect(await runGeneration(fixture.lane, fixture.drive, ready)).toMatchObject({ + kind: "settled", + outcome: { + operationId, + kind: "run", + status: "failed", + error: { code: "configured_tools_unavailable", details: { tools: ["missing"] } }, + }, }); + expect(fixture.lane.state.operation).toBeNull(); + expect(fixture.lane.state.inbox).toEqual([{ entryId: queuedId, kind: "write" }]); + expect(await fixture.session.getValue(storedValues.pendingEntry(queuedId), BACKGROUND_CONTEXT)).toBeDefined(); expect(fixture.faux.state.callCount).toBe(0); expect( fixture.storage @@ -500,15 +515,11 @@ describe("runtime assistant generation", () => { fixture.models.deleteProvider(fixture.faux.provider.id); fixture.storage.clearCommitAttempts(); - expect(await runGeneration(fixture.lane, fixture.drive, ready)).toEqual({ - kind: "continue", - }); - - expect(currentRun(fixture.lane)).toMatchObject({ - at: "run.failure_drain", - error: { code: "model_unavailable" }, - provenance: { kind: "configuration" }, + expect(await runGeneration(fixture.lane, fixture.drive, ready)).toMatchObject({ + kind: "settled", + outcome: { operationId, kind: "run", status: "failed", error: { code: "model_unavailable" } }, }); + expect(fixture.lane.state.operation).toBeNull(); expect(fixture.faux.state.callCount).toBe(0); expect( fixture.storage @@ -534,8 +545,8 @@ describe("runtime assistant generation", () => { await cancelHookStarted.promise; await cancelled.lane.command((state) => { const operation = state.operation; - if (operation === null || !isRunOperationState(operation.state)) throw new Error("missing run"); - const nextState: RunOperationState = { + if (operation === null) throw new Error("missing operation"); + const nextState: OperationState = { ...operation.state, control: { status: "cancel_requested", requestedAt: 10, drainedSteer: [], drainedFollowUp: [] }, }; @@ -559,9 +570,9 @@ describe("runtime assistant generation", () => { const ready = await advanceToReady(fixture); const responseEntryId = "01950000-0000-7000-8000-000000000028"; const usageId = "01950000-0000-7000-8000-000000000029"; - const pending: RunAssistantEffectPendingOperation = { - ...runScopeOf(ready), - at: "run.assistant.effect_pending", + const pending: AssistantEffectPendingOperation = { + ...operationScopeOf(ready), + at: "assistant.effect_pending", generationContext: { ...ready.generationContext, retryPolicy: { maxAttempts: 2, baseDelayMs: 1 } }, attempt: 1, responseEntryId, @@ -571,7 +582,7 @@ describe("runtime assistant generation", () => { }; await fixture.lane.command((state) => { const operation = state.operation; - if (operation === null || !isRunOperationState(operation.state)) throw new Error("missing run"); + if (operation === null) throw new Error("missing operation"); return { kind: "commit", writes: [storedValues.setValue(storedValues.operationState(operationId), pending)], @@ -592,7 +603,7 @@ describe("runtime assistant generation", () => { usage: { totalTokens: 0 }, }); expect(currentRun(fixture.lane)).toMatchObject({ - at: "run.assistant.retry_wait", + at: "assistant.retry_wait", nextAttempt: 2, }); expect(fixture.faux.state.callCount).toBe(0); @@ -608,9 +619,9 @@ describe("runtime assistant generation", () => { ...fauxAssistantMessage([], { timestamp: 6 }), stopReason: "pending", }; - const pending: RunAssistantEffectPendingOperation = { - ...runScopeOf(ready), - at: "run.assistant.effect_pending", + const pending: AssistantEffectPendingOperation = { + ...operationScopeOf(ready), + at: "assistant.effect_pending", generationContext: { ...ready.generationContext, retryPolicy: { maxAttempts: 1, baseDelayMs: 1 } }, attempt: 1, responseEntryId, @@ -620,7 +631,7 @@ describe("runtime assistant generation", () => { }; await fixture.lane.command((state) => { const operation = state.operation; - if (operation === null || !isRunOperationState(operation.state)) throw new Error("missing run"); + if (operation === null) throw new Error("missing operation"); return { kind: "commit", writes: [ @@ -655,8 +666,9 @@ describe("runtime assistant generation", () => { return undefined; }); - expect(await recoverAssistantGeneration(fixture.lane, fixture.drive, pending)).toEqual({ - kind: "continue", + expect(await recoverAssistantGeneration(fixture.lane, fixture.drive, pending)).toMatchObject({ + kind: "settled", + outcome: { operationId, kind: "run", status: "failed", tipId: responseEntryId }, }); expect(fixture.faux.state.callCount).toBe(0); @@ -668,7 +680,7 @@ describe("runtime assistant generation", () => { stopReason: "error", usage: { input: 0, output: 0 }, }); - expect(currentRun(fixture.lane).at).toBe("run.failure_drain"); + expect(fixture.lane.state.operation).toBeNull(); expect( await fixture.session.readList( storedValues.pendingAssistantFrames(operationId, responseEntryId), @@ -682,27 +694,31 @@ describe("runtime assistant generation", () => { await expectProjectionRestores(fixture); }); - it("finishes the no-tool run with terminal cleanup and a hydratable final assistant", async () => { + it("finishes the no-tool run with terminal cleanup and an immutable result record", async () => { const fixture = await createFixture(); const ready = await advanceToReady(fixture); fixture.faux.setResponses([fauxAssistantMessage("done", { timestamp: 7 })]); await runGeneration(fixture.lane, fixture.drive, ready); const run = currentRun(fixture.lane); - if (run.at !== "run.checkpoint") throw new Error("missing terminal checkpoint"); + if (run.at !== "checkpoint") throw new Error("missing terminal checkpoint"); const result = await runCheckpoint(fixture.lane, fixture.drive, run); expect(result).toMatchObject({ kind: "settled", - outcome: { operation: "run", runId: operationId, kind: "completed", finalMessage: { role: "assistant" } }, + outcome: { operationId, kind: "run", status: "completed", tipId: fixture.lane.state.tipId }, }); expect(fixture.lane.state.operation).toBeNull(); - expect(fixture.lane.state.lastResult).toMatchObject({ + expect(fixture.lane.state.lastOperationId).toBe(operationId); + const getEntries = vi.spyOn(fixture.storage, "getEntries"); + expect(await fixture.lane.getResult(operationId, BACKGROUND_CONTEXT)).toMatchObject({ operationId, kind: "run", - outcome: "completed", - runCompletion: "assistant", + status: "completed", + tipId: fixture.lane.state.tipId, }); + expect(await fixture.lane.getResult("unknown", BACKGROUND_CONTEXT)).toBeUndefined(); + expect(getEntries).not.toHaveBeenCalled(); expect( await fixture.session.getValue(storedValues.operationMeta(operationId), BACKGROUND_CONTEXT), ).toBeUndefined(); diff --git a/packages/agent/test/harness/runtime/drive-retry-deferred.test.ts b/packages/agent/test/harness/runtime/drive-retry-deferred.test.ts index 9b0860d43..1bd02107d 100644 --- a/packages/agent/test/harness/runtime/drive-retry-deferred.test.ts +++ b/packages/agent/test/harness/runtime/drive-retry-deferred.test.ts @@ -24,15 +24,13 @@ import { StorageBackedSession } from "../../../src/harness/session/session.ts"; import { GatingStorage } from "../../../src/harness/session/testing/gating-storage.ts"; import { InstrumentedStorage } from "../../../src/harness/session/testing/instrumented-storage.ts"; import { - isRunOperationState, + type AssistantReadyOperation, + type AssistantRetryWaitOperation, + type DeferredEffectPendingOperation, + type DeferredSuspendedOperation, type LaneConfiguration, type OperationState, - type RunAssistantReadyOperation, - type RunAssistantRetryWaitOperation, - type RunDeferredEffectPendingOperation, - type RunDeferredSuspendedOperation, - type RunOperationState, - runScopeOf, + operationScopeOf, type Session, type Write, } from "../../../src/harness/session/types.ts"; @@ -89,7 +87,8 @@ async function createFixture( storedValues.setValue(storedValues.laneConfig("main"), configuration), storedValues.setValue(storedValues.laneState("main"), { currentOperationId: null, - pendingNextRun: [], + lastOperationId: null, + inbox: [], }), ], BACKGROUND_CONTEXT, @@ -139,21 +138,21 @@ async function createFixture( return { backend, gating, storage, session, lane, drive, models, faux, hooks, events, config }; } -function currentRun(fixture: Fixture): RunOperationState { +function currentRun(fixture: Fixture): OperationState { const operation = fixture.lane.state.operation; - if (operation === null || !isRunOperationState(operation.state)) throw new Error("fixture has no run"); + if (operation === null) throw new Error("fixture has no operation"); return operation.state; } -async function advanceToReady(fixture: Fixture): Promise { +async function advanceToReady(fixture: Fixture): Promise { const starting = currentRun(fixture); - if (starting.at !== "run.starting") throw new Error("run did not start at its initial boundary"); + if (starting.at !== "starting") throw new Error("run did not start at its initial boundary"); await startRun(fixture.lane, fixture.drive, starting); let run = currentRun(fixture); - if (run.at !== "run.checkpoint") throw new Error("run did not reach checkpoint"); + if (run.at !== "checkpoint") throw new Error("run did not reach checkpoint"); await runCheckpoint(fixture.lane, fixture.drive, run); run = currentRun(fixture); - if (run.at !== "run.assistant.ready") { + if (run.at !== "assistant.ready") { throw new Error("run did not reach ready generation"); } return run; @@ -162,7 +161,7 @@ async function advanceToReady(fixture: Fixture): Promise { await fixture.lane.command((state) => { const operation = state.operation; - if (operation === null || !isRunOperationState(operation.state)) throw new Error("fixture has no run"); + if (operation === null) throw new Error("fixture has no operation"); return { kind: "commit", writes: [...extraWrites, storedValues.setValue(storedValues.operationState(operationId), nextState)], @@ -175,12 +174,12 @@ async function replaceRunState(fixture: Fixture, nextState: OperationState, extr async function submitDeferred( fixture: Fixture, response: AssistantMessage = fauxAssistantMessage("done", { timestamp: 20 }), -): Promise { +): Promise { fixture.faux.setResponses([response]); const ready = await advanceToReady(fixture); await runGeneration(fixture.lane, fixture.drive, ready); const run = currentRun(fixture); - if (run.at !== "run.deferred.suspended") { + if (run.at !== "deferred.suspended") { throw new Error("initial request did not suspend"); } return run; @@ -188,11 +187,11 @@ async function submitDeferred( async function installUnknownPoll( fixture: Fixture, - suspended: RunDeferredSuspendedOperation, -): Promise { - const effectPending: RunDeferredEffectPendingOperation = { - ...runScopeOf(currentRun(fixture)), - at: "run.deferred.effect_pending", + suspended: DeferredSuspendedOperation, +): Promise { + const effectPending: DeferredEffectPendingOperation = { + ...operationScopeOf(currentRun(fixture)), + at: "deferred.effect_pending", stepId: suspended.stepId, sourceEntryId: suspended.sourceEntryId, poll: suspended.poll + 1, @@ -211,9 +210,9 @@ async function installUnknownPoll( return effectPending; } -function currentDeferred(fixture: Fixture): RunDeferredSuspendedOperation | RunDeferredEffectPendingOperation { +function currentDeferred(fixture: Fixture): DeferredSuspendedOperation | DeferredEffectPendingOperation { const run = currentRun(fixture); - if (run.at !== "run.deferred.suspended" && run.at !== "run.deferred.effect_pending") { + if (run.at !== "deferred.suspended" && run.at !== "deferred.effect_pending") { throw new Error("fixture has no deferred phase"); } return run; @@ -246,7 +245,7 @@ describe("runtime assistant retry wait", () => { kind: "continue", }); expect(currentRun(fixture)).toMatchObject({ - at: "run.assistant.retry_wait", + at: "assistant.retry_wait", nextAttempt: 2, errorMessage: "503 service unavailable", }); @@ -259,9 +258,9 @@ describe("runtime assistant retry wait", () => { vi.setSystemTime(1_000); const fixture = await createFixture({ deferredSubmission: false }); const ready = await advanceToReady(fixture); - const retryWait: RunAssistantRetryWaitOperation = { - ...runScopeOf(ready), - at: "run.assistant.retry_wait", + const retryWait: AssistantRetryWaitOperation = { + ...operationScopeOf(ready), + at: "assistant.retry_wait", generationContext: ready.generationContext, nextAttempt: 2, notBefore: 1_100, @@ -284,9 +283,9 @@ describe("runtime assistant retry wait", () => { vi.setSystemTime(2_000); const fixture = await createFixture({ deferredSubmission: false }); const ready = await advanceToReady(fixture); - const retryWait: RunAssistantRetryWaitOperation = { - ...runScopeOf(ready), - at: "run.assistant.retry_wait", + const retryWait: AssistantRetryWaitOperation = { + ...operationScopeOf(ready), + at: "assistant.retry_wait", generationContext: ready.generationContext, nextAttempt: 2, notBefore: 2_000, @@ -299,7 +298,7 @@ describe("runtime assistant retry wait", () => { kind: "continue", }); const next = currentRun(fixture); - if (next.at !== "run.assistant.ready") { + if (next.at !== "assistant.ready") { throw new Error("retry did not become ready"); } expect(fixture.events.at(-1)).toMatchObject({ type: "retry_start", attempt: 2 }); @@ -314,9 +313,9 @@ describe("runtime assistant retry wait", () => { vi.setSystemTime(3_000); const fixture = await createFixture({ deferredSubmission: false }); const ready = await advanceToReady(fixture); - const retryWait: RunAssistantRetryWaitOperation = { - ...runScopeOf(ready), - at: "run.assistant.retry_wait", + const retryWait: AssistantRetryWaitOperation = { + ...operationScopeOf(ready), + at: "assistant.retry_wait", generationContext: ready.generationContext, nextAttempt: 2, notBefore: 3_100, @@ -332,7 +331,7 @@ describe("runtime assistant retry wait", () => { await vi.advanceTimersByTimeAsync(1); expect(await waiting).toEqual({ kind: "continue" }); expect(currentRun(fixture)).toMatchObject({ - at: "run.assistant.ready", + at: "assistant.ready", nextAttempt: 2, }); }); @@ -385,7 +384,7 @@ describe("runtime deferred polling", () => { expect(hookOptions).toMatchObject({ deferred: false }); expect(fetchOptions).toMatchObject({ wait: 0 }); expect(fetchOptions?.signal).toBe(drive.gate.signal); - expect(currentDeferred(fixture)).toMatchObject({ at: "run.deferred.suspended", poll: 1 }); + expect(currentDeferred(fixture)).toMatchObject({ at: "deferred.suspended", poll: 1 }); expect(fixture.faux.state.deferredFetchCount).toBe(1); expect(fixture.events.slice(-2).map((event) => event.type)).toEqual(["turn_end", "run_suspend"]); @@ -414,8 +413,8 @@ describe("runtime deferred polling", () => { await hookStarted.promise; await fixture.lane.command((state) => { const operation = state.operation; - if (operation === null || !isRunOperationState(operation.state)) throw new Error("missing run"); - const nextState: RunOperationState = { + if (operation === null) throw new Error("missing operation"); + const nextState: OperationState = { ...operation.state, control: { status: "cancel_requested", requestedAt: 10, drainedSteer: [], drainedFollowUp: [] }, }; @@ -431,7 +430,7 @@ describe("runtime deferred polling", () => { expect(await polling).toEqual({ kind: "continue" }); expect(fixture.faux.state.deferredFetchCount).toBe(0); expect(currentRun(fixture)).toMatchObject({ - at: "run.deferred.suspended", + at: "deferred.suspended", control: { status: "cancel_requested" }, }); }); @@ -451,7 +450,7 @@ describe("runtime deferred polling", () => { kind: "continue", }); const run = currentRun(fixture); - if (run.at !== "run.tools") throw new Error("deferred tool response did not create a batch"); + if (run.at !== "tools") throw new Error("deferred tool response did not create a batch"); expect(run.batch).toMatchObject({ turnId: `${suspended.stepId}:poll:1`, calls: [{ status: "planned", sourceIndex: 0 }], @@ -480,8 +479,11 @@ describe("runtime deferred polling", () => { expect(drive.deferredPermits).toBe(0); await expect.poll(() => fixture.faux.state.deferredFetchCount).toBe(1); await gating.next(2); // start frame, then response settlement - expect(await polling).toEqual({ kind: "continue" }); - expect(currentRun(fixture).at).toBe("run.failure_drain"); + expect(await polling).toMatchObject({ + kind: "settled", + outcome: { operationId, kind: "run", status: "failed" }, + }); + expect(fixture.lane.state.operation).toBeNull(); }); it("leaves suspended state unchanged when the fresh intent is discarded", async () => { @@ -500,7 +502,7 @@ describe("runtime deferred polling", () => { expect(drive.deferredPermits).toBe(1); expect(fixture.faux.state.deferredFetchCount).toBe(0); const durable = await fixture.backend.getValue(storedValues.operationState(operationId), BACKGROUND_CONTEXT); - expect(durable?.value).toMatchObject({ at: "run.deferred.suspended", poll: 0 }); + expect(durable?.value).toMatchObject({ at: "deferred.suspended", poll: 0 }); }); it("replaces an unknown poll under fresh ids at the same poll number and deletes old frames", async () => { @@ -547,7 +549,7 @@ describe("runtime deferred polling", () => { ); expect(intentState).toMatchObject({ value: { - at: "run.deferred.effect_pending", + at: "deferred.effect_pending", poll: unknown.poll, responseEntryId: expect.not.stringMatching(unknown.responseEntryId), usageId: expect.not.stringMatching(unknown.usageId), @@ -564,7 +566,7 @@ describe("runtime deferred polling", () => { BACKGROUND_CONTEXT, ), ).toEqual([]); - expect(currentRun(fixture).at).toBe("run.checkpoint"); + expect(currentRun(fixture).at).toBe("checkpoint"); expect(fixture.events.some((event) => "recovery" in event && event.recovery === true)).toBe(true); await expectProjectionRestores(fixture); }); @@ -577,16 +579,13 @@ describe("runtime deferred polling", () => { fixture.storage.clearCommitAttempts(); const drive = installDrive(fixture, { pollDeferred: true }); - expect(await recoverDeferredPoll(fixture.lane, drive, unknown)).toEqual({ - kind: "continue", + expect(await recoverDeferredPoll(fixture.lane, drive, unknown)).toMatchObject({ + kind: "settled", + outcome: { operationId, kind: "run", status: "failed", error: { code: "model_unavailable" } }, }); expect(drive.deferredPermits).toBe(1); expect(fixture.faux.state.deferredFetchCount).toBe(0); - expect(currentRun(fixture)).toMatchObject({ - at: "run.failure_drain", - error: { code: "model_unavailable" }, - provenance: { kind: "configuration" }, - }); + expect(fixture.lane.state.operation).toBeNull(); expect( fixture.storage .getCommitAttempts() @@ -596,7 +595,13 @@ describe("runtime deferred polling", () => { ? `${write.kind}:${write.op}:${write.namespace}` : write.kind, ), - ).toEqual(["list:delete:pi.pending.assistant_frame", "value:set:pi.op.state"]); + ).toEqual([ + "value:delete:pi.op.meta", + "value:delete:pi.op.state", + "list:delete:pi.pending.assistant_frame", + "value:set:pi.result", + "value:set:pi.lane.state", + ]); expect( fixture.storage .getCommitAttempts() diff --git a/packages/agent/test/harness/runtime/drive-structural.test.ts b/packages/agent/test/harness/runtime/drive-structural.test.ts index 507058c8e..7d5857942 100644 --- a/packages/agent/test/harness/runtime/drive-structural.test.ts +++ b/packages/agent/test/harness/runtime/drive-structural.test.ts @@ -22,15 +22,17 @@ import { MemoryStorage } from "../../../src/harness/session/memory.ts"; import { StorageBackedSession } from "../../../src/harness/session/session.ts"; import { InstrumentedStorage } from "../../../src/harness/session/testing/instrumented-storage.ts"; import type { - CompactionEffectPendingOperation, + CheckpointOperation, DurableStructuralPreparation, LaneConfiguration, NewEntry, OperationMeta, + OperationScope, OperationState, - RunCheckpointOperation, - RunScope, Session, + SummaryEffectPendingOperation, + SummaryReadyOperation, + SummaryTask, Write, } from "../../../src/harness/session/types.ts"; import * as storedValues from "../../../src/harness/session/values.ts"; @@ -56,7 +58,7 @@ function unusedWatch(): WatchHandle { throw new Error("watch is not used by structural tests"); } -function runScope(compaction = DEFAULT_COMPACTION_SETTINGS): RunScope { +function runScope(compaction = DEFAULT_COMPACTION_SETTINGS): OperationScope { return { control: { status: "running" }, settings: { @@ -65,7 +67,6 @@ function runScope(compaction = DEFAULT_COMPACTION_SETTINGS): RunScope { followUpMode: "all", toolExecution: "parallel", }, - inbox: { steer: [], followUp: [], writes: [] }, latestAssistantEntryId: null, }; } @@ -74,6 +75,44 @@ function user(content: string, timestamp = 1) { return { role: "user" as const, content, timestamp }; } +function standaloneCompactionTask(customInstructions?: string): SummaryTask { + return { + taskId: "task", + reason: "manual", + ...(customInstructions === undefined ? {} : { customInstructions }), + boundary: { kind: "finish" }, + }; +} + +function runCompactionTask( + reason: "threshold" | "overflow", + resumeAfter: Omit, +): SummaryTask { + return { taskId: "task", reason, boundary: { kind: "resume_checkpoint", resumeAfter } }; +} + +function navigationSummaryTask(targetId: string, label?: string): SummaryTask { + return { + taskId: "task", + boundary: { kind: "commit_navigation", targetId, ...(label === undefined ? {} : { label }) }, + }; +} + +function summaryReady( + scope: OperationScope, + task: SummaryTask, + configuration: LaneConfiguration, + retryPolicy = { maxAttempts: 2, baseDelayMs: 10 }, +): SummaryReadyOperation { + return { + ...scope, + at: "summary.ready", + task, + summaryContext: { resultEntryId: "summary-entry", configuration, streamOptions: {}, retryPolicy }, + nextAttempt: 1, + }; +} + function compactionPreparation( overrides: Partial> = {}, ): Extract { @@ -123,7 +162,8 @@ async function createFixture(): Promise { storedValues.setValue(storedValues.laneConfig("main"), configuration), storedValues.setValue(storedValues.laneState("main"), { currentOperationId: null, - pendingNextRun: [], + lastOperationId: null, + inbox: [], }), ], BACKGROUND_CONTEXT, @@ -196,7 +236,8 @@ async function installOperation( storedValues.setValue(storedValues.operationState(operationId), state), storedValues.setValue(storedValues.laneState("main"), { currentOperationId: operationId, - pendingNextRun: projection.pendingNextRun, + lastOperationId: null, + inbox: projection.inbox, }), ...(options.preparation === undefined ? [] @@ -258,9 +299,9 @@ describe("runtime structural drive", () => { reserveTokens: model.contextWindow, keepRecentTokens: 1, }; - const checkpoint: RunCheckpointOperation = { + const checkpoint: CheckpointOperation = { ...runScope(settings), - at: "run.checkpoint", + at: "checkpoint", continuation: { kind: "need_assistant", overflowRecoveryUsed: false }, triggerEntryId: "assistant", }; @@ -278,11 +319,12 @@ describe("runtime structural drive", () => { expect(await runCompactionThreshold(fixture.lane, fixture.drive, checkpoint)).toEqual({ kind: "continue" }); const deciding = currentState(fixture); - if (deciding.at !== "run.compaction.deciding") throw new Error("threshold did not enter compaction"); - expect(deciding.resumeAfter.thresholdCheckedTriggerEntryId).toBe("assistant"); + if (deciding.at !== "summary.deciding") throw new Error("threshold did not enter compaction"); + if (deciding.task.boundary.kind !== "resume_checkpoint") throw new Error("threshold has wrong boundary"); + expect(deciding.task.boundary.resumeAfter.thresholdCheckedTriggerEntryId).toBe("assistant"); expect( await fixture.session.getValue( - storedValues.operationPreparation(operationId, deciding.taskId), + storedValues.operationPreparation(operationId, deciding.task.taskId), BACKGROUND_CONTEXT, ), ).toBeDefined(); @@ -290,7 +332,7 @@ describe("runtime structural drive", () => { fixture.hooks.on("before_compaction", () => ({ decline: true })); expect(await runStructuralDecision(fixture.lane, fixture.drive, deciding)).toEqual({ kind: "continue" }); const restored = currentState(fixture); - if (restored.at !== "run.checkpoint") throw new Error("threshold decline did not restore checkpoint"); + if (restored.at !== "checkpoint") throw new Error("threshold decline did not restore checkpoint"); expect(restored.thresholdCheckedTriggerEntryId).toBe(restored.triggerEntryId); expect(fixture.events.filter((event) => event.type === "compaction_start")).toHaveLength(1); expect(fixture.events.filter((event) => event.type === "compaction_end")).toHaveLength(1); @@ -302,14 +344,12 @@ describe("runtime structural drive", () => { const fixture = await createFixture(); const deciding = { ...runScope(), - at: "run.compaction.deciding", - reason: "threshold", - resumeAfter: { + at: "summary.deciding", + task: runCompactionTask("threshold", { continuation: { kind: "may_finish", includeFinalAssistant: true }, triggerEntryId: "tip", thresholdCheckedTriggerEntryId: "tip", - }, - taskId: "task", + }), } as const; await installOperation( fixture, @@ -337,14 +377,8 @@ describe("runtime structural drive", () => { await hookStarted.promise; await fixture.lane.command((projection) => { const operation = projection.operation; - if (operation?.state.at !== "run.compaction.deciding") throw new Error("missing run compaction"); - const state = { - ...operation.state, - inbox: { - ...operation.state.inbox, - [queue]: ["queued"], - }, - }; + if (operation?.state.at !== "summary.deciding") throw new Error("missing run compaction"); + const inbox = [...projection.inbox, { entryId: "queued", kind: queue }]; return { kind: "commit", writes: [ @@ -352,9 +386,13 @@ describe("runtime structural drive", () => { type: "message", payload: user(`${queue} during compaction`), }), - storedValues.setValue(storedValues.operationState(operationId), state), + storedValues.setValue(storedValues.laneState("main"), { + currentOperationId: operationId, + lastOperationId: null, + inbox, + }), ], - next: { ...projection, operation: { meta: operation.meta, state } }, + next: { ...projection, inbox }, materialize: () => undefined, }; }, BACKGROUND_CONTEXT); @@ -362,23 +400,54 @@ describe("runtime structural drive", () => { expect(await running).toEqual({ kind: "continue" }); let checkpoint = currentState(fixture); - if (checkpoint.at !== "run.checkpoint") throw new Error("compaction did not restore checkpoint"); - expect(checkpoint.inbox[queue]).toEqual(["queued"]); + if (checkpoint.at !== "checkpoint") throw new Error("compaction did not restore checkpoint"); + expect(fixture.lane.state.inbox).toEqual([{ entryId: "queued", kind: queue }]); expect(await runCheckpoint(fixture.lane, fixture.drive, checkpoint)).toEqual({ kind: "continue" }); checkpoint = currentState(fixture); - if (checkpoint.at !== "run.checkpoint") throw new Error("queued input did not reach checkpoint"); + if (checkpoint.at !== "checkpoint") throw new Error("queued input did not reach checkpoint"); expect(checkpoint.continuation).toEqual({ kind: "need_assistant", overflowRecoveryUsed: false }); - expect(checkpoint.inbox[queue]).toEqual([]); + expect(fixture.lane.state.inbox).toEqual([]); expect(await runCheckpoint(fixture.lane, fixture.drive, checkpoint)).toEqual({ kind: "continue" }); - expect(currentState(fixture).at).toBe("run.assistant.ready"); + expect(currentState(fixture).at).toBe("assistant.ready"); }, ); + it("queues writes during standalone structural work without changing the operation", async () => { + const fixture = await createFixture(); + const deciding = { + ...runScope(), + at: "summary.deciding", + task: standaloneCompactionTask(), + } as const; + await installOperation( + fixture, + deciding, + { kind: "compaction" }, + { + entries: [{ id: "tip", parentId: null, type: "message", message: user("history") }], + preparation: { taskId: "task", value: compactionPreparation() }, + }, + ); + + const entryId = await fixture.lane.appendCustomEntry("note", { pending: true }, BACKGROUND_CONTEXT); + + expect(currentState(fixture)).toEqual(deciding); + expect(fixture.lane.state.tipId).toBe("tip"); + expect(fixture.lane.state.inbox).toEqual([{ entryId, kind: "write" }]); + expect((await fixture.session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT))?.value).toEqual({ + currentOperationId: operationId, + lastOperationId: null, + inbox: [{ entryId, kind: "write" }], + }); + expect(await fixture.session.getValue(storedValues.pendingEntry(entryId), BACKGROUND_CONTEXT)).toBeDefined(); + expect(fixture.events.at(-1)).toMatchObject({ type: "write_pending", entryId }); + }); + it("publishes overflow preparation with the normalized response settlement", async () => { const fixture = await createFixture(); const ready = { ...runScope({ enabled: true, reserveTokens: 1_000, keepRecentTokens: 1 }), - at: "run.assistant.ready", + at: "assistant.ready", generationContext: { stepId: "step", triggerEntryId: "tip", @@ -406,16 +475,17 @@ describe("runtime structural drive", () => { expect(await runGeneration(fixture.lane, fixture.drive, ready)).toEqual({ kind: "continue" }); const deciding = currentState(fixture); - if (deciding.at !== "run.compaction.deciding") throw new Error("overflow did not enter compaction"); - expect(deciding.reason).toBe("overflow"); - expect(deciding.resumeAfter).toMatchObject({ + if (deciding.at !== "summary.deciding") throw new Error("overflow did not enter compaction"); + expect(deciding.task.reason).toBe("overflow"); + if (deciding.task.boundary.kind !== "resume_checkpoint") throw new Error("overflow has wrong boundary"); + expect(deciding.task.boundary.resumeAfter).toMatchObject({ continuation: { kind: "need_assistant", overflowRecoveryUsed: true }, triggerEntryId: "tip", thresholdCheckedTriggerEntryId: "tip", }); expect( await fixture.session.getValue( - storedValues.operationPreparation(operationId, deciding.taskId), + storedValues.operationPreparation(operationId, deciding.task.taskId), BACKGROUND_CONTEXT, ), ).toBeDefined(); @@ -427,10 +497,9 @@ describe("runtime structural drive", () => { it("publishes a hook compaction and terminal cleanup atomically without assistant lifecycle", async () => { const fixture = await createFixture(); const deciding = { - at: "compaction.deciding", - control: { status: "running" }, - taskId: "task", - customInstructions: "focus", + ...runScope(), + at: "summary.deciding", + task: { taskId: "task", customInstructions: "focus", boundary: { kind: "finish" } }, } as const; await installOperation( fixture, @@ -451,7 +520,10 @@ describe("runtime structural drive", () => { })); const result = await runStructuralDecision(fixture.lane, fixture.drive, deciding); - expect(result).toMatchObject({ kind: "settled", outcome: { operation: "compaction", kind: "completed" } }); + expect(result).toMatchObject({ + kind: "settled", + outcome: { operationId, kind: "compaction", status: "completed" }, + }); expect(fixture.lane.state.operation).toBeNull(); const entry = await fixture.session.getEntry(fixture.lane.state.tipId!, BACKGROUND_CONTEXT); expect(entry).toMatchObject({ @@ -471,24 +543,69 @@ describe("runtime structural drive", () => { false, ); expect(fixture.events.map((event) => event.type)).toContain("entry_added"); + expect(fixture.events.at(-1)).toMatchObject({ type: "compaction_end", reason: "manual", status: "completed" }); + }); + + it("terminal-declines standalone compaction without publishing an entry", async () => { + const fixture = await createFixture(); + const deciding = { ...runScope(), at: "summary.deciding", task: standaloneCompactionTask() } as const; + await installOperation( + fixture, + deciding, + { kind: "compaction" }, + { + entries: [{ id: "tip", parentId: null, type: "message", message: user("history") }], + preparation: { taskId: "task", value: compactionPreparation() }, + }, + ); + fixture.hooks.on("before_compaction", () => ({ decline: true })); + + expect(await runStructuralDecision(fixture.lane, fixture.drive, deciding)).toMatchObject({ + kind: "settled", + outcome: { operationId, kind: "compaction", status: "declined", tipId: "tip" }, + }); + expect(fixture.lane.state.operation).toBeNull(); + expect(fixture.lane.state.tipId).toBe("tip"); + expect(fixture.events.at(-1)).toMatchObject({ type: "compaction_end", status: "declined" }); + }); + + it("terminal-fails overflow decline while preserving lane-owned input", async () => { + const fixture = await createFixture(); + const deciding = { + ...runScope(), + at: "summary.deciding", + task: runCompactionTask("overflow", { + continuation: { kind: "need_assistant", overflowRecoveryUsed: true }, + triggerEntryId: "tip", + }), + } as const; + await installOperation( + fixture, + deciding, + { kind: "run", promptEntryIds: ["tip"] }, + { + entries: [{ id: "tip", parentId: null, type: "message", message: user("history") }], + preparation: { taskId: "task", value: compactionPreparation() }, + }, + ); + const queuedId = await fixture.lane.appendCustomEntry("retained", { value: true }, BACKGROUND_CONTEXT); + fixture.hooks.on("before_compaction", () => ({ decline: true })); + + expect(await runStructuralDecision(fixture.lane, fixture.drive, deciding)).toMatchObject({ + kind: "settled", + outcome: { operationId, kind: "run", status: "failed", error: { code: "compaction_declined" } }, + }); + expect(fixture.lane.state.inbox).toEqual([{ entryId: queuedId, kind: "write" }]); + expect(await fixture.session.getValue(storedValues.pendingEntry(queuedId), BACKGROUND_CONTEXT)).toBeDefined(); + expect(fixture.events.slice(-2)).toMatchObject([ + { type: "compaction_end", status: "declined" }, + { type: "run_end", status: "failed" }, + ]); }); it("gives each split-turn provider request its own durable intent and usage row", async () => { const fixture = await createFixture(); - const ready = { - at: "compaction.ready", - control: { status: "running" }, - summaryContext: { - taskId: "task", - resultEntryId: "summary-entry", - kind: "compaction", - configuration: fixture.configuration, - streamOptions: {}, - retryPolicy: { maxAttempts: 2, baseDelayMs: 10 }, - reason: "manual", - }, - nextAttempt: 1, - } as const; + const ready = summaryReady(runScope(), standaloneCompactionTask(), fixture.configuration); await installOperation( fixture, ready, @@ -508,7 +625,10 @@ describe("runtime structural drive", () => { fixture.faux.setResponses([fauxAssistantMessage("history summary"), fauxAssistantMessage("turn prefix summary")]); const result = await runStructuralGeneration(fixture.lane, fixture.drive, ready); - expect(result).toMatchObject({ kind: "settled", outcome: { operation: "compaction", kind: "completed" } }); + expect(result).toMatchObject({ + kind: "settled", + outcome: { operationId, kind: "compaction", status: "completed" }, + }); const attempts = fixture.storage.getCommitAttempts(); const requestIndices = attempts.flatMap((writes) => writes.flatMap((write) => @@ -539,33 +659,22 @@ describe("runtime structural drive", () => { it("restores the captured run checkpoint after generated threshold compaction", async () => { const fixture = await createFixture(); - const resumeAfter: RunCheckpointOperation = { + const resumeAfter: CheckpointOperation = { ...runScope(), - at: "run.checkpoint", + at: "checkpoint", continuation: { kind: "need_assistant", overflowRecoveryUsed: false }, triggerEntryId: "tip", thresholdCheckedTriggerEntryId: "tip", }; - const ready = { - ...runScope(), - at: "run.compaction.ready", - reason: "threshold", - resumeAfter: { + const ready = summaryReady( + runScope(), + runCompactionTask("threshold", { continuation: resumeAfter.continuation, triggerEntryId: resumeAfter.triggerEntryId, thresholdCheckedTriggerEntryId: resumeAfter.thresholdCheckedTriggerEntryId, - }, - summaryContext: { - taskId: "task", - resultEntryId: "summary-entry", - kind: "compaction", - configuration: fixture.configuration, - streamOptions: {}, - retryPolicy: { maxAttempts: 2, baseDelayMs: 10 }, - reason: "threshold", - }, - nextAttempt: 1, - } as const; + }), + fixture.configuration, + ); await installOperation( fixture, ready, @@ -579,7 +688,7 @@ describe("runtime structural drive", () => { expect(await runStructuralGeneration(fixture.lane, fixture.drive, ready)).toEqual({ kind: "continue" }); const restored = currentState(fixture); - if (restored.at !== "run.checkpoint") throw new Error("generated compaction did not restore checkpoint"); + if (restored.at !== "checkpoint") throw new Error("generated compaction did not restore checkpoint"); expect(restored).toMatchObject({ continuation: resumeAfter.continuation, triggerEntryId: "tip", @@ -592,20 +701,7 @@ describe("runtime structural drive", () => { it("settles structural usage without faulting when durable cancellation aborts the request", async () => { const fixture = await createFixture(); - const ready = { - at: "compaction.ready", - control: { status: "running" }, - summaryContext: { - taskId: "task", - resultEntryId: "summary-entry", - kind: "compaction", - configuration: fixture.configuration, - streamOptions: {}, - retryPolicy: { maxAttempts: 2, baseDelayMs: 10 }, - reason: "manual", - }, - nextAttempt: 1, - } as const; + const ready = summaryReady(runScope(), standaloneCompactionTask(), fixture.configuration); await installOperation( fixture, ready, @@ -639,7 +735,7 @@ describe("runtime structural drive", () => { expect(await running).toEqual({ kind: "continue" }); const cancelled = currentState(fixture); - if (cancelled.at !== "compaction.effect_pending") { + if (cancelled.at !== "summary.effect_pending") { throw new Error("cancelled generation did not remain effect-pending for reconciliation"); } expect(cancelled.control.status).toBe("cancel_requested"); @@ -655,29 +751,15 @@ describe("runtime structural drive", () => { it("fails missing in-run structural models with configuration provenance", async () => { const fixture = await createFixture(); - const ready = { - ...runScope(), - at: "run.compaction.ready", - reason: "threshold", - resumeAfter: { + const ready = summaryReady( + runScope(), + runCompactionTask("threshold", { continuation: { kind: "need_assistant", overflowRecoveryUsed: false }, triggerEntryId: "tip", thresholdCheckedTriggerEntryId: "tip", - }, - summaryContext: { - taskId: "task", - resultEntryId: "summary-entry", - kind: "compaction", - configuration: { - ...fixture.configuration, - model: { provider: "missing", modelId: "missing" }, - }, - streamOptions: {}, - retryPolicy: { maxAttempts: 2, baseDelayMs: 10 }, - reason: "threshold", - }, - nextAttempt: 1, - } as const; + }), + { ...fixture.configuration, model: { provider: "missing", modelId: "missing" } }, + ); await installOperation( fixture, ready, @@ -688,13 +770,11 @@ describe("runtime structural drive", () => { }, ); - expect(await runStructuralGeneration(fixture.lane, fixture.drive, ready)).toEqual({ kind: "continue" }); - const failure = currentState(fixture); - if (failure.at !== "run.failure_drain") throw new Error("missing model did not fail the run"); - expect(failure).toMatchObject({ - error: { code: "model_unavailable" }, - provenance: { kind: "configuration" }, + expect(await runStructuralGeneration(fixture.lane, fixture.drive, ready)).toMatchObject({ + kind: "settled", + outcome: { operationId, kind: "run", status: "failed", error: { code: "model_unavailable" } }, }); + expect(fixture.lane.state.operation).toBeNull(); expect( fixture.storage .getCommitAttempts() @@ -703,23 +783,10 @@ describe("runtime structural drive", () => { ).toBe(false); const standalone = await createFixture(); - const standaloneReady = { - at: "compaction.ready", - control: { status: "running" }, - summaryContext: { - taskId: "task", - resultEntryId: "summary-entry", - kind: "compaction", - configuration: { - ...standalone.configuration, - model: { provider: "missing", modelId: "missing" }, - }, - streamOptions: {}, - retryPolicy: { maxAttempts: 2, baseDelayMs: 10 }, - reason: "manual", - }, - nextAttempt: 1, - } as const; + const standaloneReady = summaryReady(runScope(), standaloneCompactionTask(), { + ...standalone.configuration, + model: { provider: "missing", modelId: "missing" }, + }); await installOperation( standalone, standaloneReady, @@ -731,27 +798,17 @@ describe("runtime structural drive", () => { ); expect(await runStructuralGeneration(standalone.lane, standalone.drive, standaloneReady)).toMatchObject({ kind: "settled", - outcome: { operation: "compaction", kind: "failed", error: { code: "model_unavailable" } }, + outcome: { operationId, kind: "compaction", status: "failed", error: { code: "model_unavailable" } }, }); expect(standalone.lane.state.operation).toBeNull(); }); it("durably schedules retryable structural failures and exposes the retry wait", async () => { const fixture = await createFixture(); - const ready = { - at: "compaction.ready", - control: { status: "running" }, - summaryContext: { - taskId: "task", - resultEntryId: "summary-entry", - kind: "compaction", - configuration: fixture.configuration, - streamOptions: {}, - retryPolicy: { maxAttempts: 2, baseDelayMs: 10_000 }, - reason: "manual", - }, - nextAttempt: 1, - } as const; + const ready = summaryReady(runScope(), standaloneCompactionTask(), fixture.configuration, { + maxAttempts: 2, + baseDelayMs: 10_000, + }); await installOperation( fixture, ready, @@ -767,7 +824,7 @@ describe("runtime structural drive", () => { expect(await runStructuralGeneration(fixture.lane, fixture.drive, ready)).toEqual({ kind: "continue" }); const retry = currentState(fixture); - if (retry.at !== "compaction.retry_wait") throw new Error("retryable failure did not wait"); + if (retry.at !== "summary.retry_wait") throw new Error("retryable failure did not wait"); expect(retry.nextAttempt).toBe(2); expect(await runStructuralRetryWait(fixture.lane, fixture.drive, retry)).toEqual({ kind: "waiting", @@ -783,20 +840,10 @@ describe("runtime structural drive", () => { it("finishes a standalone structural failure at the retry cap", async () => { const fixture = await createFixture(); - const ready = { - at: "compaction.ready", - control: { status: "running" }, - summaryContext: { - taskId: "task", - resultEntryId: "summary-entry", - kind: "compaction", - configuration: fixture.configuration, - streamOptions: {}, - retryPolicy: { maxAttempts: 1, baseDelayMs: 10 }, - reason: "manual", - }, - nextAttempt: 1, - } as const; + const ready = summaryReady(runScope(), standaloneCompactionTask(), fixture.configuration, { + maxAttempts: 1, + baseDelayMs: 10, + }); await installOperation( fixture, ready, @@ -813,7 +860,7 @@ describe("runtime structural drive", () => { const result = await runStructuralGeneration(fixture.lane, fixture.drive, ready); expect(result).toMatchObject({ kind: "settled", - outcome: { operation: "compaction", kind: "failed", error: { code: "summarization_failed" } }, + outcome: { operationId, kind: "compaction", status: "failed", error: { code: "summarization_failed" } }, }); expect(fixture.lane.state.operation).toBeNull(); expect(fixture.events.map((event) => event.type)).not.toContain("retry_scheduled"); @@ -822,8 +869,8 @@ describe("runtime structural drive", () => { it("moves an unsummarized navigation and cleans up in one terminal transaction", async () => { const fixture = await createFixture(); const navigation = { + ...runScope(), at: "navigation.ready_to_commit", - control: { status: "running" }, targetId: "target", label: "chosen", } as const; @@ -841,14 +888,14 @@ describe("runtime structural drive", () => { }, ); const result = await commitNavigation(fixture.lane, fixture.drive, navigation); - expect(result).toEqual({ + expect(result).toMatchObject({ kind: "settled", outcome: { - operation: "navigation", - runId: operationId, - kind: "completed", - oldTipId: "source", - newTipId: "target", + operationId, + kind: "navigation", + status: "completed", + fromTipId: "source", + tipId: "target", }, }); expect(fixture.lane.state.tipId).toBe("target"); @@ -857,17 +904,16 @@ describe("runtime structural drive", () => { expect( writes.some((write) => write.kind === "value" && write.namespace === "pi.op.state" && write.op === "delete"), ).toBe(true); - expect(writes.some((write) => write.kind === "value" && write.namespace === "pi.lane.lastResult")).toBe(true); + expect(writes.some((write) => write.kind === "value" && write.namespace === "pi.result")).toBe(true); expect(writes.some((write) => write.kind === "value" && write.namespace === "pi.lane.state")).toBe(true); }); it("publishes a hook navigation summary with the target parent and source identity", async () => { const fixture = await createFixture(); const deciding = { - at: "navigation.summary.deciding", - control: { status: "running" }, - targetId: "target", - taskId: "task", + ...runScope(), + at: "summary.deciding", + task: navigationSummaryTask("target"), } as const; await installOperation( fixture, @@ -888,7 +934,10 @@ describe("runtime structural drive", () => { })); const result = await runStructuralDecision(fixture.lane, fixture.drive, deciding); - expect(result).toMatchObject({ kind: "settled", outcome: { operation: "navigation", kind: "completed" } }); + expect(result).toMatchObject({ + kind: "settled", + outcome: { operationId, kind: "navigation", status: "completed" }, + }); const entry = await fixture.session.getEntry(fixture.lane.state.tipId!, BACKGROUND_CONTEXT); expect(entry).toMatchObject({ type: "branch_summary", @@ -899,22 +948,36 @@ describe("runtime structural drive", () => { }); }); + it("terminal-declines summarized navigation without moving the tip", async () => { + const fixture = await createFixture(); + const deciding = { ...runScope(), at: "summary.deciding", task: navigationSummaryTask("target") } as const; + await installOperation( + fixture, + deciding, + { kind: "navigation", targetId: "target", summarize: true }, + { + entries: [ + { id: "root", parentId: null, type: "message", message: user("root") }, + { id: "source", parentId: "root", type: "message", message: user("source") }, + { id: "target", parentId: "root", type: "message", message: user("target") }, + ], + tipId: "source", + preparation: { taskId: "task", value: branchPreparation() }, + }, + ); + fixture.hooks.on("before_navigation", () => ({ decline: true })); + + expect(await runStructuralDecision(fixture.lane, fixture.drive, deciding)).toMatchObject({ + kind: "settled", + outcome: { operationId, kind: "navigation", status: "declined", tipId: "source" }, + }); + expect(fixture.lane.state.tipId).toBe("source"); + expect(fixture.events.at(-1)).toMatchObject({ type: "navigation_end", status: "declined" }); + }); + it("generates and atomically publishes a navigation summary", async () => { const fixture = await createFixture(); - const ready = { - at: "navigation.summary.ready", - control: { status: "running" }, - targetId: "target", - summaryContext: { - taskId: "task", - resultEntryId: "summary-entry", - kind: "branch_summary", - configuration: fixture.configuration, - streamOptions: {}, - retryPolicy: { maxAttempts: 2, baseDelayMs: 10 }, - }, - nextAttempt: 1, - } as const; + const ready = summaryReady(runScope(), navigationSummaryTask("target"), fixture.configuration); await installOperation( fixture, ready, @@ -935,10 +998,11 @@ describe("runtime structural drive", () => { expect(result).toMatchObject({ kind: "settled", outcome: { - operation: "navigation", - kind: "completed", - oldTipId: "source", - newTipId: "summary-entry", + operationId, + kind: "navigation", + status: "completed", + fromTipId: "source", + tipId: "summary-entry", }, }); const entry = await fixture.session.getEntry("summary-entry", BACKGROUND_CONTEXT); @@ -954,21 +1018,19 @@ describe("runtime structural drive", () => { it("consumes an orphaned structural attempt and never resumes its nested request", async () => { const fixture = await createFixture(); const effect = { - at: "compaction.effect_pending", - control: { status: "running" }, + ...runScope(), + at: "summary.effect_pending", + task: standaloneCompactionTask(), summaryContext: { - taskId: "task", resultEntryId: "summary-entry", - kind: "compaction", configuration: fixture.configuration, streamOptions: {}, retryPolicy: { maxAttempts: 2, baseDelayMs: 10 }, - reason: "manual", }, attempt: 1, request: { index: 1, usageId: "abandoned-usage" }, usageIds: ["settled-usage"], - } satisfies CompactionEffectPendingOperation; + } satisfies SummaryEffectPendingOperation; await installOperation( fixture, effect, @@ -981,7 +1043,7 @@ describe("runtime structural drive", () => { expect(await recoverStructuralGeneration(fixture.lane, fixture.drive, effect)).toEqual({ kind: "continue" }); const retry = currentState(fixture); - if (retry.at !== "compaction.retry_wait") throw new Error("orphan did not enter retry wait"); + if (retry.at !== "summary.retry_wait") throw new Error("orphan did not enter retry wait"); expect(retry.nextAttempt).toBe(2); expect("request" in retry).toBe(false); expect( @@ -995,9 +1057,9 @@ describe("runtime structural drive", () => { it("rejects a preparation whose durable kind contradicts the structural state", async () => { const fixture = await createFixture(); const deciding = { - at: "compaction.deciding", - control: { status: "running" }, - taskId: "task", + ...runScope(), + at: "summary.deciding", + task: standaloneCompactionTask(), } as const; await installOperation( fixture, diff --git a/packages/agent/test/harness/runtime/drive-terminal.test.ts b/packages/agent/test/harness/runtime/drive-terminal.test.ts index 5445048b0..50aa594d4 100644 --- a/packages/agent/test/harness/runtime/drive-terminal.test.ts +++ b/packages/agent/test/harness/runtime/drive-terminal.test.ts @@ -1,21 +1,18 @@ -import { fauxAssistantMessage } from "@earendil-works/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; import { BACKGROUND_CONTEXT } from "../../../src/harness/context.ts"; -import { hydrateTerminalOutcome, operationCleanupWrites } from "../../../src/harness/runtime/drive/terminal.ts"; -import { insertEntry } from "../../../src/harness/session/commit.ts"; +import { operationCleanupWrites, operationResultRecord } from "../../../src/harness/runtime/drive/terminal.ts"; import { MemoryStorage } from "../../../src/harness/session/memory.ts"; import { StorageBackedSession } from "../../../src/harness/session/session.ts"; import type { - CompactionDecidingOperation, + AssistantEffectPendingOperation, LaneConfiguration, - LaneLastResult, NavigationReadyToCommitOperation, OperationMeta, + OperationScope, OperationState, - RunAssistantEffectPendingOperation, - RunScope, - RunToolsOperation, Session, + SummaryDecidingOperation, + ToolsOperation, Write, } from "../../../src/harness/session/types.ts"; import * as storedValues from "../../../src/harness/session/values.ts"; @@ -27,7 +24,7 @@ const configuration: LaneConfiguration = { activeToolNames: [], }; -function runScope(): RunScope { +function runScope(): OperationScope { return { control: { status: "running" }, settings: { @@ -36,14 +33,13 @@ function runScope(): RunScope { followUpMode: "all", toolExecution: "parallel", }, - inbox: { steer: [], followUp: [], writes: [] }, latestAssistantEntryId: null, }; } function meta(operationId: string, state: OperationState): OperationMeta { const intent: OperationMeta["intent"] = - state.at === "compaction.deciding" + state.at === "summary.deciding" ? { kind: "compaction" } : state.at === "navigation.ready_to_commit" ? { kind: "navigation", targetId: state.targetId, summarize: false } @@ -93,17 +89,18 @@ async function seedLeftovers(session: Session, operationId: string, state: Opera } afterEach(async () => { + vi.restoreAllMocks(); for (const session of sessions.splice(0)) await session.close(BACKGROUND_CONTEXT); }); describe("runtime terminal cleanup mechanics", () => { - it("deletes every run-owned family and exact live frame list but preserves pendingNextRun", async () => { + it("deletes every operation-owned family and exact live frame list but preserves the lane inbox", async () => { const { session } = await createSession(); const operationId = "run"; const responseEntryId = "response"; - const state: RunAssistantEffectPendingOperation = { + const state: AssistantEffectPendingOperation = { ...runScope(), - at: "run.assistant.effect_pending", + at: "assistant.effect_pending", generationContext: { stepId: "step", triggerEntryId: "trigger", @@ -123,7 +120,6 @@ describe("runtime terminal cleanup mechanics", () => { drainedSteer: ["drained-steer"], drainedFollowUp: ["drained-follow"], }, - inbox: { steer: ["steer"], followUp: ["follow"], writes: ["write"] }, }; await seedLeftovers(session, operationId, state); await commit(session, [ @@ -141,7 +137,13 @@ describe("runtime terminal cleanup mechanics", () => { }), storedValues.setValue(storedValues.laneState("main"), { currentOperationId: operationId, - pendingNextRun: ["next"], + lastOperationId: null, + inbox: [ + { entryId: "steer", kind: "steer" }, + { entryId: "follow", kind: "followUp" }, + { entryId: "write", kind: "write" }, + { entryId: "next", kind: "nextRun" }, + ], }), ]); @@ -155,17 +157,19 @@ describe("runtime terminal cleanup mechanics", () => { "value:delete:pi.op.preparation:run:task", "value:delete:pi.pending.tool_output:run:invocation", "list:delete:pi.pending.assistant_frame:run:response", - "value:delete:pi.pending.entry:steer", - "value:delete:pi.pending.entry:follow", - "value:delete:pi.pending.entry:write", "value:delete:pi.pending.entry:drained-steer", "value:delete:pi.pending.entry:drained-follow", ]); await commit(session, writes); - expect(await session.getValue(storedValues.pendingEntry("next"), BACKGROUND_CONTEXT)).toBeDefined(); - expect( - (await session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT))?.value.pendingNextRun, - ).toEqual(["next"]); + for (const id of ["steer", "follow", "write", "next"]) { + expect(await session.getValue(storedValues.pendingEntry(id), BACKGROUND_CONTEXT)).toBeDefined(); + } + expect((await session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT))?.value.inbox).toEqual([ + { entryId: "steer", kind: "steer" }, + { entryId: "follow", kind: "followUp" }, + { entryId: "write", kind: "write" }, + { entryId: "next", kind: "nextRun" }, + ]); expect( await session.readList( storedValues.pendingAssistantFrames(operationId, responseEntryId), @@ -178,9 +182,9 @@ describe("runtime terminal cleanup mechanics", () => { it("deletes staged tool outcomes and leaves completed results alone", async () => { const { session } = await createSession(); const operationId = "tools"; - const state: RunToolsOperation = { + const state: ToolsOperation = { ...runScope(), - at: "run.tools", + at: "tools", batch: { assistantEntryId: "assistant", configuration, @@ -216,16 +220,16 @@ describe("runtime terminal cleanup mechanics", () => { [ "compaction", { - at: "compaction.deciding", - control: { status: "running" }, - taskId: "task", - } satisfies CompactionDecidingOperation, + ...runScope(), + at: "summary.deciding", + task: { taskId: "task", reason: "manual", boundary: { kind: "finish" } }, + } satisfies SummaryDecidingOperation, ], [ "navigation", { + ...runScope(), at: "navigation.ready_to_commit", - control: { status: "running" }, targetId: null, } satisfies NavigationReadyToCommitOperation, ], @@ -246,113 +250,47 @@ describe("runtime terminal cleanup mechanics", () => { }); }); -describe("runtime terminal outcome hydration", () => { - it("hydrates run, compaction, and navigation outcomes from only their direct entry references", async () => { - const { session, storage } = await createSession(); - const assistant = fauxAssistantMessage([{ type: "text", text: "done" }]); - await commit(session, [ - insertEntry({ id: "assistant", parentId: null, type: "message", message: assistant }), - insertEntry({ - id: "compaction", - parentId: null, - type: "compaction", - summary: "summary", - retainedTail: [], - tokensBefore: 10, - fromHook: false, - }), - insertEntry({ - id: "summary", - parentId: null, - type: "branch_summary", - fromId: "old", - summary: "branch", - fromHook: false, - }), - ]); - const getEntries = vi.spyOn(storage, "getEntries"); - const results: LaneLastResult[] = [ +describe("runtime operation result records", () => { + it("constructs one flat immutable observation from terminal metadata", () => { + vi.spyOn(Date, "now").mockReturnValue(20); + const record = operationResultRecord( { operationId: "run", - kind: "run", - outcome: "completed", - tipId: "assistant", - finalAssistantEntryId: "assistant", - runCompletion: "assistant", + lane: "main", + sourceTipId: "source", + startedAt: 10, + intent: { kind: "run", promptEntryIds: ["prompt"] }, }, - { operationId: "compact", kind: "compaction", outcome: "completed", tipId: "compaction" }, - { - operationId: "navigate", - kind: "navigation", - outcome: "completed", - oldTipId: "old", - tipId: "summary", - summaryEntryId: "summary", - }, - ]; - - const outcomes = await Promise.all( - results.map((result) => hydrateTerminalOutcome(session, result, BACKGROUND_CONTEXT)), + "failed", + "tip", + { code: "provider", message: "failed" }, ); - expect(outcomes).toEqual([ - { - operation: "run", - runId: "run", - kind: "completed", - tipId: "assistant", - finalEntryId: "assistant", - finalMessage: assistant, - }, - { - operation: "compaction", - runId: "compact", - kind: "completed", - tipId: "compaction", - entry: expect.objectContaining({ id: "compaction", type: "compaction", summary: "summary" }), - }, - { - operation: "navigation", - runId: "navigate", - kind: "completed", - oldTipId: "old", - newTipId: "summary", - summaryEntry: expect.objectContaining({ id: "summary", type: "branch_summary", summary: "branch" }), - }, - ]); - expect(getEntries.mock.calls.map(([ids]) => ids)).toEqual([["assistant"], ["compaction"], ["summary"]]); + expect(record).toEqual({ + operationId: "run", + kind: "run", + status: "failed", + error: { code: "provider", message: "failed" }, + fromTipId: "source", + tipId: "tip", + startedAt: 10, + endedAt: 20, + }); }); - it("hydrates entry-free outcomes without storage reads", async () => { - const { session, storage } = await createSession(); - const getEntries = vi.spyOn(storage, "getEntries"); - const results: LaneLastResult[] = [ - { - operationId: "run", - kind: "run", - outcome: "completed", - tipId: "tool-result", - runCompletion: "terminated_tools", - }, - { operationId: "compact", kind: "compaction", outcome: "declined", tipId: "leaf" }, - { - operationId: "navigate", - kind: "navigation", - outcome: "aborted", - oldTipId: "old", - tipId: "old", - }, - ]; - - const outcomes = await Promise.all( - results.map((result) => hydrateTerminalOutcome(session, result, BACKGROUND_CONTEXT)), + it("rejects errors on non-failed records and missing errors on failed records", () => { + const metadata: OperationMeta = { + operationId: "run", + lane: "main", + sourceTipId: null, + startedAt: 1, + intent: { kind: "run", promptEntryIds: [] }, + }; + expect(() => operationResultRecord(metadata, "completed", "tip", { code: "x", message: "x" })).toThrow( + "Only a failed operation result may carry an error", + ); + expect(() => operationResultRecord(metadata, "failed", "tip")).toThrow( + "Only a failed operation result may carry an error", ); - - expect(outcomes).toEqual([ - { operation: "run", runId: "run", kind: "completed", tipId: "tool-result" }, - { operation: "compaction", runId: "compact", kind: "declined", tipId: "leaf" }, - { operation: "navigation", runId: "navigate", kind: "aborted", tipId: "old" }, - ]); - expect(getEntries).not.toHaveBeenCalled(); }); }); diff --git a/packages/agent/test/harness/runtime/drive-tools.test.ts b/packages/agent/test/harness/runtime/drive-tools.test.ts index af97cbc6d..156888756 100644 --- a/packages/agent/test/harness/runtime/drive-tools.test.ts +++ b/packages/agent/test/harness/runtime/drive-tools.test.ts @@ -18,14 +18,13 @@ import { type Config, Drive } from "../../../src/harness/runtime/types.ts"; import { insertEntry } from "../../../src/harness/session/commit.ts"; import { MemoryStorage } from "../../../src/harness/session/memory.ts"; import { StorageBackedSession } from "../../../src/harness/session/session.ts"; -import { - isRunOperationState, - type LaneConfiguration, - type RunOperationState, - type RunToolsOperation, - type Session, - type ToolCall, - type Write, +import type { + LaneConfiguration, + OperationState, + Session, + ToolCall, + ToolsOperation, + Write, } from "../../../src/harness/session/types.ts"; import * as storedValues from "../../../src/harness/session/values.ts"; import type { @@ -59,7 +58,7 @@ interface FixtureOptions { operationId: string; assistantEntryId: string; resultEntryIds: string[]; - run: RunToolsOperation; + run: ToolsOperation; }) => Write[]; toolContext?: Config["toolContext"]; cancelled?: boolean; @@ -141,8 +140,8 @@ async function createFixture(options: FixtureOptions): Promise { sourceIndex: index, resultEntryId, })); - const run: RunToolsOperation = { - at: "run.tools", + const run: ToolsOperation = { + at: "tools", control: options.cancelled ? { status: "cancel_requested", requestedAt: 30, drainedSteer: [], drainedFollowUp: [] } : { status: "running" }, @@ -153,7 +152,6 @@ async function createFixture(options: FixtureOptions): Promise { toolExecution: options.mode ?? "parallel", }, batch: { assistantEntryId, configuration, turnId: "turn-1", calls }, - inbox: { steer: [], followUp: [], writes: [] }, latestAssistantEntryId: assistantEntryId, }; const writes: Write[] = [ @@ -162,7 +160,8 @@ async function createFixture(options: FixtureOptions): Promise { storedValues.setValue(storedValues.laneConfig("main"), configuration), storedValues.setValue(storedValues.laneState("main"), { currentOperationId: operationId, - pendingNextRun: [], + lastOperationId: null, + inbox: [], }), storedValues.setValue(storedValues.operationMeta(operationId), { operationId, @@ -216,21 +215,21 @@ async function createFixture(options: FixtureOptions): Promise { return { session, lane, drive, hooks, events, assistantEntryId, resultEntryIds, operationId, config }; } -function currentRun(fixture: Fixture): RunOperationState { +function currentRun(fixture: Fixture): OperationState { const operation = fixture.lane.state.operation; - if (operation === null || !isRunOperationState(operation.state)) throw new Error("fixture has no run"); + if (operation === null) throw new Error("fixture has no operation"); return operation.state; } function currentCalls(fixture: Fixture): ToolCall[] { const run = currentRun(fixture); - if (run.at !== "run.tools") return []; + if (run.at !== "tools") return []; return run.batch.calls; } async function driveTools(fixture: Fixture) { const run = currentRun(fixture); - if (run.at !== "run.tools") throw new Error("fixture has no tool batch"); + if (run.at !== "tools") throw new Error("fixture has no tool batch"); return runTools(fixture.lane, fixture.drive, run); } @@ -303,7 +302,7 @@ describe("durable tool batch", () => { expect(contextResolutions).toBe(1); expect(checkpointSeenByAfterHook).toBe(true); expect(currentRun(fixture)).toMatchObject({ - at: "run.checkpoint", + at: "checkpoint", continuation: { kind: "need_assistant" }, }); expect(fixture.lane.state.configuration.activeToolNames).toEqual(["first", "second", "introduced"]); @@ -449,7 +448,7 @@ describe("durable tool batch", () => { expect(beforeTool).not.toHaveBeenCalled(); expect(afterTool).not.toHaveBeenCalled(); expect(currentRun(fixture)).toMatchObject({ - at: "run.checkpoint", + at: "checkpoint", control: { status: "cancel_requested" }, continuation: { kind: "need_assistant" }, }); @@ -499,7 +498,7 @@ describe("durable tool batch", () => { expect(execute).not.toHaveBeenCalled(); expect(context).not.toHaveBeenCalled(); expect(currentRun(fixture)).toMatchObject({ - at: "run.checkpoint", + at: "checkpoint", continuation: { kind: "may_finish", includeFinalAssistant: false }, }); expect(fixture.lane.state.configuration.activeToolNames).toEqual(["ready", "later"]); @@ -598,8 +597,8 @@ describe("durable tool batch", () => { ).toBeUndefined(); await fixture.lane.command((state) => { const operation = state.operation; - if (operation === null || !isRunOperationState(operation.state)) throw new Error("missing run"); - const nextRun: RunOperationState = { + if (operation === null) throw new Error("missing operation"); + const nextRun: OperationState = { ...operation.state, control: { status: "cancel_requested", requestedAt: 40, drainedSteer: [], drainedFollowUp: [] }, }; @@ -652,8 +651,8 @@ describe("durable tool batch", () => { fixture.drive.beginAbort(cancellation.promise); await fixture.lane.command((state) => { const operation = state.operation; - if (operation === null || !isRunOperationState(operation.state)) throw new Error("missing run"); - const nextRun: RunOperationState = { + if (operation === null) throw new Error("missing operation"); + const nextRun: OperationState = { ...operation.state, control: { status: "cancel_requested", requestedAt: 40, drainedSteer: [], drainedFollowUp: [] }, }; diff --git a/packages/agent/test/harness/runtime/harness.test.ts b/packages/agent/test/harness/runtime/harness.test.ts index 5afffcb0c..c612fa370 100644 --- a/packages/agent/test/harness/runtime/harness.test.ts +++ b/packages/agent/test/harness/runtime/harness.test.ts @@ -78,7 +78,8 @@ describe("runtime Harness lane management", () => { expect(await lane.getActiveTools(BACKGROUND_CONTEXT)).toEqual(["read", "bash"]); expect((await session.getValue(laneState("main"), BACKGROUND_CONTEXT))?.value).toEqual({ currentOperationId: null, - pendingNextRun: [], + lastOperationId: null, + inbox: [], }); expect(events).toEqual(["main"]); expect(await harness.lanes(BACKGROUND_CONTEXT)).toMatchObject([{ name: "main", tipId: null }]); @@ -212,6 +213,46 @@ describe("runtime Harness lane management", () => { customType: "pending", payload: { queued: true }, }); + expect((await session.getValue(laneState("main"), BACKGROUND_CONTEXT))?.value.inbox).toEqual([ + { entryId: pendingId, kind: "write" }, + ]); + }); + + it("flushes queued writes before a new idle append in one commit", async () => { + const session = await createSession(); + const harness = await createHarness(session); + const lane = await harness.lane("main", BACKGROUND_CONTEXT); + if (!(lane instanceof Lane)) throw new Error("Expected runtime Lane"); + const first = session.idGenerator.next(); + const second = session.idGenerator.next(); + await lane.command((state) => { + const inbox = [ + { entryId: first, kind: "write" as const }, + { entryId: second, kind: "write" as const }, + ]; + return { + kind: "commit", + writes: [ + setValue(pendingEntry(first), { type: "custom", customType: "queued-first" }), + setValue(pendingEntry(second), { type: "custom", customType: "queued-second" }), + setValue(laneState("main"), { currentOperationId: null, lastOperationId: null, inbox }), + ], + next: { ...state, inbox }, + materialize: () => undefined, + }; + }, BACKGROUND_CONTEXT); + + const appended = await lane.appendCustomEntry("new", undefined, BACKGROUND_CONTEXT); + + expect((await lane.findEntries({ order: "oldestFirst" }, BACKGROUND_CONTEXT)).map((entry) => entry.id)).toEqual([ + first, + second, + appended, + ]); + expect(lane.state.inbox).toEqual([]); + expect((await session.getValue(laneState("main"), BACKGROUND_CONTEXT))?.value.inbox).toEqual([]); + expect(await session.getValue(pendingEntry(first), BACKGROUND_CONTEXT)).toBeUndefined(); + expect(await session.getValue(pendingEntry(second), BACKGROUND_CONTEXT)).toBeUndefined(); }); it("restores complete lanes without requiring main", async () => { @@ -224,7 +265,8 @@ describe("runtime Harness lane management", () => { setValue(laneConfig("review"), configured), setValue(laneState("review"), { currentOperationId: null, - pendingNextRun: [], + lastOperationId: null, + inbox: [], }), ], BACKGROUND_CONTEXT, diff --git a/packages/agent/test/harness/runtime/lane.test.ts b/packages/agent/test/harness/runtime/lane.test.ts index 1d3e6e31e..b9c0c8b67 100644 --- a/packages/agent/test/harness/runtime/lane.test.ts +++ b/packages/agent/test/harness/runtime/lane.test.ts @@ -9,12 +9,7 @@ import { convertToLlm } from "../../../src/harness/messages.ts"; import { Lane } from "../../../src/harness/runtime/lane.ts"; import { restoreLane } from "../../../src/harness/runtime/restore.ts"; import { StorageBackedSession } from "../../../src/harness/session/session.ts"; -import type { - LaneConfiguration, - OperationState, - RunStartingOperation, - Session, -} from "../../../src/harness/session/types.ts"; +import type { LaneConfiguration, Session, StartingOperation } from "../../../src/harness/session/types.ts"; import * as storedValues from "../../../src/harness/session/values.ts"; import { ControlledMemoryStorage, deferred } from "./test-utils.ts"; @@ -45,7 +40,11 @@ async function createLane( [ storedValues.setValue(storedValues.branchTip("main"), null), storedValues.setValue(storedValues.laneConfig("main"), configuration), - storedValues.setValue(storedValues.laneState("main"), { currentOperationId: null, pendingNextRun: [] }), + storedValues.setValue(storedValues.laneState("main"), { + currentOperationId: null, + lastOperationId: null, + inbox: [], + }), ], BACKGROUND_CONTEXT, ), @@ -313,8 +312,8 @@ describe("runtime Lane commands", () => { const accepted = await lane.accept({ kind: "prompt", prompt: "hello" }, BACKGROUND_CONTEXT); expect(accepted.ok).toBe(true); const operation = lane.state.operation; - if (operation?.state.at !== "run.starting") throw new Error("missing accepted operation"); - const cancelled: RunStartingOperation = { + if (operation?.state.at !== "starting") throw new Error("missing accepted operation"); + const cancelled: StartingOperation = { ...operation.state, control: { status: "cancel_requested", @@ -348,26 +347,24 @@ describe("runtime Lane commands", () => { const settled = await lane.settleOperation( cancelled, - (_state, current) => { + (state, current) => { expect(current.control.status).toBe("cancel_requested"); - const operationState: OperationState = { - ...current, - inbox: { ...current.inbox, writes: ["accepted-during-cancellation"] }, - }; + const inbox = [...state.inbox, { entryId: "accepted-during-cancellation", kind: "write" as const }]; return { kind: "commit", writes: [], - operationState, - materialize: () => operationState, + operationState: current, + lane: { inbox }, + materialize: () => inbox, }; }, BACKGROUND_CONTEXT, ); - expect(settled.inbox.writes).toEqual(["accepted-during-cancellation"]); - expect(lane.state.operation?.state).toEqual(settled); - expect( - (await session.getValue(storedValues.operationState(operation.meta.operationId), BACKGROUND_CONTEXT))?.value, - ).toEqual(settled); + expect(settled).toEqual([{ entryId: "accepted-during-cancellation", kind: "write" }]); + expect(lane.state.inbox).toEqual(settled); + expect((await session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT))?.value.inbox).toEqual( + settled, + ); }); it("plans queued commands from the latest committed memory", async () => { diff --git a/packages/agent/test/harness/runtime/progress.test.ts b/packages/agent/test/harness/runtime/progress.test.ts index d29cbfc4a..7a1ee50bc 100644 --- a/packages/agent/test/harness/runtime/progress.test.ts +++ b/packages/agent/test/harness/runtime/progress.test.ts @@ -10,11 +10,10 @@ import { type Config, Drive, type LaneState as RuntimeLaneState } from "../../.. import { MemoryStorage } from "../../../src/harness/session/memory.ts"; import { StorageBackedSession } from "../../../src/harness/session/session.ts"; import { - isRunOperationState, + type AssistantEffectPendingOperation, + type CheckpointOperation, type OperationState, - type RunAssistantEffectPendingOperation, - type RunCheckpointOperation, - runScopeOf, + operationScopeOf, type Session, type Write, } from "../../../src/harness/session/types.ts"; @@ -77,10 +76,10 @@ function runScope() { }; } -function assistantEffectPending(responseEntryId: string): RunAssistantEffectPendingOperation { +function assistantEffectPending(responseEntryId: string): AssistantEffectPendingOperation { return { ...runScope(), - at: "run.assistant.effect_pending", + at: "assistant.effect_pending", generationContext: { stepId: "step", triggerEntryId: "trigger", @@ -116,7 +115,8 @@ async function createFixture( const projection: RuntimeLaneState = { tipId: null, configuration, - pendingNextRun: [], + inbox: [], + lastOperationId: null, operation: { meta, state }, }; const lane = new Lane( @@ -138,7 +138,8 @@ async function createFixture( storedValues.setValue(storedValues.laneConfig("main"), configuration), storedValues.setValue(storedValues.laneState("main"), { currentOperationId: operationId, - pendingNextRun: [], + lastOperationId: null, + inbox: [], }), storedValues.setValue(storedValues.operationMeta(operationId), meta), storedValues.setValue(storedValues.operationState(operationId), state), @@ -230,10 +231,10 @@ describe("runtime progress channels", () => { const getValue = vi.spyOn(storage, "getValue"); const moving = lane.command((state) => { const operation = state.operation; - if (operation === null || !isRunOperationState(operation.state)) throw new Error("missing run"); - const nextState: RunCheckpointOperation = { - ...runScopeOf(operation.state), - at: "run.checkpoint", + if (operation === null) throw new Error("missing operation"); + const nextState: CheckpointOperation = { + ...operationScopeOf(operation.state), + at: "checkpoint", continuation: { kind: "may_finish", includeFinalAssistant: true }, triggerEntryId: responseEntryId, }; @@ -279,7 +280,8 @@ describe("runtime progress channels", () => { storedValues.deleteValue(storedValues.operationState(operationId)), storedValues.setValue(storedValues.laneState("main"), { currentOperationId: null, - pendingNextRun: state.pendingNextRun, + lastOperationId: null, + inbox: state.inbox, }), ], next: { ...state, operation: null }, @@ -307,7 +309,7 @@ describe("runtime progress channels", () => { const invocationId = "result"; const { lane, drive } = await createFixture({ ...runScope(), - at: "run.tools", + at: "tools", batch: { assistantEntryId: "assistant", configuration, diff --git a/packages/agent/test/harness/runtime/restore.test.ts b/packages/agent/test/harness/runtime/restore.test.ts index ed96c7b40..a6e21a0bf 100644 --- a/packages/agent/test/harness/runtime/restore.test.ts +++ b/packages/agent/test/harness/runtime/restore.test.ts @@ -4,9 +4,12 @@ import { BACKGROUND_CONTEXT } from "../../../src/harness/context.ts"; import { restoreLane, restoreSession } from "../../../src/harness/runtime/restore.ts"; import { MemorySessionRepo, MemoryStorage } from "../../../src/harness/session/memory.ts"; import type { + CheckpointOperation, LaneConfiguration, OperationMeta, - RunCheckpointOperation, + OperationScope, + OperationState, + ResultBoundary, Session, } from "../../../src/harness/session/types.ts"; import * as storedValues from "../../../src/harness/session/values.ts"; @@ -30,7 +33,8 @@ async function createSession(): Promise { storedValues.setValue(storedValues.laneConfig("main"), configuration), storedValues.setValue(storedValues.laneState("main"), { currentOperationId: null, - pendingNextRun: [], + lastOperationId: null, + inbox: [], }), ], BACKGROUND_CONTEXT, @@ -40,9 +44,8 @@ async function createSession(): Promise { return session; } -function runState(triggerEntryId: string): RunCheckpointOperation { +function operationScope(): OperationScope { return { - at: "run.checkpoint", control: { status: "running" }, settings: { compaction: DEFAULT_COMPACTION_SETTINGS, @@ -50,10 +53,32 @@ function runState(triggerEntryId: string): RunCheckpointOperation { followUpMode: "all", toolExecution: "parallel", }, + latestAssistantEntryId: null, + }; +} + +function runState(triggerEntryId: string): CheckpointOperation { + return { + ...operationScope(), + at: "checkpoint", continuation: { kind: "need_assistant", overflowRecoveryUsed: false }, triggerEntryId, - inbox: { steer: [], followUp: [], writes: [] }, - latestAssistantEntryId: null, + }; +} + +function summaryState(boundary: ResultBoundary): OperationState { + return { + ...operationScope(), + at: "summary.deciding", + task: { + taskId: "task", + ...(boundary.kind === "finish" + ? { reason: "manual" as const } + : boundary.kind === "resume_checkpoint" + ? { reason: "threshold" as const } + : {}), + boundary, + }, }; } @@ -62,18 +87,30 @@ afterEach(async () => { }); describe("runtime lane restore", () => { - it("restores an idle lane and its latest terminal result", async () => { + it("restores an idle lane's latest operation id without reading its result", async () => { const session = await createSession(); const result = { operationId: session.idGenerator.next(), kind: "navigation" as const, - outcome: "completed" as const, - oldTipId: null, + status: "completed" as const, + fromTipId: null, tipId: null, + startedAt: 1, + endedAt: 2, }; await session.mutate( (mutator) => - mutator.commit([storedValues.setValue(storedValues.laneLastResult("main"), result)], BACKGROUND_CONTEXT), + mutator.commit( + [ + storedValues.setValue(storedValues.operationResult(result.operationId), result), + storedValues.setValue(storedValues.laneState("main"), { + currentOperationId: null, + lastOperationId: result.operationId, + inbox: [], + }), + ], + BACKGROUND_CONTEXT, + ), BACKGROUND_CONTEXT, ); @@ -82,8 +119,8 @@ describe("runtime lane restore", () => { expect(state).toEqual({ tipId: null, configuration, - pendingNextRun: [], - lastResult: result, + inbox: [], + lastOperationId: result.operationId, operation: null, }); }); @@ -108,7 +145,8 @@ describe("runtime lane restore", () => { storedValues.setValue(storedValues.operationState(operationId), state), storedValues.setValue(storedValues.laneState("main"), { currentOperationId: operationId, - pendingNextRun: [], + lastOperationId: null, + inbox: [], }), ], BACKGROUND_CONTEXT, @@ -146,7 +184,8 @@ describe("runtime lane restore", () => { storedValues.setValue(storedValues.operationState(operationId), runState("trigger")), storedValues.setValue(storedValues.laneState("main"), { currentOperationId: operationId, - pendingNextRun: [], + lastOperationId: null, + inbox: [], }), ], BACKGROUND_CONTEXT, @@ -158,6 +197,79 @@ describe("runtime lane restore", () => { } }); + it("accepts exactly the family-neutral state reachability matrix", async () => { + const resume = { kind: "resume_checkpoint", resumeAfter: runState("trigger") } satisfies ResultBoundary; + const finish = { kind: "finish" } satisfies ResultBoundary; + const navigation = { kind: "commit_navigation", targetId: "target" } satisfies ResultBoundary; + const cases: { intent: OperationMeta["intent"]; state: OperationState; accepted: boolean }[] = [ + { intent: { kind: "run", promptEntryIds: [] }, state: runState("trigger"), accepted: true }, + { intent: { kind: "run", promptEntryIds: [] }, state: summaryState(resume), accepted: true }, + { intent: { kind: "run", promptEntryIds: [] }, state: summaryState(finish), accepted: false }, + { intent: { kind: "run", promptEntryIds: [] }, state: summaryState(navigation), accepted: false }, + { intent: { kind: "compaction" }, state: summaryState(finish), accepted: true }, + { intent: { kind: "compaction" }, state: summaryState(resume), accepted: false }, + { intent: { kind: "compaction" }, state: summaryState(navigation), accepted: false }, + { intent: { kind: "compaction" }, state: runState("trigger"), accepted: false }, + { + intent: { kind: "navigation", targetId: null, summarize: false }, + state: { ...operationScope(), at: "navigation.ready_to_commit", targetId: null }, + accepted: true, + }, + { + intent: { kind: "navigation", targetId: "target", summarize: true }, + state: summaryState(navigation), + accepted: true, + }, + { + intent: { kind: "navigation", targetId: "target", summarize: true }, + state: summaryState(finish), + accepted: false, + }, + { + intent: { kind: "navigation", targetId: "target", summarize: true }, + state: summaryState(resume), + accepted: false, + }, + { + intent: { kind: "navigation", targetId: "target", summarize: false }, + state: runState("trigger"), + accepted: false, + }, + ]; + + for (const testCase of cases) { + const session = await createSession(); + const operationId = session.idGenerator.next(); + const meta: OperationMeta = { + operationId, + lane: "main", + sourceTipId: null, + startedAt: 1, + intent: testCase.intent, + }; + await session.mutate( + (mutator) => + mutator.commit( + [ + storedValues.setValue(storedValues.operationMeta(operationId), meta), + storedValues.setValue(storedValues.operationState(operationId), testCase.state), + storedValues.setValue(storedValues.laneState("main"), { + currentOperationId: operationId, + lastOperationId: null, + inbox: [], + }), + ], + BACKGROUND_CONTEXT, + ), + BACKGROUND_CONTEXT, + ); + const restored = restoreLane(session, "main", BACKGROUND_CONTEXT); + if (testCase.accepted) + await expect(restored).resolves.toMatchObject({ operation: { meta, state: testCase.state } }); + else await expect(restored).rejects.toThrow("does not match state"); + } + }); + it.each([ [storedValues.branchTip("main").namespace, storedValues.deleteValue(storedValues.branchTip("main"))], [storedValues.laneConfig("main").namespace, storedValues.deleteValue(storedValues.laneConfig("main"))], @@ -194,7 +306,8 @@ describe("runtime lane restore", () => { : [storedValues.setValue(storedValues.operationState(operationId), state)]), storedValues.setValue(storedValues.laneState("main"), { currentOperationId: operationId, - pendingNextRun: [], + lastOperationId: null, + inbox: [], }), ], BACKGROUND_CONTEXT, @@ -223,7 +336,8 @@ describe("runtime lane restore", () => { storedValues.setValue(storedValues.laneConfig("worker"), workerConfiguration), storedValues.setValue(storedValues.laneState("worker"), { currentOperationId: null, - pendingNextRun: [], + lastOperationId: null, + inbox: [], }), ], BACKGROUND_CONTEXT, @@ -247,7 +361,8 @@ describe("runtime lane restore", () => { storedValues.setValue(storedValues.operationState(operationId), state), storedValues.setValue(storedValues.laneState("worker"), { currentOperationId: operationId, - pendingNextRun: [], + lastOperationId: null, + inbox: [], }), ], BACKGROUND_CONTEXT, diff --git a/packages/agent/test/harness/runtime/watch.test.ts b/packages/agent/test/harness/runtime/watch.test.ts index 1a7e8c103..acfef0f19 100644 --- a/packages/agent/test/harness/runtime/watch.test.ts +++ b/packages/agent/test/harness/runtime/watch.test.ts @@ -10,8 +10,8 @@ import { MemoryStorage } from "../../../src/harness/session/memory.ts"; import { StorageBackedSession } from "../../../src/harness/session/session.ts"; import type { OperationMeta, + OperationScope, OperationState, - RunScope, Session, StorageBranchScan, Write, @@ -40,7 +40,8 @@ async function createSession(storage: MemoryStorage = new MemoryStorage()): Prom storedValues.setValue(storedValues.laneConfig("main"), configuration), storedValues.setValue(storedValues.laneState("main"), { currentOperationId: null, - pendingNextRun: [], + lastOperationId: null, + inbox: [], }), ], BACKGROUND_CONTEXT, @@ -54,7 +55,7 @@ async function commit(session: Session, writes: Write[]): Promise { await session.mutate((mutator) => mutator.commit(writes, BACKGROUND_CONTEXT), BACKGROUND_CONTEXT); } -function runScope(control: RunScope["control"] = { status: "running" }): RunScope { +function runScope(control: OperationScope["control"] = { status: "running" }): OperationScope { return { control, settings: { @@ -63,7 +64,6 @@ function runScope(control: RunScope["control"] = { status: "running" }): RunScop followUpMode: "all", toolExecution: "parallel", }, - inbox: { steer: [], followUp: [], writes: [] }, latestAssistantEntryId: null, }; } @@ -166,13 +166,12 @@ describe("runtime lane watch", () => { drainedSteer: [ids.drainedSteer], drainedFollowUp: [ids.drainedFollow], }), - at: "run.deferred.suspended", + at: "deferred.suspended", stepId: "step", sourceEntryId: "source", poll: 3, configuration, streamOptions: {}, - inbox: { steer: [ids.steer], followUp: [ids.follow], writes: [ids.write] }, }; const meta: OperationMeta = { operationId, @@ -194,7 +193,13 @@ describe("runtime lane watch", () => { storedValues.setValue(storedValues.branchTip("main"), "source"), storedValues.setValue(storedValues.laneState("main"), { currentOperationId: operationId, - pendingNextRun: [ids.next], + lastOperationId: null, + inbox: [ + { entryId: ids.next, kind: "nextRun" }, + { entryId: ids.steer, kind: "steer" }, + { entryId: ids.follow, kind: "followUp" }, + { entryId: ids.write, kind: "write" }, + ], }), ]); const harness = await attach(session); @@ -233,7 +238,7 @@ describe("runtime lane watch", () => { }), storedValues.setValue(storedValues.operationState(operationId), { ...runScope(), - at: "run.assistant.effect_pending", + at: "assistant.effect_pending", generationContext: { stepId: "step", triggerEntryId: "trigger", @@ -250,7 +255,8 @@ describe("runtime lane watch", () => { }), storedValues.setValue(storedValues.laneState("main"), { currentOperationId: operationId, - pendingNextRun: [], + lastOperationId: null, + inbox: [], }), ]); const harness = await attach(session); @@ -280,7 +286,7 @@ describe("runtime lane watch", () => { }), storedValues.setValue(storedValues.operationState(frameOperationId), { ...runScope(), - at: "run.assistant.effect_pending", + at: "assistant.effect_pending", generationContext: { stepId: "step", triggerEntryId: "trigger", @@ -300,7 +306,8 @@ describe("runtime lane watch", () => { ), storedValues.setValue(storedValues.laneState("main"), { currentOperationId: frameOperationId, - pendingNextRun: [], + lastOperationId: null, + inbox: [], }), ]); const frameHarness = await attach(frameSession); @@ -326,7 +333,7 @@ describe("runtime lane watch", () => { }), storedValues.setValue(storedValues.operationState(toolOperationId), { ...runScope(), - at: "run.tools", + at: "tools", batch: { assistantEntryId: "assistant", configuration, @@ -353,7 +360,8 @@ describe("runtime lane watch", () => { storedValues.setValue(storedValues.branchTip("main"), "assistant"), storedValues.setValue(storedValues.laneState("main"), { currentOperationId: toolOperationId, - pendingNextRun: [], + lastOperationId: null, + inbox: [], }), ]); const toolHarness = await attach(toolSession); @@ -380,7 +388,8 @@ describe("runtime lane watch", () => { await commit(session, [ storedValues.setValue(storedValues.laneState("main"), { currentOperationId: null, - pendingNextRun: ["missing"], + lastOperationId: null, + inbox: [{ entryId: "missing", kind: "nextRun" }], }), ]); const harness = await attach(session); diff --git a/packages/agent/test/harness/types.test.ts b/packages/agent/test/harness/types.test.ts index 898cdf3b1..f6fadb52f 100644 --- a/packages/agent/test/harness/types.test.ts +++ b/packages/agent/test/harness/types.test.ts @@ -1,4 +1,4 @@ -import type { AssistantMessage, AssistantMessageFrame, Usage } from "@earendil-works/pi-ai"; +import type { AssistantMessage, AssistantMessageFrame, DeferredHandle, Usage } from "@earendil-works/pi-ai"; import { expectTypeOf, it } from "vitest"; import * as storedValues from "../../src/harness/session/values.ts"; import type { @@ -23,25 +23,25 @@ import type { Entry, EntryProjector, EntryWrite, - FailureProvenance, GenerationContext, HarnessEvent, HookHandler, HookMap, HookName, IdGenerator, + InboxItem, LaneConfiguration, LaneExecutionInfo, - LaneLastResult, LaneSnapshot, NewEntry, OperationAdmissionResult, OperationAt, OperationMeta, OperationRequest, + OperationResultRecord, + OperationScope, OperationState, RunResult, - RunScope, SearchQuery, Session, SessionCreateOptions, @@ -58,6 +58,8 @@ import type { Storage, StorageBranchScan, SummaryContext, + SuspendedRun, + TerminalStatus, ToolCall, UsageRow, UsageWrite, @@ -82,13 +84,10 @@ const generationContext = { overflowRecoveryUsed: false, } satisfies GenerationContext; const summaryContext = { - taskId: "task", resultEntryId: "summary", - kind: "compaction", configuration, streamOptions: {}, retryPolicy, - reason: "manual", } satisfies SummaryContext; const checkpoint = { continuation: { kind: "need_assistant", overflowRecoveryUsed: false }, @@ -103,12 +102,6 @@ const toolCalls = [ { status: "completed", sourceIndex: 3, resultEntryId: "result-3", terminate: false }, ] satisfies ToolCall[]; -const provenances = [ - { kind: "response", entryId: "response" }, - { kind: "structural", taskId: "task" }, - { kind: "configuration" }, -] satisfies FailureProvenance[]; - const runScope = { control: runningControl, settings: { @@ -117,19 +110,18 @@ const runScope = { followUpMode: "one-at-a-time", toolExecution: "parallel", }, - inbox: { steer: [], followUp: [], writes: [] }, latestAssistantEntryId: null, -} satisfies RunScope; +} satisfies OperationScope; -const runState = { ...runScope, at: "run.starting" } satisfies OperationState; +const runState = { ...runScope, at: "starting" } satisfies OperationState; // One value fixture per flat leaf; `satisfies OperationState[]` keeps every discriminant checked. const operationStates = [ runState, - { ...runScope, at: "run.checkpoint", ...checkpoint }, - { ...runScope, at: "run.assistant.ready", generationContext, nextAttempt: 1 }, + { ...runScope, at: "checkpoint", ...checkpoint }, + { ...runScope, at: "assistant.ready", generationContext, nextAttempt: 1 }, { ...runScope, - at: "run.assistant.effect_pending", + at: "assistant.effect_pending", generationContext, attempt: 1, responseEntryId: "response", @@ -139,7 +131,7 @@ const operationStates = [ }, { ...runScope, - at: "run.assistant.retry_wait", + at: "assistant.retry_wait", generationContext, nextAttempt: 2, notBefore: 10, @@ -147,12 +139,12 @@ const operationStates = [ }, { ...runScope, - at: "run.tools", + at: "tools", batch: { assistantEntryId: "assistant", configuration, turnId: "turn", calls: toolCalls }, }, { ...runScope, - at: "run.deferred.suspended", + at: "deferred.suspended", stepId: "step", sourceEntryId: "source", poll: 0, @@ -161,7 +153,7 @@ const operationStates = [ }, { ...runScope, - at: "run.deferred.effect_pending", + at: "deferred.effect_pending", stepId: "step", sourceEntryId: "source", poll: 1, @@ -170,20 +162,25 @@ const operationStates = [ configuration, streamOptions: {}, }, - { ...runScope, at: "run.compaction.deciding", reason: "threshold", resumeAfter: checkpoint, taskId: "task" }, { ...runScope, - at: "run.compaction.ready", - reason: "threshold", - resumeAfter: checkpoint, + at: "summary.deciding", + task: { taskId: "task", reason: "threshold", boundary: { kind: "resume_checkpoint", resumeAfter: checkpoint } }, + }, + { + ...runScope, + at: "summary.ready", + task: { taskId: "task", reason: "manual", customInstructions: "compact", boundary: { kind: "finish" } }, summaryContext, nextAttempt: 1, }, { ...runScope, - at: "run.compaction.effect_pending", - reason: "overflow", - resumeAfter: checkpoint, + at: "summary.effect_pending", + task: { + taskId: "task", + boundary: { kind: "commit_navigation", targetId: "target", label: "target" }, + }, summaryContext, attempt: 1, request: { index: 0, usageId: "usage" }, @@ -191,51 +188,14 @@ const operationStates = [ }, { ...runScope, - at: "run.compaction.retry_wait", - reason: "overflow", - resumeAfter: checkpoint, - summaryContext, - nextAttempt: 2, - notBefore: 10, - errorMessage: "retry", - }, - { - ...runScope, - at: "run.failure_drain", - error: { code: "provider", message: "failed" }, - provenance: provenances[0]!, - }, - { at: "compaction.deciding", control: runningControl, customInstructions: "compact", taskId: "task" }, - { at: "compaction.ready", control: runningControl, summaryContext, nextAttempt: 1 }, - { at: "compaction.effect_pending", control: runningControl, summaryContext, attempt: 1, usageIds: ["usage"] }, - { - at: "compaction.retry_wait", - control: runningControl, - summaryContext, - nextAttempt: 2, - notBefore: 10, - errorMessage: "retry", - }, - { at: "navigation.ready_to_commit", control: runningControl, targetId: null, label: "target" }, - { at: "navigation.summary.deciding", control: runningControl, targetId: "target", taskId: "task" }, - { at: "navigation.summary.ready", control: runningControl, targetId: "target", summaryContext, nextAttempt: 1 }, - { - at: "navigation.summary.effect_pending", - control: runningControl, - targetId: "target", - summaryContext, - attempt: 1, - usageIds: [], - }, - { - at: "navigation.summary.retry_wait", - control: runningControl, - targetId: "target", + at: "summary.retry_wait", + task: { taskId: "task", reason: "overflow", boundary: { kind: "resume_checkpoint", resumeAfter: checkpoint } }, summaryContext, nextAttempt: 2, notBefore: 10, errorMessage: "retry", }, + { ...runScope, at: "navigation.ready_to_commit", targetId: null }, ] satisfies OperationState[]; const operations = [ { @@ -261,19 +221,24 @@ const operations = [ }, ] satisfies OperationMeta[]; -const lastResult = { +const operationResult = { operationId: "run", kind: "run", + status: "completed", + fromTipId: null, tipId: "leaf", - finalAssistantEntryId: "assistant", - outcome: "completed", - runCompletion: "assistant", -} satisfies LaneLastResult; + startedAt: 1, + endedAt: 2, +} satisfies OperationResultRecord; const valueWrites: ValueSetWrite[] = [ storedValues.setValue(storedValues.branchTip("main"), "leaf"), storedValues.setValue(storedValues.laneConfig("main"), configuration), - storedValues.setValue(storedValues.laneState("main"), { currentOperationId: "run", pendingNextRun: [] }), - storedValues.setValue(storedValues.laneLastResult("main"), lastResult), + storedValues.setValue(storedValues.laneState("main"), { + currentOperationId: "run", + lastOperationId: null, + inbox: [], + }), + storedValues.setValue(storedValues.operationResult("run"), operationResult), storedValues.setValue(storedValues.operationMeta("run"), operations[0]), storedValues.setValue(storedValues.operationState("run"), runState), storedValues.setValue(storedValues.operationToolArgs("run", "step", 0), { path: "file" }), @@ -344,30 +309,30 @@ it("covers the complete durable storage and Part 3 discriminants", () => { expectTypeOf().toEqualTypeOf<"running" | "cancel_requested">(); expectTypeOf().toEqualTypeOf<"planned" | "effect_pending" | "outcome_ready" | "completed">(); expectTypeOf().toEqualTypeOf< - | "run.starting" - | "run.checkpoint" - | "run.assistant.ready" - | "run.assistant.effect_pending" - | "run.assistant.retry_wait" - | "run.tools" - | "run.deferred.suspended" - | "run.deferred.effect_pending" - | "run.compaction.deciding" - | "run.compaction.ready" - | "run.compaction.effect_pending" - | "run.compaction.retry_wait" - | "run.failure_drain" - | "compaction.deciding" - | "compaction.ready" - | "compaction.effect_pending" - | "compaction.retry_wait" + | "starting" + | "checkpoint" + | "assistant.ready" + | "assistant.effect_pending" + | "assistant.retry_wait" + | "tools" + | "deferred.suspended" + | "deferred.effect_pending" + | "summary.deciding" + | "summary.ready" + | "summary.effect_pending" + | "summary.retry_wait" + | "summary.deciding" + | "summary.ready" + | "summary.effect_pending" + | "summary.retry_wait" | "navigation.ready_to_commit" - | "navigation.summary.deciding" - | "navigation.summary.ready" - | "navigation.summary.effect_pending" - | "navigation.summary.retry_wait" + | "summary.deciding" + | "summary.ready" + | "summary.effect_pending" + | "summary.retry_wait" >(); - expectTypeOf().toEqualTypeOf<"response" | "structural" | "configuration">(); + expectTypeOf().toEqualTypeOf<"steer" | "followUp" | "nextRun" | "write">(); + expectTypeOf().toEqualTypeOf<"completed" | "declined" | "aborted" | "failed">(); expectTypeOf().toEqualTypeOf<"message" | "compaction" | "branch_summary" | "custom">(); void transaction; void operationStates; @@ -479,7 +444,12 @@ it("covers Part 5 results, events, hooks, snapshots, tools, and stream options", systemPrompt: string; }>(); expectTypeOf().not.toEqualTypeOf(); - expectTypeOf().returns.toEqualTypeOf>(); + expectTypeOf().returns.toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf<{ + operationId: string; + status: "suspended"; + deferred: DeferredHandle; + }>(); expectTypeOf().returns.toEqualTypeOf>(); expectTypeOf().returns.toEqualTypeOf>(); expectTypeOf().toEqualTypeOf<"operationId" | "waitForRetry" | "pollDeferred">(); diff --git a/packages/agent/test/harness/values.test.ts b/packages/agent/test/harness/values.test.ts index ec757ee61..6f4381942 100644 --- a/packages/agent/test/harness/values.test.ts +++ b/packages/agent/test/harness/values.test.ts @@ -6,9 +6,9 @@ import type { DurableStructuralPreparation, JsonValue, LaneConfiguration, - LaneLastResult, LaneState, OperationMeta, + OperationResultRecord, OperationState, PendingEntry, SessionReader, @@ -22,12 +22,12 @@ import { entryLabel, type ListElement, laneConfig, - laneLastResult, laneState, list, operationMeta, operationPreparation, operationPreparationPrefix, + operationResult, operationState, operationToolArgs, operationToolArgsPrefix, @@ -119,7 +119,7 @@ describe("built-in durable addresses", () => { expectTypeOf(config).toEqualTypeOf>(); expectTypeOf(branchTip("review")).toEqualTypeOf>(); expectTypeOf(laneState("review")).toEqualTypeOf>(); - expectTypeOf(laneLastResult("review")).toEqualTypeOf>(); + expectTypeOf(operationResult("operation")).toEqualTypeOf>(); expectTypeOf(meta).toEqualTypeOf>(); expectTypeOf(state).toEqualTypeOf>(); expectTypeOf(operationToolArgs("operation", "step", 2)).toEqualTypeOf>>(); @@ -132,7 +132,7 @@ describe("built-in durable addresses", () => { branchTip("review"), laneConfig("review"), laneState("review"), - laneLastResult("review"), + operationResult("operation"), meta, state, operationToolArgs("operation", "step", 2), @@ -147,7 +147,7 @@ describe("built-in durable addresses", () => { { kind: "value", namespace: "pi.branch.tip", key: "review" }, { kind: "value", namespace: "pi.lane.config", key: "review" }, { kind: "value", namespace: "pi.lane.state", key: "review" }, - { kind: "value", namespace: "pi.lane.lastResult", key: "review" }, + { kind: "value", namespace: "pi.result", key: "operation" }, { kind: "value", namespace: "pi.op.meta", key: "operation" }, { kind: "value", namespace: "pi.op.state", key: "operation" }, { kind: "value", namespace: "pi.op.tool_args", key: "operation:step:2" }, diff --git a/packages/coding-agent/src/experimental/client.ts b/packages/coding-agent/src/experimental/client.ts index 20242269d..1008b8f0b 100644 --- a/packages/coding-agent/src/experimental/client.ts +++ b/packages/coding-agent/src/experimental/client.ts @@ -77,8 +77,6 @@ export async function runClient(command: ClientCommand, options: RunClientOption await watch.start(async (event) => { if (event.type === "message_end" && event.runId !== undefined && event.message.role === "assistant") { completedText.set(event.runId, messageText(event.message)); - } else if (event.type === "run_end" && "finalMessage" in event) { - completedText.set(event.runId, messageText(event.finalMessage)); } await options.onEvent?.(event); }); diff --git a/packages/coding-agent/src/experimental/harness-wire-adapter.ts b/packages/coding-agent/src/experimental/harness-wire-adapter.ts index f3f89a177..94ee13c0d 100644 --- a/packages/coding-agent/src/experimental/harness-wire-adapter.ts +++ b/packages/coding-agent/src/experimental/harness-wire-adapter.ts @@ -248,58 +248,30 @@ function toHarnessDeferred(deferred: DeferredHandle): DeferredHandle { } function toWireRunValue(value: Extract["value"]): WireRunValue { - switch (value.kind) { - case "completed": - return value.finalEntryId === undefined - ? { kind: "completed", runId: value.runId, tipId: value.tipId } - : { - kind: "completed", - runId: value.runId, - tipId: value.tipId, - finalEntryId: value.finalEntryId, - finalMessage: toWireAssistantMessage(value.finalMessage), - }; - case "aborted": - return value.finalEntryId === undefined - ? { kind: "aborted", runId: value.runId, tipId: value.tipId } - : { - kind: "aborted", - runId: value.runId, - tipId: value.tipId, - finalEntryId: value.finalEntryId, - finalMessage: toWireAssistantMessage(value.finalMessage), - }; - case "failed": { - const error = { - code: value.error.code, - message: value.error.message, - ...(value.error.details === undefined - ? {} - : { details: toWireJsonValue(value.error.details, "operation error details") }), - }; - return value.finalEntryId === undefined - ? { kind: "failed", runId: value.runId, tipId: value.tipId, error } - : { - kind: "failed", - runId: value.runId, - tipId: value.tipId, - error, - finalEntryId: value.finalEntryId, - finalMessage: toWireAssistantMessage(value.finalMessage), - }; - } - case "suspended": - return { - kind: "suspended", - reason: "deferred", - runId: value.runId, - tipId: value.tipId, - finalEntryId: value.finalEntryId, - deferred: toWireDeferred(value.deferred), - }; - default: - return assertNever(value); + if (value.status === "suspended") { + return { operationId: value.operationId, status: "suspended", deferred: toWireDeferred(value.deferred) }; } + const base = { + operationId: value.operationId, + kind: value.kind, + fromTipId: value.fromTipId, + tipId: value.tipId, + startedAt: value.startedAt, + endedAt: value.endedAt, + }; + if (value.status !== "failed") return { ...base, status: value.status }; + if (value.error === undefined) throw new TypeError("Failed harness result is missing its error"); + return { + ...base, + status: "failed", + error: { + code: value.error.code, + message: value.error.message, + ...(value.error.details === undefined + ? {} + : { details: toWireJsonValue(value.error.details, "operation error details") }), + }, + }; } function toWireRunError(error: Extract["error"]): WireRunError { @@ -631,6 +603,7 @@ export function toWireLaneSnapshot(snapshot: HarnessLaneSnapshot): LaneSnapshot lane: snapshot.lane, transcript: snapshot.transcript.map(toWireEntry), tipId: snapshot.tipId, + lastOperationId: snapshot.lastOperationId, operation: snapshot.operation === null ? null @@ -721,15 +694,21 @@ export function toWireLaneEvent(event: HarnessEvent): LaneEvent | undefined { ...base, lane: event.lane, }; - case "run_end": { - const final = - event.finalEntryId === undefined - ? {} - : { finalEntryId: event.finalEntryId, finalMessage: toWireAssistantMessage(event.finalMessage) }; - return event.outcome === "failed" - ? { ...event, error: { ...event.error }, ...final, ...base, lane: event.lane } - : { ...event, ...final, ...base, lane: event.lane }; - } + case "run_end": + return event.status === "failed" + ? { + ...event, + error: { + code: event.error.code, + message: event.error.message, + ...(event.error.details === undefined + ? {} + : { details: toWireJsonValue(event.error.details, "operation error details") }), + }, + ...base, + lane: event.lane, + } + : { ...event, ...base, lane: event.lane }; case "message_start": case "message_end": return { ...event, message: toWireMessage(event.message), ...base, lane: event.lane }; diff --git a/packages/coding-agent/src/experimental/services/chat-provider.ts b/packages/coding-agent/src/experimental/services/chat-provider.ts index 3f71e384f..0bbb3ec81 100644 --- a/packages/coding-agent/src/experimental/services/chat-provider.ts +++ b/packages/coding-agent/src/experimental/services/chat-provider.ts @@ -58,9 +58,9 @@ export function toChatPromptResponse(result: HarnessRunResult): ChatPromptRespon if (result.ok) { return { accepted: true, - operationId: result.value.runId, + operationId: result.value.operationId, error: - result.value.kind === "failed" + result.value.status === "failed" && result.value.error !== undefined ? { code: result.value.error.code, message: result.value.error.message, diff --git a/packages/coding-agent/test/experimental-chat-service.test.ts b/packages/coding-agent/test/experimental-chat-service.test.ts index dd655427d..ffb767fe2 100644 --- a/packages/coding-agent/test/experimental-chat-service.test.ts +++ b/packages/coding-agent/test/experimental-chat-service.test.ts @@ -37,10 +37,13 @@ describe("Chat service", () => { const prompt = vi.fn(async () => ({ ok: true as const, value: { - operation: "run" as const, - kind: "completed" as const, - runId: "operation-1", + operationId: "operation-1", + kind: "run" as const, + status: "completed" as const, + fromTipId: null, tipId: "entry-1", + startedAt: 1, + endedAt: 2, }, })); const requestAbort = vi.fn(async () => ({ @@ -87,18 +90,29 @@ describe("Chat service", () => { expect( toChatPromptResponse({ ok: true, - value: { operation: "run", kind: "completed", runId: "operation-1", tipId: "entry-1" }, + value: { + operationId: "operation-1", + kind: "run", + status: "completed", + fromTipId: null, + tipId: "entry-1", + startedAt: 1, + endedAt: 2, + }, }), ).toEqual({ accepted: true, operationId: "operation-1", error: null }); expect( toChatPromptResponse({ ok: true, value: { - operation: "run", - kind: "failed", - runId: "operation-2", - tipId: "entry-1", + operationId: "operation-2", + kind: "run", + status: "failed", error: { code: "provider", message: "failed", details: { status: 500 } }, + fromTipId: null, + tipId: "entry-1", + startedAt: 1, + endedAt: 2, }, }), ).toEqual({ diff --git a/packages/coding-agent/test/experimental-harness-wire-adapter.test.ts b/packages/coding-agent/test/experimental-harness-wire-adapter.test.ts index fa7ba5f34..001fbfcc4 100644 --- a/packages/coding-agent/test/experimental-harness-wire-adapter.test.ts +++ b/packages/coding-agent/test/experimental-harness-wire-adapter.test.ts @@ -10,6 +10,7 @@ import { } from "@earendil-works/pi-agent-core"; import type { AssistantMessage } from "@earendil-works/pi-ai"; import { + type JsonValue, LaneEventSchema, LaneSnapshotSchema, type PromptArguments, @@ -118,67 +119,48 @@ describe("Harness wire adapter", () => { }); test.each([ - { - ok: true, - value: { operation: "run", kind: "completed", runId: "run-1", tipId: "leaf-1" }, - }, { ok: true, value: { - operation: "run", - kind: "completed", - runId: "run-1", + operationId: "run-1", + kind: "run", + status: "completed", + fromTipId: null, tipId: "leaf-1", - finalEntryId: "entry-1", - finalMessage, - }, - }, - { - ok: true, - value: { operation: "run", kind: "aborted", runId: "run-1", tipId: "leaf-1" }, - }, - { - ok: true, - value: { - operation: "run", - kind: "aborted", - runId: "run-1", - tipId: "leaf-1", - finalEntryId: "entry-1", - finalMessage, + startedAt: 1, + endedAt: 2, }, }, { ok: true, value: { - operation: "run", - kind: "failed", - runId: "run-1", + operationId: "run-1", + kind: "run", + status: "aborted", + fromTipId: "source", tipId: "leaf-1", + startedAt: 1, + endedAt: 2, + }, + }, + { + ok: true, + value: { + operationId: "run-1", + kind: "run", + status: "failed", error: { code: "provider", message: "failed", details: { status: 500 } }, + fromTipId: null, + tipId: "leaf-1", + startedAt: 1, + endedAt: 2, }, }, { ok: true, value: { - operation: "run", - kind: "failed", - runId: "run-1", - tipId: "leaf-1", - error: { code: "provider", message: "failed" }, - finalEntryId: "entry-1", - finalMessage, - }, - }, - { - ok: true, - value: { - operation: "run", - kind: "suspended", - reason: "deferred", - runId: "run-1", - tipId: "leaf-1", - finalEntryId: "entry-1", + operationId: "run-1", + status: "suspended", deferred: { provider: "test", modelId: "model", api: "test", id: "deferred-1", data: { row: 1 } }, }, }, @@ -186,8 +168,7 @@ describe("Harness wire adapter", () => { const wire = toWireRunResult(result); expect(Check(RunResultSchema, wire)).toBe(true); if (!result.ok || !wire.ok) throw new Error("Expected successful run result"); - const { operation: _operation, ...expected } = result.value; - expect(wire.value).toEqual(expected); + expect(wire.value).toEqual(result.value); }); test.each([ @@ -222,6 +203,7 @@ describe("Harness wire adapter", () => { }, ], tipId: "entry-1", + lastOperationId: null, operation: null, queues: { steer: [], followUp: [], nextRun: [] }, pendingWrites: [], @@ -257,19 +239,19 @@ describe("Harness wire adapter", () => { }); test("rejects non-JSON values in Harness output", () => { - const unsafeMessage: AssistantMessage = { - ...finalMessage, - content: [{ type: "toolCall", id: "tool-1", name: "read", arguments: { when: new Date(0) } }], - }; + const details = { when: null } satisfies JsonValue; + Object.defineProperty(details, "when", { value: new Date(0) }); const result: HarnessRunResult = { ok: true, value: { - operation: "run", - kind: "completed", - runId: "run-1", + operationId: "run-1", + kind: "run", + status: "failed", + error: { code: "provider", message: "failed", details }, + fromTipId: null, tipId: "leaf-1", - finalEntryId: "entry-1", - finalMessage: unsafeMessage, + startedAt: 1, + endedAt: 2, }, }; expect(() => toWireRunResult(result)).toThrow(/not JSON-serializable/); diff --git a/packages/coding-agent/test/experimental-remote-runtime.test.ts b/packages/coding-agent/test/experimental-remote-runtime.test.ts index 281592ad8..83d3c0e9e 100644 --- a/packages/coding-agent/test/experimental-remote-runtime.test.ts +++ b/packages/coding-agent/test/experimental-remote-runtime.test.ts @@ -643,22 +643,17 @@ describe("experimental durable server composition", () => { expect(result).toMatchObject({ ok: true, value: { - kind: "completed", + kind: "run", + status: "completed", tipId: expect.any(String), - finalEntryId: expect.any(String), - finalMessage: { - role: "assistant", - content: [{ type: "text", text: "deterministic remote answer" }], - stopReason: "stop", - }, }, }); expect(runtime.workerPids.get("demo-1")).toBe(workerPid); expect(processExists(workerPid!)).toBe(true); - if (!result.ok || result.value.kind !== "completed" || !("finalEntryId" in result.value)) { - throw new Error("Expected a completed prompt with a final assistant entry"); + if (!result.ok || result.value.status !== "completed" || result.value.tipId === null) { + throw new Error("Expected a completed prompt with a terminal tip"); } - const finalEntryId = result.value.finalEntryId; + const finalEntryId = result.value.tipId; await client.dispose(); clients.delete(client); diff --git a/packages/protocol/src/harness.ts b/packages/protocol/src/harness.ts index 50330b409..c629aec51 100644 --- a/packages/protocol/src/harness.ts +++ b/packages/protocol/src/harness.ts @@ -168,43 +168,23 @@ const OperationErrorSchema = StrictObject({ message: Type.String(), details: Type.Optional(JsonValueSchema), }); +const OperationResultBase = { + operationId: IdSchema, + kind: Type.Union([Type.Literal("run"), Type.Literal("compaction"), Type.Literal("navigation")]), + fromTipId: Type.Union([IdSchema, Type.Null()]), + tipId: Type.Union([IdSchema, Type.Null()]), + startedAt: TimestampSchema, + endedAt: TimestampSchema, +}; const RunValueSchema = Type.Union([ - StrictObject({ kind: Type.Literal("completed"), runId: IdSchema, tipId: IdSchema }), StrictObject({ - kind: Type.Literal("completed"), - runId: IdSchema, - tipId: IdSchema, - finalEntryId: IdSchema, - finalMessage: AssistantMessageSchema, + ...OperationResultBase, + status: Type.Union([Type.Literal("completed"), Type.Literal("declined"), Type.Literal("aborted")]), }), - StrictObject({ kind: Type.Literal("aborted"), runId: IdSchema, tipId: IdSchema }), + StrictObject({ ...OperationResultBase, status: Type.Literal("failed"), error: OperationErrorSchema }), StrictObject({ - kind: Type.Literal("aborted"), - runId: IdSchema, - tipId: IdSchema, - finalEntryId: IdSchema, - finalMessage: AssistantMessageSchema, - }), - StrictObject({ - kind: Type.Literal("failed"), - runId: IdSchema, - tipId: IdSchema, - error: OperationErrorSchema, - }), - StrictObject({ - kind: Type.Literal("failed"), - runId: IdSchema, - tipId: IdSchema, - error: OperationErrorSchema, - finalEntryId: IdSchema, - finalMessage: AssistantMessageSchema, - }), - StrictObject({ - kind: Type.Literal("suspended"), - reason: Type.Literal("deferred"), - runId: IdSchema, - tipId: IdSchema, - finalEntryId: IdSchema, + operationId: IdSchema, + status: Type.Literal("suspended"), deferred: DeferredHandleSchema, }), ]); @@ -380,6 +360,7 @@ export const LaneSnapshotSchema = StrictObject({ lane: Type.String(), transcript: Type.Array(LaneEntrySchema), tipId: Type.Union([IdSchema, Type.Null()]), + lastOperationId: Type.Union([IdSchema, Type.Null()]), operation: Type.Union([LaneOperationSchema, Type.Null()]), queues: StrictObject({ steer: Type.Array(QueuedItemSchema), @@ -411,6 +392,7 @@ const LaneEventBase = { const RunEndBase = { type: Type.Literal("run_end"), runId: IdSchema, + fromTipId: Type.Union([IdSchema, Type.Null()]), tipId: Type.Union([IdSchema, Type.Null()]), ...LaneEventBase, }; @@ -433,22 +415,9 @@ export const LaneEventSchema = Type.Union([ }), StrictObject({ ...RunEndBase, - outcome: Type.Union([Type.Literal("completed"), Type.Literal("aborted")]), - }), - StrictObject({ - ...RunEndBase, - outcome: Type.Union([Type.Literal("completed"), Type.Literal("aborted")]), - finalEntryId: IdSchema, - finalMessage: AssistantMessageSchema, - }), - StrictObject({ ...RunEndBase, outcome: Type.Literal("failed"), error: OperationErrorSchema }), - StrictObject({ - ...RunEndBase, - outcome: Type.Literal("failed"), - error: OperationErrorSchema, - finalEntryId: IdSchema, - finalMessage: AssistantMessageSchema, + status: Type.Union([Type.Literal("completed"), Type.Literal("aborted")]), }), + StrictObject({ ...RunEndBase, status: Type.Literal("failed"), error: OperationErrorSchema }), StrictObject({ type: Type.Literal("message_start"), runId: Type.Optional(IdSchema), diff --git a/packages/protocol/test/protocol.test.ts b/packages/protocol/test/protocol.test.ts index aba6ff654..31c32c371 100644 --- a/packages/protocol/test/protocol.test.ts +++ b/packages/protocol/test/protocol.test.ts @@ -55,6 +55,7 @@ const laneSnapshot = { lane: "main", transcript: [], tipId: null, + lastOperationId: null, operation: null, queues: { steer: [], followUp: [], nextRun: [] }, pendingWrites: [], @@ -63,7 +64,15 @@ const laneSnapshot = { const completedRunResult = { ok: true, - value: { kind: "completed", runId: "run-1", tipId: "leaf-1" }, + value: { + operationId: "run-1", + kind: "run", + status: "completed", + fromTipId: null, + tipId: "leaf-1", + startedAt: 1, + endedAt: 2, + }, } as const satisfies RunResult; describe("RPC manifest", () => { @@ -444,8 +453,9 @@ describe("protocol validation", () => { type: "run_end", lane: "main", runId: "run-1", + fromTipId: null, tipId: "leaf-1", - outcome: "completed", + status: "completed", finalEntryId: "entry-1", }, }), @@ -470,14 +480,29 @@ describe("protocol validation", () => { }); test.each([ - { ok: true, value: { kind: "completed", runId: "run-1", tipId: "leaf-1" } }, - { ok: true, value: { kind: "aborted", runId: "run-1", tipId: "leaf-1" } }, + completedRunResult, { ok: true, value: { - kind: "failed", - runId: "run-1", + operationId: "run-1", + kind: "run", + status: "aborted", + fromTipId: null, tipId: "leaf-1", + startedAt: 1, + endedAt: 2, + }, + }, + { + ok: true, + value: { + operationId: "run-1", + kind: "run", + status: "failed", + fromTipId: null, + tipId: "leaf-1", + startedAt: 1, + endedAt: 2, error: { code: "provider", message: "provider failed" }, }, }, @@ -522,9 +547,13 @@ describe("protocol validation", () => { Check(RunResultSchema, { ok: true, value: { - kind: "failed", - runId: "run-1", + operationId: "run-1", + kind: "run", + status: "failed", + fromTipId: null, tipId: "leaf-1", + startedAt: 1, + endedAt: 2, error: { code: "provider", message: "failed", details }, }, }), diff --git a/packages/server/src/testing/host.ts b/packages/server/src/testing/host.ts index 8ade433a9..7e169167d 100644 --- a/packages/server/src/testing/host.ts +++ b/packages/server/src/testing/host.ts @@ -27,6 +27,7 @@ const emptyLaneSnapshot: LaneSnapshot = { lane: "main", transcript: [], tipId: null, + lastOperationId: null, operation: null, queues: { steer: [], followUp: [], nextRun: [] }, pendingWrites: [], @@ -149,7 +150,15 @@ export class TestHarness { } const result = this.nextPromptResult ?? { ok: true, - value: { kind: "completed", runId: "run-1", tipId: "leaf-1" }, + value: { + operationId: "run-1", + kind: "run", + status: "completed", + fromTipId: null, + tipId: "leaf-1", + startedAt: 1, + endedAt: 2, + }, }; this.nextPromptResult = undefined; return result; diff --git a/packages/server/test/conformance.test.ts b/packages/server/test/conformance.test.ts index 6b1206c28..efeb219e3 100644 --- a/packages/server/test/conformance.test.ts +++ b/packages/server/test/conformance.test.ts @@ -131,7 +131,15 @@ describe("Session protocol", () => { attachClient: async () => ({ prompt: async () => ({ ok: true, - value: { kind: "completed" as const, runId: "run-1", tipId: "leaf-1" }, + value: { + operationId: "run-1", + kind: "run" as const, + status: "completed" as const, + fromTipId: null, + tipId: "leaf-1", + startedAt: 1, + endedAt: 2, + }, }), release: () => {}, }), @@ -423,7 +431,18 @@ describe("Session protocol", () => { type: "response", id: "request-3", ok: true, - result: { ok: true, value: { kind: "completed", runId: "run-1", tipId: "leaf-1" } }, + result: { + ok: true, + value: { + operationId: "run-1", + kind: "run", + status: "completed", + fromTipId: null, + tipId: "leaf-1", + startedAt: 1, + endedAt: 2, + }, + }, }); expect(host.latestHarness("session-1").promptCalls).toEqual([["Hello"]]); }); @@ -660,7 +679,15 @@ describe("routed Session acquisition failures", () => { return { prompt: async () => ({ ok: true, - value: { kind: "completed" as const, runId: "run-1", tipId: "leaf-1" }, + value: { + operationId: "run-1", + kind: "run" as const, + status: "completed" as const, + fromTipId: null, + tipId: "leaf-1", + startedAt: 1, + endedAt: 2, + }, }), release: () => { releaseCount += 1;