From 89356540fb7318e04e9599a1bc742f5e8f358fe2 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 26 Aug 2026 21:14:59 +0200 Subject: [PATCH] feat(agent): expose durable lane operations --- packages/agent/docs/harness.md | 3 +- .../work-packages/05-direct-durable-drive.md | 32 +- packages/agent/src/harness/agent-harness.ts | 73 +- packages/agent/src/harness/events.ts | 25 +- .../src/harness/runtime/drive/boundary.ts | 31 +- .../src/harness/runtime/drive/checkpoint.ts | 8 +- .../src/harness/runtime/drive/deferred.ts | 6 + .../src/harness/runtime/drive/generation.ts | 1 + .../src/harness/runtime/drive/reconcile.ts | 4 + .../src/harness/runtime/drive/response.ts | 5 + .../src/harness/runtime/drive/structural.ts | 29 +- packages/agent/src/harness/runtime/harness.ts | 59 +- packages/agent/src/harness/runtime/lane.ts | 1389 +++++++++++++---- packages/agent/src/harness/runtime/reducer.ts | 193 +++ .../agent/src/harness/runtime/transcript.ts | 32 +- packages/agent/src/harness/runtime/types.ts | 1 + packages/agent/src/harness/session/memory.ts | 1 + packages/agent/src/harness/session/session.ts | 5 + packages/agent/src/harness/session/types.ts | 1 + packages/agent/src/index.ts | 1 + .../test/harness/execution-primitives.test.ts | 46 +- .../agent/test/harness/runtime/accept.test.ts | 157 ++ .../test/harness/runtime/drive-public.test.ts | 600 +++++++ .../harness/runtime/drive-structural.test.ts | 5 +- .../test/harness/runtime/harness.test.ts | 65 +- .../agent/test/harness/runtime/lane.test.ts | 11 +- .../test/harness/runtime/reducer.test.ts | 205 +++ .../agent/test/harness/runtime/watch.test.ts | 15 +- packages/agent/test/harness/types.test.ts | 1 - packages/client/src/client.ts | 13 +- packages/client/src/types.ts | 1 + packages/client/test/client.test.ts | 68 +- .../src/experimental/harness-wire-adapter.ts | 157 +- .../experimental/services/models-provider.ts | 2 +- .../experimental/session-worker-manager.ts | 6 + .../src/experimental/session-worker.ts | 11 +- packages/coding-agent/src/main.ts | 2 +- .../experimental-harness-wire-adapter.test.ts | 11 +- .../test/experimental-remote-runtime.test.ts | 20 +- ...xperimental-session-worker-manager.test.ts | 17 +- packages/protocol/src/harness.ts | 161 +- packages/protocol/src/protocol.ts | 5 + packages/protocol/test/protocol.test.ts | 52 +- packages/server/src/session-router.ts | 14 + packages/server/src/testing/client.ts | 3 +- packages/server/src/testing/host.ts | 25 +- packages/server/src/types.ts | 1 + packages/server/test/conformance.test.ts | 7 + .../sqlite-node/src/sqlite/session.ts | 1 + 49 files changed, 3047 insertions(+), 534 deletions(-) create mode 100644 packages/agent/src/harness/runtime/reducer.ts create mode 100644 packages/agent/test/harness/runtime/drive-public.test.ts create mode 100644 packages/agent/test/harness/runtime/reducer.test.ts diff --git a/packages/agent/docs/harness.md b/packages/agent/docs/harness.md index e4a1bbfe1..d9be2270f 100644 --- a/packages/agent/docs/harness.md +++ b/packages/agent/docs/harness.md @@ -3054,13 +3054,14 @@ The request function, not this block, owns registry dispatch, auth, and operatio const admittedContext = withAbortSignal(drive.gate.signal, context); return drive.gate.admit(() => models.streamSimple(model, aiContext, { ...options, + sessionId: `${session.metadata.id}:${lane.name}`, signal: admittedContext.abortSignal, telemetryContext: admittedContext.telemetryContext, })); } ``` -There is no yield between `Gate.admit` checking admission and invoking `Models.streamSimple()`. Its asynchronous auth/lazy/provider work is part of the admitted request and owns `admittedContext.abortSignal` (§4.2). A captured identity that disappears after intent becomes an in-band provider error under the reserved response/usage ids. An identity unavailable before intent becomes a non-retryable configuration failure with no fabricated response or usage (§3.7, §4.5). Existing summary helpers keep their separate `Models`-based generation logic, but gate their `Models` operation invocation the same way. +There is no yield between `Gate.admit` checking admission and invoking `Models.streamSimple()`. Its asynchronous auth/lazy/provider work is part of the admitted request and owns `admittedContext.abortSignal` (§4.2). Ordinary assistant requests derive one stable provider cache/affinity identity as `Session metadata id + ":" + lane name`; lanes in one Session therefore never share it. Context-prefix changes may miss old cache entries but cannot incorrectly reuse them, so no durable lineage or rotation state exists. Structural summary requests retain fresh request identities with `cacheRetention: "none"`; deferred handle polling sends no cache identity. A captured identity that disappears after intent becomes an in-band provider error under the reserved response/usage ids. An identity unavailable before intent becomes a non-retryable configuration failure with no fabricated response or usage (§3.7, §4.5). Existing summary helpers keep their separate `Models`-based generation logic, but gate their `Models` operation invocation the same way. ### Tool phases diff --git a/packages/agent/docs/work-packages/05-direct-durable-drive.md b/packages/agent/docs/work-packages/05-direct-durable-drive.md index 410d3ef72..22e75626f 100644 --- a/packages/agent/docs/work-packages/05-direct-durable-drive.md +++ b/packages/agent/docs/work-packages/05-direct-durable-drive.md @@ -502,8 +502,8 @@ Audit and fix the provider-facing cache/affinity identity before public drive is - Inventory every harness and pi-ai path that sets, preserves, derives, or consumes `SimpleStreamOptions.sessionId`, including prompt-cache keys, affinity headers, WebSocket/session-resource caches, deferred requests, and structural summary requests. Current facts: no harness runtime path forwards a session id today; the sole forwarder is the legacy `src/agent.ts:451`, and `src/harness/compaction/compaction.ts:123` already mints a fresh id per request — M10 therefore establishes the lineage policy rather than preserving an existing one. - Define a provider-request cache lineage distinct from the durable Session id and operation id. Concurrent lanes must never share one lineage merely because they belong to the same Session. -- Decide whether lineage is derived or durably stored. It must remain stable while one lane's provider context only appends at the tail, and rotate whenever that assumption breaks: compaction, navigation, branch replacement, incompatible model/provider changes, or any other context-prefix discontinuity. -- Preserve useful same-lane reuse across ordinary assistant turns and retries without allowing a stale lineage to cross a context reset. +- Derive ordinary assistant lineage as `Session metadata id + ":" + lane name`; do not store another durable identifier. It remains stable for the lane's lifetime and differs across lanes in one Session. +- Preserve same-lane reuse across ordinary assistant turns and retries. Compaction, navigation, branch replacement, and model changes may miss an old prefix, but cannot incorrectly reuse it; do not add rotation machinery. - Keep structural summary requests isolated: `cacheRetention: "none"` and fresh request identity per nested request remain the baseline unless the review proves a safe, useful alternative. - Distinguish cache identity from observability correlation. Session, lane, operation, task, and request ids remain available to telemetry without being reused blindly as provider affinity/cache keys. - Review provider-specific semantics rather than assuming `sessionId` means only KV caching; some adapters also use it for headers, WebSocket reuse, fallback state, or resource cleanup. @@ -512,17 +512,29 @@ Audit and fix the provider-facing cache/affinity identity before public drive is - Two active lanes in one Session issue concurrent, divergent prompts and receive distinct provider cache/affinity identities. - One lane keeps a stable lineage across append-only assistant/tool turns where reuse is valid. -- Compaction, summarized and unsummarized navigation, branch changes, and incompatible model/provider changes rotate lineage before the next request. -- Retry and deferred paths neither collide across lanes nor accidentally defeat intended same-lineage reuse. +- Context-prefix discontinuities safely miss old cached prefixes without lineage rotation. +- Assistant retries retain the lane identity; deferred handle polling needs no cache identity. - Structural split-turn requests remain isolated from transcript assistant caching. -- Close/resource cleanup affects only the intended provider lineage and cannot close another lane's live transport. -- Faux-provider cache tests fail when a raw Session id is shared and pass with the chosen lineage policy; relevant provider adapter tests assert their exact cache/header/resource behavior. +- Faux-provider tests assert stable same-lane and distinct cross-lane request identities. ### Exit condition -No harness request forwards the durable Session id as `SimpleStreamOptions.sessionId` solely because requests belong to that Session. The chosen lineage and rotation rules are documented in `docs/harness.md`, implemented consistently across assistant/deferred/structural paths, and independently reviewed before WP05 completion. +Ordinary assistant requests use the derived lane identity, structural requests keep fresh identities with `cacheRetention: "none"`, and deferred handle polling sends no cache identity. The policy is documented in `docs/harness.md` and independently reviewed before WP05 completion. -## 13. Module boundaries +## 13. M11 — Bound durable streaming-frame volume + +**Tracked follow-up; not part of the M8/M10 public-drive gate.** A trivial mini coding-agent Session produced an approximately 300 KB JSONL file because live assistant streaming persists many `pi.pending.frames` list appends. Logical frame cleanup does not reclaim bytes already appended to the JSONL history, so short conversations can have disproportionate durable storage and replay cost. + +M11 must measure frame count and encoded-byte growth across Memory, JSONL, and SQLite, then bound amplification without weakening the existing crash contract: an admitted assistant effect remains reconstructible, progress observation remains useful, and settlement still removes operation-owned pending-frame state. Investigate coalesced durable checkpoints, bounded frame snapshots plus a short delta tail, and JSONL snapshot-compaction interaction. Do not solve this by dropping unknown-outcome recovery, persisting only the final response, or adding a generic retention framework. + +Exit checks: + +- representative short streamed responses do not create hundreds of kilobytes of durable frame history; +- crash/reopen at every assistant and deferred effect boundary reconstructs the same pending message; +- frame/checkpoint writers remain fenced to the owning durable effect; +- backend conformance covers both reconstruction and bounded write amplification. + +## 14. Module boundaries ```text session/** durable storage; imports no runtime module @@ -537,7 +549,7 @@ runtime/harness.ts Harness lifecycle and Lane composition Procedure modules import concrete `Lane` type-only. `TContext` remains `object | undefined` invariant. No `any`, `Lane`, `as unknown as`, `@ts-expect-error`, inline imports, parameter properties, enums, or other non-erasable TypeScript syntax. -## 14. Exclusions +## 15. Exclusions Do not introduce: @@ -558,7 +570,7 @@ Do not introduce: Procedure-specific writes, effect admission, settlement classification, and event construction remain visible at their call sites. -## 15. Validation and reviews +## 16. Validation and reviews After every code stage: diff --git a/packages/agent/src/harness/agent-harness.ts b/packages/agent/src/harness/agent-harness.ts index a5eb4e414..bf526e59c 100644 --- a/packages/agent/src/harness/agent-harness.ts +++ b/packages/agent/src/harness/agent-harness.ts @@ -54,11 +54,12 @@ import type { BranchScan, Entry, EntryProjector, - EntryType, JsonValue, + LaneConfiguration, OperationError, OperationResultRecord, Session, + SessionStats, SettledAssistantMessage, UsageRow, } from "./session/types.ts"; @@ -94,7 +95,7 @@ export type ResumeResult = Result; export type CancelQueuedResult = Result<{ kind: "cancelled" | "already_consumed" | "not_found" }, Closed>; export type AbortResult = Result< - { runId: string; steer: AgentMessage[]; followUp: AgentMessage[] }, + { operationId: string; steer: AgentMessage[]; followUp: AgentMessage[] }, NoActiveOperation | Closed >; export type RecordUsageResult = Result<{ usageId: string }, Closed>; @@ -178,6 +179,7 @@ export type AbortRequestResult = Result< export interface WatchHandle { snapshot: T; start(listener: EventListener): void; + resnapshot(context: Context): Promise; unsubscribe(): void; } @@ -195,20 +197,27 @@ export interface OpenOperation { aborting?: true; } -export interface QueuedItem { - entryId: string; - message: AgentMessage; -} +export type LaneQueuedItem = + | { + entryId: string; + kind: "steer" | "followUp" | "nextRun" | "write"; + type: "message"; + message: AgentMessage; + } + | { entryId: string; kind: "write"; type: "custom"; customType: string; data?: JsonValue }; export interface LaneSnapshot { lane: string; transcript: Entry[]; tipId: string | null; - lastOperationId: string | null; + lastResult?: OperationResultRecord; + configuration: LaneConfiguration; + stats: SessionStats; operation: null | { id: string; kind: "run" | "compaction" | "navigation"; startedAt: number; + fromTipId: string | null; status: OperationStatus; retry?: { attempt: number; maxAttempts: number; nextAttemptAt: number }; deferred?: { handle: DeferredHandle; poll: number }; @@ -220,14 +229,7 @@ export interface LaneSnapshot { partialResult?: AgentToolResult; }[]; }; - queues: { steer: QueuedItem[]; followUp: QueuedItem[]; nextRun: QueuedItem[] }; - pendingWrites: { - entryId: string; - type: EntryType; - customType?: string; - message?: AgentMessage; - data?: JsonValue; - }[]; + queues: LaneQueuedItem[]; faulted: boolean; } @@ -237,11 +239,11 @@ export interface SessionSnapshot { } export type HarnessEventPayload = - | { type: "run_start"; runId: string } + | { type: "run_start"; runId: string; startedAt: number } | { type: "run_resume"; runId: string } - | { type: "run_suspend"; runId: string; reason: "deferred"; deferred: DeferredHandle } + | { type: "run_suspend"; runId: string; reason: "deferred"; deferred: DeferredHandle; poll: number } | { type: "operation_abort"; operationId: string; steer: AgentMessage[]; followUp: AgentMessage[] } - | ({ type: "run_end"; runId: string; fromTipId: string | null; tipId: string | null } & ( + | ({ type: "run_end"; runId: string; fromTipId: string | null; tipId: string | null; endedAt: number } & ( | { status: "completed" | "aborted"; error?: never } | { status: "failed"; error: OperationError } )) @@ -265,6 +267,7 @@ export type HarnessEventPayload = attempt: number; maxAttempts: number; delayMs: number; + notBefore: number; errorMessage: string; } | { type: "retry_start"; runId: string; step: string; attempt: number } @@ -312,8 +315,7 @@ export type HarnessEventPayload = terminate: boolean; } | { type: "entry_added"; entry: Entry } - | { type: "write_pending"; runId: string; entryId: string; entryType: EntryType } - | { type: "queue_update"; steer: QueuedItem[]; followUp: QueuedItem[]; nextRun: QueuedItem[] } + | { type: "queue_update"; queues: LaneQueuedItem[] } | ({ type: "value_update" } & ( | { value: "session_name"; name: string | undefined } | { value: "entry_label"; targetId: string; label: string | undefined } @@ -326,25 +328,30 @@ export type HarnessEventPayload = } | { property: "thinkingLevel"; value: ThinkingLevel; previous: ThinkingLevel } | { property: "activeTools"; value: string[]; previous: string[] } + | { property: "tools" | "resources" } | { - property: - | "tools" - | "resources" - | "streamOptions" - | "retryPolicy" - | "compactionSettings" - | "steeringMode" - | "followUpMode"; + property: "streamOptions"; + value: AgentHarnessStreamOptions; + previous: AgentHarnessStreamOptions; } + | { property: "retryPolicy"; value: RetryPolicy; previous: RetryPolicy } + | { property: "compactionSettings"; value: CompactionSettings; previous: CompactionSettings } + | { property: "steeringMode"; value: QueueMode; previous: QueueMode } + | { property: "followUpMode"; value: QueueMode; previous: QueueMode } )) - | { type: "compaction_start"; runId: string; reason: "manual" | "threshold" | "overflow" } - | ({ type: "compaction_end"; runId: string; reason: "manual" | "threshold" | "overflow" } & ( + | { + type: "compaction_start"; + runId: string; + reason: "manual" | "threshold" | "overflow"; + startedAt: number; + } + | ({ type: "compaction_end"; runId: string; reason: "manual" | "threshold" | "overflow"; endedAt: number } & ( | { 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; fromTipId: string | null; tipId: string | null } & ( + | { type: "navigation_start"; runId: string; targetId: string | null; startedAt: number } + | ({ type: "navigation_end"; runId: string; fromTipId: string | null; tipId: string | null; endedAt: number } & ( | { status: "completed" | "declined" | "aborted"; error?: never } | { status: "failed"; error: OperationError } )) @@ -536,7 +543,7 @@ export interface AgentLane { waitForIdle(context: Context): Promise; runWhenIdle(callback: (context: Context) => void | Promise, context: Context): Promise; getModel(context: Context): Promise | undefined>; - setModel(model: Model, context: Context): Promise; + setModel(model: ModelIdentity, context: Context): Promise; getThinkingLevel(context: Context): Promise; setThinkingLevel(level: ThinkingLevel, context: Context): Promise; getActiveTools(context: Context): Promise; diff --git a/packages/agent/src/harness/events.ts b/packages/agent/src/harness/events.ts index 0e975fcc6..c702dea25 100644 --- a/packages/agent/src/harness/events.ts +++ b/packages/agent/src/harness/events.ts @@ -44,9 +44,14 @@ export class HarnessEventBus implements Events { return delivery; } - watch(snapshot: T, filter: (event: HarnessEvent) => boolean, _context: Context): WatchHandle { + watch( + snapshot: T, + filter: (event: HarnessEvent) => boolean, + _context: Context, + resnapshot?: (context: Context) => Promise, + ): WatchHandle { if (this.closedError !== undefined) throw this.closedError; - return this.installWatcher(snapshot, filter); + return this.installWatcher(snapshot, filter, resnapshot); } async watchFromSnapshot( @@ -55,7 +60,7 @@ export class HarnessEventBus implements Events { context: Context, ): Promise> { if (this.closedError !== undefined) throw this.closedError; - const watcher = this.installWatcher(undefined, filter); + const watcher = this.installWatcher(undefined, filter, capture); try { watcher.setSnapshot(await capture(context)); return watcher; @@ -76,8 +81,9 @@ export class HarnessEventBus implements Events { private installWatcher( snapshot: T | undefined, filter: (event: HarnessEvent) => boolean, + resnapshot: ((context: Context) => Promise) | undefined, ): BufferedEventWatcher { - const watcher = new BufferedEventWatcher(snapshot, async (error, event, context) => { + const watcher = new BufferedEventWatcher(snapshot, resnapshot, async (error, event, context) => { if (event.type === "handler_error") return; const normalized = error instanceof Error ? error : new Error(String(error)); const lane = "lane" in event && typeof event.lane === "string" ? event.lane : undefined; @@ -134,6 +140,7 @@ export class HarnessEventBus implements Events { class BufferedEventWatcher implements WatchHandle { snapshot: T; + private readonly resnapshotCallback: ((context: Context) => Promise) | undefined; private readonly onError: (error: unknown, event: HarnessEvent, context: Context) => void | Promise; private buffer: Array<{ event: HarnessEvent; context: Context }> = []; private listener: EventListener | undefined; @@ -143,9 +150,11 @@ class BufferedEventWatcher implements WatchHandle { constructor( snapshot: T | undefined, + resnapshot: ((context: Context) => Promise) | undefined, onError: (error: unknown, event: HarnessEvent, context: Context) => void | Promise, ) { this.snapshot = snapshot as T; + this.resnapshotCallback = resnapshot; this.onError = onError; } @@ -162,6 +171,14 @@ class BufferedEventWatcher implements WatchHandle { for (const bufferedEvent of buffered) this.enqueue(bufferedEvent.event, bufferedEvent.context); } + async resnapshot(context: Context): Promise { + if (this.state === "unsubscribed") throw new Error("WatchHandle is unsubscribed"); + if (this.resnapshotCallback === undefined) throw new Error("WatchHandle does not support resnapshot"); + const snapshot = await this.resnapshotCallback(context); + this.snapshot = snapshot; + return snapshot; + } + unsubscribe(): void { if (this.state === "unsubscribed") return; this.state = "unsubscribed"; diff --git a/packages/agent/src/harness/runtime/drive/boundary.ts b/packages/agent/src/harness/runtime/drive/boundary.ts index 5bd8202ce..952f8c3f8 100644 --- a/packages/agent/src/harness/runtime/drive/boundary.ts +++ b/packages/agent/src/harness/runtime/drive/boundary.ts @@ -1,5 +1,4 @@ -import type { AgentMessage } from "../../../types.ts"; -import type { HarnessEvent } from "../../agent-harness.ts"; +import type { HarnessEvent, LaneQueuedItem } from "../../agent-harness.ts"; import { insertEntry } from "../../session/commit.ts"; import { SessionInvariantError } from "../../session/session.ts"; import type { @@ -17,16 +16,10 @@ import type { } from "../../session/types.ts"; import { branchTip, deleteValue, pendingEntry, setValue } from "../../session/values.ts"; import type { Lane } from "../lane.ts"; -import { committedEntryEvents, entryLifecycleEvents, readBoundedContext, readPendingMessages } from "../transcript.ts"; +import { committedEntryEvents, entryLifecycleEvents, readBoundedContext, readLaneQueues } from "../transcript.ts"; import type { Drive, LaneState, ProcedureResult } from "../types.ts"; import { operationCleanupWrites, operationResultRecord } from "./terminal.ts"; -interface QueueSnapshot { - steer: Array<{ entryId: string; message: AgentMessage }>; - followUp: Array<{ entryId: string; message: AgentMessage }>; - nextRun: Array<{ entryId: string; message: AgentMessage }>; -} - export interface BoundaryFinishPending { kind: "finish_pending"; entryIds: string[]; @@ -38,7 +31,7 @@ export interface BoundaryPlacement { tipId: string | null; inbox: LaneState["inbox"]; triggerEntryId?: string; - queue?: QueueSnapshot; + queues?: LaneQueuedItem[]; } type FinishBoundaryOperation = @@ -136,18 +129,7 @@ export async function planBoundaryInbox( }); const selectedIds = new Set(selected.map((item) => item.entryId)); const inbox = state.inbox.filter((item) => !selectedIds.has(item.entryId)); - const queue = selected.some((item) => item.kind === "steer" || item.kind === "followUp") - ? await (async () => { - const remaining = (kind: "steer" | "followUp" | "nextRun") => - inbox.filter((item) => item.kind === kind).map((item) => item.entryId); - const [remainingSteer, remainingFollowUp, nextRun] = await Promise.all([ - readPendingMessages(reader, remaining("steer"), "Steer entry", drive.context), - readPendingMessages(reader, remaining("followUp"), "Follow-up entry", drive.context), - readPendingMessages(reader, remaining("nextRun"), "Pending next-run entry", drive.context), - ]); - return { steer: remainingSteer, followUp: remainingFollowUp, nextRun }; - })() - : undefined; + const queues = selected.length === 0 ? undefined : await readLaneQueues(reader, inbox, drive.context); return { entries, writes: [ @@ -158,7 +140,7 @@ export async function planBoundaryInbox( tipId: parentId, inbox, ...(triggerEntryId === undefined ? {} : { triggerEntryId }), - ...(queue === undefined ? {} : { queue }), + ...(queues === undefined ? {} : { queues }), }; } @@ -171,7 +153,7 @@ export function boundaryPlacementEvents( ): HarnessEvent[] { return [ ...committedEntryEvents(placement.entries, commit, lane, runId, firstWriteIndex), - ...(placement.queue === undefined ? [] : [{ type: "queue_update" as const, lane, ...placement.queue }]), + ...(placement.queues === undefined ? [] : [{ type: "queue_update" as const, lane, queues: placement.queues }]), ]; } @@ -266,6 +248,7 @@ export async function finishRunBoundary( materialize: () => ({ kind: "continue" }) as const, events: (commit) => [ ...boundaryPlacementEvents(placement, commit, 0, lane.name, drive.operationId), - { type: "compaction_start", lane: lane.name, runId: drive.operationId, reason: "threshold" }, + { + type: "compaction_start", + lane: lane.name, + runId: drive.operationId, + reason: "threshold", + startedAt: commit.timestamp, + }, ], }; } diff --git a/packages/agent/src/harness/runtime/drive/deferred.ts b/packages/agent/src/harness/runtime/drive/deferred.ts index 3b4240b8c..00cb8679b 100644 --- a/packages/agent/src/harness/runtime/drive/deferred.ts +++ b/packages/agent/src/harness/runtime/drive/deferred.ts @@ -162,6 +162,12 @@ async function publishPollIntent( return nextState; }, events: () => [ + { + type: "run_resume", + lane: lane.name, + runId: drive.operationId, + ...(recovery ? { recovery: true as const } : {}), + }, { type: "turn_start", lane: lane.name, diff --git a/packages/agent/src/harness/runtime/drive/generation.ts b/packages/agent/src/harness/runtime/drive/generation.ts index 13260550c..ef95da0dd 100644 --- a/packages/agent/src/harness/runtime/drive/generation.ts +++ b/packages/agent/src/harness/runtime/drive/generation.ts @@ -215,6 +215,7 @@ async function performGeneration( return drive.gate.admit(() => lane.models.streamSimple(prepared.model, aiContext, { ...options, + sessionId: `${lane.session.metadata.id}:${lane.name}`, signal: admitted.abortSignal, telemetryContext: admitted.telemetryContext, }), diff --git a/packages/agent/src/harness/runtime/drive/reconcile.ts b/packages/agent/src/harness/runtime/drive/reconcile.ts index 28ba8ad01..0d012b45e 100644 --- a/packages/agent/src/harness/runtime/drive/reconcile.ts +++ b/packages/agent/src/harness/runtime/drive/reconcile.ts @@ -80,6 +80,7 @@ async function publishAbortedTerminal( runId: drive.operationId, reason: current.task.reason, status: "aborted", + endedAt: record.endedAt, }); break; default: @@ -92,6 +93,7 @@ async function publishAbortedTerminal( status: "aborted", fromTipId: meta.sourceTipId, tipId: state.tipId, + endedAt: record.endedAt, }); } else if (meta.intent.kind === "compaction") { events.push({ @@ -100,6 +102,7 @@ async function publishAbortedTerminal( runId: drive.operationId, reason: "manual", status: "aborted", + endedAt: record.endedAt, }); } else { events.push({ @@ -109,6 +112,7 @@ async function publishAbortedTerminal( status: "aborted", fromTipId: meta.sourceTipId, tipId: state.tipId, + endedAt: record.endedAt, }); } return { diff --git a/packages/agent/src/harness/runtime/drive/response.ts b/packages/agent/src/harness/runtime/drive/response.ts index 67d5f4c3e..789d4d479 100644 --- a/packages/agent/src/harness/runtime/drive/response.ts +++ b/packages/agent/src/harness/runtime/drive/response.ts @@ -126,6 +126,7 @@ export async function publishConfigurationFailure< error, fromTipId: meta.sourceTipId, tipId: state.tipId, + endedAt: record.endedAt, }, ], }; @@ -395,6 +396,7 @@ export async function publishResponse( attempt: settled.nextAttempt, maxAttempts: settled.generationContext.retryPolicy.maxAttempts, delayMs: retryDelay(current.generationContext.retryPolicy.baseDelayMs, current.attempt), + notBefore: settled.notBefore, errorMessage: settled.errorMessage, }); } @@ -414,6 +416,7 @@ export async function publishResponse( lane: lane.name, runId: drive.operationId, reason: "overflow", + startedAt: commit.timestamp, }); } } else if (settled?.at !== "tools") { @@ -438,6 +441,7 @@ export async function publishResponse( runId: drive.operationId, reason: "deferred", deferred: committed.deferred, + poll: settled.poll, ...(current.at === "deferred.effect_pending" ? options : {}), }); } @@ -450,6 +454,7 @@ export async function publishResponse( error: failure, fromTipId: meta.sourceTipId, tipId: responseEntryId, + endedAt: record.endedAt, }); } return batch; diff --git a/packages/agent/src/harness/runtime/drive/structural.ts b/packages/agent/src/harness/runtime/drive/structural.ts index 4edab345c..4db644e86 100644 --- a/packages/agent/src/harness/runtime/drive/structural.ts +++ b/packages/agent/src/harness/runtime/drive/structural.ts @@ -87,6 +87,17 @@ export function durableCompactionPreparation( }; } +export function durableBranchPreparation( + preparation: BranchPreparation, +): Extract { + return { + kind: "branch_summary", + messages: preparation.messages, + fileOps: durableFileOperations(preparation.fileOps), + totalTokens: preparation.totalTokens, + }; +} + function fileOperations(fileOps: DurableFileOperations): CompactionPreparation["fileOps"] { return { read: new Set(fileOps.read), @@ -226,6 +237,7 @@ async function publishStructuralOutcome( capability, async (state, current, meta, reader) => { const expected = summaryKind(current.task); + let terminalCompactionEndedAt: number | undefined; if ((outcome.kind === "compaction" || outcome.kind === "branch_summary") && outcome.kind !== expected) { throw new SessionInvariantError( `Structural ${outcome.kind} result does not match ${expected} task ${current.task.taskId}`, @@ -304,7 +316,7 @@ async function publishStructuralOutcome( ]); } if (outcome.kind === "compaction") { - baseEvents.push(() => [ + baseEvents.push((commit) => [ { type: "compaction_end", lane: lane.name, @@ -312,6 +324,7 @@ async function publishStructuralOutcome( reason: compactionReason(current.task), status: "completed", entryId: outcome.resultEntryId, + endedAt: terminalCompactionEndedAt ?? commit.timestamp, }, ]); } @@ -388,6 +401,7 @@ async function publishStructuralOutcome( runId: drive.operationId, reason: "threshold" as const, status: "declined" as const, + endedAt: commit.timestamp, }, ]), ...boundaryPlacementEvents( @@ -415,6 +429,7 @@ async function publishStructuralOutcome( runId: drive.operationId, reason: compactionReason(current.task), status: "declined", + endedAt: record.endedAt, } : { type: "compaction_end", @@ -423,6 +438,7 @@ async function publishStructuralOutcome( reason: compactionReason(current.task), status: "failed", error: outcome.error, + endedAt: record.endedAt, }; return { kind: "finish", @@ -440,6 +456,7 @@ async function publishStructuralOutcome( error, fromTipId: meta.sourceTipId, tipId: state.tipId, + endedAt: record.endedAt, }, ], }; @@ -456,6 +473,7 @@ async function publishStructuralOutcome( outcome.kind === "declined" ? "declined" : outcome.kind === "failed" ? "failed" : "completed"; const cleanup = await operationCleanupWrites(reader, drive.operationId, current, drive.context); const record = operationResultRecord(meta, status, terminalTipId, error); + terminalCompactionEndedAt = record.endedAt; const compactionEnd: HarnessEvent | undefined = outcome.kind === "compaction" ? undefined @@ -466,6 +484,7 @@ async function publishStructuralOutcome( runId: drive.operationId, reason: "manual", status: "declined", + endedAt: record.endedAt, } : { type: "compaction_end", @@ -474,6 +493,7 @@ async function publishStructuralOutcome( reason: "manual", status: "failed", error: outcome.error, + endedAt: record.endedAt, }; return { kind: "finish", @@ -502,6 +522,7 @@ async function publishStructuralOutcome( status: "completed", fromTipId: meta.sourceTipId, tipId: terminalTipId, + endedAt: record.endedAt, } : outcome.kind === "declined" ? { @@ -511,6 +532,7 @@ async function publishStructuralOutcome( status: "declined", fromTipId: meta.sourceTipId, tipId: terminalTipId, + endedAt: record.endedAt, } : { type: "navigation_end", @@ -520,6 +542,7 @@ async function publishStructuralOutcome( error: outcome.error, fromTipId: meta.sourceTipId, tipId: terminalTipId, + endedAt: record.endedAt, }; return { kind: "finish", @@ -547,6 +570,7 @@ async function publishStructuralOutcome( runId: drive.operationId, reason: "threshold", status: "declined", + endedAt: Date.now(), }, ]); } @@ -969,6 +993,7 @@ async function publishAttemptResult( attempt: retryWait.nextAttempt, maxAttempts: effect.summaryContext.retryPolicy.maxAttempts, delayMs: retryDelay(effect.summaryContext.retryPolicy.baseDelayMs, effect.attempt), + notBefore: retryWait.notBefore, errorMessage: result.error.message, }, ], @@ -1077,6 +1102,7 @@ export async function recoverStructuralGeneration( status: "completed", fromTipId: meta.sourceTipId, tipId: current.targetId, + endedAt: record.endedAt, }, ], }; diff --git a/packages/agent/src/harness/runtime/harness.ts b/packages/agent/src/harness/runtime/harness.ts index 094770c20..633b31632 100644 --- a/packages/agent/src/harness/runtime/harness.ts +++ b/packages/agent/src/harness/runtime/harness.ts @@ -25,8 +25,6 @@ import { Lane } from "./lane.ts"; import { readLaneStorage, restoreLaneState, restoreSession } from "./restore.ts"; import { type Config, type LaneState, SliceNotImplemented } from "./types.ts"; -type GlobalConfigProperty = GlobalConfigEventPayload["property"]; - /** Runtime implementation of AgentHarness. The harness manages lanes but is not itself a lane. */ export class Harness implements AgentHarness { readonly session: Session; @@ -137,7 +135,11 @@ export class Harness implements AgentHarnes const writes = [ ...(stored.kind === "absent" ? [setValue(branchTip(name), tipId)] : []), setValue(laneConfig(name), attachedConfiguration), - setValue(laneState(name), { currentOperationId: null, lastOperationId: null, inbox: [] }), + setValue(laneState(name), { + currentOperationId: null, + lastOperationId: null, + inbox: [], + }), ]; await mutator.commit(writes, context); lane = this.buildLane(name, state); @@ -222,7 +224,7 @@ export class Harness implements AgentHarnes setTools(tools: AgentHarnessTool[], context: Context): Promise { validateToolNames(tools); - return this.setConfig("tools", tools, "tools", context); + return this.setConfig("tools", tools, () => ({ type: "config_update", property: "tools" }), context); } getResources(context: Context): Promise { @@ -230,7 +232,7 @@ export class Harness implements AgentHarnes } setResources(resources: Resources, context: Context): Promise { - return this.setConfig("resources", resources, "resources", context); + return this.setConfig("resources", resources, () => ({ type: "config_update", property: "resources" }), context); } getStreamOptions(context: Context): Promise { @@ -238,7 +240,12 @@ export class Harness implements AgentHarnes } setStreamOptions(options: AgentHarnessStreamOptions, context: Context): Promise { - return this.setConfig("streamOptions", options, "streamOptions", context); + return this.setConfig( + "streamOptions", + options, + (previous, value) => ({ type: "config_update", property: "streamOptions", previous, value }), + context, + ); } getRetryPolicy(context: Context): Promise { @@ -247,7 +254,12 @@ export class Harness implements AgentHarnes setRetryPolicy(policy: RetryPolicy, context: Context): Promise { validateRetryPolicy(policy); - return this.setConfig("retryPolicy", policy, "retryPolicy", context); + return this.setConfig( + "retryPolicy", + policy, + (previous, value) => ({ type: "config_update", property: "retryPolicy", previous, value }), + context, + ); } getCompactionSettings(context: Context): Promise { @@ -256,7 +268,12 @@ export class Harness implements AgentHarnes setCompactionSettings(compaction: CompactionSettings, context: Context): Promise { validateCompactionSettings(compaction); - return this.setConfig("compaction", compaction, "compactionSettings", context); + return this.setConfig( + "compaction", + compaction, + (previous, value) => ({ type: "config_update", property: "compactionSettings", previous, value }), + context, + ); } getSteeringMode(context: Context): Promise { @@ -264,7 +281,12 @@ export class Harness implements AgentHarnes } setSteeringMode(steeringMode: QueueMode, context: Context): Promise { - return this.setConfig("steeringMode", steeringMode, "steeringMode", context); + return this.setConfig( + "steeringMode", + steeringMode, + (previous, value) => ({ type: "config_update", property: "steeringMode", previous, value }), + context, + ); } getFollowUpMode(context: Context): Promise { @@ -272,7 +294,12 @@ export class Harness implements AgentHarnes } setFollowUpMode(followUpMode: QueueMode, context: Context): Promise { - return this.setConfig("followUpMode", followUpMode, "followUpMode", context); + return this.setConfig( + "followUpMode", + followUpMode, + (previous, value) => ({ type: "config_update", property: "followUpMode", previous, value }), + context, + ); } async watchSession(_context: Context): Promise { @@ -296,10 +323,11 @@ export class Harness implements AgentHarnes if (this.closePromise !== undefined) return this.closePromise; const error = new HarnessClosed(); this.closedError = error; - for (const lane of this.lanesByName.values()) lane.seal(error); + const idleCallbacks = [...this.lanesByName.values()].map((lane) => lane.seal(error)); this.hooks.close(error); this.events.close(error); - this.closePromise = this.session.close(context); + const sessionClose = this.session.close(context); + this.closePromise = Promise.all([sessionClose, ...idleCallbacks]).then(() => undefined); return this.closePromise; } @@ -312,7 +340,7 @@ export class Harness implements AgentHarnes state, (cause, context) => this.fault(cause, context), (events, context) => this.events.emitBatch(events, context), - (snapshot, filter, context) => this.events.watch(snapshot, filter, context), + (snapshot, filter, context, resnapshot) => this.events.watch(snapshot, filter, context, resnapshot), () => this.configStore.value, ); } @@ -328,12 +356,13 @@ export class Harness implements AgentHarnes private async setConfig>( key: TKey, value: Config[TKey], - property: GlobalConfigProperty, + event: (previous: Config[TKey], value: Config[TKey]) => GlobalConfigEventPayload, context: Context, ): Promise { this.assertOpen(); + const previous = this.configStore.value[key]; this.configStore.value = { ...this.configStore.value, [key]: value }; - await this.events.emit({ type: "config_update", property }, context); + await this.events.emit(event(previous, value), context); } private assertOpen(): void { diff --git a/packages/agent/src/harness/runtime/lane.ts b/packages/agent/src/harness/runtime/lane.ts index c8f42024f..14036d86b 100644 --- a/packages/agent/src/harness/runtime/lane.ts +++ b/packages/agent/src/harness/runtime/lane.ts @@ -4,34 +4,55 @@ import { type Model, type Models, reduceAssistantMessageFrames, + type Usage, } from "@earendil-works/pi-ai"; import type { AgentMessage, ThinkingLevel } from "../../types.ts"; import type { AbortRequestResult, + AbortResult, AgentLane, + CancelQueuedResult, + CompactionResult, + DriveOptions, + DriveResult, HarnessEvent, LaneConfigEventPayload, LaneExecutionInfo, LaneSnapshot, ModelIdentity, + NavigationResult, + OperationAdmission, OperationAdmissionResult, OperationRequest, + QueueResult, + RecordUsageResult, + ResumeResult, + RunResult, + SuspendedRun, WatchHandle, } from "../agent-harness.ts"; -import type { Context } from "../context.ts"; +import { type BranchPreparation, prepareBranchEntries } from "../compaction/branch-summarization.ts"; +import { prepareCompaction } from "../compaction/compaction.ts"; +import { awaitWithContext, type Context } from "../context.ts"; import type { HookRegistry } from "../hooks.ts"; import { formatPromptTemplateInvocation } from "../prompt-templates.ts"; import { Closed, HarnessClosed, + HarnessFault, InvalidMessage, + InvalidNavigation, LaneBusy, + NoActiveOperation, + NothingToCompact, + NothingToResume, OperationMismatch, Result, UnknownSkill, + UnknownTarget, UnknownTemplate, } from "../result.ts"; -import { insertEntry } from "../session/commit.ts"; +import { insertEntry, insertUsage } from "../session/commit.ts"; import { SessionInvariantError, SessionPendingAssistantMessageError } from "../session/session.ts"; import type { BranchScan, @@ -39,15 +60,19 @@ import type { InboxItem, InboxItemKind, JsonValue, + NavigationReadyToCommitOperation, NewEntry, Operation, OperationMeta, OperationResultRecord, OperationState, PendingEntry, + RunSettings, Session, SessionReader, StartingOperation, + SummaryDecidingOperation, + Write, } from "../session/types.ts"; import { branchTip, @@ -55,6 +80,7 @@ import { laneConfig, laneState as laneStateValue, operationMeta as operationMetaValue, + operationPreparation, operationResult as operationResultValue, operationState as operationStateValue, operationToolArgs, @@ -63,25 +89,41 @@ import { setValue, } from "../session/values.ts"; import { formatSkillInvocation } from "../skills.ts"; +import { durableBranchPreparation, durableCompactionPreparation } from "./drive/structural.ts"; +import { driveOperation } from "./drive.ts"; import { readAssistantFrames } from "./progress.ts"; -import { chainEntries, committedEntryEvents, readPendingMessages } from "./transcript.ts"; +import { chainEntries, committedEntryEvents, readLaneQueues } from "./transcript.ts"; import { type Config, type ContinueOperationResult, - type Drive, + Drive, type LaneCommand, type LaneState, type OperationCommand, - SliceNotImplemented, } from "./types.ts"; type EmitBatch = (events: readonly HarnessEvent[], context: Context) => Promise; -type WatchHandler = (snapshot: T, filter: (event: HarnessEvent) => boolean, context: Context) => WatchHandle; +type WatchHandler = ( + snapshot: T, + filter: (event: HarnessEvent) => boolean, + context: Context, + resnapshot: (context: Context) => Promise, +) => WatchHandle; type FaultHandler = (cause: unknown, context: Context) => Error; type LaneCommandOutcome = | { kind: "return"; result: TResult; delivery?: Promise } - | { kind: "reject"; error: Error }; + | { kind: "reject"; error: Error } + | { kind: "idle_blocked"; owner: Promise; change: Promise }; + +type IdleObservation = { kind: "idle" } | { kind: "wait"; drive: Drive | undefined; change: Promise }; +type IdleClaimObservation = { kind: "claimed" } | { kind: "wait"; drive: Drive | undefined; change: Promise }; + +type DriveClaim = + | { kind: "observe"; drive: Drive; installed: boolean } + | { kind: "occupied"; drive: Drive } + | { kind: "settled"; outcome: OperationResultRecord } + | { kind: "mismatch"; error: OperationMismatch }; function isPromiseLike(value: unknown): value is PromiseLike { if (value === null || (typeof value !== "object" && typeof value !== "function")) return false; @@ -123,6 +165,24 @@ function selectAcceptedInbox( return { selected, remainder }; } +function capturedSettings(config: Config): RunSettings { + return { + compaction: config.compaction, + steeringMode: config.steeringMode, + followUpMode: config.followUpMode, + toolExecution: config.toolExecution, + }; +} + +function durableLaneState( + state: LaneState, + currentOperationId: string | null, + inbox: InboxItem[] = state.inbox, + lastOperationId: string | null = state.lastOperationId, +) { + return { currentOperationId, lastOperationId, inbox }; +} + function pendingEntryWrite(entryId: string, pending: PendingEntry): NewEntry { return pending.type === "message" ? { id: entryId, parentId: null, type: "message", message: pending.payload } @@ -166,6 +226,9 @@ export class Lane implements AgentLane { private readonly onFault: FaultHandler; private readonly installWatch: WatchHandler; private readonly config: () => Config; + private stateChange: Promise; + private resolveStateChange: () => void; + private idleOwner: Promise | undefined; /** Package-internal drive owner. Public only because deterministic procedure tests install exact owners directly. */ activeDrive: Drive | undefined; /** Authoritative live control projection while this harness owns the Session. */ @@ -192,6 +255,11 @@ export class Lane implements AgentLane { this.emitBatch = emitBatch; this.installWatch = installWatch; this.config = readConfig; + let resolveStateChange!: () => void; + this.stateChange = new Promise((resolve) => { + resolveStateChange = resolve; + }); + this.resolveStateChange = resolveStateChange; } async getTipId(_context: Context): Promise { @@ -240,10 +308,17 @@ export class Lane implements AgentLane { context: Context, ): Promise { this.assertOpen(); + while (this.idleOwner !== undefined) { + await awaitWithContext(Promise.race([this.idleOwner, this.stateChange]), context); + this.assertOpen(); + } let outcome: LaneCommandOutcome; try { outcome = await this.session.mutate(async (mutator) => { this.assertOpen(); + if (this.idleOwner !== undefined) { + return { kind: "idle_blocked", owner: this.idleOwner, change: this.stateChange }; + } try { const decision = await plan(this.state, mutator); switch (decision.kind) { @@ -254,6 +329,7 @@ export class Lane implements AgentLane { case "commit": { const commit = await mutator.commit(decision.writes, context); this.state = decision.next; + this.signalStateChange(); const result = decision.materialize(commit); if (isPromiseLike(result)) { throw new TypeError("Lane command materialize() must be synchronous"); @@ -272,6 +348,11 @@ export class Lane implements AgentLane { if (this.closedError !== undefined) throw this.closedError; throw error; } + if (outcome.kind === "idle_blocked") { + await awaitWithContext(Promise.race([outcome.owner, outcome.change]), context); + this.assertOpen(); + return this.command(plan, context); + } if (outcome.kind === "reject") throw outcome.error; await outcome.delivery; return outcome.result; @@ -304,11 +385,10 @@ export class Lane implements AgentLane { ...(decision.lane?.inbox === undefined ? [] : [ - setValue(laneStateValue(this.name), { - currentOperationId: operation.meta.operationId, - lastOperationId: state.lastOperationId, - inbox: decision.lane.inbox, - }), + setValue( + laneStateValue(this.name), + durableLaneState(state, operation.meta.operationId, decision.lane.inbox), + ), ]), ], next: { @@ -327,11 +407,7 @@ export class Lane implements AgentLane { writes: [ ...decision.writes, setValue(operationResultValue(operation.meta.operationId), decision.record), - setValue(laneStateValue(this.name), { - currentOperationId: null, - lastOperationId: operation.meta.operationId, - inbox, - }), + setValue(laneStateValue(this.name), durableLaneState(state, null, inbox, operation.meta.operationId)), ], next: { ...state, @@ -384,13 +460,26 @@ export class Lane implements AgentLane { return Result.err(new Closed({ message: this.closedError.message })); } this.assertOpen(); - if (request.kind === "compaction" || request.kind === "navigation") { - throw new SliceNotImplemented(`accept(${request.kind})`); - } - const startedAt = Date.now(); const operationId = request.operationId ?? this.session.idGenerator.next(startedAt); const acceptanceConfig = this.readConfig(); + if (request.kind === "compaction") { + return this.acceptCompaction(request, operationId, startedAt, acceptanceConfig, context); + } + if (request.kind === "navigation") { + return this.acceptNavigation(request, operationId, startedAt, acceptanceConfig, context); + } + + return this.acceptRun(request, operationId, startedAt, acceptanceConfig, context); + } + + private async acceptRun( + request: Extract, + operationId: string, + startedAt: number, + acceptanceConfig: Config, + context: Context, + ): Promise { let messages: AgentMessage[]; switch (request.kind) { case "prompt": @@ -529,34 +618,10 @@ export class Lane implements AgentLane { const operationState: StartingOperation = { at: "starting", control: { status: "running" }, - settings: { - compaction: acceptanceConfig.compaction, - steeringMode: acceptanceConfig.steeringMode, - followUpMode: acceptanceConfig.followUpMode, - toolExecution: acceptanceConfig.toolExecution, - }, + settings: capturedSettings(acceptanceConfig), 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 remainingQueues = await readLaneQueues(reader, inbox, context); const next: LaneState = { ...state, tipId: parentId, @@ -571,27 +636,17 @@ export class Lane implements AgentLane { setValue(branchTip(this.name), parentId), setValue(operationMetaValue(operationId), meta), setValue(operationStateValue(operationId), operationState), - setValue(laneStateValue(this.name), { - currentOperationId: operationId, - lastOperationId: state.lastOperationId, - inbox, - }), + setValue(laneStateValue(this.name), durableLaneState(state, operationId, inbox)), ], next, materialize: () => Result.ok({ operationId, kind: "run", startedAt }), events: (commit) => { const events: HarnessEvent[] = [ - { type: "run_start", runId: operationId, lane: this.name }, + { type: "run_start", runId: operationId, startedAt, lane: this.name }, ...committedEntryEvents(entries, commit, this.name, operationId), ]; - if (hasCapturedConversation) { - events.push({ - type: "queue_update", - steer: remainingSteer, - followUp: remainingFollowUp, - nextRun: remainingNextRun, - lane: this.name, - }); + if (selectedItems.length > 0) { + events.push({ type: "queue_update", queues: remainingQueues, lane: this.name }); } return events; }, @@ -599,8 +654,330 @@ export class Lane implements AgentLane { }, context); } - async drive(..._args: Parameters): Promise { - throw new SliceNotImplemented("drive"); + private acceptCompaction( + request: Extract, + operationId: string, + startedAt: number, + acceptanceConfig: Config, + context: Context, + ): Promise { + const taskId = this.session.idGenerator.next(startedAt); + return this.command(async (state, reader) => { + if (state.operation !== null) { + return { + kind: "return", + result: Result.err( + new LaneBusy({ + lane: this.name, + operationId: state.operation.meta.operationId, + operationKind: state.operation.meta.intent.kind, + message: `Lane ${JSON.stringify(this.name)} already has an active operation`, + }), + ), + }; + } + const path = + state.tipId === null + ? [] + : ( + await reader.scanBranch( + { start: state.tipId, stopAtType: "compaction", order: "newestFirst" }, + context, + ) + ).reverse(); + const prepared = prepareCompaction(path, acceptanceConfig.compaction); + if (!prepared.ok) throw prepared.error; + if (prepared.value === undefined) { + return { + kind: "return", + result: Result.err( + new NothingToCompact({ + lane: this.name, + message: `Lane ${JSON.stringify(this.name)} has nothing to compact`, + }), + ), + }; + } + const meta: OperationMeta = { + operationId, + lane: this.name, + sourceTipId: state.tipId, + startedAt, + intent: { + kind: "compaction", + ...(request.customInstructions === undefined ? {} : { customInstructions: request.customInstructions }), + }, + }; + const operationState: SummaryDecidingOperation = { + at: "summary.deciding", + control: { status: "running" }, + settings: capturedSettings(acceptanceConfig), + latestAssistantEntryId: null, + task: { + taskId, + reason: "manual", + ...(request.customInstructions === undefined ? {} : { customInstructions: request.customInstructions }), + boundary: { kind: "finish" }, + }, + }; + return { + kind: "commit", + writes: [ + setValue(operationPreparation(operationId, taskId), durableCompactionPreparation(prepared.value)), + setValue(operationMetaValue(operationId), meta), + setValue(operationStateValue(operationId), operationState), + setValue(laneStateValue(this.name), durableLaneState(state, operationId)), + ], + next: { ...state, operation: { meta, state: operationState } }, + materialize: () => Result.ok({ operationId, kind: "compaction", startedAt }), + events: () => [ + { type: "compaction_start", lane: this.name, runId: operationId, reason: "manual", startedAt }, + ], + }; + }, context); + } + + private async acceptNavigation( + request: Extract, + operationId: string, + startedAt: number, + acceptanceConfig: Config, + context: Context, + ): Promise { + const taskId = this.session.idGenerator.next(startedAt); + const targetId = request.targetId; + const options = request.options ?? {}; + const summarize = options.summarize ?? false; + for (;;) { + const observedTipId = this.state.tipId; + let preparation: BranchPreparation | undefined; + if (summarize && observedTipId !== null && targetId !== null) { + const target = await this.session.getEntries([targetId], context); + if (target.has(targetId)) { + const [oldPath, targetPath] = await Promise.all([ + this.session.scanBranch({ start: observedTipId, order: "newestFirst" }, context), + this.session.scanBranch({ start: targetId, order: "newestFirst" }, context), + ]); + const oldIds = new Set(oldPath.map((entry) => entry.id)); + const commonAncestorId = targetPath.find((entry) => oldIds.has(entry.id))?.id ?? null; + preparation = prepareBranchEntries( + oldPath + .slice( + 0, + commonAncestorId === null + ? oldPath.length + : oldPath.findIndex((entry) => entry.id === commonAncestorId), + ) + .reverse(), + ); + } + } + const accepted = await this.command(async (state, reader) => { + if (state.operation !== null) { + return { + kind: "return", + result: Result.err( + new LaneBusy({ + lane: this.name, + operationId: state.operation.meta.operationId, + operationKind: state.operation.meta.intent.kind, + message: `Lane ${JSON.stringify(this.name)} already has an active operation`, + }), + ), + }; + } + if (state.tipId !== observedTipId) return { kind: "return", result: undefined }; + if (targetId === state.tipId) { + return { + kind: "return", + result: Result.err( + new InvalidNavigation({ + lane: this.name, + reason: "current_tip", + message: "Navigation target must differ from the current tip", + }), + ), + }; + } + if (targetId === null && options.label !== undefined) { + return { + kind: "return", + result: Result.err( + new InvalidNavigation({ + lane: this.name, + reason: "root_label", + message: "Root navigation cannot set a label", + }), + ), + }; + } + if (summarize && (state.tipId === null || targetId === null)) { + return { + kind: "return", + result: Result.err( + new InvalidNavigation({ + lane: this.name, + reason: state.tipId === null ? "source_root" : "target_root", + message: "Summarized navigation requires non-root source and target entries", + }), + ), + }; + } + if (targetId !== null && !(await reader.getEntries([targetId], context)).has(targetId)) { + return { + kind: "return", + result: Result.err(new UnknownTarget({ targetId, message: `Unknown target: ${targetId}` })), + }; + } + + const intent: OperationMeta["intent"] = { + kind: "navigation", + targetId, + summarize, + ...(options.label === undefined ? {} : { label: options.label }), + ...(options.customInstructions === undefined ? {} : { customInstructions: options.customInstructions }), + }; + const meta: OperationMeta = { + operationId, + lane: this.name, + sourceTipId: state.tipId, + startedAt, + intent, + }; + const operationScope = { + control: { status: "running" as const }, + settings: capturedSettings(acceptanceConfig), + latestAssistantEntryId: null, + }; + let operationState: SummaryDecidingOperation | NavigationReadyToCommitOperation; + const writes: Write[] = []; + if (summarize) { + if (state.tipId === null || targetId === null || preparation === undefined) { + throw new SessionInvariantError("Validated summarized navigation is missing its preparation"); + } + writes.push(setValue(operationPreparation(operationId, taskId), durableBranchPreparation(preparation))); + operationState = { + ...operationScope, + at: "summary.deciding", + task: { + taskId, + ...(options.customInstructions === undefined + ? {} + : { customInstructions: options.customInstructions }), + boundary: { + kind: "commit_navigation", + targetId, + ...(options.label === undefined ? {} : { label: options.label }), + }, + }, + }; + } else { + operationState = { + ...operationScope, + at: "navigation.ready_to_commit", + targetId, + ...(options.label === undefined ? {} : { label: options.label }), + }; + } + writes.push( + setValue(operationMetaValue(operationId), meta), + setValue(operationStateValue(operationId), operationState), + setValue(laneStateValue(this.name), durableLaneState(state, operationId)), + ); + return { + kind: "commit", + writes, + next: { ...state, operation: { meta, state: operationState } }, + materialize: () => Result.ok({ operationId, kind: "navigation", startedAt }), + events: () => [{ type: "navigation_start", lane: this.name, runId: operationId, targetId, startedAt }], + }; + }, context); + if (accepted !== undefined) return accepted; + } + } + + async drive(options: DriveOptions, context: Context): Promise { + if (this.closedError instanceof HarnessClosed) { + return Result.err(new Closed({ message: this.closedError.message })); + } + this.assertOpen(); + + for (;;) { + const claim = await this.command(async (state, reader) => { + const signal = context.abortSignal; + if (signal?.aborted) { + const reason: unknown = signal.reason; + return { + kind: "reject", + error: reason instanceof Error ? reason : new DOMException("The operation was aborted", "AbortError"), + }; + } + if (state.operation?.meta.operationId === options.operationId) { + if (this.activeDrive === undefined) { + const drive = new Drive(options, context); + this.activeDrive = drive; + this.signalStateChange(); + return { kind: "return", result: { kind: "observe", drive, installed: true } }; + } + return { + kind: "return", + result: + this.activeDrive.operationId === options.operationId + ? { kind: "observe", drive: this.activeDrive, installed: false } + : { kind: "occupied", drive: this.activeDrive }, + }; + } + + const stored = await reader.getValue(operationResultValue(options.operationId), context); + return stored === undefined + ? { + kind: "return", + result: { + kind: "mismatch", + error: this.mismatch( + options.operationId, + state.operation?.meta.operationId ?? null, + state.lastOperationId, + ), + }, + } + : { kind: "return", result: { kind: "settled", outcome: stored.value } }; + }, context); + + if (claim.kind === "settled") return Result.ok({ kind: "settled", outcome: claim.outcome }); + if (claim.kind === "mismatch") return Result.err(claim.error); + if (claim.kind === "occupied") { + await awaitWithContext(claim.drive.completion, context); + continue; + } + if (claim.installed) { + void driveOperation(this, claim.drive).then( + (outcome) => { + if (this.activeDrive === claim.drive) { + this.activeDrive = undefined; + this.signalStateChange(); + } + claim.drive.settle(outcome); + }, + (error: unknown) => { + let failure: unknown = this.closedError; + if (failure === undefined) { + try { + failure = this.onFault(error, claim.drive.context); + } catch (faultError) { + failure = faultError; + } + } + if (this.activeDrive === claim.drive) { + this.activeDrive = undefined; + this.signalStateChange(); + } + claim.drive.fail(failure); + }, + ); + } + return Result.ok(await awaitWithContext(claim.drive.completion, context)); + } } /** Package-private durable cancellation primitive. Public exposure remains guarded until M8. */ @@ -661,6 +1038,7 @@ export class Lane implements AgentLane { const followUp = payloads.filter(({ item }) => item.kind === "followUp").map(({ message }) => message); const removedIds = new Set(removed.map((item) => item.entryId)); const inbox = state.inbox.filter((item) => !removedIds.has(item.entryId)); + const queues = await readLaneQueues(reader, inbox, context); const operationState: OperationState = { ...operation.state, control: { status: "cancel_requested", requestedAt: Date.now() }, @@ -670,11 +1048,7 @@ export class Lane implements AgentLane { writes: [ ...removed.map((item) => deleteValue(pendingEntry(item.entryId))), setValue(operationStateValue(operationId), operationState), - setValue(laneStateValue(this.name), { - currentOperationId: operationId, - lastOperationId: state.lastOperationId, - inbox, - }), + setValue(laneStateValue(this.name), durableLaneState(state, operationId, inbox)), ], next: { ...state, inbox, operation: { meta: operation.meta, state: operationState } }, materialize: () => { @@ -689,10 +1063,12 @@ export class Lane implements AgentLane { followUp, lane: this.name, }, + ...(removed.length === 0 ? [] : [{ type: "queue_update" as const, queues, lane: this.name }]), ], }; }, context); - // A fresh Drive may observe an already-durable marker; pull its gate on the repeat path too. + // A fresh Drive may observe an already-durable marker; pull its gate on the repeat path too. A mismatch + // can leave only the stale Drive's admission gate in aborting state; its cancellation wait is still released. settleGate(result.ok && result.value.newlyRequested === false); return result; } catch (error) { @@ -701,8 +1077,8 @@ export class Lane implements AgentLane { } } - async requestAbort(..._args: Parameters): Promise { - throw new SliceNotImplemented("requestAbort"); + requestAbort(operationId: string, context: Context): Promise { + return this.requestOperationAbort(operationId, context); } inspectExecution(context: Context): Promise { @@ -736,63 +1112,518 @@ export class Lane implements AgentLane { } async prompt( - ..._args: + ...args: | [text: string, images: ImageContent[] | undefined, context: Context] | [message: AgentMessage | AgentMessage[], context: Context] - ): Promise { - throw new SliceNotImplemented("prompt"); + ): Promise { + if (args.length === 3) { + return this.driveRunRequest( + { kind: "prompt", prompt: args[0], ...(args[1] === undefined ? {} : { images: args[1] }) }, + args[2], + ); + } + return this.driveRunRequest({ kind: "prompt", prompt: args[0] }, args[1]); } - async skill(..._args: Parameters): Promise { - throw new SliceNotImplemented("skill"); + skill(name: string, additionalInstructions: string | undefined, context: Context): Promise { + return this.driveRunRequest( + { kind: "skill", name, ...(additionalInstructions === undefined ? {} : { additionalInstructions }) }, + context, + ); } - async promptFromTemplate(..._args: Parameters): Promise { - throw new SliceNotImplemented("promptFromTemplate"); + promptFromTemplate(name: string, args: string[] | undefined, context: Context): Promise { + return this.driveRunRequest({ kind: "prompt_template", name, ...(args === undefined ? {} : { args }) }, context); } - async compact(..._args: Parameters): Promise { - throw new SliceNotImplemented("compact"); + private async driveRunRequest( + request: Extract, + context: Context, + ): Promise { + const admission = await this.accept(request, context); + if (!admission.ok) { + switch (admission.error._tag) { + case "LaneBusy": + case "InvalidMessage": + case "UnknownSkill": + case "UnknownTemplate": + case "Closed": + return Result.err(admission.error); + default: + throw this.onFault( + new SessionInvariantError(`Run acceptance returned ${admission.error._tag}`), + context, + ); + } + } + const driven = await this.drive({ operationId: admission.value.operationId, waitForRetry: true }, context); + if (!driven.ok) { + if (driven.error._tag === "Closed") return Result.err(driven.error); + throw this.onFault( + new SessionInvariantError(`Accepted run ${admission.value.operationId} no longer matches its lane`), + context, + ); + } + if (driven.value.kind === "settled") return Result.ok(driven.value.outcome); + if (driven.value.reason === "deferred") { + return Result.ok({ + operationId: admission.value.operationId, + status: "suspended", + deferred: driven.value.deferred, + }); + } + throw this.onFault( + new SessionInvariantError(`Run ${admission.value.operationId} returned an unwaited retry`), + context, + ); } - async navigateTree(..._args: Parameters): Promise { - throw new SliceNotImplemented("navigateTree"); + async compact(options: { customInstructions?: string } | undefined, context: Context): Promise { + const admission = await this.accept( + { + kind: "compaction", + ...(options?.customInstructions === undefined ? {} : { customInstructions: options.customInstructions }), + }, + context, + ); + if (!admission.ok) { + switch (admission.error._tag) { + case "LaneBusy": + case "NothingToCompact": + case "Closed": + return Result.err(admission.error); + default: + throw this.onFault( + new SessionInvariantError(`Compaction acceptance returned ${admission.error._tag}`), + context, + ); + } + } + const compacted = await this.driveStructuralAdmission(admission.value, context); + if (!compacted.ok) return compacted; + const continuation = await this.continueAfterStructural(compacted.value, context); + return continuation.ok + ? Result.ok({ + compaction: compacted.value, + ...(continuation.value === undefined ? {} : { run: continuation.value }), + }) + : continuation; } - async resume(..._args: Parameters): Promise { - throw new SliceNotImplemented("resume"); + async navigateTree( + targetId: string | null, + options: Extract["options"], + context: Context, + ): Promise { + const admission = await this.accept( + { kind: "navigation", targetId, ...(options === undefined ? {} : { options }) }, + context, + ); + if (!admission.ok) { + switch (admission.error._tag) { + case "LaneBusy": + case "InvalidNavigation": + case "UnknownTarget": + case "Closed": + return Result.err(admission.error); + default: + throw this.onFault( + new SessionInvariantError(`Navigation acceptance returned ${admission.error._tag}`), + context, + ); + } + } + const navigated = await this.driveStructuralAdmission(admission.value, context); + if (!navigated.ok) return navigated; + const continuation = await this.continueAfterStructural(navigated.value, context); + return continuation.ok + ? Result.ok({ + navigation: navigated.value, + ...(continuation.value === undefined ? {} : { run: continuation.value }), + }) + : continuation; } - async abort(..._args: Parameters): Promise { - throw new SliceNotImplemented("abort"); + private async driveStructuralAdmission( + admission: OperationAdmission, + context: Context, + ): Promise> { + const driven = await this.drive({ operationId: admission.operationId, waitForRetry: true }, context); + if (!driven.ok) { + if (driven.error._tag === "Closed") return Result.err(driven.error); + throw this.onFault( + new SessionInvariantError(`Accepted ${admission.kind} ${admission.operationId} no longer matches its lane`), + context, + ); + } + if (driven.value.kind === "settled") return Result.ok(driven.value.outcome); + throw this.onFault( + new SessionInvariantError(`${admission.kind} ${admission.operationId} returned ${driven.value.reason}`), + context, + ); } - async steer(..._args: Parameters): Promise { - throw new SliceNotImplemented("steer"); + private async continueAfterStructural( + record: OperationResultRecord, + context: Context, + ): Promise> { + if (record.status === "aborted") return Result.ok(undefined); + const admission = await this.accept({ kind: "prompt", prompt: "" }, context); + if (!admission.ok) { + switch (admission.error._tag) { + case "InvalidMessage": + case "LaneBusy": + return Result.ok(undefined); + case "Closed": + return Result.err(admission.error); + default: + throw this.onFault( + new SessionInvariantError(`Structural continuation acceptance returned ${admission.error._tag}`), + context, + ); + } + } + const driven = await this.drive({ operationId: admission.value.operationId, waitForRetry: true }, context); + if (!driven.ok) { + if (driven.error._tag === "Closed") return Result.err(driven.error); + throw this.onFault( + new SessionInvariantError(`Continuation run ${admission.value.operationId} no longer matches its lane`), + context, + ); + } + if (driven.value.kind === "settled") return Result.ok(driven.value.outcome); + if (driven.value.reason === "deferred") { + return Result.ok({ + operationId: admission.value.operationId, + status: "suspended", + deferred: driven.value.deferred, + }); + } + throw this.onFault( + new SessionInvariantError(`Continuation run ${admission.value.operationId} returned an unwaited retry`), + context, + ); } - async followUp(..._args: Parameters): Promise { - throw new SliceNotImplemented("followUp"); + async resume(context: Context): Promise { + if (this.closedError instanceof HarnessClosed) { + return Result.err(new Closed({ message: this.closedError.message })); + } + this.assertOpen(); + const inspected = await this.command>((state) => { + const operation = state.operation; + return operation === null + ? { + kind: "return", + result: Result.err( + new NothingToResume({ + lane: this.name, + message: `Lane ${JSON.stringify(this.name)} has no active operation to resume`, + }), + ), + } + : { kind: "return", result: Result.ok({ operationId: operation.meta.operationId }) }; + }, context); + if (!inspected.ok) return inspected; + + const driven = await this.drive( + { operationId: inspected.value.operationId, pollDeferred: true, waitForRetry: true }, + context, + ); + if (!driven.ok) { + if (driven.error._tag === "Closed") return Result.err(driven.error); + throw this.onFault( + new SessionInvariantError(`Deferred operation ${inspected.value.operationId} no longer matches its lane`), + context, + ); + } + if (driven.value.kind === "settled") return Result.ok(driven.value.outcome); + if (driven.value.reason === "deferred") { + return Result.ok({ + operationId: inspected.value.operationId, + status: "suspended", + deferred: driven.value.deferred, + }); + } + throw this.onFault( + new SessionInvariantError(`Deferred operation ${inspected.value.operationId} returned an unwaited retry`), + context, + ); } - async nextRun(..._args: Parameters): Promise { - throw new SliceNotImplemented("nextRun"); + async abort(context: Context): Promise { + if (this.closedError instanceof HarnessClosed) { + return Result.err(new Closed({ message: this.closedError.message })); + } + this.assertOpen(); + const operationId = await this.command( + (state) => ({ + kind: "return", + result: state.operation?.meta.operationId, + }), + context, + ); + if (operationId === undefined) { + return Result.err( + new NoActiveOperation({ + lane: this.name, + message: `Lane ${JSON.stringify(this.name)} has no active operation`, + }), + ); + } + const requested = await this.requestAbort(operationId, context); + if (!requested.ok) { + if (requested.error._tag === "Closed") return Result.err(requested.error); + return Result.err( + new NoActiveOperation({ + lane: this.name, + message: `Lane ${JSON.stringify(this.name)} no longer has the inspected operation`, + }), + ); + } + const driven = await this.drive({ operationId }, context); + if (!driven.ok && driven.error._tag === "Closed") return Result.err(driven.error); + if (!driven.ok) { + throw this.onFault( + new SessionInvariantError(`Cancelled operation ${operationId} no longer matches its lane`), + context, + ); + } + return Result.ok({ + operationId, + steer: requested.value.steer, + followUp: requested.value.followUp, + }); } - async cancelQueued(..._args: Parameters): Promise { - throw new SliceNotImplemented("cancelQueued"); + steer(message: string | AgentMessage, images: ImageContent[] | undefined, context: Context): Promise { + return this.enqueue("steer", message, images, context); } - async recordUsage(..._args: Parameters): Promise { - throw new SliceNotImplemented("recordUsage"); + followUp( + message: string | AgentMessage, + images: ImageContent[] | undefined, + context: Context, + ): Promise { + return this.enqueue("followUp", message, images, context); } - async waitForIdle(..._args: Parameters): Promise { - throw new SliceNotImplemented("waitForIdle"); + nextRun(message: string | AgentMessage, images: ImageContent[] | undefined, context: Context): Promise { + return this.enqueue("nextRun", message, images, context); } - async runWhenIdle(..._args: Parameters): Promise { - throw new SliceNotImplemented("runWhenIdle"); + private async enqueue( + kind: "steer" | "followUp" | "nextRun", + input: string | AgentMessage, + images: ImageContent[] | undefined, + context: Context, + ): Promise { + if (this.closedError instanceof HarnessClosed) { + return Result.err(new Closed({ message: this.closedError.message })); + } + this.assertOpen(); + const at = Date.now(); + let message: AgentMessage; + if (typeof input === "string") { + if (input.length === 0 && (images === undefined || images.length === 0)) { + return Result.err( + new InvalidMessage({ + lane: this.name, + reason: "empty", + message: "Queued input must contain text or an image", + }), + ); + } + message = { + role: "user", + content: [...(input.length === 0 ? [] : [{ type: "text" as const, text: input }]), ...(images ?? [])], + timestamp: at, + }; + } else { + if (input.role === "assistant" && input.stopReason === "pending") { + return Result.err( + new InvalidMessage({ + lane: this.name, + reason: "pending_assistant", + message: "Cannot queue a pending assistant message", + }), + ); + } + if (images !== undefined && images.length > 0 && input.role !== "user") { + return Result.err( + new InvalidMessage({ + lane: this.name, + reason: "images_with_non_user", + message: "Images can be added only to queued user messages", + }), + ); + } + message = + images === undefined || images.length === 0 || input.role !== "user" + ? structuredClone(input) + : { + ...structuredClone(input), + content: [ + ...(typeof input.content === "string" + ? input.content.length === 0 + ? [] + : [{ type: "text" as const, text: input.content }] + : input.content), + ...images, + ], + }; + } + const entryId = this.session.idGenerator.next(at); + return this.command(async (state, reader) => { + const inbox = [...state.inbox, { entryId, kind }]; + const queues = [ + ...(await readLaneQueues(reader, state.inbox, context)), + { entryId, kind, type: "message" as const, message }, + ]; + return { + kind: "commit", + writes: [ + setValue(pendingEntry(entryId), { type: "message", payload: message }), + setValue( + laneStateValue(this.name), + durableLaneState(state, state.operation?.meta.operationId ?? null, inbox), + ), + ], + next: { ...state, inbox }, + materialize: () => Result.ok({ entryId }), + events: () => [{ type: "queue_update", queues, lane: this.name }], + }; + }, context); + } + + async cancelQueued(entryId: string, context: Context): Promise { + if (this.closedError instanceof HarnessClosed) { + return Result.err(new Closed({ message: this.closedError.message })); + } + this.assertOpen(); + return this.command(async (state, reader) => { + const queued = state.inbox.find((item) => item.entryId === entryId); + if (queued === undefined) { + const consumed = (await reader.getEntries([entryId], context)).has(entryId); + return { + kind: "return", + result: Result.ok({ kind: consumed ? "already_consumed" : "not_found" }), + }; + } + if ((await reader.getValue(pendingEntry(entryId), context)) === undefined) { + throw new SessionInvariantError(`Queued ${queued.kind} entry ${entryId} is missing its payload`); + } + const inbox = state.inbox.filter((item) => item.entryId !== entryId); + const queues = await readLaneQueues(reader, inbox, context); + return { + kind: "commit", + writes: [ + deleteValue(pendingEntry(entryId)), + setValue( + laneStateValue(this.name), + durableLaneState(state, state.operation?.meta.operationId ?? null, inbox), + ), + ], + next: { ...state, inbox }, + materialize: () => Result.ok({ kind: "cancelled" }), + events: () => [{ type: "queue_update", queues, lane: this.name }], + }; + }, context); + } + + async recordUsage( + usage: Usage, + options: { entryId?: string; details?: JsonValue } | undefined, + context: Context, + ): Promise { + if (this.closedError instanceof HarnessClosed) { + return Result.err(new Closed({ message: this.closedError.message })); + } + this.assertOpen(); + return this.command((state) => { + const usageId = this.session.idGenerator.next(); + const row = { + id: usageId, + usage, + adjustment: true, + ...(options?.entryId === undefined ? {} : { entryId: options.entryId }), + ...(options?.details === undefined ? {} : { details: options.details }), + }; + return { + kind: "commit", + writes: [insertUsage(row)], + next: state, + materialize: () => Result.ok({ usageId }), + events: (commit) => [ + { + type: "usage", + lane: this.name, + row: { ...row, seq: commit.seqs[0]! }, + totals: commit.stats.usage, + }, + ], + }; + }, context); + } + + async waitForIdle(context: Context): Promise { + for (;;) { + const observation = await this.command( + (state) => ({ + kind: "return", + result: + state.operation === null && this.activeDrive === undefined + ? { kind: "idle" } + : { kind: "wait", drive: this.activeDrive, change: this.stateChange }, + }), + context, + ); + if (observation.kind === "idle") return; + await awaitWithContext( + observation.drive === undefined ? observation.change : observation.drive.completion.then(() => undefined), + context, + ); + } + } + + async runWhenIdle(callback: (context: Context) => void | Promise, context: Context): Promise { + let owner: Promise | undefined; + let releaseOwner: (() => void) | undefined; + for (;;) { + const observation = await this.command((state) => { + if (state.operation !== null || this.activeDrive !== undefined) { + return { + kind: "return", + result: { kind: "wait", drive: this.activeDrive, change: this.stateChange }, + }; + } + let release!: () => void; + const claimed = new Promise((resolve) => { + release = resolve; + }); + owner = claimed; + releaseOwner = release; + this.idleOwner = claimed; + this.signalStateChange(); + return { kind: "return", result: { kind: "claimed" } }; + }, context); + if (observation.kind === "claimed") break; + await awaitWithContext( + observation.drive === undefined ? observation.change : observation.drive.completion.then(() => undefined), + context, + ); + } + if (owner === undefined || releaseOwner === undefined) { + throw this.onFault(new SessionInvariantError("Idle callback claim was not published"), context); + } + try { + this.assertOpen(); + await callback(context); + } finally { + if (this.idleOwner === owner) this.idleOwner = undefined; + releaseOwner(); + this.signalStateChange(); + } } async getModel(_context: Context): Promise | undefined> { @@ -800,11 +1631,11 @@ export class Lane implements AgentLane { return this.models.getModel(this.state.configuration.model.provider, this.state.configuration.model.modelId); } - setModel(model: Model, context: Context): Promise { + setModel(model: ModelIdentity, context: Context): Promise { return this.setConfiguration( (configuration) => ({ ...configuration, - model: { provider: model.provider, modelId: model.id }, + model: { ...model }, }), (previous, value) => ({ type: "config_update", @@ -858,164 +1689,17 @@ export class Lane implements AgentLane { {} as LaneSnapshot, (event) => event.type === "usage" || !("lane" in event) || event.lane === this.name, context, + (resnapshotContext) => + this.command( + async (latest, latestReader) => ({ + kind: "return", + result: await this.captureLaneSnapshot(latest, latestReader, resnapshotContext), + }), + resnapshotContext, + ), ); - const captured = structuredClone(state); try { - const transcript = - captured.tipId === null - ? [] - : ( - await reader.scanBranch( - { start: captured.tipId, stopAtType: "compaction", order: "newestFirst" }, - context, - ) - ).reverse(); - 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; - - let operationSnapshot: NonNullable | null = null; - if (operation !== null) { - const runningTools: NonNullable["runningTools"] = []; - let streamingMessage: NonNullable["streamingMessage"]; - let retry: NonNullable["retry"]; - let deferred: NonNullable["deferred"]; - const readStreamingMessage = async (responseEntryId: string) => - reduceAssistantMessageFrames( - await readAssistantFrames(reader, operation.meta.operationId, responseEntryId, context), - ); - const state = operation.state; - switch (state.at) { - case "assistant.retry_wait": - retry = { - attempt: state.nextAttempt, - maxAttempts: state.generationContext.retryPolicy.maxAttempts, - nextAttemptAt: state.notBefore, - }; - break; - case "assistant.effect_pending": - streamingMessage = await readStreamingMessage(state.responseEntryId); - break; - case "deferred.suspended": - case "deferred.effect_pending": { - const source = (await reader.getEntries([state.sourceEntryId], context)).get(state.sourceEntryId); - if ( - source?.type !== "message" || - source.message.role !== "assistant" || - source.message.deferred === undefined - ) { - throw new SessionInvariantError("Deferred source is missing its assistant handle"); - } - deferred = { handle: source.message.deferred, poll: state.poll }; - if (state.at === "deferred.effect_pending") { - streamingMessage = await readStreamingMessage(state.responseEntryId); - } - break; - } - case "tools": { - const { batch } = state; - const assistant = (await reader.getEntries([batch.assistantEntryId], context)).get( - batch.assistantEntryId, - ); - if (assistant?.type !== "message" || assistant.message.role !== "assistant") { - throw new SessionInvariantError("Tool batch assistant entry is invalid"); - } - for (const call of batch.calls) { - if (call.status !== "effect_pending") continue; - const block = assistant.message.content[call.sourceIndex]; - if (block?.type !== "toolCall") { - throw new SessionInvariantError( - `Tool call source index ${call.sourceIndex} does not name a tool-call block`, - ); - } - const args = await reader.getValue( - operationToolArgs(operation.meta.operationId, batch.turnId, call.sourceIndex), - context, - ); - if (args === undefined) { - throw new SessionInvariantError(`Tool call ${block.id} is missing persisted arguments`); - } - const checkpoint = await reader.getValue( - pendingToolOutput(operation.meta.operationId, call.resultEntryId), - context, - ); - runningTools.push({ - toolCallId: block.id, - toolName: block.name, - args: args.value, - ...(checkpoint === undefined ? {} : { partialResult: checkpoint.value }), - }); - } - break; - } - case "summary.retry_wait": - retry = { - attempt: state.nextAttempt, - maxAttempts: state.summaryContext.retryPolicy.maxAttempts, - nextAttemptAt: state.notBefore, - }; - break; - default: - break; - } - - operationSnapshot = { - id: operation.meta.operationId, - kind: operation.meta.intent.kind, - startedAt: operation.meta.startedAt, - status: operation.state.control.status === "cancel_requested" ? "aborting" : "open", - ...(retry === undefined ? {} : { retry }), - ...(deferred === undefined ? {} : { deferred }), - ...(streamingMessage === undefined ? {} : { streamingMessage }), - runningTools, - }; - } - - watcher.snapshot = structuredClone({ - lane: this.name, - transcript, - tipId: captured.tipId, - lastOperationId: captured.lastOperationId, - operation: operationSnapshot, - queues: { steer, followUp, nextRun }, - pendingWrites, - faulted: false, - }); + watcher.snapshot = await this.captureLaneSnapshot(state, reader, context); return { kind: "return", result: watcher }; } catch (error) { watcher.unsubscribe(); @@ -1024,6 +1708,142 @@ export class Lane implements AgentLane { }, context); } + private async captureLaneSnapshot(state: LaneState, reader: SessionReader, context: Context): Promise { + const captured = structuredClone(state); + const transcript = + captured.tipId === null + ? [] + : ( + await reader.scanBranch( + { start: captured.tipId, stopAtType: "compaction", order: "newestFirst" }, + context, + ) + ).reverse(); + const queues = await readLaneQueues(reader, captured.inbox, context); + let lastResult: OperationResultRecord | undefined; + if (captured.lastOperationId !== null) { + const stored = await reader.getValue(operationResultValue(captured.lastOperationId), context); + if (stored === undefined) { + throw new SessionInvariantError( + `Lane ${JSON.stringify(this.name)} is missing result ${captured.lastOperationId}`, + ); + } + lastResult = stored.value; + } + const stats = await reader.getStats(context); + const operation = captured.operation; + + let operationSnapshot: NonNullable | null = null; + if (operation !== null) { + const runningTools: NonNullable["runningTools"] = []; + let streamingMessage: NonNullable["streamingMessage"]; + let retry: NonNullable["retry"]; + let deferred: NonNullable["deferred"]; + const readStreamingMessage = async (responseEntryId: string) => + reduceAssistantMessageFrames( + await readAssistantFrames(reader, operation.meta.operationId, responseEntryId, context), + ); + const state = operation.state; + switch (state.at) { + case "assistant.retry_wait": + retry = { + attempt: state.nextAttempt, + maxAttempts: state.generationContext.retryPolicy.maxAttempts, + nextAttemptAt: state.notBefore, + }; + break; + case "assistant.effect_pending": + streamingMessage = await readStreamingMessage(state.responseEntryId); + break; + case "deferred.suspended": + case "deferred.effect_pending": { + const source = (await reader.getEntries([state.sourceEntryId], context)).get(state.sourceEntryId); + if ( + source?.type !== "message" || + source.message.role !== "assistant" || + source.message.deferred === undefined + ) { + throw new SessionInvariantError("Deferred source is missing its assistant handle"); + } + deferred = { handle: source.message.deferred, poll: state.poll }; + if (state.at === "deferred.effect_pending") { + streamingMessage = await readStreamingMessage(state.responseEntryId); + } + break; + } + case "tools": { + const { batch } = state; + const assistant = (await reader.getEntries([batch.assistantEntryId], context)).get( + batch.assistantEntryId, + ); + if (assistant?.type !== "message" || assistant.message.role !== "assistant") { + throw new SessionInvariantError("Tool batch assistant entry is invalid"); + } + for (const call of batch.calls) { + if (call.status !== "effect_pending") continue; + const block = assistant.message.content[call.sourceIndex]; + if (block?.type !== "toolCall") { + throw new SessionInvariantError( + `Tool call source index ${call.sourceIndex} does not name a tool-call block`, + ); + } + const args = await reader.getValue( + operationToolArgs(operation.meta.operationId, batch.turnId, call.sourceIndex), + context, + ); + if (args === undefined) { + throw new SessionInvariantError(`Tool call ${block.id} is missing persisted arguments`); + } + const checkpoint = await reader.getValue( + pendingToolOutput(operation.meta.operationId, call.resultEntryId), + context, + ); + runningTools.push({ + toolCallId: block.id, + toolName: block.name, + args: args.value, + ...(checkpoint === undefined ? {} : { partialResult: checkpoint.value }), + }); + } + break; + } + case "summary.retry_wait": + retry = { + attempt: state.nextAttempt, + maxAttempts: state.summaryContext.retryPolicy.maxAttempts, + nextAttemptAt: state.notBefore, + }; + break; + default: + break; + } + + operationSnapshot = { + id: operation.meta.operationId, + kind: operation.meta.intent.kind, + startedAt: operation.meta.startedAt, + fromTipId: operation.meta.sourceTipId, + status: operation.state.control.status === "cancel_requested" ? "aborting" : "open", + ...(retry === undefined ? {} : { retry }), + ...(deferred === undefined ? {} : { deferred }), + ...(streamingMessage === undefined ? {} : { streamingMessage }), + runningTools, + }; + } + + return structuredClone({ + lane: this.name, + transcript, + tipId: captured.tipId, + ...(lastResult === undefined ? {} : { lastResult }), + configuration: captured.configuration, + stats, + operation: operationSnapshot, + queues, + faulted: this.closedError instanceof HarnessFault, + }); + } + private async setConfiguration( update: (configuration: LaneState["configuration"]) => LaneState["configuration"], event: (previous: LaneState["configuration"], value: LaneState["configuration"]) => LaneConfigEventPayload, @@ -1088,6 +1908,7 @@ export class Lane implements AgentLane { }), ); const inbox = withoutInboxItems(state.inbox, queued); + const queues = queued.length === 0 ? undefined : await readLaneQueues(reader, inbox, context); const entries = chainEntries(state.tipId, [...captured, pendingEntryWrite(id, pending)]); return { kind: "commit", @@ -1095,48 +1916,58 @@ export class Lane implements AgentLane { ...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, - }), + setValue(laneStateValue(this.name), durableLaneState(state, null, inbox)), ], next: { ...state, tipId: id, inbox }, materialize: () => id, - events: (commit) => committedEntryEvents(entries, commit, this.name), + events: (commit) => [ + ...committedEntryEvents(entries, commit, this.name), + ...(queues === undefined ? [] : [{ type: "queue_update" as const, queues, lane: this.name }]), + ], }; } const operation = state.operation; const inbox = [...state.inbox, { entryId: id, kind: "write" as const }]; + const queues = [ + ...(await readLaneQueues(reader, state.inbox, context)), + pending.type === "message" + ? { entryId: id, kind: "write" as const, type: "message" as const, message: pending.payload } + : { + entryId: id, + kind: "write" as const, + type: "custom" as const, + customType: pending.customType, + ...(pending.payload === undefined ? {} : { data: pending.payload }), + }, + ]; return { kind: "commit", writes: [ setValue(pendingEntry(id), pending), - setValue(laneStateValue(this.name), { - currentOperationId: operation.meta.operationId, - lastOperationId: state.lastOperationId, - inbox, - }), + setValue(laneStateValue(this.name), durableLaneState(state, operation.meta.operationId, inbox)), ], next: { ...state, inbox }, materialize: () => id, - events: () => [ - { - type: "write_pending", - runId: operation.meta.operationId, - entryId: id, - entryType: pending.type, - lane: this.name, - }, - ], + events: () => [{ type: "queue_update", queues, lane: this.name }], }; }, context); } - seal(error: Error): void { + seal(error: Error): Promise { this.closedError ??= error; this.activeDrive?.closeGate(error); + this.signalStateChange(); + return this.idleOwner ?? Promise.resolve(); + } + + private signalStateChange(): void { + this.resolveStateChange(); + let resolveStateChange!: () => void; + this.stateChange = new Promise((resolve) => { + resolveStateChange = resolve; + }); + this.resolveStateChange = resolveStateChange; } assertOpen(): void { diff --git a/packages/agent/src/harness/runtime/reducer.ts b/packages/agent/src/harness/runtime/reducer.ts new file mode 100644 index 000000000..82d89018a --- /dev/null +++ b/packages/agent/src/harness/runtime/reducer.ts @@ -0,0 +1,193 @@ +import type { HarnessEvent, LaneSnapshot } from "../agent-harness.ts"; +import type { OperationResultRecord } from "../session/types.ts"; + +export type LaneSnapshotReduction = LaneSnapshot | { rebase: true }; + +function matchingOperation( + snapshot: LaneSnapshot, + operationId: string, +): NonNullable | undefined { + return snapshot.operation?.id === operationId ? snapshot.operation : undefined; +} + +/** Apply one harness event to a replicated lane snapshot. Navigation completion requires a fresh snapshot. */ +export function reduceLaneSnapshot(snapshot: LaneSnapshot, event: HarnessEvent): LaneSnapshotReduction { + if ("lane" in event && event.lane !== undefined && event.lane !== snapshot.lane && event.type !== "usage") { + return snapshot; + } + const next = structuredClone(snapshot); + switch (event.type) { + case "run_start": + next.operation = { + id: event.runId, + kind: "run", + startedAt: event.startedAt, + fromTipId: next.tipId, + status: "open", + runningTools: [], + }; + return next; + case "compaction_start": + if (next.operation !== null) return next; + next.operation = { + id: event.runId, + kind: "compaction", + startedAt: event.startedAt, + fromTipId: next.tipId, + status: "open", + runningTools: [], + }; + return next; + case "navigation_start": + next.operation = { + id: event.runId, + kind: "navigation", + startedAt: event.startedAt, + fromTipId: next.tipId, + status: "open", + runningTools: [], + }; + return next; + case "operation_abort": + if (next.operation?.id === event.operationId) next.operation.status = "aborting"; + return next; + case "run_resume": + if (next.operation?.id === event.runId) delete next.operation.deferred; + return next; + case "run_suspend": + if (next.operation?.id === event.runId) { + next.operation.deferred = { handle: event.deferred, poll: event.poll }; + delete next.operation.streamingMessage; + } + return next; + case "retry_scheduled": + if (next.operation?.id === event.runId) { + next.operation.retry = { + attempt: event.attempt, + maxAttempts: event.maxAttempts, + nextAttemptAt: event.notBefore, + }; + } + return next; + case "retry_start": + case "retry_end": + if (next.operation?.id === event.runId) delete next.operation.retry; + return next; + case "message_start": + if ( + event.runId !== undefined && + next.operation?.id === event.runId && + event.message.role === "assistant" && + event.message.stopReason === "pending" + ) { + next.operation.streamingMessage = event.message; + } + return next; + case "message_update": + if (next.operation?.id === event.runId && event.message.role === "assistant") { + next.operation.streamingMessage = event.message; + } + return next; + case "message_end": + if (event.runId !== undefined && next.operation?.id === event.runId) { + delete next.operation.streamingMessage; + } + return next; + case "tool_start": + if (next.operation?.id === event.runId) { + next.operation.runningTools.push({ + toolCallId: event.toolCallId, + toolName: event.toolName, + args: event.args, + }); + } + return next; + case "tool_update": { + const tool = next.operation?.runningTools.find((candidate) => candidate.toolCallId === event.toolCallId); + if (tool !== undefined) tool.partialResult = event.partialResult; + return next; + } + case "tool_end": + if (next.operation?.id === event.runId) { + next.operation.runningTools = next.operation.runningTools.filter( + (candidate) => candidate.toolCallId !== event.toolCallId, + ); + } + return next; + case "entry_added": { + const alreadyPresent = next.transcript.some((entry) => entry.id === event.entry.id); + if (!alreadyPresent) { + next.transcript = event.entry.type === "compaction" ? [event.entry] : [...next.transcript, event.entry]; + next.tipId = event.entry.id; + if (event.entry.type === "message") next.stats.messageCount++; + } + return next; + } + case "queue_update": + next.queues = event.queues; + return next; + case "usage": + next.stats.usage = event.totals; + return next; + case "config_update": + if (!("lane" in event) || event.lane !== next.lane) return next; + switch (event.property) { + case "model": + next.configuration.model = event.value; + break; + case "thinkingLevel": + next.configuration.thinkingLevel = event.value; + break; + case "activeTools": + next.configuration.activeToolNames = event.value; + break; + } + return next; + case "run_end": { + const operation = matchingOperation(next, event.runId); + if (operation?.kind !== "run") return next; + const record: OperationResultRecord = { + operationId: event.runId, + kind: "run", + status: event.status, + ...(event.status === "failed" ? { error: event.error } : {}), + fromTipId: event.fromTipId, + tipId: event.tipId, + startedAt: operation.startedAt, + endedAt: event.endedAt, + }; + next.lastResult = record; + next.operation = null; + next.tipId = event.tipId; + return next; + } + case "compaction_end": { + const operation = matchingOperation(next, event.runId); + if (operation?.kind !== "compaction") return next; + const record: OperationResultRecord = { + operationId: event.runId, + kind: "compaction", + status: event.status, + ...(event.status === "failed" ? { error: event.error } : {}), + fromTipId: operation.fromTipId, + tipId: next.tipId, + startedAt: operation.startedAt, + endedAt: event.endedAt, + }; + next.lastResult = record; + next.operation = null; + return next; + } + case "navigation_end": + return { rebase: true }; + case "fault": + next.faulted = true; + return next; + case "handler_error": + case "turn_start": + case "turn_end": + case "value_update": + case "lane_created": + return next; + } +} diff --git a/packages/agent/src/harness/runtime/transcript.ts b/packages/agent/src/harness/runtime/transcript.ts index 4eec5efb4..3b25d000c 100644 --- a/packages/agent/src/harness/runtime/transcript.ts +++ b/packages/agent/src/harness/runtime/transcript.ts @@ -1,10 +1,10 @@ import type { AgentMessage } from "../../types.ts"; -import type { HarnessEvent } from "../agent-harness.ts"; +import type { HarnessEvent, LaneQueuedItem } from "../agent-harness.ts"; 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, OperationState, SessionReader } from "../session/types.ts"; +import type { CommitResult, Entry, InboxItem, 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"; @@ -83,6 +83,34 @@ export async function readBoundedContext { + return Promise.all( + inbox.map(async (item): Promise => { + 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 (stored.value.type === "message") { + return { entryId: item.entryId, kind: item.kind, type: "message", message: stored.value.payload }; + } + if (item.kind !== "write") { + throw new SessionInvariantError(`Pending ${item.kind} entry ${item.entryId} is not a message`); + } + return { + entryId: item.entryId, + kind: "write", + type: "custom", + customType: stored.value.customType, + ...(stored.value.payload === undefined ? {} : { data: stored.value.payload }), + }; + }), + ); +} + export function readPendingMessages( reader: SessionReader, ids: readonly string[], diff --git a/packages/agent/src/harness/runtime/types.ts b/packages/agent/src/harness/runtime/types.ts index 1c84fb316..006ac12bf 100644 --- a/packages/agent/src/harness/runtime/types.ts +++ b/packages/agent/src/harness/runtime/types.ts @@ -136,6 +136,7 @@ export class Drive { closeGate(error: Error): void { this.control.close(error); if (!this.closeSignal.aborted) this.closeController.abort(error); + this.rejectCompletion(error); } } diff --git a/packages/agent/src/harness/session/memory.ts b/packages/agent/src/harness/session/memory.ts index 7b45fade0..4bca06f41 100644 --- a/packages/agent/src/harness/session/memory.ts +++ b/packages/agent/src/harness/session/memory.ts @@ -204,6 +204,7 @@ class MemorySessionFacade implements Session { } }, getEntries: (ids, readContext) => source.getEntries(ids, readContext), + getStats: (readContext) => source.getStats(readContext), getValue: (address, readContext) => source.getValue(address, readContext), scanValues: (prefix, readContext) => source.scanValues(prefix, readContext), readList: (address, options, readContext) => source.readList(address, options, readContext), diff --git a/packages/agent/src/harness/session/session.ts b/packages/agent/src/harness/session/session.ts index 440a9afe3..54a129f0f 100644 --- a/packages/agent/src/harness/session/session.ts +++ b/packages/agent/src/harness/session/session.ts @@ -137,6 +137,11 @@ class StorageBackedSessionMutation implements SessionMutation { return this.storage.getEntries(ids, context); } + getStats(context: Context): Promise { + this.assertActive(); + return this.storage.getStats(context); + } + getValue(address: Value, context: Context): Promise | undefined> { this.assertActive(); return this.storage.getValue(address, context); diff --git a/packages/agent/src/harness/session/types.ts b/packages/agent/src/harness/session/types.ts index f4ace93a7..d77eb5068 100644 --- a/packages/agent/src/harness/session/types.ts +++ b/packages/agent/src/harness/session/types.ts @@ -491,6 +491,7 @@ export interface EntryQuery { export interface SessionReader { getEntries(ids: string[], context: Context): Promise>; + getStats(context: Context): Promise; getValue(address: Value, context: Context): Promise | undefined>; scanValues(prefix: Value, context: Context): Promise[]>; readList( diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 9f0d725d4..dd7d87edd 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -74,6 +74,7 @@ export * from "./harness/context.ts"; export * from "./harness/messages.ts"; export * from "./harness/prompt-templates.ts"; export * from "./harness/result.ts"; +export { type LaneSnapshotReduction, reduceLaneSnapshot } from "./harness/runtime/reducer.ts"; export * from "./harness/session/index.ts"; export * from "./harness/skills.ts"; export * from "./harness/system-prompt.ts"; diff --git a/packages/agent/test/harness/execution-primitives.test.ts b/packages/agent/test/harness/execution-primitives.test.ts index 4068f6268..9718c2e87 100644 --- a/packages/agent/test/harness/execution-primitives.test.ts +++ b/packages/agent/test/harness/execution-primitives.test.ts @@ -369,17 +369,17 @@ describe("HarnessEventBus", () => { it("buffers between snapshot and start, then delivers each event once in order", async () => { const bus = new HarnessEventBus(); const watcher = bus.watch({ tipId: null }, () => true, BACKGROUND_CONTEXT); - await bus.emit({ type: "run_start", runId: "one", lane: "main" }, BACKGROUND_CONTEXT); + await bus.emit({ type: "run_start", runId: "one", startedAt: 1, lane: "main" }, BACKGROUND_CONTEXT); const seen: string[] = []; watcher.start((event) => { seen.push(`${event.type}:${"runId" in event ? event.runId : ""}`); }); - await bus.emit({ type: "run_start", runId: "two", lane: "main" }, BACKGROUND_CONTEXT); + await bus.emit({ type: "run_start", runId: "two", startedAt: 1, lane: "main" }, BACKGROUND_CONTEXT); await new Promise((resolve) => setTimeout(resolve, 0)); expect(watcher.snapshot).toEqual({ tipId: null }); expect(seen).toEqual(["run_start:one", "run_start:two"]); watcher.unsubscribe(); - await bus.emit({ type: "run_start", runId: "three", lane: "main" }, BACKGROUND_CONTEXT); + await bus.emit({ type: "run_start", runId: "three", startedAt: 1, lane: "main" }, BACKGROUND_CONTEXT); expect(seen).toHaveLength(2); }); @@ -419,9 +419,9 @@ describe("HarnessEventBus", () => { seen.push(`${event.runId}:end`); }); - const one = bus.emit({ type: "run_start", runId: "one", lane: "main" }, BACKGROUND_CONTEXT); + const one = bus.emit({ type: "run_start", runId: "one", startedAt: 1, lane: "main" }, BACKGROUND_CONTEXT); await started.promise; - const two = bus.emit({ type: "run_start", runId: "two", lane: "main" }, BACKGROUND_CONTEXT); + const two = bus.emit({ type: "run_start", runId: "two", startedAt: 1, lane: "main" }, BACKGROUND_CONTEXT); await new Promise((resolve) => setTimeout(resolve, 0)); expect(seen).toEqual(["one:start"]); release.resolve(); @@ -442,15 +442,15 @@ describe("HarnessEventBus", () => { const first = bus.emitBatch( [ - { type: "run_start", runId: "a1", lane: "main" }, - { type: "run_start", runId: "a2", lane: "main" }, + { type: "run_start", runId: "a1", startedAt: 1, lane: "main" }, + { type: "run_start", runId: "a2", startedAt: 1, lane: "main" }, ], firstContext, ); const second = bus.emitBatch( [ - { type: "run_start", runId: "b1", lane: "worker" }, - { type: "run_start", runId: "b2", lane: "worker" }, + { type: "run_start", runId: "b1", startedAt: 1, lane: "worker" }, + { type: "run_start", runId: "b2", startedAt: 1, lane: "worker" }, ], secondContext, ); @@ -470,10 +470,13 @@ describe("HarnessEventBus", () => { started.resolve(); await release.promise; }); - const blocking = bus.emit({ type: "run_start", runId: "blocking", lane: "main" }, BACKGROUND_CONTEXT); + const blocking = bus.emit( + { type: "run_start", runId: "blocking", startedAt: 1, lane: "main" }, + BACKGROUND_CONTEXT, + ); await started.promise; - const queued = bus.emit({ type: "run_start", runId: "queued", lane: "main" }, BACKGROUND_CONTEXT); + const queued = bus.emit({ type: "run_start", runId: "queued", startedAt: 1, lane: "main" }, BACKGROUND_CONTEXT); const lateListenerEvents: string[] = []; bus.on("run_start", (event) => { lateListenerEvents.push(event.runId); @@ -489,7 +492,7 @@ describe("HarnessEventBus", () => { expect(lateListenerEvents).toEqual([]); expect(lateWatcherEvents).toEqual([]); - await bus.emit({ type: "run_start", runId: "later", lane: "main" }, BACKGROUND_CONTEXT); + await bus.emit({ type: "run_start", runId: "later", startedAt: 1, lane: "main" }, BACKGROUND_CONTEXT); expect(lateListenerEvents).toEqual(["later"]); expect(lateWatcherEvents).toEqual(["later"]); }); @@ -516,18 +519,21 @@ describe("HarnessEventBus", () => { await release.promise; } }); - const blocking = bus.emit({ type: "run_start", runId: "blocking", lane: "main" }, BACKGROUND_CONTEXT); + const blocking = bus.emit( + { type: "run_start", runId: "blocking", startedAt: 1, lane: "main" }, + BACKGROUND_CONTEXT, + ); await started.promise; const batch = bus.emitBatch( [ - { type: "run_start", runId: "one", lane: "main" }, - { type: "run_start", runId: "two", lane: "main" }, + { type: "run_start", runId: "one", startedAt: 1, lane: "main" }, + { type: "run_start", runId: "two", startedAt: 1, lane: "main" }, ], BACKGROUND_CONTEXT, ); bus.close(new Error("closed")); - await bus.emit({ type: "run_start", runId: "late", lane: "main" }, BACKGROUND_CONTEXT); + await bus.emit({ type: "run_start", runId: "late", startedAt: 1, lane: "main" }, BACKGROUND_CONTEXT); release.resolve(); await Promise.all([blocking, batch]); @@ -548,8 +554,8 @@ describe("HarnessEventBus", () => { seen.push(event.runId); }); - await bus.emit({ type: "run_start", runId: "one", lane: "main" }, BACKGROUND_CONTEXT); - await bus.emit({ type: "run_start", runId: "two", lane: "main" }, BACKGROUND_CONTEXT); + await bus.emit({ type: "run_start", runId: "one", startedAt: 1, lane: "main" }, BACKGROUND_CONTEXT); + await bus.emit({ type: "run_start", runId: "two", startedAt: 1, lane: "main" }, BACKGROUND_CONTEXT); await new Promise((resolve) => setTimeout(resolve, 0)); expect(seen).toEqual(["two"]); expect(failures).toEqual(["watcher failed"]); @@ -564,7 +570,7 @@ describe("HarnessEventBus", () => { bus.on("handler_error", (event) => { failures.push(event.error); }); - await bus.emit({ type: "run_start", runId: "run", lane: "main" }, BACKGROUND_CONTEXT); + await bus.emit({ type: "run_start", runId: "run", startedAt: 1, lane: "main" }, BACKGROUND_CONTEXT); expect(failures).toEqual(["listener failed"]); }); @@ -579,7 +585,7 @@ describe("HarnessEventBus", () => { throw new Error("error listener failed"); }); - await bus.emit({ type: "run_start", runId: "run", lane: "main" }, BACKGROUND_CONTEXT); + await bus.emit({ type: "run_start", runId: "run", startedAt: 1, lane: "main" }, BACKGROUND_CONTEXT); expect(handlerErrors).toBe(1); }); diff --git a/packages/agent/test/harness/runtime/accept.test.ts b/packages/agent/test/harness/runtime/accept.test.ts index 7de08d799..7bbeb0c66 100644 --- a/packages/agent/test/harness/runtime/accept.test.ts +++ b/packages/agent/test/harness/runtime/accept.test.ts @@ -368,6 +368,163 @@ describe("runtime atomic run acceptance", () => { }); }); + it("accepts standalone compaction with durable preparation and no execution", async () => { + const { harness, lane, session, storage } = await createHarness(); + await lane.appendMessage({ role: "user", content: "history", timestamp: 1 }, BACKGROUND_CONTEXT); + storage.clearCommitAttempts(); + const starts = vi.fn(); + harness.events.on("compaction_start", starts); + + const admission = unwrap( + await lane.accept( + { kind: "compaction", operationId: "compaction", customInstructions: "focus" }, + BACKGROUND_CONTEXT, + ), + ); + + expect(admission).toEqual({ operationId: "compaction", kind: "compaction", startedAt: expect.any(Number) }); + const operation = lane.state.operation; + if (operation?.state.at !== "summary.deciding") throw new Error("Expected accepted compaction"); + expect(operation.state.task).toMatchObject({ + reason: "manual", + customInstructions: "focus", + boundary: { kind: "finish" }, + }); + expect( + await session.getValue( + storedValues.operationPreparation("compaction", operation.state.task.taskId), + BACKGROUND_CONTEXT, + ), + ).toMatchObject({ value: { kind: "compaction" } }); + expect(storage.getCommitAttempts()).toHaveLength(1); + expect(starts).toHaveBeenCalledTimes(1); + expect(lane.activeDrive).toBeUndefined(); + }); + + it("rejects empty standalone compaction without writing", async () => { + const { lane, storage } = await createHarness(); + + expect(await lane.accept({ kind: "compaction" }, BACKGROUND_CONTEXT)).toMatchObject({ + ok: false, + error: { _tag: "NothingToCompact" }, + }); + expect(storage.getCommitAttempts()).toEqual([]); + }); + + it.each([false, true])("accepts %s summarized navigation atomically", async (summarize) => { + const { harness, lane, session, storage } = await createHarness(async (source) => { + await source.mutate( + (mutator) => + mutator.commit( + [ + { + kind: "entry", + entry: { + id: "root", + parentId: null, + type: "message", + message: { role: "user", content: "root", timestamp: 1 }, + }, + }, + { + kind: "entry", + entry: { + id: "source", + parentId: "root", + type: "message", + message: { role: "user", content: "source", timestamp: 2 }, + }, + }, + { + kind: "entry", + entry: { + id: "target", + parentId: "root", + type: "message", + message: { role: "user", content: "target", timestamp: 3 }, + }, + }, + storedValues.setValue(storedValues.branchTip("main"), "source"), + ], + BACKGROUND_CONTEXT, + ), + BACKGROUND_CONTEXT, + ); + }); + storage.clearCommitAttempts(); + const starts = vi.fn(); + harness.events.on("navigation_start", starts); + + unwrap( + await lane.accept( + { + kind: "navigation", + operationId: summarize ? "summarized" : "direct", + targetId: "target", + options: { summarize, label: "chosen", customInstructions: "focus" }, + }, + BACKGROUND_CONTEXT, + ), + ); + + const operation = lane.state.operation; + if (operation === null) throw new Error("Expected accepted navigation"); + expect(operation.state.at).toBe(summarize ? "summary.deciding" : "navigation.ready_to_commit"); + expect(lane.state.tipId).toBe("source"); + if (summarize) { + if (operation.state.at !== "summary.deciding") throw new Error("Expected summary decision"); + expect( + await session.getValue( + storedValues.operationPreparation(operation.meta.operationId, operation.state.task.taskId), + BACKGROUND_CONTEXT, + ), + ).toMatchObject({ value: { kind: "branch_summary", messages: [{ content: "source" }] } }); + } + expect(storage.getCommitAttempts()).toHaveLength(1); + expect(starts).toHaveBeenCalledTimes(1); + expect(lane.activeDrive).toBeUndefined(); + }); + + it("validates navigation before acceptance", async () => { + const { lane, storage } = await createHarness(async (source) => { + await source.mutate( + (mutator) => + mutator.commit( + [ + { + kind: "entry", + entry: { + id: "source", + parentId: null, + type: "message", + message: { role: "user", content: "source", timestamp: 1 }, + }, + }, + storedValues.setValue(storedValues.branchTip("main"), "source"), + ], + BACKGROUND_CONTEXT, + ), + BACKGROUND_CONTEXT, + ); + }); + + expect(await lane.accept({ kind: "navigation", targetId: "source" }, BACKGROUND_CONTEXT)).toMatchObject({ + ok: false, + error: { _tag: "InvalidNavigation", reason: "current_tip" }, + }); + expect( + await lane.accept({ kind: "navigation", targetId: null, options: { label: "bad" } }, BACKGROUND_CONTEXT), + ).toMatchObject({ + ok: false, + error: { _tag: "InvalidNavigation", reason: "root_label" }, + }); + expect(await lane.accept({ kind: "navigation", targetId: "missing" }, BACKGROUND_CONTEXT)).toMatchObject({ + ok: false, + error: { _tag: "UnknownTarget" }, + }); + expect(storage.getCommitAttempts()).toEqual([]); + }); + it("returns expected pre-acceptance errors without writing", async () => { const { lane, storage } = await createHarness(); const pending = fauxAssistantMessage([], { stopReason: "pending" }); diff --git a/packages/agent/test/harness/runtime/drive-public.test.ts b/packages/agent/test/harness/runtime/drive-public.test.ts new file mode 100644 index 000000000..c42eeb960 --- /dev/null +++ b/packages/agent/test/harness/runtime/drive-public.test.ts @@ -0,0 +1,600 @@ +import { createModels, fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it } from "vitest"; +import { + AgentHarness, + type AgentLane, + HarnessClosed, + HarnessFault, + type OperationAdmission, + type Resources, +} from "../../../src/harness/agent-harness.ts"; +import { BACKGROUND_CONTEXT, withCancel } from "../../../src/harness/context.ts"; +import { Lane } from "../../../src/harness/runtime/lane.ts"; +import { MemoryStorage } from "../../../src/harness/session/memory.ts"; +import { StorageBackedSession } from "../../../src/harness/session/session.ts"; +import type { Session } from "../../../src/harness/session/types.ts"; +import { deferred } from "./test-utils.ts"; + +const sessions: Session[] = []; + +async function createFixture(options: { deferred?: boolean; resources?: Resources } = {}): Promise<{ + lane: AgentLane; + runtimeLane: Lane; + harness: Awaited>["harness"]; + faux: ReturnType; + session: Session; +}> { + const session = new StorageBackedSession( + { id: `public-drive-${sessions.length}`, createdAt: 1, storageVersion: 1 }, + new MemoryStorage(), + ); + sessions.push(session); + const faux = fauxProvider(); + const models = createModels(); + models.setProvider(faux.provider); + const { harness } = await AgentHarness.create( + { + session, + models, + model: faux.getModel(), + ...(options.deferred === true ? { streamOptions: { deferred: true } } : {}), + ...(options.resources === undefined ? {} : { resources: options.resources }), + }, + BACKGROUND_CONTEXT, + ); + const lane = await harness.lane("main", BACKGROUND_CONTEXT); + if (!(lane instanceof Lane)) throw new Error("Expected runtime Lane"); + return { lane, runtimeLane: lane, harness, faux, session }; +} + +async function acceptRun(lane: AgentLane, operationId: string): Promise { + const result = await lane.accept({ kind: "prompt", operationId, prompt: operationId }, BACKGROUND_CONTEXT); + if (!result.ok) throw result.error; + return result.value; +} + +afterEach(async () => { + for (const session of sessions.splice(0)) await session.close(BACKGROUND_CONTEXT); +}); + +describe("runtime public drive", () => { + it("persists model identity without requiring a local registration", async () => { + const { lane, faux } = await createFixture(); + await lane.setModel({ provider: "missing", modelId: "missing-model" }, BACKGROUND_CONTEXT); + + expect(await lane.getModel(BACKGROUND_CONTEXT)).toBeUndefined(); + expect(await lane.prompt("prompt", undefined, BACKGROUND_CONTEXT)).toMatchObject({ + ok: true, + value: { + kind: "run", + status: "failed", + error: { code: "model_unavailable" }, + }, + }); + expect(faux.state.callCount).toBe(0); + }); + + it("composes prompt, skill, and template acceptance with drive", async () => { + const resources: Resources = { + skills: [ + { name: "review", description: "Review", content: "Inspect it", filePath: "/skills/review/SKILL.md" }, + ], + promptTemplates: [{ name: "fix", content: "Fix $1" }], + }; + const { lane, faux } = await createFixture({ resources }); + faux.setResponses([ + fauxAssistantMessage("prompt answer"), + fauxAssistantMessage("skill answer"), + fauxAssistantMessage("template answer"), + ]); + + expect(await lane.prompt("prompt", undefined, BACKGROUND_CONTEXT)).toMatchObject({ + ok: true, + value: { kind: "run", status: "completed" }, + }); + expect(await lane.skill("review", "strict", BACKGROUND_CONTEXT)).toMatchObject({ + ok: true, + value: { kind: "run", status: "completed" }, + }); + expect(await lane.promptFromTemplate("fix", ["it"], BACKGROUND_CONTEXT)).toMatchObject({ + ok: true, + value: { kind: "run", status: "completed" }, + }); + expect(faux.state.callCount).toBe(3); + }); + + it("returns a convenience-only suspension observation", async () => { + const { lane, runtimeLane, faux } = await createFixture({ deferred: true }); + faux.setResponses([fauxAssistantMessage("eventual answer")]); + + expect(await lane.prompt("defer", undefined, BACKGROUND_CONTEXT)).toMatchObject({ + ok: true, + value: { + operationId: expect.any(String), + status: "suspended", + deferred: { provider: "faux", modelId: "faux-1" }, + }, + }); + expect(runtimeLane.state.operation?.state.at).toBe("deferred.suspended"); + }); + + it("records caller usage as an adjustment and publishes committed totals", async () => { + const { lane, harness, session } = await createFixture(); + const usage = fauxAssistantMessage("usage").usage; + const events: unknown[] = []; + harness.events.on("usage", (event) => { + events.push(event); + }); + + const recorded = await lane.recordUsage( + usage, + { entryId: "external", details: { source: "test" } }, + BACKGROUND_CONTEXT, + ); + if (!recorded.ok) throw recorded.error; + + expect(events).toEqual([ + expect.objectContaining({ + type: "usage", + lane: "main", + row: expect.objectContaining({ id: recorded.value.usageId, adjustment: true }), + totals: (await session.getStats(BACKGROUND_CONTEXT)).usage, + }), + ]); + }); + + it.each(["steer", "followUp", "nextRun"] as const)( + "starts an ordinary continuation run from queued %s input", + async (kind) => { + const { lane, faux } = await createFixture(); + await lane.appendMessage({ role: "user", content: "history", timestamp: 1 }, BACKGROUND_CONTEXT); + const summaryStarted = deferred(); + const releaseSummary = deferred(); + faux.setResponses([ + async () => { + summaryStarted.resolve(); + await releaseSummary.promise; + return fauxAssistantMessage("summary"); + }, + fauxAssistantMessage("continuation answer"), + ]); + const compacting = lane.compact(undefined, BACKGROUND_CONTEXT); + await summaryStarted.promise; + const queued = await lane[kind]("continue", undefined, BACKGROUND_CONTEXT); + expect(queued.ok).toBe(true); + releaseSummary.resolve(); + + expect(await compacting).toMatchObject({ + ok: true, + value: { + compaction: { kind: "compaction", status: "completed" }, + run: { kind: "run", status: "completed" }, + }, + }); + expect(faux.state.callCount).toBe(2); + }, + ); + + it("lets a competing acceptance win the structural continuation window", async () => { + const { lane, harness, faux } = await createFixture(); + await lane.appendMessage({ role: "user", content: "history", timestamp: 1 }, BACKGROUND_CONTEXT); + const summaryStarted = deferred(); + const releaseSummary = deferred(); + faux.setResponses([ + async () => { + summaryStarted.resolve(); + await releaseSummary.promise; + return fauxAssistantMessage("summary"); + }, + fauxAssistantMessage("competitor answer"), + ]); + let competing: ReturnType | undefined; + harness.events.on("compaction_end", (event) => { + if (event.status === "completed") { + competing = lane.accept({ kind: "prompt", prompt: "competitor" }, BACKGROUND_CONTEXT); + } + }); + const compacting = lane.compact(undefined, BACKGROUND_CONTEXT); + await summaryStarted.promise; + await lane.nextRun("queued", undefined, BACKGROUND_CONTEXT); + releaseSummary.resolve(); + + const compacted = await compacting; + expect(compacted).toMatchObject({ ok: true, value: { compaction: { status: "completed" } } }); + if (!compacted.ok) throw compacted.error; + expect(compacted.value).not.toHaveProperty("run"); + if (competing === undefined) throw new Error("Competing acceptance did not start"); + const admission = await competing; + if (!admission.ok) throw admission.error; + expect(await lane.drive({ operationId: admission.value.operationId }, BACKGROUND_CONTEXT)).toMatchObject({ + ok: true, + value: { kind: "settled", outcome: { status: "completed" } }, + }); + }); + + it("cancels queued input and reports consumed or missing ids", async () => { + const { lane, faux } = await createFixture(); + const cancelled = await lane.nextRun("cancel", undefined, BACKGROUND_CONTEXT); + if (!cancelled.ok) throw cancelled.error; + expect(await lane.cancelQueued(cancelled.value.entryId, BACKGROUND_CONTEXT)).toEqual({ + ok: true, + value: { kind: "cancelled" }, + }); + expect(await lane.cancelQueued(cancelled.value.entryId, BACKGROUND_CONTEXT)).toEqual({ + ok: true, + value: { kind: "not_found" }, + }); + + const consumed = await lane.nextRun("consume", undefined, BACKGROUND_CONTEXT); + if (!consumed.ok) throw consumed.error; + const admission = await lane.accept({ kind: "prompt", prompt: "" }, BACKGROUND_CONTEXT); + if (!admission.ok) throw admission.error; + expect(await lane.cancelQueued(consumed.value.entryId, BACKGROUND_CONTEXT)).toEqual({ + ok: true, + value: { kind: "already_consumed" }, + }); + faux.setResponses([fauxAssistantMessage("answer")]); + await lane.drive({ operationId: admission.value.operationId }, BACKGROUND_CONTEXT); + }); + + it("admits input after cancellation and preserves it through reconciliation", async () => { + const { lane, runtimeLane, faux } = await createFixture(); + await acceptRun(lane, "cancelled"); + await lane.requestAbort("cancelled", BACKGROUND_CONTEXT); + const late = await lane.steer("late", undefined, BACKGROUND_CONTEXT); + expect(late.ok).toBe(true); + expect(await lane.drive({ operationId: "cancelled" }, BACKGROUND_CONTEXT)).toMatchObject({ + ok: true, + value: { kind: "settled", outcome: { status: "aborted" } }, + }); + expect(runtimeLane.state.inbox).toHaveLength(1); + + faux.setResponses([fauxAssistantMessage("late answer")]); + expect(await lane.prompt("", undefined, BACKGROUND_CONTEXT)).toMatchObject({ + ok: true, + value: { kind: "run", status: "completed" }, + }); + }); + + it("composes standalone compaction acceptance with drive", async () => { + const { lane, faux } = await createFixture(); + await lane.appendMessage({ role: "user", content: "history", timestamp: 1 }, BACKGROUND_CONTEXT); + faux.setResponses([fauxAssistantMessage("summary")]); + + expect(await lane.compact(undefined, BACKGROUND_CONTEXT)).toMatchObject({ + ok: true, + value: { compaction: { kind: "compaction", status: "completed" } }, + }); + expect(faux.state.callCount).toBe(1); + }); + + it.each([false, true])("composes %s summarized navigation acceptance with drive", async (summarize) => { + const { lane, session, faux } = await createFixture(); + const rootId = await lane.appendMessage({ role: "user", content: "root", timestamp: 1 }, BACKGROUND_CONTEXT); + await lane.appendMessage({ role: "user", content: "source", timestamp: 2 }, BACKGROUND_CONTEXT); + await session.mutate( + (mutator) => + mutator.commit( + [ + { + kind: "entry", + entry: { + id: "target", + parentId: rootId, + type: "message", + message: { role: "user", content: "target", timestamp: 3 }, + }, + }, + ], + BACKGROUND_CONTEXT, + ), + BACKGROUND_CONTEXT, + ); + if (summarize) faux.setResponses([fauxAssistantMessage("branch summary")]); + + expect(await lane.navigateTree("target", { summarize, label: "chosen" }, BACKGROUND_CONTEXT)).toMatchObject({ + ok: true, + value: { navigation: { kind: "navigation", status: "completed" } }, + }); + expect(faux.state.callCount).toBe(summarize ? 1 : 0); + expect(await lane.getTipId(BACKGROUND_CONTEXT)).not.toBeNull(); + }); + + it("resumes any current operation after acceptance or reopen", async () => { + const { lane, faux } = await createFixture(); + await acceptRun(lane, "run"); + faux.setResponses([fauxAssistantMessage("answer")]); + + expect(await lane.resume(BACKGROUND_CONTEXT)).toMatchObject({ + ok: true, + value: { operationId: "run", kind: "run", status: "completed" }, + }); + }); + + it("polls one deferred permit through resume", async () => { + const { lane, runtimeLane, faux } = await createFixture({ deferred: true }); + faux.setResponses([fauxAssistantMessage("eventual answer")]); + const suspended = await lane.prompt("defer", undefined, BACKGROUND_CONTEXT); + if (!suspended.ok || suspended.value.status !== "suspended") throw new Error("Expected suspended run"); + + expect(await lane.resume(BACKGROUND_CONTEXT)).toMatchObject({ + ok: true, + value: { operationId: suspended.value.operationId, kind: "run", status: "completed" }, + }); + expect(runtimeLane.state.operation).toBeNull(); + expect(faux.state.deferredFetchCount).toBe(1); + expect(await lane.resume(BACKGROUND_CONTEXT)).toMatchObject({ + ok: false, + error: { _tag: "NothingToResume" }, + }); + }); + + it("aborts and reconciles the current operation", async () => { + const { lane, runtimeLane, faux } = await createFixture(); + await acceptRun(lane, "run"); + + expect(await lane.abort(BACKGROUND_CONTEXT)).toEqual({ + ok: true, + value: { operationId: "run", steer: [], followUp: [] }, + }); + expect(runtimeLane.state.operation).toBeNull(); + expect(faux.state.callCount).toBe(0); + expect(await lane.abort(BACKGROUND_CONTEXT)).toMatchObject({ + ok: false, + error: { _tag: "NoActiveOperation" }, + }); + }); + + it("waits for an operation that has no installed drive", async () => { + const { lane, faux } = await createFixture(); + await acceptRun(lane, "run"); + let idle = false; + const waiting = lane.waitForIdle(BACKGROUND_CONTEXT).then(() => { + idle = true; + }); + await Promise.resolve(); + expect(idle).toBe(false); + + faux.setResponses([fauxAssistantMessage("answer")]); + await lane.drive({ operationId: "run" }, BACKGROUND_CONTEXT); + await waiting; + expect(idle).toBe(true); + }); + + it("serializes concurrent runWhenIdle callbacks", async () => { + const { lane } = await createFixture(); + const firstStarted = deferred(); + const releaseFirst = deferred(); + const order: string[] = []; + const first = lane.runWhenIdle(async () => { + order.push("first:start"); + firstStarted.resolve(); + await releaseFirst.promise; + order.push("first:end"); + }, BACKGROUND_CONTEXT); + await firstStarted.promise; + const second = lane.runWhenIdle(() => { + order.push("second"); + }, BACKGROUND_CONTEXT); + await Promise.resolve(); + expect(order).toEqual(["first:start"]); + + releaseFirst.resolve(); + await Promise.all([first, second]); + expect(order).toEqual(["first:start", "first:end", "second"]); + }); + + it("owns the idle window while runWhenIdle executes", async () => { + const { lane, faux } = await createFixture(); + const callbackStarted = deferred(); + const releaseCallback = deferred(); + const callback = lane.runWhenIdle(async () => { + callbackStarted.resolve(); + await releaseCallback.promise; + }, BACKGROUND_CONTEXT); + await callbackStarted.promise; + let accepted = false; + const acceptance = lane.accept({ kind: "prompt", prompt: "after" }, BACKGROUND_CONTEXT).then((result) => { + accepted = true; + return result; + }); + await Promise.resolve(); + expect(accepted).toBe(false); + + releaseCallback.resolve(); + await callback; + const admission = await acceptance; + if (!admission.ok) throw admission.error; + faux.setResponses([fauxAssistantMessage("answer")]); + await lane.drive({ operationId: admission.value.operationId }, BACKGROUND_CONTEXT); + }); + + it("releases idle ownership when the callback fails", async () => { + const { lane } = await createFixture(); + const failure = new Error("callback failed"); + + await expect( + lane.runWhenIdle(() => { + throw failure; + }, BACKGROUND_CONTEXT), + ).rejects.toBe(failure); + expect(await lane.accept({ kind: "prompt", prompt: "after" }, BACKGROUND_CONTEXT)).toMatchObject({ ok: true }); + }); + + it("close waits for an already-running idle callback", async () => { + const { lane, harness } = await createFixture(); + const callbackStarted = deferred(); + const releaseCallback = deferred(); + const callback = lane.runWhenIdle(async () => { + callbackStarted.resolve(); + await releaseCallback.promise; + }, BACKGROUND_CONTEXT); + await callbackStarted.promise; + let closed = false; + const closing = harness.close(BACKGROUND_CONTEXT).then(() => { + closed = true; + }); + await Promise.resolve(); + expect(closed).toBe(false); + + releaseCallback.resolve(); + await Promise.all([callback, closing]); + expect(closed).toBe(true); + }); + + it("installs one pass and joins same-operation callers", async () => { + const { lane, runtimeLane, faux } = await createFixture(); + const started = deferred(); + const release = deferred(); + faux.setResponses([ + async () => { + started.resolve(); + await release.promise; + return fauxAssistantMessage("answer"); + }, + ]); + const admission = await acceptRun(lane, "run"); + + const first = lane.drive({ operationId: admission.operationId }, BACKGROUND_CONTEXT); + await started.promise; + const second = lane.drive({ operationId: admission.operationId }, BACKGROUND_CONTEXT); + expect(runtimeLane.activeDrive?.operationId).toBe(admission.operationId); + release.resolve(); + + const [firstResult, secondResult] = await Promise.all([first, second]); + expect(firstResult).toMatchObject({ + ok: true, + value: { kind: "settled", outcome: { operationId: "run", status: "completed" } }, + }); + expect(secondResult).toEqual(firstResult); + expect(faux.state.callCount).toBe(1); + expect(runtimeLane.activeDrive).toBeUndefined(); + }); + + it("returns old result records without disturbing the current operation", async () => { + const { lane, runtimeLane, faux } = await createFixture(); + faux.setResponses([fauxAssistantMessage("first")]); + await acceptRun(lane, "first"); + expect(await lane.drive({ operationId: "first" }, BACKGROUND_CONTEXT)).toMatchObject({ + ok: true, + value: { kind: "settled", outcome: { operationId: "first" } }, + }); + await acceptRun(lane, "second"); + + expect(await lane.drive({ operationId: "first" }, BACKGROUND_CONTEXT)).toMatchObject({ + ok: true, + value: { kind: "settled", outcome: { operationId: "first" } }, + }); + expect(runtimeLane.state.operation?.meta.operationId).toBe("second"); + expect(runtimeLane.activeDrive).toBeUndefined(); + + faux.setResponses([fauxAssistantMessage("second")]); + expect(await lane.drive({ operationId: "second" }, BACKGROUND_CONTEXT)).toMatchObject({ + ok: true, + value: { kind: "settled", outcome: { operationId: "second" } }, + }); + }); + + it("isolates stale operation ids", async () => { + const { lane, runtimeLane, faux } = await createFixture(); + await acceptRun(lane, "current"); + + expect(await lane.drive({ operationId: "stale" }, BACKGROUND_CONTEXT)).toMatchObject({ + ok: false, + error: { _tag: "OperationMismatch", expectedOperationId: "stale", currentOperationId: "current" }, + }); + expect(runtimeLane.activeDrive).toBeUndefined(); + expect(faux.state.callCount).toBe(0); + }); + + it("does not install for a caller already cancelled", async () => { + const { lane, runtimeLane, faux } = await createFixture(); + await acceptRun(lane, "run"); + const caller = withCancel(BACKGROUND_CONTEXT); + const cancelled = new Error("caller cancelled"); + caller.cancel(cancelled); + + await expect(lane.drive({ operationId: "run" }, caller.context)).rejects.toBe(cancelled); + expect(runtimeLane.activeDrive).toBeUndefined(); + expect(faux.state.callCount).toBe(0); + }); + + it("caller cancellation stops only that caller's observation", async () => { + const { lane, runtimeLane, faux } = await createFixture(); + const started = deferred(); + const release = deferred(); + faux.setResponses([ + async () => { + started.resolve(); + await release.promise; + return fauxAssistantMessage("answer"); + }, + ]); + await acceptRun(lane, "run"); + const owner = lane.drive({ operationId: "run" }, BACKGROUND_CONTEXT); + await started.promise; + const caller = withCancel(BACKGROUND_CONTEXT); + const observer = lane.drive({ operationId: "run" }, caller.context); + const cancelled = new Error("observer cancelled"); + caller.cancel(cancelled); + + await expect(observer).rejects.toBe(cancelled); + expect(runtimeLane.activeDrive?.operationId).toBe("run"); + release.resolve(); + expect(await owner).toMatchObject({ ok: true, value: { kind: "settled" } }); + expect(faux.state.callCount).toBe(1); + }); + + it("close rejects observation without waiting for a non-cooperative effect", async () => { + const { lane, runtimeLane, harness, faux } = await createFixture(); + const started = deferred(); + const release = deferred(); + faux.setResponses([ + async () => { + started.resolve(); + await release.promise; + return fauxAssistantMessage("late"); + }, + ]); + await acceptRun(lane, "run"); + const observation = lane.drive({ operationId: "run" }, BACKGROUND_CONTEXT); + await started.promise; + + await harness.close(BACKGROUND_CONTEXT); + await expect(observation).rejects.toBeInstanceOf(HarnessClosed); + release.resolve(); + await expect.poll(() => runtimeLane.activeDrive).toBeUndefined(); + }); + + it("exposes durable abort and reconciles through public drive", async () => { + const { lane, runtimeLane, faux } = await createFixture(); + await acceptRun(lane, "run"); + + expect(await lane.requestAbort("run", BACKGROUND_CONTEXT)).toMatchObject({ + ok: true, + value: { operationId: "run", newlyRequested: true, steer: [], followUp: [] }, + }); + expect(runtimeLane.activeDrive).toBeUndefined(); + expect(await lane.drive({ operationId: "run" }, BACKGROUND_CONTEXT)).toMatchObject({ + ok: true, + value: { kind: "settled", outcome: { operationId: "run", status: "aborted" } }, + }); + expect(faux.state.callCount).toBe(0); + expect(await lane.requestAbort("run", BACKGROUND_CONTEXT)).toMatchObject({ + ok: false, + error: { _tag: "OperationMismatch", expectedOperationId: "run", lastOperationId: "run" }, + }); + }); + + it("faults the harness when a detached pass fails", async () => { + const { lane, runtimeLane, harness } = await createFixture(); + harness.hooks.on("before_drive", () => { + throw new Error("drive failed"); + }); + await acceptRun(lane, "run"); + + await expect(lane.drive({ operationId: "run" }, BACKGROUND_CONTEXT)).rejects.toBeInstanceOf(HarnessFault); + expect(runtimeLane.activeDrive).toBeUndefined(); + await expect(lane.getTipId(BACKGROUND_CONTEXT)).rejects.toBeInstanceOf(HarnessFault); + }); +}); diff --git a/packages/agent/test/harness/runtime/drive-structural.test.ts b/packages/agent/test/harness/runtime/drive-structural.test.ts index 672456957..2df1db0c6 100644 --- a/packages/agent/test/harness/runtime/drive-structural.test.ts +++ b/packages/agent/test/harness/runtime/drive-structural.test.ts @@ -684,7 +684,10 @@ describe("runtime structural drive", () => { 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 }); + expect(fixture.events.at(-1)).toMatchObject({ + type: "queue_update", + queues: [{ entryId, kind: "write", type: "custom" }], + }); }); it("publishes overflow preparation with the normalized response settlement", async () => { diff --git a/packages/agent/test/harness/runtime/harness.test.ts b/packages/agent/test/harness/runtime/harness.test.ts index c612fa370..3f90ee021 100644 --- a/packages/agent/test/harness/runtime/harness.test.ts +++ b/packages/agent/test/harness/runtime/harness.test.ts @@ -1,4 +1,4 @@ -import { createModels, fauxProvider } from "@earendil-works/pi-ai"; +import { createModels, fauxAssistantMessage, fauxProvider, type Provider } from "@earendil-works/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; import { AgentHarness, HarnessFault, InvalidLane, UnknownTarget } from "../../../src/harness/agent-harness.ts"; import { BACKGROUND_CONTEXT } from "../../../src/harness/context.ts"; @@ -102,6 +102,43 @@ describe("runtime Harness lane management", () => { expect(listener).toHaveBeenCalledTimes(1); }); + it("uses one stable provider session id per lane", async () => { + const session = await createSession("shared-session"); + const faux = fauxProvider(); + faux.setResponses([ + fauxAssistantMessage("main one"), + fauxAssistantMessage("main two"), + fauxAssistantMessage("review"), + ]); + const sessionIds: Array = []; + const base = faux.provider; + const provider: Provider = { + id: base.id, + name: base.name, + auth: base.auth, + getModels: () => base.getModels(), + stream: (model, context, options) => base.stream(model, context, options), + streamSimple: (model, context, options) => { + sessionIds.push(options?.sessionId); + return base.streamSimple(model, context, options); + }, + }; + const models = createModels(); + models.setProvider(provider); + const created = await AgentHarness.create( + { session, models, model: faux.getModel(), activeToolNames: [] }, + BACKGROUND_CONTEXT, + ); + const main = await created.harness.lane("main", BACKGROUND_CONTEXT); + const review = await created.harness.lane("review", BACKGROUND_CONTEXT); + + await main.prompt("one", undefined, BACKGROUND_CONTEXT); + await main.prompt("two", undefined, BACKGROUND_CONTEXT); + await review.prompt("review", undefined, BACKGROUND_CONTEXT); + + expect(sessionIds).toEqual(["shared-session:main", "shared-session:main", "shared-session:review"]); + }); + it("serializes commands from different AgentLanes on the one Session line", async () => { const harness = await createHarness(); const main = await harness.lane("main", BACKGROUND_CONTEXT); @@ -315,6 +352,32 @@ describe("runtime Harness global metadata", () => { expect(seen).toEqual(["name:named", "entry_label"]); }); + it("publishes previous and current data-bearing global configuration", async () => { + const harness = await createHarness(); + const events: unknown[] = []; + harness.events.on("config_update", (event) => { + events.push(event); + }); + + await harness.setStreamOptions({ timeoutMs: 123 }, BACKGROUND_CONTEXT); + await harness.setSteeringMode("one-at-a-time", BACKGROUND_CONTEXT); + + expect(events).toEqual([ + expect.objectContaining({ + type: "config_update", + property: "streamOptions", + previous: {}, + value: { timeoutMs: 123 }, + }), + expect.objectContaining({ + type: "config_update", + property: "steeringMode", + previous: "all", + value: "one-at-a-time", + }), + ]); + }); + it("closes every lane and rejects later acquisition", async () => { const harness = await createHarness(); const lane = await harness.lane("main", BACKGROUND_CONTEXT); diff --git a/packages/agent/test/harness/runtime/lane.test.ts b/packages/agent/test/harness/runtime/lane.test.ts index c43d5d417..81c42e2cd 100644 --- a/packages/agent/test/harness/runtime/lane.test.ts +++ b/packages/agent/test/harness/runtime/lane.test.ts @@ -62,7 +62,12 @@ async function createLane( await restoreLane(session, "main", BACKGROUND_CONTEXT), (cause) => (cause instanceof Error ? cause : new Error(String(cause))), emitBatch, - (snapshot) => ({ snapshot, start: () => {}, unsubscribe: () => {} }), + (snapshot) => ({ + snapshot, + start: () => {}, + resnapshot: () => Promise.resolve(snapshot), + unsubscribe: () => {}, + }), () => ({ tools: [], resources: {}, @@ -110,7 +115,7 @@ describe("runtime Lane commands", () => { const { lane, model, session } = await createLane(); const activeToolNames = ["read"]; - await lane.setModel(model, BACKGROUND_CONTEXT); + await lane.setModel({ provider: model.provider, modelId: model.id }, BACKGROUND_CONTEXT); await lane.setThinkingLevel("high", BACKGROUND_CONTEXT); await lane.setActiveTools(activeToolNames, BACKGROUND_CONTEXT); @@ -133,7 +138,7 @@ describe("runtime Lane commands", () => { await releaseCommit.promise; }; - const modelUpdate = lane.setModel(model, BACKGROUND_CONTEXT); + const modelUpdate = lane.setModel({ provider: model.provider, modelId: model.id }, BACKGROUND_CONTEXT); await commitStarted.promise; const thinkingUpdate = lane.setThinkingLevel("high", BACKGROUND_CONTEXT); releaseCommit.resolve(); diff --git a/packages/agent/test/harness/runtime/reducer.test.ts b/packages/agent/test/harness/runtime/reducer.test.ts new file mode 100644 index 000000000..3006a3e7e --- /dev/null +++ b/packages/agent/test/harness/runtime/reducer.test.ts @@ -0,0 +1,205 @@ +import { createModels, fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it } from "vitest"; +import { AgentHarness, type HarnessEvent, type LaneSnapshot } from "../../../src/harness/agent-harness.ts"; +import { BACKGROUND_CONTEXT } from "../../../src/harness/context.ts"; +import { reduceLaneSnapshot } from "../../../src/harness/runtime/reducer.ts"; +import { MemoryStorage } from "../../../src/harness/session/memory.ts"; +import { StorageBackedSession } from "../../../src/harness/session/session.ts"; +import type { Session } from "../../../src/harness/session/types.ts"; + +const sessions: Session[] = []; + +async function createFixture(options: { deferred?: boolean } = {}) { + const session = new StorageBackedSession( + { id: `reducer-${sessions.length}`, createdAt: 1, storageVersion: 1 }, + new MemoryStorage(), + ); + sessions.push(session); + const faux = fauxProvider(); + const models = createModels(); + models.setProvider(faux.provider); + const { harness } = await AgentHarness.create( + { + session, + models, + model: faux.getModel(), + ...(options.deferred === true ? { streamOptions: { deferred: true } } : {}), + }, + BACKGROUND_CONTEXT, + ); + const lane = await harness.lane("main", BACKGROUND_CONTEXT); + return { harness, lane, faux }; +} + +async function settleEvents(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +function fold(snapshot: LaneSnapshot, events: HarnessEvent[]): LaneSnapshot { + let current = snapshot; + for (const event of events) { + const reduced = reduceLaneSnapshot(current, event); + if ("rebase" in reduced) throw new Error(`Unexpected rebase for ${event.type}`); + current = reduced; + } + return current; +} + +afterEach(async () => { + for (const session of sessions.splice(0)) await session.close(BACKGROUND_CONTEXT); +}); + +describe("lane snapshot reducer", () => { + it("folds an ordinary run to the authoritative resnapshot", async () => { + const { lane, faux } = await createFixture(); + const watch = await lane.watch(BACKGROUND_CONTEXT); + const events: HarnessEvent[] = []; + watch.start((event) => { + events.push(event); + }); + faux.setResponses([fauxAssistantMessage("answer")]); + + expect(await lane.prompt("question", undefined, BACKGROUND_CONTEXT)).toMatchObject({ + ok: true, + value: { kind: "run", status: "completed" }, + }); + await settleEvents(); + + expect(fold(watch.snapshot, events)).toEqual(await watch.resnapshot(BACKGROUND_CONTEXT)); + watch.unsubscribe(); + }); + + it("folds suspend and resume without closing the operation early", async () => { + const { lane, faux } = await createFixture({ deferred: true }); + const watch = await lane.watch(BACKGROUND_CONTEXT); + const events: HarnessEvent[] = []; + watch.start((event) => { + events.push(event); + }); + faux.setResponses([fauxAssistantMessage("answer")]); + + expect(await lane.prompt("question", undefined, BACKGROUND_CONTEXT)).toMatchObject({ + ok: true, + value: { status: "suspended" }, + }); + await settleEvents(); + let replica = fold(watch.snapshot, events); + expect(replica.operation).toMatchObject({ kind: "run", deferred: { poll: 0 } }); + expect(replica).toEqual(await watch.resnapshot(BACKGROUND_CONTEXT)); + + events.length = 0; + expect(await lane.resume(BACKGROUND_CONTEXT)).toMatchObject({ + ok: true, + value: { kind: "run", status: "completed" }, + }); + await settleEvents(); + replica = fold(watch.snapshot, events); + expect(replica).toEqual(await watch.resnapshot(BACKGROUND_CONTEXT)); + watch.unsubscribe(); + }); + + it("folds standalone compaction and preserves segment semantics", async () => { + const { lane, faux } = await createFixture(); + await lane.appendMessage({ role: "user", content: "history", timestamp: 1 }, BACKGROUND_CONTEXT); + const watch = await lane.watch(BACKGROUND_CONTEXT); + const events: HarnessEvent[] = []; + watch.start((event) => { + events.push(event); + }); + faux.setResponses([fauxAssistantMessage("summary")]); + + expect(await lane.compact(undefined, BACKGROUND_CONTEXT)).toMatchObject({ + ok: true, + value: { compaction: { status: "completed" } }, + }); + await settleEvents(); + const replica = fold(watch.snapshot, events); + + expect(replica.operation).toBeNull(); + expect(replica.lastResult).toMatchObject({ kind: "compaction", status: "completed" }); + expect(replica).toEqual(await watch.resnapshot(BACKGROUND_CONTEXT)); + watch.unsubscribe(); + }); + + it("replicates globally ordered queue changes", async () => { + const { lane } = await createFixture(); + const watch = await lane.watch(BACKGROUND_CONTEXT); + const events: HarnessEvent[] = []; + watch.start((event) => { + events.push(event); + }); + + await lane.nextRun("next", undefined, BACKGROUND_CONTEXT); + const steer = await lane.steer("steer", undefined, BACKGROUND_CONTEXT); + await lane.followUp("follow", undefined, BACKGROUND_CONTEXT); + if (!steer.ok) throw steer.error; + await lane.cancelQueued(steer.value.entryId, BACKGROUND_CONTEXT); + await settleEvents(); + + const replica = fold(watch.snapshot, events); + expect(replica.queues.map((item) => item.kind)).toEqual(["nextRun", "followUp"]); + expect(replica).toEqual(await watch.resnapshot(BACKGROUND_CONTEXT)); + watch.unsubscribe(); + }); + + it("keeps in-run compaction segments inside the open run", async () => { + const { lane } = await createFixture(); + const snapshot = (await lane.watch(BACKGROUND_CONTEXT)).snapshot; + const running: LaneSnapshot = { + ...snapshot, + operation: { + id: "run", + kind: "run", + startedAt: 1, + fromTipId: null, + status: "open", + runningTools: [], + }, + }; + const started = reduceLaneSnapshot(running, { + type: "compaction_start", + lane: "main", + runId: "run", + reason: "threshold", + startedAt: 2, + }); + if ("rebase" in started) throw new Error("Unexpected segment rebase"); + const ended = reduceLaneSnapshot(started, { + type: "compaction_end", + lane: "main", + runId: "run", + reason: "threshold", + status: "declined", + endedAt: 3, + }); + expect(ended).toMatchObject({ operation: { id: "run", kind: "run" } }); + }); + + it("marks navigation completion for rebase", async () => { + const { lane } = await createFixture(); + const snapshot = (await lane.watch(BACKGROUND_CONTEXT)).snapshot; + const reduced = reduceLaneSnapshot( + { + ...snapshot, + operation: { + id: "navigation", + kind: "navigation", + startedAt: 1, + fromTipId: null, + status: "open", + runningTools: [], + }, + }, + { + type: "navigation_end", + lane: "main", + runId: "navigation", + status: "completed", + fromTipId: null, + tipId: "target", + endedAt: 2, + }, + ); + expect(reduced).toEqual({ rebase: true }); + }); +}); diff --git a/packages/agent/test/harness/runtime/watch.test.ts b/packages/agent/test/harness/runtime/watch.test.ts index 0debfbf66..76258b960 100644 --- a/packages/agent/test/harness/runtime/watch.test.ts +++ b/packages/agent/test/harness/runtime/watch.test.ts @@ -128,9 +128,10 @@ describe("runtime lane watch", () => { expect(first.snapshot).toMatchObject({ lane: "main", tipId: "after", + configuration, + stats: { messageCount: 1 }, operation: null, - queues: { steer: [], followUp: [], nextRun: [] }, - pendingWrites: [], + queues: [], faulted: false, }); first.snapshot.transcript.length = 0; @@ -199,11 +200,11 @@ describe("runtime lane watch", () => { const watch = await harness.watch(BACKGROUND_CONTEXT); - expect(watch.snapshot.queues.steer).toMatchObject([{ entryId: ids.steer, message: { content: ids.steer } }]); - expect(watch.snapshot.queues.followUp).toMatchObject([{ entryId: ids.follow, message: { content: ids.follow } }]); - expect(watch.snapshot.queues.nextRun).toMatchObject([{ entryId: ids.next, message: { content: ids.next } }]); - expect(watch.snapshot.pendingWrites).toEqual([ - { entryId: ids.write, type: "custom", customType: "note", data: { id: ids.write } }, + expect(watch.snapshot.queues).toMatchObject([ + { entryId: ids.next, kind: "nextRun", type: "message", message: { content: ids.next } }, + { entryId: ids.steer, kind: "steer", type: "message", message: { content: ids.steer } }, + { entryId: ids.follow, kind: "followUp", type: "message", message: { content: ids.follow } }, + { entryId: ids.write, kind: "write", type: "custom", customType: "note", data: { id: ids.write } }, ]); expect(watch.snapshot.operation).toMatchObject({ id: operationId, diff --git a/packages/agent/test/harness/types.test.ts b/packages/agent/test/harness/types.test.ts index cc7617d06..fa7a5a53f 100644 --- a/packages/agent/test/harness/types.test.ts +++ b/packages/agent/test/harness/types.test.ts @@ -413,7 +413,6 @@ it("covers Part 5 results, events, hooks, snapshots, tools, and stream options", | "tool_update" | "tool_end" | "entry_added" - | "write_pending" | "queue_update" | "value_update" | "config_update" diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index f551788a5..f5033c2f8 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -287,11 +287,14 @@ export class Client { this.#requireSessionTarget(sessionId); const { watchId, snapshot } = await this.#rpc.watch(); const connection = this.#hello; + let currentSnapshot = snapshot; let state: "ready" | "starting" | "started" | "disposed" = "ready"; return { id: watchId, sessionId, - snapshot, + get snapshot() { + return currentSnapshot; + }, start: async (listener) => { if (state !== "ready") throw new Error("Lane watch may be started only once"); if (this.#watchListeners.has(watchId)) { @@ -310,6 +313,13 @@ export class Client { throw error; } }, + resnapshot: async () => { + if (state === "disposed") throw new Error("Lane watch is disposed"); + this.#requireSessionTarget(sessionId); + const refreshed = await this.#rpc.resnapshotWatch(watchId); + currentSnapshot = refreshed.snapshot; + return currentSnapshot; + }, dispose: async () => { if (state === "disposed") return; state = "disposed"; @@ -477,6 +487,7 @@ export class Client { case "prompt": case "watch": case "startWatch": + case "resnapshotWatch": case "stopWatch": return this.#requireSessionTarget(); } diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index 89b74451d..c4b47ee2f 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -23,6 +23,7 @@ export interface LaneWatch { readonly sessionId: string; readonly snapshot: LaneSnapshot; start(listener: (event: LaneEvent) => void | Promise): Promise; + resnapshot(): Promise; dispose(): Promise; } diff --git a/packages/client/test/client.test.ts b/packages/client/test/client.test.ts index 32fc1d417..d139f8149 100644 --- a/packages/client/test/client.test.ts +++ b/packages/client/test/client.test.ts @@ -152,16 +152,22 @@ describe("Client service operations", () => { }, call: { serviceId: "pi.chat", member: "prompt", args: [["Hello"]] }, }); + const completed = { + operationId: "run-1", + kind: "run" as const, + status: "completed" as const, + fromTipId: null, + tipId: "leaf-1", + startedAt: 1, + endedAt: 2, + }; server.send({ type: "response", id: "request-4", ok: true, - result: { ok: true, value: { kind: "completed", runId: "run-1", tipId: "leaf-1" } }, - }); - await expect(prompting).resolves.toEqual({ - ok: true, - value: { kind: "completed", runId: "run-1", tipId: "leaf-1" }, + result: { ok: true, value: completed }, }); + await expect(prompting).resolves.toEqual({ ok: true, value: completed }); await client.dispose(); }); @@ -247,9 +253,24 @@ describe("Client service operations", () => { lane: "main", transcript: [], tipId: null, + configuration: { + model: { provider: "faux", modelId: "faux-1" }, + thinkingLevel: "off", + activeToolNames: [], + }, + stats: { + messageCount: 0, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }, operation: null, - queues: { steer: [], followUp: [], nextRun: [] }, - pendingWrites: [], + queues: [], faulted: false, }, }, @@ -273,22 +294,47 @@ describe("Client service operations", () => { server.send({ type: "event", watchId: "watch-1", - event: { type: "run_start", lane: "main", runId: "run-1" }, + event: { type: "run_start", lane: "main", runId: "run-1", startedAt: 1 }, }); await Promise.resolve(); expect(events).toEqual([]); server.send({ type: "response", id: "request-3", ok: true, result: { watchId: "watch-1" } }); await starting; + const refreshing = watch.resnapshot(); + await server.waitForMessages(5); + expect(server.messages[4]).toMatchObject({ + call: { serviceId: "pi.transcript", member: "resnapshotWatch", args: ["watch-1"] }, + }); + const refreshed = { + ...watch.snapshot, + operation: { + id: "run-1", + kind: "run" as const, + startedAt: 1, + fromTipId: null, + status: "open" as const, + runningTools: [], + }, + }; + server.send({ + type: "response", + id: "request-4", + ok: true, + result: { watchId: "watch-1", snapshot: refreshed }, + }); + expect(await refreshing).toEqual(refreshed); + expect(watch.snapshot).toEqual(refreshed); + let disposed = false; const disposing = watch.dispose().then(() => { disposed = true; }); - await server.waitForMessages(5); - expect(server.messages[4]).toMatchObject({ + await server.waitForMessages(6); + expect(server.messages[5]).toMatchObject({ call: { serviceId: "pi.transcript", member: "stopWatch", args: ["watch-1"] }, }); - server.send({ type: "response", id: "request-4", ok: true, result: { watchId: "watch-1" } }); + server.send({ type: "response", id: "request-5", ok: true, result: { watchId: "watch-1" } }); await Promise.resolve(); expect(disposed).toBe(false); releaseEvent(); diff --git a/packages/coding-agent/src/experimental/harness-wire-adapter.ts b/packages/coding-agent/src/experimental/harness-wire-adapter.ts index ba203dea7..46f6096b6 100644 --- a/packages/coding-agent/src/experimental/harness-wire-adapter.ts +++ b/packages/coding-agent/src/experimental/harness-wire-adapter.ts @@ -29,6 +29,7 @@ import { type LaneEntry, type LaneEvent, type LaneSnapshot, + type OperationResultRecord, type PromptArguments, type PromptImage, type PromptMessage, @@ -251,6 +252,10 @@ function toWireRunValue(value: Extract["value"]) if (value.status === "suspended") { return { operationId: value.operationId, status: "suspended", deferred: toWireDeferred(value.deferred) }; } + return toWireOperationResult(value); +} + +function toWireOperationResult(value: NonNullable): OperationResultRecord { const base = { operationId: value.operationId, kind: value.kind, @@ -598,12 +603,30 @@ function toWireFrame(frame: AiAssistantMessageFrame): AssistantMessageFrame { } } +function toWireQueuedItem(item: HarnessLaneSnapshot["queues"][number]): LaneSnapshot["queues"][number] { + return item.type === "message" + ? { entryId: item.entryId, kind: item.kind, type: "message", message: toWireMessage(item.message) } + : { + entryId: item.entryId, + kind: "write", + type: "custom", + customType: item.customType, + ...(item.data === undefined ? {} : { data: item.data }), + }; +} + export function toWireLaneSnapshot(snapshot: HarnessLaneSnapshot): LaneSnapshot { return { lane: snapshot.lane, transcript: snapshot.transcript.map(toWireEntry), tipId: snapshot.tipId, - lastOperationId: snapshot.lastOperationId, + ...(snapshot.lastResult === undefined ? {} : { lastResult: toWireOperationResult(snapshot.lastResult) }), + configuration: { + model: { ...snapshot.configuration.model }, + thinkingLevel: snapshot.configuration.thinkingLevel, + activeToolNames: [...snapshot.configuration.activeToolNames], + }, + stats: { messageCount: snapshot.stats.messageCount, usage: toHarnessUsage(snapshot.stats.usage) }, operation: snapshot.operation === null ? null @@ -612,6 +635,7 @@ export function toWireLaneSnapshot(snapshot: HarnessLaneSnapshot): LaneSnapshot kind: snapshot.operation.kind, status: snapshot.operation.status, startedAt: snapshot.operation.startedAt, + fromTipId: snapshot.operation.fromTipId, ...(snapshot.operation.deferred === undefined ? {} : { @@ -633,41 +657,35 @@ export function toWireLaneSnapshot(snapshot: HarnessLaneSnapshot): LaneSnapshot })), ...(snapshot.operation.retry === undefined ? {} : { retry: { ...snapshot.operation.retry } }), }, - queues: { - steer: snapshot.queues.steer.map((item) => ({ entryId: item.entryId, message: toWireMessage(item.message) })), - followUp: snapshot.queues.followUp.map((item) => ({ - entryId: item.entryId, - message: toWireMessage(item.message), - })), - nextRun: snapshot.queues.nextRun.map((item) => ({ - entryId: item.entryId, - message: toWireMessage(item.message), - })), - }, - pendingWrites: snapshot.pendingWrites.map((pending) => ({ - entryId: pending.entryId, - type: pending.type, - ...(pending.customType === undefined ? {} : { customType: pending.customType }), - ...(pending.message === undefined ? {} : { message: toWireMessage(pending.message) }), - ...(pending.data === undefined ? {} : { data: pending.data }), - })), + queues: snapshot.queues.map(toWireQueuedItem), faulted: snapshot.faulted, }; } +function toWireOperationError(error: Extract["error"]) { + if (error === undefined) return undefined; + return { + code: error.code, + message: error.message, + ...(error.details === undefined ? {} : { details: toWireJsonValue(error.details, "operation error details") }), + }; +} + export function toWireLaneEvent(event: HarnessEvent): LaneEvent | undefined { const lane = "lane" in event && typeof event.lane === "string" ? event.lane : undefined; const base = lane === undefined ? {} : { lane, ...(event.recovery === true ? { recovery: true as const } : {}) }; switch (event.type) { case "run_start": + return { type: "run_start", runId: event.runId, startedAt: event.startedAt, ...base, lane: event.lane }; case "run_resume": - return { type: event.type, runId: event.runId, ...base, lane: event.lane }; + return { type: "run_resume", runId: event.runId, ...base, lane: event.lane }; case "run_suspend": return { type: "run_suspend", runId: event.runId, reason: "deferred", deferred: toWireDeferred(event.deferred), + poll: event.poll, ...base, lane: event.lane, }; @@ -682,32 +700,23 @@ export function toWireLaneEvent(event: HarnessEvent): LaneEvent | undefined { }; 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, error: toWireOperationError(event.error)!, ...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 }; case "message_update": - return event.frame === undefined - ? undefined - : { - type: "message_update", - runId: event.runId, - frame: toWireFrame(event.frame), - ...base, - lane: event.lane, - }; + if (event.message.role !== "assistant") { + throw new TypeError("Harness message_update did not carry an assistant message"); + } + return { + type: "message_update", + runId: event.runId, + message: toWireAssistantMessage(event.message), + ...(event.frame === undefined ? {} : { frame: toWireFrame(event.frame) }), + ...base, + lane: event.lane, + }; case "tool_start": return { ...event, @@ -722,32 +731,68 @@ export function toWireLaneEvent(event: HarnessEvent): LaneEvent | undefined { case "entry_added": return { type: "entry_added", entry: toWireEntry(event.entry), ...base, lane: event.lane }; case "queue_update": - return { - type: "queue_update", - steer: event.steer.map((item) => ({ entryId: item.entryId, message: toWireMessage(item.message) })), - followUp: event.followUp.map((item) => ({ entryId: item.entryId, message: toWireMessage(item.message) })), - nextRun: event.nextRun.map((item) => ({ entryId: item.entryId, message: toWireMessage(item.message) })), - ...base, - lane: event.lane, - }; + return { type: "queue_update", queues: event.queues.map(toWireQueuedItem), ...base, lane: event.lane }; case "retry_scheduled": case "retry_start": case "retry_end": + case "compaction_start": + case "navigation_start": return { ...event, ...base, lane: event.lane }; + case "compaction_end": + case "navigation_end": + return event.status === "failed" + ? { ...event, error: toWireOperationError(event.error)!, ...base, lane: event.lane } + : { ...event, ...base, lane: event.lane }; + case "usage": + return { + type: "usage", + lane: event.lane, + row: { + ...event.row, + usage: toHarnessUsage(event.row.usage), + ...(event.row.details === undefined + ? {} + : { details: toWireJsonValue(event.row.details, "usage details") }), + }, + totals: toHarnessUsage(event.totals), + }; + case "config_update": + switch (event.property) { + case "tools": + case "resources": + return { type: "config_update", property: event.property }; + case "model": + case "thinkingLevel": + case "activeTools": + return { + type: "config_update", + property: event.property, + value: toWireJsonValue(event.value, `config ${event.property}`), + previous: toWireJsonValue(event.previous, `previous config ${event.property}`), + ...base, + lane: event.lane, + }; + case "streamOptions": + case "retryPolicy": + case "compactionSettings": + case "steeringMode": + case "followUpMode": + return { + type: "config_update", + property: event.property, + value: toWireJsonValue(event.value, `config ${event.property}`), + previous: toWireJsonValue(event.previous, `previous config ${event.property}`), + }; + default: + return assertNever(event); + } case "fault": return { type: "fault", code: event.code, message: event.message }; case "handler_error": case "turn_start": case "turn_end": - case "write_pending": case "value_update": - case "config_update": - case "compaction_start": - case "compaction_end": - case "navigation_start": - case "navigation_end": case "lane_created": - case "usage": return undefined; default: return assertNever(event); diff --git a/packages/coding-agent/src/experimental/services/models-provider.ts b/packages/coding-agent/src/experimental/services/models-provider.ts index f300a414a..e56edfd65 100644 --- a/packages/coding-agent/src/experimental/services/models-provider.ts +++ b/packages/coding-agent/src/experimental/services/models-provider.ts @@ -90,7 +90,7 @@ export function createModelsService( async select(model, context) { const selected = modelRuntime?.getModel(model.provider, model.modelId); if (selected === undefined) throw new Error(`Unknown model: ${model.provider}/${model.modelId}`); - await lane.setModel(selected, context); + await lane.setModel({ provider: selected.provider, modelId: selected.id }, context); state.set({ ...state.value, configuration: await readConfiguration(context) }, context); }, }; diff --git a/packages/coding-agent/src/experimental/session-worker-manager.ts b/packages/coding-agent/src/experimental/session-worker-manager.ts index 03fe54e35..fa2c57ea0 100644 --- a/packages/coding-agent/src/experimental/session-worker-manager.ts +++ b/packages/coding-agent/src/experimental/session-worker-manager.ts @@ -309,6 +309,12 @@ export class SessionWorkerManager { throw error; } }, + resnapshot: async (resnapshotContext) => { + if (state === "unsubscribed" || this.#laneWatches.get(watchId) !== watch) { + throw new Error("Session worker lane watch is unsubscribed"); + } + return (await this.#operations(worker, scope, resnapshotContext).resnapshotWatch(watchId)).snapshot; + }, unsubscribe: async (unsubscribeContext) => { if (state === "unsubscribed") return; state = "unsubscribed"; diff --git a/packages/coding-agent/src/experimental/session-worker.ts b/packages/coding-agent/src/experimental/session-worker.ts index ffcf2a83c..b402f8b92 100644 --- a/packages/coding-agent/src/experimental/session-worker.ts +++ b/packages/coding-agent/src/experimental/session-worker.ts @@ -103,6 +103,10 @@ export const SessionWorkerOperations = defineRpc({ args: Type.Tuple([Type.String({ minLength: 1 })]), result: StrictObject({ watchId: Type.String({ minLength: 1 }) }), }, + resnapshotWatch: { + args: Type.Tuple([Type.String({ minLength: 1 })]), + result: StrictObject({ watchId: Type.String({ minLength: 1 }), snapshot: LaneSnapshotSchema }), + }, stopWatch: { args: Type.Tuple([Type.String({ minLength: 1 })]), result: StrictObject({ watchId: Type.String({ minLength: 1 }) }), @@ -701,6 +705,11 @@ async function run(options: SessionWorkerOptions, createHarness: CreateSessionWo }); return { watchId }; }, + resnapshotWatch: async ({ scope, context }: WorkerOperationContext, watchId) => { + const watch = laneWatches.get(watchId); + if (!watch || !sameScope(watch.scope, scope)) throw new Error("Session worker lane watch was not found"); + return { watchId, snapshot: toWireLaneSnapshot(await watch.handle.resnapshot(context)) }; + }, stopWatch: async ({ scope }: WorkerOperationContext, watchId) => { const watch = laneWatches.get(watchId); if (!watch || !sameScope(watch.scope, scope)) throw new Error("Session worker lane watch was not found"); @@ -934,7 +943,7 @@ async function createCodingAgentHarness( currentModel.provider !== resolved.model.provider || currentModel.id !== resolved.model.id ) { - await lane.setModel(resolved.model, TODO_CONTEXT); + await lane.setModel({ provider: resolved.model.provider, modelId: resolved.model.id }, TODO_CONTEXT); } if ( resolved.thinkingLevel !== undefined && diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 843bd34e2..79b65a988 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -611,7 +611,7 @@ async function runClientCommand(command: ClientCommand): Promise { let streamedText = false; const result = await runClient(command, { onEvent(event) { - if (event.type !== "message_update" || event.frame.type !== "text_delta") return; + if (event.type !== "message_update" || event.frame?.type !== "text_delta") return; streamedText = true; process.stdout.write(event.frame.delta); }, diff --git a/packages/coding-agent/test/experimental-harness-wire-adapter.test.ts b/packages/coding-agent/test/experimental-harness-wire-adapter.test.ts index feaaecea4..001d21d0b 100644 --- a/packages/coding-agent/test/experimental-harness-wire-adapter.test.ts +++ b/packages/coding-agent/test/experimental-harness-wire-adapter.test.ts @@ -203,10 +203,14 @@ describe("Harness wire adapter", () => { }, ], tipId: "entry-1", - lastOperationId: null, + configuration: { + model: { provider: "faux", modelId: "faux-1" }, + thinkingLevel: "off", + activeToolNames: [], + }, + stats: { messageCount: 1, usage: finalMessage.usage }, operation: null, - queues: { steer: [], followUp: [], nextRun: [] }, - pendingWrites: [], + queues: [], faulted: false, }; const wire = toWireLaneSnapshot(snapshot); @@ -247,6 +251,7 @@ describe("Harness wire adapter", () => { type: "message_update", lane: "main", runId: "run-1", + message: partial, frame: { type: "text_delta", contentIndex: 0, delta: "swer" }, }); }); diff --git a/packages/coding-agent/test/experimental-remote-runtime.test.ts b/packages/coding-agent/test/experimental-remote-runtime.test.ts index 83d3c0e9e..d756cb9f0 100644 --- a/packages/coding-agent/test/experimental-remote-runtime.test.ts +++ b/packages/coding-agent/test/experimental-remote-runtime.test.ts @@ -566,21 +566,9 @@ describe("experimental durable server composition", () => { await expect(services.dispose(BACKGROUND_CONTEXT)).resolves.toBeUndefined(); }); - test("routes Chat through the worker-owned service provider and sanitizes runtime failures", async ({ - onTestFinished, - }) => { + test("streams prompt events through the worker-owned service provider", async ({ onTestFinished }) => { const legacyPrompt = vi.spyOn(Client.prototype, "promptSession"); onTestFinished(() => legacyPrompt.mockRestore()); - const { directory } = await makeServer(); - - await expect( - runClient({ command: "client", sessionId: "demo-1", prompt: "question" }, { directory }), - ).rejects.toThrow("Internal server error"); - expect(legacyPrompt).not.toHaveBeenCalled(); - }); - - // Runtime cannot complete no-tool runs yet; keep the framed prompt path covered as a sanitized failure above. - test.skip("streams prompt events from the worker to the client", async ({ onTestFinished }) => { const spawn = vi .spyOn(processRuntime, "spawnInternalProcess") .mockImplementation((role, args, options) => @@ -593,7 +581,6 @@ describe("experimental durable server composition", () => { onTestFinished(() => spawn.mockRestore()); const { directory } = await makeServer(); const eventTypes: string[] = []; - const text: string[] = []; const result = await runClient( { command: "client", sessionId: "demo-1", prompt: "question" }, @@ -601,15 +588,11 @@ describe("experimental durable server composition", () => { directory, onEvent(event) { eventTypes.push(event.type); - if (event.type === "message_update" && event.frame.type === "text_delta") { - text.push(event.frame.delta); - } }, }, ); expect(result).toMatchObject({ kind: "prompted", text: "deterministic remote answer" }); - expect(text.join("")).toBe("deterministic remote answer"); expect(eventTypes).toEqual( expect.arrayContaining([ "run_start", @@ -620,6 +603,7 @@ describe("experimental durable server composition", () => { "run_end", ]), ); + expect(legacyPrompt).not.toHaveBeenCalled(); }); // Re-enable with runtime no-tool execution. diff --git a/packages/coding-agent/test/experimental-session-worker-manager.test.ts b/packages/coding-agent/test/experimental-session-worker-manager.test.ts index 57dab9bcb..34a34a01e 100644 --- a/packages/coding-agent/test/experimental-session-worker-manager.test.ts +++ b/packages/coding-agent/test/experimental-session-worker-manager.test.ts @@ -173,6 +173,15 @@ describe("Session worker lifecycle failures", () => { }); describe("Session worker operations", () => { + const completedRun = { + operationId: "run-1", + kind: "run" as const, + status: "completed" as const, + fromTipId: null, + tipId: "leaf-1", + startedAt: 1, + endedAt: 2, + }; test("correlates prompt results to the worker generation and attachment", async () => { const { coordinator, workers, attachment, release } = await createAttachedWorker(); coordinator.onSend = (peerId, payload) => { @@ -192,7 +201,7 @@ describe("Session worker operations", () => { scope, result: { ok: true, - value: { kind: "completed", runId: "run-1", tipId: "leaf-1" }, + value: completedRun, }, }, }, @@ -202,7 +211,7 @@ describe("Session worker operations", () => { await expect(attachment.prompt(["Hello"], BACKGROUND_CONTEXT)).resolves.toEqual({ ok: true, - value: { kind: "completed", runId: "run-1", tipId: "leaf-1" }, + value: completedRun, }); const operation = coordinator.sent .map(({ payload }) => asObject(payload)) @@ -233,7 +242,7 @@ describe("Session worker operations", () => { scope: payload.scope, result: { ok: true, - value: { kind: "completed", runId: "run-1", tipId: "leaf-1" }, + value: completedRun, }, }, }, @@ -264,7 +273,7 @@ describe("Session worker operations", () => { scope: null, result: { ok: true, - value: { kind: "completed", runId: "run-1", tipId: "leaf-1" }, + value: completedRun, }, }, }, diff --git a/packages/protocol/src/harness.ts b/packages/protocol/src/harness.ts index 312ab51fb..16d024fba 100644 --- a/packages/protocol/src/harness.ts +++ b/packages/protocol/src/harness.ts @@ -176,12 +176,16 @@ const OperationResultBase = { startedAt: TimestampSchema, endedAt: TimestampSchema, }; -const RunValueSchema = Type.Union([ +export const OperationResultRecordSchema = Type.Union([ StrictObject({ ...OperationResultBase, status: Type.Union([Type.Literal("completed"), Type.Literal("declined"), Type.Literal("aborted")]), }), StrictObject({ ...OperationResultBase, status: Type.Literal("failed"), error: OperationErrorSchema }), +]); +export type OperationResultRecord = Static; +const RunValueSchema = Type.Union([ + OperationResultRecordSchema, StrictObject({ operationId: IdSchema, status: Type.Literal("suspended"), @@ -330,7 +334,27 @@ export const LaneEntrySchema = Type.Union([ ]); export type LaneEntry = Static; -const QueuedItemSchema = StrictObject({ entryId: IdSchema, message: PromptMessageSchema }); +export const LaneQueuedItemSchema = Type.Union([ + StrictObject({ + entryId: IdSchema, + kind: Type.Union([ + Type.Literal("steer"), + Type.Literal("followUp"), + Type.Literal("nextRun"), + Type.Literal("write"), + ]), + type: Type.Literal("message"), + message: PromptMessageSchema, + }), + StrictObject({ + entryId: IdSchema, + kind: Type.Literal("write"), + type: Type.Literal("custom"), + customType: Type.String(), + data: Type.Optional(JsonValueSchema), + }), +]); +export type LaneQueuedItem = Static; const RunningToolSchema = StrictObject({ toolCallId: Type.String(), toolName: Type.String(), @@ -342,6 +366,7 @@ const LaneOperationSchema = StrictObject({ kind: Type.Union([Type.Literal("run"), Type.Literal("compaction"), Type.Literal("navigation")]), status: Type.Union([Type.Literal("running"), Type.Literal("open"), Type.Literal("aborting")]), startedAt: TimestampSchema, + fromTipId: Type.Union([IdSchema, Type.Null()]), deferred: Type.Optional(StrictObject({ handle: DeferredHandleSchema, poll: Type.Integer({ minimum: 0 }) })), streamingMessage: Type.Optional(AssistantMessageSchema), runningTools: Type.Array(RunningToolSchema), @@ -353,31 +378,31 @@ const LaneOperationSchema = StrictObject({ }), ), }); +const ModelIdentitySchema = StrictObject({ provider: Type.String(), modelId: Type.String() }); +const ThinkingLevelSchema = Type.Union([ + Type.Literal("off"), + Type.Literal("minimal"), + Type.Literal("low"), + Type.Literal("medium"), + Type.Literal("high"), + Type.Literal("xhigh"), + Type.Literal("max"), +]); +const LaneConfigurationSchema = StrictObject({ + model: ModelIdentitySchema, + thinkingLevel: ThinkingLevelSchema, + activeToolNames: Type.Array(Type.String()), +}); +const SessionStatsSchema = StrictObject({ messageCount: Type.Integer({ minimum: 0 }), usage: UsageSchema }); export const LaneSnapshotSchema = StrictObject({ lane: Type.String(), transcript: Type.Array(LaneEntrySchema), tipId: Type.Union([IdSchema, Type.Null()]), - lastOperationId: Type.Union([IdSchema, Type.Null()]), + lastResult: Type.Optional(OperationResultRecordSchema), + configuration: LaneConfigurationSchema, + stats: SessionStatsSchema, operation: Type.Union([LaneOperationSchema, Type.Null()]), - queues: StrictObject({ - steer: Type.Array(QueuedItemSchema), - followUp: Type.Array(QueuedItemSchema), - nextRun: Type.Array(QueuedItemSchema), - }), - pendingWrites: Type.Array( - StrictObject({ - entryId: IdSchema, - type: Type.Union([ - Type.Literal("message"), - Type.Literal("compaction"), - Type.Literal("branch_summary"), - Type.Literal("custom"), - ]), - customType: Type.Optional(Type.String()), - message: Type.Optional(PromptMessageSchema), - data: Type.Optional(JsonValueSchema), - }), - ), + queues: Type.Array(LaneQueuedItemSchema), faulted: Type.Boolean(), }); export type LaneSnapshot = Static; @@ -391,16 +416,18 @@ const RunEndBase = { runId: IdSchema, fromTipId: Type.Union([IdSchema, Type.Null()]), tipId: Type.Union([IdSchema, Type.Null()]), + endedAt: TimestampSchema, ...LaneEventBase, }; export const LaneEventSchema = Type.Union([ - StrictObject({ type: Type.Literal("run_start"), runId: IdSchema, ...LaneEventBase }), + StrictObject({ type: Type.Literal("run_start"), runId: IdSchema, startedAt: TimestampSchema, ...LaneEventBase }), StrictObject({ type: Type.Literal("run_resume"), runId: IdSchema, ...LaneEventBase }), StrictObject({ type: Type.Literal("run_suspend"), runId: IdSchema, reason: Type.Literal("deferred"), deferred: DeferredHandleSchema, + poll: Type.Integer({ minimum: 0 }), ...LaneEventBase, }), StrictObject({ @@ -424,7 +451,8 @@ export const LaneEventSchema = Type.Union([ StrictObject({ type: Type.Literal("message_update"), runId: IdSchema, - frame: AssistantMessageFrameSchema, + message: AssistantMessageSchema, + frame: Type.Optional(AssistantMessageFrameSchema), ...LaneEventBase, }), StrictObject({ @@ -466,9 +494,7 @@ export const LaneEventSchema = Type.Union([ StrictObject({ type: Type.Literal("entry_added"), entry: LaneEntrySchema, ...LaneEventBase }), StrictObject({ type: Type.Literal("queue_update"), - steer: Type.Array(QueuedItemSchema), - followUp: Type.Array(QueuedItemSchema), - nextRun: Type.Array(QueuedItemSchema), + queues: Type.Array(LaneQueuedItemSchema), ...LaneEventBase, }), StrictObject({ @@ -478,6 +504,7 @@ export const LaneEventSchema = Type.Union([ attempt: Type.Integer({ minimum: 1 }), maxAttempts: Type.Integer({ minimum: 1 }), delayMs: Type.Integer({ minimum: 0 }), + notBefore: TimestampSchema, errorMessage: Type.String(), ...LaneEventBase, }), @@ -497,6 +524,86 @@ export const LaneEventSchema = Type.Union([ finalError: Type.Optional(Type.String()), ...LaneEventBase, }), + StrictObject({ + type: Type.Literal("compaction_start"), + runId: IdSchema, + reason: Type.Union([Type.Literal("manual"), Type.Literal("threshold"), Type.Literal("overflow")]), + startedAt: TimestampSchema, + ...LaneEventBase, + }), + StrictObject({ + type: Type.Literal("compaction_end"), + runId: IdSchema, + reason: Type.Union([Type.Literal("manual"), Type.Literal("threshold"), Type.Literal("overflow")]), + status: Type.Union([ + Type.Literal("completed"), + Type.Literal("declined"), + Type.Literal("failed"), + Type.Literal("aborted"), + ]), + entryId: Type.Optional(IdSchema), + error: Type.Optional(OperationErrorSchema), + endedAt: TimestampSchema, + ...LaneEventBase, + }), + StrictObject({ + type: Type.Literal("navigation_start"), + runId: IdSchema, + targetId: Type.Union([IdSchema, Type.Null()]), + startedAt: TimestampSchema, + ...LaneEventBase, + }), + StrictObject({ + type: Type.Literal("navigation_end"), + runId: IdSchema, + status: Type.Union([ + Type.Literal("completed"), + Type.Literal("declined"), + Type.Literal("failed"), + Type.Literal("aborted"), + ]), + fromTipId: Type.Union([IdSchema, Type.Null()]), + tipId: Type.Union([IdSchema, Type.Null()]), + error: Type.Optional(OperationErrorSchema), + endedAt: TimestampSchema, + ...LaneEventBase, + }), + StrictObject({ + type: Type.Literal("usage"), + lane: Type.String(), + row: StrictObject({ + id: IdSchema, + seq: Type.Integer({ minimum: 1 }), + usage: UsageSchema, + entryId: Type.Optional(IdSchema), + adjustment: Type.Boolean(), + details: Type.Optional(JsonValueSchema), + }), + totals: UsageSchema, + }), + StrictObject({ + type: Type.Literal("config_update"), + property: Type.Union([Type.Literal("model"), Type.Literal("thinkingLevel"), Type.Literal("activeTools")]), + value: JsonValueSchema, + previous: JsonValueSchema, + ...LaneEventBase, + }), + StrictObject({ + type: Type.Literal("config_update"), + property: Type.Union([ + Type.Literal("streamOptions"), + Type.Literal("retryPolicy"), + Type.Literal("compactionSettings"), + Type.Literal("steeringMode"), + Type.Literal("followUpMode"), + ]), + value: JsonValueSchema, + previous: JsonValueSchema, + }), + StrictObject({ + type: Type.Literal("config_update"), + property: Type.Union([Type.Literal("tools"), Type.Literal("resources")]), + }), StrictObject({ type: Type.Literal("fault"), code: Type.String(), message: Type.String() }), ]); export type LaneEvent = Static; diff --git a/packages/protocol/src/protocol.ts b/packages/protocol/src/protocol.ts index 275513118..77e327750 100644 --- a/packages/protocol/src/protocol.ts +++ b/packages/protocol/src/protocol.ts @@ -70,6 +70,10 @@ export const ServiceRpc = defineRpc({ args: Type.Tuple([IdSchema]), result: StrictObject({ watchId: IdSchema }), }, + resnapshotWatch: { + args: Type.Tuple([IdSchema]), + result: StrictObject({ watchId: IdSchema, snapshot: LaneSnapshotSchema }), + }, stopWatch: { args: Type.Tuple([IdSchema]), result: StrictObject({ watchId: IdSchema }), @@ -215,6 +219,7 @@ const ServiceRpcAddresses = { prompt: { serviceId: "pi.chat", member: "prompt" }, watch: { serviceId: "pi.transcript", member: "watch" }, startWatch: { serviceId: "pi.transcript", member: "startWatch" }, + resnapshotWatch: { serviceId: "pi.transcript", member: "resnapshotWatch" }, stopWatch: { serviceId: "pi.transcript", member: "stopWatch" }, } as const satisfies Record; diff --git a/packages/protocol/test/protocol.test.ts b/packages/protocol/test/protocol.test.ts index 31c32c371..ba5a6dee9 100644 --- a/packages/protocol/test/protocol.test.ts +++ b/packages/protocol/test/protocol.test.ts @@ -55,10 +55,24 @@ const laneSnapshot = { lane: "main", transcript: [], tipId: null, - lastOperationId: null, + configuration: { + model: { provider: "faux", modelId: "faux-1" }, + thinkingLevel: "off", + activeToolNames: [], + }, + stats: { + messageCount: 0, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }, operation: null, - queues: { steer: [], followUp: [], nextRun: [] }, - pendingWrites: [], + queues: [], faulted: false, } satisfies LaneSnapshot; @@ -94,6 +108,8 @@ describe("RPC manifest", () => { case "startWatch": case "stopWatch": return { watchId: call.args[0] }; + case "resnapshotWatch": + return { watchId: call.args[0], snapshot: laneSnapshot }; } }); @@ -106,6 +122,10 @@ describe("RPC manifest", () => { await expect(client.prompt(["Hello"])).resolves.toEqual(completedRunResult); await expect(client.watch()).resolves.toEqual({ watchId: "watch-1", snapshot: laneSnapshot }); await expect(client.startWatch("watch-1")).resolves.toEqual({ watchId: "watch-1" }); + await expect(client.resnapshotWatch("watch-1")).resolves.toEqual({ + watchId: "watch-1", + snapshot: laneSnapshot, + }); await expect(client.stopWatch("watch-1")).resolves.toEqual({ watchId: "watch-1" }); expect(calls).toEqual([ { method: "list", args: [] }, @@ -114,6 +134,7 @@ describe("RPC manifest", () => { { method: "prompt", args: [["Hello"]] }, { method: "watch", args: [] }, { method: "startWatch", args: ["watch-1"] }, + { method: "resnapshotWatch", args: ["watch-1"] }, { method: "stopWatch", args: ["watch-1"] }, ]); }); @@ -138,6 +159,7 @@ describe("RPC manifest", () => { prompt: () => completedRunResult, watch: () => ({ watchId: "watch-1", snapshot: laneSnapshot }), startWatch: (_context, watchId) => ({ watchId }), + resnapshotWatch: (_context, watchId) => ({ watchId, snapshot: laneSnapshot }), stopWatch: (_context, watchId) => ({ watchId }), }); await expect(dispatch({ method: "list", args: [] }, undefined)).resolves.toEqual([summary]); @@ -151,6 +173,10 @@ describe("RPC manifest", () => { watchId: "watch-1", snapshot: laneSnapshot, }); + await expect(dispatch({ method: "resnapshotWatch", args: ["watch-1"] }, undefined)).resolves.toEqual({ + watchId: "watch-1", + snapshot: laneSnapshot, + }); await expect(dispatch({ method: "attach", args: [] } as never, undefined)).rejects.toThrow(/Invalid arguments/); await expect(dispatch({ method: "prompt", args: [[]] } as never, undefined)).rejects.toThrow(/Invalid arguments/); }); @@ -166,6 +192,7 @@ describe("RPC manifest", () => { prompt: () => completedRunResult, watch: () => ({ watchId: "watch-1", snapshot: laneSnapshot }), startWatch: (_context: undefined, watchId: string) => ({ watchId }), + resnapshotWatch: (_context: undefined, watchId: string) => ({ watchId, snapshot: laneSnapshot }), stopWatch: (_context: undefined, watchId: string) => ({ watchId }), } as never); await expect(dispatch({ method: "list", args: [] }, undefined)).rejects.toThrow(/Invalid result.*list/); @@ -426,7 +453,7 @@ describe("protocol validation", () => { const eventMessage: ServerMessage = { type: "event", watchId: "watch-1", - event: { type: "run_start", lane: "main", runId: "run-1" }, + event: { type: "run_start", lane: "main", runId: "run-1", startedAt: 1 }, }; expect(parseServerMessage(snapshotMessage)).toEqual(snapshotMessage); expect(parseServerMessage(eventMessage)).toEqual(eventMessage); @@ -438,6 +465,23 @@ describe("protocol validation", () => { type: "message_update", lane: "main", runId: "run-1", + message: { + role: "assistant", + content: [{ type: "text", text: "hello" }], + api: "test", + provider: "test", + model: "test", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "pending", + timestamp: 1, + }, frame: { type: "text_delta", contentIndex: 0, delta: "hello" }, }, }), diff --git a/packages/server/src/session-router.ts b/packages/server/src/session-router.ts index 5c6f2cf59..145b7839f 100644 --- a/packages/server/src/session-router.ts +++ b/packages/server/src/session-router.ts @@ -117,6 +117,8 @@ export class SessionRouter this.runForClient(client, () => this.createWatch(client, target, context)), startWatch: ({ client, publish, context, target }, watchId) => this.runForClient(client, () => this.startWatch(client, target, watchId, publish, context)), + resnapshotWatch: ({ client, context, target }, watchId) => + this.runForClient(client, () => this.resnapshotWatch(client, target, watchId, context)), stopWatch: ({ client, context, target }, watchId) => this.runForClient(client, () => this.stopWatch(client, target, watchId, context)), }, @@ -383,6 +385,18 @@ export class SessionRouter return { watchId }; } + private async resnapshotWatch( + client: object, + target: RpcTarget, + watchId: string, + context: Context, + ): Promise<{ watchId: string; snapshot: RoutedSessionWatch["snapshot"] }> { + const attachment = this.requireAttachment(client, target); + const watch = attachment.watch; + if (watch?.id !== watchId) throw new WatchNotFoundError(); + return { watchId, snapshot: await watch.handle.resnapshot(context) }; + } + private async stopWatch( client: object, target: RpcTarget, diff --git a/packages/server/src/testing/client.ts b/packages/server/src/testing/client.ts index 745ce4bdf..558cb7653 100644 --- a/packages/server/src/testing/client.ts +++ b/packages/server/src/testing/client.ts @@ -91,7 +91,7 @@ export class ProtocolTestClient { if (!Array.isArray(call.args) || !call.args.every(isJsonValue)) { throw new Error(`Test call ${call.method} requires JSON array arguments`); } - const sessionOperation = ["prompt", "watch", "startWatch", "stopWatch"].includes(call.method); + const sessionOperation = ["prompt", "watch", "startWatch", "resnapshotWatch", "stopWatch"].includes(call.method); let target: RpcTarget = { serverId }; let args = call.args; if (sessionOperation) { @@ -174,6 +174,7 @@ function serviceAddress(method: string): { serviceId: string; member: string } { return { serviceId: "pi.chat", member: "prompt" }; case "watch": case "startWatch": + case "resnapshotWatch": case "stopWatch": return { serviceId: "pi.transcript", member: method }; default: diff --git a/packages/server/src/testing/host.ts b/packages/server/src/testing/host.ts index 7e169167d..e91529f5f 100644 --- a/packages/server/src/testing/host.ts +++ b/packages/server/src/testing/host.ts @@ -27,10 +27,24 @@ const emptyLaneSnapshot: LaneSnapshot = { lane: "main", transcript: [], tipId: null, - lastOperationId: null, + configuration: { + model: { provider: "faux", modelId: "faux-1" }, + thinkingLevel: "off", + activeToolNames: [], + }, + stats: { + messageCount: 0, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }, operation: null, - queues: { steer: [], followUp: [], nextRun: [] }, - pendingWrites: [], + queues: [], faulted: false, }; @@ -52,6 +66,10 @@ class TestHarnessWatch implements RoutedSessionWatch { for (const buffered of this.buffered.splice(0)) this.enqueue(buffered.event, buffered.context); } + resnapshot(_context: Context): Promise { + return Promise.resolve(structuredClone(this.snapshot)); + } + unsubscribe(_context: Context): void { this.state = "unsubscribed"; this.buffered.splice(0); @@ -124,6 +142,7 @@ export class TestHarness { return { snapshot: watch.snapshot, start: (listener, context) => watch.start(listener, context), + resnapshot: (context) => watch.resnapshot(context), unsubscribe: (context) => { watch.unsubscribe(context); this.watches.delete(watch); diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index 8870a5af4..2a85e509e 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -64,6 +64,7 @@ export interface RoutedServerServiceHost { export interface RoutedSessionWatch { readonly snapshot: LaneSnapshot; start(listener: (event: LaneEvent, context: Context) => MaybePromise, context: Context): MaybePromise; + resnapshot(context: Context): Promise; unsubscribe(context: Context): MaybePromise; } diff --git a/packages/server/test/conformance.test.ts b/packages/server/test/conformance.test.ts index efeb219e3..04aa385ba 100644 --- a/packages/server/test/conformance.test.ts +++ b/packages/server/test/conformance.test.ts @@ -286,6 +286,7 @@ describe("Session protocol", () => { type: "run_start", lane: "main", runId: "run-1", + startedAt: 1, }); expect(client.messages.some((message) => message.type === "event")).toBe(false); @@ -300,6 +301,12 @@ describe("Session protocol", () => { event: { type: "run_start", runId: "run-1" }, }); await expect(starting).resolves.toMatchObject({ ok: true, result: { watchId } }); + await expect( + client.request("00000000-0000-4000-8000-000000000001", { + method: "resnapshotWatch", + args: ["session-1", watchId], + }), + ).resolves.toMatchObject({ ok: true, result: { watchId, snapshot: { lane: "main" } } }); await host.latestHarness("session-1").emitEvent({ type: "message_start", diff --git a/packages/session-backends/sqlite-node/src/sqlite/session.ts b/packages/session-backends/sqlite-node/src/sqlite/session.ts index e352c9423..b0613d821 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/session.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/session.ts @@ -91,6 +91,7 @@ export class SqliteOpenSession implements Session { } }, getEntries: (ids, readContext) => source.getEntries(ids, readContext), + getStats: (readContext) => source.getStats(readContext), getValue: (address, readContext) => source.getValue(address, readContext), scanValues: (prefix, readContext) => source.scanValues(prefix, readContext), readList: (address, options, readContext) => source.readList(address, options, readContext),