mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(agent): simplify durable operation graph
This commit is contained in:
parent
3a68f018c0
commit
c60dd63fdf
49 changed files with 2460 additions and 2772 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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<OperationResultRecord>("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<OperationOutcome, …>`,
|
||||
`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<OperationResultRecord | SuspendedRun, …>`,
|
||||
`CompactionResult = Result<{ compaction: OperationResultRecord; run?: OperationResultRecord | SuspendedRun }, …>`,
|
||||
`NavigationResult = Result<{ navigation: OperationResultRecord; run?: OperationResultRecord | SuspendedRun }, …>`,
|
||||
`ResumeResult = Result<OperationResultRecord | SuspendedRun, …>`;
|
||||
`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<OperationOutcome | undefined>` (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<OperationResultRecord | undefined>` — 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<OperationOutcome, …>`.
|
||||
- `prompt`/`skill`/`promptFromTemplate`: accept + drive; return `Result<OperationResultRecord | SuspendedRun, …>` — 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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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<CompactionOperationOutcome, LaneBusy | NothingToCompact | Closed>;
|
||||
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<ResumeOutcome, NothingToResume | Closed>;
|
||||
export type QueueResult = Result<{ entryId: string }, NoActiveRun | InvalidMessage | Closed>;
|
||||
export type NextRunResult = Result<{ entryId: string }, InvalidMessage | Closed>;
|
||||
export type ResumeResult = Result<OperationResultRecord | SuspendedRun, NothingToResume | Closed>;
|
||||
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<RunOutcome, { kind: "suspended" }>)
|
||||
| ({ 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<DriveOutcome, OperationMismatch | Closed>;
|
||||
|
|
@ -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<Entry | undefined>;
|
||||
appendMessage(message: AgentMessage, context: Context): Promise<string>;
|
||||
appendCustomEntry(customType: string, data: JsonValue | undefined, context: Context): Promise<string>;
|
||||
getLastResult(context: Context): Promise<LaneLastResult | undefined>;
|
||||
getResult(operationId: string, context: Context): Promise<OperationResultRecord | undefined>;
|
||||
accept(request: OperationRequest, context: Context): Promise<OperationAdmissionResult>;
|
||||
drive(options: DriveOptions, context: Context): Promise<DriveResult>;
|
||||
requestAbort(operationId: string, context: Context): Promise<AbortRequestResult>;
|
||||
|
|
@ -568,11 +527,7 @@ export interface AgentLane {
|
|||
abort(context: Context): Promise<AbortResult>;
|
||||
steer(message: string | AgentMessage, images: ImageContent[] | undefined, context: Context): Promise<QueueResult>;
|
||||
followUp(message: string | AgentMessage, images: ImageContent[] | undefined, context: Context): Promise<QueueResult>;
|
||||
nextRun(
|
||||
message: string | AgentMessage,
|
||||
images: ImageContent[] | undefined,
|
||||
context: Context,
|
||||
): Promise<NextRunResult>;
|
||||
nextRun(message: string | AgentMessage, images: ImageContent[] | undefined, context: Context): Promise<QueueResult>;
|
||||
cancelQueued(entryId: string, context: Context): Promise<CancelQueuedResult>;
|
||||
recordUsage(
|
||||
usage: Usage,
|
||||
|
|
|
|||
|
|
@ -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<RunCheckpointOperation["continuation"], { kind: "may_finish" }>;
|
||||
|
||||
function isSettledAssistant(message: AgentMessage): message is SettledAssistantMessage {
|
||||
return message.role === "assistant" && message.stopReason !== "pending";
|
||||
}
|
||||
type FinishContinuation = Extract<CheckpointOperation["continuation"], { kind: "may_finish" }>;
|
||||
|
||||
/** Consume before_run and commit the initial checkpoint. */
|
||||
export async function startRun<TContext extends object | undefined>(
|
||||
lane: Lane<TContext>,
|
||||
drive: Drive,
|
||||
run: RunStartingOperation,
|
||||
run: StartingOperation,
|
||||
): Promise<ProcedureResult> {
|
||||
const prompt = await lane.continueOperation(
|
||||
run,
|
||||
|
|
@ -78,9 +70,9 @@ export async function startRun<TContext extends object | undefined>(
|
|||
);
|
||||
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<TContext extends object | undefined>(
|
|||
async function applyPendingWrites<TContext extends object | undefined>(
|
||||
lane: Lane<TContext>,
|
||||
drive: Drive,
|
||||
checkpoint: RunCheckpointOperation,
|
||||
checkpoint: CheckpointOperation,
|
||||
): Promise<ProcedureResult> {
|
||||
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<TContext extends object | undefined>(
|
|||
}
|
||||
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<TContext extends object | undefined>(
|
|||
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<TContext extends object | undefined>(
|
|||
async function consumeQueuedMessages<TContext extends object | undefined>(
|
||||
lane: Lane<TContext>,
|
||||
drive: Drive,
|
||||
checkpoint: RunCheckpointOperation,
|
||||
checkpoint: CheckpointOperation,
|
||||
queue: "steer" | "followUp",
|
||||
): Promise<ProcedureResult> {
|
||||
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<MessageEntry>[] = 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<TContext extends object | undefined>(
|
|||
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<TContext extends object | undefined>(
|
|||
async function finishRun<TContext extends object | undefined>(
|
||||
lane: Lane<TContext>,
|
||||
drive: Drive,
|
||||
checkpoint: RunCheckpointOperation,
|
||||
checkpoint: CheckpointOperation,
|
||||
continuation: FinishContinuation,
|
||||
): Promise<ProcedureResult> {
|
||||
const context = await readBoundedContext(lane, drive, checkpoint);
|
||||
|
|
@ -252,20 +246,16 @@ async function finishRun<TContext extends object | undefined>(
|
|||
message: { role: "user" as const, content: hook.followUp, timestamp: Date.now() },
|
||||
};
|
||||
|
||||
const result = await lane.continueOperation<RunCheckpointOperation, ProcedureResult>(
|
||||
const result = await lane.continueOperation<CheckpointOperation, ProcedureResult>(
|
||||
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<TContext extends object | undefined>(
|
|||
}
|
||||
|
||||
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<TContext extends object | undefined>(
|
|||
export async function runCheckpoint<TContext extends object | undefined>(
|
||||
lane: Lane<TContext>,
|
||||
drive: Drive,
|
||||
run: RunCheckpointOperation,
|
||||
run: CheckpointOperation,
|
||||
): Promise<ProcedureResult> {
|
||||
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<TContext extends object | undefined>(
|
|||
(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<TContext extends object | undefined>(
|
|||
);
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<RunDeferredEffectPendingOperation, keyof RunScope>;
|
||||
type EffectPendingFields = Omit<DeferredEffectPendingOperation, keyof OperationScope>;
|
||||
|
||||
type PreparedDeferredPoll = {
|
||||
kind: "ready";
|
||||
|
|
@ -92,7 +92,7 @@ async function prepareDeferredPoll<TContext extends object | undefined>(
|
|||
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<TContext extends object | undefined>(
|
|||
deferred: DeferredLeaf,
|
||||
prepared: PreparedDeferredPoll,
|
||||
recovery: boolean,
|
||||
): Promise<ContinueOperationResult<RunDeferredEffectPendingOperation>> {
|
||||
): Promise<ContinueOperationResult<DeferredEffectPendingOperation>> {
|
||||
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<TContext extends object | undefined>(
|
|||
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<TContext extends object | undefined>(
|
|||
lane: Lane<TContext>,
|
||||
drive: Drive,
|
||||
prepared: PreparedDeferredPoll,
|
||||
intent: RunDeferredEffectPendingOperation,
|
||||
intent: DeferredEffectPendingOperation,
|
||||
recovery: boolean,
|
||||
): Promise<SettledAssistantMessage> {
|
||||
const response = openAssistantResponse(lane, drive, intent.responseEntryId, recovery);
|
||||
|
|
@ -234,15 +234,7 @@ async function pollDeferred<TContext extends object | undefined>(
|
|||
};
|
||||
}
|
||||
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<TContext extends object | undefined>(
|
|||
export function runDeferredSuspended<TContext extends object | undefined>(
|
||||
lane: Lane<TContext>,
|
||||
drive: Drive,
|
||||
deferred: RunDeferredSuspendedOperation,
|
||||
deferred: DeferredSuspendedOperation,
|
||||
): Promise<ProcedureResult> {
|
||||
return pollDeferred(lane, drive, deferred, false);
|
||||
}
|
||||
|
|
@ -264,7 +256,7 @@ export function runDeferredSuspended<TContext extends object | undefined>(
|
|||
export function recoverDeferredPoll<TContext extends object | undefined>(
|
||||
lane: Lane<TContext>,
|
||||
drive: Drive,
|
||||
deferred: RunDeferredEffectPendingOperation,
|
||||
deferred: DeferredEffectPendingOperation,
|
||||
): Promise<ProcedureResult> {
|
||||
return pollDeferred(lane, drive, deferred, true);
|
||||
}
|
||||
|
|
@ -273,9 +265,9 @@ export function recoverDeferredPoll<TContext extends object | undefined>(
|
|||
export function runDeferred<TContext extends object | undefined>(
|
||||
lane: Lane<TContext>,
|
||||
drive: Drive,
|
||||
deferred: RunDeferredSuspendedOperation | RunDeferredEffectPendingOperation,
|
||||
deferred: DeferredSuspendedOperation | DeferredEffectPendingOperation,
|
||||
): Promise<ProcedureResult> {
|
||||
return deferred.at === "run.deferred.suspended"
|
||||
return deferred.at === "deferred.suspended"
|
||||
? runDeferredSuspended(lane, drive, deferred)
|
||||
: recoverDeferredPoll(lane, drive, deferred);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<RunAssistantEffectPendingOperation, keyof RunScope>;
|
||||
type AssistantEffectPending = Omit<AssistantEffectPendingOperation, keyof OperationScope>;
|
||||
|
||||
type PreparedGeneration = {
|
||||
kind: "ready";
|
||||
|
|
@ -68,7 +68,7 @@ async function resolveSystemPrompt<TContext extends object | undefined>(
|
|||
async function prepareGeneration<TContext extends object | undefined>(
|
||||
lane: Lane<TContext>,
|
||||
drive: Drive,
|
||||
generation: RunAssistantReadyOperation,
|
||||
generation: AssistantReadyOperation,
|
||||
): Promise<GenerationPreparation> {
|
||||
const identity = generation.generationContext.configuration.model;
|
||||
const model = lane.models.getModel(identity.provider, identity.modelId);
|
||||
|
|
@ -132,12 +132,12 @@ async function prepareGeneration<TContext extends object | undefined>(
|
|||
async function publishGenerationIntent<TContext extends object | undefined>(
|
||||
lane: Lane<TContext>,
|
||||
drive: Drive,
|
||||
ready: RunAssistantReadyOperation,
|
||||
ready: AssistantReadyOperation,
|
||||
prepared: PreparedGeneration,
|
||||
): Promise<ContinueOperationResult<RunAssistantEffectPendingOperation>> {
|
||||
): Promise<ContinueOperationResult<AssistantEffectPendingOperation>> {
|
||||
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<TContext extends object | undefined>(
|
|||
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<TContext extends object | undefined>(
|
|||
async function performGeneration<TContext extends object | undefined>(
|
||||
lane: Lane<TContext>,
|
||||
drive: Drive,
|
||||
intent: RunAssistantEffectPendingOperation,
|
||||
intent: AssistantEffectPendingOperation,
|
||||
prepared: PreparedGeneration,
|
||||
): Promise<SettledAssistantMessage> {
|
||||
const response = openAssistantResponse(lane, drive, intent.responseEntryId);
|
||||
|
|
@ -233,7 +233,7 @@ async function performGeneration<TContext extends object | undefined>(
|
|||
export async function runRetryWait<TContext extends object | undefined>(
|
||||
lane: Lane<TContext>,
|
||||
drive: Drive,
|
||||
generation: RunAssistantRetryWaitOperation,
|
||||
generation: AssistantRetryWaitOperation,
|
||||
): Promise<ProcedureResult> {
|
||||
if (Date.now() < generation.notBefore) {
|
||||
if (!drive.waitForRetry) {
|
||||
|
|
@ -253,9 +253,9 @@ export async function runRetryWait<TContext extends object | undefined>(
|
|||
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<TContext extends object | undefined>(
|
|||
export async function runGeneration<TContext extends object | undefined>(
|
||||
lane: Lane<TContext>,
|
||||
drive: Drive,
|
||||
generation: RunAssistantReadyOperation | RunAssistantRetryWaitOperation,
|
||||
generation: AssistantReadyOperation | AssistantRetryWaitOperation,
|
||||
): Promise<ProcedureResult> {
|
||||
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") {
|
||||
|
|
|
|||
|
|
@ -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<TContext extends object | undefined>(
|
||||
lane: Lane<TContext>,
|
||||
drive: Drive,
|
||||
generation: RunAssistantEffectPendingOperation,
|
||||
generation: AssistantEffectPendingOperation,
|
||||
): Promise<ProcedureResult> {
|
||||
const frames = await lane.continueOperation(
|
||||
generation,
|
||||
|
|
|
|||
|
|
@ -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<TContext extends object | undefined>(
|
|||
};
|
||||
}
|
||||
|
||||
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<TContext>,
|
||||
drive: Drive,
|
||||
capability: TState,
|
||||
error: OperationError,
|
||||
writes: Write[] = [],
|
||||
): Promise<ProcedureResult> {
|
||||
TState extends ConfigurationFailureState,
|
||||
>(lane: Lane<TContext>, drive: Drive, capability: TState, error: OperationError): Promise<ProcedureResult> {
|
||||
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<TContext extends object | undefined>(
|
|||
options: { recovery?: true } = {},
|
||||
): Promise<ProcedureResult> {
|
||||
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<ResponseIntent, ProcedureResult>(
|
||||
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<TContext extends object | undefined>(
|
|||
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<TContext extends object | undefined>(
|
|||
};
|
||||
} 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<TContext extends object | undefined>(
|
|||
}
|
||||
} 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<TContext extends object | undefined>(
|
|||
}));
|
||||
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<TContext extends object | undefined>(
|
|||
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,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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<string>();
|
||||
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<MessageEntry & { message: SettledAssistantMessage }> {
|
||||
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<CompactionEntry> {
|
||||
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<BranchSummaryEntry> {
|
||||
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<TerminalOperationOutcome> {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TContext extends object | undefined>(
|
||||
|
|
@ -88,7 +88,7 @@ async function readPlacement<TContext extends object | undefined>(
|
|||
): Promise<PlacementRead | undefined> {
|
||||
return lane.command<PlacementRead | undefined>(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<TContext extends object | undefined>(
|
|||
async function commitPlacement<TContext extends object | undefined>(
|
||||
lane: Lane<TContext>,
|
||||
drive: Drive,
|
||||
capability: RunToolsOperation,
|
||||
capability: ToolsOperation,
|
||||
read: PlacementRead,
|
||||
): Promise<boolean> {
|
||||
const usageIds = read.items.map((item) =>
|
||||
item.message.usage === undefined ? undefined : lane.session.idGenerator.next(),
|
||||
);
|
||||
return lane.settleOperation<RunToolsOperation, boolean>(
|
||||
return lane.settleOperation<ToolsOperation, boolean>(
|
||||
capability,
|
||||
async (state, run, _meta, reader) => {
|
||||
const current = run.batch;
|
||||
|
|
@ -219,9 +219,9 @@ async function commitPlacement<TContext extends object | undefined>(
|
|||
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<TContext extends object | undefined>(
|
|||
export async function materializeReady<TContext extends object | undefined>(
|
||||
lane: Lane<TContext>,
|
||||
drive: Drive,
|
||||
capability: RunToolsOperation,
|
||||
capability: ToolsOperation,
|
||||
sources: ToolBatchSource,
|
||||
recovery: boolean,
|
||||
): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -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<TContext extends object | undefined>(
|
||||
lane: Lane<TContext>,
|
||||
): { 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<TContext extends object | undefined>(
|
|||
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<TContext extends object | undefined>(
|
||||
lane: Lane<TContext>,
|
||||
drive: Drive,
|
||||
run: RunToolsOperation,
|
||||
run: ToolsOperation,
|
||||
planned: Extract<ToolCall, { status: "planned" }>,
|
||||
args: Record<string, JsonValue>,
|
||||
replay: "never" | "safe",
|
||||
|
|
@ -205,7 +205,7 @@ async function publishToolIntent<TContext extends object | undefined>(
|
|||
async function publishToolOutcome<TContext extends object | undefined>(
|
||||
lane: Lane<TContext>,
|
||||
drive: Drive,
|
||||
capability: RunToolsOperation,
|
||||
capability: ToolsOperation,
|
||||
call: ToolCall,
|
||||
message: ToolResultMessage<unknown>,
|
||||
terminate: boolean,
|
||||
|
|
@ -451,7 +451,7 @@ async function prepareToolInvocation<TContext extends object | undefined>(
|
|||
async function startToolInvocation<TContext extends object | undefined>(
|
||||
lane: Lane<TContext>,
|
||||
drive: Drive,
|
||||
run: RunToolsOperation,
|
||||
run: ToolsOperation,
|
||||
sources: ToolBatchSource,
|
||||
call: Extract<ToolCall, { status: "planned" }>,
|
||||
tools: AgentHarnessTool<TContext>[],
|
||||
|
|
@ -493,7 +493,7 @@ async function startToolInvocation<TContext extends object | undefined>(
|
|||
async function recoverToolInvocation<TContext extends object | undefined>(
|
||||
lane: Lane<TContext>,
|
||||
drive: Drive,
|
||||
run: RunToolsOperation,
|
||||
run: ToolsOperation,
|
||||
sources: ToolBatchSource,
|
||||
call: Extract<ToolCall, { status: "effect_pending" }>,
|
||||
toolsByName: Map<string, AgentHarnessTool<TContext>>,
|
||||
|
|
@ -520,7 +520,7 @@ async function recoverToolInvocation<TContext extends object | undefined>(
|
|||
async function runSequential<TContext extends object | undefined>(
|
||||
lane: Lane<TContext>,
|
||||
drive: Drive,
|
||||
run: RunToolsOperation,
|
||||
run: ToolsOperation,
|
||||
sources: ToolBatchSource,
|
||||
execution:
|
||||
| {
|
||||
|
|
@ -582,7 +582,7 @@ async function runSequential<TContext extends object | undefined>(
|
|||
async function runParallel<TContext extends object | undefined>(
|
||||
lane: Lane<TContext>,
|
||||
drive: Drive,
|
||||
run: RunToolsOperation,
|
||||
run: ToolsOperation,
|
||||
sources: ToolBatchSource,
|
||||
tools: AgentHarnessTool<TContext>[],
|
||||
toolsByName: Map<string, AgentHarnessTool<TContext>>,
|
||||
|
|
@ -627,7 +627,7 @@ async function runParallel<TContext extends object | undefined>(
|
|||
export async function runTools<TContext extends object | undefined>(
|
||||
lane: Lane<TContext>,
|
||||
drive: Drive,
|
||||
run: RunToolsOperation,
|
||||
run: ToolsOperation,
|
||||
): Promise<ProcedureResult> {
|
||||
const batch = run.batch;
|
||||
const recovery = batch.calls.some((call) => call.status === "effect_pending" || call.status === "outcome_ready");
|
||||
|
|
|
|||
|
|
@ -130,16 +130,14 @@ export class Harness<TContext extends object | undefined> 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);
|
||||
|
|
|
|||
|
|
@ -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<unknown> {
|
|||
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<TContext extends object | undefined> implements AgentLane {
|
|||
return this.state.tipId;
|
||||
}
|
||||
|
||||
async getLastResult(_context: Context): Promise<LaneLastResult | undefined> {
|
||||
async getResult(operationId: string, context: Context): Promise<OperationResultRecord | undefined> {
|
||||
this.assertOpen();
|
||||
return this.state.lastResult;
|
||||
return (await this.session.getValue(operationResultValue(operationId), context))?.value;
|
||||
}
|
||||
|
||||
readConfig(): Config<TContext> {
|
||||
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<TContext extends object | undefined> 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<TContext extends object | undefined> 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<TContext extends object | undefined> 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<TContext extends object | undefined> 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<TContext extends object | undefined> 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<TContext extends object | undefined> 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<TContext extends object | undefined> 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<TContext extends object | undefined> 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<TContext extends object | undefined> 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<LaneSnapshot["operation"]> | null = null;
|
||||
if (operation !== null) {
|
||||
|
|
@ -720,18 +822,18 @@ export class Lane<TContext extends object | undefined> 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<TContext extends object | undefined> 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<TContext extends object | undefined> 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<TContext extends object | undefined> 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<TContext extends object | undefined> 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: () => [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ export function openFrameProgress<TContext extends object | undefined>(
|
|||
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<TContext extends object | undefined>(
|
|||
(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 &&
|
||||
|
|
|
|||
|
|
@ -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<OperationState, { at: `summary.${string}` }> {
|
||||
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<string | null> | undefined;
|
||||
configuration: StoredValue<LaneConfiguration> | undefined;
|
||||
laneState: StoredValue<DurableLaneState> | undefined;
|
||||
lastResult: StoredValue<LaneLastResult> | undefined;
|
||||
};
|
||||
|
||||
export type ClassifiedLaneStorage =
|
||||
|
|
@ -34,22 +51,21 @@ export type ClassifiedLaneStorage =
|
|||
tip: StoredValue<string | null>;
|
||||
configuration: StoredValue<LaneConfiguration>;
|
||||
laneState: StoredValue<DurableLaneState>;
|
||||
lastResult: StoredValue<LaneLastResult> | 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<ClassifiedLaneStorage> {
|
||||
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<Map<string, LaneState>> {
|
||||
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<string, LaneState>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<T extends { id: string }>(
|
|||
});
|
||||
}
|
||||
|
||||
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<TContext extends object | undefined, TState extends RunOperationState>(
|
||||
export function readBoundedEntries<TContext extends object | undefined, TState extends OperationState>(
|
||||
lane: Lane<TContext>,
|
||||
drive: Drive,
|
||||
capability: TState,
|
||||
|
|
@ -65,7 +66,7 @@ export function readBoundedEntries<TContext extends object | undefined, TState e
|
|||
);
|
||||
}
|
||||
|
||||
export async function readBoundedContext<TContext extends object | undefined, TState extends RunOperationState>(
|
||||
export async function readBoundedContext<TContext extends object | undefined, TState extends OperationState>(
|
||||
lane: Lane<TContext>,
|
||||
drive: Drive,
|
||||
capability: TState,
|
||||
|
|
|
|||
|
|
@ -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<TContext extends object | undefined> {
|
|||
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<TResult> =
|
|||
export type ContinueOperationResult<TResult> = { kind: "cancel_requested" } | { kind: "result"; value: TResult };
|
||||
|
||||
/** A durable operation transition. The Lane pairs the state write with projection publication. */
|
||||
type LanePatch = Partial<Pick<LaneState, "tipId" | "configuration">>;
|
||||
type LanePatch = Partial<Pick<LaneState, "tipId" | "configuration" | "inbox">>;
|
||||
|
||||
interface FinishDecision<TResult> {
|
||||
kind: "finish";
|
||||
writes: Write[];
|
||||
lastResult: LaneLastResult;
|
||||
record: OperationResultRecord;
|
||||
lane?: LanePatch;
|
||||
materialize(commit: CommitResult): Synchronous<TResult>;
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<JsonValue>("test.application.value");
|
||||
const applicationList = list<JsonValue>("test.application.list");
|
||||
|
|
@ -364,14 +365,17 @@ export function createSessionRepoForkBehaviorConformance<TMetadata extends Sessi
|
|||
setValue(laneConfig("main"), configuration),
|
||||
setValue(laneState("main"), {
|
||||
currentOperationId: OPERATION_ID,
|
||||
pendingNextRun: [PENDING_ID],
|
||||
lastOperationId: "previous",
|
||||
inbox: [{ entryId: PENDING_ID, kind: "write" }],
|
||||
}),
|
||||
setValue(laneLastResult("main"), {
|
||||
setValue(operationResult("previous"), {
|
||||
operationId: "previous",
|
||||
kind: "navigation",
|
||||
status: "completed",
|
||||
fromTipId: ROOT_ID,
|
||||
tipId: CHILD_ID,
|
||||
oldTipId: ROOT_ID,
|
||||
outcome: "completed",
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
}),
|
||||
setValue(sessionName, "source name"),
|
||||
setValue(applicationValue, { copied: false }),
|
||||
|
|
@ -390,9 +394,16 @@ export function createSessionRepoForkBehaviorConformance<TMetadata extends Sessi
|
|||
intent: { kind: "compaction" },
|
||||
}),
|
||||
setValue(operationState(OPERATION_ID), {
|
||||
at: "compaction.deciding",
|
||||
at: "summary.deciding",
|
||||
control: { status: "running" },
|
||||
taskId: OPERATION_ID,
|
||||
settings: {
|
||||
compaction: { enabled: true, reserveTokens: 1, keepRecentTokens: 1 },
|
||||
steeringMode: "all",
|
||||
followUpMode: "all",
|
||||
toolExecution: "sequential",
|
||||
},
|
||||
latestAssistantEntryId: null,
|
||||
task: { taskId: OPERATION_ID, reason: "manual", boundary: { kind: "finish" } },
|
||||
}),
|
||||
setValue(operationToolArgs(OPERATION_ID, ROOT_ID, 0), {
|
||||
argument: true,
|
||||
|
|
@ -436,7 +447,7 @@ export function createSessionRepoForkBehaviorConformance<TMetadata extends Sessi
|
|||
deepStrictEqual(await fork.readList(applicationList, undefined, BACKGROUND_CONTEXT), []);
|
||||
strictEqual(await fork.getLabel(ROOT_ID, BACKGROUND_CONTEXT), "root label");
|
||||
strictEqual(await fork.getLabel(SIBLING_ID, BACKGROUND_CONTEXT), undefined);
|
||||
strictEqual(await fork.getValue(laneLastResult("main"), BACKGROUND_CONTEXT), undefined);
|
||||
strictEqual(await fork.getValue(operationResult("previous"), BACKGROUND_CONTEXT), undefined);
|
||||
strictEqual(await fork.getValue(pendingEntry(PENDING_ID), BACKGROUND_CONTEXT), undefined);
|
||||
strictEqual(await fork.getValue(operationMeta(OPERATION_ID), BACKGROUND_CONTEXT), undefined);
|
||||
strictEqual(await fork.getValue(operationState(OPERATION_ID), BACKGROUND_CONTEXT), undefined);
|
||||
|
|
|
|||
|
|
@ -103,6 +103,20 @@ export interface OperationError {
|
|||
details?: JsonValue;
|
||||
}
|
||||
|
||||
export type TerminalStatus = "completed" | "declined" | "aborted" | "failed";
|
||||
|
||||
/** Immutable lane-lived observation record written by one terminal transaction. */
|
||||
export interface OperationResultRecord {
|
||||
operationId: string;
|
||||
kind: OperationMeta["intent"]["kind"];
|
||||
status: TerminalStatus;
|
||||
error?: OperationError;
|
||||
fromTipId: string | null;
|
||||
tipId: string | null;
|
||||
startedAt: number;
|
||||
endedAt: number;
|
||||
}
|
||||
|
||||
export type Continuation =
|
||||
| { kind: "need_assistant"; overflowRecoveryUsed: boolean }
|
||||
| { kind: "may_finish"; includeFinalAssistant: boolean };
|
||||
|
|
@ -115,10 +129,11 @@ export interface CheckpointData {
|
|||
skipInboxOnce?: boolean;
|
||||
}
|
||||
|
||||
export interface Inbox {
|
||||
steer: string[];
|
||||
followUp: string[];
|
||||
writes: string[];
|
||||
export type InboxItemKind = "steer" | "followUp" | "nextRun" | "write";
|
||||
|
||||
export interface InboxItem {
|
||||
entryId: string;
|
||||
kind: InboxItemKind;
|
||||
}
|
||||
|
||||
export interface NormalizedRetryPolicy {
|
||||
|
|
@ -157,23 +172,18 @@ export interface ToolBatch {
|
|||
}
|
||||
|
||||
export interface SummaryContext {
|
||||
taskId: string;
|
||||
resultEntryId: string;
|
||||
kind: "compaction" | "branch_summary";
|
||||
configuration: LaneConfiguration;
|
||||
streamOptions: AgentHarnessStreamOptions;
|
||||
retryPolicy: NormalizedRetryPolicy;
|
||||
reason?: "manual" | "threshold" | "overflow";
|
||||
}
|
||||
|
||||
/*
|
||||
* Durable operation state is one flat union: a single `at` discriminator with one
|
||||
* namespaced literal per dispatcher leaf. There is no nested phase/status hierarchy.
|
||||
* ToolBatch/ToolCall remain the nested child collection state machine, and
|
||||
* cancellation stays orthogonal via `Control`.
|
||||
* Durable operation state is one flat union with one family-neutral discriminator
|
||||
* per dispatcher leaf. ToolBatch/ToolCall remain the nested child collection state
|
||||
* machine, and cancellation stays orthogonal via Control.
|
||||
*/
|
||||
|
||||
/** Every leaf carries the orthogonal cancellation shape. */
|
||||
export interface Cancellable {
|
||||
control: Control;
|
||||
}
|
||||
|
|
@ -185,37 +195,37 @@ export interface RunSettings {
|
|||
toolExecution: "sequential" | "parallel";
|
||||
}
|
||||
|
||||
/** Run-wide durable data shared by every `run.*` leaf. */
|
||||
export interface RunScope extends Cancellable {
|
||||
/** Uniform scope carried by every operation leaf. */
|
||||
export interface OperationScope extends Cancellable {
|
||||
settings: RunSettings;
|
||||
inbox: Inbox;
|
||||
latestAssistantEntryId: string | null;
|
||||
}
|
||||
|
||||
/** Failure origin. Payload datum only; never used for operation dispatch. */
|
||||
export type FailureProvenance =
|
||||
| { kind: "response"; entryId: string }
|
||||
| { kind: "structural"; taskId: string }
|
||||
| { kind: "configuration" };
|
||||
|
||||
/** Shared backoff data for any `*.retry_wait` leaf. */
|
||||
/** Shared backoff data for every retry-wait leaf. */
|
||||
export interface RetryWait {
|
||||
nextAttempt: number;
|
||||
notBefore: number;
|
||||
errorMessage: string;
|
||||
}
|
||||
|
||||
/** Assistant generation input shared by every `run.assistant.*` leaf. */
|
||||
export interface AssistantGenerationScope {
|
||||
generationContext: GenerationContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary generation input shared by every structural generating leaf. The structural
|
||||
* task id lives in `summaryContext.taskId`; only deciding leaves carry it explicitly
|
||||
* (via StructuralTask).
|
||||
*/
|
||||
export type ResultBoundary =
|
||||
| { kind: "resume_checkpoint"; resumeAfter: CheckpointData }
|
||||
| { kind: "finish" }
|
||||
| { kind: "commit_navigation"; targetId: string; label?: string };
|
||||
|
||||
export interface SummaryTask {
|
||||
taskId: string;
|
||||
reason?: "manual" | "threshold" | "overflow";
|
||||
customInstructions?: string;
|
||||
boundary: ResultBoundary;
|
||||
}
|
||||
|
||||
export interface SummaryGenerationScope {
|
||||
task: SummaryTask;
|
||||
summaryContext: SummaryContext;
|
||||
}
|
||||
|
||||
|
|
@ -231,13 +241,7 @@ export interface SummaryGenerationEffectPending extends SummaryGenerationScope {
|
|||
|
||||
export interface SummaryGenerationRetryWait extends SummaryGenerationScope, RetryWait {}
|
||||
|
||||
/** Explicit structural task id for deciding leaves that have no SummaryContext yet. */
|
||||
export interface StructuralTask {
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
/** Deferred-suspension durable data shared by both `run.deferred.*` leaves. */
|
||||
export interface DeferredScope extends RunScope {
|
||||
export interface DeferredScope extends OperationScope {
|
||||
stepId: string;
|
||||
sourceEntryId: string;
|
||||
poll: number;
|
||||
|
|
@ -245,43 +249,21 @@ export interface DeferredScope extends RunScope {
|
|||
streamOptions: AgentHarnessStreamOptions;
|
||||
}
|
||||
|
||||
/** In-run compaction data: why it started and which checkpoint resumes after it. */
|
||||
export interface RunCompactionScope extends RunScope {
|
||||
reason: "threshold" | "overflow";
|
||||
resumeAfter: CheckpointData;
|
||||
export interface StartingOperation extends OperationScope {
|
||||
at: "starting";
|
||||
}
|
||||
|
||||
/** Standalone compaction shared data. */
|
||||
export interface CompactionScope extends Cancellable {
|
||||
customInstructions?: string;
|
||||
export interface CheckpointOperation extends OperationScope, CheckpointData {
|
||||
at: "checkpoint";
|
||||
}
|
||||
|
||||
/** Navigation shared data. */
|
||||
export interface NavigationScope extends Cancellable {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** Summarized navigation requires a concrete target entry. */
|
||||
export interface NavigationSummaryScope extends NavigationScope {
|
||||
targetId: string;
|
||||
customInstructions?: string;
|
||||
}
|
||||
|
||||
export interface RunStartingOperation extends RunScope {
|
||||
at: "run.starting";
|
||||
}
|
||||
|
||||
export interface RunCheckpointOperation extends RunScope, CheckpointData {
|
||||
at: "run.checkpoint";
|
||||
}
|
||||
|
||||
export interface RunAssistantReadyOperation extends RunScope, AssistantGenerationScope {
|
||||
at: "run.assistant.ready";
|
||||
export interface AssistantReadyOperation extends OperationScope, AssistantGenerationScope {
|
||||
at: "assistant.ready";
|
||||
nextAttempt: number;
|
||||
}
|
||||
|
||||
export interface RunAssistantEffectPendingOperation extends RunScope, AssistantGenerationScope {
|
||||
at: "run.assistant.effect_pending";
|
||||
export interface AssistantEffectPendingOperation extends OperationScope, AssistantGenerationScope {
|
||||
at: "assistant.effect_pending";
|
||||
attempt: number;
|
||||
responseEntryId: string;
|
||||
usageId: string;
|
||||
|
|
@ -289,129 +271,72 @@ export interface RunAssistantEffectPendingOperation extends RunScope, AssistantG
|
|||
contextWindow: number;
|
||||
}
|
||||
|
||||
export interface RunAssistantRetryWaitOperation extends RunScope, AssistantGenerationScope, RetryWait {
|
||||
at: "run.assistant.retry_wait";
|
||||
export interface AssistantRetryWaitOperation extends OperationScope, AssistantGenerationScope, RetryWait {
|
||||
at: "assistant.retry_wait";
|
||||
}
|
||||
|
||||
export interface RunToolsOperation extends RunScope {
|
||||
at: "run.tools";
|
||||
/** Unchanged child collection state machine. */
|
||||
export interface ToolsOperation extends OperationScope {
|
||||
at: "tools";
|
||||
batch: ToolBatch;
|
||||
}
|
||||
|
||||
export interface RunDeferredSuspendedOperation extends DeferredScope {
|
||||
at: "run.deferred.suspended";
|
||||
export interface DeferredSuspendedOperation extends DeferredScope {
|
||||
at: "deferred.suspended";
|
||||
}
|
||||
|
||||
export interface RunDeferredEffectPendingOperation extends DeferredScope {
|
||||
at: "run.deferred.effect_pending";
|
||||
export interface DeferredEffectPendingOperation extends DeferredScope {
|
||||
at: "deferred.effect_pending";
|
||||
responseEntryId: string;
|
||||
usageId: string;
|
||||
}
|
||||
|
||||
export interface RunCompactionDecidingOperation extends RunCompactionScope, StructuralTask {
|
||||
at: "run.compaction.deciding";
|
||||
export interface SummaryDecidingOperation extends OperationScope {
|
||||
at: "summary.deciding";
|
||||
task: SummaryTask;
|
||||
}
|
||||
|
||||
export interface RunCompactionReadyOperation extends RunCompactionScope, SummaryGenerationReady {
|
||||
at: "run.compaction.ready";
|
||||
export interface SummaryReadyOperation extends OperationScope, SummaryGenerationReady {
|
||||
at: "summary.ready";
|
||||
}
|
||||
|
||||
export interface RunCompactionEffectPendingOperation extends RunCompactionScope, SummaryGenerationEffectPending {
|
||||
at: "run.compaction.effect_pending";
|
||||
export interface SummaryEffectPendingOperation extends OperationScope, SummaryGenerationEffectPending {
|
||||
at: "summary.effect_pending";
|
||||
}
|
||||
|
||||
export interface RunCompactionRetryWaitOperation extends RunCompactionScope, SummaryGenerationRetryWait {
|
||||
at: "run.compaction.retry_wait";
|
||||
export interface SummaryRetryWaitOperation extends OperationScope, SummaryGenerationRetryWait {
|
||||
at: "summary.retry_wait";
|
||||
}
|
||||
|
||||
export interface RunFailureDrainOperation extends RunScope {
|
||||
at: "run.failure_drain";
|
||||
error: OperationError;
|
||||
provenance: FailureProvenance;
|
||||
}
|
||||
|
||||
export interface CompactionDecidingOperation extends CompactionScope, StructuralTask {
|
||||
at: "compaction.deciding";
|
||||
}
|
||||
|
||||
export interface CompactionReadyOperation extends CompactionScope, SummaryGenerationReady {
|
||||
at: "compaction.ready";
|
||||
}
|
||||
|
||||
export interface CompactionEffectPendingOperation extends CompactionScope, SummaryGenerationEffectPending {
|
||||
at: "compaction.effect_pending";
|
||||
}
|
||||
|
||||
export interface CompactionRetryWaitOperation extends CompactionScope, SummaryGenerationRetryWait {
|
||||
at: "compaction.retry_wait";
|
||||
}
|
||||
|
||||
export interface NavigationReadyToCommitOperation extends NavigationScope {
|
||||
export interface NavigationReadyToCommitOperation extends OperationScope {
|
||||
at: "navigation.ready_to_commit";
|
||||
/** Unsummarized navigation may target the branch root (null). */
|
||||
targetId: string | null;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface NavigationSummaryDecidingOperation extends NavigationSummaryScope, StructuralTask {
|
||||
at: "navigation.summary.deciding";
|
||||
}
|
||||
|
||||
export interface NavigationSummaryReadyOperation extends NavigationSummaryScope, SummaryGenerationReady {
|
||||
at: "navigation.summary.ready";
|
||||
}
|
||||
|
||||
export interface NavigationSummaryEffectPendingOperation
|
||||
extends NavigationSummaryScope,
|
||||
SummaryGenerationEffectPending {
|
||||
at: "navigation.summary.effect_pending";
|
||||
}
|
||||
|
||||
export interface NavigationSummaryRetryWaitOperation extends NavigationSummaryScope, SummaryGenerationRetryWait {
|
||||
at: "navigation.summary.retry_wait";
|
||||
}
|
||||
|
||||
/** Flat durable operation state: exactly one leaf per dispatcher target. */
|
||||
/** Flat durable operation state: exactly 13 family-neutral dispatcher leaves. */
|
||||
export type OperationState =
|
||||
| RunStartingOperation
|
||||
| RunCheckpointOperation
|
||||
| RunAssistantReadyOperation
|
||||
| RunAssistantEffectPendingOperation
|
||||
| RunAssistantRetryWaitOperation
|
||||
| RunToolsOperation
|
||||
| RunDeferredSuspendedOperation
|
||||
| RunDeferredEffectPendingOperation
|
||||
| RunCompactionDecidingOperation
|
||||
| RunCompactionReadyOperation
|
||||
| RunCompactionEffectPendingOperation
|
||||
| RunCompactionRetryWaitOperation
|
||||
| RunFailureDrainOperation
|
||||
| CompactionDecidingOperation
|
||||
| CompactionReadyOperation
|
||||
| CompactionEffectPendingOperation
|
||||
| CompactionRetryWaitOperation
|
||||
| NavigationReadyToCommitOperation
|
||||
| NavigationSummaryDecidingOperation
|
||||
| NavigationSummaryReadyOperation
|
||||
| NavigationSummaryEffectPendingOperation
|
||||
| NavigationSummaryRetryWaitOperation;
|
||||
| StartingOperation
|
||||
| CheckpointOperation
|
||||
| AssistantReadyOperation
|
||||
| AssistantEffectPendingOperation
|
||||
| AssistantRetryWaitOperation
|
||||
| ToolsOperation
|
||||
| DeferredSuspendedOperation
|
||||
| DeferredEffectPendingOperation
|
||||
| SummaryDecidingOperation
|
||||
| SummaryReadyOperation
|
||||
| SummaryEffectPendingOperation
|
||||
| SummaryRetryWaitOperation
|
||||
| NavigationReadyToCommitOperation;
|
||||
|
||||
export type OperationAt = OperationState["at"];
|
||||
|
||||
export type RunOperationState = Extract<OperationState, { at: `run.${string}` }>;
|
||||
export type CompactionOperationState = Extract<OperationState, { at: `compaction.${string}` }>;
|
||||
export type NavigationOperationState = Extract<OperationState, { at: `navigation.${string}` }>;
|
||||
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -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<string | null>("pi.branch.tip
|
|||
export const branchTipInventoryPrefix = () => value<string | null>("pi.branch.tip");
|
||||
export const laneConfig = (lane: string) => value<LaneConfiguration>("pi.lane.config", lane);
|
||||
export const laneState = (lane: string) => value<LaneState>("pi.lane.state", lane);
|
||||
export const laneLastResult = (lane: string) => value<LaneLastResult>("pi.lane.lastResult", lane);
|
||||
export const operationResult = (operationId: string) => value<OperationResultRecord>("pi.result", operationId);
|
||||
|
||||
export const operationMeta = (operationId: string) => value<OperationMeta>("pi.op.meta", operationId);
|
||||
export const operationState = (operationId: string) => value<OperationState>("pi.op.state", operationId);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<void>,
|
||||
resources: Parameters<typeof createAgentHarness>[0]["resources"] = {},
|
||||
queueModes: { steeringMode?: "all" | "one-at-a-time"; followUpMode?: "all" | "one-at-a-time" } = {},
|
||||
): Promise<{
|
||||
harness: Harness<object | undefined>;
|
||||
lane: Lane<object | undefined>;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<undefined>): RunOperationState {
|
||||
function currentRun(lane: Lane<undefined>): 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<undefined>): RunAssistantReadyOperation {
|
||||
function readyGeneration(lane: Lane<undefined>): 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<RunAssistantReadyOperation> {
|
||||
async function advanceToReady(fixture: Fixture): Promise<AssistantReadyOperation> {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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<RunAssistantReadyOperation> {
|
||||
async function advanceToReady(fixture: Fixture): Promise<AssistantReadyOperation> {
|
||||
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<RunAssistantReadyOperat
|
|||
async function replaceRunState(fixture: Fixture, nextState: OperationState, extraWrites: Write[] = []): Promise<void> {
|
||||
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<RunDeferredSuspendedOperation> {
|
||||
): Promise<DeferredSuspendedOperation> {
|
||||
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<RunDeferredEffectPendingOperation> {
|
||||
const effectPending: RunDeferredEffectPendingOperation = {
|
||||
...runScopeOf(currentRun(fixture)),
|
||||
at: "run.deferred.effect_pending",
|
||||
suspended: DeferredSuspendedOperation,
|
||||
): Promise<DeferredEffectPendingOperation> {
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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<T>(): WatchHandle<T> {
|
|||
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<CheckpointOperation, keyof OperationScope | "at">,
|
||||
): 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<DurableStructuralPreparation, { kind: "compaction" }>> = {},
|
||||
): Extract<DurableStructuralPreparation, { kind: "compaction" }> {
|
||||
|
|
@ -123,7 +162,8 @@ async function createFixture(): Promise<Fixture> {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<undefined>["toolContext"];
|
||||
cancelled?: boolean;
|
||||
|
|
@ -141,8 +140,8 @@ async function createFixture(options: FixtureOptions): Promise<Fixture> {
|
|||
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<Fixture> {
|
|||
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<Fixture> {
|
|||
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<Fixture> {
|
|||
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: [] },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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<undefined>(
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<Session> {
|
|||
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<Session> {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -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<Control["status"]>().toEqualTypeOf<"running" | "cancel_requested">();
|
||||
expectTypeOf<ToolCall["status"]>().toEqualTypeOf<"planned" | "effect_pending" | "outcome_ready" | "completed">();
|
||||
expectTypeOf<OperationAt>().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<FailureProvenance["kind"]>().toEqualTypeOf<"response" | "structural" | "configuration">();
|
||||
expectTypeOf<InboxItem["kind"]>().toEqualTypeOf<"steer" | "followUp" | "nextRun" | "write">();
|
||||
expectTypeOf<TerminalStatus>().toEqualTypeOf<"completed" | "declined" | "aborted" | "failed">();
|
||||
expectTypeOf<NewEntry["type"]>().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<LaneSnapshot["operation"]>().not.toEqualTypeOf<SessionSnapshot>();
|
||||
expectTypeOf<AgentLane["getLastResult"]>().returns.toEqualTypeOf<Promise<LaneLastResult | undefined>>();
|
||||
expectTypeOf<AgentLane["getResult"]>().returns.toEqualTypeOf<Promise<OperationResultRecord | undefined>>();
|
||||
expectTypeOf<SuspendedRun>().toEqualTypeOf<{
|
||||
operationId: string;
|
||||
status: "suspended";
|
||||
deferred: DeferredHandle;
|
||||
}>();
|
||||
expectTypeOf<AgentLane["accept"]>().returns.toEqualTypeOf<Promise<OperationAdmissionResult>>();
|
||||
expectTypeOf<AgentLane["drive"]>().returns.toEqualTypeOf<Promise<DriveResult>>();
|
||||
expectTypeOf<keyof DriveOptions>().toEqualTypeOf<"operationId" | "waitForRetry" | "pollDeferred">();
|
||||
|
|
|
|||
|
|
@ -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<Value<LaneConfiguration>>();
|
||||
expectTypeOf(branchTip("review")).toEqualTypeOf<Value<string | null>>();
|
||||
expectTypeOf(laneState("review")).toEqualTypeOf<Value<LaneState>>();
|
||||
expectTypeOf(laneLastResult("review")).toEqualTypeOf<Value<LaneLastResult>>();
|
||||
expectTypeOf(operationResult("operation")).toEqualTypeOf<Value<OperationResultRecord>>();
|
||||
expectTypeOf(meta).toEqualTypeOf<Value<OperationMeta>>();
|
||||
expectTypeOf(state).toEqualTypeOf<Value<OperationState>>();
|
||||
expectTypeOf(operationToolArgs("operation", "step", 2)).toEqualTypeOf<Value<Record<string, JsonValue>>>();
|
||||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -248,58 +248,30 @@ function toHarnessDeferred(deferred: DeferredHandle): DeferredHandle {
|
|||
}
|
||||
|
||||
function toWireRunValue(value: Extract<HarnessRunResult, { ok: true }>["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<HarnessRunResult, { ok: false }>["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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue