diff --git a/docs/architecture/runtime-resume-extraction-ledger.zh-CN.md b/docs/architecture/runtime-resume-extraction-ledger.zh-CN.md index c0da7e8c3e..549a31b47e 100644 --- a/docs/architecture/runtime-resume-extraction-ledger.zh-CN.md +++ b/docs/architecture/runtime-resume-extraction-ledger.zh-CN.md @@ -310,11 +310,11 @@ B3(typed retry/reattach branch)仍然 defer,不进入本 PR。 | `agent-run.ts` | Run create 与 backend reservation 之间提交 continuation-start | | `runtime-continuation-admission.ts` | opaque、runner-bound、one-shot start proof/receipt 及精确 identity 绑定 | | `runtime-kernel.ts` | continuation dispatch fail closed;仅消费合法的一次性 start proof | -| `session-manager.ts` | claim-only saga、claim-owned child admission defer、proven-abandonment retry、branch/revision 创建前 preflight | +| `session-manager.ts` | claim-only saga、branch/revision 创建前 preflight;历史 linked-child admission descriptor 仅作重启关闭兼容,不再提供 live child retry | | `runtime-event-read-model.ts` | continuation-start 是消息不可见的 canonical audit fact | | `runtime-continuation*.test.ts` | lineage、claim、T1、SIGKILL crash matrix | | `session-manager.test.ts` | proof 防伪、one-shot、provider replay mutation fence 与无重复 user event | -| `session-manager.test.ts` | production-shaped plan/execute/race/failure/stop,以及 linked/legacy child retry 的 V2 claim/start | +| `session-manager.test.ts` | production-shaped plan/execute/race/failure/stop,以及历史 linked-child admission 的重启关闭兼容 | #### UI 与文档 @@ -359,8 +359,8 @@ B3(typed retry/reattach branch)仍然 defer,不进入本 PR。 | start writer exact retry | 同一个物理 `event_seq=1` | | terminal 后追加 immutable event | rejected;exact terminal retry 仍幂等 | | stop after durable start | provider dispatch 被 fence | -| claim-owned linked child admission 与 generic repair 竞争 | generic repair defer | -| child abandonment retry | 必须证明 deterministic repair start + terminal,字符串篡改 fail closed | +| claim-owned historical linked-child admission closure 与 generic repair 竞争 | generic repair defer | +| historical child abandonment closure | deterministic repair start + terminal,provider 0 次;字符串篡改 fail closed | | branch/revision 遇到 V1/V2 continuation 或 authority fact | 创建新 Session 前拒绝 | | SIGKILL after claim/run create/live start/terminal event/terminal header | reopen 后稳定分类;live start/no-terminal 保守 park | @@ -369,20 +369,17 @@ B3(typed retry/reattach branch)仍然 defer,不进入本 PR。 - authority-capable `SessionManager + SqliteRuntimeStore` 的协议与 production-shaped 路径已覆盖; - runtime-host 的 execution-store facade 当前仍以 file RuntimeEvent store 为主,尚未拥有 B2 continuation authority; -- hosted child provider RateLimit retry 只保留 `durable_continuation` 单一准入:continuation - authority 与 safety inspector 任一缺席,都会在任何 claim/Run/T1 之前 fail closed,禁止 - 静默 fallback;早期的 `legacy_provider_retry` 兼容 lane 已在 host authority lifecycle - integration 完成后移除; +- hosted child provider RateLimit retry 入口已经删除;历史 `linked_child_resume` / + `linked_child_provider_retry` descriptor 只在 startup recovery 中收敛为 durable terminal + fact,provider 调用次数为 0;早期的 `legacy_provider_retry` 兼容 lane 也已移除; - PR D 必须在同一 storage-root lease 下接入 SQLite authority,并锁定 - `claim repair → linked-child admission repair → generic ledger repair → planning` 的 owner 顺序; + `claim repair → historical linked-child admission closure → generic ledger repair → planning` + 的 owner 顺序; - SQLite canonical terminal 可幂等提交;文件型 AgentRun projection 当前是跨存储 saga。确定性 event id 能让单恢复者重试收敛,但两个进程同时 repair 时还没有 append-if-absent/CAS; PR D 的 lease/fencing 或 projection CAS 是宣称跨进程 exactly-once 前的硬前置; -- 在上述 composition/owner 测试完成前,不能把当前切片描述为 hosted auto-resume 已默认可用。 - `legacy_provider_retry` 兼容 lane(仅维持升级前的 provider 429 重试能力,不具有 durable - continuation、跨进程 exactly-once 或 crash-resume 承诺)已在 host authority lifecycle - integration 完成 typed authority composition 后移除;child provider retry 现在只走 - durable continuation 准入,组合不完整即 fail closed。 +- 在上述 composition/owner 测试完成前,不能把当前切片描述为 hosted auto-resume 已默认可用; + 当前也不存在 child provider retry 的 live 或降级准入路径。 ### 8.4 明确不进入 PR B diff --git a/docs/architecture/runtime-resume-phase3-phase4-workspace-checkpoint-design.zh-CN.md b/docs/architecture/runtime-resume-phase3-phase4-workspace-checkpoint-design.zh-CN.md index aecb8def28..965f1c7512 100644 --- a/docs/architecture/runtime-resume-phase3-phase4-workspace-checkpoint-design.zh-CN.md +++ b/docs/architecture/runtime-resume-phase3-phase4-workspace-checkpoint-design.zh-CN.md @@ -165,12 +165,6 @@ composite replay 存在时,它是 tool-state 与 provider suffix 的唯一 gat `buildResumePlanFromRuntimeEvents()` 只服务无 composite boundary 的 legacy 路径,不能在外层 再次把已经安全裁掉的 `definitely_not_dispatched` call 判为 dangling。 -`retryChildAgentWithExecution()` 同样通过 `RuntimeContinuationPlanner` 构造 immutable -composite boundary。旧 child 的 `retriedFromRunId/resumedFromRunId` 边从对应 immutable prefix -严格派生临时 V1 segment;新 child retry 持久化 V2 `continuationSource`。该路径不再使用 -`readRuntimeEvents()`、`events.length` 或独立的 `buildResumePlanFromRuntimeEvents()` 组装 -provider history。 - 最终同时冻结: - `providerProjectionVersion = 1`; @@ -287,9 +281,9 @@ sequenceDiagram 5. 通过 dedicated writer 提交 `continuation_start_v2`,必须是 target `event_seq=1`; 6. 才允许 append running turn state、reserve backend、标记 running、调用 provider。 -linked child 与 legacy child 的 provider retry 也按这套顺序执行:重新验证 boundary/replay、 -workspace/background/tool catalog,竞争同一个 SQLite boundary claim,再写 continuation-start。 -`retriedFromRunId` 继续承担产品查询与展示语义,但不承担并发执行所有权。 +linked child 当前通过普通 Session turn 执行,不再有 same-session child AgentRun 的 provider +retry 入口。历史 `linked_child_resume` / `linked_child_provider_retry` descriptor 与 +`retriedFromRunId` 只保留重启关闭、查询和展示兼容,不会重新触发 provider。 live continuation-start 同时绑定 claim id、boundary digest、immediate source identity/high-water/prefix digest、replay manifest、provider projection version 和 provider replay digest。V2 AgentRun header 的 @@ -327,11 +321,10 @@ failed terminal,failureClass 为 另一个进程擅自补 terminal,因为原 provider 可能仍存活。SQLite writer 同时拒绝 terminal 后追加 任何 immutable event,作为最终写侧防线。 -linked-child 的 generic admission repair 在发现 target identity 已由 continuation claim 占有时 -必须 defer,不能抢先创建一个缺少 V2 source 的同 id Run。只有同时证明 claim target、B2.1 -deterministic repair start、deterministic terminal 与 target header 全部一致,才允许把 -`continuation_abandoned_before_provider_dispatch` Run 作为 child provider retry source。仅凭 -字符串 failureClass 或 V2 header 不构成证明。 +历史 linked-child admission closure 在发现 target identity 已由 continuation claim 占有时必须 +defer,不能抢先创建一个缺少 V2 source 的同 id Run。没有 claim owner 时,该 closure 只保留旧 +descriptor 的 lineage 并物化 durable failed terminal fact,provider 调用数为 0;它不会把 repaired +Run 重新变成 child provider retry source。 canonical continuation authority 读取失败时,best-effort startup 必须隔离整个 session,不允许 退回 generic/legacy repair。否则一个暂时读不到 claim 的 host 可能把 claim-owned target 当成普通 @@ -377,18 +370,17 @@ lease/fencing 或 append-if-absent 解决。 #### B3:明确延后 本 PR 不实现通用 provider retry、ShellRun reattach、Bash 重放、conversation clone identity -rewrite 或其他 typed continuation branch。authority-capable SessionManager 内已有的 -linked-child RateLimit retry 与 B2.1 repair retry 是窄协议,不代表 runtime-host 已获得生产启用 -资格。其他能力必须在各自拥有 durable handle/幂等协议后独立设计,不能复用 B2 的普通 -continuation claim 来暗示副作用可重跑。 +rewrite 或其他 typed continuation branch。linked-child RateLimit retry 入口已经删除;B2.1 +repair retry 只修复已持久化的 continuation authority,不会重新调用 provider。其他能力必须在 +各自拥有 durable handle/幂等协议后独立设计,不能复用 B2 的普通 continuation claim 来暗示 +副作用可重跑。 兼容约束:早期的 `legacy_provider_retry` lane(只允许 continuation authority 与 safety inspector 同时缺席的组合,半配置状态 fail closed;执行前重验 immutable immediate-source replay,但不产生 claim/start,也不能承接 `continuation_abandoned_before_provider_dispatch`)已在 host authority lifecycle -integration 接入 typed SQLite authority owner 后移除。child provider RateLimit retry 现在 -只走 durable continuation 准入:authority 或 safety inspector 任一缺席时在 claim/Run/T1 -之前 fail closed,没有降级模式。 +integration 接入 typed SQLite authority owner 后移除。历史 child admission descriptor 在 +恢复时只会被收敛为 durable terminal fact,不存在 provider retry 降级模式。 B3 之前,branch/revision preflight 必须在创建目标 Session 之前拒绝任何 V1/V2 `continuationSource` 与 continuation-start,稳定返回 @@ -517,7 +509,7 @@ Host owner 使用显式 `opening -> ready -> closing -> closed` 状态机;`clo ```text continuation claim repair -→ linked-child admission repair +→ historical linked-child admission closure → generic AgentRun ledger repair → ordinary continuation planning / auto-resume ``` diff --git a/packages/core/src/agent-swarm.ts b/packages/core/src/agent-swarm.ts index 02d32a9f5f..d2613d673f 100644 --- a/packages/core/src/agent-swarm.ts +++ b/packages/core/src/agent-swarm.ts @@ -34,8 +34,8 @@ export interface AgentSwarmResultProjection { /** * Bounded presentation/diagnostic facts derived from the canonical settled - * tool result. This is a projection only: child AgentRuns remain the authority - * for child lifecycle and artifacts. + * tool result. This is a projection only: linked child Sessions remain the + * authority for child lifecycle and artifacts. */ export function projectAgentSwarmResult(result: AgentSwarmResult): AgentSwarmResultProjection { let startedItemCount = 0; diff --git a/packages/core/src/runtime-inputs.ts b/packages/core/src/runtime-inputs.ts index 44fc43d3c0..d401076dda 100644 --- a/packages/core/src/runtime-inputs.ts +++ b/packages/core/src/runtime-inputs.ts @@ -117,11 +117,6 @@ export interface UserMessageInput extends MessageContent { turnOrchestration?: TurnOrchestration; /** Trusted host-supplied tool protocol override for this run only. */ toolMode?: ToolMode; - parentRunId?: string; - /** Child AgentRun whose durable conversation this child continues. */ - resumedFromRunId?: string; - /** Immediate child AgentRun retried without appending another user prompt. */ - retriedFromRunId?: string; agentId?: string; agentName?: string; parentTurnId?: string; @@ -133,21 +128,6 @@ export interface UserMessageInput extends MessageContent { origin?: TurnOrigin; } -export interface AgentSpec { - id: string; - name: string; - systemPrompt: string; -} - -export interface ChildAgentTurnInput { - turnId: string; - parentRunId: string; - spec: AgentSpec; - prompt: string; - /** Trusted, preflighted child AgentRun whose RuntimeEvent history is replayed. */ - resumedFromRunId?: string; -} - export interface RegenerateTurnInput { sourceTurnId: string; turnId?: string; diff --git a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts index ec371b34ff..08eb929d6c 100644 --- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts @@ -2720,116 +2720,6 @@ test('hosted linked child roots share admission, message, terminal, and stop aut }, }); - const resumeAbort = new AbortController(); - resumeAbort.abort(); - const runsBeforeAbortedResume = await stores.agentRunStore.listSessionRuns( - child.childSessionId, - ); - const sendsBeforeAbortedResume = linkedBackends.get(child.childSessionId)?.sendCount; - let abortedResumeReady = 0; - await assert.rejects( - manager.resumeChildAgent(parent.id, { - parentRunId: parentStarted.result.turn.runId, - sourceRunId: child.runId, - prompt: 'must not start', - abortSignal: resumeAbort.signal, - onReady: () => { - abortedResumeReady += 1; - }, - }), - { name: 'AbortError' }, - ); - assert.equal( - (await stores.agentRunStore.listSessionRuns(child.childSessionId)).length, - runsBeforeAbortedResume.length, - ); - assert.equal(linkedBackends.get(child.childSessionId)?.sendCount, sendsBeforeAbortedResume); - assert.equal(abortedResumeReady, 0); - - let resumeReadyRunId: string | undefined; - let resumeEventCount = 0; - const resumed = await manager.resumeChildAgent(parent.id, { - parentRunId: parentStarted.result.turn.runId, - sourceRunId: child.runId, - prompt: 'rate limit this resumed child', - onReady: (ready) => { - resumeReadyRunId = ready.runId; - }, - onEvent: () => { - resumeEventCount += 1; - }, - }); - assert.equal(resumed.status, 'failed'); - assert.equal(resumed.failureClass, 'RateLimit'); - assert.equal(resumed.resumedFromRunId, child.runId); - assert.equal(resumeReadyRunId, resumed.runId); - assert.equal(resumeEventCount, resumed.eventCount); - - let retryReadyRunId: string | undefined; - let retryEventCount = 0; - const retried = await manager.retryChildAgent(parent.id, { - parentRunId: parentStarted.result.turn.runId, - sourceRunId: resumed.runId!, - execution: { - kind: 'child_session', - sessionId: child.childSessionId, - currentRunId: resumed.runId, - }, - onReady: (ready) => { - retryReadyRunId = ready.runId; - }, - onEvent: () => { - retryEventCount += 1; - }, - }); - assert.equal(retried.status, 'completed'); - assert.equal(retried.retriedFromRunId, resumed.runId); - assert.equal(retryReadyRunId, retried.runId); - assert.equal(retryEventCount, retried.eventCount); - const admissions = await stores.agentRunStore.listRootTurnAdmissionsForRecovery( - child.childSessionId, - ); - assert.equal(admissions.length, 3); - assert.equal(admissions[1]?.runId, resumed.runId); - assert.ok(admissions[1]?.userMessageId); - assert.deepEqual(admissions[1]?.execution, { - kind: 'linked_child_resume', - agentId: resumed.agentId, - agentName: resumed.agentName, - sourceRunId: child.runId, - }); - assert.equal(admissions[2]?.runId, retried.runId); - assert.equal(admissions[2]?.userMessageId, null); - assert.deepEqual(admissions[2]?.execution, { - kind: 'linked_child_provider_retry', - agentId: retried.agentId, - agentName: retried.agentName, - sourceRunId: resumed.runId, - }); - const retryMessages = (await stores.sessionStore.readMessages(child.childSessionId)).filter( - (message) => 'turnId' in message && message.turnId === retried.turnId, - ); - assert.deepEqual(retryMessages, []); - const durableRetryRun = await stores.agentRunStore.readRun( - child.childSessionId, - retried.runId!, - ); - const durableRetrySource = durableRetryRun.continuationSource; - assert.ok(durableRetrySource && 'protocol' in durableRetrySource); - if (!durableRetrySource || !('protocol' in durableRetrySource)) return; - assert.equal(durableRetrySource.sourceRunId, resumed.runId); - assert.equal(durableRetrySource.protocol, 'continuation_source_v2'); - const continuationStart = ( - await stores.runtimeEventStore.readImmutableRuntimeEvents( - child.childSessionId, - retried.runId!, - ) - )[0]?.actions?.continuationStart; - assert.equal(continuationStart?.claimId, durableRetrySource.claimId); - assert.deepEqual(coordinator.readRootState(child.childSessionId), { - kind: 'idle', - }); - const callbackAbortController = new AbortController(); const stopClosureObserved = deferred(); stopClosureSignal = stopClosureObserved; @@ -2898,60 +2788,6 @@ test('hosted linked child roots share admission, message, terminal, and stop aut assert.equal(followupState.kind, 'active'); if (followupState.kind !== 'active') return; - await assert.rejects( - manager.resumeChildAgent(parent.id, { - parentRunId: parentStarted.result.turn.runId, - sourceRunId: retried.runId!, - prompt: 'internal resume racing the external follow-up', - }), - (error) => { - assert.ok(error instanceof RuntimeHostedRootConflictError); - assert.equal(error.code, 'session_busy'); - assert.deepEqual(error.scope, { - kind: 'session', - sessionId: child.childSessionId, - }); - return true; - }, - ); - assert.equal(drainRequested, false); - await coordinator.stopRoot(followupState); - assert.deepEqual(coordinator.readRootState(child.childSessionId), { - kind: 'idle', - }); - - const failedResume = await manager.resumeChildAgent(parent.id, { - parentRunId: parentStarted.result.turn.runId, - sourceRunId: retried.runId!, - prompt: 'rate limit one more linked child', - }); - assert.equal(failedResume.status, 'failed'); - const linkedBackend = linkedBackends.get(child.childSessionId); - assert.ok(linkedBackend); - const runsBeforeAbortedRetry = await stores.agentRunStore.listSessionRuns(child.childSessionId); - const sendsBeforeAbortedRetry = linkedBackend?.sendCount; - const retryAbort = new AbortController(); - retryAbort.abort(); - let abortedRetryReady = 0; - await assert.rejects( - manager.retryChildAgent(parent.id, { - parentRunId: parentStarted.result.turn.runId, - sourceRunId: failedResume.runId!, - abortSignal: retryAbort.signal, - onReady: () => { - abortedRetryReady += 1; - }, - }), - { name: 'AbortError' }, - ); - assert.equal( - (await stores.agentRunStore.listSessionRuns(child.childSessionId)).length, - runsBeforeAbortedRetry.length, - ); - assert.equal(linkedBackend?.sendCount, sendsBeforeAbortedRetry); - assert.equal(abortedRetryReady, 0); - assert.equal(drainRequested, false); - const abortController = new AbortController(); let joinedInitial: Promise | undefined; const interrupted = await manager.spawnChildSession(parent.id, { diff --git a/packages/runtime-host/src/server/child-agent-composition.ts b/packages/runtime-host/src/server/child-agent-composition.ts index 6624f9cb15..2bf61000aa 100644 --- a/packages/runtime-host/src/server/child-agent-composition.ts +++ b/packages/runtime-host/src/server/child-agent-composition.ts @@ -32,24 +32,12 @@ import { type SessionManager } from '@maka/runtime/session-manager'; type ChildAgentAuthority = Pick< SessionManager, - | 'spawnChildAgent' - | 'spawnChildSession' - | 'prepareChildAgentResume' - | 'resumeChildAgent' - | 'retryChildAgent' - | 'listChildAgents' - | 'readChildAgentOutput' + 'spawnChildSession' | 'listChildAgents' | 'readChildAgentOutput' >; export type HostChildAgentBackendCapabilities = Pick< ConstructorParameters[0], - | 'spawnChildAgent' - | 'spawnChildSession' - | 'prepareChildAgentResume' - | 'resumeChildAgent' - | 'retryChildAgent' - | 'listChildAgents' - | 'readChildAgentOutput' + 'spawnChildSession' | 'listChildAgents' | 'readChildAgentOutput' >; export interface HostChildAgentToolComposition { @@ -84,7 +72,6 @@ export function bindHostChildAgentBackend( parentSessionId: string, ): HostChildAgentBackendCapabilities { return { - spawnChildAgent: (input) => authority.spawnChildAgent(parentSessionId, input), spawnChildSession: (input) => authority.spawnChildSession(parentSessionId, { spawnedBy: { @@ -100,10 +87,6 @@ export function bindHostChildAgentBackend( ...(input.onReady ? { onReady: input.onReady } : {}), ...(input.onEvent ? { onEvent: input.onEvent } : {}), }), - prepareChildAgentResume: (sourceRunId) => - authority.prepareChildAgentResume(parentSessionId, sourceRunId), - resumeChildAgent: (input) => authority.resumeChildAgent(parentSessionId, input), - retryChildAgent: (input) => authority.retryChildAgent(parentSessionId, input), listChildAgents: () => authority.listChildAgents(parentSessionId), readChildAgentOutput: (input) => authority.readChildAgentOutput(parentSessionId, input), }; diff --git a/packages/runtime/src/__tests__/admission-limiter.test.ts b/packages/runtime/src/__tests__/admission-limiter.test.ts index 7f45e0badf..f70124b058 100644 --- a/packages/runtime/src/__tests__/admission-limiter.test.ts +++ b/packages/runtime/src/__tests__/admission-limiter.test.ts @@ -93,8 +93,8 @@ describe('AdmissionLimiter', () => { }); }); -describe('ToolRuntime child-agent run permits', () => { - test('caps real child runs spawned inside one admitted subagent tool', async () => { +describe('ToolRuntime child Session run permits', () => { + test('caps linked child runs spawned inside one admitted subagent tool', async () => { const active = new Set(); const started: string[] = []; const releases = new Map void>(); @@ -223,7 +223,7 @@ describe('ToolRuntime child-agent run permits', () => { test('rejects future child starts from a tool context after its turn ends', async () => { let childStarts = 0; - let capturedSpawn: MakaToolContext['spawnChildAgent']; + let capturedSpawn: MakaToolContext['spawnChildSession']; const runtime = buildRuntime(async () => { childStarts += 1; return {}; @@ -233,7 +233,7 @@ describe('ToolRuntime child-agent run permits', () => { description: 'test-only spawn capability capture', parameters: {}, impl: async (_args, ctx) => { - capturedSpawn = ctx.spawnChildAgent; + capturedSpawn = ctx.spawnChildSession; return { kind: 'json', value: { captured: true } }; }, }; @@ -243,7 +243,7 @@ describe('ToolRuntime child-agent run permits', () => { assert.ok(capturedSpawn); await assert.rejects( capturedSpawn({ - spec: childSpec(0), + agentProfile: 'local_read', prompt: 'late child', }), /permit scope ended/, @@ -264,10 +264,10 @@ function childBatchProbeTool( parameters: {}, categoryHint: 'subagent', impl: async (_args, ctx) => { - if (!ctx.spawnChildAgent) throw new Error('missing spawn capability'); + if (!ctx.spawnChildSession) throw new Error('missing spawn capability'); const pending = Array.from({ length: count }, (_, index) => - ctx.spawnChildAgent!({ - spec: childSpec(index), + ctx.spawnChildSession!({ + agentProfile: 'local_read', prompt: `${promptPrefix}-${index}`, }), ); @@ -291,12 +291,12 @@ function sequentialFailureProbeTool(count: number): MakaTool { parameters: {}, categoryHint: 'subagent', impl: async (_args, ctx) => { - if (!ctx.spawnChildAgent) throw new Error('missing spawn capability'); + if (!ctx.spawnChildSession) throw new Error('missing spawn capability'); let rejected = 0; for (let index = 0; index < count; index += 1) { try { - await ctx.spawnChildAgent({ - spec: childSpec(index), + await ctx.spawnChildSession({ + agentProfile: 'local_read', prompt: `failure-${index}`, }); } catch { @@ -308,16 +308,8 @@ function sequentialFailureProbeTool(count: number): MakaTool { }; } -function childSpec(index: number) { - return { - id: `test-child-${index}`, - name: `Test Child ${index}`, - systemPrompt: 'Test child.', - }; -} - function buildRuntime( - spawnChildAgent: NonNullable[0]['spawnChildAgent']>, + spawnChildSession: NonNullable[0]['spawnChildSession']>, ): ToolRuntime { return createTestToolRuntime({ sessionId: 'session-1', @@ -329,7 +321,7 @@ function buildRuntime( now: () => 1, getPermissionPauseTarget: () => null, runId: 'parent-run', - spawnChildAgent, + spawnChildSession, }); } diff --git a/packages/runtime/src/__tests__/agent-run-steering-recovery.test.ts b/packages/runtime/src/__tests__/agent-run-steering-recovery.test.ts index f3d95e1ac2..0fcba613ce 100644 --- a/packages/runtime/src/__tests__/agent-run-steering-recovery.test.ts +++ b/packages/runtime/src/__tests__/agent-run-steering-recovery.test.ts @@ -56,7 +56,6 @@ test('rejects an invalid tool mode before a durable AgentRun can be created', as store, newId: () => 'unused', now: () => 1, - recordSessionMessages: false, hooks: { reserveRun: async () => { throw new Error('reserveRun should not be called'); @@ -98,7 +97,6 @@ test('does not re-append atomically committed tool facts through the generic eve toolBoundaryProtocol: 't1_after_preflight_v1', newId: () => 'unused-id', now: () => 10, - recordSessionMessages: false, hooks: { reserveRun: async () => { throw new Error('reserveRun should not be called'); @@ -174,7 +172,6 @@ test('acks a steering event whose canonical append preceded proof publication fa runtimeEventStore, newId: () => 'unused-id', now: () => 10, - recordSessionMessages: false, hooks: { reserveRun: async () => { throw new Error('reserveRun should not be called'); @@ -442,7 +439,6 @@ test('awaits canonical Run status persistence before accepting an interaction re runtimeEventStore, newId: () => 'status-event', now: () => 10, - recordSessionMessages: false, hooks: { reserveRun: async () => { throw new Error('reserveRun should not be called'); @@ -536,7 +532,6 @@ test('required interaction resume recovers a failed best-effort Run Store latch runtimeEventStore, newId: () => 'status-latch-event', now: () => 10, - recordSessionMessages: false, hooks: { reserveRun: async () => { throw new Error('reserveRun should not be called'); @@ -654,7 +649,6 @@ test('required interaction resume stays fail-closed until a later required write runtimeEventStore, newId: () => 'status-latch-failure-event', now: () => 10, - recordSessionMessages: false, hooks: { reserveRun: async () => { throw new Error('reserveRun should not be called'); diff --git a/packages/runtime/src/__tests__/runtime-ledger-repair.test.ts b/packages/runtime/src/__tests__/runtime-ledger-repair.test.ts index 0b1d39a5d1..066c6fedd8 100644 --- a/packages/runtime/src/__tests__/runtime-ledger-repair.test.ts +++ b/packages/runtime/src/__tests__/runtime-ledger-repair.test.ts @@ -185,7 +185,6 @@ test('repairs imported transcript turns into provider-neutral canonical history' sessionId: session.id, currentRunId, currentTurnId: 'turn-3', - linkedChildSession: false, runStore: runs, runtimeEventStore: runtimeEvents, runStoreAvailable: true, diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 794572efb0..50f3a9df05 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -115,7 +115,6 @@ import { LOCAL_READ_AGENT_DEFINITION, LOCAL_READ_AGENT_ID, LOCAL_READ_AGENT_PROFILE, - WEB_RESEARCH_AGENT_DEFINITION, WEB_RESEARCH_AGENT_ID, } from '../agent-catalog.js'; import { @@ -162,6 +161,38 @@ test('sendMessage rejects removed Automation as a live trigger', async () => { ); }); +test('sendMessage rejects removed child AgentRun lineage as a live trigger', async () => { + for (const lineage of [ + { parentRunId: 'parent-run' }, + { resumedFromRunId: 'resumed-run' }, + { retriedFromRunId: 'retried-run' }, + ]) { + const runStore = new MemoryAgentRunStore(); + const manager = new SessionManager({ + store: new MemorySessionStore(), + runStore, + runtimeEventStore: runStore, + backends: new BackendRegistry(), + newId: nextId(), + now: nextNow(1), + }); + const session = await manager.createSession(makeInput()); + + await assert.rejects( + () => + drain( + manager.sendMessage(session.id, { + turnId: 'legacy-child-turn', + text: 'removed child AgentRun input', + ...lineage, + } as UserMessageInput), + ), + /removed child AgentRun lineage/, + ); + expect(await runStore.listSessionRuns(session.id)).toEqual([]); + } +}); + /** * "A turn is running" is a fact about the live process, so the read model takes * it from the run rather than from the persisted status. The status cannot @@ -2448,14 +2479,15 @@ describe('SessionManager child-session runtime primitive', () => { while (!(await parentTurn.next()).done) {} }); - test('resumes a fresh child by returned runId inside the same linked Session', async () => { + test('hosted child spawn joins the exact in-flight identity and rejects request drift', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); const backends = new BackendRegistry(); const parentGate = makeGate(); + const childGate = makeGate(); const backendsBySession = new Map(); backends.register('ai-sdk', (ctx) => { - const backend = new TestBackend(ctx, ctx.header.subagentRuntime ? undefined : parentGate); + const backend = new TestBackend(ctx, ctx.header.subagentRuntime ? childGate : parentGate); backendsBySession.set(ctx.sessionId, backend); return backend; }); @@ -2466,61 +2498,65 @@ describe('SessionManager child-session runtime primitive', () => { backends, childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], newId: nextId(), - now: nextNow(130), + now: nextNow(150), + messageAuthority: hostedRootAuthority(), }); - const parent = await manager.createSession(makeInput({ permissionMode: 'ask' })); + const parent = await manager.createSession(makeInput()); const parentTurn = manager .sendMessage(parent.id, { turnId: 'parent-turn', text: 'keep parent active' }) [Symbol.asyncIterator](); await parentTurn.next(); const [parentRun] = await runStore.listSessionRuns(parent.id); if (!parentRun) throw new Error('parent run was not recorded'); - - const child = await manager.spawnChildSession(parent.id, { + const spawnInput = { spawnedBy: { parentRunId: parentRun.runId, parentTurnId: parentRun.turnId, - toolCallId: 'fresh-swarm-item', + toolCallId: 'same-tool-call', }, agentProfile: LOCAL_READ_AGENT_PROFILE, - prompt: 'inspect the initial boundary', - }); - const prepared = await manager.prepareChildAgentResume(parent.id, child.runId); - expect(prepared.execution).toEqual({ - kind: 'child_session', - sessionId: child.childSessionId, - currentRunId: child.runId, + prompt: 'one durable task', + } as const; + const ready = makeGate(); + const first = manager.spawnChildSession(parent.id, { + ...spawnInput, + onReady: () => ready.release(), }); + await ready.promise; + const joined = manager.spawnChildSession(parent.id, spawnInput); + await expectRejects( + manager.spawnChildSession(parent.id, { ...spawnInput, prompt: 'different work' }), + /reused for different work/, + ); + expect((await manager.listChildSessions(parent.id)).length).toBe(1); - const resumed = await manager.resumeChildAgent(parent.id, { - parentRunId: parentRun.runId, - sourceRunId: child.runId, - prompt: 'continue from the returned swarm run id', - }); - expect(resumed.childSessionId).toBe(child.childSessionId); - expect(resumed.resumedFromRunId).toBe(child.runId); - const resumedRun = await runStore.readRun(child.childSessionId, resumed.runId!); - expect(isSessionInlineRun(resumedRun)).toBe(true); - expect(resumedRun.parentRunId).toBe(undefined); - expect(resumedRun.resumedFromRunId).toBe(child.runId); - expect( - backendsBySession - .get(child.childSessionId) - ?.sendInputs[1]?.runtimeContext?.some((event) => event.runId === child.runId), - ).toBe(true); - expect((await manager.listChildAgents(parent.id)).executions[0]?.execution).toEqual({ - kind: 'child_session', - sessionId: child.childSessionId, - currentRunId: resumed.runId, - }); + childGate.release(); + const [firstResult, joinedResult] = await Promise.all([first, joined]); + expect(joinedResult.childSessionId).toBe(firstResult.childSessionId); + expect(joinedResult.runId).toBe(firstResult.runId); + expect((await runStore.listSessionRuns(firstResult.childSessionId)).length).toBe(1); + + const durableRetry = await manager.spawnChildSession(parent.id, spawnInput); + expect(durableRetry.childSessionId).toBe(firstResult.childSessionId); + expect(durableRetry.runId).toBe(firstResult.runId); + expect(durableRetry.summary).toBe('ok'); + expect((await manager.listChildSessions(parent.id)).length).toBe(1); + expect(backendsBySession.get(firstResult.childSessionId)?.sendInputs.length).toBe(1); parentGate.release(); while (!(await parentTurn.next()).done) {} }); - test('does not resume a linked child after its parent boundary narrows', async () => { - const store = new AtomicBoundaryMemorySessionStore(); - const runStore = new MemoryAgentRunStore(); + test('starts a metadata-only retry once, notifies once, and rechecks cancellation', async () => { + const store = new MemorySessionStore(); + const abortController = new AbortController(); + const runStore = new MemoryAgentRunStore({ + beforeRunRead: (sessionId, runId) => { + if (sessionId === 'session-3' && runId === 'cancelled-child-run') { + abortController.abort(); + } + }, + }); const backends = new BackendRegistry(); const parentGate = makeGate(); backends.register( @@ -2534,9 +2570,9 @@ describe('SessionManager child-session runtime primitive', () => { backends, childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], newId: nextId(), - now: nextNow(140), + now: nextNow(170), }); - const parent = await manager.createSession(makeInput({ permissionMode: 'bypass' })); + const parent = await manager.createSession(makeInput()); const parentTurn = manager .sendMessage(parent.id, { turnId: 'parent-turn', text: 'keep parent active' }) [Symbol.asyncIterator](); @@ -2544,84 +2580,127 @@ describe('SessionManager child-session runtime primitive', () => { const [parentRun] = await runStore.listSessionRuns(parent.id); if (!parentRun) throw new Error('parent run was not recorded'); - const child = await manager.spawnChildSession(parent.id, { + const seedMetadataOnlyChild = async ( + toolCallId: string, + prompt: string, + initialTurnId: string, + initialRunId: string, + ): Promise => { + const requestFingerprint = createHash('sha256') + .update( + JSON.stringify([ + 1, + parent.id, + parentRun.runId, + parentRun.turnId, + toolCallId, + LOCAL_READ_AGENT_PROFILE, + prompt, + null, + null, + ]), + ) + .digest('hex'); + return ( + await store.createSubagent( + makeInput({ + permissionMode: 'explore', + collaborationMode: 'agent', + orchestrationMode: 'default', + subagentParent: { + kind: 'subagent', + parentSessionId: parent.id, + spawnedBy: { + parentRunId: parentRun.runId, + parentTurnId: parentRun.turnId, + toolCallId, + }, + lifecycle: 'foreground', + }, + subagentRuntime: { + schemaVersion: 1, + definitionVersion: LOCAL_READ_AGENT_DEFINITION.definitionVersion, + agentId: LOCAL_READ_AGENT_ID, + agentName: LOCAL_READ_AGENT_DEFINITION.name, + profile: LOCAL_READ_AGENT_PROFILE, + systemPrompt: LOCAL_READ_AGENT_DEFINITION.systemPrompt, + toolNames: [...LOCAL_READ_AGENT_DEFINITION.tools], + categoryPolicy: {}, + }, + subagentSpawn: { + schemaVersion: 1, + requestFingerprint, + initialTurnId, + initialRunId, + }, + }), + ) + ).header; + }; + + const metadataOnly = await seedMetadataOnlyChild( + 'metadata-only-tool', + 'resume after metadata commit', + 'metadata-only-turn', + 'metadata-only-run', + ); + let readyCalls = 0; + const resumed = await manager.spawnChildSession(parent.id, { spawnedBy: { parentRunId: parentRun.runId, parentTurnId: parentRun.turnId, - toolCallId: 'stale-boundary-child', + toolCallId: 'metadata-only-tool', }, agentProfile: LOCAL_READ_AGENT_PROFILE, - prompt: 'inspect before the boundary narrows', - }); - store.forceBoundary(parent.id, { - ...createGenesisExecutionBoundary('ask'), - revision: 1, + prompt: 'resume after metadata commit', + onReady: () => { + readyCalls += 1; + }, }); + expect(resumed.childSessionId).toBe(metadataOnly.id); + expect(resumed.runId).toBe('metadata-only-run'); + expect(readyCalls).toBe(1); + expect((await runStore.listSessionRuns(metadataOnly.id)).length).toBe(1); + const cancelled = await seedMetadataOnlyChild( + 'cancelled-metadata-tool', + 'must remain cancelled', + 'cancelled-child-turn', + 'cancelled-child-run', + ); + let cancelledReadyCalls = 0; await expectRejects( - manager.prepareChildAgentResume(parent.id, child.runId), - /execution boundary no longer matches its parent/, + manager.spawnChildSession(parent.id, { + spawnedBy: { + parentRunId: parentRun.runId, + parentTurnId: parentRun.turnId, + toolCallId: 'cancelled-metadata-tool', + }, + agentProfile: LOCAL_READ_AGENT_PROFILE, + prompt: 'must remain cancelled', + abortSignal: abortController.signal, + onReady: () => { + cancelledReadyCalls += 1; + }, + }), + /cancelled before its first run/, ); + expect(cancelledReadyCalls).toBe(0); + expect(await runStore.listSessionRuns(cancelled.id)).toEqual([]); parentGate.release(); while (!(await parentTurn.next()).done) {} }); - test('retries a rate-limited fresh child inside the same linked Session', async () => { + test('reopens a child from its exact runtime snapshot after the builtin profile changes', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); const backends = new BackendRegistry(); const parentGate = makeGate(); - const childInputs: BackendSendInput[] = []; - let childAttempts = 0; - let failClaimOnce = true; + const contexts: BackendFactoryContext[] = []; backends.register('ai-sdk', (ctx) => { - if (!ctx.header.subagentRuntime) return new TestBackend(ctx, parentGate); - return { - kind: 'fake' as const, - sessionId: ctx.sessionId, - async *send(input: BackendSendInput): AsyncIterable { - childAttempts += 1; - childInputs.push(input); - if (childAttempts <= 2) { - yield { - type: 'error', - id: `${input.turnId}-error`, - turnId: input.turnId, - ts: 1, - recoverable: true, - reason: 'RateLimit', - message: 'provider 429', - }; - yield { - type: 'complete', - id: `${input.turnId}-complete`, - turnId: input.turnId, - ts: 2, - stopReason: 'error', - }; - return; - } - yield { - type: 'text_delta', - id: `${input.turnId}-delta`, - turnId: input.turnId, - ts: 3, - messageId: `${input.turnId}-message`, - text: 'recovered', - }; - yield { - type: 'complete', - id: `${input.turnId}-complete`, - turnId: input.turnId, - ts: 4, - stopReason: 'end_turn', - }; - }, - async stop(): Promise {}, - async respondToSandboxBoundary(): Promise {}, - async dispose(): Promise {}, - }; + contexts.push(ctx); + return new TestBackend(ctx, ctx.header.subagentRuntime ? undefined : parentGate); }); const manager = new SessionManager({ store, @@ -2629,238 +2708,95 @@ describe('SessionManager child-session runtime primitive', () => { runtimeEventStore: runStore, backends, childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], - inspectContinuationSafety: async () => ({ - workspaceIdentity: '/tmp/cwd', - backgroundOperationsSettled: true, - availableToolNames: [], - }), - continuationFailpoint: async (point) => { - if (point === 'after_continuation_claim_committed' && failClaimOnce) { - failClaimOnce = false; - throw new Error('simulated linked child claim-only crash'); - } - }, newId: nextId(), - now: nextNow(145), + now: nextNow(175), }); - const parent = await manager.createSession(makeInput({ permissionMode: 'ask' })); + const parent = await manager.createSession(makeInput()); const parentTurn = manager .sendMessage(parent.id, { turnId: 'parent-turn', text: 'keep parent active' }) [Symbol.asyncIterator](); await parentTurn.next(); const [parentRun] = await runStore.listSessionRuns(parent.id); if (!parentRun) throw new Error('parent run was not recorded'); - const child = await manager.spawnChildSession(parent.id, { spawnedBy: { parentRunId: parentRun.runId, parentTurnId: parentRun.turnId, - toolCallId: 'rate-limited-swarm-item', + toolCallId: 'snapshot-tool-call', }, agentProfile: LOCAL_READ_AGENT_PROFILE, - prompt: 'inspect with a transient provider failure', + prompt: 'first child turn', }); - expect(child.status).toBe('failed'); - expect(child.failureClass).toBe('RateLimit'); + const durablePrompt = (await store.readHeader(child.childSessionId)).subagentRuntime + ?.systemPrompt; + if (!durablePrompt) throw new Error('child runtime snapshot was not persisted'); + + const originalPrompt = LOCAL_READ_AGENT_DEFINITION.systemPrompt; + let parentTurnDrained = false; + const drainParentTurn = async (): Promise => { + parentGate.release(); + while (!(await parentTurn.next()).done) {} + parentTurnDrained = true; + }; + try { + LOCAL_READ_AGENT_DEFINITION.systemPrompt = 'Changed catalog prompt that must not leak.'; + let refreshSettled = false; + const refresh = manager.refreshIdleBackends().finally(() => { + refreshSettled = true; + }); + await Promise.resolve(); + expect(refreshSettled).toBe(false); + expect(contexts.filter((ctx) => ctx.sessionId === parent.id).length).toBe(1); + await drainParentTurn(); + await refresh; + await drain( + manager.sendMessage(child.childSessionId, { + turnId: 'child-follow-up', + text: 'use the durable profile', + }), + ); + } finally { + if (!parentTurnDrained) await drainParentTurn(); + LOCAL_READ_AGENT_DEFINITION.systemPrompt = originalPrompt; + } + + const childContexts = contexts.filter((ctx) => ctx.sessionId === child.childSessionId); + expect(childContexts.length).toBe(2); + expect(childContexts[1]?.systemPrompt).toBe(durablePrompt); + expect(childContexts[1]?.tools?.map((tool) => tool.name)).toEqual(['Read', 'Glob', 'Grep']); - const resumed = await manager.resumeChildAgent(parent.id, { - parentRunId: parentRun.runId, - sourceRunId: child.runId, - prompt: 'retry with additional guidance', + const missingToolManager = new SessionManager({ + store, + runStore, + runtimeEventStore: runStore, + backends, + childTools: [testTool('Read'), testTool('Glob')], + newId: nextId(), + now: nextNow(185), }); - expect(resumed.status).toBe('failed'); - expect(resumed.failureClass).toBe('RateLimit'); await expectRejects( - manager.retryChildAgent(parent.id, { - parentRunId: parentRun.runId, - sourceRunId: child.runId, - }), - /already has a successor/, - ); - - const resumedHeader = await runStore.readRun(child.childSessionId, resumed.runId!); - const resumedTerminal = ( - await runStore.readImmutableRuntimeEvents(child.childSessionId, resumed.runId!) - ).find(isTerminalRuntimeEvent); - if (!resumedTerminal) throw new Error('resumed child terminal fact was not recorded'); - runStore.replaceRuntimeEvent(child.childSessionId, resumed.runId!, resumedTerminal.id, { - ...resumedTerminal, - actions: { - ...resumedTerminal.actions, - stateDelta: { - ...resumedTerminal.actions?.stateDelta, - failureClass: 'continuation_abandoned_before_provider_dispatch', - }, - }, - }); - await runStore.updateRun(child.childSessionId, resumed.runId!, { - failureClass: 'continuation_abandoned_before_provider_dispatch', - }); - await expectRejects( - manager.retryChildAgent(parent.id, { - parentRunId: parentRun.runId, - sourceRunId: resumed.runId!, - execution: { - kind: 'child_session', - sessionId: child.childSessionId, - currentRunId: resumed.runId!, - }, - }), - /proven pre-provider continuation abandonment/, - ); - runStore.replaceRuntimeEvent( - child.childSessionId, - resumed.runId!, - resumedTerminal.id, - resumedTerminal, - ); - await runStore.updateRun(child.childSessionId, resumed.runId!, { - failureClass: resumedHeader.failureClass, - }); - - await expectRejects( - manager.retryChildAgent(parent.id, { - parentRunId: parentRun.runId, - sourceRunId: resumed.runId!, - execution: { - kind: 'child_session', - sessionId: child.childSessionId, - currentRunId: resumed.runId!, - }, - }), - /simulated linked child claim-only crash/, - ); - expect(childInputs).toHaveLength(2); - - const [claimState] = await runStore.listContinuationClaimsForRecovery(child.childSessionId); - if (!claimState) throw new Error('linked child continuation claim was not recorded'); - const claimedHeader = claimState.claim.targetRunHeader; - if (!claimedHeader.agentId || !claimedHeader.agentName) { - throw new Error('linked child continuation claim lost its trusted agent identity'); - } - await manager.closePendingHostedAdmission({ - sessionId: child.childSessionId, - turnId: claimState.claim.target.turnId, - runId: claimState.claim.target.runId, - admittedAt: claimState.claim.claimedAt - 1, - execution: { - kind: 'linked_child_provider_retry', - agentId: claimedHeader.agentId, - agentName: claimedHeader.agentName, - sourceRunId: resumed.runId!, - }, - }); - await expectRejects( - runStore.readRun(child.childSessionId, claimState.claim.target.runId), - /unknown run/i, - ); - - await manager.recoverInterruptedSessions(); - const repaired = (await runStore.listSessionRuns(child.childSessionId)).find( - (run) => - run.retriedFromRunId === resumed.runId && - run.failureClass === 'continuation_abandoned_before_provider_dispatch', - ); - if (!repaired) throw new Error('linked child claim-only target was not repaired'); - - const retried = await manager.retryChildAgent(parent.id, { - parentRunId: parentRun.runId, - sourceRunId: repaired.runId, - execution: { - kind: 'child_session', - sessionId: child.childSessionId, - currentRunId: repaired.runId, - }, - }); - expect(retried.status).toBe('completed'); - expect(retried.childSessionId).toBe(child.childSessionId); - expect(retried.retriedFromRunId).toBe(repaired.runId); - expect(childInputs.map((input) => input.text)).toEqual([ - 'inspect with a transient provider failure', - 'retry with additional guidance', - '', - ]); - const retryRun = await runStore.readRun(child.childSessionId, retried.runId!); - expect(isSessionInlineRun(retryRun)).toBe(true); - expect(retryRun.parentRunId).toBe(undefined); - expect(retryRun.retriedFromRunId).toBe(repaired.runId); - expect(retryRun.continuationSource).toMatchObject({ - protocol: 'continuation_source_v2', - sourceRunId: repaired.runId, - }); - const retryStart = ( - await runStore.readImmutableRuntimeEvents(child.childSessionId, retried.runId!) - )[0]?.actions?.continuationStart; - expect(retryStart?.protocol).toBe('continuation_start_v2'); - expect(retryStart?.immediateSource.runId).toBe(repaired.runId); - await expectRejects( - manager.prepareChildAgentResume(parent.id, child.runId), - /already has a successor/, - ); - expect((await manager.prepareChildAgentResume(parent.id, retried.runId!)).execution).toEqual({ - kind: 'child_session', - sessionId: child.childSessionId, - currentRunId: retried.runId, - }); - expect( - (await store.readMessages(child.childSessionId)).filter( - (message) => 'turnId' in message && message.turnId === retried.turnId, + drain( + missingToolManager.sendMessage(child.childSessionId, { + turnId: 'missing-tool-follow-up', + text: 'must fail closed', + }), ), - ).toEqual([]); - expect((await manager.listChildAgents(parent.id)).executions[0]?.execution).toEqual({ - kind: 'child_session', - sessionId: child.childSessionId, - currentRunId: retried.runId, - }); - - parentGate.release(); - while (!(await parentTurn.next()).done) {} + /runtime tool snapshot is unavailable/, + ); }); - test('linked retry exposes its hinted target Session before the first ledger read', async () => { - const ledgerReadStarted = makeGate(); - const releaseLedgerRead = makeGate(); - let gateLedgerRead = false; + test('reopens after restart with isolated history, tool activity, usage, and compaction', async () => { const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore({ - beforeRuntimeEventRead: async () => { - if (!gateLedgerRead) return; - gateLedgerRead = false; - ledgerReadStarted.release(); - await releaseLedgerRead.promise; - }, - }); + const runStore = new MemoryAgentRunStore(); const backends = new BackendRegistry(); const parentGate = makeGate(); - const childInputs: BackendSendInput[] = []; + const compactCalls: Array<{ turnId: string; runtimeContextCount: number }> = []; + const childBackends: LifecycleChildBackend[] = []; backends.register('ai-sdk', (ctx) => { if (!ctx.header.subagentRuntime) return new TestBackend(ctx, parentGate); - return { - kind: 'fake' as const, - sessionId: ctx.sessionId, - async *send(input: BackendSendInput): AsyncIterable { - childInputs.push(input); - yield { - type: 'error', - id: `${input.turnId}-error`, - turnId: input.turnId, - ts: 1, - recoverable: true, - reason: 'RateLimit', - message: 'provider 429', - }; - yield { - type: 'complete', - id: `${input.turnId}-complete`, - turnId: input.turnId, - ts: 2, - stopReason: 'error', - }; - }, - async stop(): Promise {}, - async respondToSandboxBoundary(): Promise {}, - async dispose(): Promise {}, - }; + const backend = new LifecycleChildBackend(ctx, compactCalls); + childBackends.push(backend); + return backend; }); const manager = new SessionManager({ store, @@ -2868,169 +2804,90 @@ describe('SessionManager child-session runtime primitive', () => { runtimeEventStore: runStore, backends, childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], - inspectContinuationSafety: async () => ({ - workspaceIdentity: '/tmp/cwd', - backgroundOperationsSettled: true, - availableToolNames: [], - }), newId: nextId(), - now: nextNow(155), + now: nextNow(186), }); - const parent = await manager.createSession(makeInput({ permissionMode: 'ask' })); + const parent = await manager.createSession(makeInput()); const parentTurn = manager - .sendMessage(parent.id, { turnId: 'parent-linked-retry', text: 'keep parent active' }) + .sendMessage(parent.id, { turnId: 'parent-turn', text: 'private parent context' }) [Symbol.asyncIterator](); await parentTurn.next(); const [parentRun] = await runStore.listSessionRuns(parent.id); if (!parentRun) throw new Error('parent run was not recorded'); + const child = await manager.spawnChildSession(parent.id, { spawnedBy: { parentRunId: parentRun.runId, parentTurnId: parentRun.turnId, - toolCallId: 'target-stop-retry', + toolCallId: 'restart-observation-tool', }, agentProfile: LOCAL_READ_AGENT_PROFILE, - prompt: 'fail once before target stop', - }); - expect(child.status).toBe('failed'); - - gateLedgerRead = true; - const retried = manager.retryChildAgent(parent.id, { - parentRunId: parentRun.runId, - sourceRunId: child.runId, - execution: { - kind: 'child_session', - sessionId: child.childSessionId, - currentRunId: child.runId, - }, + prompt: 'inspect README before restart', }); - await ledgerReadStarted.promise; - let stopSettled = false; - const stopping = manager - .stopSession(child.childSessionId, { source: 'stop_button' }) - .finally(() => { - stopSettled = true; - }); - await Promise.resolve(); - expect(stopSettled).toBe(false); - - releaseLedgerRead.release(); - const [result] = await Promise.all([retried, stopping]); - expect(result.status).toBe('cancelled'); - expect(result.retriedFromRunId).toBe(child.runId); - expect(childInputs).toHaveLength(1); - const retryRun = (await runStore.listSessionRuns(child.childSessionId)).find( - (run) => run.retriedFromRunId === child.runId, - ); - expect(retryRun?.status).toBe('cancelled'); - expect((await store.readHeader(child.childSessionId)).status === 'blocked').toBe(false); - - await expectRejects( - manager.retryChildAgent(parent.id, { - parentRunId: parentRun.runId, - sourceRunId: child.runId, - execution: { - kind: 'child_session', - sessionId: child.childSessionId, - currentRunId: 'stale-hinted-run', - }, - }), - /execution identity changed/, - ); - await manager.stopSession(child.childSessionId, { source: 'stop_button' }); - parentGate.release(); - while (!(await parentTurn.next()).done) {} - }); - - test('hosted child spawn joins the exact in-flight identity and rejects request drift', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - const parentGate = makeGate(); - const childGate = makeGate(); - const backendsBySession = new Map(); - backends.register('ai-sdk', (ctx) => { - const backend = new TestBackend(ctx, ctx.header.subagentRuntime ? childGate : parentGate); - backendsBySession.set(ctx.sessionId, backend); - return backend; - }); - const manager = new SessionManager({ + // A new manager represents a restarted Runtime Host. It must activate the + // child through the durable Session header and replay only child history. + const restarted = new SessionManager({ store, runStore, runtimeEventStore: runStore, backends, childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], newId: nextId(), - now: nextNow(150), - messageAuthority: hostedRootAuthority(), - }); - const parent = await manager.createSession(makeInput()); - const parentTurn = manager - .sendMessage(parent.id, { turnId: 'parent-turn', text: 'keep parent active' }) - [Symbol.asyncIterator](); - await parentTurn.next(); - const [parentRun] = await runStore.listSessionRuns(parent.id); - if (!parentRun) throw new Error('parent run was not recorded'); - const spawnInput = { - spawnedBy: { - parentRunId: parentRun.runId, - parentTurnId: parentRun.turnId, - toolCallId: 'same-tool-call', - }, - agentProfile: LOCAL_READ_AGENT_PROFILE, - prompt: 'one durable task', - } as const; - const ready = makeGate(); - const first = manager.spawnChildSession(parent.id, { - ...spawnInput, - onReady: () => ready.release(), + now: nextNow(196), }); - await ready.promise; - const joined = manager.spawnChildSession(parent.id, spawnInput); - await expectRejects( - manager.spawnChildSession(parent.id, { ...spawnInput, prompt: 'different work' }), - /reused for different work/, + await drain( + restarted.sendMessage(child.childSessionId, { + turnId: 'child-follow-up', + text: 'inspect it again after restart', + }), ); - expect((await manager.listChildSessions(parent.id)).length).toBe(1); + const restartedBackend = childBackends.at(-1); + const followUpContext = restartedBackend?.sendInputs.at(-1)?.runtimeContext ?? []; + expect(followUpContext.some((event) => event.runId === child.runId)).toBe(true); + expect( + followUpContext.some( + (event) => + event.content?.kind === 'text' && event.content.text.includes('private parent context'), + ), + ).toBe(false); - childGate.release(); - const [firstResult, joinedResult] = await Promise.all([first, joined]); - expect(joinedResult.childSessionId).toBe(firstResult.childSessionId); - expect(joinedResult.runId).toBe(firstResult.runId); - expect((await runStore.listSessionRuns(firstResult.childSessionId)).length).toBe(1); + await drain(restarted.compactSession(child.childSessionId, { turnId: 'child-compact' })); + expect(compactCalls).toHaveLength(1); + expect(compactCalls[0]?.turnId).toBe('child-compact'); - const durableRetry = await manager.spawnChildSession(parent.id, spawnInput); - expect(durableRetry.childSessionId).toBe(firstResult.childSessionId); - expect(durableRetry.runId).toBe(firstResult.runId); - expect(durableRetry.summary).toBe('ok'); - expect((await manager.listChildSessions(parent.id)).length).toBe(1); - expect(backendsBySession.get(firstResult.childSessionId)?.sendInputs.length).toBe(1); + const childMessages = await restarted.getMessages(child.childSessionId); + expect(childMessages.some((message) => message.type === 'tool_call')).toBe(true); + expect(childMessages.some((message) => message.type === 'tool_result')).toBe(true); + expect(childMessages.some((message) => message.type === 'token_usage')).toBe(true); + expect( + childMessages.some( + (message) => + message.type === 'turn_state' && + message.turnId === 'child-compact' && + message.status === 'completed', + ), + ).toBe(true); + const parentMessages = await restarted.getMessages(parent.id); + expect( + parentMessages.some( + (message) => + message.turnId === child.turnId || + message.turnId === 'child-follow-up' || + message.turnId === 'child-compact', + ), + ).toBe(false); parentGate.release(); while (!(await parentTurn.next()).done) {} }); - test('hosted linked-child gate precedes resume preparation and remains held through terminal', async () => { + test('recovers an idempotent retry whose persisted initial run is no longer active', async () => { const store = new MemorySessionStore(); - const preparationEntered = makeGate(); - const allowPreparation = makeGate(); - let watchedRunId: string | undefined; - let preparationReads = 0; - const runStore = new MemoryAgentRunStore({ - beforeRuntimeEventRead: async (_sessionId, runId) => { - if (runId !== watchedRunId) return; - preparationReads += 1; - preparationEntered.release(); - await allowPreparation.promise; - }, - }); + const runStore = new MemoryAgentRunStore(); const backends = new BackendRegistry(); const parentGate = makeGate(); - backends.register( - 'ai-sdk', - (ctx) => new TestBackend(ctx, ctx.header.subagentRuntime ? undefined : parentGate), - ); + backends.register('ai-sdk', (ctx) => new TestBackend(ctx, parentGate)); const manager = new SessionManager({ store, runStore, @@ -3038,8 +2895,7 @@ describe('SessionManager child-session runtime primitive', () => { backends, childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], newId: nextId(), - now: nextNow(165), - messageAuthority: hostedRootAuthority(), + now: nextNow(190), }); const parent = await manager.createSession(makeInput()); const parentTurn = manager @@ -3048,66 +2904,104 @@ describe('SessionManager child-session runtime primitive', () => { await parentTurn.next(); const [parentRun] = await runStore.listSessionRuns(parent.id); if (!parentRun) throw new Error('parent run was not recorded'); - const child = await manager.spawnChildSession(parent.id, { - spawnedBy: { - parentRunId: parentRun.runId, - parentTurnId: parentRun.turnId, - toolCallId: 'hosted-gate-cut', - }, - agentProfile: LOCAL_READ_AGENT_PROFILE, - prompt: 'inspect the gate cut', - }); - - watchedRunId = child.runId; - const first = manager.resumeChildAgent(parent.id, { - parentRunId: parentRun.runId, - sourceRunId: child.runId, - prompt: 'first successor', - }); - await preparationEntered.promise; - const second = manager.resumeChildAgent(parent.id, { - parentRunId: parentRun.runId, - sourceRunId: child.runId, - prompt: 'stale second successor', - }); - const outcomes = Promise.allSettled([first, second]); - await new Promise((resolve) => setTimeout(resolve, 10)); - const readsBeforeRelease = preparationReads; - allowPreparation.release(); - - const [firstOutcome, secondOutcome] = await outcomes; - expect(readsBeforeRelease).toBe(1); - expect(firstOutcome.status).toBe('fulfilled'); - expect(secondOutcome.status).toBe('rejected'); - expect(secondOutcome.status === 'rejected' ? String(secondOutcome.reason) : '').toContain( - 'already has an active execution', - ); - expect( - (await runStore.listSessionRuns(child.childSessionId)).filter( - (run) => run.resumedFromRunId === child.runId, - ).length, - ).toBe(1); - - parentGate.release(); - while (!(await parentTurn.next()).done) {} - }); - - test('starts a metadata-only retry once, notifies once, and rechecks cancellation', async () => { - const store = new MemorySessionStore(); - const abortController = new AbortController(); - const runStore = new MemoryAgentRunStore({ - beforeRunRead: (sessionId, runId) => { - if (sessionId === 'session-3' && runId === 'cancelled-child-run') { - abortController.abort(); - } - }, - }); - const backends = new BackendRegistry(); - const parentGate = makeGate(); - backends.register( - 'ai-sdk', - (ctx) => new TestBackend(ctx, ctx.header.subagentRuntime ? undefined : parentGate), + const toolCallId = 'recovery-tool-call'; + const prompt = 'recover this exact request'; + const requestFingerprint = createHash('sha256') + .update( + JSON.stringify([ + 1, + parent.id, + parentRun.runId, + parentRun.turnId, + toolCallId, + LOCAL_READ_AGENT_PROFILE, + prompt, + null, + null, + ]), + ) + .digest('hex'); + const { header: child } = await store.createSubagent( + makeInput({ + name: 'Stale child', + permissionMode: 'explore', + collaborationMode: 'agent', + orchestrationMode: 'default', + subagentParent: { + kind: 'subagent', + parentSessionId: parent.id, + spawnedBy: { + parentRunId: parentRun.runId, + parentTurnId: parentRun.turnId, + toolCallId, + }, + lifecycle: 'foreground', + }, + subagentRuntime: { + schemaVersion: 1, + definitionVersion: LOCAL_READ_AGENT_DEFINITION.definitionVersion, + agentId: LOCAL_READ_AGENT_ID, + agentName: LOCAL_READ_AGENT_DEFINITION.name, + profile: LOCAL_READ_AGENT_PROFILE, + systemPrompt: LOCAL_READ_AGENT_DEFINITION.systemPrompt, + toolNames: [...LOCAL_READ_AGENT_DEFINITION.tools], + categoryPolicy: {}, + }, + subagentSpawn: { + schemaVersion: 1, + requestFingerprint, + initialTurnId: 'stale-child-turn', + initialRunId: 'stale-child-run', + }, + }), + ); + await seedRunningTurn(store, child.id, 'stale-child-turn'); + await seedRun( + runStore, + makeRunHeader({ + sessionId: child.id, + runId: 'stale-child-run', + turnId: 'stale-child-turn', + status: 'running', + permissionMode: 'explore', + agentId: LOCAL_READ_AGENT_ID, + agentName: LOCAL_READ_AGENT_DEFINITION.name, + }), + [ + makeRunEvent({ + sessionId: child.id, + runId: 'stale-child-run', + turnId: 'stale-child-turn', + type: 'run_started', + ts: 191, + }), + ], ); + + const recovered = await manager.spawnChildSession(parent.id, { + spawnedBy: { + parentRunId: parentRun.runId, + parentTurnId: parentRun.turnId, + toolCallId, + }, + agentProfile: LOCAL_READ_AGENT_PROFILE, + prompt, + }); + expect(recovered.childSessionId).toBe(child.id); + expect(recovered.runId).toBe('stale-child-run'); + expect(recovered.status).toBe('failed'); + expect(recovered.failureClass).toBe('app_restarted'); + expect((await manager.listChildSessions(parent.id)).length).toBe(1); + + parentGate.release(); + while (!(await parentTurn.next()).done) {} + }); + + test('requires the exact parent run to remain active before admitting child work', async () => { + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const backends = new BackendRegistry(); + backends.register('ai-sdk', (ctx) => new TestBackend(ctx)); const manager = new SessionManager({ store, runStore, @@ -3115,232 +3009,92 @@ describe('SessionManager child-session runtime primitive', () => { backends, childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], newId: nextId(), - now: nextNow(170), + now: nextNow(200), }); const parent = await manager.createSession(makeInput()); - const parentTurn = manager - .sendMessage(parent.id, { turnId: 'parent-turn', text: 'keep parent active' }) - [Symbol.asyncIterator](); - await parentTurn.next(); + await drain( + manager.sendMessage(parent.id, { turnId: 'parent-turn', text: 'already complete' }), + ); const [parentRun] = await runStore.listSessionRuns(parent.id); if (!parentRun) throw new Error('parent run was not recorded'); - const seedMetadataOnlyChild = async ( - toolCallId: string, - prompt: string, - initialTurnId: string, - initialRunId: string, - ): Promise => { - const requestFingerprint = createHash('sha256') - .update( - JSON.stringify([ - 1, - parent.id, - parentRun.runId, - parentRun.turnId, - toolCallId, - LOCAL_READ_AGENT_PROFILE, - prompt, - null, - null, - ]), - ) - .digest('hex'); - return ( - await store.createSubagent( - makeInput({ - permissionMode: 'explore', - collaborationMode: 'agent', - orchestrationMode: 'default', - subagentParent: { - kind: 'subagent', - parentSessionId: parent.id, - spawnedBy: { - parentRunId: parentRun.runId, - parentTurnId: parentRun.turnId, - toolCallId, - }, - lifecycle: 'foreground', - }, - subagentRuntime: { - schemaVersion: 1, - definitionVersion: LOCAL_READ_AGENT_DEFINITION.definitionVersion, - agentId: LOCAL_READ_AGENT_ID, - agentName: LOCAL_READ_AGENT_DEFINITION.name, - profile: LOCAL_READ_AGENT_PROFILE, - systemPrompt: LOCAL_READ_AGENT_DEFINITION.systemPrompt, - toolNames: [...LOCAL_READ_AGENT_DEFINITION.tools], - categoryPolicy: {}, - }, - subagentSpawn: { - schemaVersion: 1, - requestFingerprint, - initialTurnId, - initialRunId, - }, - }), - ) - ).header; - }; - - const metadataOnly = await seedMetadataOnlyChild( - 'metadata-only-tool', - 'resume after metadata commit', - 'metadata-only-turn', - 'metadata-only-run', - ); - let readyCalls = 0; - const resumed = await manager.spawnChildSession(parent.id, { - spawnedBy: { - parentRunId: parentRun.runId, - parentTurnId: parentRun.turnId, - toolCallId: 'metadata-only-tool', - }, - agentProfile: LOCAL_READ_AGENT_PROFILE, - prompt: 'resume after metadata commit', - onReady: () => { - readyCalls += 1; - }, - }); - expect(resumed.childSessionId).toBe(metadataOnly.id); - expect(resumed.runId).toBe('metadata-only-run'); - expect(readyCalls).toBe(1); - expect((await runStore.listSessionRuns(metadataOnly.id)).length).toBe(1); - - const cancelled = await seedMetadataOnlyChild( - 'cancelled-metadata-tool', - 'must remain cancelled', - 'cancelled-child-turn', - 'cancelled-child-run', - ); - let cancelledReadyCalls = 0; await expectRejects( manager.spawnChildSession(parent.id, { spawnedBy: { parentRunId: parentRun.runId, parentTurnId: parentRun.turnId, - toolCallId: 'cancelled-metadata-tool', + toolCallId: 'tool-call-1', }, agentProfile: LOCAL_READ_AGENT_PROFILE, - prompt: 'must remain cancelled', - abortSignal: abortController.signal, - onReady: () => { - cancelledReadyCalls += 1; - }, + prompt: 'must not start', }), - /cancelled before its first run/, + /parent run is not active/, ); - expect(cancelledReadyCalls).toBe(0); - expect(await runStore.listSessionRuns(cancelled.id)).toEqual([]); - - parentGate.release(); - while (!(await parentTurn.next()).done) {} + expect(await manager.listChildSessions(parent.id)).toEqual([]); }); - test('reopens a child from its exact runtime snapshot after the builtin profile changes', async () => { + test('admits child work through an external parent-run authority', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); const backends = new BackendRegistry(); - const parentGate = makeGate(); - const contexts: BackendFactoryContext[] = []; - backends.register('ai-sdk', (ctx) => { - contexts.push(ctx); - return new TestBackend(ctx, ctx.header.subagentRuntime ? undefined : parentGate); - }); + backends.register('ai-sdk', (ctx) => new TestBackend(ctx)); + let externalParent: + | { + sessionId: string; + runId: string; + turnId: string; + } + | undefined; const manager = new SessionManager({ store, runStore, runtimeEventStore: runStore, backends, childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], + isParentRunActive: (sessionId, runId, turnId) => + externalParent !== undefined && + externalParent.sessionId === sessionId && + externalParent.runId === runId && + externalParent.turnId === turnId, newId: nextId(), - now: nextNow(175), + now: nextNow(250), }); const parent = await manager.createSession(makeInput()); - const parentTurn = manager - .sendMessage(parent.id, { turnId: 'parent-turn', text: 'keep parent active' }) - [Symbol.asyncIterator](); - await parentTurn.next(); + await drain(manager.sendMessage(parent.id, { turnId: 'parent-turn', text: 'parent' })); const [parentRun] = await runStore.listSessionRuns(parent.id); if (!parentRun) throw new Error('parent run was not recorded'); + externalParent = { + sessionId: parent.id, + runId: parentRun.runId, + turnId: parentRun.turnId, + }; + const child = await manager.spawnChildSession(parent.id, { spawnedBy: { parentRunId: parentRun.runId, parentTurnId: parentRun.turnId, - toolCallId: 'snapshot-tool-call', + toolCallId: 'tool-call-1', }, agentProfile: LOCAL_READ_AGENT_PROFILE, - prompt: 'first child turn', + prompt: 'inspect', }); - const durablePrompt = (await store.readHeader(child.childSessionId)).subagentRuntime - ?.systemPrompt; - if (!durablePrompt) throw new Error('child runtime snapshot was not persisted'); - const originalPrompt = LOCAL_READ_AGENT_DEFINITION.systemPrompt; - let parentTurnDrained = false; - const drainParentTurn = async (): Promise => { - parentGate.release(); - while (!(await parentTurn.next()).done) {} - parentTurnDrained = true; - }; - try { - LOCAL_READ_AGENT_DEFINITION.systemPrompt = 'Changed catalog prompt that must not leak.'; - let refreshSettled = false; - const refresh = manager.refreshIdleBackends().finally(() => { - refreshSettled = true; - }); - await Promise.resolve(); - expect(refreshSettled).toBe(false); - expect(contexts.filter((ctx) => ctx.sessionId === parent.id).length).toBe(1); - await drainParentTurn(); - await refresh; - await drain( - manager.sendMessage(child.childSessionId, { - turnId: 'child-follow-up', - text: 'use the durable profile', - }), - ); - } finally { - if (!parentTurnDrained) await drainParentTurn(); - LOCAL_READ_AGENT_DEFINITION.systemPrompt = originalPrompt; - } - - const childContexts = contexts.filter((ctx) => ctx.sessionId === child.childSessionId); - expect(childContexts.length).toBe(2); - expect(childContexts[1]?.systemPrompt).toBe(durablePrompt); - expect(childContexts[1]?.tools?.map((tool) => tool.name)).toEqual(['Read', 'Glob', 'Grep']); - - const missingToolManager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - childTools: [testTool('Read'), testTool('Glob')], - newId: nextId(), - now: nextNow(185), - }); - await expectRejects( - drain( - missingToolManager.sendMessage(child.childSessionId, { - turnId: 'missing-tool-follow-up', - text: 'must fail closed', - }), - ), - /runtime tool snapshot is unavailable/, - ); + expect(child.status).toBe('completed'); + expect(child.childSessionId === parent.id).toBe(false); }); - test('reopens after restart with isolated history, tool activity, usage, and compaction', async () => { + test('child stop is isolated while parent stop reaches every foreground child session', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); const backends = new BackendRegistry(); const parentGate = makeGate(); - const compactCalls: Array<{ turnId: string; runtimeContextCount: number }> = []; - const childBackends: LifecycleChildBackend[] = []; + const childGates = [makeGate(), makeGate()]; + let childGateIndex = 0; + const backendsBySession = new Map(); backends.register('ai-sdk', (ctx) => { - if (!ctx.header.subagentRuntime) return new TestBackend(ctx, parentGate); - const backend = new LifecycleChildBackend(ctx, compactCalls); - childBackends.push(backend); + const gate = ctx.header.subagentRuntime ? childGates[childGateIndex++] : parentGate; + const backend = new TestBackend(ctx, gate); + backendsBySession.set(ctx.sessionId, backend); return backend; }); const manager = new SessionManager({ @@ -3350,483 +3104,395 @@ describe('SessionManager child-session runtime primitive', () => { backends, childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], newId: nextId(), - now: nextNow(186), + now: nextNow(300), }); const parent = await manager.createSession(makeInput()); const parentTurn = manager - .sendMessage(parent.id, { turnId: 'parent-turn', text: 'private parent context' }) + .sendMessage(parent.id, { turnId: 'parent-turn', text: 'coordinate children' }) [Symbol.asyncIterator](); await parentTurn.next(); const [parentRun] = await runStore.listSessionRuns(parent.id); if (!parentRun) throw new Error('parent run was not recorded'); - const child = await manager.spawnChildSession(parent.id, { + const childOneStarted = makeGate(); + let childOneId = ''; + const childOne = manager.spawnChildSession(parent.id, { + name: 'Child one', spawnedBy: { parentRunId: parentRun.runId, parentTurnId: parentRun.turnId, - toolCallId: 'restart-observation-tool', + toolCallId: 'tool-call-1', }, agentProfile: LOCAL_READ_AGENT_PROFILE, - prompt: 'inspect README before restart', + prompt: 'first child', + onReady: ({ childSessionId }) => { + childOneId = childSessionId; + }, + onEvent: (event) => { + if (event.type === 'text_delta') childOneStarted.release(); + }, }); + await childOneStarted.promise; - // A new manager represents a restarted Runtime Host. It must activate the - // child through the durable Session header and replay only child history. - const restarted = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], - newId: nextId(), - now: nextNow(196), + const childTwoStarted = makeGate(); + let childTwoId = ''; + const childTwo = manager.spawnChildSession(parent.id, { + name: 'Child two', + spawnedBy: { + parentRunId: parentRun.runId, + parentTurnId: parentRun.turnId, + toolCallId: 'tool-call-2', + }, + agentProfile: LOCAL_READ_AGENT_PROFILE, + prompt: 'second child', + onReady: ({ childSessionId }) => { + childTwoId = childSessionId; + }, + onEvent: (event) => { + if (event.type === 'text_delta') childTwoStarted.release(); + }, }); - await drain( - restarted.sendMessage(child.childSessionId, { - turnId: 'child-follow-up', - text: 'inspect it again after restart', - }), - ); - const restartedBackend = childBackends.at(-1); - const followUpContext = restartedBackend?.sendInputs.at(-1)?.runtimeContext ?? []; - expect(followUpContext.some((event) => event.runId === child.runId)).toBe(true); - expect( - followUpContext.some( - (event) => - event.content?.kind === 'text' && event.content.text.includes('private parent context'), - ), - ).toBe(false); + await childTwoStarted.promise; - await drain(restarted.compactSession(child.childSessionId, { turnId: 'child-compact' })); - expect(compactCalls).toHaveLength(1); - expect(compactCalls[0]?.turnId).toBe('child-compact'); + await manager.stopSession(childOneId, { source: 'stop_button' }); + expect(backendsBySession.get(childOneId)?.stopCalls).toBe(1); + expect(backendsBySession.get(childTwoId)?.stopCalls).toBe(0); + expect(backendsBySession.get(parent.id)?.stopCalls).toBe(0); + expect((await runStore.readRun(parent.id, parentRun.runId)).status).toBe('running'); - const childMessages = await restarted.getMessages(child.childSessionId); - expect(childMessages.some((message) => message.type === 'tool_call')).toBe(true); - expect(childMessages.some((message) => message.type === 'tool_result')).toBe(true); - expect(childMessages.some((message) => message.type === 'token_usage')).toBe(true); - expect( - childMessages.some( - (message) => - message.type === 'turn_state' && - message.turnId === 'child-compact' && - message.status === 'completed', - ), - ).toBe(true); - const parentMessages = await restarted.getMessages(parent.id); - expect( - parentMessages.some( - (message) => - message.turnId === child.turnId || - message.turnId === 'child-follow-up' || - message.turnId === 'child-compact', - ), - ).toBe(false); + await manager.stopSession(parent.id, { source: 'stop_button' }); + expect(backendsBySession.get(parent.id)?.stopCalls).toBe(1); + expect(backendsBySession.get(childOneId)?.stopCalls).toBe(1); + expect(backendsBySession.get(childTwoId)?.stopCalls).toBe(1); parentGate.release(); + for (const gate of childGates) gate.release(); while (!(await parentTurn.next()).done) {} + const [childOneResult, childTwoResult] = await Promise.all([childOne, childTwo]); + expect(childOneResult.status).toBe('cancelled'); + expect(childTwoResult.status).toBe('cancelled'); }); - test('recovers an idempotent retry whose persisted initial run is no longer active', async () => { + test('startup recovery repairs an interrupted child inline run only in the child session', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); const backends = new BackendRegistry(); - const parentGate = makeGate(); - backends.register('ai-sdk', (ctx) => new TestBackend(ctx, parentGate)); + backends.register('ai-sdk', (ctx) => new TestBackend(ctx)); const manager = new SessionManager({ store, runStore, runtimeEventStore: runStore, backends, - childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], newId: nextId(), - now: nextNow(190), + now: nextNow(400), }); const parent = await manager.createSession(makeInput()); - const parentTurn = manager - .sendMessage(parent.id, { turnId: 'parent-turn', text: 'keep parent active' }) - [Symbol.asyncIterator](); - await parentTurn.next(); - const [parentRun] = await runStore.listSessionRuns(parent.id); - if (!parentRun) throw new Error('parent run was not recorded'); - const toolCallId = 'recovery-tool-call'; - const prompt = 'recover this exact request'; - const requestFingerprint = createHash('sha256') - .update( - JSON.stringify([ - 1, - parent.id, - parentRun.runId, - parentRun.turnId, - toolCallId, - LOCAL_READ_AGENT_PROFILE, - prompt, - null, - null, - ]), - ) - .digest('hex'); - const { header: child } = await store.createSubagent( + await store.appendMessage(parent.id, { + type: 'system_note', + id: 'parent-marker', + ts: 1, + kind: 'session_start', + data: { marker: 'parent stays untouched' }, + }); + const child = await manager.createSession( makeInput({ - name: 'Stale child', + name: 'Interrupted child', + status: 'running', permissionMode: 'explore', - collaborationMode: 'agent', - orchestrationMode: 'default', subagentParent: { kind: 'subagent', parentSessionId: parent.id, spawnedBy: { - parentRunId: parentRun.runId, - parentTurnId: parentRun.turnId, - toolCallId, + parentRunId: 'parent-run', + parentTurnId: 'parent-turn', + toolCallId: 'tool-call', }, lifecycle: 'foreground', }, subagentRuntime: { schemaVersion: 1, - definitionVersion: LOCAL_READ_AGENT_DEFINITION.definitionVersion, + definitionVersion: 1, agentId: LOCAL_READ_AGENT_ID, - agentName: LOCAL_READ_AGENT_DEFINITION.name, + agentName: 'Local Read', profile: LOCAL_READ_AGENT_PROFILE, systemPrompt: LOCAL_READ_AGENT_DEFINITION.systemPrompt, - toolNames: [...LOCAL_READ_AGENT_DEFINITION.tools], - categoryPolicy: {}, + toolNames: ['Read', 'Glob', 'Grep'], + categoryPolicy: { read: 'allow' }, }, subagentSpawn: { schemaVersion: 1, - requestFingerprint, - initialTurnId: 'stale-child-turn', - initialRunId: 'stale-child-run', + requestFingerprint: 'a'.repeat(64), + initialTurnId: 'child-turn', + initialRunId: 'child-run', }, }), ); - await seedRunningTurn(store, child.id, 'stale-child-turn'); + await seedRunningTurn(store, child.id, 'child-turn'); await seedRun( runStore, makeRunHeader({ sessionId: child.id, - runId: 'stale-child-run', - turnId: 'stale-child-turn', + runId: 'child-run', + turnId: 'child-turn', status: 'running', permissionMode: 'explore', agentId: LOCAL_READ_AGENT_ID, - agentName: LOCAL_READ_AGENT_DEFINITION.name, + agentName: 'Local Read', }), [ makeRunEvent({ sessionId: child.id, - runId: 'stale-child-run', - turnId: 'stale-child-turn', + runId: 'child-run', + turnId: 'child-turn', type: 'run_started', - ts: 191, + ts: 11, + }), + makeRunEvent({ + sessionId: child.id, + runId: 'child-run', + turnId: 'child-turn', + type: 'model_stream_started', + ts: 12, }), ], ); + const parentMessagesBefore = await store.readMessages(parent.id); - const recovered = await manager.spawnChildSession(parent.id, { - spawnedBy: { - parentRunId: parentRun.runId, - parentTurnId: parentRun.turnId, - toolCallId, - }, - agentProfile: LOCAL_READ_AGENT_PROFILE, - prompt, - }); - expect(recovered.childSessionId).toBe(child.id); - expect(recovered.runId).toBe('stale-child-run'); - expect(recovered.status).toBe('failed'); - expect(recovered.failureClass).toBe('app_restarted'); - expect((await manager.listChildSessions(parent.id)).length).toBe(1); + const recovered = await manager.recoverInterruptedSessions(); - parentGate.release(); - while (!(await parentTurn.next()).done) {} + expect(recovered).toEqual([child.id]); + const recoveredRun = await runStore.readRun(child.id, 'child-run'); + expect(recoveredRun.parentRunId).toBe(undefined); + expect(isSessionInlineRun(recoveredRun)).toBe(true); + expect(recoveredRun.status).toBe('failed'); + expect(recoveredRun.failureClass).toBe('app_restarted'); + expect( + (await store.readMessages(child.id)).some( + (message) => + message.type === 'turn_state' && + message.turnId === 'child-turn' && + message.status === 'failed', + ), + ).toBe(true); + expect(await store.readMessages(parent.id)).toEqual(parentMessagesBefore); }); +}); - test('requires the exact parent run to remain active before admitting child work', async () => { +describe('SessionManager manual compaction and quiescent session changes', () => { + test('runs backend history compaction as a runtime turn and persists diagnostics', async () => { const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); + const runStore = new OrderingAgentRunStore(); const backends = new BackendRegistry(); - backends.register('ai-sdk', (ctx) => new TestBackend(ctx)); + const compactCalls: Array<{ turnId: string; runtimeContextCount: number }> = []; + backends.register('ai-sdk', (ctx) => new CompactingTestBackend(ctx, compactCalls)); const manager = new SessionManager({ store, runStore, runtimeEventStore: runStore, backends, - childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], newId: nextId(), - now: nextNow(200), + now: nextNow(10_000), }); - const parent = await manager.createSession(makeInput()); - await drain( - manager.sendMessage(parent.id, { turnId: 'parent-turn', text: 'already complete' }), + const session = await manager.createSession(makeInput({ permissionMode: 'bypass' })); + + await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'hello' })); + runStore.operations = []; + const events = await collectSessionEvents( + manager.compactSession(session.id, { turnId: 'turn-compact' }), ); - const [parentRun] = await runStore.listSessionRuns(parent.id); - if (!parentRun) throw new Error('parent run was not recorded'); - await expectRejects( - manager.spawnChildSession(parent.id, { - spawnedBy: { - parentRunId: parentRun.runId, - parentTurnId: parentRun.turnId, - toolCallId: 'tool-call-1', - }, - agentProfile: LOCAL_READ_AGENT_PROFILE, - prompt: 'must not start', - }), - /parent run is not active/, + expect(compactCalls).toEqual([{ turnId: 'turn-compact', runtimeContextCount: 3 }]); + expect(events.map((event) => event.type)).toEqual(['token_usage', 'complete']); + const usage = events[0]; + if (usage?.type !== 'token_usage') throw new Error('expected token_usage'); + expect(usage.contextBudget?.compactionDecisions?.[0]?.decision).toBe('replaced'); + const complete = events[1]; + if (complete?.type !== 'complete') throw new Error('expected complete'); + expect(complete.contextCompactionOutcome?.kind).toBe('compacted'); + + const messages = await store.readMessages(session.id); + expect( + messages.some((message) => message.type === 'user' && message.text.includes('compact')), + ).toBe(false); + expect( + messages.some( + (message) => message.type === 'token_usage' && message.turnId === 'turn-compact', + ), + ).toBe(true); + expect( + messages.some( + (message) => + message.type === 'turn_state' && + message.turnId === 'turn-compact' && + message.status === 'completed', + ), + ).toBe(true); + + const compactRun = (await runStore.listSessionRuns(session.id)).find( + (run) => run.turnId === 'turn-compact', ); - expect(await manager.listChildSessions(parent.id)).toEqual([]); + expect(compactRun?.status).toBe('completed'); + expect(runStore.operations).toEqual(['terminalRuntimeEvent', 'completedRunHeader']); + expect( + (await runStore.readRuntimeEvents(session.id, compactRun!.runId)).some( + (event) => event.actions?.stateDelta?.contextCompactionOutcome, + ), + ).toBe(true); + await manager.stopSession(session.id, { source: 'stop_button' }); }); - test('admits child work through an external parent-run authority', async () => { + test('manual compaction settles one canonical record for the run the kernel opened', async () => { + // `sessions:compact` and CLI `/compact` both land here. The call has no + // send to inherit a run from, so the kernel states the run it opened and + // the record must carry it — otherwise a real, billed summarization is + // silently unmetered (#1679). const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); const backends = new BackendRegistry(); - backends.register('ai-sdk', (ctx) => new TestBackend(ctx)); - let externalParent: - | { - sessionId: string; - runId: string; - turnId: string; - } - | undefined; + const modelCalls: ModelCallAttempt[] = []; + const summarizerModel = new MockLanguageModelV4({ + doGenerate: { + content: [ + { + type: 'text', + // Structured so it passes the summarizer's checkpoint validation + // (#3029) while keeping the sentinel greppable. + text: '## Goal\nMANUAL_COMPACT_SUMMARY\n\n## Progress\n- done\n\n## Next Steps\n1. continue\n\n## Critical Context\n- (none)', + }, + ], + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 41, noCache: 41, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 9, text: 9, reasoning: 0 }, + raw: { input_tokens: 41, output_tokens: 9 }, + }, + warnings: [], + }, + }); + backends.register('ai-sdk', (ctx) => + createTestAiSdkBackend({ + sessionId: ctx.sessionId, + header: ctx.header, + appendMessage: async () => {}, + connection: { + slug: 'mock-main', + providerType: 'anthropic', + defaultModel: 'mock-model-id', + }, + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => + new MockLanguageModelV4({ + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'ok '.repeat(80) }, + { type: 'text-end', id: 'text-1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 100, noCache: 100, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 10, text: 10, reasoning: 0 }, + }, + }, + ] as LanguageModelV4StreamPart[], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }), + }), + tools: [], + newId: nextId(), + now: nextNow(1), + contextBudget: { + name: 'manual-compact-accounting', + maxHistoryEstimatedTokens: 10_000, + charsPerToken: 1, + }, + summarizeHistoryCompact: buildLlmHistorySummarizer({ + resolveModel: () => summarizerModel, + }), + recordHistoryCompactCheckpoint: () => {}, + recordModelCallAttempt: ({ attempt }) => { + modelCalls.push(attempt); + }, + }), + ); const manager = new SessionManager({ store, runStore, runtimeEventStore: runStore, backends, - childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], - isParentRunActive: (sessionId, runId, turnId) => - externalParent !== undefined && - externalParent.sessionId === sessionId && - externalParent.runId === runId && - externalParent.turnId === turnId, newId: nextId(), - now: nextNow(250), + now: nextNow(13_000), }); - const parent = await manager.createSession(makeInput()); - await drain(manager.sendMessage(parent.id, { turnId: 'parent-turn', text: 'parent' })); - const [parentRun] = await runStore.listSessionRuns(parent.id); - if (!parentRun) throw new Error('parent run was not recorded'); - externalParent = { - sessionId: parent.id, - runId: parentRun.runId, - turnId: parentRun.turnId, - }; + const session = await manager.createSession(makeInput({ permissionMode: 'bypass' })); - const child = await manager.spawnChildSession(parent.id, { - spawnedBy: { - parentRunId: parentRun.runId, - parentTurnId: parentRun.turnId, - toolCallId: 'tool-call-1', - }, - agentProfile: LOCAL_READ_AGENT_PROFILE, - prompt: 'inspect', - }); + await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'first '.repeat(400) })); + await drain(manager.sendMessage(session.id, { turnId: 'turn-2', text: 'second' })); + await drain(manager.compactSession(session.id, { turnId: 'turn-compact' })); - expect(child.status).toBe('completed'); - expect(child.childSessionId === parent.id).toBe(false); + const compactRun = (await runStore.listSessionRuns(session.id)).find( + (run) => run.turnId === 'turn-compact', + ); + assert.ok(compactRun, 'the kernel opens a run for a manual compaction'); + const compactions = modelCalls + .map((attempt) => decodeModelCallAttempt(attempt)) + .filter((attempt) => attempt.callKind === 'history_compact'); + assert.equal(compactions.length, 1, 'one manual compaction is one record'); + assert.equal( + compactions[0]?.runId, + compactRun.runId, + 'attributed to the run the kernel opened, not to whatever ran last', + ); + assert.equal(compactions[0]?.turnId, 'turn-compact'); + assert.equal(compactions[0]?.inputTokens, 41); + assert.equal(compactions[0]?.usageBasis, 'reported'); + await manager.stopSession(session.id, { source: 'stop_button' }); }); - test('child stop is isolated while parent stop reaches every foreground child session', async () => { + test('persists one visible warning when manual compaction fails open', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); const backends = new BackendRegistry(); - const parentGate = makeGate(); - const childGates = [makeGate(), makeGate()]; - let childGateIndex = 0; - const backendsBySession = new Map(); - backends.register('ai-sdk', (ctx) => { - const gate = ctx.header.subagentRuntime ? childGates[childGateIndex++] : parentGate; - const backend = new TestBackend(ctx, gate); - backendsBySession.set(ctx.sessionId, backend); - return backend; - }); + backends.register('ai-sdk', (ctx) => new FailOpenCompactingBackend(ctx)); const manager = new SessionManager({ store, runStore, runtimeEventStore: runStore, backends, - childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], newId: nextId(), - now: nextNow(300), + now: nextNow(12_000), }); - const parent = await manager.createSession(makeInput()); - const parentTurn = manager - .sendMessage(parent.id, { turnId: 'parent-turn', text: 'coordinate children' }) - [Symbol.asyncIterator](); - await parentTurn.next(); - const [parentRun] = await runStore.listSessionRuns(parent.id); - if (!parentRun) throw new Error('parent run was not recorded'); - - const childOneStarted = makeGate(); - let childOneId = ''; - const childOne = manager.spawnChildSession(parent.id, { - name: 'Child one', - spawnedBy: { - parentRunId: parentRun.runId, - parentTurnId: parentRun.turnId, - toolCallId: 'tool-call-1', - }, - agentProfile: LOCAL_READ_AGENT_PROFILE, - prompt: 'first child', - onReady: ({ childSessionId }) => { - childOneId = childSessionId; - }, - onEvent: (event) => { - if (event.type === 'text_delta') childOneStarted.release(); - }, - }); - await childOneStarted.promise; - - const childTwoStarted = makeGate(); - let childTwoId = ''; - const childTwo = manager.spawnChildSession(parent.id, { - name: 'Child two', - spawnedBy: { - parentRunId: parentRun.runId, - parentTurnId: parentRun.turnId, - toolCallId: 'tool-call-2', - }, - agentProfile: LOCAL_READ_AGENT_PROFILE, - prompt: 'second child', - onReady: ({ childSessionId }) => { - childTwoId = childSessionId; - }, - onEvent: (event) => { - if (event.type === 'text_delta') childTwoStarted.release(); - }, - }); - await childTwoStarted.promise; - - await manager.stopSession(childOneId, { source: 'stop_button' }); - expect(backendsBySession.get(childOneId)?.stopCalls).toBe(1); - expect(backendsBySession.get(childTwoId)?.stopCalls).toBe(0); - expect(backendsBySession.get(parent.id)?.stopCalls).toBe(0); - expect((await runStore.readRun(parent.id, parentRun.runId)).status).toBe('running'); - - await manager.stopSession(parent.id, { source: 'stop_button' }); - expect(backendsBySession.get(parent.id)?.stopCalls).toBe(1); - expect(backendsBySession.get(childOneId)?.stopCalls).toBe(1); - expect(backendsBySession.get(childTwoId)?.stopCalls).toBe(1); + const session = await manager.createSession(makeInput({ permissionMode: 'bypass' })); - parentGate.release(); - for (const gate of childGates) gate.release(); - while (!(await parentTurn.next()).done) {} - const [childOneResult, childTwoResult] = await Promise.all([childOne, childTwo]); - expect(childOneResult.status).toBe('cancelled'); - expect(childTwoResult.status).toBe('cancelled'); - }); + await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'hello' })); + await drain(manager.compactSession(session.id, { turnId: 'turn-compact' })); - test('startup recovery repairs an interrupted child inline run only in the child session', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - backends.register('ai-sdk', (ctx) => new TestBackend(ctx)); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - newId: nextId(), - now: nextNow(400), - }); - const parent = await manager.createSession(makeInput()); - await store.appendMessage(parent.id, { - type: 'system_note', - id: 'parent-marker', - ts: 1, - kind: 'session_start', - data: { marker: 'parent stays untouched' }, - }); - const child = await manager.createSession( - makeInput({ - name: 'Interrupted child', - status: 'running', - permissionMode: 'explore', - subagentParent: { - kind: 'subagent', - parentSessionId: parent.id, - spawnedBy: { - parentRunId: 'parent-run', - parentTurnId: 'parent-turn', - toolCallId: 'tool-call', - }, - lifecycle: 'foreground', - }, - subagentRuntime: { - schemaVersion: 1, - definitionVersion: 1, - agentId: LOCAL_READ_AGENT_ID, - agentName: 'Local Read', - profile: LOCAL_READ_AGENT_PROFILE, - systemPrompt: LOCAL_READ_AGENT_DEFINITION.systemPrompt, - toolNames: ['Read', 'Glob', 'Grep'], - categoryPolicy: { read: 'allow' }, - }, - subagentSpawn: { - schemaVersion: 1, - requestFingerprint: 'a'.repeat(64), - initialTurnId: 'child-turn', - initialRunId: 'child-run', - }, - }), - ); - await seedRunningTurn(store, child.id, 'child-turn'); - await seedRun( - runStore, - makeRunHeader({ - sessionId: child.id, - runId: 'child-run', - turnId: 'child-turn', - status: 'running', - permissionMode: 'explore', - agentId: LOCAL_READ_AGENT_ID, - agentName: 'Local Read', - }), - [ - makeRunEvent({ - sessionId: child.id, - runId: 'child-run', - turnId: 'child-turn', - type: 'run_started', - ts: 11, - }), - makeRunEvent({ - sessionId: child.id, - runId: 'child-run', - turnId: 'child-turn', - type: 'model_stream_started', - ts: 12, - }), - ], + const warnings = (await store.readMessages(session.id)).filter( + (message) => + message.type === 'system_note' && + message.turnId === 'turn-compact' && + message.kind === 'context_compaction_failed_open', ); - const parentMessagesBefore = await store.readMessages(parent.id); - - const recovered = await manager.recoverInterruptedSessions(); - - expect(recovered).toEqual([child.id]); - const recoveredRun = await runStore.readRun(child.id, 'child-run'); - expect(recoveredRun.parentRunId).toBe(undefined); - expect(isSessionInlineRun(recoveredRun)).toBe(true); - expect(recoveredRun.status).toBe('failed'); - expect(recoveredRun.failureClass).toBe('app_restarted'); - expect( - (await store.readMessages(child.id)).some( - (message) => - message.type === 'turn_state' && - message.turnId === 'child-turn' && - message.status === 'failed', - ), - ).toBe(true); - expect(await store.readMessages(parent.id)).toEqual(parentMessagesBefore); + expect(warnings).toHaveLength(1); }); -}); -describe('SessionManager manual compaction and quiescent session changes', () => { - test('runs backend history compaction as a runtime turn and persists diagnostics', async () => { + test('manual compaction stopped before backend start does not write compact artifacts', async () => { const store = new MemorySessionStore(); - const runStore = new OrderingAgentRunStore(); + const readGate = makeGate(); + const readStarted = makeGate(); + let blockPriorRead = false; + const runStore = new MemoryAgentRunStore({ + beforeRuntimeEventRead: async () => { + if (!blockPriorRead) return; + readStarted.release(); + await readGate.promise; + }, + }); const backends = new BackendRegistry(); const compactCalls: Array<{ turnId: string; runtimeContextCount: number }> = []; backends.register('ai-sdk', (ctx) => new CompactingTestBackend(ctx, compactCalls)); @@ -3836,249 +3502,38 @@ describe('SessionManager manual compaction and quiescent session changes', () => runtimeEventStore: runStore, backends, newId: nextId(), - now: nextNow(10_000), + now: nextNow(15_000), }); const session = await manager.createSession(makeInput({ permissionMode: 'bypass' })); - await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'hello' })); - runStore.operations = []; - const events = await collectSessionEvents( + + blockPriorRead = true; + const compactPromise = collectSessionEvents( manager.compactSession(session.id, { turnId: 'turn-compact' }), ); + await readStarted.promise; + let stopSettled = false; + const stop = manager.stopSession(session.id, { source: 'stop_button' }).finally(() => { + stopSettled = true; + }); + await Promise.resolve(); + await Promise.resolve(); + expect(stopSettled).toBe(false); + expect(compactCalls).toEqual([]); - expect(compactCalls).toEqual([{ turnId: 'turn-compact', runtimeContextCount: 3 }]); - expect(events.map((event) => event.type)).toEqual(['token_usage', 'complete']); - const usage = events[0]; - if (usage?.type !== 'token_usage') throw new Error('expected token_usage'); - expect(usage.contextBudget?.compactionDecisions?.[0]?.decision).toBe('replaced'); - const complete = events[1]; - if (complete?.type !== 'complete') throw new Error('expected complete'); - expect(complete.contextCompactionOutcome?.kind).toBe('compacted'); - - const messages = await store.readMessages(session.id); - expect( - messages.some((message) => message.type === 'user' && message.text.includes('compact')), - ).toBe(false); - expect( - messages.some( - (message) => message.type === 'token_usage' && message.turnId === 'turn-compact', - ), - ).toBe(true); - expect( - messages.some( - (message) => - message.type === 'turn_state' && - message.turnId === 'turn-compact' && - message.status === 'completed', - ), - ).toBe(true); + readGate.release(); + await stop; + const compactEvents = await compactPromise.catch(() => []); + expect(compactCalls).toEqual([]); + expect(compactEvents.some((event) => event.type === 'token_usage')).toBe(false); const compactRun = (await runStore.listSessionRuns(session.id)).find( (run) => run.turnId === 'turn-compact', ); - expect(compactRun?.status).toBe('completed'); - expect(runStore.operations).toEqual(['terminalRuntimeEvent', 'completedRunHeader']); - expect( - (await runStore.readRuntimeEvents(session.id, compactRun!.runId)).some( - (event) => event.actions?.stateDelta?.contextCompactionOutcome, - ), - ).toBe(true); - await manager.stopSession(session.id, { source: 'stop_button' }); + expect(compactRun?.status).toBe('cancelled'); }); - test('manual compaction settles one canonical record for the run the kernel opened', async () => { - // `sessions:compact` and CLI `/compact` both land here. The call has no - // send to inherit a run from, so the kernel states the run it opened and - // the record must carry it — otherwise a real, billed summarization is - // silently unmetered (#1679). - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - const modelCalls: ModelCallAttempt[] = []; - const summarizerModel = new MockLanguageModelV4({ - doGenerate: { - content: [ - { - type: 'text', - // Structured so it passes the summarizer's checkpoint validation - // (#3029) while keeping the sentinel greppable. - text: '## Goal\nMANUAL_COMPACT_SUMMARY\n\n## Progress\n- done\n\n## Next Steps\n1. continue\n\n## Critical Context\n- (none)', - }, - ], - finishReason: { unified: 'stop', raw: 'stop' }, - usage: { - inputTokens: { total: 41, noCache: 41, cacheRead: 0, cacheWrite: 0 }, - outputTokens: { total: 9, text: 9, reasoning: 0 }, - raw: { input_tokens: 41, output_tokens: 9 }, - }, - warnings: [], - }, - }); - backends.register('ai-sdk', (ctx) => - createTestAiSdkBackend({ - sessionId: ctx.sessionId, - header: ctx.header, - appendMessage: async () => {}, - connection: { - slug: 'mock-main', - providerType: 'anthropic', - defaultModel: 'mock-model-id', - }, - apiKey: 'sk-test', - modelId: 'mock-model-id', - modelFactory: () => - new MockLanguageModelV4({ - doStream: async () => ({ - stream: simulateReadableStream({ - chunks: [ - { type: 'stream-start', warnings: [] }, - { type: 'text-start', id: 'text-1' }, - { type: 'text-delta', id: 'text-1', delta: 'ok '.repeat(80) }, - { type: 'text-end', id: 'text-1' }, - { - type: 'finish', - finishReason: { unified: 'stop', raw: 'stop' }, - usage: { - inputTokens: { total: 100, noCache: 100, cacheRead: 0, cacheWrite: 0 }, - outputTokens: { total: 10, text: 10, reasoning: 0 }, - }, - }, - ] as LanguageModelV4StreamPart[], - initialDelayInMs: null, - chunkDelayInMs: null, - }), - }), - }), - tools: [], - newId: nextId(), - now: nextNow(1), - contextBudget: { - name: 'manual-compact-accounting', - maxHistoryEstimatedTokens: 10_000, - charsPerToken: 1, - }, - summarizeHistoryCompact: buildLlmHistorySummarizer({ - resolveModel: () => summarizerModel, - }), - recordHistoryCompactCheckpoint: () => {}, - recordModelCallAttempt: ({ attempt }) => { - modelCalls.push(attempt); - }, - }), - ); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - newId: nextId(), - now: nextNow(13_000), - }); - const session = await manager.createSession(makeInput({ permissionMode: 'bypass' })); - - await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'first '.repeat(400) })); - await drain(manager.sendMessage(session.id, { turnId: 'turn-2', text: 'second' })); - await drain(manager.compactSession(session.id, { turnId: 'turn-compact' })); - - const compactRun = (await runStore.listSessionRuns(session.id)).find( - (run) => run.turnId === 'turn-compact', - ); - assert.ok(compactRun, 'the kernel opens a run for a manual compaction'); - const compactions = modelCalls - .map((attempt) => decodeModelCallAttempt(attempt)) - .filter((attempt) => attempt.callKind === 'history_compact'); - assert.equal(compactions.length, 1, 'one manual compaction is one record'); - assert.equal( - compactions[0]?.runId, - compactRun.runId, - 'attributed to the run the kernel opened, not to whatever ran last', - ); - assert.equal(compactions[0]?.turnId, 'turn-compact'); - assert.equal(compactions[0]?.inputTokens, 41); - assert.equal(compactions[0]?.usageBasis, 'reported'); - await manager.stopSession(session.id, { source: 'stop_button' }); - }); - - test('persists one visible warning when manual compaction fails open', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - backends.register('ai-sdk', (ctx) => new FailOpenCompactingBackend(ctx)); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - newId: nextId(), - now: nextNow(12_000), - }); - const session = await manager.createSession(makeInput({ permissionMode: 'bypass' })); - - await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'hello' })); - await drain(manager.compactSession(session.id, { turnId: 'turn-compact' })); - - const warnings = (await store.readMessages(session.id)).filter( - (message) => - message.type === 'system_note' && - message.turnId === 'turn-compact' && - message.kind === 'context_compaction_failed_open', - ); - expect(warnings).toHaveLength(1); - }); - - test('manual compaction stopped before backend start does not write compact artifacts', async () => { - const store = new MemorySessionStore(); - const readGate = makeGate(); - const readStarted = makeGate(); - let blockPriorRead = false; - const runStore = new MemoryAgentRunStore({ - beforeRuntimeEventRead: async () => { - if (!blockPriorRead) return; - readStarted.release(); - await readGate.promise; - }, - }); - const backends = new BackendRegistry(); - const compactCalls: Array<{ turnId: string; runtimeContextCount: number }> = []; - backends.register('ai-sdk', (ctx) => new CompactingTestBackend(ctx, compactCalls)); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - newId: nextId(), - now: nextNow(15_000), - }); - const session = await manager.createSession(makeInput({ permissionMode: 'bypass' })); - await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'hello' })); - - blockPriorRead = true; - const compactPromise = collectSessionEvents( - manager.compactSession(session.id, { turnId: 'turn-compact' }), - ); - await readStarted.promise; - let stopSettled = false; - const stop = manager.stopSession(session.id, { source: 'stop_button' }).finally(() => { - stopSettled = true; - }); - await Promise.resolve(); - await Promise.resolve(); - expect(stopSettled).toBe(false); - expect(compactCalls).toEqual([]); - - readGate.release(); - await stop; - const compactEvents = await compactPromise.catch(() => []); - - expect(compactCalls).toEqual([]); - expect(compactEvents.some((event) => event.type === 'token_usage')).toBe(false); - const compactRun = (await runStore.listSessionRuns(session.id)).find( - (run) => run.turnId === 'turn-compact', - ); - expect(compactRun?.status).toBe('cancelled'); - }); - - test('cold manual compaction normalizes only its execution cancellation reason', async () => { + test('cold manual compaction normalizes only its execution cancellation reason', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); const backends = new BackendRegistry(); @@ -4651,71 +4106,6 @@ describe('SessionManager manual compaction and quiescent session changes', () => expect(disposeCalls).toBe(1); }); - test('backend refresh reports a retained child generation disposal failure', async () => { - const store = new MemorySessionStore(); - let builds = 0; - let disposeCalls = 0; - const backends = new BackendRegistry(); - backends.register( - 'ai-sdk', - (ctx) => - new (class extends TestBackend { - constructor() { - super(ctx); - builds += 1; - } - - override async dispose(): Promise { - disposeCalls += 1; - throw new Error('injected child generation disposal failure'); - } - })(), - ); - const newId = nextId(); - const now = nextNow(26_800); - const childTools = [testTool('Read'), testTool('Glob'), testTool('Grep')]; - const runtimeKernel = new RuntimeKernel({ - store, - backends, - childTools, - newId, - now, - }); - const manager = new SessionManager({ - store, - backends, - runtimeKernel, - childTools, - newId, - now, - }); - const session = await manager.createSession(makeInput()); - - await drain( - manager.startChildTurn(session.id, { - turnId: 'child-disposal-failure', - parentRunId: 'parent-run', - spec: { - id: LOCAL_READ_AGENT_ID, - name: 'Researcher', - systemPrompt: 'read only', - }, - prompt: 'inspect', - }), - ); - - await expectRejects( - runtimeKernel.disposeBackend(session.id), - /injected child generation disposal failure/, - ); - await expectRejects( - manager.refreshIdleBackends(), - /injected child generation disposal failure/, - ); - expect(builds).toBe(1); - expect(disposeCalls).toBe(1); - }); - test('backend activation waits for disposal and a later mutation invalidates its replacement', async () => { const store = new MemorySessionStore(); const firstDisposeStarted = makeGate(); @@ -4792,100 +4182,16 @@ describe('SessionManager manual compaction and quiescent session changes', () => expect(builds).toBe(3); }); - test('child backend activation waits for disposal and is covered by the later mutation', async () => { + test('backend refresh invalidates only cached sessions without listing persisted history', async () => { const store = new MemorySessionStore(); - const firstDisposeStarted = makeGate(); - const releaseFirstDispose = makeGate(); - const childPreBuildReached = makeGate(); - const releaseChildPreBuild = makeGate(); - let childCheckpointArmed = true; - const runStore = new MemoryAgentRunStore({ - beforeRuntimeEventAppend: async (_sessionId, _runId, event) => { - if (!childCheckpointArmed || event.turnId !== 'child-turn') return; - childCheckpointArmed = false; - childPreBuildReached.release(); - await releaseChildPreBuild.promise; - }, - }); - const { runBackendActivation, runMutation } = makeTestRuntimePolicyGate(); - let builds = 0; - const disposed: number[] = []; + const disposed: string[] = []; const backends = new BackendRegistry(); - backends.register('ai-sdk', (ctx) => { - const generation = ++builds; - return new (class extends TestBackend { - override async dispose(): Promise { - disposed.push(generation); - if (generation === 1) { - firstDisposeStarted.release(); - await releaseFirstDispose.promise; - } - } - })(ctx); - }); - const newId = nextId(); - const now = nextNow(26_900); - const runtimeKernel = new RuntimeKernel({ - store, - runStore, - runtimeEventStore: runStore, - backends, - newId, - now, - childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], - runBackendActivation, - }); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - runtimeKernel, - newId, - now, - }); - const session = await manager.createSession(makeInput()); - await drain(manager.sendMessage(session.id, { turnId: 'turn-a', text: 'build A' })); - - const bestEffort = runtimeKernel.invalidateBackend(session.id); - await firstDisposeStarted.promise; - const childTurn = drain( - manager.startChildTurn(session.id, { - turnId: 'child-turn', - parentRunId: 'parent-run', - spec: { - id: LOCAL_READ_AGENT_ID, - name: 'Researcher', - systemPrompt: 'read only', - }, - prompt: 'inspect', - }), - ); - await childPreBuildReached.promise; - releaseChildPreBuild.release(); - await new Promise((resolve) => setImmediate(resolve)); - expect(builds).toBe(1); - const mutation = runMutation(() => manager.refreshIdleBackends()); - - releaseFirstDispose.release(); - await bestEffort; - await childTurn; - await mutation; - - expect(builds).toBe(2); - expect(disposed).toEqual([1, 2]); - }); - - test('backend refresh invalidates only cached sessions without listing persisted history', async () => { - const store = new MemorySessionStore(); - const disposed: string[] = []; - const backends = new BackendRegistry(); - backends.register( - 'ai-sdk', - (ctx) => - new (class extends TestBackend { - override async dispose(): Promise { - disposed.push(this.sessionId); + backends.register( + 'ai-sdk', + (ctx) => + new (class extends TestBackend { + override async dispose(): Promise { + disposed.push(this.sessionId); } })(ctx), ); @@ -8991,779 +8297,228 @@ describe('SessionManager permission mode updates', () => { projectionCache: store, }).getSessionView(session.id); - const readRequestId = (value: unknown): string[] => - value !== null && typeof value === 'object' && 'requestId' in value - ? [(value as { requestId: string }).requestId] - : []; - // Both halves of the interaction survive: without the decision the view - // would show a request that never resolves. - expect( - view.events.flatMap((event) => - readRequestId(event.actions?.stateDelta?.sandboxBoundaryRequest), - ), - ).toEqual(['boundary-pending']); - expect( - view.events.flatMap((event) => - readRequestId(event.actions?.stateDelta?.sandboxBoundaryDecision), - ), - ).toEqual(['boundary-pending']); - }); - - test('mixed projection-cache-only system notes do not override RuntimeEvent projection', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const manager = makeManagerForReadCutover(store, runStore); - const session = await manager.createSession(makeInput()); - const seeded = await seedRuntimeReadTurn({ - store, - runStore, - sessionId: session.id, - turnId: 'turn-1', - runId: 'run-1', - userText: 'question', - assistantText: 'answer', - legacyIdPrefix: 'legacy', - }); - const legacyNote: StoredMessage = { - type: 'system_note', - id: 'legacy-note', - ts: 104, - kind: 'mode_change', - data: { from: 'ask', to: 'bypass' }, - }; - await store.appendMessage(session.id, legacyNote); - - const messages = await manager.getMessages(session.id); - - expect(messages).toEqual(seeded.projectedMessages); - }); - - test('getMessages orders RuntimeEvent-primary reads by session event chronology across runs', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const manager = makeManagerForReadCutover(store, runStore); - const session = await manager.createSession(makeInput()); - await seedRuntimeRun( - runStore, - makeRunHeader({ - sessionId: session.id, - runId: 'slow-run', - turnId: 'slow', - status: 'completed', - createdAt: 100, - updatedAt: 107, - completedAt: 107, - }), - [ - runtimeEvent({ - id: 'slow-user', - sessionId: session.id, - runId: 'slow-run', - turnId: 'slow', - ts: 101, - role: 'user', - author: 'user', - content: { kind: 'text', text: 'slow question' }, - refs: { storedMessageId: 'slow-user-message' }, - }), - runtimeEvent({ - id: 'slow-assistant', - sessionId: session.id, - runId: 'slow-run', - turnId: 'slow', - ts: 106, - role: 'model', - author: 'agent', - content: { kind: 'text', text: 'slow answer' }, - refs: { storedMessageId: 'slow-assistant-message' }, - }), - runtimeEvent({ - id: 'slow-complete', - sessionId: session.id, - runId: 'slow-run', - turnId: 'slow', - ts: 107, - role: 'system', - author: 'system', - status: 'completed', - actions: { endInvocation: true }, - }), - ], - ); - await seedRuntimeRun( - runStore, - makeRunHeader({ - sessionId: session.id, - runId: 'fast-run', - turnId: 'fast', - status: 'completed', - createdAt: 102, - updatedAt: 105, - completedAt: 105, - }), - [ - runtimeEvent({ - id: 'fast-user', - sessionId: session.id, - runId: 'fast-run', - turnId: 'fast', - ts: 103, - role: 'user', - author: 'user', - content: { kind: 'text', text: 'fast question' }, - refs: { storedMessageId: 'fast-user-message' }, - }), - runtimeEvent({ - id: 'fast-assistant', - sessionId: session.id, - runId: 'fast-run', - turnId: 'fast', - ts: 104, - role: 'model', - author: 'agent', - content: { kind: 'text', text: 'fast answer' }, - refs: { storedMessageId: 'fast-assistant-message' }, - }), - runtimeEvent({ - id: 'fast-complete', - sessionId: session.id, - runId: 'fast-run', - turnId: 'fast', - ts: 105, - role: 'system', - author: 'system', - status: 'completed', - actions: { endInvocation: true }, - }), - ], - ); - store.failNextReadMessagesFor.set(session.id, 1); - - const messages = await manager.getMessages(session.id); - - expect( - messages.map( - (message) => - `${message.type}:${'turnId' in message ? message.turnId : 'none'}:${message.ts}`, - ), - ).toEqual([ - 'user:slow:101', - 'user:fast:103', - 'assistant:fast:104', - 'turn_state:fast:105', - 'assistant:slow:106', - 'turn_state:slow:107', - ]); - }); - - test('regenerate finds completed source turns through the RuntimeEvent-primary view', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - backends.register( - 'ai-sdk', - (ctx) => new EventBackend(ctx, [{ type: 'complete', stopReason: 'end_turn' }]), - ); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - newId: nextId(), - now: nextNow(6_770), - }); - const session = await manager.createSession(makeInput()); - await seedRuntimeReadTurn({ - store, - runStore, - sessionId: session.id, - turnId: 'source', - runId: 'source-run', - userText: 'runtime regenerate text', - assistantText: 'runtime answer', - legacyIdPrefix: 'legacy', - }); - store.failNextReadMessagesFor.set(session.id, 1); - - await drain(manager.regenerateTurn(session.id, { sourceTurnId: 'source', turnId: 'regen-1' })); - - const messages = await store.readMessages(session.id); - const regenUser = messages.find( - (message) => message.type === 'user' && message.turnId === 'regen-1', - ); - expect(regenUser?.type === 'user' ? regenUser.text : undefined).toBe('runtime regenerate text'); - const regenState = deriveTurnRecords(messages).find((turn) => turn.turnId === 'regen-1'); - expect(regenState?.regeneratedFromTurnId).toBe('source'); - }); - - test('regenerate is stop-visible during its first source-ledger preflight', async () => { - const preflightStarted = makeGate(); - const releasePreflight = makeGate(); - let gatePreflight = false; - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore({ - beforeListSessionRuns: async () => { - if (!gatePreflight) return; - gatePreflight = false; - preflightStarted.release(); - await releasePreflight.promise; - }, - }); - const backends = new BackendRegistry(); - let backend: TestBackend | undefined; - backends.register('ai-sdk', (ctx) => { - backend = new TestBackend(ctx); - return backend; - }); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - newId: nextId(), - now: nextNow(6_775), - }); - const session = await manager.createSession(makeInput()); - await seedRuntimeReadTurn({ - store, - runStore, - sessionId: session.id, - turnId: 'source', - runId: 'source-run', - userText: 'do not regenerate after stop', - assistantText: 'source answer', - legacyIdPrefix: 'legacy-stop', - }); - - gatePreflight = true; - const regenerating = manager - .regenerateTurn(session.id, { - sourceTurnId: 'source', - turnId: 'regen-stopped-preflight', - }) - [Symbol.asyncIterator](); - const firstEvent = regenerating.next(); - await preflightStarted.promise; - let stopSettled = false; - const stopping = manager.stopSession(session.id, { source: 'stop_button' }).finally(() => { - stopSettled = true; - }); - await Promise.resolve(); - expect(stopSettled).toBe(false); - - releasePreflight.release(); - await stopping; - expect((await firstEvent).done).toBe(true); - expect(backend?.sendInputs).toEqual([]); - const regenerated = (await runStore.listSessionRuns(session.id)).find( - (run) => run.turnId === 'regen-stopped-preflight', - ); - expect(regenerated?.status).toBe('cancelled'); - expect((await store.readHeader(session.id)).status === 'blocked').toBe(false); - }); - - test('regenerate accepts an aborted source turn (retry semantics merged into regenerate)', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - backends.register( - 'ai-sdk', - (ctx) => new EventBackend(ctx, [{ type: 'complete', stopReason: 'end_turn' }]), - ); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - newId: nextId(), - now: nextNow(6_780), - }); - const session = await manager.createSession(makeInput()); - await seedRuntimeRun( - runStore, - makeRunHeader({ - sessionId: session.id, - runId: 'source-run', - turnId: 'source', - status: 'cancelled', - createdAt: 100, - updatedAt: 102, - completedAt: 102, - }), - [ - runtimeEvent({ - id: 'source-user', - sessionId: session.id, - runId: 'source-run', - turnId: 'source', - ts: 101, - role: 'user', - author: 'user', - content: { kind: 'text', text: 'aborted turn text' }, - }), - runtimeEvent({ - id: 'source-abort', - sessionId: session.id, - runId: 'source-run', - turnId: 'source', - ts: 102, - role: 'system', - author: 'system', - status: 'aborted', - actions: { endInvocation: true, stateDelta: { abortSource: 'renderer.stop_button' } }, - }), - ], - ); - store.failNextReadMessagesFor.set(session.id, 1); - - await drain( - manager.regenerateTurn(session.id, { sourceTurnId: 'source', turnId: 'regen-aborted' }), - ); - - const regenUser = (await store.readMessages(session.id)).find( - (message) => message.type === 'user' && message.turnId === 'regen-aborted', - ); - expect(regenUser?.type === 'user' ? regenUser.text : undefined).toBe('aborted turn text'); - }); - - test('branchFromTurn copies through the RuntimeEvent-primary message boundary', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const manager = makeManagerForReadCutover(store, runStore); - const session = await manager.createSession(makeInput({ name: 'Parent' })); - await seedRuntimeReadTurn({ - store, - runStore, - sessionId: session.id, - turnId: 'source', - runId: 'source-run', - userText: 'runtime branch context', - assistantText: 'runtime branch answer', - legacyIdPrefix: 'legacy', - }); - store.failNextReadMessagesFor.set(session.id, 1); - - const child = await manager.branchFromTurn(session.id, { - sourceTurnId: 'source', - name: 'Child', - }); - - const childMessages = await store.readMessages(child.id); - expect(childMessages[0]).toMatchObject({ - type: 'user', - turnId: 'source', - text: 'runtime branch context', - }); - expect(childMessages[1]).toMatchObject({ - type: 'assistant', - turnId: 'source', - text: 'runtime branch answer', - }); - expect(childMessages[2]).toMatchObject({ type: 'system_note', kind: 'session_start' }); - expect(childMessages.some((message) => message.type === 'turn_state')).toBe(false); - - const runtimeMessages = await manager.getMessages(child.id); - expect(runtimeMessages[0]).toMatchObject({ - type: 'user', - turnId: 'source', - text: 'runtime branch context', - }); - expect(runtimeMessages[1]).toMatchObject({ - type: 'assistant', - turnId: 'source', - text: 'runtime branch answer', - }); - }); - - test('conversation copies materialize requestless hosted permission history', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - const backendInstances: TestBackend[] = []; - backends.register('ai-sdk', (ctx) => { - const backend = new TestBackend(ctx); - backendInstances.push(backend); - return backend; - }); - const session = await store.create(makeInput({ name: 'Parent' })); - const header = makeRunHeader({ - sessionId: session.id, - runId: 'source-run', - turnId: 'source', - status: 'completed', - createdAt: 100, - updatedAt: 130, - completedAt: 130, - }); - await seedCanonicalPermissionRun(runStore, header, false); - const sourcePermissionEvents = await runStore.readRuntimeEvents(session.id, header.runId); - expect(sourcePermissionEvents.some((event) => event.actions?.permissionRequest)).toBe(false); - expect(sourcePermissionEvents.some((event) => event.content?.kind === 'function_call')).toBe( - false, - ); - await seedRuntimeRun( - runStore, - makeRunHeader({ - sessionId: session.id, - runId: 'later-run', - turnId: 'later', - status: 'completed', - createdAt: 140, - updatedAt: 141, - completedAt: 141, - }), - [ - runtimeEvent({ - id: 'later-user', - sessionId: session.id, - runId: 'later-run', - turnId: 'later', - ts: 140, - role: 'user', - author: 'user', - content: { kind: 'text', text: 'replace this turn' }, - }), - runtimeEvent({ - id: 'later-terminal', - sessionId: session.id, - runId: 'later-run', - turnId: 'later', - ts: 141, - status: 'completed', - actions: { endInvocation: true }, - }), - ], - ); - let sourceInteractionAvailable = true; - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - newId: nextId(), - now: nextNow(6_755), - interactionAuthority: testInteractionAuthority(), - canonicalPermissionOutcomes: { - readPermissionOutcome: async () => - sourceInteractionAvailable ? canonicalPermissionRecord(header) : undefined, - }, - }); - - const child = await manager.branchFromTurn(session.id, { - sourceTurnId: header.turnId, - name: 'Child', - }); - const revision = await manager.reviseBeforeTurn(session.id, { sourceTurnId: 'later' }); - sourceInteractionAvailable = false; - - const [childSourceRun] = await runStore.listSessionRuns(child.id); - const childSourceEvents = childSourceRun - ? await runStore.readRuntimeEvents(child.id, childSourceRun.runId) - : []; - expect( - childSourceEvents.find((event) => event.actions?.permissionDecision)?.actions - ?.permissionDecision, - ).toMatchObject({ - requestId: 'request-canonical', - toolName: 'Write', - decision: 'deny', - }); - expect(childSourceEvents.some((event) => event.actions?.permissionRequest)).toBe(false); - expect(childSourceEvents.some((event) => event.actions?.permissionAnswerAccepted)).toBe(false); - - for (const copiedSession of [child, revision]) { - expect( - (await manager.getMessages(copiedSession.id)).find( - (message) => message.type === 'permission_decision', - ), - ).toMatchObject({ - type: 'permission_decision', - id: 'request-canonical', - turnId: header.turnId, - toolUseId: 'tool-canonical', - toolName: 'Write', - decision: 'deny', - reviewer: 'user', - }); - } - - await drain(manager.sendMessage(child.id, { turnId: 'child-next', text: 'continue' })); - expect( - backendInstances - .find((backend) => backend.sessionId === child.id) - ?.sendInputs[0]?.context.find((message) => message.type === 'permission_decision'), - ).toMatchObject({ - id: 'request-canonical', - toolUseId: 'tool-canonical', - toolName: 'Write', - decision: 'deny', - }); - }); - - test('branch child next turn receives cloned RuntimeEvent context', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - const backendInstances: TestBackend[] = []; - backends.register('ai-sdk', (ctx) => { - const backend = new TestBackend(ctx); - backendInstances.push(backend); - return backend; - }); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - newId: nextId(), - now: nextNow(6_870), - }); - const session = await manager.createSession(makeInput({ name: 'Parent' })); - - await drain(manager.sendMessage(session.id, { turnId: 'source', text: 'branch seed' })); - const child = await manager.branchFromTurn(session.id, { - sourceTurnId: 'source', - name: 'Child', - }); - await store.appendMessage(child.id, { - type: 'assistant', - id: 'child-cache-only', - turnId: 'cache-only', - ts: 6_999, - text: 'cache-only child context', - modelId: 'fake-model', - }); - - await drain(manager.sendMessage(child.id, { turnId: 'child-next', text: 'child follow-up' })); - - const childInput = backendInstances[1]?.sendInputs[0]; - if (!childInput) throw new Error('child backend input was not recorded'); + const readRequestId = (value: unknown): string[] => + value !== null && typeof value === 'object' && 'requestId' in value + ? [(value as { requestId: string }).requestId] + : []; + // Both halves of the interaction survive: without the decision the view + // would show a request that never resolves. expect( - childInput.context.some( - (message) => - message.type === 'user' && message.turnId === 'source' && message.text === 'branch seed', + view.events.flatMap((event) => + readRequestId(event.actions?.stateDelta?.sandboxBoundaryRequest), ), - ).toBe(true); + ).toEqual(['boundary-pending']); expect( - childInput.context.some( - (message) => message.type === 'assistant' && message.id === 'child-cache-only', + view.events.flatMap((event) => + readRequestId(event.actions?.stateDelta?.sandboxBoundaryDecision), ), - ).toBe(false); - expect(childInput.runtimeContext?.map((event) => event.turnId)).toEqual([ - 'source', - 'source', - 'source', - ]); - expect(childInput.runtimeContext?.[0]?.sessionId).toBe(child.id); - }); - - test('branchFromTurn removes an incomplete target when Runtime ledger copy fails', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore({ failRuntimeEventAppendAfter: 5 }); - const manager = makeManagerForReadCutover(store, runStore); - const session = await manager.createSession(makeInput({ name: 'Parent' })); - await seedRuntimeReadTurn({ - store, - runStore, - sessionId: session.id, - turnId: 'source', - runId: 'source-run', - userText: 'runtime branch context', - assistantText: 'runtime branch answer', - legacyIdPrefix: 'legacy', - }); - - let branchError: unknown; - try { - await manager.branchFromTurn(session.id, { sourceTurnId: 'source', name: 'Child' }); - } catch (error) { - branchError = error; - } - expect(branchError instanceof Error ? branchError.message : String(branchError)).toContain( - 'runtime event append failed', - ); - - const child = (await store.list()).find((summary) => summary.parentSessionId === session.id); - expect(child).toBeUndefined(); - const childRuns = await runStore.listSessionRuns('session-2'); - for (const run of childRuns) { - const runtimeEvents = await runStore.readRuntimeEvents('session-2', run.runId); - const hasTerminalFact = runtimeEvents.some(isTerminalRuntimeEvent); - expect( - run.status === 'completed' || run.status === 'failed' || run.status === 'cancelled' - ? hasTerminalFact - : true, - ).toBe(true); - } + ).toEqual(['boundary-pending']); }); - test('multi-run RuntimeEvent projection preserves retry regenerate and branch lineage on turns', async () => { + test('mixed projection-cache-only system notes do not override RuntimeEvent projection', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); const manager = makeManagerForReadCutover(store, runStore); const session = await manager.createSession(makeInput()); - await seedRuntimeReadTurn({ - store, - runStore, - sessionId: session.id, - turnId: 'root', - runId: 'root-run', - userText: 'root question', - assistantText: 'root answer', - legacyIdPrefix: 'root-legacy', - }); - await seedRuntimeReadTurnWithHeader({ - store, - runStore, - sessionId: session.id, - turnId: 'retry', - runId: 'retry-run', - userText: 'retry question', - assistantText: 'retry answer', - legacyIdPrefix: 'retry-legacy', - header: { parentTurnId: 'root', retriedFromTurnId: 'root' }, - tsBase: 200, - }); - await seedRuntimeReadTurnWithHeader({ - store, - runStore, - sessionId: session.id, - turnId: 'regen', - runId: 'regen-run', - userText: 'regen question', - assistantText: 'regen answer', - legacyIdPrefix: 'regen-legacy', - header: { parentTurnId: 'root', regeneratedFromTurnId: 'root' }, - tsBase: 300, - }); - await seedRuntimeReadTurnWithHeader({ + const seeded = await seedRuntimeReadTurn({ store, runStore, sessionId: session.id, - turnId: 'branch', - runId: 'branch-run', - userText: 'branch question', - assistantText: 'branch answer', - legacyIdPrefix: 'branch-legacy', - header: { parentSessionId: 'parent-session', branchOfTurnId: 'root' }, - tsBase: 400, + turnId: 'turn-1', + runId: 'run-1', + userText: 'question', + assistantText: 'answer', + legacyIdPrefix: 'legacy', }); - store.failNextReadMessagesFor.set(session.id, 1); + const legacyNote: StoredMessage = { + type: 'system_note', + id: 'legacy-note', + ts: 104, + kind: 'mode_change', + data: { from: 'ask', to: 'bypass' }, + }; + await store.appendMessage(session.id, legacyNote); - const turns = await manager.listTurns(session.id); + const messages = await manager.getMessages(session.id); - expect(turns.find((turn) => turn.turnId === 'retry')).toMatchObject({ - status: 'completed', - parentTurnId: 'root', - retriedFromTurnId: 'root', - }); - expect(turns.find((turn) => turn.turnId === 'regen')).toMatchObject({ - status: 'completed', - parentTurnId: 'root', - regeneratedFromTurnId: 'root', - }); - expect(turns.find((turn) => turn.turnId === 'branch')).toMatchObject({ - status: 'completed', - parentSessionId: 'parent-session', - branchOfTurnId: 'root', - }); + expect(messages).toEqual(seeded.projectedMessages); }); - test('next turn projects failed prior RuntimeEvents with the terminal fact failure class', async () => { + test('getMessages orders RuntimeEvent-primary reads by session event chronology across runs', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - const backendInstances: TestBackend[] = []; - backends.register('ai-sdk', (ctx) => { - const backend = new TestBackend(ctx); - backendInstances.push(backend); - return backend; - }); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - newId: nextId(), - now: nextNow(6_810), - }); + const manager = makeManagerForReadCutover(store, runStore); const session = await manager.createSession(makeInput()); await seedRuntimeRun( runStore, makeRunHeader({ sessionId: session.id, - runId: 'run-1', - turnId: 'turn-1', - status: 'running', - createdAt: 101, - updatedAt: 103, + runId: 'slow-run', + turnId: 'slow', + status: 'completed', + createdAt: 100, + updatedAt: 107, + completedAt: 107, }), [ runtimeEvent({ - id: 'rt-user', + id: 'slow-user', sessionId: session.id, - runId: 'run-1', - turnId: 'turn-1', + runId: 'slow-run', + turnId: 'slow', ts: 101, role: 'user', author: 'user', - content: { kind: 'text', text: 'first' }, + content: { kind: 'text', text: 'slow question' }, + refs: { storedMessageId: 'slow-user-message' }, }), runtimeEvent({ - id: 'rt-assistant', + id: 'slow-assistant', sessionId: session.id, - runId: 'run-1', - turnId: 'turn-1', - ts: 102, + runId: 'slow-run', + turnId: 'slow', + ts: 106, role: 'model', author: 'agent', - content: { kind: 'text', text: 'before failure' }, + content: { kind: 'text', text: 'slow answer' }, + refs: { storedMessageId: 'slow-assistant-message' }, }), runtimeEvent({ - id: 'rt-failed', + id: 'slow-complete', sessionId: session.id, - runId: 'run-1', - turnId: 'turn-1', + runId: 'slow-run', + turnId: 'slow', + ts: 107, + role: 'system', + author: 'system', + status: 'completed', + actions: { endInvocation: true }, + }), + ], + ); + await seedRuntimeRun( + runStore, + makeRunHeader({ + sessionId: session.id, + runId: 'fast-run', + turnId: 'fast', + status: 'completed', + createdAt: 102, + updatedAt: 105, + completedAt: 105, + }), + [ + runtimeEvent({ + id: 'fast-user', + sessionId: session.id, + runId: 'fast-run', + turnId: 'fast', ts: 103, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'fast question' }, + refs: { storedMessageId: 'fast-user-message' }, + }), + runtimeEvent({ + id: 'fast-assistant', + sessionId: session.id, + runId: 'fast-run', + turnId: 'fast', + ts: 104, + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'fast answer' }, + refs: { storedMessageId: 'fast-assistant-message' }, + }), + runtimeEvent({ + id: 'fast-complete', + sessionId: session.id, + runId: 'fast-run', + turnId: 'fast', + ts: 105, role: 'system', author: 'system', - status: 'failed', - actions: { endInvocation: true, stateDelta: { failureClass: 'tool_failed' } }, + status: 'completed', + actions: { endInvocation: true }, }), ], ); + store.failNextReadMessagesFor.set(session.id, 1); + + const messages = await manager.getMessages(session.id); + + expect( + messages.map( + (message) => + `${message.type}:${'turnId' in message ? message.turnId : 'none'}:${message.ts}`, + ), + ).toEqual([ + 'user:slow:101', + 'user:fast:103', + 'assistant:fast:104', + 'turn_state:fast:105', + 'assistant:slow:106', + 'turn_state:slow:107', + ]); + }); + + test('regenerate finds completed source turns through the RuntimeEvent-primary view', async () => { + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const backends = new BackendRegistry(); + backends.register( + 'ai-sdk', + (ctx) => new EventBackend(ctx, [{ type: 'complete', stopReason: 'end_turn' }]), + ); + const manager = new SessionManager({ + store, + runStore, + runtimeEventStore: runStore, + backends, + newId: nextId(), + now: nextNow(6_770), + }); + const session = await manager.createSession(makeInput()); + await seedRuntimeReadTurn({ + store, + runStore, + sessionId: session.id, + turnId: 'source', + runId: 'source-run', + userText: 'runtime regenerate text', + assistantText: 'runtime answer', + legacyIdPrefix: 'legacy', + }); + store.failNextReadMessagesFor.set(session.id, 1); - await drain(manager.sendMessage(session.id, { turnId: 'turn-2', text: 'second' })); + await drain(manager.regenerateTurn(session.id, { sourceTurnId: 'source', turnId: 'regen-1' })); - const secondInput = backendInstances[0]?.sendInputs[0]; - if (!secondInput) throw new Error('backend input was not recorded'); - expect(secondInput.runtimeContext?.map((event) => event.turnId)).toEqual([ - 'turn-1', - 'turn-1', - 'turn-1', - ]); - const turnState = secondInput.context.find( - (message) => message.type === 'turn_state' && message.turnId === 'turn-1', + const messages = await store.readMessages(session.id); + const regenUser = messages.find( + (message) => message.type === 'user' && message.turnId === 'regen-1', ); - if (turnState?.type !== 'turn_state') - throw new Error('prior failed turn_state was not projected'); - expect(turnState.status).toBe('failed'); - expect(turnState.errorClass).toBe('tool_failed'); + expect(regenUser?.type === 'user' ? regenUser.text : undefined).toBe('runtime regenerate text'); + const regenState = deriveTurnRecords(messages).find((turn) => turn.turnId === 'regen-1'); + expect(regenState?.regeneratedFromTurnId).toBe('source'); }); - test('next turn uses failed terminal RuntimeEvents when failed header commit was interrupted', async () => { + test('regenerate is stop-visible during its first source-ledger preflight', async () => { + const preflightStarted = makeGate(); + const releasePreflight = makeGate(); + let gatePreflight = false; const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore({ failUpdateRunStatusOnce: 'failed' }); + const runStore = new MemoryAgentRunStore({ + beforeListSessionRuns: async () => { + if (!gatePreflight) return; + gatePreflight = false; + preflightStarted.release(); + await releasePreflight.promise; + }, + }); const backends = new BackendRegistry(); - let backend: TurnScriptBackend | undefined; + let backend: TestBackend | undefined; backends.register('ai-sdk', (ctx) => { - backend = new TurnScriptBackend(ctx, [ - [{ type: 'complete', stopReason: 'error' }], - [ - { type: 'text_delta', messageId: 'm2', text: 'second ok' }, - { type: 'complete', stopReason: 'end_turn' }, - ], - ]); + backend = new TestBackend(ctx); return backend; }); const manager = new SessionManager({ @@ -9772,964 +8527,596 @@ describe('SessionManager permission mode updates', () => { runtimeEventStore: runStore, backends, newId: nextId(), - now: nextNow(6_812), + now: nextNow(6_775), }); const session = await manager.createSession(makeInput()); + await seedRuntimeReadTurn({ + store, + runStore, + sessionId: session.id, + turnId: 'source', + runId: 'source-run', + userText: 'do not regenerate after stop', + assistantText: 'source answer', + legacyIdPrefix: 'legacy-stop', + }); - await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'first' })); - const [firstRun] = await runStore.listSessionRuns(session.id); - if (!firstRun) throw new Error('first run was not recorded'); - expect(firstRun.status).toBe('running'); - const firstRuntimeEvents = await runStore.readRuntimeEvents(session.id, firstRun.runId); - const firstTerminalEvents = firstRuntimeEvents.filter(isTerminalRuntimeEvent); - expect(firstTerminalEvents).toHaveLength(1); - expect(firstTerminalEvents[0]?.status).toBe('failed'); - expect(firstTerminalEvents[0]?.actions?.stateDelta?.failureClass).toBe('runtime_error'); - - await drain(manager.sendMessage(session.id, { turnId: 'turn-2', text: 'second' })); + gatePreflight = true; + const regenerating = manager + .regenerateTurn(session.id, { + sourceTurnId: 'source', + turnId: 'regen-stopped-preflight', + }) + [Symbol.asyncIterator](); + const firstEvent = regenerating.next(); + await preflightStarted.promise; + let stopSettled = false; + const stopping = manager.stopSession(session.id, { source: 'stop_button' }).finally(() => { + stopSettled = true; + }); + await Promise.resolve(); + expect(stopSettled).toBe(false); - const secondInput = backend?.sendInputs[1]; - if (!secondInput) throw new Error('second backend input was not recorded'); - expect(secondInput.runtimeContext?.map((event) => event.turnId)).toEqual(['turn-1', 'turn-1']); - const turnState = secondInput.context.find( - (message) => message.type === 'turn_state' && message.turnId === 'turn-1', + releasePreflight.release(); + await stopping; + expect((await firstEvent).done).toBe(true); + expect(backend?.sendInputs).toEqual([]); + const regenerated = (await runStore.listSessionRuns(session.id)).find( + (run) => run.turnId === 'regen-stopped-preflight', ); - if (turnState?.type !== 'turn_state') - throw new Error('prior failed turn_state was not projected'); - expect(turnState.status).toBe('failed'); - expect(turnState.errorClass).toBe('runtime_error'); - const terminalEventsAfterSecondTurn = ( - await runStore.readRuntimeEvents(session.id, firstRun.runId) - ).filter(isTerminalRuntimeEvent); - expect(terminalEventsAfterSecondTurn).toHaveLength(1); + expect(regenerated?.status).toBe('cancelled'); + expect((await store.readHeader(session.id)).status === 'blocked').toBe(false); }); - test('next parent turn excludes child run RuntimeEvents from model context', async () => { + test('regenerate accepts an aborted source turn (retry semantics merged into regenerate)', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); const backends = new BackendRegistry(); - const backendInstances: TestBackend[] = []; - backends.register('ai-sdk', (ctx) => { - const backend = new TestBackend(ctx); - backendInstances.push(backend); - return backend; - }); + backends.register( + 'ai-sdk', + (ctx) => new EventBackend(ctx, [{ type: 'complete', stopReason: 'end_turn' }]), + ); const manager = new SessionManager({ store, runStore, runtimeEventStore: runStore, backends, newId: nextId(), - now: nextNow(6_825), + now: nextNow(6_780), }); const session = await manager.createSession(makeInput()); - - await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'first' })); - const [parentRun] = await runStore.listSessionRuns(session.id); - if (!parentRun) throw new Error('parent run was not recorded'); await seedRuntimeRun( runStore, makeRunHeader({ sessionId: session.id, - runId: 'child-run', - turnId: 'child-turn', - status: 'completed', - createdAt: parentRun.updatedAt + 1, - updatedAt: parentRun.updatedAt + 4, - completedAt: parentRun.updatedAt + 4, - parentRunId: parentRun.runId, - agentName: 'Researcher', + runId: 'source-run', + turnId: 'source', + status: 'cancelled', + createdAt: 100, + updatedAt: 102, + completedAt: 102, }), [ runtimeEvent({ - id: 'child-user', + id: 'source-user', sessionId: session.id, - runId: 'child-run', - turnId: 'child-turn', - ts: parentRun.updatedAt + 2, + runId: 'source-run', + turnId: 'source', + ts: 101, role: 'user', author: 'user', - content: { kind: 'text', text: 'child prompt' }, - }), - runtimeEvent({ - id: 'child-assistant', - sessionId: session.id, - runId: 'child-run', - turnId: 'child-turn', - ts: parentRun.updatedAt + 3, - role: 'model', - author: 'agent', - content: { kind: 'text', text: 'child private answer' }, + content: { kind: 'text', text: 'aborted turn text' }, }), runtimeEvent({ - id: 'child-complete', + id: 'source-abort', sessionId: session.id, - runId: 'child-run', - turnId: 'child-turn', - ts: parentRun.updatedAt + 4, + runId: 'source-run', + turnId: 'source', + ts: 102, role: 'system', author: 'system', - status: 'completed', - actions: { endInvocation: true }, + status: 'aborted', + actions: { endInvocation: true, stateDelta: { abortSource: 'renderer.stop_button' } }, }), ], ); + store.failNextReadMessagesFor.set(session.id, 1); - await drain(manager.sendMessage(session.id, { turnId: 'turn-2', text: 'second' })); - - const secondInput = backendInstances[0]?.sendInputs[1]; - if (!secondInput) throw new Error('second backend input was not recorded'); - expect(secondInput.runtimeContext?.map((event) => event.turnId)).toEqual([ - 'turn-1', - 'turn-1', - 'turn-1', - ]); - expect(secondInput.runtimeContext?.some((event) => event.turnId === 'child-turn')).toBe(false); - expect( - secondInput.context.some( - (message) => message.type === 'user' && message.turnId === 'child-turn', - ), - ).toBe(false); - }); - - test('child run input records parentRunId and starts without implicit prior context', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - const backendInstances: TestBackend[] = []; - backends.register('ai-sdk', (ctx) => { - const backend = new TestBackend(ctx); - backendInstances.push(backend); - return backend; - }); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - newId: nextId(), - now: nextNow(6_835), - }); - const session = await manager.createSession(makeInput()); - - await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'parent context' })); - const [parentRun] = await runStore.listSessionRuns(session.id); - if (!parentRun) throw new Error('parent run was not recorded'); await drain( - manager.sendMessage(session.id, { - turnId: 'child-turn', - text: 'child prompt', - parentRunId: parentRun.runId, - agentName: 'Researcher', - }), + manager.regenerateTurn(session.id, { sourceTurnId: 'source', turnId: 'regen-aborted' }), ); - const childRun = (await runStore.listSessionRuns(session.id)).find( - (run) => run.turnId === 'child-turn', + const regenUser = (await store.readMessages(session.id)).find( + (message) => message.type === 'user' && message.turnId === 'regen-aborted', ); - if (!childRun) throw new Error('child run was not recorded'); - expect(childRun.parentRunId).toBe(parentRun.runId); - expect(childRun.agentName).toBe('Researcher'); - - const childInput = backendInstances[0]?.sendInputs[1]; - if (!childInput) throw new Error('child backend input was not recorded'); - expect(childInput.context).toEqual([]); - expect(childInput.runtimeContext).toBe(undefined); + expect(regenUser?.type === 'user' ? regenUser.text : undefined).toBe('aborted turn text'); }); - test('resumeChildAgent replays durable child history into a fresh lineage run', async () => { + test('branchFromTurn copies through the RuntimeEvent-primary message boundary', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - const childBackends: TestBackend[] = []; - backends.register('ai-sdk', (ctx) => { - const backend = new TestBackend(ctx); - if (ctx.systemPrompt) childBackends.push(backend); - return backend; - }); - const manager = new SessionManager({ + const manager = makeManagerForReadCutover(store, runStore); + const session = await manager.createSession(makeInput({ name: 'Parent' })); + await seedRuntimeReadTurn({ store, runStore, - runtimeEventStore: runStore, - backends, - childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], - newId: nextId(), - now: nextNow(6_846), + sessionId: session.id, + turnId: 'source', + runId: 'source-run', + userText: 'runtime branch context', + assistantText: 'runtime branch answer', + legacyIdPrefix: 'legacy', }); - const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); - await drain(manager.sendMessage(session.id, { turnId: 'parent-turn', text: 'parent context' })); - const [parentRun] = await runStore.listSessionRuns(session.id); - if (!parentRun) throw new Error('parent run was not recorded'); + store.failNextReadMessagesFor.set(session.id, 1); - const source = await manager.spawnChildAgent(session.id, { - turnId: 'child-source', - parentRunId: parentRun.runId, - spec: { id: LOCAL_READ_AGENT_ID, name: 'ignored', systemPrompt: 'ignored' }, - prompt: 'inspect runtime', + const child = await manager.branchFromTurn(session.id, { + sourceTurnId: 'source', + name: 'Child', }); - if (!source.runId) throw new Error('source child run was not recorded'); - expect(await manager.prepareChildAgentResume(session.id, source.runId)).toEqual({ - sourceRunId: source.runId, - execution: { - kind: 'legacy_child_run', - sessionId: session.id, - runId: source.runId, - }, - agentId: LOCAL_READ_AGENT_ID, - agentName: LOCAL_READ_AGENT_DEFINITION.name, - profile: LOCAL_READ_AGENT_DEFINITION.profile, + const childMessages = await store.readMessages(child.id); + expect(childMessages[0]).toMatchObject({ + type: 'user', + turnId: 'source', + text: 'runtime branch context', }); - const resumed = await manager.resumeChildAgent(session.id, { - turnId: 'child-resumed', - parentRunId: parentRun.runId, - sourceRunId: source.runId, - prompt: 'continue with tests', + expect(childMessages[1]).toMatchObject({ + type: 'assistant', + turnId: 'source', + text: 'runtime branch answer', }); + expect(childMessages[2]).toMatchObject({ type: 'system_note', kind: 'session_start' }); + expect(childMessages.some((message) => message.type === 'turn_state')).toBe(false); - expect(resumed.resumedFromRunId).toBe(source.runId); - const resumedHeader = await runStore.readRun(session.id, resumed.runId!); - expect(resumedHeader).toMatchObject({ - parentRunId: parentRun.runId, - resumedFromRunId: source.runId, - agentId: LOCAL_READ_AGENT_ID, - agentName: LOCAL_READ_AGENT_DEFINITION.name, + const runtimeMessages = await manager.getMessages(child.id); + expect(runtimeMessages[0]).toMatchObject({ + type: 'user', + turnId: 'source', + text: 'runtime branch context', }); - const resumedInput = childBackends[1]?.sendInputs[0]; - expect(resumedInput?.text).toBe('continue with tests'); - expect(resumedInput?.runtimeContext?.some((event) => event.runId === source.runId)).toBe(true); - expect( - resumedInput?.runtimeContext?.some( - (event) => - event.role === 'user' && - event.content?.kind === 'text' && - event.content.text === 'inspect runtime', - ), - ).toBe(true); - await expectRejects( - manager.prepareChildAgentResume(session.id, source.runId), - /already has a successor/, - ); - expect(await manager.prepareChildAgentResume(session.id, resumed.runId!)).toMatchObject({ - sourceRunId: resumed.runId, + expect(runtimeMessages[1]).toMatchObject({ + type: 'assistant', + turnId: 'source', + text: 'runtime branch answer', }); }); - test('prepareChildAgentResume rejects an indeterminate child tool boundary', async () => { + test('conversation copies materialize requestless hosted permission history', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); const backends = new BackendRegistry(); - backends.register('ai-sdk', (ctx) => new TestBackend(ctx)); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], - newId: nextId(), - now: nextNow(6_847), + const backendInstances: TestBackend[] = []; + backends.register('ai-sdk', (ctx) => { + const backend = new TestBackend(ctx); + backendInstances.push(backend); + return backend; }); - const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); - const child = makeRunHeader({ + const session = await store.create(makeInput({ name: 'Parent' })); + const header = makeRunHeader({ sessionId: session.id, - runId: 'unsafe-child', - turnId: 'unsafe-turn', - status: 'failed', - parentRunId: 'parent-run', - agentId: LOCAL_READ_AGENT_ID, - agentName: LOCAL_READ_AGENT_DEFINITION.name, - permissionMode: 'explore', - createdAt: 1, - updatedAt: 4, - completedAt: 4, + runId: 'source-run', + turnId: 'source', + status: 'completed', + createdAt: 100, + updatedAt: 130, + completedAt: 130, }); - await seedRuntimeRun(runStore, child, [ - runtimeEvent({ - id: 'unsafe-user', - sessionId: session.id, - runId: child.runId, - turnId: child.turnId, - ts: 1, - role: 'user', - author: 'user', - content: { kind: 'text', text: 'inspect' }, - }), - runtimeEvent({ - id: 'unsafe-call', - sessionId: session.id, - runId: child.runId, - turnId: child.turnId, - ts: 2, - role: 'model', - author: 'agent', - content: { kind: 'function_call', id: 'read-1', name: 'Read', args: { path: 'x' } }, - refs: { toolCallId: 'read-1', stepId: 'step-1' }, - }), - runtimeEvent({ - id: 'unsafe-terminal', + await seedCanonicalPermissionRun(runStore, header, false); + const sourcePermissionEvents = await runStore.readRuntimeEvents(session.id, header.runId); + expect(sourcePermissionEvents.some((event) => event.actions?.permissionRequest)).toBe(false); + expect(sourcePermissionEvents.some((event) => event.content?.kind === 'function_call')).toBe( + false, + ); + await seedRuntimeRun( + runStore, + makeRunHeader({ sessionId: session.id, - runId: child.runId, - turnId: child.turnId, - ts: 4, - status: 'failed', - actions: { endInvocation: true }, + runId: 'later-run', + turnId: 'later', + status: 'completed', + createdAt: 140, + updatedAt: 141, + completedAt: 141, }), - ]); - - await expectRejects( - manager.prepareChildAgentResume(session.id, child.runId), - /unmatched_tool_call/, + [ + runtimeEvent({ + id: 'later-user', + sessionId: session.id, + runId: 'later-run', + turnId: 'later', + ts: 140, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'replace this turn' }, + }), + runtimeEvent({ + id: 'later-terminal', + sessionId: session.id, + runId: 'later-run', + turnId: 'later', + ts: 141, + status: 'completed', + actions: { endInvocation: true }, + }), + ], ); - }); - - test('retryChildAgent replays a rate-limited child without appending its prompt again', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - const sendInputs: BackendSendInput[] = []; - const contexts: BackendFactoryContext[] = []; - let childAttempt = 0; - const createBackend = (ctx: BackendFactoryContext) => ({ - kind: 'fake' as const, - sessionId: ctx.sessionId, - async *send(input: BackendSendInput): AsyncIterable { - childAttempt += 1; - sendInputs.push(input); - if (childAttempt <= 2) { - yield { - type: 'error', - id: `${input.turnId}-error`, - turnId: input.turnId, - ts: 1, - recoverable: true, - reason: 'RateLimit', - message: 'provider 429', - }; - yield { - type: 'complete', - id: `${input.turnId}-complete`, - turnId: input.turnId, - ts: 2, - stopReason: 'error', - }; - return; - } - yield { - type: 'text_delta', - id: `${input.turnId}-delta`, - turnId: input.turnId, - ts: 3, - messageId: `${input.turnId}-message`, - text: 'recovered', - }; - yield { - type: 'complete', - id: `${input.turnId}-complete`, - turnId: input.turnId, - ts: 4, - stopReason: 'end_turn', - }; - }, - async stop(): Promise {}, - async respondToSandboxBoundary(): Promise {}, - async dispose(): Promise {}, - }); - backends.register('ai-sdk', (ctx) => { - contexts.push(ctx); - return createBackend(ctx); - }); - const retryResolutionStarted = makeGate(); - const releaseRetryResolution = makeGate(); - const policyGate = makeTestRuntimePolicyGate(); - let activationDepth = 0; - let holdRetryActivation = false; - let shellRevision = 'A'; + let sourceInteractionAvailable = true; const manager = new SessionManager({ store, runStore, runtimeEventStore: runStore, backends, - resolveChildTools: async () => { - const capturedRevision = shellRevision; - if (holdRetryActivation && activationDepth > 0) { - retryResolutionStarted.release(); - await releaseRetryResolution.promise; - } - return { - tools: ['Read', 'Glob', 'Grep'].map((name) => ({ - ...testTool(name), - description: `${name} retry shell ${capturedRevision}`, - })), - shell: { - plan: { kind: 'posix', displayName: `retry shell ${capturedRevision}` }, - }, - }; - }, - runBackendActivation: (operation) => - policyGate.runBackendActivation(async () => { - activationDepth += 1; - try { - return await operation(); - } finally { - activationDepth -= 1; - } - }), - inspectContinuationSafety: async () => ({ - workspaceIdentity: '/tmp/cwd', - backgroundOperationsSettled: true, - availableToolNames: [], - }), newId: nextId(), - now: nextNow(6_843), - }); - const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); - const parentRun = makeRunHeader({ - sessionId: session.id, - runId: 'parent-run', - turnId: 'parent-turn', - status: 'completed', - createdAt: 100, - updatedAt: 110, - completedAt: 110, - }); - await seedRuntimeRun(runStore, parentRun, [ - runtimeEvent({ - id: 'parent-complete', - sessionId: session.id, - runId: parentRun.runId, - turnId: parentRun.turnId, - ts: 110, - status: 'completed', - actions: { endInvocation: true }, - }), - ]); - - const first = await manager.spawnChildAgent(session.id, { - turnId: 'child-rate-limited', - parentRunId: parentRun.runId, - spec: { id: LOCAL_READ_AGENT_ID, name: 'Reader', systemPrompt: 'read only' }, - prompt: 'inspect auth', - }); - expect(first.status).toBe('failed'); - expect(first.failureClass).toBe('RateLimit'); - if (!first.runId) throw new Error('rate-limited child run id was not recorded'); - - const second = await manager.resumeChildAgent(session.id, { - parentRunId: parentRun.runId, - sourceRunId: first.runId, - prompt: 'retry with additional guidance', - }); - expect(second.status).toBe('failed'); - expect(second.failureClass).toBe('RateLimit'); - if (!second.runId) throw new Error('second rate-limited child run id was not recorded'); - await expectRejects( - manager.retryChildAgent(session.id, { - parentRunId: parentRun.runId, - sourceRunId: first.runId, - }), - /already has a successor/, - ); - - const retryAbort = new AbortController(); - retryAbort.abort(); - const runsBeforeAbortedRetry = await runStore.listSessionRuns(session.id); - let abortedRetryReady = 0; - await expectRejects( - manager.retryChildAgent(session.id, { - parentRunId: parentRun.runId, - sourceRunId: second.runId, - abortSignal: retryAbort.signal, - onReady: () => { - abortedRetryReady += 1; - }, - }), - /cancelled before start/, - ); - expect((await runStore.listSessionRuns(session.id)).length).toBe(runsBeforeAbortedRetry.length); - expect(sendInputs).toHaveLength(2); - expect(abortedRetryReady).toBe(0); - - holdRetryActivation = true; - const retriedPromise = manager.retryChildAgent(session.id, { - parentRunId: parentRun.runId, - sourceRunId: second.runId, - }); - await retryResolutionStarted.promise; - let mutationApplied = false; - const mutation = policyGate.runMutation(async () => { - shellRevision = 'B'; - mutationApplied = true; - }); - await new Promise((resolve) => setImmediate(resolve)); - expect(mutationApplied).toBe(false); - releaseRetryResolution.release(); - const retried = await retriedPromise; - await mutation; - - expect(retried.status).toBe('completed'); - expect(retried.retriedFromRunId).toBe(second.runId); - expect(contexts.at(-1)?.turnShellPlan?.plan.displayName).toBe('retry shell A'); - expect(contexts.at(-1)?.tools?.[0]?.description).toMatch(/retry shell A/); - expect(sendInputs.map((input) => input.text)).toEqual([ - 'inspect auth', - 'retry with additional guidance', - '', - ]); - expect( - sendInputs[2]?.runtimeContext?.some( - (event) => - event.role === 'user' && - event.content?.kind === 'text' && - event.content.text === 'inspect auth', - ), - ).toBe(true); - const retryRun = await runStore.readRun(session.id, retried.runId!); - expect(retryRun.retriedFromRunId).toBe(second.runId); - expect(isSessionInlineRun(retryRun)).toBe(false); - expect(retryRun.continuationSource).toMatchObject({ - protocol: 'continuation_source_v2', - sourceRunId: second.runId, - }); - const retryStart = (await runStore.readImmutableRuntimeEvents(session.id, retried.runId!))[0] - ?.actions?.continuationStart; - expect(retryStart?.protocol).toBe('continuation_start_v2'); - expect(retryStart?.immediateSource.runId).toBe(second.runId); - expect( - (await store.readMessages(session.id)).filter( - (message) => 'turnId' in message && message.turnId === retried.turnId, - ), - ).toEqual([]); - }); - - test('retryChildAgent fails closed without a complete continuation composition', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - let providerDispatches = 0; - backends.register('ai-sdk', (ctx) => ({ - kind: 'fake' as const, - sessionId: ctx.sessionId, - async *send(input: BackendSendInput): AsyncIterable { - providerDispatches += 1; - yield { - type: 'error', - id: `${input.turnId}-error`, - turnId: input.turnId, - ts: 1, - recoverable: true, - reason: 'RateLimit', - message: 'provider 429', - }; - yield { - type: 'complete', - id: `${input.turnId}-complete`, - turnId: input.turnId, - ts: 2, - stopReason: 'error', - }; + now: nextNow(6_755), + interactionAuthority: testInteractionAuthority(), + canonicalPermissionOutcomes: { + readPermissionOutcome: async () => + sourceInteractionAvailable ? canonicalPermissionRecord(header) : undefined, }, - async stop(): Promise {}, - async respondToSandboxBoundary(): Promise {}, - async dispose(): Promise {}, - })); - const inspectContinuationSafety = async () => ({ - workspaceIdentity: '/tmp/cwd', - backgroundOperationsSettled: true, - availableToolNames: [] as string[], }); - const composeManager = (options: { - runtimeEventStore?: RuntimeEventStore; - withSafetyInspector?: boolean; - }) => - new SessionManager({ - store, - runStore, - runtimeEventStore: options.runtimeEventStore ?? runStore, - backends, - childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], - ...(options.withSafetyInspector ? { inspectContinuationSafety } : {}), - newId: nextId(), - now: nextNow(6_845), - }); - const eventStoreWithoutAuthority = new Proxy(runStore, { - get(target, property, receiver) { - if (property === 'continuationAuthorityCapability') return undefined; - return Reflect.get(target, property, receiver); - }, + + const child = await manager.branchFromTurn(session.id, { + sourceTurnId: header.turnId, + name: 'Child', }); + const revision = await manager.reviseBeforeTurn(session.id, { sourceTurnId: 'later' }); + sourceInteractionAvailable = false; - const seeder = composeManager({}); - const session = await seeder.createSession(makeInput({ permissionMode: 'ask' })); - const parentRun = makeRunHeader({ - sessionId: session.id, - runId: 'parent-run', - turnId: 'parent-turn', - status: 'completed', - createdAt: 100, - updatedAt: 110, - completedAt: 110, + const [childSourceRun] = await runStore.listSessionRuns(child.id); + const childSourceEvents = childSourceRun + ? await runStore.readRuntimeEvents(child.id, childSourceRun.runId) + : []; + expect( + childSourceEvents.find((event) => event.actions?.permissionDecision)?.actions + ?.permissionDecision, + ).toMatchObject({ + requestId: 'request-canonical', + toolName: 'Write', + decision: 'deny', }); - await seedRuntimeRun(runStore, parentRun, [ - runtimeEvent({ - id: 'parent-complete', - sessionId: session.id, - runId: parentRun.runId, - turnId: parentRun.turnId, - ts: 110, - status: 'completed', - actions: { endInvocation: true }, - }), - ]); - const first = await seeder.spawnChildAgent(session.id, { - turnId: 'child-rate-limited', - parentRunId: parentRun.runId, - spec: { id: LOCAL_READ_AGENT_ID, name: 'Reader', systemPrompt: 'read only' }, - prompt: 'inspect auth', - }); - expect(first.status).toBe('failed'); - expect(first.failureClass).toBe('RateLimit'); - if (!first.runId) throw new Error('rate-limited child run id was not recorded'); - const runsBeforeRetries = await runStore.listSessionRuns(session.id); - - const incompleteCompositions = [ - { - label: 'continuation authority and safety inspector are both missing', - runtimeEventStore: eventStoreWithoutAuthority, - withSafetyInspector: false, - }, - { - label: 'continuation authority is missing its safety inspector', - runtimeEventStore: runStore, - withSafetyInspector: false, - }, - { - label: 'safety inspector is missing its continuation authority', - runtimeEventStore: eventStoreWithoutAuthority, - withSafetyInspector: true, - }, - ]; - for (const composition of incompleteCompositions) { - const manager = composeManager(composition); - await expectRejects( - manager.retryChildAgent(session.id, { - parentRunId: parentRun.runId, - sourceRunId: first.runId, - }), - /composition is incomplete/, - ); + expect(childSourceEvents.some((event) => event.actions?.permissionRequest)).toBe(false); + expect(childSourceEvents.some((event) => event.actions?.permissionAnswerAccepted)).toBe(false); + + for (const copiedSession of [child, revision]) { + expect( + (await manager.getMessages(copiedSession.id)).find( + (message) => message.type === 'permission_decision', + ), + ).toMatchObject({ + type: 'permission_decision', + id: 'request-canonical', + turnId: header.turnId, + toolUseId: 'tool-canonical', + toolName: 'Write', + decision: 'deny', + reviewer: 'user', + }); } - expect(providerDispatches).toBe(1); - expect(await runStore.listSessionRuns(session.id)).toHaveLength(runsBeforeRetries.length); + + await drain(manager.sendMessage(child.id, { turnId: 'child-next', text: 'continue' })); + expect( + backendInstances + .find((backend) => backend.sessionId === child.id) + ?.sendInputs[0]?.context.find((message) => message.type === 'permission_decision'), + ).toMatchObject({ + id: 'request-canonical', + toolUseId: 'tool-canonical', + toolName: 'Write', + decision: 'deny', + }); }); - test('parent and child runs can read their ledger while only parents may compact session history', async () => { + test('branch child next turn receives cloned RuntimeEvent context', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); const backends = new BackendRegistry(); - const contexts: BackendFactoryContext[] = []; - const seamReads: Array<{ turnId: string; eventIds: string[] } | undefined> = []; - class SeamProbeBackend extends TestBackend { - constructor(private readonly probeCtx: BackendFactoryContext) { - super(probeCtx); - } - - override async *send(input: BackendSendInput): AsyncIterable { - seamReads.push( - this.probeCtx.loadTurnRuntimeEvents - ? { - turnId: input.turnId, - eventIds: (await this.probeCtx.loadTurnRuntimeEvents(input.turnId)).map( - (event) => event.id, - ), - } - : undefined, - ); - yield* super.send(input); - } - } + const backendInstances: TestBackend[] = []; backends.register('ai-sdk', (ctx) => { - contexts.push(ctx); - return new SeamProbeBackend(ctx); + const backend = new TestBackend(ctx); + backendInstances.push(backend); + return backend; }); const manager = new SessionManager({ store, runStore, runtimeEventStore: runStore, backends, - childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], newId: nextId(), - now: nextNow(6_850), + now: nextNow(6_870), }); - const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); - await drain(manager.sendMessage(session.id, { turnId: 'parent-turn', text: 'parent context' })); - const [parentRun] = await runStore.listSessionRuns(session.id); - if (!parentRun) throw new Error('parent run was not recorded'); - await manager.spawnChildAgent(session.id, { - turnId: 'child-turn', - parentRunId: parentRun.runId, - spec: { id: LOCAL_READ_AGENT_ID, name: 'Reader', systemPrompt: 'read only' }, - prompt: 'inspect', + const session = await manager.createSession(makeInput({ name: 'Parent' })); + + await drain(manager.sendMessage(session.id, { turnId: 'source', text: 'branch seed' })); + const child = await manager.branchFromTurn(session.id, { + sourceTurnId: 'source', + name: 'Child', + }); + await store.appendMessage(child.id, { + type: 'assistant', + id: 'child-cache-only', + turnId: 'cache-only', + ts: 6_999, + text: 'cache-only child context', + modelId: 'fake-model', }); - // The parent (main-session) backend can read its turn's durable ledger — - // the seam resolves the active run and returns the persisted events. - expect(seamReads.length).toBe(2); - expect(seamReads[0]?.turnId).toBe('parent-turn'); - expect((seamReads[0]?.eventIds.length ?? 0) > 0).toBe(true); - - // Both runs need their authoritative ledger for Runtime-owned continuation. - // A separate capability keeps child-only ledgers from claiming coverage of - // the parent session projection during mid-turn history compaction. - expect(contexts.length).toBe(2); - expect(typeof contexts[0]?.loadTurnRuntimeEvents).toBe('function'); - expect(typeof contexts[1]?.loadTurnRuntimeEvents).toBe('function'); - expect((seamReads[1]?.eventIds.length ?? 0) > 0).toBe(true); - const capabilities = contexts as Array< - BackendFactoryContext & { allowMidTurnHistoryCompaction?: boolean } - >; - expect(capabilities[0]?.allowMidTurnHistoryCompaction).toBe(true); - expect(capabilities[1]?.allowMidTurnHistoryCompaction).toBe(false); + await drain(manager.sendMessage(child.id, { turnId: 'child-next', text: 'child follow-up' })); + + const childInput = backendInstances[1]?.sendInputs[0]; + if (!childInput) throw new Error('child backend input was not recorded'); + expect( + childInput.context.some( + (message) => + message.type === 'user' && message.turnId === 'source' && message.text === 'branch seed', + ), + ).toBe(true); + expect( + childInput.context.some( + (message) => message.type === 'assistant' && message.id === 'child-cache-only', + ), + ).toBe(false); + expect(childInput.runtimeContext?.map((event) => event.turnId)).toEqual([ + 'source', + 'source', + 'source', + ]); + expect(childInput.runtimeContext?.[0]?.sessionId).toBe(child.id); }); - test('resolves the child execution toolset at activation time', async () => { + test('branchFromTurn removes an incomplete target when Runtime ledger copy fails', async () => { const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - const contexts: BackendFactoryContext[] = []; - backends.register('ai-sdk', (ctx) => { - contexts.push(ctx); - return new TestBackend(ctx); - }); - let resolutions = 0; - const manager = new SessionManager({ + const runStore = new MemoryAgentRunStore({ failRuntimeEventAppendAfter: 5 }); + const manager = makeManagerForReadCutover(store, runStore); + const session = await manager.createSession(makeInput({ name: 'Parent' })); + await seedRuntimeReadTurn({ store, runStore, - runtimeEventStore: runStore, - backends, - resolveChildTools: async () => { - resolutions += 1; - return { - tools: ['Read', 'Glob', 'Grep'].map((name) => ({ - ...testTool(name), - description: `${name} turn-scoped toolset ${resolutions}`, - })), - }; - }, - newId: nextId(), - now: nextNow(6_851), + sessionId: session.id, + turnId: 'source', + runId: 'source-run', + userText: 'runtime branch context', + assistantText: 'runtime branch answer', + legacyIdPrefix: 'legacy', }); - const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); - await drain(manager.sendMessage(session.id, { turnId: 'parent-turn', text: 'parent context' })); - const [parentRun] = await runStore.listSessionRuns(session.id); - if (!parentRun) throw new Error('parent run was not recorded'); - await manager.spawnChildAgent(session.id, { - turnId: 'child-turn', - parentRunId: parentRun.runId, - spec: { id: LOCAL_READ_AGENT_ID, name: 'Reader', systemPrompt: 'read only' }, - prompt: 'inspect', - }); + let branchError: unknown; + try { + await manager.branchFromTurn(session.id, { sourceTurnId: 'source', name: 'Child' }); + } catch (error) { + branchError = error; + } + expect(branchError instanceof Error ? branchError.message : String(branchError)).toContain( + 'runtime event append failed', + ); - assert.equal(resolutions, 1); - assert.match(contexts[1]?.tools?.[0]?.description ?? '', /turn-scoped toolset/); + const child = (await store.list()).find((summary) => summary.parentSessionId === session.id); + expect(child).toBeUndefined(); + const childRuns = await runStore.listSessionRuns('session-2'); + for (const run of childRuns) { + const runtimeEvents = await runStore.readRuntimeEvents('session-2', run.runId); + const hasTerminalFact = runtimeEvents.some(isTerminalRuntimeEvent); + expect( + run.status === 'completed' || run.status === 'failed' || run.status === 'cancelled' + ? hasTerminalFact + : true, + ).toBe(true); + } }); - test('captures child tools and shell plan inside one backend activation snapshot', async () => { + test('multi-run RuntimeEvent projection preserves retry regenerate and branch lineage on turns', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - const contexts: BackendFactoryContext[] = []; - backends.register('ai-sdk', (ctx) => { - contexts.push(ctx); - return new TestBackend(ctx); + const manager = makeManagerForReadCutover(store, runStore); + const session = await manager.createSession(makeInput()); + await seedRuntimeReadTurn({ + store, + runStore, + sessionId: session.id, + turnId: 'root', + runId: 'root-run', + userText: 'root question', + assistantText: 'root answer', + legacyIdPrefix: 'root-legacy', }); - const resolutionStarted = makeGate(); - const releaseResolution = makeGate(); - const { runBackendActivation, runMutation } = makeTestRuntimePolicyGate(); - let shellRevision = 'A'; - let mutationApplied = false; - const manager = new SessionManager({ + await seedRuntimeReadTurnWithHeader({ store, runStore, - runtimeEventStore: runStore, - backends, - resolveChildTools: async () => { - const capturedRevision = shellRevision; - resolutionStarted.release(); - await releaseResolution.promise; - return { - tools: ['Read', 'Glob', 'Grep'].map((name) => ({ - ...testTool(name), - description: `${name} shell snapshot ${capturedRevision}`, - })), - shell: { - plan: { kind: 'posix', displayName: `shell ${capturedRevision}` }, - }, - }; - }, - runBackendActivation, - newId: nextId(), - now: nextNow(6_852), + sessionId: session.id, + turnId: 'retry', + runId: 'retry-run', + userText: 'retry question', + assistantText: 'retry answer', + legacyIdPrefix: 'retry-legacy', + header: { parentTurnId: 'root', retriedFromTurnId: 'root' }, + tsBase: 200, }); - const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); - await drain(manager.sendMessage(session.id, { turnId: 'parent-turn', text: 'parent context' })); - const [parentRun] = await runStore.listSessionRuns(session.id); - if (!parentRun) throw new Error('parent run was not recorded'); + await seedRuntimeReadTurnWithHeader({ + store, + runStore, + sessionId: session.id, + turnId: 'regen', + runId: 'regen-run', + userText: 'regen question', + assistantText: 'regen answer', + legacyIdPrefix: 'regen-legacy', + header: { parentTurnId: 'root', regeneratedFromTurnId: 'root' }, + tsBase: 300, + }); + await seedRuntimeReadTurnWithHeader({ + store, + runStore, + sessionId: session.id, + turnId: 'branch', + runId: 'branch-run', + userText: 'branch question', + assistantText: 'branch answer', + legacyIdPrefix: 'branch-legacy', + header: { parentSessionId: 'parent-session', branchOfTurnId: 'root' }, + tsBase: 400, + }); + store.failNextReadMessagesFor.set(session.id, 1); - const childTurn = manager.spawnChildAgent(session.id, { - turnId: 'child-turn', - parentRunId: parentRun.runId, - spec: { id: LOCAL_READ_AGENT_ID, name: 'Reader', systemPrompt: 'read only' }, - prompt: 'inspect', + const turns = await manager.listTurns(session.id); + + expect(turns.find((turn) => turn.turnId === 'retry')).toMatchObject({ + status: 'completed', + parentTurnId: 'root', + retriedFromTurnId: 'root', }); - await resolutionStarted.promise; - const mutation = runMutation(async () => { - shellRevision = 'B'; - mutationApplied = true; + expect(turns.find((turn) => turn.turnId === 'regen')).toMatchObject({ + status: 'completed', + parentTurnId: 'root', + regeneratedFromTurnId: 'root', + }); + expect(turns.find((turn) => turn.turnId === 'branch')).toMatchObject({ + status: 'completed', + parentSessionId: 'parent-session', + branchOfTurnId: 'root', }); - await new Promise((resolve) => setImmediate(resolve)); - expect(mutationApplied).toBe(false); - - releaseResolution.release(); - await childTurn; - await mutation; - - expect(contexts[1]?.turnShellPlan?.plan.displayName).toBe('shell A'); - expect(contexts[1]?.tools?.[0]?.description).toMatch(/shell snapshot A/); - expect(shellRevision).toBe('B'); }); - test('spawnChildAgent returns the terminal RuntimeEvent status when the child header commit fails', async () => { + test('next turn projects failed prior RuntimeEvents with the terminal fact failure class', async () => { const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore({ failUpdateRunStatusOnce: 'completed' }); + const runStore = new MemoryAgentRunStore(); const backends = new BackendRegistry(); - backends.register('ai-sdk', (ctx) => new TestBackend(ctx)); + const backendInstances: TestBackend[] = []; + backends.register('ai-sdk', (ctx) => { + const backend = new TestBackend(ctx); + backendInstances.push(backend); + return backend; + }); const manager = new SessionManager({ store, runStore, runtimeEventStore: runStore, backends, - childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], newId: nextId(), - now: nextNow(6_843), - }); - const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); - const parentRun = makeRunHeader({ - sessionId: session.id, - runId: 'parent-run', - turnId: 'parent-turn', - status: 'completed', - createdAt: 100, - updatedAt: 110, - completedAt: 110, + now: nextNow(6_810), }); - await seedRuntimeRun(runStore, parentRun, [ - runtimeEvent({ - id: 'parent-complete', + const session = await manager.createSession(makeInput()); + await seedRuntimeRun( + runStore, + makeRunHeader({ sessionId: session.id, - runId: parentRun.runId, - turnId: parentRun.turnId, - ts: 110, - status: 'completed', - actions: { endInvocation: true }, + runId: 'run-1', + turnId: 'turn-1', + status: 'running', + createdAt: 101, + updatedAt: 103, }), - ]); + [ + runtimeEvent({ + id: 'rt-user', + sessionId: session.id, + runId: 'run-1', + turnId: 'turn-1', + ts: 101, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'first' }, + }), + runtimeEvent({ + id: 'rt-assistant', + sessionId: session.id, + runId: 'run-1', + turnId: 'turn-1', + ts: 102, + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'before failure' }, + }), + runtimeEvent({ + id: 'rt-failed', + sessionId: session.id, + runId: 'run-1', + turnId: 'turn-1', + ts: 103, + role: 'system', + author: 'system', + status: 'failed', + actions: { endInvocation: true, stateDelta: { failureClass: 'tool_failed' } }, + }), + ], + ); - const result = await manager.spawnChildAgent(session.id, { - turnId: 'child-turn', - parentRunId: parentRun.runId, - spec: { id: LOCAL_READ_AGENT_ID, name: 'Researcher', systemPrompt: 'read only' }, - prompt: 'inspect', - }); + await drain(manager.sendMessage(session.id, { turnId: 'turn-2', text: 'second' })); - expect(result.status).toBe('completed'); - const childRun = (await runStore.listSessionRuns(session.id)).find( - (run) => run.turnId === 'child-turn', + const secondInput = backendInstances[0]?.sendInputs[0]; + if (!secondInput) throw new Error('backend input was not recorded'); + expect(secondInput.runtimeContext?.map((event) => event.turnId)).toEqual([ + 'turn-1', + 'turn-1', + 'turn-1', + ]); + const turnState = secondInput.context.find( + (message) => message.type === 'turn_state' && message.turnId === 'turn-1', ); - expect(childRun?.status).toBe('running'); - const childTerminalEvents = ( - await runStore.readRuntimeEvents(session.id, childRun!.runId) - ).filter(isTerminalRuntimeEvent); - expect(childTerminalEvents).toHaveLength(1); + if (turnState?.type !== 'turn_state') + throw new Error('prior failed turn_state was not projected'); + expect(turnState.status).toBe('failed'); + expect(turnState.errorClass).toBe('tool_failed'); }); - test('spawnChildAgent preserves step-limit failure without a run store', async () => { + test('next turn uses failed terminal RuntimeEvents when failed header commit was interrupted', async () => { const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore({ failUpdateRunStatusOnce: 'failed' }); const backends = new BackendRegistry(); - backends.register( - 'ai-sdk', - (ctx) => new EventBackend(ctx, [{ type: 'complete', stopReason: 'step_limit' }]), - ); - const manager = new SessionManager({ - store, - backends, - childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], - newId: nextId(), - now: nextNow(6_844), - }); - const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); - - const result = await manager.spawnChildAgent(session.id, { - turnId: 'child-turn', - parentRunId: 'parent-run', - spec: { id: LOCAL_READ_AGENT_ID, name: 'Researcher', systemPrompt: 'read only' }, - prompt: 'inspect', + let backend: TurnScriptBackend | undefined; + backends.register('ai-sdk', (ctx) => { + backend = new TurnScriptBackend(ctx, [ + [{ type: 'complete', stopReason: 'error' }], + [ + { type: 'text_delta', messageId: 'm2', text: 'second ok' }, + { type: 'complete', stopReason: 'end_turn' }, + ], + ]); + return backend; }); - - expect(result.status).toBe('failed'); - expect(result.failureClass).toBe('tool_step_cap_reached'); - }); - - test('spawnChildAgent summarizes high-volume child output without returning the full stream', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - backends.register('ai-sdk', (ctx) => new HighVolumeDeltaBackend(ctx, 512)); const manager = new SessionManager({ store, runStore, runtimeEventStore: runStore, backends, - childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], newId: nextId(), - now: nextNow(6_844), + now: nextNow(6_812), }); - const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); - await drain(manager.sendMessage(session.id, { turnId: 'parent-turn', text: 'parent context' })); - const [parentRun] = await runStore.listSessionRuns(session.id); - if (!parentRun) throw new Error('parent run was not recorded'); + const session = await manager.createSession(makeInput()); - const observed: SessionEvent[] = []; - const result = await manager.spawnChildAgent(session.id, { - turnId: 'child-turn', - parentRunId: parentRun.runId, - spec: { id: LOCAL_READ_AGENT_ID, name: 'Researcher', systemPrompt: 'read only' }, - prompt: 'produce a large report', - onEvent: (event) => observed.push(event), - }); + await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'first' })); + const [firstRun] = await runStore.listSessionRuns(session.id); + if (!firstRun) throw new Error('first run was not recorded'); + expect(firstRun.status).toBe('running'); + const firstRuntimeEvents = await runStore.readRuntimeEvents(session.id, firstRun.runId); + const firstTerminalEvents = firstRuntimeEvents.filter(isTerminalRuntimeEvent); + expect(firstTerminalEvents).toHaveLength(1); + expect(firstTerminalEvents[0]?.status).toBe('failed'); + expect(firstTerminalEvents[0]?.actions?.stateDelta?.failureClass).toBe('runtime_error'); - expect(result.status).toBe('completed'); - expect(result.eventCount).toBe(513); - expect(result.summary.length <= 4_000).toBe(true); - expect(result.summary.startsWith('…')).toBe(true); - expect(result.summary.includes('chunk-000')).toBe(false); - expect(result.summary.includes('chunk-511')).toBe(true); - expect(observed).toHaveLength(513); - expect(observed[0]?.type).toBe('text_delta'); - expect(observed.at(-1)?.type).toBe('complete'); + await drain(manager.sendMessage(session.id, { turnId: 'turn-2', text: 'second' })); + + const secondInput = backend?.sendInputs[1]; + if (!secondInput) throw new Error('second backend input was not recorded'); + expect(secondInput.runtimeContext?.map((event) => event.turnId)).toEqual(['turn-1', 'turn-1']); + const turnState = secondInput.context.find( + (message) => message.type === 'turn_state' && message.turnId === 'turn-1', + ); + if (turnState?.type !== 'turn_state') + throw new Error('prior failed turn_state was not projected'); + expect(turnState.status).toBe('failed'); + expect(turnState.errorClass).toBe('runtime_error'); + const terminalEventsAfterSecondTurn = ( + await runStore.readRuntimeEvents(session.id, firstRun.runId) + ).filter(isTerminalRuntimeEvent); + expect(terminalEventsAfterSecondTurn).toHaveLength(1); }); - test('stopSession cancels active child runs and disposes their backend', async () => { + test('next parent turn excludes child run RuntimeEvents from model context', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); const backends = new BackendRegistry(); - const childGate = makeGate(); const backendInstances: TestBackend[] = []; backends.register('ai-sdk', (ctx) => { - const backend = new TestBackend( - ctx, - ctx.header.permissionMode === 'explore' ? childGate : undefined, - ); + const backend = new TestBackend(ctx); backendInstances.push(backend); return backend; }); @@ -10738,91 +9125,77 @@ describe('SessionManager permission mode updates', () => { runStore, runtimeEventStore: runStore, backends, - childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], newId: nextId(), - now: nextNow(6_845), + now: nextNow(6_825), }); - const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); - await drain(manager.sendMessage(session.id, { turnId: 'parent-turn', text: 'parent context' })); + const session = await manager.createSession(makeInput()); + + await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'first' })); const [parentRun] = await runStore.listSessionRuns(session.id); if (!parentRun) throw new Error('parent run was not recorded'); - - const child = manager - .startChildTurn(session.id, { + await seedRuntimeRun( + runStore, + makeRunHeader({ + sessionId: session.id, + runId: 'child-run', turnId: 'child-turn', + status: 'completed', + createdAt: parentRun.updatedAt + 1, + updatedAt: parentRun.updatedAt + 4, + completedAt: parentRun.updatedAt + 4, parentRunId: parentRun.runId, - spec: { id: LOCAL_READ_AGENT_ID, name: 'Researcher', systemPrompt: 'read only' }, - prompt: 'inspect slowly', - }) - [Symbol.asyncIterator](); - await child.next(); - - await manager.stopSession(session.id, { source: 'stop_button' }); - expect(backendInstances[0]?.stopCalls).toBe(0); - expect(backendInstances[1]?.stopCalls).toBe(1); - childGate.release(); - await child.next(); - await child.next(); - - const childRun = (await runStore.listSessionRuns(session.id)).find( - (run) => run.turnId === 'child-turn', + agentName: 'Researcher', + }), + [ + runtimeEvent({ + id: 'child-user', + sessionId: session.id, + runId: 'child-run', + turnId: 'child-turn', + ts: parentRun.updatedAt + 2, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'child prompt' }, + }), + runtimeEvent({ + id: 'child-assistant', + sessionId: session.id, + runId: 'child-run', + turnId: 'child-turn', + ts: parentRun.updatedAt + 3, + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'child private answer' }, + }), + runtimeEvent({ + id: 'child-complete', + sessionId: session.id, + runId: 'child-run', + turnId: 'child-turn', + ts: parentRun.updatedAt + 4, + role: 'system', + author: 'system', + status: 'completed', + actions: { endInvocation: true }, + }), + ], ); - if (!childRun) throw new Error('child run was not recorded'); - expect(childRun.status).toBe('cancelled'); - expect(store.disposeCount).toBe(1); - await manager.setPermissionMode(session.id, 'bypass'); - expect((await store.readHeader(session.id)).permissionMode).toBe('bypass'); - }); - - test('stopSession waits for a child start blocked before Run reservation', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - let childBackend: TestBackend | undefined; - backends.register('ai-sdk', (ctx) => { - childBackend = new TestBackend(ctx); - return childBackend; - }); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], - newId: nextId(), - now: nextNow(6_845), - }); - const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); - const readStarted = makeGate(); - const releaseRead = makeGate(); - store.nextReadHeaderGate = { started: readStarted, release: releaseRead }; - const child = manager - .startChildTurn(session.id, { - turnId: 'pending-child-turn', - parentRunId: 'parent-run', - spec: { id: LOCAL_READ_AGENT_ID, name: 'Researcher', systemPrompt: 'read only' }, - prompt: 'must not dispatch after stop', - }) - [Symbol.asyncIterator](); - const firstEvent = child.next(); - await readStarted.promise; - let stopSettled = false; - const stop = manager.stopSession(session.id, { source: 'stop_button' }).finally(() => { - stopSettled = true; - }); - await Promise.resolve(); - await Promise.resolve(); - expect(stopSettled).toBe(false); + await drain(manager.sendMessage(session.id, { turnId: 'turn-2', text: 'second' })); - releaseRead.release(); - await stop; - expect((await firstEvent).done).toBe(true); - expect(childBackend?.sendInputs).toEqual([]); - const childRun = (await runStore.listSessionRuns(session.id)).find( - (run) => run.turnId === 'pending-child-turn', - ); - expect(childRun?.status).toBe('cancelled'); + const secondInput = backendInstances[0]?.sendInputs[1]; + if (!secondInput) throw new Error('second backend input was not recorded'); + expect(secondInput.runtimeContext?.map((event) => event.turnId)).toEqual([ + 'turn-1', + 'turn-1', + 'turn-1', + ]); + expect(secondInput.runtimeContext?.some((event) => event.turnId === 'child-turn')).toBe(false); + expect( + secondInput.context.some( + (message) => message.type === 'user' && message.turnId === 'child-turn', + ), + ).toBe(false); }); test('stopSession owns an active Run and a parent turn admitted before reservation', async () => { @@ -10880,57 +9253,6 @@ describe('SessionManager permission mode updates', () => { expect((await store.readHeader(session.id)).status).toBe('aborted'); }); - test('public child spawn is stop-visible while async onReady is pending', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - let backend: TestBackend | undefined; - backends.register('ai-sdk', (ctx) => { - backend = new TestBackend(ctx); - return backend; - }); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], - newId: nextId(), - now: nextNow(6_845), - }); - const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); - const readyStarted = makeGate(); - const releaseReady = makeGate(); - const spawned = manager.spawnChildAgent(session.id, { - turnId: 'child-on-ready-stop', - parentRunId: 'parent-run', - spec: { id: LOCAL_READ_AGENT_ID, name: 'Researcher', systemPrompt: 'read only' }, - prompt: 'must not reach the provider', - onReady: async () => { - readyStarted.release(); - await releaseReady.promise; - }, - }); - await readyStarted.promise; - - let stopSettled = false; - const stopping = manager.stopSession(session.id, { source: 'stop_button' }).finally(() => { - stopSettled = true; - }); - await Promise.resolve(); - expect(stopSettled).toBe(false); - - releaseReady.release(); - const [result] = await Promise.all([spawned, stopping]); - expect(result.status).toBe('cancelled'); - expect(backend?.sendInputs).toEqual([]); - const run = (await runStore.listSessionRuns(session.id)).find( - (candidate) => candidate.turnId === 'child-on-ready-stop', - ); - expect(run?.status).toBe('cancelled'); - expect((await store.readHeader(session.id)).status === 'blocked').toBe(false); - }); - test('a factory AbortError is not normalized as execution cancellation', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); @@ -11206,57 +9528,6 @@ describe('SessionManager permission mode updates', () => { expect(disposeCalls).toBe(1); }); - test('stopSession keeps a rejected backend generation quarantined across retries', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - const childGate = makeGate(); - let childBackend: RetryStopBackend | undefined; - backends.register('ai-sdk', (ctx) => { - if (ctx.header.permissionMode !== 'explore') return new TestBackend(ctx); - childBackend = new RetryStopBackend(ctx, childGate); - return childBackend; - }); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], - newId: nextId(), - now: nextNow(6_846), - }); - const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); - await drain(manager.sendMessage(session.id, { turnId: 'parent-turn', text: 'parent context' })); - const [parentRun] = await runStore.listSessionRuns(session.id); - if (!parentRun) throw new Error('parent run was not recorded'); - const child = manager - .startChildTurn(session.id, { - turnId: 'child-turn', - parentRunId: parentRun.runId, - spec: { id: LOCAL_READ_AGENT_ID, name: 'Researcher', systemPrompt: 'read only' }, - prompt: 'inspect slowly', - }) - [Symbol.asyncIterator](); - await child.next(); - - await expectRejects( - manager.stopSession(session.id, { source: 'stop_button' }), - /first stop failed/, - ); - await expectRejects( - manager.stopSession(session.id, { source: 'stop_button' }), - /first stop failed/, - ); - expect(childBackend?.stopCalls).toBe(1); - childGate.release(); - while (!(await child.next()).done) {} - const childRun = (await runStore.listSessionRuns(session.id)).find( - (run) => run.turnId === 'child-turn', - ); - expect(childRun?.status).toBe('cancelled'); - }); - test('concurrent stopSession calls share one stop attempt', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); @@ -11469,117 +9740,6 @@ describe('SessionManager permission mode updates', () => { expect(backend?.sendInputs).toHaveLength(2); }); - test('routes a sandbox boundary response only to the generation that emitted its request', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - const childGate = makeGate(); - let parentBackend: SandboxBoundaryWaitBackend | undefined; - let childBackend: PermissionBroadcastBackend | undefined; - backends.register('ai-sdk', (ctx) => { - if (ctx.header.permissionMode === 'explore') { - childBackend = new PermissionBroadcastBackend(ctx, childGate); - return childBackend; - } - parentBackend = new SandboxBoundaryWaitBackend(ctx); - return parentBackend; - }); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], - newId: nextId(), - now: nextNow(6_848), - }); - const session = await manager.createSession(makeInput()); - const parent = manager - .sendMessage(session.id, { turnId: 'parent-boundary-turn', text: 'parent' }) - [Symbol.asyncIterator](); - expect((await parent.next()).value?.type).toBe('sandbox_boundary_request'); - - const child = manager - .startChildTurn(session.id, { - turnId: 'child-running-turn', - parentRunId: 'parent-run', - spec: { id: LOCAL_READ_AGENT_ID, name: 'Researcher', systemPrompt: 'read only' }, - prompt: 'stay active', - }) - [Symbol.asyncIterator](); - expect((await child.next()).value?.type).toBe('text_delta'); - - await manager.respondToSandboxBoundary(session.id, { - requestId: 'boundary-1', - decision: 'deny', - }); - expect(parentBackend?.responses).toEqual([{ requestId: 'boundary-1', decision: 'deny' }]); - expect(childBackend?.permissionResponses).toBe(0); - - while (!(await parent.next()).done) {} - childGate.release(); - while (!(await child.next()).done) {} - }); - - test('stopSession does not reenter a failed backend in a multi-generation stop', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - const parentGate = makeGate(); - const childGate = makeGate(); - let parentBackend: CountingStopBackend | undefined; - let childBackend: RetryStopBackend | undefined; - backends.register('ai-sdk', (ctx) => { - if (ctx.header.permissionMode === 'explore') { - childBackend = new RetryStopBackend(ctx, childGate); - return childBackend; - } - parentBackend = new CountingStopBackend(ctx, parentGate); - return parentBackend; - }); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], - newId: nextId(), - now: nextNow(6_848), - }); - const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); - const parent = manager - .sendMessage(session.id, { turnId: 'parent-turn', text: 'parent' }) - [Symbol.asyncIterator](); - await parent.next(); - const [parentRun] = await runStore.listSessionRuns(session.id); - if (!parentRun) throw new Error('parent run was not recorded'); - const child = manager - .startChildTurn(session.id, { - turnId: 'child-turn', - parentRunId: parentRun.runId, - spec: { id: LOCAL_READ_AGENT_ID, name: 'Researcher', systemPrompt: 'read only' }, - prompt: 'child', - }) - [Symbol.asyncIterator](); - await child.next(); - - await expectRejects( - manager.stopSession(session.id, { source: 'stop_button' }), - /first stop failed/, - ); - await expectRejects( - manager.stopSession(session.id, { source: 'stop_button' }), - /first stop failed/, - ); - - expect(parentBackend?.stopCalls).toBe(1); - expect(childBackend?.stopCalls).toBe(1); - parentGate.release(); - childGate.release(); - while (!(await parent.next()).done) {} - while (!(await child.next()).done) {} - }); - test('stopSession retries only unfinished projections', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); @@ -11679,68 +9839,6 @@ describe('SessionManager permission mode updates', () => { ).toHaveLength(1); }); - test('spawnChildAgent fails closed instead of running a degraded catalog agent', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - const backendInstances: TestBackend[] = []; - backends.register('ai-sdk', (ctx) => { - const backend = new TestBackend(ctx); - backendInstances.push(backend); - return backend; - }); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - childTools: [testTool('Read')], - newId: nextId(), - now: nextNow(6_847), - }); - const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); - await drain(manager.sendMessage(session.id, { turnId: 'parent-turn', text: 'parent context' })); - const [parentRun] = await runStore.listSessionRuns(session.id); - if (!parentRun) throw new Error('parent run was not recorded'); - - await expectRejects( - manager.spawnChildAgent(session.id, { - turnId: 'child-turn', - parentRunId: parentRun.runId, - spec: { - id: LOCAL_READ_AGENT_ID, - name: LOCAL_READ_AGENT_DEFINITION.name, - systemPrompt: LOCAL_READ_AGENT_DEFINITION.systemPrompt, - }, - prompt: 'inspect', - }), - /Agent "local-read" is unavailable: missing tools: Glob, Grep/, - ); - - expect(backendInstances).toHaveLength(1); - expect( - (await runStore.listSessionRuns(session.id)).some((run) => run.turnId === 'child-turn'), - ).toBe(false); - - await expectRejects( - manager.spawnChildAgent(session.id, { - turnId: 'web-child-turn', - parentRunId: parentRun.runId, - spec: { - id: WEB_RESEARCH_AGENT_ID, - name: WEB_RESEARCH_AGENT_DEFINITION.name, - systemPrompt: WEB_RESEARCH_AGENT_DEFINITION.systemPrompt, - }, - prompt: 'search', - }), - /Agent "web-research" is unavailable: missing tools: WebSearch/, - ); - expect(backendInstances).toHaveLength(1); - expect( - (await runStore.listSessionRuns(session.id)).some((run) => run.turnId === 'web-child-turn'), - ).toBe(false); - }); - test('agent projections list catalog definitions separately from child runs and read output artifacts by child turn', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); @@ -13071,135 +11169,34 @@ describe('SessionManager permission mode updates', () => { runtimeEvent({ id: 'cleanup-child-text', invocationId: 'cleanup-child-invocation', - sessionId: session.id, - runId: 'cleanup-child-run', - turnId: 'cleanup-child-turn', - ts: 106, - role: 'model', - author: 'agent', - content: { kind: 'text', text: 'private child output' }, - }), - runtimeEvent({ - id: 'cleanup-child-complete', - invocationId: 'cleanup-child-invocation', - sessionId: session.id, - runId: 'cleanup-child-run', - turnId: 'cleanup-child-turn', - ts: 107, - status: 'completed', - actions: { endInvocation: true }, - }), - ], - ); - - await drain( - manager.sendMessage(session.id, { turnId: 'cleanup-current-turn', text: 'continue' }), - ); - await cleanupCalled.promise; - - expect(observedEventIds).toContain('cleanup-continuation-text'); - expect(observedEventIds).not.toContain('cleanup-child-text'); - }); - - test('shares the latest history compact checkpoint across disposable child backends', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const observedCheckpointIds: Array = []; - const backends = new BackendRegistry(); - backends.register( - 'ai-sdk', - (ctx) => new HistoryCompactCheckpointCacheProbeBackend(ctx, observedCheckpointIds), - ); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], - newId: nextId(), - now: nextNow(12_795), - }); - const session = await manager.createSession(makeInput()); - - await drain(manager.sendMessage(session.id, { turnId: 'parent-turn', text: 'hello' })); - const parentRun = (await runStore.listSessionRuns(session.id)).find( - (run) => run.turnId === 'parent-turn', - ); - if (!parentRun) throw new Error('parent run was not recorded'); - runStore.readEventsCalls = 0; - - for (const turnId of ['child-turn-1', 'child-turn-2']) { - await drain( - manager.startChildTurn(session.id, { - turnId, - parentRunId: parentRun.runId, - spec: { - id: LOCAL_READ_AGENT_ID, - name: LOCAL_READ_AGENT_DEFINITION.name, - systemPrompt: LOCAL_READ_AGENT_DEFINITION.systemPrompt, - }, - prompt: 'inspect the repo', + sessionId: session.id, + runId: 'cleanup-child-run', + turnId: 'cleanup-child-turn', + ts: 106, + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'private child output' }, }), - ); - } - - expect(observedCheckpointIds).toEqual([undefined, 'hcheckpoint-shared', 'hcheckpoint-shared']); - expect(runStore.readEventsCalls).toBe(0); - }); - - test('does not let a stale child checkpoint move the session cache backward', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const observedCoverage: Array = []; - const backends = new BackendRegistry(); - backends.register( - 'ai-sdk', - (ctx) => new HistoryCompactCheckpointMonotonicProbeBackend(ctx, observedCoverage), + runtimeEvent({ + id: 'cleanup-child-complete', + invocationId: 'cleanup-child-invocation', + sessionId: session.id, + runId: 'cleanup-child-run', + turnId: 'cleanup-child-turn', + ts: 107, + status: 'completed', + actions: { endInvocation: true }, + }), + ], ); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], - newId: nextId(), - now: nextNow(12_796), - }); - const session = await manager.createSession(makeInput()); - await drain(manager.sendMessage(session.id, { turnId: 'parent-furthest', text: 'hello' })); - const parentRun = (await runStore.listSessionRuns(session.id)).find( - (run) => run.turnId === 'parent-furthest', + await drain( + manager.sendMessage(session.id, { turnId: 'cleanup-current-turn', text: 'continue' }), ); - if (!parentRun) throw new Error('parent run was not recorded'); - - for (const turnId of ['child-stale', 'child-observe']) { - await drain( - manager.startChildTurn(session.id, { - turnId, - parentRunId: parentRun.runId, - spec: { - id: LOCAL_READ_AGENT_ID, - name: LOCAL_READ_AGENT_DEFINITION.name, - systemPrompt: LOCAL_READ_AGENT_DEFINITION.systemPrompt, - }, - prompt: 'inspect the repo', - }), - ); - } + await cleanupCalled.promise; - expect(observedCoverage).toEqual([undefined, 2, 2]); - const recordedCoverage: number[] = []; - for (const run of await runStore.listSessionRuns(session.id)) { - for (const event of await runStore.readEvents(session.id, run.runId)) { - if (event.type === 'history_compact_checkpoint_recorded') { - recordedCoverage.push( - (event.data!.checkpoint as HistoryCompactCheckpoint).coverage.eventCount, - ); - } - } - } - expect(recordedCoverage).toEqual([2]); + expect(observedEventIds).toContain('cleanup-continuation-text'); + expect(observedEventIds).not.toContain('cleanup-child-text'); }); test('persists an explicit same-coverage successor checkpoint', async () => { @@ -13245,155 +11242,6 @@ describe('SessionManager permission mode updates', () => { expect(checkpoints[1]?.coverage).toEqual(checkpoints[0]?.coverage); }); - test('serializes accepted checkpoint persistence across parent and child runs', async () => { - const store = new MemorySessionStore(); - const parentWriteGate = makeGate(); - const parentWriteStarted = makeGate(); - const childRecorderCalled = makeGate(); - const physicalCoverage: number[] = []; - const recorderReturnedPromises: boolean[] = []; - const runStore = new MemoryAgentRunStore({ - beforeAgentRunEventAppend: async (_sessionId, _runId, event) => { - if (event.type !== 'history_compact_checkpoint_recorded') return; - const coverage = (event.data!.checkpoint as HistoryCompactCheckpoint).coverage.eventCount; - if (coverage === 1) { - parentWriteStarted.release(); - await parentWriteGate.promise; - } - physicalCoverage.push(coverage); - }, - }); - const backends = new BackendRegistry(); - backends.register( - 'ai-sdk', - (ctx) => - new SerializedCheckpointProbeBackend(ctx, childRecorderCalled, recorderReturnedPromises), - ); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], - newId: nextId(), - now: nextNow(12_797), - }); - const session = await manager.createSession(makeInput()); - - const parentTurn = drain( - manager.sendMessage(session.id, { turnId: 'parent-delayed', text: 'hello' }), - ); - await parentWriteStarted.promise; - const parentRun = (await runStore.listSessionRuns(session.id)).find( - (run) => run.turnId === 'parent-delayed', - ); - if (!parentRun) throw new Error('parent run was not recorded'); - const childTurn = drain( - manager.startChildTurn(session.id, { - turnId: 'child-furthest', - parentRunId: parentRun.runId, - spec: { - id: LOCAL_READ_AGENT_ID, - name: LOCAL_READ_AGENT_DEFINITION.name, - systemPrompt: LOCAL_READ_AGENT_DEFINITION.systemPrompt, - }, - prompt: 'inspect the repo', - }), - ); - await childRecorderCalled.promise; - try { - expect(recorderReturnedPromises).toEqual([true]); - } finally { - parentWriteGate.release(); - } - await Promise.all([parentTurn, childTurn]); - - expect(physicalCoverage).toEqual([1, 2]); - }); - - test('keeps failed checkpoint writes out of the cache and continues the session write queue', async () => { - const store = new MemorySessionStore(); - const parentWriteGate = makeGate(); - const parentWriteStarted = makeGate(); - const childRecorderCalled = makeGate(); - const physicalCoverage: number[] = []; - const observedCoverage: Array = []; - const writeOutcomes: string[] = []; - const runStore = new MemoryAgentRunStore({ - beforeAgentRunEventAppend: async (_sessionId, _runId, event) => { - if (event.type !== 'history_compact_checkpoint_recorded') return; - const coverage = (event.data!.checkpoint as HistoryCompactCheckpoint).coverage.eventCount; - physicalCoverage.push(coverage); - if (coverage === 1) { - parentWriteStarted.release(); - await parentWriteGate.promise; - throw new Error('checkpoint append failed'); - } - }, - }); - const backends = new BackendRegistry(); - backends.register( - 'ai-sdk', - (ctx) => - new RecoveringCheckpointWriteProbeBackend( - ctx, - childRecorderCalled, - observedCoverage, - writeOutcomes, - ), - ); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], - newId: nextId(), - now: nextNow(12_798), - }); - const session = await manager.createSession(makeInput()); - - const parentTurn = drain( - manager.sendMessage(session.id, { turnId: 'parent-failing', text: 'hello' }), - ); - await parentWriteStarted.promise; - const parentRun = (await runStore.listSessionRuns(session.id)).find( - (run) => run.turnId === 'parent-failing', - ); - if (!parentRun) throw new Error('parent run was not recorded'); - const childTurn = drain( - manager.startChildTurn(session.id, { - turnId: 'child-succeeding', - parentRunId: parentRun.runId, - spec: { - id: LOCAL_READ_AGENT_ID, - name: LOCAL_READ_AGENT_DEFINITION.name, - systemPrompt: LOCAL_READ_AGENT_DEFINITION.systemPrompt, - }, - prompt: 'inspect the repo', - }), - ); - await childRecorderCalled.promise; - parentWriteGate.release(); - await Promise.all([parentTurn, childTurn]); - await drain( - manager.startChildTurn(session.id, { - turnId: 'child-observe', - parentRunId: parentRun.runId, - spec: { - id: LOCAL_READ_AGENT_ID, - name: LOCAL_READ_AGENT_DEFINITION.name, - systemPrompt: LOCAL_READ_AGENT_DEFINITION.systemPrompt, - }, - prompt: 'inspect the repo', - }), - ); - - expect(observedCoverage).toEqual([undefined, undefined, 2]); - expect(writeOutcomes).toEqual(['parent-failing:rejected', 'child-succeeding:fulfilled']); - expect(physicalCoverage).toEqual([1, 2]); - }); - test('rejects checkpoint recording after the current AgentRun store becomes unavailable', async () => { const store = new MemorySessionStore(); const runStoreUnavailable = makeGate(); @@ -13421,198 +11269,12 @@ describe('SessionManager permission mode updates', () => { backends, newId: nextId(), now: nextNow(12_800), - }); - const session = await manager.createSession(makeInput()); - - await drain(manager.sendMessage(session.id, { turnId: 'store-unavailable', text: 'hello' })); - - expect(writeOutcomes).toEqual(['store-unavailable:rejected']); - }); - - test('rejects a stale checkpoint candidate while a further checkpoint is in flight', async () => { - const store = new MemorySessionStore(); - const furthestWriteGate = makeGate(); - const furthestWriteStarted = makeGate(); - const staleRecorderCalled = makeGate(); - const writeOutcomes: string[] = []; - const physicalCoverage: number[] = []; - const runStore = new MemoryAgentRunStore({ - beforeAgentRunEventAppend: async (_sessionId, _runId, event) => { - if (event.type !== 'history_compact_checkpoint_recorded') return; - const coverage = (event.data!.checkpoint as HistoryCompactCheckpoint).coverage.eventCount; - physicalCoverage.push(coverage); - if (coverage === 2) { - furthestWriteStarted.release(); - await furthestWriteGate.promise; - } - }, - }); - const backends = new BackendRegistry(); - backends.register( - 'ai-sdk', - (ctx) => - new CheckpointRecorderContractProbeBackend( - ctx, - async (turnId) => { - if (turnId === 'child-stale-in-flight') staleRecorderCalled.release(); - }, - writeOutcomes, - ), - ); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], - newId: nextId(), - now: nextNow(12_801), - }); - const session = await manager.createSession(makeInput()); - - const parentTurn = drain( - manager.sendMessage(session.id, { turnId: 'parent-furthest-in-flight', text: 'hello' }), - ); - await furthestWriteStarted.promise; - const parentRun = (await runStore.listSessionRuns(session.id)).find( - (run) => run.turnId === 'parent-furthest-in-flight', - ); - if (!parentRun) throw new Error('parent run was not recorded'); - const childTurn = drain( - manager.startChildTurn(session.id, { - turnId: 'child-stale-in-flight', - parentRunId: parentRun.runId, - spec: { - id: LOCAL_READ_AGENT_ID, - name: LOCAL_READ_AGENT_DEFINITION.name, - systemPrompt: LOCAL_READ_AGENT_DEFINITION.systemPrompt, - }, - prompt: 'inspect the repo', - }), - ); - await staleRecorderCalled.promise; - furthestWriteGate.release(); - await Promise.all([parentTurn, childTurn]); - - expect(writeOutcomes).toEqual([ - 'parent-furthest-in-flight:fulfilled', - 'child-stale-in-flight:rejected', - ]); - expect(physicalCoverage).toEqual([2]); - }); - - test('waits for the initial durable checkpoint load before accepting a write', async () => { - const store = new MemorySessionStore(); - const initialLoadGate = makeGate(); - const initialLoadStarted = makeGate(); - const staleRecorderCalled = makeGate(); - const observedCoverage: Array = []; - const writeOutcomes: string[] = []; - const runStore = new MemoryAgentRunStore({ - beforeAgentRunEventRead: async (_sessionId, runId) => { - if (runId !== 'seed-checkpoint-run') return; - initialLoadStarted.release(); - await initialLoadGate.promise; - }, - }); - const backends = new BackendRegistry(); - backends.register( - 'ai-sdk', - (ctx) => - new InitialCheckpointLoadRaceProbeBackend( - ctx, - staleRecorderCalled, - observedCoverage, - writeOutcomes, - ), - ); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], - newId: nextId(), - now: nextNow(12_802), - }); - const session = await manager.createSession(makeInput()); - const durableCheckpoint = buildHistoryCompactCheckpoint({ - sessionId: session.id, - coveredRuntimeEvents: Array.from( - { length: 10 }, - (_, index): RuntimeEvent => ({ - id: `durable-source-event-${index}`, - sessionId: session.id, - runId: `durable-source-run-${index}`, - turnId: `durable-source-turn-${index}`, - invocationId: `durable-source-invocation-${index}`, - ts: index + 1, - partial: false, - role: 'user', - author: 'user', - content: { kind: 'text', text: `source ${index}` }, - }), - ), - summary: 'durable checkpoint', - summaryFormat: 'legacy_freeform', - }); - await seedRun( - runStore, - makeRunHeader({ - sessionId: session.id, - runId: 'seed-checkpoint-run', - turnId: 'seed-checkpoint-turn', - status: 'completed', - }), - [ - makeRunEvent({ - sessionId: session.id, - runId: 'seed-checkpoint-run', - turnId: 'seed-checkpoint-turn', - type: 'history_compact_checkpoint_recorded', - ts: 1, - data: { checkpoint: durableCheckpoint }, - }), - ], - ); - - const parentTurn = drain( - manager.sendMessage(session.id, { turnId: 'parent-initial-load', text: 'hello' }), - ); - await initialLoadStarted.promise; - const parentRun = (await runStore.listSessionRuns(session.id)).find( - (run) => run.turnId === 'parent-initial-load', - ); - if (!parentRun) throw new Error('parent run was not recorded'); - const childTurn = drain( - manager.startChildTurn(session.id, { - turnId: 'child-stale-during-load', - parentRunId: parentRun.runId, - spec: { - id: LOCAL_READ_AGENT_ID, - name: LOCAL_READ_AGENT_DEFINITION.name, - systemPrompt: LOCAL_READ_AGENT_DEFINITION.systemPrompt, - }, - prompt: 'inspect the repo', - }), - ); - await staleRecorderCalled.promise; - initialLoadGate.release(); - await Promise.all([parentTurn, childTurn]); - - expect(observedCoverage).toEqual([10]); - expect(writeOutcomes).toEqual(['child-stale-during-load:rejected']); - const checkpointCoverage: number[] = []; - for (const run of await runStore.listSessionRuns(session.id)) { - for (const event of await runStore.readEvents(session.id, run.runId)) { - if (event.type === 'history_compact_checkpoint_recorded') { - checkpointCoverage.push( - (event.data!.checkpoint as HistoryCompactCheckpoint).coverage.eventCount, - ); - } - } - } - expect(checkpointCoverage).toEqual([10]); + }); + const session = await manager.createSession(makeInput()); + + await drain(manager.sendMessage(session.id, { turnId: 'store-unavailable', text: 'hello' })); + + expect(writeOutcomes).toEqual(['store-unavailable:rejected']); }); test('recovers a missing projection before rejecting a shorter cold-start checkpoint', async () => { @@ -15028,24 +12690,6 @@ class DelegatingRuntimeKernel implements RuntimeKernelLike { } } - async *startChildTurn( - sessionId: string, - input: Parameters[1], - ): AsyncIterable { - this.starts.push({ - sessionId, - input: { - turnId: input.turnId, - text: input.prompt, - parentRunId: input.parentRunId, - agentName: input.spec.name, - }, - }); - for (const event of this.events) { - yield event; - } - } - async *compactSession( sessionId: string, input: Parameters[1] = {}, @@ -15184,21 +12828,6 @@ class PermissionBroadcastBackend extends TestBackend { } } -class RetryStopBackend extends TestBackend { - constructor( - ctx: BackendFactoryContext, - private readonly stopGate: Gate, - ) { - super(ctx, stopGate); - } - - override async stop(): Promise { - this.stopCalls += 1; - if (this.stopCalls === 1) throw new Error('first stop failed'); - this.stopGate.release(); - } -} - class ConcurrentStopBackend extends TestBackend { constructor( ctx: BackendFactoryContext, @@ -15419,43 +13048,6 @@ function compactHistoryFailOpenResult() { }; } -class HighVolumeDeltaBackend implements AgentBackend { - readonly kind = 'ai-sdk' as const; - readonly sessionId: string; - - constructor( - ctx: BackendFactoryContext, - private readonly chunkCount: number, - ) { - this.sessionId = ctx.sessionId; - } - - async *send(input: BackendSendInput): AsyncIterable { - const messageId = `${input.turnId}-m`; - for (let index = 0; index < this.chunkCount; index += 1) { - yield { - type: 'text_delta', - id: `${input.turnId}-delta-${index}`, - turnId: input.turnId, - ts: index + 1, - messageId, - text: `chunk-${String(index).padStart(3, '0')}:${'x'.repeat(32)}\n`, - }; - } - yield { - type: 'complete', - id: `${input.turnId}-complete`, - turnId: input.turnId, - ts: this.chunkCount + 1, - stopReason: 'end_turn', - }; - } - - async stop(): Promise {} - async respondToSandboxBoundary(_decision: SandboxBoundaryResponse): Promise {} - async dispose(): Promise {} -} - class LateErrorBackend implements AgentBackend { readonly kind = 'ai-sdk' as const; readonly sessionId: string; @@ -16070,117 +13662,6 @@ class HistoryCompactCheckpointBackend implements AgentBackend { async dispose(): Promise {} } -class HistoryCompactCheckpointCacheProbeBackend implements AgentBackend { - readonly kind = 'ai-sdk' as const; - readonly sessionId: string; - - constructor( - private readonly ctx: BackendFactoryContext, - private readonly observedCheckpointIds: Array, - ) { - this.sessionId = ctx.sessionId; - } - - async *send(input: BackendSendInput): AsyncIterable { - const loaded = await this.ctx.loadHistoryCompactCheckpoint?.(); - this.observedCheckpointIds.push(loaded?.checkpointId); - if (input.turnId === 'parent-turn') { - const checkpoint = buildHistoryCompactCheckpoint({ - sessionId: this.sessionId, - coveredRuntimeEvents: [ - { - id: 'shared-source-event', - sessionId: this.sessionId, - runId: 'shared-source-run', - turnId: 'shared-source-turn', - invocationId: 'shared-source-invocation', - ts: 1, - partial: false, - role: 'user', - author: 'user', - content: { kind: 'text', text: 'source' }, - }, - ], - summary: 'share this checkpoint across session backends', - summaryFormat: 'legacy_freeform', - }); - this.ctx.recordHistoryCompactCheckpoint?.( - { ...checkpoint, checkpointId: 'hcheckpoint-shared' }, - input.turnId, - ); - } - yield { - type: 'complete', - id: `${input.turnId}-complete`, - turnId: input.turnId, - ts: 4, - stopReason: 'end_turn', - }; - } - - async stop(): Promise {} - async respondToSandboxBoundary(_decision: SandboxBoundaryResponse): Promise {} - async dispose(): Promise {} -} - -class HistoryCompactCheckpointMonotonicProbeBackend implements AgentBackend { - readonly kind = 'ai-sdk' as const; - readonly sessionId: string; - - constructor( - private readonly ctx: BackendFactoryContext, - private readonly observedCoverage: Array, - ) { - this.sessionId = ctx.sessionId; - } - - async *send(input: BackendSendInput): AsyncIterable { - const loaded = await this.ctx.loadHistoryCompactCheckpoint?.(); - this.observedCoverage.push(loaded?.coverage.eventCount); - const coverage = - input.turnId === 'parent-furthest' ? 2 : input.turnId === 'child-stale' ? 1 : 0; - if (coverage > 0) { - const coveredRuntimeEvents = Array.from( - { length: coverage }, - (_, index): RuntimeEvent => ({ - id: `monotonic-source-event-${index}`, - sessionId: this.sessionId, - runId: `monotonic-source-run-${index}`, - turnId: `monotonic-source-turn-${index}`, - invocationId: `monotonic-source-invocation-${index}`, - ts: index + 1, - partial: false, - role: 'user', - author: 'user', - content: { kind: 'text', text: `source ${index}` }, - }), - ); - await this.ctx - .recordHistoryCompactCheckpoint?.( - buildHistoryCompactCheckpoint({ - sessionId: this.sessionId, - coveredRuntimeEvents, - summary: `${input.turnId} checkpoint`, - summaryFormat: 'legacy_freeform', - }), - input.turnId, - ) - .catch(() => {}); - } - yield { - type: 'complete', - id: `${input.turnId}-complete`, - turnId: input.turnId, - ts: 4, - stopReason: 'end_turn', - }; - } - - async stop(): Promise {} - async respondToSandboxBoundary(_decision: SandboxBoundaryResponse): Promise {} - async dispose(): Promise {} -} - class SameCoverageCheckpointReplacementProbeBackend implements AgentBackend { readonly kind = 'ai-sdk' as const; readonly sessionId: string; @@ -16237,129 +13718,6 @@ class SameCoverageCheckpointReplacementProbeBackend implements AgentBackend { async dispose(): Promise {} } -class SerializedCheckpointProbeBackend implements AgentBackend { - readonly kind = 'ai-sdk' as const; - readonly sessionId: string; - - constructor( - private readonly ctx: BackendFactoryContext, - private readonly childRecorderCalled: Gate, - private readonly recorderReturnedPromises: boolean[], - ) { - this.sessionId = ctx.sessionId; - } - - async *send(input: BackendSendInput): AsyncIterable { - const coverage = input.turnId === 'parent-delayed' ? 1 : 2; - const coveredRuntimeEvents = Array.from( - { length: coverage }, - (_, index): RuntimeEvent => ({ - id: `serialized-source-event-${index}`, - sessionId: this.sessionId, - runId: `serialized-source-run-${index}`, - turnId: `serialized-source-turn-${index}`, - invocationId: `serialized-source-invocation-${index}`, - ts: index + 1, - partial: false, - role: 'user', - author: 'user', - content: { kind: 'text', text: `source ${index}` }, - }), - ); - const write = this.ctx.recordHistoryCompactCheckpoint?.( - buildHistoryCompactCheckpoint({ - sessionId: this.sessionId, - coveredRuntimeEvents, - summary: `${input.turnId} checkpoint`, - summaryFormat: 'legacy_freeform', - }), - input.turnId, - ); - if (input.turnId === 'child-furthest') { - this.recorderReturnedPromises.push( - Boolean(write && typeof (write as PromiseLike).then === 'function'), - ); - this.childRecorderCalled.release(); - } - await write; - yield { - type: 'complete', - id: `${input.turnId}-complete`, - turnId: input.turnId, - ts: 4, - stopReason: 'end_turn', - }; - } - - async stop(): Promise {} - async respondToSandboxBoundary(_decision: SandboxBoundaryResponse): Promise {} - async dispose(): Promise {} -} - -class RecoveringCheckpointWriteProbeBackend implements AgentBackend { - readonly kind = 'ai-sdk' as const; - readonly sessionId: string; - - constructor( - private readonly ctx: BackendFactoryContext, - private readonly childRecorderCalled: Gate, - private readonly observedCoverage: Array, - private readonly writeOutcomes: string[], - ) { - this.sessionId = ctx.sessionId; - } - - async *send(input: BackendSendInput): AsyncIterable { - const loaded = await this.ctx.loadHistoryCompactCheckpoint?.(); - this.observedCoverage.push(loaded?.coverage.eventCount); - if (input.turnId !== 'child-observe') { - const coverage = input.turnId === 'parent-failing' ? 1 : 2; - const coveredRuntimeEvents = Array.from( - { length: coverage }, - (_, index): RuntimeEvent => ({ - id: `recovering-source-event-${index}`, - sessionId: this.sessionId, - runId: `recovering-source-run-${index}`, - turnId: `recovering-source-turn-${index}`, - invocationId: `recovering-source-invocation-${index}`, - ts: index + 1, - partial: false, - role: 'user', - author: 'user', - content: { kind: 'text', text: `source ${index}` }, - }), - ); - const write = this.ctx.recordHistoryCompactCheckpoint?.( - buildHistoryCompactCheckpoint({ - sessionId: this.sessionId, - coveredRuntimeEvents, - summary: `${input.turnId} checkpoint`, - summaryFormat: 'legacy_freeform', - }), - input.turnId, - ); - if (input.turnId === 'child-succeeding') this.childRecorderCalled.release(); - try { - await write; - this.writeOutcomes.push(`${input.turnId}:fulfilled`); - } catch { - this.writeOutcomes.push(`${input.turnId}:rejected`); - } - } - yield { - type: 'complete', - id: `${input.turnId}-complete`, - turnId: input.turnId, - ts: 4, - stopReason: 'end_turn', - }; - } - - async stop(): Promise {} - async respondToSandboxBoundary(_decision: SandboxBoundaryResponse): Promise {} - async dispose(): Promise {} -} - class CheckpointRecorderContractProbeBackend implements AgentBackend { readonly kind = 'ai-sdk' as const; readonly sessionId: string; @@ -16418,70 +13776,6 @@ class CheckpointRecorderContractProbeBackend implements AgentBackend { async dispose(): Promise {} } -class InitialCheckpointLoadRaceProbeBackend implements AgentBackend { - readonly kind = 'ai-sdk' as const; - readonly sessionId: string; - - constructor( - private readonly ctx: BackendFactoryContext, - private readonly staleRecorderCalled: Gate, - private readonly observedCoverage: Array, - private readonly writeOutcomes: string[], - ) { - this.sessionId = ctx.sessionId; - } - - async *send(input: BackendSendInput): AsyncIterable { - if (input.turnId === 'parent-initial-load') { - const loaded = await this.ctx.loadHistoryCompactCheckpoint?.(); - this.observedCoverage.push(loaded?.coverage.eventCount); - } else { - const coveredRuntimeEvents = Array.from( - { length: 5 }, - (_, index): RuntimeEvent => ({ - id: `stale-load-race-event-${index}`, - sessionId: this.sessionId, - runId: `stale-load-race-run-${index}`, - turnId: `stale-load-race-turn-${index}`, - invocationId: `stale-load-race-invocation-${index}`, - ts: index + 1, - partial: false, - role: 'user', - author: 'user', - content: { kind: 'text', text: `source ${index}` }, - }), - ); - const write = this.ctx.recordHistoryCompactCheckpoint?.( - buildHistoryCompactCheckpoint({ - sessionId: this.sessionId, - coveredRuntimeEvents, - summary: 'stale checkpoint during initial load', - summaryFormat: 'legacy_freeform', - }), - input.turnId, - ); - this.staleRecorderCalled.release(); - try { - await write; - this.writeOutcomes.push(`${input.turnId}:fulfilled`); - } catch { - this.writeOutcomes.push(`${input.turnId}:rejected`); - } - } - yield { - type: 'complete', - id: `${input.turnId}-complete`, - turnId: input.turnId, - ts: 4, - stopReason: 'end_turn', - }; - } - - async stop(): Promise {} - async respondToSandboxBoundary(_decision: SandboxBoundaryResponse): Promise {} - async dispose(): Promise {} -} - class MemorySessionStore implements SessionStore { private headers = new Map(); private messages = new Map(); diff --git a/packages/runtime/src/__tests__/subagent-tools.test.ts b/packages/runtime/src/__tests__/subagent-tools.test.ts index 13543be2b1..243ac1557b 100644 --- a/packages/runtime/src/__tests__/subagent-tools.test.ts +++ b/packages/runtime/src/__tests__/subagent-tools.test.ts @@ -529,6 +529,59 @@ describe('subagent tools', () => { expect(output.at(-1)).toBe('Child agent Local Read: completed\n'); }); + test('agent_spawn bounds projected child tool activity by characters', async () => { + const tool = buildSubagentSpawnTool(); + const output: string[] = []; + + await tool.impl( + { + profile: LOCAL_READ_AGENT_PROFILE, + task: 'Inspect verbose tool activity.', + }, + { + sessionId: 'session-1', + turnId: 'parent-turn', + cwd: '/tmp', + toolCallId: 'tool-1', + abortSignal: new AbortController().signal, + emitOutput: (_stream, chunk) => output.push(chunk), + spawnChildSession: async (input) => { + input.onEvent?.({ + type: 'tool_start', + id: 'start-1', + turnId: 'child-turn', + ts: 1, + toolUseId: 'child-tool-1', + toolName: 'x'.repeat(10_000), + args: {}, + }); + input.onEvent?.({ + type: 'provider_retry', + id: 'retry-1', + turnId: 'child-turn', + ts: 2, + phase: 'scheduled', + attempt: 1, + maxAttempts: 2, + delayMs: 100, + reason: 'rate_limit', + }); + return { + agentId: requireBuiltinAgentDefinitionByProfile(input.agentProfile).id, + agentName: requireBuiltinAgentDefinitionByProfile(input.agentProfile).name, + turnId: 'child-turn', + status: 'completed', + permissionMode: 'explore', + summary: 'done', + artifactIds: [], + }; + }, + }, + ); + + expect(output.slice(1, -1).join('')).toHaveLength(8_192); + }); + test('agent_spawn bounds projected startup failures', async () => { const tool = buildSubagentSpawnTool(); const output: string[] = []; diff --git a/packages/runtime/src/agent-run-recovery.ts b/packages/runtime/src/agent-run-recovery.ts index 6aea8e74ed..7a04ef94b0 100644 --- a/packages/runtime/src/agent-run-recovery.ts +++ b/packages/runtime/src/agent-run-recovery.ts @@ -23,7 +23,6 @@ import { } from '@maka/core/sandbox-boundary'; import type { AgentRunEvent, AgentRunHeader } from '@maka/core/agent-run'; import type { SandboxBoundaryRequest } from '@maka/core/sandbox-boundary'; -import type { UserMessageInput } from '@maka/core/runtime-inputs'; export interface AgentRunRecoveryDecision { runId: string; @@ -32,19 +31,21 @@ export interface AgentRunRecoveryDecision { failureClass?: string; abortSource?: string; diagnostic?: Record; - lineage: Partial< - Pick< - UserMessageInput, - | 'parentRunId' - | 'parentTurnId' - | 'retriedFromTurnId' - | 'regeneratedFromTurnId' - | 'branchOfTurnId' - | 'parentSessionId' - > - >; + lineage: AgentRunRecoveryLineage; } +type AgentRunRecoveryLineage = Partial< + Pick< + AgentRunHeader, + | 'parentRunId' + | 'parentTurnId' + | 'retriedFromTurnId' + | 'regeneratedFromTurnId' + | 'branchOfTurnId' + | 'parentSessionId' + > +>; + export function classifyAgentRunRecovery( header: AgentRunHeader, events: readonly AgentRunEvent[], @@ -196,19 +197,7 @@ function diagnostic( }; } -function headerLineage( - header: AgentRunHeader, -): Partial< - Pick< - UserMessageInput, - | 'parentRunId' - | 'parentTurnId' - | 'retriedFromTurnId' - | 'regeneratedFromTurnId' - | 'branchOfTurnId' - | 'parentSessionId' - > -> { +function headerLineage(header: AgentRunHeader): AgentRunRecoveryLineage { return { ...(header.parentRunId ? { parentRunId: header.parentRunId } : {}), ...(header.parentTurnId ? { parentTurnId: header.parentTurnId } : {}), diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index 176fbdf863..ba68ad62c5 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -124,17 +124,15 @@ export interface AgentRunHooks { } export type AgentRunLineage = Partial< - Pick< - UserMessageInput, - | 'parentRunId' - | 'resumedFromRunId' - | 'retriedFromRunId' - | 'parentTurnId' - | 'retriedFromTurnId' - | 'regeneratedFromTurnId' - | 'branchOfTurnId' - | 'parentSessionId' - > + Pick & + Pick< + UserMessageInput, + | 'parentTurnId' + | 'retriedFromTurnId' + | 'regeneratedFromTurnId' + | 'branchOfTurnId' + | 'parentSessionId' + > >; export type AgentRunDurability = 'best_effort' | 'required'; @@ -143,6 +141,8 @@ export interface AgentRunInput { sessionId: string; header: SessionHeader; userInput: UserMessageInput; + /** Internal lineage for runtime-owned continuations; never accepted by live turn input. */ + runLineage?: Pick; rootExecutionKind?: AgentRunHeader['rootExecutionKind']; runId?: string; userMessageId?: string | null; @@ -160,7 +160,6 @@ export interface AgentRunInput { /** Commits the claimed continuation provider-call T1 after Run creation. */ commitContinuationStart?: (startedAt: number) => Promise<{ startEventId: string; created: true }>; hooks: AgentRunHooks; - recordSessionMessages?: boolean; invocationId?: string; /** Pre-resolved snapshot used by continuations; normal turns derive it from header + input. */ effectiveOrchestration?: EffectiveOrchestration; @@ -297,15 +296,7 @@ export class AgentRun { } this.toolMode = requestedToolMode; this.lineage = { - ...(acceptedInput.userInput.parentRunId - ? { parentRunId: acceptedInput.userInput.parentRunId } - : {}), - ...(acceptedInput.userInput.resumedFromRunId - ? { resumedFromRunId: acceptedInput.userInput.resumedFromRunId } - : {}), - ...(acceptedInput.userInput.retriedFromRunId - ? { retriedFromRunId: acceptedInput.userInput.retriedFromRunId } - : {}), + ...acceptedInput.runLineage, ...(acceptedInput.userInput.parentTurnId ? { parentTurnId: acceptedInput.userInput.parentTurnId } : {}), @@ -631,13 +622,12 @@ export class AgentRun { const steering = runtimeEvent.content?.kind === 'text' && runtimeEvent.content.steering === true; await this.recordRuntimeEvents([runtimeEvent], steering ? { requireDurableWrite: true } : {}); - if (this.recordsSessionMessages()) { - await materializeRuntimeEventTranscriptProjection( - this.input.store, - this.sessionId, - runtimeEvent, - ); - } + + await materializeRuntimeEventTranscriptProjection( + this.input.store, + this.sessionId, + runtimeEvent, + ); } } @@ -645,39 +635,35 @@ export class AgentRun { await this.createRunRecord(); let initialRuntimeEventId: string; - if (this.recordsSessionMessages()) { - const userMessageTs = this.input.now(); - if (this.input.userMessageId === null) { - initialRuntimeEventId = this.input.newId(); - } else { - const userMessageId = this.input.userMessageId ?? this.input.newId(); - initialRuntimeEventId = userMessageId; - const userMsg = cloneAndFreezeRuntimeSnapshot({ - type: 'user', - id: userMessageId, - turnId: this.turnId, - ts: userMessageTs, - text: this.input.userInput.text, - ...(this.input.userInput.displayText !== undefined - ? { displayText: this.input.userInput.displayText } - : {}), - ...(this.input.userInput.attachments - ? { attachments: this.input.userInput.attachments } - : {}), - ...(this.input.userInput.quotes ? { quotes: this.input.userInput.quotes } : {}), - ...(this.input.userInput.inlineReferences - ? { inlineReferences: this.input.userInput.inlineReferences } - : {}), - ...(this.input.userInput.origin ? { origin: this.input.userInput.origin } : {}), - }); - await appendUserMessageOnce(this.input.store, this.sessionId, userMsg); - } - await this.input.hooks.appendTurnState(this.sessionId, this.turnId, 'running', this.lineage); - this.lastTs = userMessageTs; - } else { + + const userMessageTs = this.input.now(); + if (this.input.userMessageId === null) { initialRuntimeEventId = this.input.newId(); - this.lastTs = this.input.now(); + } else { + const userMessageId = this.input.userMessageId ?? this.input.newId(); + initialRuntimeEventId = userMessageId; + const userMsg = cloneAndFreezeRuntimeSnapshot({ + type: 'user', + id: userMessageId, + turnId: this.turnId, + ts: userMessageTs, + text: this.input.userInput.text, + ...(this.input.userInput.displayText !== undefined + ? { displayText: this.input.userInput.displayText } + : {}), + ...(this.input.userInput.attachments + ? { attachments: this.input.userInput.attachments } + : {}), + ...(this.input.userInput.quotes ? { quotes: this.input.userInput.quotes } : {}), + ...(this.input.userInput.inlineReferences + ? { inlineReferences: this.input.userInput.inlineReferences } + : {}), + ...(this.input.userInput.origin ? { origin: this.input.userInput.origin } : {}), + }); + await appendUserMessageOnce(this.input.store, this.sessionId, userMsg); } + await this.input.hooks.appendTurnState(this.sessionId, this.turnId, 'running', this.lineage); + this.lastTs = userMessageTs; const initialRuntimeEvent = cloneAndFreezeRuntimeSnapshot( this.buildInitialRuntimeEvent(initialRuntimeEventId, this.lastTs), @@ -724,11 +710,10 @@ export class AgentRun { const startedAt = this.input.now(); this.lastTs = startedAt; - if (this.recordsSessionMessages()) { - await this.input.hooks.appendTurnState(this.sessionId, this.turnId, 'running', this.lineage, { - ts: startedAt, - }); - } + + await this.input.hooks.appendTurnState(this.sessionId, this.turnId, 'running', this.lineage, { + ts: startedAt, + }); this.active = await this.input.hooks.reserveRun(this.sessionId, this.header, this); await this.markRunStarted(startedAt); @@ -769,11 +754,10 @@ export class AgentRun { throw new ContinuationStartCommitError(error); } await this.input.continuationFailpoint?.('after_continuation_start_committed'); - if (this.recordsSessionMessages()) { - await this.input.hooks.appendTurnState(this.sessionId, this.turnId, 'running', this.lineage, { - ts: startedAt, - }); - } + + await this.input.hooks.appendTurnState(this.sessionId, this.turnId, 'running', this.lineage, { + ts: startedAt, + }); this.active = await this.input.hooks.reserveRun(this.sessionId, this.header, this); await this.markRunStarted(startedAt); @@ -831,7 +815,6 @@ export class AgentRun { } async recordStoredSessionEvent(ev: SessionEvent): Promise { - if (!this.recordsSessionMessages()) return; if (ev.type === 'token_usage') { await this.input.store.appendMessage(this.sessionId, { ...ev } satisfies StoredMessage); } @@ -894,7 +877,7 @@ export class AgentRun { await this.recordStatusFromTransition(ev, transition, ev.ts); } } - if (turnStatus && !this.stopped && this.recordsSessionMessages()) { + if (turnStatus && !this.stopped) { const appendTurnState = this.input.hooks.appendTurnState( this.sessionId, this.turnId, @@ -922,14 +905,14 @@ export class AgentRun { } else { this.turnFailed = true; this.finalStatus = transition ?? { status: 'blocked', blockedReason: 'unknown' }; - if (this.recordsSessionMessages()) { - await this.input.hooks - .appendTurnState(this.sessionId, this.turnId, 'failed', this.lineage, { - ts: ev.ts, - errorClass: ev.reason ?? ev.code ?? 'unknown', - }) - .catch((error) => this.enqueueTraceWriteFailure(error, 'terminal session projection')); - } + + await this.input.hooks + .appendTurnState(this.sessionId, this.turnId, 'failed', this.lineage, { + ts: ev.ts, + errorClass: ev.reason ?? ev.code ?? 'unknown', + }) + .catch((error) => this.enqueueTraceWriteFailure(error, 'terminal session projection')); + this.markRunFailed(ev.reason ?? ev.code ?? 'unknown', ev.message, ev.ts); } } @@ -1038,13 +1021,13 @@ export class AgentRun { return; } this.finalStatus = { status: 'blocked', blockedReason: 'unknown' }; - if (this.recordsSessionMessages()) { - await this.input.hooks - .appendTurnState(this.sessionId, this.turnId, 'failed', this.lineage, { - errorClass: error instanceof Error ? error.name : 'unknown', - }) - .catch(() => {}); - } + + await this.input.hooks + .appendTurnState(this.sessionId, this.turnId, 'failed', this.lineage, { + errorClass: error instanceof Error ? error.name : 'unknown', + }) + .catch(() => {}); + this.markRunFailed( error instanceof Error ? error.name : 'unknown', errorMessage(error), @@ -1083,7 +1066,7 @@ export class AgentRun { } catch { // The user-visible turn already completed; preserve existing behavior. } - if (this.sawCompletion && this.recordsSessionMessages()) { + if (this.sawCompletion) { await this.input.store .appendMessage(this.sessionId, { type: 'system_note', @@ -1097,10 +1080,6 @@ export class AgentRun { await this.finishRun(this.finalStatus, lastTs); } - private recordsSessionMessages(): boolean { - return this.input.recordSessionMessages !== false; - } - private async createRunRecord(continuation?: RuntimeContinuation): Promise { if (!this.input.runStore) { if (continuation) throw new Error('Runtime continuation requires a durable run store'); @@ -1222,10 +1201,6 @@ export class AgentRun { sessionId: this.sessionId, currentRunId: this.runId, currentTurnId: this.turnId, - parentRunId: this.lineage.parentRunId, - resumedFromRunId: this.lineage.resumedFromRunId, - agentId: this.input.userInput.agentId, - linkedChildSession: this.input.header.subagentParent?.kind === 'subagent', runStore: this.input.runStore, runtimeEventStore: this.input.runtimeEventStore, runStoreAvailable: this.runStoreAvailable, diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index feac5acf09..0301b75243 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -77,7 +77,6 @@ import type { BackendSendInput, HostedInteractionBridge, } from '@maka/core/backend-types'; -import type { AgentSpec } from '@maka/core/runtime-inputs'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import type { UserQuestionResponse } from '@maka/core/user-question'; @@ -155,7 +154,6 @@ import { type ToolRuntimeInput, } from './tool-runtime.js'; import type { RuntimeCommitSink } from './runtime-commit-sink.js'; -import type { SubagentExecutionRef } from './subagent-execution.js'; import { ModelAdapter, type ModelFactoryInput, @@ -763,35 +761,7 @@ export interface AiSdkBackendInput extends AiSdkCompactionCapabilities { ) => void | Promise; /** Optional pricing lookup shared with telemetry; defaults to builtin public pricing. */ lookupPricing?: (modelKey: string) => PricingConfig | null; - spawnChildAgent?: (input: { - parentRunId: string; - spec: AgentSpec; - prompt: string; - abortSignal: AbortSignal; - onReady?: (input: { - turnId: string; - agentId: string; - agentName: string; - }) => void | Promise; - onEvent?: (event: SessionEvent) => void; - }) => Promise; spawnChildSession?: ToolRuntimeInput['spawnChildSession']; - prepareChildAgentResume?: ToolRuntimeInput['prepareChildAgentResume']; - resumeChildAgent?: ToolRuntimeInput['resumeChildAgent']; - retryChildAgent?: (input: { - parentRunId: string; - sourceRunId: string; - execution?: SubagentExecutionRef; - abortSignal: AbortSignal; - onReady?: (input: { - childSessionId?: string; - turnId: string; - runId?: string; - agentId: string; - agentName: string; - }) => void | Promise; - onEvent?: (event: SessionEvent) => void; - }) => Promise; listChildAgents?: () => Promise; readChildAgentOutput?: ToolRuntimeInput['readChildAgentOutput']; /** Optional diagnostic trace hook for explaining a runtime turn without changing renderer events. */ @@ -1368,11 +1338,7 @@ export class AiSdkBackend implements AgentBackend { ...(identity.invocationId ? { invocationId: identity.invocationId } : {}), materializeDefaultToolResultOutput: ({ toolCallId, output }) => this.materializeToolResultOutput(identity.scope().imageBudget, output, false, toolCallId), - spawnChildAgent: input.spawnChildAgent, spawnChildSession: input.spawnChildSession, - prepareChildAgentResume: input.prepareChildAgentResume, - resumeChildAgent: input.resumeChildAgent, - retryChildAgent: input.retryChildAgent, listChildAgents: input.listChildAgents, readChildAgentOutput: input.readChildAgentOutput, getRunTrace: () => identity.scope().runTrace, diff --git a/packages/runtime/src/child-agent-progress.ts b/packages/runtime/src/child-agent-progress.ts index 0641e94bc2..044a1f4bbe 100644 --- a/packages/runtime/src/child-agent-progress.ts +++ b/packages/runtime/src/child-agent-progress.ts @@ -22,52 +22,20 @@ import type { MakaToolContext } from './tool-runtime.js'; export const CHILD_AGENT_PROGRESS_MAX_EVENTS = 64; export const CHILD_AGENT_PROGRESS_MAX_CHARS = 8_192; -export const CHILD_AGENT_PROGRESS_BATCH_MAX_EVENTS = 128; -export const CHILD_AGENT_PROGRESS_BATCH_MAX_CHARS = 16_384; - -export interface ChildAgentProgressBudget { - readonly maxEvents: number; - readonly maxChars: number; - projectedEvents: number; - projectedChars: number; -} - -export interface ChildAgentProgressProjectorOptions { - prefix?: string; - sharedBudget?: ChildAgentProgressBudget; -} - -export function createChildAgentProgressBudget( - maxEvents: number, - maxChars: number, -): ChildAgentProgressBudget { - return { - maxEvents, - maxChars, - projectedEvents: 0, - projectedChars: 0, - }; -} export class ChildAgentProgressProjector { private readonly tools = new Map(); - private readonly prefix: string; private projectedEvents = 0; private projectedChars = 0; - constructor( - private readonly ctx: Pick, - private readonly options: ChildAgentProgressProjectorOptions = {}, - ) { - this.prefix = options.prefix ?? 'Child'; - } + constructor(private readonly ctx: Pick) {} observe(event: SessionEvent): void { if (event.type === 'tool_start') { if (!this.hasCapacity()) return; const name = event.displayName ?? event.toolName; this.tools.set(event.toolUseId, name); - this.emit('stdout', `${this.prefix} tool started: ${name}\n`); + this.emit('stdout', `Child tool started: ${name}\n`); return; } if (event.type === 'tool_result') { @@ -75,7 +43,7 @@ export class ChildAgentProgressProjector { this.tools.delete(event.toolUseId); this.emit( event.isError ? 'stderr' : 'stdout', - `${this.prefix} tool ${event.isError ? 'failed' : 'finished'}: ${name}\n`, + `Child tool ${event.isError ? 'failed' : 'finished'}: ${name}\n`, ); return; } @@ -84,34 +52,24 @@ export class ChildAgentProgressProjector { event.phase === 'scheduled' ? `scheduled: attempt ${event.attempt}/${event.maxAttempts} in ${event.delayMs}ms` : `started: attempt ${event.attempt}/${event.maxAttempts}`; - this.emit('stderr', `${this.prefix} provider retry ${retry} (${event.reason})\n`); + this.emit('stderr', `Child provider retry ${retry} (${event.reason})\n`); } } private emit(stream: 'stdout' | 'stderr', chunk: string): void { if (!this.hasCapacity()) return; - const shared = this.options.sharedBudget; - const localRemaining = CHILD_AGENT_PROGRESS_MAX_CHARS - this.projectedChars; - const sharedRemaining = shared ? shared.maxChars - shared.projectedChars : localRemaining; - const remaining = Math.min(localRemaining, sharedRemaining); + const remaining = CHILD_AGENT_PROGRESS_MAX_CHARS - this.projectedChars; const bounded = chunk.slice(0, remaining); this.projectedEvents += 1; this.projectedChars += bounded.length; - if (shared) { - shared.projectedEvents += 1; - shared.projectedChars += bounded.length; - } this.ctx.emitOutput(stream, bounded); } private hasCapacity(): boolean { - const shared = this.options.sharedBudget; return ( this.projectedEvents < CHILD_AGENT_PROGRESS_MAX_EVENTS && - this.projectedChars < CHILD_AGENT_PROGRESS_MAX_CHARS && - (!shared || - (shared.projectedEvents < shared.maxEvents && shared.projectedChars < shared.maxChars)) + this.projectedChars < CHILD_AGENT_PROGRESS_MAX_CHARS ); } } diff --git a/packages/runtime/src/prior-run-context.ts b/packages/runtime/src/prior-run-context.ts index e968c45d44..01533dfafe 100644 --- a/packages/runtime/src/prior-run-context.ts +++ b/packages/runtime/src/prior-run-context.ts @@ -38,10 +38,6 @@ export interface BuildPriorRuntimeContextInput { sessionId: string; currentRunId: string; currentTurnId: string; - parentRunId?: string; - resumedFromRunId?: string; - agentId?: string; - linkedChildSession: boolean; runStore?: AgentRunStore; runtimeEventStore?: RuntimeEventStore; runStoreAvailable: boolean; @@ -58,9 +54,6 @@ interface PriorRunTerminalFactContext { export async function buildPriorRuntimeContext( input: BuildPriorRuntimeContextInput, ): Promise { - if (input.resumedFromRunId) - return await buildResumedChildRuntimeContext(input, input.resumedFromRunId); - if (input.parentRunId) return undefined; if ( !input.runStore || !input.runtimeEventStore || @@ -132,89 +125,6 @@ export async function buildPriorRuntimeContext( return { events, runs: priorRuns }; } -async function buildResumedChildRuntimeContext( - input: BuildPriorRuntimeContextInput, - sourceRunId: string, -): Promise { - if ( - !input.runStore || - !input.runtimeEventStore || - !input.runStoreAvailable || - !input.runtimeEventStoreAvailable - ) { - throw new Error('Child AgentRun resume requires durable run and RuntimeEvent stores'); - } - const sessionRuns = await input.runStore.listSessionRuns(input.sessionId); - const runsById = new Map(sessionRuns.map((run) => [run.runId, run])); - const reverseChain: AgentRunHeader[] = []; - const visited = new Set(); - let cursor: string | undefined = sourceRunId; - while (cursor) { - if (visited.has(cursor)) - throw new Error(`Child AgentRun resume lineage contains a cycle at ${cursor}`); - visited.add(cursor); - const run = runsById.get(cursor); - if (!run) throw new Error(`Child AgentRun resume source ${cursor} was not found`); - if ( - input.linkedChildSession - ? !isSessionInlineRun(run) - : !run.parentRunId || isSessionInlineRun(run) - ) { - throw new Error(`AgentRun ${cursor} is not a resumable child run`); - } - if (!run.agentId || run.agentId !== input.agentId) { - throw new Error(`Child AgentRun resume profile changed at ${cursor}`); - } - reverseChain.push(run); - cursor = run.resumedFromRunId ?? (input.linkedChildSession ? run.retriedFromRunId : undefined); - } - const effectiveRuns: AgentRunHeader[] = []; - const events: RuntimeEvent[] = []; - for (const run of reverseChain.reverse()) { - const loaded = await loadRequiredChildResumeContext(input, run); - effectiveRuns.push(loaded.run); - events.push(...loaded.events); - } - const replay = buildRuntimeEventModelReplayPlan(events); - const unsafe = replay.diagnostics.find((diagnostic) => - [ - 'unmatched_tool_call', - 'unmatched_tool_result', - 'tool_id_mismatch', - 'unsupported_role', - 'unsupported_content', - ].includes(diagnostic.code), - ); - if (unsafe) throw new Error(`Child AgentRun resume history is unsafe: ${unsafe.code}`); - const first = replay.items[0]; - if (!first || first.kind !== 'text' || first.role !== 'user') { - throw new Error('Child AgentRun resume history has no user-anchored replay boundary'); - } - return { events, runs: effectiveRuns }; -} - -async function loadRequiredChildResumeContext( - input: BuildPriorRuntimeContextInput, - run: AgentRunHeader, -): Promise { - let events = await input.runtimeEventStore!.readRuntimeEvents(input.sessionId, run.runId); - if ( - (events.length === 0 || !events.some(isTerminalRuntimeEvent)) && - (await input.repairRunRuntimeLedger?.(input.sessionId, run.runId)) - ) { - events = await input.runtimeEventStore!.readRuntimeEvents(input.sessionId, run.runId); - } - if (events.length === 0 || !events.some(isTerminalRuntimeEvent)) { - throw new Error(`Child AgentRun resume source ${run.runId} has no terminal RuntimeEvent fact`); - } - const terminalFact = classifyRuntimeEventTerminalFact(run, events).fact; - if (!terminalFact) - throw new Error( - `Child AgentRun resume source ${run.runId} has an invalid terminal RuntimeEvent fact`, - ); - return { events, run: effectiveRunHeaderFromTerminalFact(run, terminalFact) }; -} - /** * A prior run whose header never reached a terminal status: it was stopped * while parked on an interaction, or the process died mid-turn. Its turn is diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index b91e7627c8..1463202268 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -32,7 +32,6 @@ import type { RuntimeContinuationAuthorityStore, RuntimeEventStore, } from '@maka/core/runtime-event-store'; -import { isSessionInlineRun } from '@maka/core/agent-run'; import { type ActiveInteractionRequestEvent, type CompleteEvent, @@ -50,7 +49,7 @@ import type { TurnStateMessage, } from '@maka/core/session'; import { isDeepStrictEqual } from 'node:util'; -import type { ChildAgentTurnInput, UserMessageInput } from '@maka/core/runtime-inputs'; +import type { UserMessageInput } from '@maka/core/runtime-inputs'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import { resolveEffectiveOrchestration, @@ -98,11 +97,7 @@ import { normalizeStopSessionSource, turnHasRetainedOutput as messagesHaveRetainedOutput, } from './session-projection-helpers.js'; -import { - assertAgentDefinitionRunnable, - buildToolsForAgentDefinition, - requireBuiltinAgentDefinition, -} from './agent-catalog.js'; +import { buildToolsForAgentDefinition } from './agent-catalog.js'; import { loadLatestHistoryCompactCheckpointFromRunLedger } from './history-compact-ledger.js'; import { canReplaceHistoryCompactCheckpoint, @@ -169,16 +164,6 @@ export interface RuntimeKernelLike { ): AsyncIterable; compactSession(sessionId: string, input?: CompactSessionInput): AsyncIterable; preflightContextCompaction(sessionId: string): Promise; - startChildTurn( - sessionId: string, - input: ChildAgentTurnInput, - execution?: RuntimeExecutionClaim, - ): AsyncIterable; - startChildRetry?( - sessionId: string, - input: ChildAgentRetryInput, - execution?: RuntimeExecutionClaim, - ): AsyncIterable; stopSession(sessionId: string, input?: StopSessionInput): Promise; respondToSandboxBoundary(sessionId: string, response: SandboxBoundaryResponse): Promise; listActiveInteractions?(sessionId: string): ActiveInteractionRequestEvent[]; @@ -253,15 +238,6 @@ export class RuntimeOwnerCleanupError extends Error { } } -export interface ChildAgentRetryInput { - parentRunId: string; - spec: ChildAgentTurnInput['spec']; - continuation: RuntimeContinuation; - /** Retry an ordinary session-inline AgentRun inside a linked child Session. */ - linkedSession?: boolean; - onRunStarted?: () => void | Promise; -} - export type BackendActivationBoundary = (operation: () => Promise | T) => Promise; interface ChildToolActivation { @@ -269,13 +245,6 @@ interface ChildToolActivation { readonly shell?: TurnShellPlan; } -function requireChildToolActivation( - activation: ChildToolActivation | undefined, -): ChildToolActivation { - if (!activation) throw new Error('Child tool activation was not prepared'); - return activation; -} - export interface RuntimeKernelDeps { store: SessionStore; runStore?: AgentRunStore; @@ -305,7 +274,6 @@ export type { HistoryCompactCleanupRequest } from './history-compact-checkpoint- interface BackendGeneration extends AgentRunActiveSession { sessionId: string; generation: number; - route: { kind: 'parent' } | { kind: 'child'; activeKey: string }; phase: 'active' | 'stopping' | 'disposing' | 'failed' | 'terminated'; backend: AgentBackend; stopBackend: AgentBackend['stop']; @@ -396,7 +364,6 @@ interface InteractionRequestOwner { export class RuntimeKernel implements RuntimeKernelLike { private readonly active = new Map(); - private readonly childActive = new Map(); private readonly backendGenerations = new Map(); private readonly backendActivationBuilds = new Map>(); private readonly stopOperations = new Map(); @@ -651,6 +618,7 @@ export class RuntimeKernel implements RuntimeKernelLike { input: UserMessageInput, options: TurnStartOptions = {}, ): AsyncIterable { + assertNoRemovedChildAgentRunLineage(input); if (this.pendingContinuationSessions.has(sessionId)) { throw new Error('Cannot start a turn while a runtime continuation is being claimed'); } @@ -775,7 +743,6 @@ export class RuntimeKernel implements RuntimeKernelLike { const userInput: UserMessageInput = { turnId: continuation.turnId, text: '', - parentRunId: continuation.sourceRunId, parentTurnId: continuation.sourceTurnId, }; const effectiveOrchestration = effectiveOrchestrationForRun(sourceRun, header); @@ -826,6 +793,7 @@ export class RuntimeKernel implements RuntimeKernelLike { sessionId: continuation.sessionId, header, userInput, + runLineage: { parentRunId: continuation.sourceRunId }, runId: continuation.runId, invocationId: continuation.invocationId, store: this.deps.store, @@ -1119,351 +1087,6 @@ export class RuntimeKernel implements RuntimeKernelLike { return active; } - async *startChildTurn( - sessionId: string, - input: ChildAgentTurnInput, - suppliedExecution?: RuntimeExecutionClaim, - ): AsyncIterable { - const execution = this.takeExecutionClaim(sessionId, suppliedExecution); - try { - yield* this.startChildTurnClaimed(sessionId, input, execution); - } finally { - this.releaseExecutionClaim(execution); - } - } - - private async *startChildTurnClaimed( - sessionId: string, - input: ChildAgentTurnInput, - execution: PendingExecutionClaim, - ): AsyncIterable { - await this.enterExecutionClaim(execution); - const parentHeader = await this.deps.store.readHeader(sessionId); - const definition = requireBuiltinAgentDefinition(input.spec.id); - let childActivation: ChildToolActivation | undefined; - const childHeader: SessionHeader = { - ...parentHeader, - permissionMode: definition.permissionMode, - }; - const userInput: UserMessageInput = { - turnId: input.turnId, - text: input.prompt, - parentRunId: input.parentRunId, - ...(input.resumedFromRunId ? { resumedFromRunId: input.resumedFromRunId } : {}), - agentId: definition.id, - agentName: definition.name, - }; - const activeKey = childActiveKey(sessionId, input.turnId); - const run = new AgentRun({ - sessionId, - header: childHeader, - userInput, - store: this.deps.store, - runStore: this.deps.runStore, - runtimeEventStore: this.deps.runtimeEventStore, - ...(this.deps.toolBoundaryProtocol - ? { toolBoundaryProtocol: this.deps.toolBoundaryProtocol } - : {}), - repairRunRuntimeLedger: this.deps.repairRunRuntimeLedger, - newId: this.deps.newId, - now: this.deps.now, - effectiveOrchestration: resolveEffectiveOrchestration('default', undefined), - recordSessionMessages: false, - hooks: { - reserveRun: async (targetSessionId, nextHeader, activeRun) => { - const activation = requireChildToolActivation(childActivation); - const active = await this.reserveChildRun( - activeKey, - targetSessionId, - nextHeader, - definition.systemPrompt, - activation.tools, - activation.shell, - activeRun, - execution, - ); - this.reserveExecutionClaim(execution, active, activeRun); - return active; - }, - unregisterRun: (active, activeRun) => this.unregisterChildRun(active, activeRun), - updateHeader: async (_targetSessionId, patch) => ({ ...childHeader, ...patch }), - updateStatus: async () => {}, - appendTurnState: async () => {}, - }, - }); - - this.attachExecutionClaim(execution, run); - yield* this.runAgentTurn(sessionId, run, execution, { - prepareBackendActivation: async () => { - const available = await this.childToolActivationForSession(sessionId); - assertAgentDefinitionRunnable({ definition, tools: available.tools }); - childActivation = { - tools: buildToolsForAgentDefinition(available.tools, definition), - ...(available.shell ? { shell: available.shell } : {}), - }; - }, - }); - } - - async *startChildRetry( - sessionId: string, - input: ChildAgentRetryInput, - suppliedExecution?: RuntimeExecutionClaim, - ): AsyncIterable { - const execution = this.takeExecutionClaim(sessionId, suppliedExecution); - try { - yield* this.startChildRetryClaimed(sessionId, input, execution); - } finally { - this.releaseExecutionClaim(execution); - } - } - - private async *startChildRetryClaimed( - sessionId: string, - input: ChildAgentRetryInput, - execution: PendingExecutionClaim, - ): AsyncIterable { - const continuation = snapshotRuntimeContinuation(input.continuation); - await this.enterExecutionClaim(execution); - if (continuation.sessionId !== sessionId) { - throw new Error('Child retry continuation belongs to a different session'); - } - const parentHeader = await this.deps.store.readHeader(sessionId); - const linkedSnapshot = input.linkedSession ? parentHeader.subagentRuntime : undefined; - if ( - input.linkedSession && - (parentHeader.subagentParent?.kind !== 'subagent' || - !linkedSnapshot || - linkedSnapshot.agentId !== input.spec.id) - ) { - throw new Error('Linked child retry is missing its durable runtime snapshot'); - } - const definition = linkedSnapshot - ? { - id: linkedSnapshot.agentId, - name: linkedSnapshot.agentName, - systemPrompt: linkedSnapshot.systemPrompt, - permissionMode: parentHeader.permissionMode, - tools: linkedSnapshot.toolNames, - } - : requireBuiltinAgentDefinition(input.spec.id); - const preflightActivation = await this.childToolActivationForSession(sessionId); - if (!linkedSnapshot) { - assertAgentDefinitionRunnable({ - definition: requireBuiltinAgentDefinition(input.spec.id), - tools: preflightActivation.tools, - }); - } - const preflightChildTools = buildToolsForAgentDefinition(preflightActivation.tools, definition); - if (linkedSnapshot && preflightChildTools.length !== linkedSnapshot.toolNames.length) { - throw new Error('Linked child retry durable runtime tool snapshot is unavailable'); - } - const childHeader: SessionHeader = linkedSnapshot - ? parentHeader - : { - ...parentHeader, - permissionMode: definition.permissionMode, - }; - const userInput: UserMessageInput = { - turnId: continuation.turnId, - text: '', - ...(!linkedSnapshot ? { parentRunId: input.parentRunId } : {}), - retriedFromRunId: continuation.sourceRunId, - agentId: definition.id, - agentName: definition.name, - }; - const effectiveOrchestration = resolveEffectiveOrchestration('default', undefined); - if (!this.deps.runStore || !this.deps.runtimeEventStore) { - throw new Error('Child retry continuation requires AgentRunStore and RuntimeEventStore'); - } - const sourceRun = await this.deps.runStore.readRun(sessionId, continuation.sourceRunId); - const effectiveToolMode = effectiveToolModeForRun(sourceRun); - const continuationAuthority = requireRuntimeContinuationAuthority(this.deps.runtimeEventStore); - const sourceEvents = await revalidateContinuationBoundary(continuationAuthority, continuation); - assertContinuationSourceUnchanged(continuation, sourceRun, sourceEvents); - await this.revalidateContinuationSafety( - continuation, - preflightChildTools.map((tool) => tool.name), - ); - - const claimedAt = this.deps.now(); - const targetRunHeader = continuationTargetRunHeaderForExecution({ - continuation, - sessionHeader: childHeader, - userInput, - workspaceIdentity: continuation.safetySnapshot.workspaceIdentity, - effectiveOrchestration, - effectiveToolMode, - claimedAt, - }); - const claim = continuationClaimForExecution(continuation, claimedAt, targetRunHeader); - const claimResult = await continuationAuthority.claimContinuation({ - claim, - }); - if (claimResult.kind !== 'acquired') { - throw new RuntimeContinuationRevalidationError( - 'continuation_claim_conflict', - `Child retry continuation boundary is already claimed by ${claimResult.claim.claimId}`, - ); - } - await this.deps.continuationFailpoint?.('after_continuation_claim_committed'); - const continuationToolBoundaryProtocol = this.deps.toolBoundaryProtocol; - const durableAdmission: { - claimedRunHeader: AgentRunHeader; - commitContinuationStart: ( - startedAt: number, - ) => Promise<{ startEventId: string; created: true }>; - } = { - claimedRunHeader: claim.targetRunHeader, - commitContinuationStart: async (startedAt) => { - const source = claim.boundary.segments.at(-1)!; - const eventId = this.deps.newId(); - const result = await continuationAuthority.commitContinuationStart({ - claim, - event: { - id: eventId, - ...claim.target, - ts: startedAt, - partial: false, - role: 'system', - author: 'system', - actions: { - ...(continuationToolBoundaryProtocol - ? { - runtimeProtocol: { - toolBoundary: continuationToolBoundaryProtocol, - }, - } - : {}), - continuationStart: { - protocol: 'continuation_start_v2', - provenance: 'runtime_admission', - claimId: claim.claimId, - boundaryDigest: claim.boundaryDigest, - immediateSource: { - sessionId: source.identity.sessionId, - invocationId: source.identity.invocationId, - runId: source.identity.runId, - turnId: source.identity.turnId, - highWater: source.position.lastEventSeq, - prefixDigest: source.prefixDigest, - }, - replayManifestDigest: claim.boundary.manifestDigest, - providerProjectionVersion: claim.providerProjectionVersion, - providerReplayDigest: claim.providerReplayDigest, - }, - }, - }, - }); - if (!result.created) { - throw new Error( - 'Continuation-start already existed; refusing to reissue provider admission', - ); - } - return { startEventId: eventId, created: true }; - }, - }; - const activeKey = childActiveKey(sessionId, continuation.turnId); - let childActivation: ChildToolActivation | undefined; - const run = new AgentRun({ - sessionId, - header: childHeader, - userInput, - runId: continuation.runId, - invocationId: continuation.invocationId, - store: this.deps.store, - runStore: this.deps.runStore, - runtimeEventStore: this.deps.runtimeEventStore, - ...(continuationToolBoundaryProtocol - ? { toolBoundaryProtocol: continuationToolBoundaryProtocol } - : {}), - repairRunRuntimeLedger: this.deps.repairRunRuntimeLedger, - newId: this.deps.newId, - now: this.deps.now, - workspaceIdentity: continuation.safetySnapshot.workspaceIdentity, - effectiveOrchestration, - effectiveToolMode, - ...durableAdmission, - recordSessionMessages: false, - hooks: { - reserveRun: async (targetSessionId, nextHeader, activeRun) => { - let active: BackendGeneration; - if (linkedSnapshot) { - active = await this.reserveParentRun(targetSessionId, nextHeader, activeRun, execution); - } else { - const activation = requireChildToolActivation(childActivation); - active = await this.reserveChildRun( - activeKey, - targetSessionId, - nextHeader, - definition.systemPrompt, - activation.tools, - activation.shell, - activeRun, - execution, - ); - } - this.reserveExecutionClaim(execution, active, activeRun); - return active; - }, - unregisterRun: (active, activeRun) => - linkedSnapshot - ? this.unregisterParentRun(active, activeRun) - : this.unregisterChildRun(active, activeRun), - updateHeader: (targetSessionId, patch) => - linkedSnapshot - ? this.updateHeader(targetSessionId, patch) - : Promise.resolve({ ...childHeader, ...patch }), - updateStatus: (targetSessionId, status, blockedReason, ts) => - linkedSnapshot - ? this.updateStatus(targetSessionId, status, blockedReason, ts) - : Promise.resolve(), - appendTurnState: (targetSessionId, turnId, status, lineage, options) => - linkedSnapshot - ? this.appendTurnState(targetSessionId, turnId, status, lineage, options) - : Promise.resolve(), - }, - }); - - this.attachExecutionClaim(execution, run); - // The retry replays without a second user prompt; the source boundary is - // consumed through claim + continuation-start T1. - yield* this.runAgentContinuation( - continuation, - run, - execution, - input.linkedSession === true - ? { - sessionId, - turnId: continuation.turnId, - runId: continuation.runId, - } - : undefined, - input.onRunStarted, - async () => { - const available = await this.childToolActivationForSession(sessionId); - if (!linkedSnapshot) { - assertAgentDefinitionRunnable({ - definition: requireBuiltinAgentDefinition(input.spec.id), - tools: available.tools, - }); - } - const tools = buildToolsForAgentDefinition(available.tools, definition); - if (linkedSnapshot && tools.length !== linkedSnapshot.toolNames.length) { - throw new Error('Linked child retry durable runtime tool snapshot is unavailable'); - } - childActivation = { - tools, - ...(available.shell ? { shell: available.shell } : {}), - }; - await this.revalidateContinuationSafety( - continuation, - tools.map((tool) => tool.name), - ); - }, - ); - } - private async *runAgentTurn( sessionId: string, run: AgentRun, @@ -2587,9 +2210,6 @@ export class RuntimeKernel implements RuntimeKernelLike { private detachBackendGeneration(active: BackendGeneration): void { if (this.active.get(active.sessionId) === active) this.active.delete(active.sessionId); - for (const [key, child] of this.childActive.entries()) { - if (child === active) this.childActive.delete(key); - } } private stopOperationReferences(active: BackendGeneration): boolean { @@ -2598,16 +2218,7 @@ export class RuntimeKernel implements RuntimeKernelLike { ); } - /** - * Builds the backend recorder hooks shared by `ensureActive` and - * `ensureChildActive`. The two paths are structurally identical except for - * which active-session map they resolve against — captured here by - * `resolveActive`, so each call site binds its own resolver. Callers retain - * the intentionally divergent fields (`allowMidTurnHistoryCompaction`, - * `shellRunContextSummary`, system prompt/tools source) at their own sites. - */ private buildBackendRecorderHooks(input: { - resolveActive: () => BackendGeneration | undefined; sessionId: string; }): Pick< BackendFactoryContext, @@ -2620,9 +2231,9 @@ export class RuntimeKernel implements RuntimeKernelLike { | 'recordHistoryCompactCheckpoint' | 'loadTurnRuntimeEvents' > { - const { resolveActive, sessionId } = input; + const { sessionId } = input; const runFor = (turnId: string): AgentRun | undefined => { - const active = resolveActive(); + const active = this.active.get(sessionId); const runId = active?.turnToRunId.get(turnId); return runId ? active?.activeRuns.get(runId) : undefined; }; @@ -2644,11 +2255,11 @@ export class RuntimeKernel implements RuntimeKernelLike { // Resolved by runId rather than turnId: the canonical record names // the run it belongs to, so it needs no turn-to-run indirection. recordModelCallAttempt: (commit) => { - const run = resolveActive()?.activeRuns.get(commit.attempt.runId); + const run = this.active.get(sessionId)?.activeRuns.get(commit.attempt.runId); return run?.recordModelCallAttempt(commit) ?? Promise.resolve(); }, recordRunComposition: (runId, snapshot) => { - const run = resolveActive()?.activeRuns.get(runId); + const run = this.active.get(sessionId)?.activeRuns.get(runId); if (!run) { return Promise.reject(new Error('No active AgentRun for Run Composition')); } @@ -2709,17 +2320,14 @@ export class RuntimeKernel implements RuntimeKernelLike { } : {}), ...this.buildBackendRecorderHooks({ - resolveActive: () => this.active.get(sessionId), sessionId, }), allowMidTurnHistoryCompaction: Boolean(this.deps.runtimeEventStore), shellRunContextSummary: () => this.deps.shellRuns?.buildContextSummary(sessionId) ?? Promise.resolve(undefined), }); - await this.rejectCancelledBackendActivation(backend, header, { kind: 'parent' }, execution); - const generation = this.createBackendGeneration(sessionId, backend, header, { - kind: 'parent', - }); + await this.rejectCancelledBackendActivation(backend, header, execution); + const generation = this.createBackendGeneration(sessionId, backend, header); this.active.set(sessionId, generation); return generation; }); @@ -2780,64 +2388,6 @@ export class RuntimeKernel implements RuntimeKernelLike { return await this.deps.resolveChildTools(sessionId); } - private async ensureChildActive( - activeKey: string, - sessionId: string, - header: SessionHeader, - systemPrompt: string, - tools: readonly MakaTool[], - turnShellPlan: TurnShellPlan | undefined, - execution: PendingExecutionClaim, - ): Promise { - await this.clearBackendQuarantineForActivation(sessionId, execution); - let existing = this.childActive.get(activeKey); - if (existing) { - existing.cachedHeader = header; - return existing; - } - await this.waitForBackendDisposal(sessionId); - existing = this.childActive.get(activeKey); - if (existing) { - existing.cachedHeader = header; - return existing; - } - const entry = await this.shareBackendActivation(`child:${activeKey}`, async () => { - const current = this.childActive.get(activeKey); - if (current) return current; - const backend = await this.deps.backends.build(header.backend, { - sessionId, - workspaceRoot: header.workspaceRoot, - header, - store: this.deps.store, - abortSignal: execution.abortController.signal, - appendMessage: async () => {}, - systemPrompt, - tools, - ...(turnShellPlan ? { turnShellPlan } : {}), - ...this.buildBackendRecorderHooks({ - resolveActive: () => this.childActive.get(activeKey), - sessionId, - }), - // A child-only ledger cannot claim coverage of the parent session prefix. - allowMidTurnHistoryCompaction: false, - }); - await this.rejectCancelledBackendActivation( - backend, - header, - { kind: 'child', activeKey }, - execution, - ); - const generation = this.createBackendGeneration(sessionId, backend, header, { - kind: 'child', - activeKey, - }); - this.childActive.set(activeKey, generation); - return generation; - }); - entry.cachedHeader = header; - return entry; - } - private async reserveParentRun( sessionId: string, header: SessionHeader, @@ -2849,39 +2399,14 @@ export class RuntimeKernel implements RuntimeKernelLike { return active; } - private async reserveChildRun( - activeKey: string, - sessionId: string, - header: SessionHeader, - systemPrompt: string, - tools: readonly MakaTool[], - turnShellPlan: TurnShellPlan | undefined, - run: AgentRun, - execution: PendingExecutionClaim, - ): Promise { - const active = await this.ensureChildActive( - activeKey, - sessionId, - header, - systemPrompt, - tools, - turnShellPlan, - execution, - ); - this.reserveGenerationRun(active, run); - return active; - } - private createBackendGeneration( sessionId: string, backend: AgentBackend, header: SessionHeader, - route: BackendGeneration['route'], ): BackendGeneration { const active: BackendGeneration = { sessionId, generation: ++this.nextBackendGeneration, - route, phase: 'active', backend, stopBackend: undefined as never, @@ -2898,11 +2423,10 @@ export class RuntimeKernel implements RuntimeKernelLike { private async rejectCancelledBackendActivation( backend: AgentBackend, header: SessionHeader, - route: BackendGeneration['route'], execution: PendingExecutionClaim, ): Promise { if (!execution.abortController.signal.aborted) return; - const generation = this.createBackendGeneration(execution.sessionId, backend, header, route); + const generation = this.createBackendGeneration(execution.sessionId, backend, header); const disposal = await this.quarantineBackendGeneration(generation); if (!disposal.ok) { throw new AggregateError( @@ -2947,9 +2471,7 @@ export class RuntimeKernel implements RuntimeKernelLike { } private isCurrentGeneration(active: BackendGeneration): boolean { - return active.route.kind === 'parent' - ? this.active.get(active.sessionId) === active - : this.childActive.get(active.route.activeKey) === active; + return this.active.get(active.sessionId) === active; } private unregisterRun(active: AgentRunActiveSession, run: AgentRun): void { @@ -2966,19 +2488,6 @@ export class RuntimeKernel implements RuntimeKernelLike { await this.flushBackendInvalidation(active.sessionId); } - private async unregisterChildRun(active: AgentRunActiveSession, run: AgentRun): Promise { - this.unregisterRun(active, run); - if (active.activeRuns.size > 0) return; - await this.settleRunStopOperation(active.sessionId, run); - const generation = active as BackendGeneration; - await this.settleBackendGenerationAfterRunExit(generation); - if (generation.phase === 'active') { - await this.quarantineBackendGeneration(generation); - } - await this.settleBackendGenerationAfterRunExit(generation); - await this.flushBackendInvalidation(active.sessionId); - } - private async settleRunStopOperation(sessionId: string, run: AgentRun): Promise { const operation = this.stopOperations.get(sessionId); if ( @@ -3316,9 +2825,7 @@ function continuationTargetRunHeaderForExecution(input: { toolMode: effectiveToolMode, createdAt: claimedAt, updatedAt: claimedAt, - ...(userInput.parentRunId ? { parentRunId: userInput.parentRunId } : {}), - ...(userInput.resumedFromRunId ? { resumedFromRunId: userInput.resumedFromRunId } : {}), - ...(userInput.retriedFromRunId ? { retriedFromRunId: userInput.retriedFromRunId } : {}), + parentRunId: continuation.sourceRunId, ...(userInput.parentTurnId ? { parentTurnId: userInput.parentTurnId } : {}), ...(userInput.retriedFromTurnId ? { retriedFromTurnId: userInput.retriedFromTurnId } : {}), ...(userInput.regeneratedFromTurnId @@ -3679,10 +3186,6 @@ class RuntimeRunOwnerScope { } } -function childActiveKey(sessionId: string, turnId: string): string { - return `${sessionId}:${turnId}`; -} - function effectiveOrchestrationForRun( run: AgentRunHeader, session: SessionHeader, @@ -3705,6 +3208,21 @@ function effectiveToolModeForRun(run: AgentRunHeader): ToolMode { return run.toolMode ?? DEFAULT_TOOL_MODE; } +function assertNoRemovedChildAgentRunLineage(input: UserMessageInput): void { + const legacy = input as UserMessageInput & { + parentRunId?: unknown; + resumedFromRunId?: unknown; + retriedFromRunId?: unknown; + }; + if ( + legacy.parentRunId !== undefined || + legacy.resumedFromRunId !== undefined || + legacy.retriedFromRunId !== undefined + ) { + throw new Error('Live Turn cannot use removed child AgentRun lineage'); + } +} + async function interactionResumeAllowed( interactionRun: RuntimeInteractionRunBinding | undefined, event: SessionEvent, diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index d9d31e06bf..62bdf8f6f2 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -60,8 +60,6 @@ import type { PersistedBackendKind, } from '@maka/core/session'; import type { - AgentSpec, - ChildAgentTurnInput, CreateSessionInput, BranchFromTurnInput, RegenerateTurnInput, @@ -126,7 +124,6 @@ import type { import type { ArtifactRecord } from '@maka/core/artifacts'; import type { ContinuationClaimV1 } from '@maka/core/runtime-boundary'; import type { - ContinuationClaimStateV1, RuntimeEventStore, RuntimeContinuationAuthorityStore, } from '@maka/core/runtime-event-store'; @@ -139,10 +136,7 @@ import type { import type { SubagentPreset } from '@maka/core/subagent-settings'; import type { ResolvedSubagentPreset } from './configured-subagent-catalog.js'; import { AGENT_GRAPH_OPERATOR_PROVISION_SCHEMA_VERSION } from '@maka/core/agent-graph-topology'; -import { - classifyRuntimeEventTerminalFact, - type RuntimeEventTerminalFact, -} from './runtime-event-read-model.js'; +import type { RuntimeEventTerminalFact } from './runtime-event-read-model.js'; import { RuntimeReadModel, RuntimeReadModelError, @@ -162,7 +156,6 @@ import { classifyTerminalRuntimeLedger, commitTerminalRunWithRuntimeFact, effectiveRunHeaderFromTerminalFact, - terminalRunHeaderMatchesFact, terminalRunStatusFromRuntimeEvent, } from './terminal-run-commit.js'; @@ -220,7 +213,6 @@ import { import { assertAgentDefinitionRunnable, buildToolsForAgentDefinition, - getBuiltinAgentDefinition, listBuiltinAgentDefinitions, requireBuiltinAgentDefinition, requireBuiltinAgentDefinitionByProfile, @@ -230,7 +222,6 @@ import { type AgentDefinitionListItem, type SubagentPresetListItem, } from './agent-catalog.js'; -import { buildRuntimeEventModelReplayPlan } from './model-history.js'; import { stableHash } from './request-shape.js'; import type { SubagentExecutionRef } from './subagent-execution.js'; import { @@ -293,17 +284,6 @@ export interface PlanAuthoritativeSafeBoundaryContinuationInput { expectedRuntimeEventHighWater?: number; } -export interface SpawnChildAgentInput { - parentRunId: string; - turnId?: string; - spec: AgentSpec; - prompt: string; - abortSignal?: AbortSignal; - onReady?: (input: { turnId: string; agentId: string; agentName: string }) => void | Promise; - /** Presentation-only observer for projecting child activity into a parent surface. */ - onEvent?: (event: SessionEvent) => void; -} - export interface SpawnChildSessionInput { spawnedBy: SubagentSessionParent['spawnedBy']; agentProfile: AgentProfile; @@ -331,10 +311,22 @@ type ResolvedSpawnChildSessionInput = SpawnChildSessionInput & { resolvedPreset?: ResolvedSubagentPreset; }; -export interface SpawnChildSessionResult extends SpawnChildAgentResult { +export interface SpawnChildSessionResult { childSessionId: string; + agentId: string; + agentName: string; + turnId: string; runId: string; profile: string; + status: 'completed' | 'failed' | 'cancelled' | 'running' | 'waiting_for_user'; + permissionMode: PermissionMode; + summary: string; + artifactIds: string[]; + startedAt: number; + completedAt: number; + durationMs: number; + eventCount: number; + failureClass?: string; } export interface ProvisionAgentGraphOperatorInput { @@ -401,66 +393,6 @@ type ResolvedClaimedAgentGraphIntentInput = Omit< hostedGraphExecution?: RuntimeHostedAgentGraphExecutionCapability; }; -export interface PrepareChildAgentResumeResult { - sourceRunId: string; - execution: SubagentExecutionRef; - agentId: string; - agentName: string; - profile: string; -} - -export interface ResumeChildAgentInput { - parentRunId: string; - sourceRunId: string; - turnId?: string; - prompt: string; - abortSignal?: AbortSignal; - onReady?: (input: { - childSessionId?: string; - turnId: string; - runId?: string; - agentId: string; - agentName: string; - }) => void | Promise; - /** Presentation-only observer for projecting child activity into a parent surface. */ - onEvent?: (event: SessionEvent) => void; -} - -export interface SpawnChildAgentResult { - childSessionId?: string; - agentId: string; - agentName: string; - turnId: string; - runId?: string; - status: 'completed' | 'failed' | 'cancelled' | 'running' | 'waiting_for_user'; - permissionMode: PermissionMode; - summary: string; - artifactIds: string[]; - startedAt: number; - completedAt: number; - durationMs: number; - eventCount: number; - failureClass?: string; - resumedFromRunId?: string; - retriedFromRunId?: string; -} - -export interface RetryChildAgentInput { - parentRunId: string; - sourceRunId: string; - execution?: SubagentExecutionRef; - abortSignal?: AbortSignal; - onReady?: (input: { - childSessionId?: string; - turnId: string; - runId?: string; - agentId: string; - agentName: string; - }) => void | Promise; - /** Presentation-only observer for projecting child activity into a parent surface. */ - onEvent?: (event: SessionEvent) => void; -} - const CHILD_AGENT_SUMMARY_MAX_CHARS = 4_000; const MAX_RUNTIME_LEDGER_REPAIR_ATTEMPTS = 8; @@ -720,9 +652,8 @@ export interface BackendFactoryContext { abortSignal?: AbortSignal; appendMessage?: (message: StoredMessage) => Promise; /** - * Child-agent instruction channel. Legacy child runs and linked child - * sessions populate this; an ordinary main-session activation leaves it - * undefined. A + * Child-agent instruction channel. Linked child sessions populate this; an + * ordinary main-session activation leaves it undefined. A * main-session factory that needs a system prompt must source it from * its own closure — do NOT route a main-session prompt through this * field, it is semantically the child instruction, not the session @@ -2308,35 +2239,8 @@ export class SessionManager { await this.runtimeKernel.preflightContextCompaction(sessionId); } - async *startChildTurn( - sessionId: string, - input: ChildAgentTurnInput, - ): AsyncIterable { - const execution = this.runtimeKernel.claimExecution(sessionId); - try { - await this.ensureChildWorkspace(await this.deps.store.readHeader(sessionId)); - yield* this.runtimeKernel.startChildTurn(sessionId, input, execution); - } finally { - execution.release(); - } - } - - async spawnChildAgent( - sessionId: string, - input: SpawnChildAgentInput, - ): Promise { - const execution = this.runtimeKernel.claimExecution(sessionId); - try { - const definition = requireBuiltinAgentDefinition(input.spec.id); - return await this.runChildAgent(sessionId, definition, input, execution); - } finally { - execution.release(); - } - } - /** - * Create and run a durable linked child Session without changing the - * parent-facing agent_spawn compatibility path. + * Create and run a durable linked child Session. * * Cross-session provenance lives on the child header. The first AgentRun * intentionally carries no parentRunId, so it is an ordinary session-inline @@ -3347,304 +3251,6 @@ export class SessionManager { }; } - async prepareChildAgentResume( - sessionId: string, - sourceRunId: string, - ): Promise { - if (!this.deps.runStore || !this.deps.runtimeEventStore) { - throw new Error('Child AgentRun resume requires AgentRunStore and RuntimeEventStore'); - } - const execution = await this.resolveChildAgentExecution(sessionId, sourceRunId); - return await this.prepareChildAgentResumeForExecution(sessionId, execution, sourceRunId); - } - - private async prepareChildAgentResumeForExecution( - sessionId: string, - execution: SubagentExecutionRef, - sourceRunId: string, - ): Promise { - if (!this.deps.runStore || !this.deps.runtimeEventStore) { - throw new Error('Child AgentRun resume requires AgentRunStore and RuntimeEventStore'); - } - if (execution.kind === 'child_session') { - return await this.prepareLinkedChildSessionResume(sessionId, execution, sourceRunId); - } - const runs = await this.deps.runStore.listSessionRuns(sessionId); - const runsById = new Map(runs.map((run) => [run.runId, run])); - const source = runsById.get(sourceRunId); - if (!source || !source.parentRunId || isSessionInlineRun(source)) { - throw new Error(`Child AgentRun resume source ${sourceRunId} was not found`); - } - const definition = source.agentId ? getBuiltinAgentDefinition(source.agentId) : undefined; - if (!definition) { - throw new Error(`AgentRun ${sourceRunId} is not a resumable built-in child agent`); - } - - const sessionHeader = await this.deps.store.readHeader(sessionId); - await this.ensureChildWorkspace(sessionHeader); - assertAgentDefinitionRunnable({ - definition, - tools: await this.childToolsForSession(sessionId), - worktreeChildExecutorAvailable: await this.isWorktreeChildExecutorAvailable(sessionHeader), - }); - const visited = new Set(); - let cursor: AgentRunHeader | undefined = source; - while (cursor) { - if (visited.has(cursor.runId)) { - throw new Error(`Child AgentRun resume lineage contains a cycle at ${cursor.runId}`); - } - visited.add(cursor.runId); - if (!cursor.parentRunId || isSessionInlineRun(cursor) || cursor.agentId !== definition.id) { - throw new Error(`Child AgentRun resume profile changed at ${cursor.runId}`); - } - if ( - cursor.backendKind !== sessionHeader.backend || - (cursor.llmConnectionId !== undefined && - cursor.llmConnectionId !== sessionHeader.llmConnectionId) || - cursor.llmConnectionSlug !== sessionHeader.llmConnectionSlug || - cursor.modelId !== sessionHeader.model || - cursor.cwd !== sessionHeader.cwd || - cursor.permissionMode !== definition.permissionMode - ) { - throw new Error(`Child AgentRun resume environment changed for ${cursor.runId}`); - } - - const events = await this.deps.runtimeEventStore - .readRuntimeEvents(sessionId, cursor.runId) - .catch(() => []); - const replay = buildRuntimeEventModelReplayPlan(events); - const unsafe = replay.diagnostics.find((diagnostic) => - isUnsafeChildResumeDiagnostic(diagnostic.code), - ); - if (unsafe) { - throw new Error(`Child AgentRun resume history is unsafe: ${unsafe.code}`); - } - const first = replay.items[0]; - if (!first || first.kind !== 'text' || first.role !== 'user') { - throw new Error( - `Child AgentRun resume source ${cursor.runId} has no user-anchored history`, - ); - } - const terminal = classifyTerminalRuntimeLedger(cursor, events); - if (terminal.kind !== 'fact') { - throw new Error(`Child AgentRun resume source ${cursor.runId} is not durably terminal`); - } - const effective = effectiveRunHeaderFromTerminalFact(cursor, terminal.fact); - if (!['completed', 'failed', 'cancelled'].includes(effective.status)) { - throw new Error(`Child AgentRun resume source ${cursor.runId} is not in a resumable state`); - } - - const previousRunId = cursor.resumedFromRunId ?? cursor.retriedFromRunId; - if (!previousRunId) break; - cursor = runsById.get(previousRunId); - if (!cursor) { - throw new Error(`Child AgentRun resume source ${previousRunId} was not found`); - } - } - - this.assertChildRunHasNoSuccessor(runs, sourceRunId); - return { - sourceRunId, - execution, - agentId: definition.id, - agentName: definition.name, - profile: definition.profile, - }; - } - - private async resolveChildAgentExecution( - parentSessionId: string, - sourceRunId: string, - ): Promise { - if (!this.deps.runStore) { - throw new Error('Child AgentRun lookup requires AgentRunStore'); - } - const legacy = await this.deps.runStore.readRun(parentSessionId, sourceRunId).catch((error) => { - if (isNotFoundError(error)) return undefined; - throw error; - }); - if (legacy?.parentRunId && !isSessionInlineRun(legacy)) { - return { - kind: 'legacy_child_run', - sessionId: parentSessionId, - runId: sourceRunId, - }; - } - - const children = await this.listChildSessions(parentSessionId); - const matches = ( - await Promise.all( - children.map(async (child) => { - const run = await this.deps.runStore!.readRun(child.id, sourceRunId).catch((error) => { - if (isNotFoundError(error)) return undefined; - throw error; - }); - return run && isSessionInlineRun(run) ? child.id : undefined; - }), - ) - ).filter((childSessionId): childSessionId is string => childSessionId !== undefined); - if (matches.length !== 1) { - throw new Error(`Child AgentRun resume source ${sourceRunId} was not found`); - } - return { - kind: 'child_session', - sessionId: matches[0]!, - currentRunId: sourceRunId, - }; - } - - private async prepareLinkedChildSessionResume( - parentSessionId: string, - execution: Extract, - sourceRunId: string, - ): Promise { - if (!this.deps.runStore || !this.deps.runtimeEventStore) { - throw new Error('Child Session resume requires AgentRunStore and RuntimeEventStore'); - } - const child = await this.deps.store.readHeader(execution.sessionId); - const snapshot = child.subagentRuntime; - if ( - child.subagentParent?.kind !== 'subagent' || - child.subagentParent.parentSessionId !== parentSessionId || - !snapshot - ) { - throw new Error(`Child AgentRun resume source ${sourceRunId} was not found`); - } - await this.ensureChildWorkspace(child); - await this.assertLinkedChildBoundaryMatchesParent(parentSessionId, child.id); - const runnableTools = buildToolsForAgentDefinition(await this.childToolsForSession(child.id), { - id: snapshot.agentId, - permissionMode: child.permissionMode, - tools: snapshot.toolNames, - }); - if (runnableTools.length !== snapshot.toolNames.length) { - throw new Error('Child Session durable runtime tool snapshot is unavailable'); - } - - const runs = await this.deps.runStore.listSessionRuns(child.id); - const runsById = new Map(runs.map((run) => [run.runId, run])); - const source = runsById.get(sourceRunId); - if (!source || !isSessionInlineRun(source)) { - throw new Error(`Child AgentRun resume source ${sourceRunId} was not found`); - } - const visited = new Set(); - const replaySegments: RuntimeEvent[][] = []; - let cursor: AgentRunHeader | undefined = source; - while (cursor) { - if (visited.has(cursor.runId)) { - throw new Error(`Child AgentRun resume lineage contains a cycle at ${cursor.runId}`); - } - visited.add(cursor.runId); - if (!isSessionInlineRun(cursor) || cursor.agentId !== snapshot.agentId) { - throw new Error(`Child AgentRun resume profile changed at ${cursor.runId}`); - } - if ( - cursor.backendKind !== child.backend || - (cursor.llmConnectionId !== undefined && - cursor.llmConnectionId !== child.llmConnectionId) || - cursor.llmConnectionSlug !== child.llmConnectionSlug || - cursor.modelId !== child.model || - cursor.cwd !== child.cwd || - cursor.permissionMode !== child.permissionMode - ) { - throw new Error(`Child AgentRun resume environment changed for ${cursor.runId}`); - } - - const events = await this.deps.runtimeEventStore - .readRuntimeEvents(child.id, cursor.runId) - .catch(() => []); - const replay = buildRuntimeEventModelReplayPlan(events); - const unsafe = replay.diagnostics.find((diagnostic) => - isUnsafeChildResumeDiagnostic(diagnostic.code), - ); - if (unsafe) { - throw new Error(`Child AgentRun resume history is unsafe: ${unsafe.code}`); - } - replaySegments.unshift(events); - const terminal = classifyTerminalRuntimeLedger(cursor, events); - if (terminal.kind !== 'fact') { - throw new Error(`Child AgentRun resume source ${cursor.runId} is not durably terminal`); - } - const effective = effectiveRunHeaderFromTerminalFact(cursor, terminal.fact); - if (!['completed', 'failed', 'cancelled'].includes(effective.status)) { - throw new Error(`Child AgentRun resume source ${cursor.runId} is not in a resumable state`); - } - - const previousRunId = cursor.resumedFromRunId ?? cursor.retriedFromRunId; - if (!previousRunId) break; - cursor = runsById.get(previousRunId); - if (!cursor) { - throw new Error(`Child AgentRun resume source ${previousRunId} was not found`); - } - } - - const replay = buildRuntimeEventModelReplayPlan(replaySegments.flat()); - const first = replay.items[0]; - if (!first || first.kind !== 'text' || first.role !== 'user') { - throw new Error(`Child AgentRun resume source ${sourceRunId} has no user-anchored history`); - } - await this.effectiveRunHeaderFromRuntimeLedger(source); - await this.finalizeChildWorkspacePatches(child.id); - this.assertChildRunHasNoSuccessor(runs, sourceRunId); - return { - sourceRunId, - execution, - agentId: snapshot.agentId, - agentName: snapshot.agentName, - profile: snapshot.profile, - }; - } - - async resumeChildAgent( - sessionId: string, - input: ResumeChildAgentInput, - ): Promise { - let runtimeExecution = this.runtimeKernel.claimExecution(sessionId); - try { - throwIfChildExecutionAborted( - input.abortSignal, - 'Child agent resume was cancelled before start', - ); - const execution = await this.resolveChildAgentExecution(sessionId, input.sourceRunId); - if (execution.kind === 'child_session') { - runtimeExecution = this.transferRuntimeExecution(runtimeExecution, execution.sessionId); - const releaseHostedExecution = this.acquireHostedLinkedChildExecution(execution.sessionId); - try { - const prepared = await this.prepareChildAgentResumeForExecution( - sessionId, - execution, - input.sourceRunId, - ); - return await this.resumeLinkedChildSession( - sessionId, - execution, - prepared, - input, - runtimeExecution, - true, - ); - } finally { - releaseHostedExecution(); - } - } - const prepared = await this.prepareChildAgentResumeForExecution( - sessionId, - execution, - input.sourceRunId, - ); - const definition = getBuiltinAgentDefinition(prepared.agentId)!; - return await this.runChildAgent( - sessionId, - definition, - input, - runtimeExecution, - input.sourceRunId, - ); - } finally { - runtimeExecution.release(); - } - } - private transferRuntimeExecution( source: RuntimeExecutionClaim, targetSessionId: string, @@ -3679,278 +3285,6 @@ export class SessionManager { return target; } - private async resumeLinkedChildSession( - parentSessionId: string, - execution: Extract, - prepared: PrepareChildAgentResumeResult, - input: ResumeChildAgentInput, - runtimeExecution: RuntimeExecutionClaim, - hostedGateAlreadyHeld: boolean, - ): Promise { - throwIfChildExecutionAborted( - input.abortSignal, - 'Child agent resume was cancelled before start', - ); - if (!this.deps.runStore) { - throw new Error('Child Session resume requires AgentRunStore'); - } - const [parentRun, child] = await Promise.all([ - this.deps.runStore.readRun(parentSessionId, input.parentRunId), - this.deps.store.readHeader(execution.sessionId), - ]); - this.assertActiveParentRun(parentSessionId, parentRun, parentRun.turnId); - if ( - child.subagentParent?.kind !== 'subagent' || - child.subagentParent.parentSessionId !== parentSessionId || - child.subagentRuntime?.agentId !== prepared.agentId - ) { - throw new Error(`Child AgentRun resume source ${input.sourceRunId} was not found`); - } - - const turnId = input.turnId ?? this.deps.newId(); - const runId = this.deps.newId(); - const startedAt = this.deps.now(); - const summary = new ChildAgentSummaryAccumulator(); - let aborted = input.abortSignal?.aborted === true; - let stopPromise: Promise | undefined; - const identity = { sessionId: child.id, turnId, runId }; - throwIfChildExecutionAborted( - input.abortSignal, - 'Child agent resume was cancelled before start', - ); - const executionPromise = this.consumeLinkedRootExecution( - { - ...identity, - userMessageId: this.deps.newId(), - execution: { - kind: 'linked_child_resume', - agentId: prepared.agentId, - agentName: prepared.agentName, - sourceRunId: input.sourceRunId, - }, - content: { text: input.prompt }, - start: ({ runId: admittedRunId, userMessageId, onRunStarted }) => - this.sendMessage( - child.id, - { - turnId, - text: input.prompt, - resumedFromRunId: input.sourceRunId, - agentId: prepared.agentId, - agentName: prepared.agentName, - }, - { - runId: admittedRunId, - ...(userMessageId ? { userMessageId } : {}), - durability: 'required', - onRunStarted, - execution: runtimeExecution, - }, - ), - onReady: () => - input.onReady?.({ - childSessionId: child.id, - turnId, - runId, - agentId: prepared.agentId, - agentName: prepared.agentName, - }), - onEvent: (event) => { - summary.add(event); - try { - input.onEvent?.(event); - } catch { - // A presentation observer must not change the child run outcome. - } - }, - }, - hostedGateAlreadyHeld, - ); - const onAbort = () => { - aborted = true; - stopPromise ??= this.stopLinkedRoot(identity, { source: 'stop_button' }); - }; - if (input.abortSignal) { - input.abortSignal.addEventListener('abort', onAbort, { once: true }); - if (input.abortSignal.aborted) onAbort(); - } - try { - await executionPromise; - } finally { - input.abortSignal?.removeEventListener('abort', onAbort); - if (aborted) await stopPromise; - } - - const completedAt = this.deps.now(); - const run = await this.findRunByTurnId(child.id, turnId); - const failureClass = run?.failureClass ?? summary.failureClass; - const artifacts = run - ? await this.finalizeAndListChildTurnArtifacts(child.id, turnId, run.status) - : []; - return { - childSessionId: child.id, - agentId: prepared.agentId, - agentName: prepared.agentName, - turnId, - runId, - resumedFromRunId: input.sourceRunId, - status: run ? agentRunStatusForSpawnResult(run.status) : summary.status(aborted), - permissionMode: child.permissionMode, - summary: summary.text(), - artifactIds: artifacts.map((artifact) => artifact.id), - startedAt, - completedAt, - durationMs: Math.max(0, completedAt - startedAt), - eventCount: summary.eventCount, - ...(failureClass ? { failureClass } : {}), - }; - } - - private async runChildAgent( - sessionId: string, - definition: AgentDefinition, - input: SpawnChildAgentInput | ResumeChildAgentInput, - execution: RuntimeExecutionClaim, - resumedFromRunId?: string, - ): Promise { - throwIfChildExecutionAborted( - input.abortSignal, - 'Child agent execution was cancelled before start', - ); - const turnId = input.turnId ?? this.deps.newId(); - const startedAt = this.deps.now(); - const summary = new ChildAgentSummaryAccumulator(); - let aborted = input.abortSignal?.aborted === true; - await input.onReady?.({ turnId, agentId: definition.id, agentName: definition.name }); - const iterator = this.runtimeKernel - .startChildTurn( - sessionId, - { - turnId, - parentRunId: input.parentRunId, - spec: { - id: definition.id, - name: definition.name, - systemPrompt: definition.systemPrompt, - }, - prompt: input.prompt, - ...(resumedFromRunId ? { resumedFromRunId } : {}), - }, - execution, - ) - [Symbol.asyncIterator](); - const onAbort = () => { - aborted = true; - void iterator.return?.(); - }; - if (input.abortSignal && !input.abortSignal.aborted) { - input.abortSignal.addEventListener('abort', onAbort, { once: true }); - } - try { - while (!aborted) { - const next = await iterator.next(); - if (next.done) break; - summary.add(next.value); - try { - input.onEvent?.(next.value); - } catch { - // A presentation observer must not change the child run outcome. - } - } - } finally { - input.abortSignal?.removeEventListener('abort', onAbort); - if (aborted) await iterator.return?.(); - } - - const completedAt = this.deps.now(); - const run = await this.findRunByTurnId(sessionId, turnId); - const failureClass = run?.failureClass ?? summary.failureClass; - const artifacts = run - ? await this.finalizeAndListChildTurnArtifacts(sessionId, turnId, run.status) - : []; - return { - agentId: definition.id, - agentName: definition.name, - turnId, - ...(run?.runId ? { runId: run.runId } : {}), - status: run ? agentRunStatusForSpawnResult(run.status) : summary.status(aborted), - permissionMode: definition.permissionMode, - summary: summary.text(), - artifactIds: artifacts.map((artifact) => artifact.id), - startedAt, - completedAt, - durationMs: Math.max(0, completedAt - startedAt), - eventCount: summary.eventCount, - ...(failureClass ? { failureClass } : {}), - ...(resumedFromRunId ? { resumedFromRunId } : {}), - }; - } - - async retryChildAgent( - sessionId: string, - input: RetryChildAgentInput, - ): Promise { - let runtimeExecution = this.runtimeKernel.claimExecution(sessionId); - let hintedTargetExecution: RuntimeExecutionClaim | undefined; - try { - if (input.execution?.kind === 'child_session') { - hintedTargetExecution = this.runtimeKernel.claimExecution(input.execution.sessionId); - } - throwIfChildExecutionAborted( - input.abortSignal, - 'Child agent retry was cancelled before start', - ); - if (!this.deps.runStore || !this.deps.runtimeEventStore) { - throw new Error('Child agent retry requires AgentRunStore and RuntimeEventStore'); - } - const execution = await this.resolveChildAgentExecution(sessionId, input.sourceRunId); - this.assertChildRetryExecutionIdentity(input, execution); - if (execution.kind === 'child_session') { - if (hintedTargetExecution) { - runtimeExecution = this.handoffRuntimeExecution(runtimeExecution, hintedTargetExecution); - hintedTargetExecution = undefined; - } else { - runtimeExecution = this.transferRuntimeExecution(runtimeExecution, execution.sessionId); - } - } - const releaseHostedExecution = - execution.kind === 'child_session' - ? this.acquireHostedLinkedChildExecution(execution.sessionId) - : () => {}; - try { - return await this.retryChildAgentWithExecution( - sessionId, - input, - execution, - runtimeExecution, - ); - } finally { - releaseHostedExecution(); - } - } finally { - hintedTargetExecution?.release(); - runtimeExecution.release(); - } - } - - private assertChildRetryExecutionIdentity( - input: RetryChildAgentInput, - execution: SubagentExecutionRef, - ): void { - if ( - input.execution && - (input.execution.kind !== execution.kind || - input.execution.sessionId !== execution.sessionId || - (input.execution.kind === 'legacy_child_run' && - (execution.kind !== 'legacy_child_run' || input.execution.runId !== execution.runId)) || - (input.execution.kind === 'child_session' && - input.execution.currentRunId !== undefined && - input.execution.currentRunId !== input.sourceRunId)) - ) { - throw new Error('Child agent retry execution identity changed'); - } - } - private async assertLinkedChildBoundaryMatchesParent( parentSessionId: string, childSessionId: string, @@ -3964,314 +3298,6 @@ export class SessionManager { } } - private async retryChildAgentWithExecution( - sessionId: string, - input: RetryChildAgentInput, - execution: SubagentExecutionRef, - runtimeExecution: RuntimeExecutionClaim, - ): Promise { - throwIfChildExecutionAborted(input.abortSignal, 'Child agent retry was cancelled before start'); - if (!this.deps.runStore || !this.deps.runtimeEventStore) { - throw new Error('Child agent retry requires AgentRunStore and RuntimeEventStore'); - } - const targetSessionId = execution.sessionId; - const runs = await this.deps.runStore.listSessionRuns(targetSessionId); - const rawSourceRun = runs.find((run) => run.runId === input.sourceRunId); - if (!rawSourceRun) throw new Error('Child agent retry source run was not found'); - const sourceRun = await this.effectiveRunHeaderFromRuntimeLedger(rawSourceRun); - let definition: { - id: string; - name: string; - systemPrompt: string; - permissionMode: PermissionMode; - toolNames: string[]; - }; - if (execution.kind === 'child_session') { - const [parentRun, child] = await Promise.all([ - this.deps.runStore.readRun(sessionId, input.parentRunId), - this.deps.store.readHeader(targetSessionId), - ]); - this.assertActiveParentRun(sessionId, parentRun, parentRun.turnId); - const snapshot = child.subagentRuntime; - if ( - child.subagentParent?.kind !== 'subagent' || - child.subagentParent.parentSessionId !== sessionId || - !snapshot || - !isSessionInlineRun(sourceRun) || - sourceRun.agentId !== snapshot.agentId - ) { - throw new Error('Child agent retry source does not belong to the parent Session'); - } - await this.ensureChildWorkspace(child); - await this.assertLinkedChildBoundaryMatchesParent(sessionId, child.id); - definition = { - id: snapshot.agentId, - name: snapshot.agentName, - systemPrompt: snapshot.systemPrompt, - permissionMode: child.permissionMode, - toolNames: [...snapshot.toolNames], - }; - } else { - if (!sourceRun.parentRunId || sourceRun.parentRunId !== input.parentRunId) { - throw new Error('Child agent retry source does not belong to the active parent run'); - } - if (!sourceRun.agentId) throw new Error('Child agent retry source is missing its agent id'); - const resolved = requireBuiltinAgentDefinition(sourceRun.agentId); - definition = { - ...resolved, - toolNames: buildToolsForAgentDefinition( - await this.childToolsForSession(sessionId), - resolved, - ).map((tool) => tool.name), - }; - } - const authority = runtimeContinuationAuthority(this.deps.runtimeEventStore); - const abandonedBeforeProvider = - sourceRun.failureClass === 'continuation_abandoned_before_provider_dispatch' - ? await isProvenRecoveredContinuationAbandonment(authority, sourceRun) - : false; - if ( - sourceRun.status !== 'failed' || - (sourceRun.failureClass !== 'RateLimit' && !abandonedBeforeProvider) - ) { - throw new Error( - 'Child agent retry source must be a provider rate-limit failure or a proven pre-provider continuation abandonment', - ); - } - if (execution.kind === 'child_session') { - await this.finalizeChildWorkspacePatches(targetSessionId); - } - this.assertChildRunHasNoSuccessor(runs, sourceRun.runId); - - if (!authority || !this.deps.inspectContinuationSafety) { - throw new Error( - 'Child agent retry continuation authority composition is incomplete; refusing legacy fallback', - ); - } - const continuation = await this.planDurableChildProviderRetry({ - targetSessionId, - sourceRun, - runs, - authority, - inspectSafety: this.deps.inspectContinuationSafety, - availableToolNames: definition.toolNames, - }); - const retryReplay = buildRuntimeEventModelReplayPlan(continuation.runtimeContext); - const retryAnchor = retryReplay.items[0]; - if (!retryAnchor || retryAnchor.kind !== 'text' || retryAnchor.role !== 'user') { - throw new Error('Child agent retry source has no user-anchored history'); - } - const { turnId, runId } = continuation; - - const startedAt = this.deps.now(); - const summary = new ChildAgentSummaryAccumulator(); - let aborted = input.abortSignal?.aborted === true; - const startChildRetry = this.runtimeKernel.startChildRetry; - if (!startChildRetry) throw new Error('RuntimeKernel does not support child agent retry'); - const linkedIdentity = { sessionId: targetSessionId, turnId, runId }; - const emitReady = () => - input.onReady?.({ - ...(execution.kind === 'child_session' - ? { childSessionId: execution.sessionId, runId } - : {}), - turnId, - agentId: definition.id, - agentName: definition.name, - }); - const consumeEvent = (event: SessionEvent) => { - summary.add(event); - try { - input.onEvent?.(event); - } catch { - // A presentation observer must not change the child run outcome. - } - }; - throwIfChildExecutionAborted(input.abortSignal, 'Child agent retry was cancelled before start'); - const executionPromise = - execution.kind === 'child_session' - ? this.consumeLinkedRootExecution( - { - ...linkedIdentity, - userMessageId: null, - execution: { - kind: 'linked_child_provider_retry', - agentId: definition.id, - agentName: definition.name, - sourceRunId: sourceRun.runId, - }, - content: { - text: retryAnchor.content, - ...(retryAnchor.attachments ? { attachments: retryAnchor.attachments } : {}), - }, - start: ({ onRunStarted }) => - startChildRetry.call( - this.runtimeKernel, - targetSessionId, - { - parentRunId: input.parentRunId, - spec: { - id: definition.id, - name: definition.name, - systemPrompt: definition.systemPrompt, - }, - continuation, - linkedSession: true, - onRunStarted, - }, - runtimeExecution, - ), - onReady: emitReady, - onEvent: consumeEvent, - }, - true, - ) - : this.consumeRuntimeEvents( - startChildRetry.call( - this.runtimeKernel, - targetSessionId, - { - parentRunId: input.parentRunId, - spec: { - id: definition.id, - name: definition.name, - systemPrompt: definition.systemPrompt, - }, - continuation, - }, - runtimeExecution, - ), - emitReady, - consumeEvent, - ); - let stopPromise: Promise | undefined; - const onAbort = () => { - aborted = true; - if (execution.kind === 'child_session') { - stopPromise ??= this.stopLinkedRoot(linkedIdentity, { source: 'stop_button' }); - } else { - stopPromise ??= this.runtimeKernel.stopSession(targetSessionId, { - source: 'stop_button', - }); - } - }; - if (input.abortSignal) { - input.abortSignal.addEventListener('abort', onAbort, { once: true }); - if (input.abortSignal.aborted) onAbort(); - } - try { - await executionPromise; - } finally { - input.abortSignal?.removeEventListener('abort', onAbort); - if (aborted) await stopPromise; - } - - const completedAt = this.deps.now(); - const run = await this.findRunByTurnId(targetSessionId, turnId); - const failureClass = run?.failureClass ?? summary.failureClass; - const artifacts = run - ? await this.finalizeAndListChildTurnArtifacts(targetSessionId, turnId, run.status) - : []; - return { - ...(execution.kind === 'child_session' ? { childSessionId: execution.sessionId } : {}), - agentId: definition.id, - agentName: definition.name, - turnId, - ...(run?.runId ? { runId: run.runId } : { runId }), - retriedFromRunId: sourceRun.runId, - status: run ? agentRunStatusForSpawnResult(run.status) : summary.status(aborted), - permissionMode: definition.permissionMode, - summary: summary.text(), - artifactIds: artifacts.map((artifact) => artifact.id), - startedAt, - completedAt, - durationMs: Math.max(0, completedAt - startedAt), - eventCount: summary.eventCount, - ...(failureClass ? { failureClass } : {}), - }; - } - - private async planDurableChildProviderRetry(input: { - targetSessionId: string; - sourceRun: AgentRunHeader; - runs: readonly AgentRunHeader[]; - authority: RuntimeContinuationAuthorityStore; - inspectSafety: (sessionId: string) => Promise; - availableToolNames: readonly string[]; - }): Promise { - const runsById = new Map(input.runs.map((run) => [run.runId, run])); - const planner = new RuntimeContinuationPlanner({ - readSourceRun: async (_sessionId, runId) => { - const raw = runsById.get(runId); - if (!raw) throw new Error(`Child agent retry lineage source ${runId} is missing`); - const effective = - runId === input.sourceRun.runId - ? input.sourceRun - : await this.effectiveRunHeaderFromRuntimeLedger(raw); - if (effective.continuationSource) return effective; - const previousRunId = effective.retriedFromRunId ?? effective.resumedFromRunId; - if (!previousRunId) return effective; - const previous = runsById.get(previousRunId); - if (!previous) { - throw new Error(`Child agent retry lineage source ${previousRunId} is missing`); - } - const prefix = await input.authority.readImmutableRuntimePrefix({ - sessionId: input.targetSessionId, - runId: previousRunId, - }); - return { - ...effective, - continuationSource: { - sourceInvocationId: prefix.identity.invocationId, - sourceRunId: prefix.identity.runId, - sourceTurnId: prefix.identity.turnId, - sourceRuntimeEventHighWater: prefix.position.lastEventSeq, - }, - }; - }, - readImmutableRuntimePrefix: (prefixInput) => - input.authority.readImmutableRuntimePrefix(prefixInput), - readContinuationClaimStateByBoundary: (boundaryDigest) => - input.authority.readContinuationClaimStateByBoundary(boundaryDigest), - findExistingContinuation: async (_sessionId, sourceRunId, sourceRuntimeEventHighWater) => - input.runs.find( - (run) => - run.continuationSource?.sourceRunId === sourceRunId && - run.continuationSource.sourceRuntimeEventHighWater === sourceRuntimeEventHighWater, - ), - newId: this.deps.newId, - }); - const [targetHeader, safety] = await Promise.all([ - this.deps.store.readHeader(input.targetSessionId), - input.inspectSafety(input.targetSessionId), - ]); - const retryPlan = await planner.plan({ - sessionId: input.targetSessionId, - sourceRunId: input.sourceRun.runId, - currentCwd: targetHeader.cwd, - sourceWorkspaceIdentity: input.sourceRun.workspaceIdentity ?? input.sourceRun.cwd, - currentWorkspaceIdentity: safety.workspaceIdentity, - backgroundOperationsSettled: safety.backgroundOperationsSettled, - availableToolNames: input.availableToolNames, - ...(safety.workspaceCheckpoint ? { workspaceCheckpoint: safety.workspaceCheckpoint } : {}), - }); - if (retryPlan.disposition !== 'continue' || !retryPlan.continuation) { - throw new Error( - `Child agent retry source is not safely replayable: ${retryPlan.rejectionReasons.join(', ')}`, - ); - } - return retryPlan.continuation; - } - - private assertChildRunHasNoSuccessor(runs: readonly AgentRunHeader[], sourceRunId: string): void { - if ( - runs.some( - (run) => run.resumedFromRunId === sourceRunId || run.retriedFromRunId === sourceRunId, - ) - ) { - throw new Error(`Child AgentRun ${sourceRunId} already has a successor`); - } - } - async listChildAgents(sessionId: string): Promise { const [header, tools] = await Promise.all([ this.deps.store.readHeader(sessionId), @@ -6035,64 +5061,6 @@ function assertClaimOwnsHostedLinkedChildAdmission( } } -async function isProvenRecoveredContinuationAbandonment( - authority: RuntimeContinuationAuthorityStore | undefined, - run: AgentRunHeader, -): Promise { - const continuationSource = run.continuationSource; - const continuationSourceV2 = - continuationSource !== undefined && - 'protocol' in continuationSource && - continuationSource.protocol === 'continuation_source_v2' - ? continuationSource - : undefined; - if ( - !authority || - !continuationSourceV2 || - run.failureClass !== 'continuation_abandoned_before_provider_dispatch' - ) { - return false; - } - let state: ContinuationClaimStateV1 | undefined; - let events: RuntimeEvent[]; - try { - state = await authority.readContinuationClaimStateByBoundary( - continuationSourceV2.boundaryDigest, - ); - events = await authority.readImmutableRuntimeEvents(run.sessionId, run.runId); - } catch { - return false; - } - if (!state) return false; - const { claim } = state; - const expectedStart = buildContinuationRepairStartEvent(claim); - const expectedTerminal = buildRecoveredTerminalRuntimeEvent({ - id: continuationRepairEventId('terminal', claim.claimId), - run: claim.targetRunHeader, - status: 'failed', - ts: Math.max(expectedStart.ts + 1, claim.claimedAt + 1), - recoveryReason: 'continuation_abandoned_before_provider_dispatch', - invocationId: claim.target.invocationId, - failureClass: 'continuation_abandoned_before_provider_dispatch', - message: 'continuation_abandoned_before_provider_dispatch', - }); - const terminalFact = classifyRuntimeEventTerminalFact(run, events).fact; - return ( - claim.claimId === continuationSourceV2.claimId && - claim.target.sessionId === run.sessionId && - claim.target.invocationId === run.invocationId && - claim.target.runId === run.runId && - claim.target.turnId === run.turnId && - claimTargetRunHeaderIsCompatible(run, claim.targetRunHeader) && - state.startEventId === expectedStart.id && - events.length === 2 && - isDeepStrictEqual(events[0], expectedStart) && - isDeepStrictEqual(events[1], expectedTerminal) && - terminalFact?.terminalEvent.id === expectedTerminal.id && - terminalRunHeaderMatchesFact(run, terminalFact) - ); -} - async function readImmutableRuntimeEventsOrEmpty( authority: RuntimeContinuationAuthorityStore, sessionId: string, @@ -6304,7 +5272,7 @@ function narrowsExecutionAuthority( function agentRunStatusForSpawnResult( status: AgentRunHeader['status'], -): SpawnChildAgentResult['status'] { +): SpawnChildSessionResult['status'] { if (status === 'waiting_for_user') return 'waiting_for_user'; if (status === 'cancelled') return 'cancelled'; if (status === 'failed') return 'failed'; @@ -6312,16 +5280,6 @@ function agentRunStatusForSpawnResult( return 'completed'; } -function isUnsafeChildResumeDiagnostic(code: string): boolean { - return ( - code === 'unmatched_tool_call' || - code === 'unmatched_tool_result' || - code === 'tool_id_mismatch' || - code === 'unsupported_role' || - code === 'unsupported_content' - ); -} - function trimSummary(text: string): string { const trimmed = text.trim(); return trimmed.length <= CHILD_AGENT_SUMMARY_MAX_CHARS @@ -6332,7 +5290,7 @@ function trimSummary(text: string): string { class ChildAgentSummaryAccumulator { eventCount = 0; failureClass: string | undefined; - private terminalStatus: SpawnChildAgentResult['status'] | undefined; + private terminalStatus: SpawnChildSessionResult['status'] | undefined; private lastTextComplete = ''; private textDeltaTail = ''; private textDeltaTruncated = false; @@ -6363,7 +5321,7 @@ class ChildAgentSummaryAccumulator { } } - status(aborted: boolean): SpawnChildAgentResult['status'] { + status(aborted: boolean): SpawnChildSessionResult['status'] { if (aborted) return 'cancelled'; return this.terminalStatus ?? 'running'; } @@ -6743,11 +5701,6 @@ function shellRunBashToolCallIds(messages: readonly StoredMessage[]): Set, ) => void; - spawnChildAgent?: (input: { - spec: AgentSpec; - prompt: string; - /** Optional per-child signal, always composed with the owning tool invocation signal. */ - abortSignal?: AbortSignal; - onReady?: (input: { - turnId: string; - agentId: string; - agentName: string; - }) => void | Promise; - onEvent?: (event: SessionEvent) => void; - }) => Promise; spawnChildSession?: (input: { agentProfile: AgentProfile; subagentId?: string; @@ -251,41 +238,6 @@ export interface MakaToolContext { }) => void | Promise; onEvent?: (event: SessionEvent) => void; }) => Promise; - prepareChildAgentResume?: (sourceRunId: string) => Promise<{ - sourceRunId: string; - execution: SubagentExecutionRef; - agentId: string; - agentName: string; - profile: string; - }>; - resumeChildAgent?: (input: { - sourceRunId: string; - prompt: string; - /** Optional per-child signal, always composed with the owning tool invocation signal. */ - abortSignal?: AbortSignal; - onReady?: (input: { - childSessionId?: string; - turnId: string; - runId?: string; - agentId: string; - agentName: string; - }) => void | Promise; - onEvent?: (event: SessionEvent) => void; - }) => Promise; - retryChildAgent?: (input: { - sourceRunId: string; - execution?: SubagentExecutionRef; - /** Optional per-child signal, always composed with the owning tool invocation signal. */ - abortSignal?: AbortSignal; - onReady?: (input: { - childSessionId?: string; - turnId: string; - runId?: string; - agentId: string; - agentName: string; - }) => void | Promise; - onEvent?: (event: SessionEvent) => void; - }) => Promise; listChildAgents?: () => Promise; readChildAgentOutput?: (input: { execution?: SubagentExecutionRef; @@ -386,18 +338,6 @@ export interface ToolRuntimeInput { toolCallId: string; output: unknown; }) => ToolResultOutput | PromiseLike; - spawnChildAgent?: (input: { - parentRunId: string; - spec: AgentSpec; - prompt: string; - abortSignal: AbortSignal; - onReady?: (input: { - turnId: string; - agentId: string; - agentName: string; - }) => void | Promise; - onEvent?: (event: SessionEvent) => void; - }) => Promise; spawnChildSession?: (input: { parentRunId: string; parentTurnId: string; @@ -420,41 +360,6 @@ export interface ToolRuntimeInput { }) => void | Promise; onEvent?: (event: SessionEvent) => void; }) => Promise; - prepareChildAgentResume?: (sourceRunId: string) => Promise<{ - sourceRunId: string; - execution: SubagentExecutionRef; - agentId: string; - agentName: string; - profile: string; - }>; - resumeChildAgent?: (input: { - parentRunId: string; - sourceRunId: string; - prompt: string; - abortSignal: AbortSignal; - onReady?: (input: { - childSessionId?: string; - turnId: string; - runId?: string; - agentId: string; - agentName: string; - }) => void | Promise; - onEvent?: (event: SessionEvent) => void; - }) => Promise; - retryChildAgent?: (input: { - parentRunId: string; - sourceRunId: string; - execution?: SubagentExecutionRef; - abortSignal: AbortSignal; - onReady?: (input: { - childSessionId?: string; - turnId: string; - runId?: string; - agentId: string; - agentName: string; - }) => void | Promise; - onEvent?: (event: SessionEvent) => void; - }) => Promise; listChildAgents?: () => Promise; readChildAgentOutput?: (input: { execution?: SubagentExecutionRef; @@ -2025,19 +1930,11 @@ export class ToolRuntime { parentToolCallId?: string; parentOperationId?: string; }; - }): Pick< - MakaToolContext, - | 'spawnChildAgent' - | 'spawnChildSession' - | 'prepareChildAgentResume' - | 'resumeChildAgent' - | 'retryChildAgent' - > { + }): Pick { const parentRunId = this.input.runId; if (!parentRunId) return {}; const limiter = this.childAgentRunLimiter; const runWithPermit = async ( - mode: 'spawn' | 'spawn_session' | 'resume' | 'retry', abortSignal: AbortSignal, execute: () => Promise, ): Promise => { @@ -2048,7 +1945,7 @@ export class ToolRuntime { toolName: input.toolName, boundary: 'shared_child_run_permit', stage: 'waiting', - mode, + mode: 'spawn_session', activeChildRuns: limiter.activeCount, waitingChildRuns: limiter.waitingCount + 1, capacity: limiter.capacity, @@ -2067,7 +1964,7 @@ export class ToolRuntime { toolName: input.toolName, boundary: 'shared_child_run_permit', stage: 'cancelled_while_waiting', - mode, + mode: 'spawn_session', status: abortSignal.aborted ? 'aborted' : 'error', }, ); @@ -2079,7 +1976,7 @@ export class ToolRuntime { toolName: input.toolName, boundary: 'child_run_execution', stage: 'started', - mode, + mode: 'spawn_session', waitedForPermit: waitingForPermit, activeChildRuns: limiter.activeCount, waitingChildRuns: limiter.waitingCount, @@ -2097,7 +1994,7 @@ export class ToolRuntime { toolName: input.toolName, boundary: 'child_run_execution', stage: 'completed', - mode, + mode: 'spawn_session', status: 'success', durationMs: Math.max(0, this.input.now() - childStartedAt), }); @@ -2108,7 +2005,7 @@ export class ToolRuntime { toolName: input.toolName, boundary: 'child_run_execution', stage: 'completed', - mode, + mode: 'spawn_session', status: abortSignal.aborted ? 'aborted' : 'error', durationMs: Math.max(0, this.input.now() - childStartedAt), }); @@ -2118,35 +2015,8 @@ export class ToolRuntime { } }; - const spawnChildAgent = this.input.spawnChildAgent; const spawnChildSession = this.input.spawnChildSession; - const prepareChildAgentResume = this.input.prepareChildAgentResume; - const resumeChildAgent = this.input.resumeChildAgent; - const retryChildAgent = this.input.retryChildAgent; return { - ...(spawnChildAgent - ? { - spawnChildAgent: async (spawnInput) => { - const abortSignal = composeChildAbortSignal( - input.abortSignal, - spawnInput.abortSignal, - ); - return await runWithPermit( - 'spawn', - abortSignal, - async () => - await spawnChildAgent({ - parentRunId, - spec: spawnInput.spec, - prompt: spawnInput.prompt, - abortSignal, - ...(spawnInput.onReady ? { onReady: spawnInput.onReady } : {}), - ...(spawnInput.onEvent ? { onEvent: spawnInput.onEvent } : {}), - }), - ); - }, - } - : {}), ...(spawnChildSession ? { spawnChildSession: async (spawnInput) => { @@ -2155,7 +2025,6 @@ export class ToolRuntime { spawnInput.abortSignal, ); return await runWithPermit( - 'spawn_session', abortSignal, async () => await spawnChildSession({ @@ -2197,57 +2066,6 @@ export class ToolRuntime { }, } : {}), - ...(prepareChildAgentResume - ? { - prepareChildAgentResume: (sourceRunId) => prepareChildAgentResume(sourceRunId), - } - : {}), - ...(resumeChildAgent - ? { - resumeChildAgent: async (resumeInput) => { - const abortSignal = composeChildAbortSignal( - input.abortSignal, - resumeInput.abortSignal, - ); - return await runWithPermit( - 'resume', - abortSignal, - async () => - await resumeChildAgent({ - parentRunId, - sourceRunId: resumeInput.sourceRunId, - prompt: resumeInput.prompt, - abortSignal, - ...(resumeInput.onReady ? { onReady: resumeInput.onReady } : {}), - ...(resumeInput.onEvent ? { onEvent: resumeInput.onEvent } : {}), - }), - ); - }, - } - : {}), - ...(retryChildAgent - ? { - retryChildAgent: async (retryInput) => { - const abortSignal = composeChildAbortSignal( - input.abortSignal, - retryInput.abortSignal, - ); - return await runWithPermit( - 'retry', - abortSignal, - async () => - await retryChildAgent({ - parentRunId, - sourceRunId: retryInput.sourceRunId, - ...(retryInput.execution ? { execution: retryInput.execution } : {}), - abortSignal, - ...(retryInput.onReady ? { onReady: retryInput.onReady } : {}), - ...(retryInput.onEvent ? { onEvent: retryInput.onEvent } : {}), - }), - ); - }, - } - : {}), }; }