From 0ef2547aa4cbe07fa5bb2f79429208fa92e28c0f Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 2 Sep 2026 23:29:44 +0800 Subject: [PATCH 01/46] feat(core): define the invocation opening fact as a RuntimeEvent Route provenance, execution configuration, root authority and lineage are immutable the moment an invocation opens, but today they only exist on the mutable AgentRunHeader, so every RuntimeEvent that needs its own route has to join back through event.runId. That join is what removed compatible provider reasoning in #4286. Add `invocation_opened` as a closed, versioned RuntimeEvent content kind. It carries the route once per invocation so readers join it by invocationId instead of copying it onto every event, and it fails closed: a route whose connection identity cannot be established decodes as `provenance: 'unknown'` rather than as an authenticated route. The root authority is a discriminated union instead of a bag of mutually exclusive optional ids, so a reader names the root it wants rather than asserting that every other root field is absent. Refs #4311 Generated-by: Claude Code --- .../runtime-invocation-opened.test.ts | 235 +++++++++++++ packages/core/src/runtime-event.ts | 328 +++++++++++++++++- 2 files changed, 559 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/__tests__/runtime-invocation-opened.test.ts diff --git a/packages/core/src/__tests__/runtime-invocation-opened.test.ts b/packages/core/src/__tests__/runtime-invocation-opened.test.ts new file mode 100644 index 0000000000..0f8385de57 --- /dev/null +++ b/packages/core/src/__tests__/runtime-invocation-opened.test.ts @@ -0,0 +1,235 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + decodeRuntimeEvent, + decodeRuntimeInvocationOpened, + runtimeEventHasModelVisibleContent, + runtimeEventInvocationOpening, + RUNTIME_EVENT_CONTENT_KINDS, + type RuntimeEvent, + type RuntimeEventInvocationOpenedContent, +} from '../runtime-event.js'; + +const DIGEST = `sha256:${'a'.repeat(64)}` as const; + +function opening( + overrides: Partial = {}, +): RuntimeEventInvocationOpenedContent { + return { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'runtime', + backendKind: 'ai-sdk', + llmConnectionId: 'conn-1', + llmConnectionSlug: 'anthropic', + modelId: 'claude-x', + providerStateIdentity: DIGEST, + }, + configuration: { + cwd: '/repo', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + root: { kind: 'user' }, + source: { kind: 'fresh' }, + ...overrides, + }; +} + +function openingEvent(content: unknown): unknown { + return { + id: 'evt-open', + invocationId: 'inv-1', + runId: 'inv-1', + sessionId: 'sess-1', + turnId: 'turn-1', + ts: 10, + partial: false, + role: 'system', + author: 'system', + modelVisibility: 'hidden', + content, + }; +} + +describe('invocation_opened content contract', () => { + test('is a runtime event content kind', () => { + assert.ok(RUNTIME_EVENT_CONTENT_KINDS.includes('invocation_opened')); + }); + + test('decodes as RuntimeEvent content and narrows back out', () => { + const event = decodeRuntimeEvent(openingEvent(opening())); + const fact = runtimeEventInvocationOpening(event); + assert.ok(fact); + assert.equal(fact.protocol, 'invocation_opened_v1'); + assert.equal(fact.route.provenance, 'runtime'); + assert.equal(fact.route.modelId, 'claude-x'); + }); + + test('is never model visible', () => { + const event = decodeRuntimeEvent(openingEvent(opening())) as RuntimeEvent; + assert.equal(runtimeEventHasModelVisibleContent(event), false); + }); + + test('accepts the unknown route provenance without connection identity', () => { + const fact = decodeRuntimeInvocationOpened( + opening({ + route: { + provenance: 'unknown', + backendKind: 'ai-sdk', + llmConnectionSlug: 'legacy', + modelId: 'legacy-model', + }, + }), + ); + assert.equal(fact.route.provenance, 'unknown'); + }); + + test('rejects an unknown route that still carries a connection identity', () => { + assert.throws(() => + decodeRuntimeInvocationOpened( + opening({ + route: { + provenance: 'unknown', + backendKind: 'ai-sdk', + llmConnectionSlug: 'legacy', + modelId: 'legacy-model', + llmConnectionId: 'conn-1', + } as never, + }), + ), + ); + }); + + test('rejects a runtime route with no connection identity', () => { + assert.throws(() => + decodeRuntimeInvocationOpened( + opening({ + route: { + provenance: 'runtime', + backendKind: 'ai-sdk', + llmConnectionSlug: 'anthropic', + modelId: 'claude-x', + } as never, + }), + ), + ); + }); + + test('rejects an unversioned or misversioned protocol', () => { + assert.throws(() => decodeRuntimeInvocationOpened(opening({ protocol: 'v2' as never }))); + const { protocol: _protocol, ...withoutProtocol } = opening(); + assert.throws(() => decodeRuntimeInvocationOpened(withoutProtocol)); + }); + + test('rejects an unknown extra field anywhere in the closed shape', () => { + assert.throws(() => + decodeRuntimeInvocationOpened({ ...opening(), runComposition: {} } as never), + ); + assert.throws(() => + decodeRuntimeInvocationOpened( + opening({ + configuration: { ...opening().configuration, sessionMode: 'agent' } as never, + }), + ), + ); + }); + + test('rejects a root authority that mixes two roots', () => { + assert.throws(() => + decodeRuntimeInvocationOpened( + opening({ root: { kind: 'goal', goalId: 'g1', scheduledTaskId: 's1' } as never }), + ), + ); + }); + + test('accepts every root authority the runtime can open', () => { + for (const root of [ + { kind: 'user' }, + { kind: 'context_compact' }, + { kind: 'scheduled_task', scheduledTaskId: 'task-1' }, + { kind: 'goal', goalId: 'goal-1' }, + { kind: 'agent_graph_supervisor_wake', wakeId: 'w1', attemptId: 'a1' }, + { kind: 'legacy_automation', legacyAutomationId: 'auto-1' }, + ] as const) { + assert.equal(decodeRuntimeInvocationOpened(opening({ root })).root.kind, root.kind); + } + }); + + test('carries the continuation source identity when the invocation continues one', () => { + const fact = decodeRuntimeInvocationOpened( + opening({ + source: { + kind: 'continuation', + sourceInvocationId: 'inv-0', + sourceRunId: 'inv-0', + sourceTurnId: 'turn-0', + sourceRuntimeEventHighWater: 7, + claimId: 'claim-1', + boundaryDigest: DIGEST, + }, + }), + ); + assert.equal(fact.source.kind, 'continuation'); + }); + + test('rejects a continuation source missing its boundary position', () => { + assert.throws(() => + decodeRuntimeInvocationOpened( + opening({ + source: { + kind: 'continuation', + sourceInvocationId: 'inv-0', + sourceRunId: 'inv-0', + sourceTurnId: 'turn-0', + } as never, + }), + ), + ); + }); + + test('rejects an empty lineage object rather than storing a meaningless key', () => { + assert.throws(() => decodeRuntimeInvocationOpened(opening({ lineage: {} }))); + }); + + test('rejects an invalid enum member in configuration', () => { + assert.throws(() => + decodeRuntimeInvocationOpened( + opening({ + configuration: { ...opening().configuration, toolMode: 'telepathy' } as never, + }), + ), + ); + }); + + test('a malformed opening fact fails the whole RuntimeEvent decode', () => { + assert.throws(() => + decodeRuntimeEvent( + openingEvent({ kind: 'invocation_opened', protocol: 'invocation_opened_v1' }), + ), + ); + }); +}); diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index 4365735f99..e134b53714 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -45,7 +45,23 @@ import { decodeInteractionRequest, type InteractionFormInput, } from './interaction.js'; -import type { PermissionRequestPayload, PermissionResponse } from './permission.js'; +import { + isPermissionMode, + type PermissionMode, + type PermissionRequestPayload, + type PermissionResponse, +} from './permission.js'; +import { isCollaborationMode, type CollaborationMode } from './collaboration.js'; +import { + isAgentSwarmAuthorizationSource, + isEffectiveOrchestrationSource, + isOrchestrationMode, + type AgentSwarmAuthorizationSource, + type EffectiveOrchestrationSource, + type OrchestrationMode, +} from './orchestration.js'; +import { isToolMode, type ToolMode } from './tool-mode.js'; +import type { PersistedBackendKind } from './session.js'; import { decodeTurnOrigin, type TurnOrigin } from './turn-origin.js'; import type { UserQuestionRequest } from './user-question.js'; import { @@ -211,17 +227,114 @@ export interface RuntimeEventErrorContent { details?: string[] | Record; } +/** + * Where an invocation's provider route came from. `unknown` is the fail-closed + * marker for data that predates the opening fact: the transcript and tool + * evidence stay readable, but nothing may treat the route as authenticated. + */ +export type RuntimeInvocationRoute = + | { + provenance: 'runtime'; + backendKind: PersistedBackendKind; + llmConnectionId: string; + llmConnectionSlug: string; + modelId: string; + /** Frozen provider endpoint and credential ownership; absent on non-provider runs. */ + providerStateIdentity?: `sha256:${string}`; + } + | { + provenance: 'unknown'; + backendKind: PersistedBackendKind; + llmConnectionSlug: string; + modelId: string; + }; + +/** Execution configuration frozen before an invocation's first dispatch. */ +export interface RuntimeInvocationConfiguration { + cwd: string; + permissionMode: PermissionMode; + collaborationMode: CollaborationMode; + orchestrationMode: OrchestrationMode; + orchestrationSource: EffectiveOrchestrationSource; + toolMode: ToolMode; + agentSwarmAuthorization?: AgentSwarmAuthorizationSource; + /** Authoritative host identity for the workspace observed at open. */ + workspaceIdentity?: string; +} + +/** + * The authority that caused this invocation to exist. Closed and discriminated, + * so a reader names the root it wants instead of asserting that every other + * optional root field is absent. + */ +export type RuntimeInvocationRootAuthority = + | { kind: 'user' } + | { kind: 'context_compact' } + | { kind: 'scheduled_task'; scheduledTaskId: string } + | { kind: 'goal'; goalId: string } + | { kind: 'agent_graph_supervisor_wake'; wakeId: string; attemptId: string } + | { kind: 'legacy_automation'; legacyAutomationId: string }; + +/** Turn/session lineage that is immutable once the invocation opens. */ +export interface RuntimeInvocationLineage { + parentRunId?: string; + parentTurnId?: string; + parentSessionId?: string; + retriedFromTurnId?: string; + regeneratedFromTurnId?: string; + branchOfTurnId?: string; + agentId?: string; + agentName?: string; +} + +/** + * How this invocation was opened. `continuation` carries the same source + * identity the continuation-start action authenticates, so a migrated opening + * fact keeps the lineage edge even where no start event exists. + */ +export type RuntimeInvocationOpenSource = + | { kind: 'fresh' } + | { + kind: 'continuation'; + sourceInvocationId: string; + sourceRunId: string; + sourceTurnId: string; + sourceRuntimeEventHighWater: number; + claimId?: string; + boundaryDigest?: `sha256:${string}`; + }; + +/** + * The one immutable opening fact of a run-kind invocation, committed before any + * provider or tool dispatch. Route provenance lives here once per invocation + * and is joined by `invocationId`; it is never copied onto other events. + * + * Reserved control-plane streams (history compaction checkpoints, workspace + * version authority) have no run and therefore no opening fact. + */ +export interface RuntimeEventInvocationOpenedContent { + kind: 'invocation_opened'; + protocol: 'invocation_opened_v1'; + route: RuntimeInvocationRoute; + configuration: RuntimeInvocationConfiguration; + root: RuntimeInvocationRootAuthority; + source: RuntimeInvocationOpenSource; + /** Omitted entirely when the invocation has no lineage edges. */ + lineage?: RuntimeInvocationLineage; +} + /** * Content union for user/model text, model thinking, function call, - * function response, and error payloads. Discriminated by `kind` to - * match the existing ToolResultContent convention. + * function response, error payloads, and the invocation opening fact. + * Discriminated by `kind` to match the existing ToolResultContent convention. */ export type RuntimeEventContent = | RuntimeEventTextContent | RuntimeEventThinkingContent | RuntimeEventFunctionCallContent | RuntimeEventFunctionResponseContent - | RuntimeEventErrorContent; + | RuntimeEventErrorContent + | RuntimeEventInvocationOpenedContent; export const RUNTIME_EVENT_CONTENT_KINDS = [ 'text', @@ -229,6 +342,7 @@ export const RUNTIME_EVENT_CONTENT_KINDS = [ 'function_call', 'function_response', 'error', + 'invocation_opened', ] as const; export type RuntimeEventContentKind = (typeof RUNTIME_EVENT_CONTENT_KINDS)[number]; @@ -579,6 +693,74 @@ const ERROR_CONTENT_SHAPE = defineObjectShape()( ['kind', 'message'], ['code', 'reason', 'details'], ); +const INVOCATION_OPENED_CONTENT_SHAPE = defineObjectShape()( + ['kind', 'protocol', 'route', 'configuration', 'root', 'source'], + ['lineage'], +); +const INVOCATION_ROUTE_RUNTIME_SHAPE = defineObjectShape< + Extract +>()( + ['provenance', 'backendKind', 'llmConnectionId', 'llmConnectionSlug', 'modelId'], + ['providerStateIdentity'], +); +const INVOCATION_ROUTE_UNKNOWN_SHAPE = defineObjectShape< + Extract +>()(['provenance', 'backendKind', 'llmConnectionSlug', 'modelId'], []); +const INVOCATION_CONFIGURATION_SHAPE = defineObjectShape()( + [ + 'cwd', + 'permissionMode', + 'collaborationMode', + 'orchestrationMode', + 'orchestrationSource', + 'toolMode', + ], + ['agentSwarmAuthorization', 'workspaceIdentity'], +); +const INVOCATION_LINEAGE_SHAPE = defineObjectShape()( + [], + [ + 'parentRunId', + 'parentTurnId', + 'parentSessionId', + 'retriedFromTurnId', + 'regeneratedFromTurnId', + 'branchOfTurnId', + 'agentId', + 'agentName', + ], +); +const INVOCATION_CONTINUATION_SOURCE_SHAPE = defineObjectShape< + Extract +>()( + ['kind', 'sourceInvocationId', 'sourceRunId', 'sourceTurnId', 'sourceRuntimeEventHighWater'], + ['claimId', 'boundaryDigest'], +); +const INVOCATION_FRESH_SOURCE_SHAPE = defineObjectShape< + Extract +>()(['kind'], []); +const INVOCATION_ROOT_SHAPES = { + user: defineObjectShape>()( + ['kind'], + [], + ), + context_compact: defineObjectShape< + Extract + >()(['kind'], []), + scheduled_task: defineObjectShape< + Extract + >()(['kind', 'scheduledTaskId'], []), + goal: defineObjectShape>()( + ['kind', 'goalId'], + [], + ), + agent_graph_supervisor_wake: defineObjectShape< + Extract + >()(['kind', 'wakeId', 'attemptId'], []), + legacy_automation: defineObjectShape< + Extract + >()(['kind', 'legacyAutomationId'], []), +} as const; const RUNTIME_ACTIONS_SHAPE = defineObjectShape()( [], [ @@ -835,11 +1017,148 @@ function isRuntimeEventContent(value: unknown): value is RuntimeEventContent { typeof value.message === 'string' && (value.details === undefined || isStringArray(value.details) || isRecord(value.details)) ); + case 'invocation_opened': + return isRuntimeInvocationOpened(value); default: return false; } } +/** + * True when the event is the immutable opening fact of its invocation. + * Narrowing here keeps every reader off a hand-rolled `content.kind` test. + */ +export function runtimeEventInvocationOpening( + event: RuntimeEvent, +): RuntimeEventInvocationOpenedContent | undefined { + return event.content?.kind === 'invocation_opened' ? event.content : undefined; +} + +/** Strict decode for one persisted opening fact; throws on any drift. */ +export function decodeRuntimeInvocationOpened(value: unknown): RuntimeEventInvocationOpenedContent { + if (!isRuntimeInvocationOpened(value)) { + throw new Error('Invalid RuntimeEvent invocation_opened schema'); + } + return value; +} + +function isRuntimeInvocationOpened(value: unknown): value is RuntimeEventInvocationOpenedContent { + return ( + isRecord(value) && + value.kind === 'invocation_opened' && + hasExactShape(value, INVOCATION_OPENED_CONTENT_SHAPE) && + value.protocol === 'invocation_opened_v1' && + isRuntimeInvocationRoute(value.route) && + isRuntimeInvocationConfiguration(value.configuration) && + isRuntimeInvocationRootAuthority(value.root) && + isRuntimeInvocationOpenSource(value.source) && + (value.lineage === undefined || isRuntimeInvocationLineage(value.lineage)) + ); +} + +function isRuntimeInvocationRoute(value: unknown): value is RuntimeInvocationRoute { + if (!isRecord(value)) return false; + if ( + !isPersistedBackendKind(value.backendKind) || + !isNonEmptyString(value.llmConnectionSlug) || + !isNonEmptyString(value.modelId) + ) { + return false; + } + if (value.provenance === 'runtime') { + return ( + hasExactShape(value, INVOCATION_ROUTE_RUNTIME_SHAPE) && + isNonEmptyString(value.llmConnectionId) && + (value.providerStateIdentity === undefined || isSha256Digest(value.providerStateIdentity)) + ); + } + return value.provenance === 'unknown' && hasExactShape(value, INVOCATION_ROUTE_UNKNOWN_SHAPE); +} + +function isPersistedBackendKind(value: unknown): value is PersistedBackendKind { + return value === 'ai-sdk' || value === 'fake'; +} + +function isRuntimeInvocationConfiguration(value: unknown): value is RuntimeInvocationConfiguration { + return ( + isRecord(value) && + hasExactShape(value, INVOCATION_CONFIGURATION_SHAPE) && + typeof value.cwd === 'string' && + isPermissionMode(value.permissionMode) && + isCollaborationMode(value.collaborationMode) && + isOrchestrationMode(value.orchestrationMode) && + isEffectiveOrchestrationSource(value.orchestrationSource) && + isToolMode(value.toolMode) && + (value.agentSwarmAuthorization === undefined || + isAgentSwarmAuthorizationSource(value.agentSwarmAuthorization)) && + (value.workspaceIdentity === undefined || isNonEmptyString(value.workspaceIdentity)) + ); +} + +function isRuntimeInvocationRootAuthority(value: unknown): value is RuntimeInvocationRootAuthority { + if (!isRecord(value)) return false; + switch (value.kind) { + case 'user': + return hasExactShape(value, INVOCATION_ROOT_SHAPES.user); + case 'context_compact': + return hasExactShape(value, INVOCATION_ROOT_SHAPES.context_compact); + case 'scheduled_task': + return ( + hasExactShape(value, INVOCATION_ROOT_SHAPES.scheduled_task) && + isNonEmptyString(value.scheduledTaskId) + ); + case 'goal': + return hasExactShape(value, INVOCATION_ROOT_SHAPES.goal) && isNonEmptyString(value.goalId); + case 'agent_graph_supervisor_wake': + return ( + hasExactShape(value, INVOCATION_ROOT_SHAPES.agent_graph_supervisor_wake) && + isNonEmptyString(value.wakeId) && + isNonEmptyString(value.attemptId) + ); + case 'legacy_automation': + return ( + hasExactShape(value, INVOCATION_ROOT_SHAPES.legacy_automation) && + isNonEmptyString(value.legacyAutomationId) + ); + default: + return false; + } +} + +function isRuntimeInvocationOpenSource(value: unknown): value is RuntimeInvocationOpenSource { + if (!isRecord(value)) return false; + if (value.kind === 'fresh') return hasExactShape(value, INVOCATION_FRESH_SOURCE_SHAPE); + return ( + value.kind === 'continuation' && + hasExactShape(value, INVOCATION_CONTINUATION_SOURCE_SHAPE) && + isNonEmptyString(value.sourceInvocationId) && + isNonEmptyString(value.sourceRunId) && + isNonEmptyString(value.sourceTurnId) && + Number.isSafeInteger(value.sourceRuntimeEventHighWater) && + (value.sourceRuntimeEventHighWater as number) >= 0 && + (value.claimId === undefined || isNonEmptyString(value.claimId)) && + (value.boundaryDigest === undefined || isSha256Digest(value.boundaryDigest)) + ); +} + +function isRuntimeInvocationLineage(value: unknown): value is RuntimeInvocationLineage { + return ( + isRecord(value) && + hasExactShape(value, INVOCATION_LINEAGE_SHAPE) && + Object.keys(value).length > 0 && + [ + value.parentRunId, + value.parentTurnId, + value.parentSessionId, + value.retriedFromTurnId, + value.regeneratedFromTurnId, + value.branchOfTurnId, + value.agentId, + value.agentName, + ].every(isOptionalString) + ); +} + function decodesDurableToolResultProjection(value: unknown): boolean { try { decodeDurableToolResultProjection(value); @@ -1159,6 +1478,7 @@ export function runtimeEventHasModelVisibleContent(event: RuntimeEvent): boolean case 'function_response': return true; case 'error': + case 'invocation_opened': return false; } } From ac2a3d4002c52b42395ff6e9135607b18f0f8590 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 2 Sep 2026 23:43:45 +0800 Subject: [PATCH 02/46] feat(runtime): open every invocation with a durable opening fact Every run-kind invocation now commits its opening fact as its own first RuntimeEvent, before the run row, before the run_created ledger row and before any provider or tool dispatch. The store already requires a continuation's start event to be event one of its target invocation, so for a continuation the start event carries the opening fact instead of a second event preceding it. The opening fact is projected from the Run header by one shared function, so the two authorities cannot disagree while both exist, and the runtime protocol marker moves with it: it has always belonged to the invocation's first event, and that event is now the opening fact. Run and invocation converge on one identity. All three sites that used to mint them independently (continuation planning, conversation copy, imported transcript repair) now emit the same value for both, and the derived `invocation-` prefix is gone. Nothing renames a field yet; this only removes the multiplicity that would have made a rename a lie. The terminal event's own error message becomes the only source of a run's failure text. It was already the source the header copy was written from, so preferring the header only let a stale projection outlive its fact. Refs #4311 Generated-by: Claude Code --- packages/core/src/agent-run.ts | 122 +++++++++++++++++- .../src/__tests__/conversation-copy.test.ts | 9 +- .../__tests__/runtime-continuation.test.ts | 5 +- .../src/__tests__/session-manager.test.ts | 121 +++++++++++++++-- .../stream-graph-coordinator.test.ts | 18 ++- packages/runtime/src/agent-run.ts | 58 ++++++++- packages/runtime/src/conversation-copy.ts | 6 +- .../runtime/src/runtime-event-read-model.ts | 5 + packages/runtime/src/runtime-kernel.ts | 5 + packages/runtime/src/runtime-ledger-repair.ts | 4 +- packages/runtime/src/runtime-resume.ts | 20 ++- packages/runtime/src/session-manager.ts | 4 +- packages/runtime/src/terminal-run-commit.ts | 14 +- .../__tests__/sqlite-runtime-store.test.ts | 3 + packages/storage/src/sqlite-runtime-store.ts | 13 +- 15 files changed, 370 insertions(+), 37 deletions(-) diff --git a/packages/core/src/agent-run.ts b/packages/core/src/agent-run.ts index ffc7e44d4c..f49ad8e8b0 100644 --- a/packages/core/src/agent-run.ts +++ b/packages/core/src/agent-run.ts @@ -41,7 +41,14 @@ import { isRecord, } from './record-schema.js'; import type { AgentGraphIntentClaim } from './agent-graph-control.js'; -import { isToolMode, type ToolMode } from './tool-mode.js'; +import { DEFAULT_TOOL_MODE, isToolMode, type ToolMode } from './tool-mode.js'; +import type { + RuntimeEventInvocationOpenedContent, + RuntimeInvocationLineage, + RuntimeInvocationOpenSource, + RuntimeInvocationRootAuthority, + RuntimeInvocationRoute, +} from './runtime-event.js'; import { decodeRunCompositionSnapshot, type RunCompositionSnapshot } from './run-composition.js'; export const AGENT_RUN_STATUSES = [ @@ -838,3 +845,116 @@ export function isSessionInlineRun(run: { (run.continuationSource !== undefined && run.agentId === undefined) ); } + +/** + * Project one Run header onto its invocation opening fact. + * + * This is the single mapping from the old authority to the new one: the live + * writer and the storage backfill both go through it, so a header field can + * never be classified two different ways. + * + * Route provenance fails closed. A header with no Connection identity cannot + * prove which endpoint and credential owned the run, so it projects as + * `unknown` rather than as an authenticated route; its transcript and tool + * evidence stay readable either way. + * + * Throws when a root authority marker is present but incomplete — that is + * corruption, and inventing a root would be worse than refusing one. + */ +export function runtimeInvocationOpeningFromRunHeader( + header: AgentRunHeader, +): RuntimeEventInvocationOpenedContent { + const lineage: RuntimeInvocationLineage = { + ...(header.parentRunId !== undefined ? { parentRunId: header.parentRunId } : {}), + ...(header.parentTurnId !== undefined ? { parentTurnId: header.parentTurnId } : {}), + ...(header.parentSessionId !== undefined ? { parentSessionId: header.parentSessionId } : {}), + ...(header.retriedFromTurnId !== undefined + ? { retriedFromTurnId: header.retriedFromTurnId } + : {}), + ...(header.regeneratedFromTurnId !== undefined + ? { regeneratedFromTurnId: header.regeneratedFromTurnId } + : {}), + ...(header.branchOfTurnId !== undefined ? { branchOfTurnId: header.branchOfTurnId } : {}), + ...(header.agentId !== undefined ? { agentId: header.agentId } : {}), + ...(header.agentName !== undefined ? { agentName: header.agentName } : {}), + }; + return { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: invocationRouteFromRunHeader(header), + configuration: { + cwd: header.cwd, + permissionMode: header.permissionMode, + collaborationMode: header.collaborationMode ?? 'agent', + orchestrationMode: header.orchestrationMode ?? 'default', + orchestrationSource: header.orchestrationSource ?? 'session', + toolMode: header.toolMode ?? DEFAULT_TOOL_MODE, + ...(header.agentSwarmAuthorization !== undefined + ? { agentSwarmAuthorization: header.agentSwarmAuthorization } + : {}), + ...(header.workspaceIdentity !== undefined + ? { workspaceIdentity: header.workspaceIdentity } + : {}), + }, + root: invocationRootFromRunHeader(header), + source: invocationOpenSourceFromRunHeader(header), + ...(Object.keys(lineage).length > 0 ? { lineage } : {}), + }; +} + +function invocationRouteFromRunHeader(header: AgentRunHeader): RuntimeInvocationRoute { + if (header.llmConnectionId === undefined) { + return { + provenance: 'unknown', + backendKind: header.backendKind, + llmConnectionSlug: header.llmConnectionSlug, + modelId: header.modelId, + }; + } + return { + provenance: 'runtime', + backendKind: header.backendKind, + llmConnectionId: header.llmConnectionId, + llmConnectionSlug: header.llmConnectionSlug, + modelId: header.modelId, + ...(header.providerStateIdentity !== undefined + ? { providerStateIdentity: header.providerStateIdentity } + : {}), + }; +} + +function invocationRootFromRunHeader(header: AgentRunHeader): RuntimeInvocationRootAuthority { + if (header.scheduledTaskId !== undefined) { + return { kind: 'scheduled_task', scheduledTaskId: header.scheduledTaskId }; + } + if (header.goalId !== undefined) return { kind: 'goal', goalId: header.goalId }; + if (header.legacyAutomationId !== undefined) { + return { kind: 'legacy_automation', legacyAutomationId: header.legacyAutomationId }; + } + if (header.agentGraphWakeId !== undefined) { + if (header.agentGraphWakeAttemptId === undefined) { + throw new Error(`AgentRun ${header.runId} has a graph wake with no delivery attempt`); + } + return { + kind: 'agent_graph_supervisor_wake', + wakeId: header.agentGraphWakeId, + attemptId: header.agentGraphWakeAttemptId, + }; + } + if (header.rootExecutionKind === 'context_compact') return { kind: 'context_compact' }; + return { kind: 'user' }; +} + +function invocationOpenSourceFromRunHeader(header: AgentRunHeader): RuntimeInvocationOpenSource { + const source = header.continuationSource; + if (!source) return { kind: 'fresh' }; + const v2 = 'protocol' in source ? source : undefined; + return { + kind: 'continuation', + sourceInvocationId: source.sourceInvocationId, + sourceRunId: source.sourceRunId, + sourceTurnId: source.sourceTurnId, + sourceRuntimeEventHighWater: source.sourceRuntimeEventHighWater, + ...(v2 ? { claimId: v2.claimId, boundaryDigest: v2.boundaryDigest } : {}), + }; +} diff --git a/packages/runtime/src/__tests__/conversation-copy.test.ts b/packages/runtime/src/__tests__/conversation-copy.test.ts index e34c6301bf..9731fcc0d4 100644 --- a/packages/runtime/src/__tests__/conversation-copy.test.ts +++ b/packages/runtime/src/__tests__/conversation-copy.test.ts @@ -2227,9 +2227,10 @@ test('conversation copy clones one terminal Runtime ledger with new owned identi /missing Artifact artifact-deleted/, ); assert.deepEqual(await runStore.listSessionRuns('session-missing-artifact'), []); + // A copied run and its copied invocation share one fresh identity, so the + // copy mints one id here rather than two. const ids = [ 'run-target', - 'invocation-target', 'event-target-1', 'event-target-2', 'event-target-3', @@ -2270,7 +2271,7 @@ test('conversation copy clones one terminal Runtime ledger with new owned identi ); const [targetRun] = await runStore.listSessionRuns('session-target'); assert.equal(targetRun?.runId, 'run-target'); - assert.equal(targetRun?.invocationId, 'invocation-target'); + assert.equal(targetRun?.invocationId, 'run-target'); assert.equal(targetRun?.status, 'completed'); const targetEvents = await runtimeEventStore.readRuntimeEvents('session-target', 'run-target'); assert.deepEqual( @@ -2289,7 +2290,7 @@ test('conversation copy clones one terminal Runtime ledger with new owned identi (event) => event.sessionId === 'session-target' && event.runId === 'run-target' && - event.invocationId === 'invocation-target', + event.invocationId === 'run-target', ), ); assert.equal(targetEvents[0]?.refs?.artifactId, 'artifact-target'); @@ -2301,7 +2302,7 @@ test('conversation copy clones one terminal Runtime ledger with new owned identi copied.copiedMessages.find((message) => message.type === 'assistant')?.text, targetAttachmentText, ); - assert.equal(targetEvents[1]?.refs?.sourceInvocationId, 'invocation-target'); + assert.equal(targetEvents[1]?.refs?.sourceInvocationId, 'run-target'); assert.deepEqual( targetEvents[1]?.content?.kind === 'function_call' ? targetEvents[1].content.args : undefined, sourceEvents[1]?.content?.kind === 'function_call' ? sourceEvents[1].content.args : undefined, diff --git a/packages/runtime/src/__tests__/runtime-continuation.test.ts b/packages/runtime/src/__tests__/runtime-continuation.test.ts index 678a0f96fe..0b0cd6c591 100644 --- a/packages/runtime/src/__tests__/runtime-continuation.test.ts +++ b/packages/runtime/src/__tests__/runtime-continuation.test.ts @@ -77,7 +77,8 @@ test('RuntimeContinuationPlanner reads the durable source boundary and allocates }), ]; const sourcePrefix = immutablePrefix(sourceEvents); - const ids = ['invocation-2', 'run-2', 'turn-2', 'claim-2']; + // Run and invocation are one identity, so the planner mints three ids, not four. + const ids = ['invocation-2', 'turn-2', 'claim-2']; const planner = new RuntimeContinuationPlanner({ readSourceRun: async () => runHeader('run-1'), readImmutableRuntimePrefix: async () => sourcePrefix, @@ -99,7 +100,7 @@ test('RuntimeContinuationPlanner reads the durable source boundary and allocates assert.deepEqual(plan.continuation, { sessionId: 'session-1', invocationId: 'invocation-2', - runId: 'run-2', + runId: 'invocation-2', turnId: 'turn-2', sourceInvocationId: 'invocation-1', sourceRunId: 'run-1', diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index a0babc9ecb..c18c0ab65b 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -3369,7 +3369,8 @@ describe('SessionManager manual compaction and quiescent session changes', () => assert.deepStrictEqual(compactCalls, [ { turnId: 'turn-compact', - runtimeContextCount: 3, + // Opening fact, prompt, answer, terminal. + runtimeContextCount: 4, sourceRoutes: [ { runId: sourceRun.runId, @@ -4894,26 +4895,118 @@ describe('SessionManager permission mode updates', () => { const [run] = await runStore.listSessionRuns(session.id); if (!run) throw new Error('AgentRunStore run was not created'); const runtimeEvents = await runtimeEventStore.readRuntimeEvents(session.id, run.runId); - assert.deepStrictEqual(backend?.sendInputs[0]?.headAnchorRuntimeEvent, runtimeEvents[0]); + assert.deepStrictEqual(backend?.sendInputs[0]?.headAnchorRuntimeEvent, runtimeEvents[1]); assert.deepStrictEqual( runtimeEvents.map((event) => event.runId), - [run.runId, run.runId, run.runId], + [run.runId, run.runId, run.runId, run.runId], ); assert.deepStrictEqual( runtimeEvents.map((event) => event.sessionId), - [session.id, session.id, session.id], + [session.id, session.id, session.id, session.id], ); assert.deepStrictEqual( runtimeEvents.map((event) => event.turnId), - ['turn-1', 'turn-1', 'turn-1'], + ['turn-1', 'turn-1', 'turn-1', 'turn-1'], ); assert.deepStrictEqual( runtimeEvents.map((event) => event.role), - ['user', 'model', 'system'], + ['system', 'user', 'model', 'system'], + ); + assert.strictEqual(runtimeEvents[0]?.content?.kind, 'invocation_opened'); + assert.deepStrictEqual(runtimeEvents[1]?.content, { kind: 'text', text: 'hello' }); + assert.deepStrictEqual(runtimeEvents[2]?.content, { kind: 'text', text: 'ok' }); + assert.strictEqual(runtimeEvents[3]?.status, 'completed'); + }); + + test('the invocation opening fact is durable before the run row and any dispatch', async () => { + const store = new MemorySessionStore(); + const trace: string[] = []; + const runStore = new MemoryAgentRunStore({ + beforeRuntimeEventAppend: (_sessionId, _runId, event, options) => { + trace.push( + `runtime:${event.content?.kind ?? event.status ?? 'fact'}:durable=${options?.durable === true}`, + ); + }, + beforeAgentRunEventAppend: (_sessionId, _runId, event) => { + trace.push(`ledger:${event.type}`); + }, + }); + const backends = new BackendRegistry(); + backends.register('ai-sdk', (ctx) => { + trace.push('backend:activated'); + return new FinalTextTestBackend(ctx); + }); + const manager = new SessionManager({ + store, + runStore, + runtimeEventStore: runStore, + backends, + newId: nextId(), + now: nextNow(7_100), + }); + const session = await manager.createSession(makeInput()); + await collectSessionEvents( + manager.sendMessage(session.id, { turnId: 'turn-1', text: 'hello' }), + ); + + const openingIndex = trace.findIndex((entry) => entry.startsWith('runtime:invocation_opened')); + assert.notStrictEqual(openingIndex, -1, 'the invocation must commit an opening fact'); + assert.strictEqual( + trace.slice(0, openingIndex).some((entry) => entry.startsWith('runtime:')), + false, + 'the opening fact must be the first RuntimeEvent of the invocation', + ); + assert.ok( + openingIndex < trace.indexOf('ledger:run_created'), + 'the opening fact must precede the operational run_created row', + ); + assert.ok( + openingIndex < trace.findIndex((entry) => entry.startsWith('runtime:text')), + 'the opening fact must precede the first model-visible event of the turn', + ); + }); + + test('a rejected opening fact stops the turn before the backend can dispatch', async () => { + const store = new MemorySessionStore(); + const sends: string[] = []; + const runStore = new MemoryAgentRunStore({ + beforeRuntimeEventAppend: (_sessionId, _runId, event) => { + if (event.content?.kind === 'invocation_opened') { + throw new Error('opening fact store is unavailable'); + } + }, + }); + const canonicalRuntimeEventStore: RuntimeEventStore = Object.assign( + Object.create(Object.getPrototypeOf(runStore) as object) as MemoryAgentRunStore, + runStore, + { durability: 'canonical' as const }, + ); + const backends = new BackendRegistry(); + let backend: FinalTextTestBackend | undefined; + backends.register('ai-sdk', (ctx) => { + backend = new FinalTextTestBackend(ctx); + return backend; + }); + const manager = new SessionManager({ + store, + runStore, + runtimeEventStore: canonicalRuntimeEventStore, + backends, + newId: nextId(), + now: nextNow(7_150), + }); + const session = await manager.createSession(makeInput()); + await assert.rejects( + collectSessionEvents(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'hello' })), + /opening fact store is unavailable/, ); - assert.deepStrictEqual(runtimeEvents[0]?.content, { kind: 'text', text: 'hello' }); - assert.deepStrictEqual(runtimeEvents[1]?.content, { kind: 'text', text: 'ok' }); - assert.strictEqual(runtimeEvents[2]?.status, 'completed'); + + assert.deepStrictEqual( + backend?.sendInputs ?? [], + [], + 'no provider dispatch may happen without a durable opening fact', + ); + assert.deepStrictEqual(sends, []); }); test('snapshots mutable turn content before durable commit and backend dispatch', async () => { @@ -5039,7 +5132,11 @@ describe('SessionManager permission mode updates', () => { const [run] = await runStore.listSessionRuns(session.id); if (!run) throw new Error('AgentRunStore run was not created'); - const [storedUserEvent] = await durableEvents.readRuntimeEvents(session.id, run.runId); + const [openingFact, storedUserEvent] = await durableEvents.readRuntimeEvents( + session.id, + run.runId, + ); + assert.strictEqual(openingFact?.content?.kind, 'invocation_opened'); assert.deepStrictEqual(storedUserEvent?.content, { kind: 'text', text: 'inspect the attachment', @@ -9375,7 +9472,7 @@ describe('SessionManager permission mode updates', () => { if (!secondInput) throw new Error('second backend input was not recorded'); assert.deepStrictEqual( secondInput.runtimeContext?.map((event) => event.turnId), - ['turn-1', 'turn-1'], + ['turn-1', 'turn-1', 'turn-1'], ); const turnState = secondInput.context.find( (message) => message.type === 'turn_state' && message.turnId === 'turn-1', @@ -9467,7 +9564,7 @@ describe('SessionManager permission mode updates', () => { if (!secondInput) throw new Error('second backend input was not recorded'); assert.deepStrictEqual( secondInput.runtimeContext?.map((event) => event.turnId), - ['turn-1', 'turn-1', 'turn-1'], + ['turn-1', 'turn-1', 'turn-1', 'turn-1'], ); assert.strictEqual( secondInput.runtimeContext?.some((event) => event.turnId === 'child-turn'), diff --git a/packages/runtime/src/__tests__/stream-graph-coordinator.test.ts b/packages/runtime/src/__tests__/stream-graph-coordinator.test.ts index e596bd0377..b300f3de96 100644 --- a/packages/runtime/src/__tests__/stream-graph-coordinator.test.ts +++ b/packages/runtime/src/__tests__/stream-graph-coordinator.test.ts @@ -430,9 +430,23 @@ describe('host-managed agent graph coordinator', () => { assert.ok(wakeRun); assert.equal(wakeRun.agentGraphWakeId, graphWake.origin.wakeId); assert.equal(wakeRun.agentGraphWakeAttemptId, graphWake.origin.attemptId); + const wakeEvents = await runtimeEventStore.readImmutableRuntimeEvents( + rootSession.id, + wakeRun.runId, + ); + assert.deepEqual( + wakeEvents[0]?.content?.kind === 'invocation_opened' + ? wakeEvents[0].content.root + : undefined, + { + kind: 'agent_graph_supervisor_wake', + wakeId: graphWake.origin.wakeId, + attemptId: graphWake.origin.attemptId, + }, + 'the opening fact must name the host authority that woke this invocation', + ); assert.equal( - (await runtimeEventStore.readImmutableRuntimeEvents(rootSession.id, wakeRun.runId))[0] - ?.author, + wakeEvents[1]?.author, 'host', 'canonical provenance must distinguish the host-authored wake from human input', ); diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index 03213161d3..7d369e4323 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -23,7 +23,12 @@ import type { AgentRunStore, EmittedAgentRunEvent, } from '@maka/core/agent-run'; -import type { RuntimeEvent, ToolBoundaryProtocol } from '@maka/core/runtime-event'; +import { runtimeInvocationOpeningFromRunHeader } from '@maka/core/agent-run'; +import type { + RuntimeEvent, + RuntimeEventInvocationOpenedContent, + ToolBoundaryProtocol, +} from '@maka/core/runtime-event'; import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; import type { RunCompositionSnapshot } from '@maka/core/run-composition'; import { decodeRunCompositionSnapshot } from '@maka/core/run-composition'; @@ -252,6 +257,8 @@ export class AgentRun { private terminalRunHeaderCommitted = false; private continuationActive = false; private providerStateIdentity: `sha256:${string}` | undefined; + private invocationOpening: RuntimeEventInvocationOpenedContent | undefined; + private invocationOpeningCommitted = false; private terminalClaim: | { owner: 'event' | 'stop'; @@ -831,7 +838,10 @@ export class AgentRun { ? { inlineReferences: input.inlineReferences } : {}), }, - ...(this.toolBoundaryProtocol + // The marker belongs to the invocation's first event. Once an opening + // fact exists it holds the marker, and a second copy here would read as + // a stray marker to RecoveryResolver. + ...(this.toolBoundaryProtocol && !this.invocationOpeningCommitted ? { actions: { runtimeProtocol: { toolBoundary: this.toolBoundaryProtocol } } } : {}), }; @@ -1187,6 +1197,12 @@ export class AgentRun { ) { throw new Error('Claimed continuation target Run header no longer matches execution'); } + this.invocationOpening = runtimeInvocationOpeningFromRunHeader(header); + // A continuation's opening fact rides its continuation-start event, which + // the store requires to be event 1 of the target invocation. Every other + // invocation opens with its own event, committed before the run row and + // before any provider or tool dispatch. + if (!continuation) await this.commitInvocationOpening(createdAt); try { const durable = this.requiresDurablePersistence(); await this.input.runStore.createRun(header, { durable }); @@ -1219,6 +1235,44 @@ export class AgentRun { } } + /** + * Make the invocation's opening fact durable before anything can dispatch. + * + * It is the invocation's first event, so it also carries the protocol marker + * RecoveryResolver reads off event one. + */ + private async commitInvocationOpening(ts: number): Promise { + const opening = this.invocationOpening; + if (!opening || this.invocationOpeningCommitted) return; + await this.recordRuntimeEvents( + [ + { + id: this.input.newId(), + invocationId: this.invocationId, + runId: this.runId, + sessionId: this.sessionId, + turnId: this.turnId, + ts, + partial: false, + role: 'system', + author: 'system', + modelVisibility: 'hidden', + content: opening, + ...(this.toolBoundaryProtocol + ? { actions: { runtimeProtocol: { toolBoundary: this.toolBoundaryProtocol } } } + : {}), + }, + ], + { requireDurableWrite: this.requiresDurablePersistence() }, + ); + this.invocationOpeningCommitted = true; + } + + /** The opening fact this invocation committed, for its continuation-start event. */ + invocationOpeningFact(): RuntimeEventInvocationOpenedContent | undefined { + return this.invocationOpening; + } + private requiresDurablePersistence(): boolean { return this.input.durability === 'required'; } diff --git a/packages/runtime/src/conversation-copy.ts b/packages/runtime/src/conversation-copy.ts index 1b5d78bd02..ab1b3d8757 100644 --- a/packages/runtime/src/conversation-copy.ts +++ b/packages/runtime/src/conversation-copy.ts @@ -480,8 +480,12 @@ export async function cloneConversationRuntimeLedger( flattenedPlans, input.plan.inlineRuntimeEvents, ); + // One physical execution attempt, one identity: a copied run and its copied + // invocation get the same fresh value rather than two independent ones. const runIds = new Map(flattenedPlans.map(({ run }) => [run.runId, input.newId()])); - const targetInvocationIds = new Map(flattenedPlans.map(({ run }) => [run.runId, input.newId()])); + const targetInvocationIds = new Map( + flattenedPlans.map(({ run }) => [run.runId, runIds.get(run.runId)!]), + ); const invocationIds = new Map( flattenedPlans.flatMap(({ run }) => run.invocationId ? [[run.invocationId, targetInvocationIds.get(run.runId)!] as const] : [], diff --git a/packages/runtime/src/runtime-event-read-model.ts b/packages/runtime/src/runtime-event-read-model.ts index 4de08bacb0..72f6e332f9 100644 --- a/packages/runtime/src/runtime-event-read-model.ts +++ b/packages/runtime/src/runtime-event-read-model.ts @@ -249,6 +249,11 @@ export function projectRuntimeEventsToStoredMessages( case 'thinking': projected = projectThinking(event, state, messages) || projected; break; + case 'invocation_opened': + // The opening fact records route, configuration and lineage once per + // invocation. Every reader joins it by invocationId; it has no chat row. + projected = true; + break; case 'error': if (!isTerminalRuntimeEvent(event)) { diagnostic( diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index 2ec0173e0a..8fd0886db8 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -18,6 +18,7 @@ */ import type { AgentRunHeader, AgentRunStore } from '@maka/core/agent-run'; +import { runtimeInvocationOpeningFromRunHeader } from '@maka/core/agent-run'; import { decodeRuntimeBoundaryCursor, type ContinuationClaimV1, @@ -854,6 +855,10 @@ export class RuntimeKernel implements RuntimeKernelLike { partial: false, role: 'system', author: 'system', + modelVisibility: 'hidden', + // The start event is event 1 of the target invocation, so it is + // also where that invocation's opening fact lives. + content: runtimeInvocationOpeningFromRunHeader(claim.targetRunHeader), actions: { ...(continuationToolBoundaryProtocol ? { diff --git a/packages/runtime/src/runtime-ledger-repair.ts b/packages/runtime/src/runtime-ledger-repair.ts index 0c00e01f24..39d2a50bd1 100644 --- a/packages/runtime/src/runtime-ledger-repair.ts +++ b/packages/runtime/src/runtime-ledger-repair.ts @@ -425,7 +425,9 @@ function transcriptRunHeader(input: { const status = transcriptRunStatus(input.turn.status); return { runId: input.runId, - invocationId: `invocation-${input.runId}`, + // One physical execution attempt, one identity. A derived `invocation-` + // prefix bought nothing and made the two names look independent. + invocationId: input.runId, sessionId: input.header.id, turnId: input.turn.turnId, status, diff --git a/packages/runtime/src/runtime-resume.ts b/packages/runtime/src/runtime-resume.ts index 849972c74a..a5610be480 100644 --- a/packages/runtime/src/runtime-resume.ts +++ b/packages/runtime/src/runtime-resume.ts @@ -32,7 +32,7 @@ import type { RuntimeBoundaryCursorV1, RuntimeBoundaryDigest, } from '@maka/core/runtime-boundary'; -import type { AgentRunHeader } from '@maka/core/agent-run'; +import { runtimeInvocationOpeningFromRunHeader, type AgentRunHeader } from '@maka/core/agent-run'; import type { ContinuationClaimStateV1 } from '@maka/core/runtime-event-store'; import { isDeepStrictEqual } from 'node:util'; import { @@ -495,11 +495,13 @@ export class RuntimeContinuationPlanner { currentWorkspaceIdentity: input.currentWorkspaceIdentity, backgroundOperationsSettled: input.backgroundOperationsSettled, availableToolNames: input.availableToolNames, - continuationIdentity: { - invocationId: this.deps.newId(), - runId: this.deps.newId(), - turnId: this.deps.newId(), - }, + // One physical execution attempt, one identity. Run and invocation are + // the same value at every mint site so the opening fact can be joined + // either way while the two names are still being retired. + continuationIdentity: (() => { + const invocationId = this.deps.newId(); + return { invocationId, runId: invocationId, turnId: this.deps.newId() }; + })(), continuationClaimId: this.deps.newId(), continuationReplayPlan: replay.plan, ...(input.expectedRuntimeEventHighWater !== undefined @@ -1532,7 +1534,11 @@ function continuationStartMatchesClaim( event.role === 'system' && event.author === 'system' && event.status === undefined && - event.content === undefined && + // Event 1 of a continuation target is also that invocation's opening fact. + isDeepStrictEqual( + event.content, + runtimeInvocationOpeningFromRunHeader(claim.targetRunHeader), + ) && event.actions && actionShapeMatches && runtimeProtocolMatches && diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index e602dba5c4..ea2b613cee 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -100,7 +100,7 @@ import { decodeAgentGraphIntentClaim } from '@maka/core/agent-graph-control'; import { executionBoundaryContains } from '@maka/core/sandbox-boundary'; import { failureClassFromCompleteStopReason } from '@maka/core/events'; import { isActiveShellRunStatus } from '@maka/core/shell-run'; -import { isSessionInlineRun } from '@maka/core/agent-run'; +import { isSessionInlineRun, runtimeInvocationOpeningFromRunHeader } from '@maka/core/agent-run'; import { isTerminalRuntimeEvent } from '@maka/core/runtime-event'; import type { AgentGraphIntentClaim, @@ -4847,6 +4847,8 @@ function buildContinuationRepairStartEvent(claim: ContinuationClaimV1): RuntimeE partial: false, role: 'system', author: 'system', + modelVisibility: 'hidden', + content: runtimeInvocationOpeningFromRunHeader(claim.targetRunHeader), actions: { continuationStart: { protocol: 'continuation_start_v2', diff --git a/packages/runtime/src/terminal-run-commit.ts b/packages/runtime/src/terminal-run-commit.ts index 8cc0335cab..f93555bc3e 100644 --- a/packages/runtime/src/terminal-run-commit.ts +++ b/packages/runtime/src/terminal-run-commit.ts @@ -385,6 +385,16 @@ function runtimeEventFailureClass(event: RuntimeEvent): string | undefined { return undefined; } +/** + * The terminal event's own message is the only source of a run's failure text. + * The header copy was written from this same message, so preferring the header + * only let a stale projection outlive the fact that produced it. + */ +function runtimeEventFailureMessage(event: RuntimeEvent): string | undefined { + if (event.content?.kind !== 'error') return undefined; + return event.content.message.length > 0 ? event.content.message : undefined; +} + export function terminalRunStatusFromRuntimeEvent( event: RuntimeEvent, ): TerminalAgentRunStatus | undefined { @@ -411,8 +421,8 @@ export function effectiveRunHeaderFromTerminalFact( ...(fact.runStatus === 'failed' && fact.failureClass ? { failureClass: fact.failureClass } : {}), - ...(fact.runStatus === 'failed' && run.failureMessage - ? { failureMessage: run.failureMessage } + ...(fact.runStatus === 'failed' && runtimeEventFailureMessage(fact.terminalEvent) + ? { failureMessage: runtimeEventFailureMessage(fact.terminalEvent)! } : {}), ...(fact.runStatus === 'cancelled' && fact.abortSource ? { abortSource: fact.abortSource } diff --git a/packages/storage/src/__tests__/sqlite-runtime-store.test.ts b/packages/storage/src/__tests__/sqlite-runtime-store.test.ts index 3fa3060403..ce5afbdbaf 100644 --- a/packages/storage/src/__tests__/sqlite-runtime-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-runtime-store.test.ts @@ -23,6 +23,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { describe, it } from 'node:test'; +import { runtimeInvocationOpeningFromRunHeader } from '@maka/core/agent-run'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { RunSealedError } from '@maka/core/runtime-event-store'; import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; @@ -2021,6 +2022,8 @@ function continuationStartEvent( partial: false, role: 'system', author: 'system', + modelVisibility: 'hidden', + content: runtimeInvocationOpeningFromRunHeader(claim.targetRunHeader), actions: { ...(overrides.toolBoundaryProtocol ? { runtimeProtocol: { toolBoundary: overrides.toolBoundaryProtocol } } diff --git a/packages/storage/src/sqlite-runtime-store.ts b/packages/storage/src/sqlite-runtime-store.ts index a25a0b7cb3..3fee999240 100644 --- a/packages/storage/src/sqlite-runtime-store.ts +++ b/packages/storage/src/sqlite-runtime-store.ts @@ -64,7 +64,11 @@ import { import { type ToolRecoveryDecisionFact } from '@maka/core/tool-recovery-fact'; import { canonicalToolArgsHash, stableJsonStringify } from '@maka/core/tool-args-identity'; import { encodeCanonicalRuntimeEvent } from '@maka/core/canonical-runtime-event'; -import { decodePersistedAgentRunHeader, type AgentRunHeader } from '@maka/core/agent-run'; +import { + decodePersistedAgentRunHeader, + runtimeInvocationOpeningFromRunHeader, + type AgentRunHeader, +} from '@maka/core/agent-run'; import { markPersisted } from '@maka/core/persisted-value'; import { scanToolLedger, @@ -4023,7 +4027,12 @@ function assertContinuationStartEvent( event.role !== 'system' || event.author !== 'system' || event.status !== undefined || - event.content !== undefined || + // Event 1 of a continuation target is also that invocation's opening fact, + // and the claim's target header is what it must project from. + !isDeepStrictEqual( + event.content, + runtimeInvocationOpeningFromRunHeader(claim.targetRunHeader), + ) || !event.actions || !validActionShape || !validRuntimeProtocol || From a520e84f15598d0446e9645daacbaeb0bd5f27bc Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 2 Sep 2026 23:57:28 +0800 Subject: [PATCH 03/46] feat(storage): enumerate a Session's invocations from the event spine Enumerating a Session's runs has only ever been possible through `core_agent_runs`, which is also the only place the opening metadata lives. That is what makes the Run header impossible to retire: it is simultaneously the authority and the index. Add `listSessionInvocations`, a query over `runtime_events` that reads each invocation's opening fact and, where the invocation has ended, its terminal event. Nothing writes it and nothing repairs it, so dropping the physical index and rebuilding gives the same inventory. It sits beside `listSessionRuns` on the same facades so consumers can move one at a time. Runtime schema 16 adds the covering index for the opening lookup and gives every header-only run the opening fact it never wrote. A run that already owns an immutable sequence is left alone: its position one, digests and coverage are already signed by other facts, so inserting into it would rewrite history rather than record it. A header the projection cannot read fails closed and is skipped, keeping its transcript and tool evidence exactly as readable as before. Refs #4311 Generated-by: Claude Code --- packages/core/src/runtime-event-store.ts | 27 ++- .../runtime-invocation-index.test.ts | 127 ++++++++++++ packages/runtime/src/agent-run.ts | 5 - .../invocation-opening-backfill.test.ts | 191 ++++++++++++++++++ .../recovery-persistence-authority.test.ts | 4 +- .../sqlite-recovery-concurrency.test.ts | 1 + .../__tests__/sqlite-runtime-schema.test.ts | 14 +- ...pace-version-authority-persistence.test.ts | 1 + packages/storage/src/execution-stores.ts | 15 +- .../storage/src/runtime-event-persistence.ts | 3 + packages/storage/src/sqlite-runtime-schema.ts | 108 +++++++++- packages/storage/src/sqlite-runtime-store.ts | 49 +++++ 12 files changed, 533 insertions(+), 12 deletions(-) create mode 100644 packages/runtime/src/__tests__/runtime-invocation-index.test.ts create mode 100644 packages/storage/src/__tests__/invocation-opening-backfill.test.ts diff --git a/packages/core/src/runtime-event-store.ts b/packages/core/src/runtime-event-store.ts index 1593689f0e..ea74c4f84a 100644 --- a/packages/core/src/runtime-event-store.ts +++ b/packages/core/src/runtime-event-store.ts @@ -17,7 +17,7 @@ * under the License. */ -import type { RuntimeEvent } from './runtime-event.js'; +import type { RuntimeEvent, RuntimeEventInvocationOpenedContent } from './runtime-event.js'; import type { ContinuationClaimV1, ImmutableRuntimePrefixV1, @@ -69,9 +69,34 @@ export class DurableStoreWriteError extends Error { } } +/** + * One invocation as the event spine itself describes it: its opening fact and, + * once it has ended, its terminal event. + * + * This is a query, not a table. Nothing writes it and nothing repairs it, so + * clearing any physical index and rebuilding from the events produces the same + * inventory. Reserved control-plane invocation streams have no opening fact and + * therefore never appear here. + */ +export interface RuntimeInvocationRecord { + sessionId: string; + invocationId: string; + runId: string; + turnId: string; + /** Timestamp of the opening fact's own event. */ + openedAt: number; + opening: RuntimeEventInvocationOpenedContent; + terminalEvent?: RuntimeEvent; +} + export interface RuntimeEventStore { /** Canonical stores fail the active run closed on every durable write error. */ readonly durability?: 'best_effort' | 'canonical'; + /** + * Enumerate a Session's invocations from the canonical events. Optional only + * while the Run header is still the enumeration authority consumers read. + */ + listSessionInvocations?(sessionId: string): Promise; appendRuntimeEvent( sessionId: string, runId: string, diff --git a/packages/runtime/src/__tests__/runtime-invocation-index.test.ts b/packages/runtime/src/__tests__/runtime-invocation-index.test.ts new file mode 100644 index 0000000000..b974ea68b1 --- /dev/null +++ b/packages/runtime/src/__tests__/runtime-invocation-index.test.ts @@ -0,0 +1,127 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * The invocation index is a query over the canonical events, not a second + * record. This test writes real turns through the production seams and then + * asks both authorities the same question: which invocations does this Session + * have, and what route did each one open with? + */ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { runtimeInvocationOpeningFromRunHeader } from '@maka/core/agent-run'; +import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; +import { createWorkspaceRuntimeStore } from '@maka/storage/runtime-event-persistence'; +import { createSessionStore } from '@maka/storage/session-store'; +import type { SessionEvent } from '@maka/core/events'; +import type { BackendSendInput } from '@maka/core/backend-types'; +import { BackendRegistry, SessionManager } from '../session-manager.js'; + +test('the invocation index returns the same inventory as the Run header table', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-invocation-index-')); + try { + const sessionStore = createSessionStore(root); + const runStore = createSqliteAgentRunStore(root); + const runtimeEventStore = createWorkspaceRuntimeStore(root); + const backends = new BackendRegistry(); + backends.register('ai-sdk', (ctx) => ({ + kind: 'ai-sdk' as const, + sessionId: ctx.sessionId, + async *send(input: BackendSendInput): AsyncIterable { + yield { + type: 'complete', + id: `${input.turnId}-complete`, + turnId: input.turnId, + ts: 2, + stopReason: 'end_turn', + }; + }, + async stop() {}, + async respondToSandboxBoundary() {}, + async dispose() {}, + })); + let ids = 0; + let clock = 1_000; + const manager = new SessionManager({ + store: sessionStore, + runStore, + runtimeEventStore, + backends, + newId: () => `index-${++ids}`, + now: () => (clock += 1), + }); + const session = await manager.createSession({ + cwd: root, + llmConnectionSlug: 'fake', + permissionMode: 'bypass', + }); + + for (const turnId of ['turn-1', 'turn-2', 'turn-3']) { + for await (const _event of manager.sendMessage(session.id, { turnId, text: turnId })) { + // Drain the turn so its run reaches the durable ledger. + } + } + + const runs = await runStore.listSessionRuns(session.id); + const invocations = await runtimeEventStore.listSessionInvocations(session.id); + assert.equal(runs.length, 3); + + assert.deepStrictEqual( + invocations + .map((invocation) => ({ + runId: invocation.runId, + invocationId: invocation.invocationId, + turnId: invocation.turnId, + })) + .sort((a, b) => a.runId.localeCompare(b.runId)), + runs + .map((run) => ({ + runId: run.runId, + invocationId: run.invocationId ?? run.runId, + turnId: run.turnId, + })) + .sort((a, b) => a.runId.localeCompare(b.runId)), + 'clearing the index and rebuilding from events must give the same inventory', + ); + + for (const run of runs) { + const invocation = invocations.find((candidate) => candidate.runId === run.runId); + assert.ok(invocation, `invocation for ${run.runId} must be enumerable from events alone`); + assert.deepStrictEqual( + invocation.opening, + runtimeInvocationOpeningFromRunHeader(run), + 'replay provenance read from events must equal the header projection', + ); + assert.equal( + invocation.terminalEvent?.status, + 'completed', + 'a finished invocation must expose its terminal event through the index', + ); + } + + runStore.close?.(); + runtimeEventStore.close(); + sessionStore.close?.(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index 7d369e4323..f861ca0b80 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -1268,11 +1268,6 @@ export class AgentRun { this.invocationOpeningCommitted = true; } - /** The opening fact this invocation committed, for its continuation-start event. */ - invocationOpeningFact(): RuntimeEventInvocationOpenedContent | undefined { - return this.invocationOpening; - } - private requiresDurablePersistence(): boolean { return this.input.durability === 'required'; } diff --git a/packages/storage/src/__tests__/invocation-opening-backfill.test.ts b/packages/storage/src/__tests__/invocation-opening-backfill.test.ts new file mode 100644 index 0000000000..7964f7ab84 --- /dev/null +++ b/packages/storage/src/__tests__/invocation-opening-backfill.test.ts @@ -0,0 +1,191 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { DatabaseSync } from 'node:sqlite'; +import type { AgentRunHeader } from '@maka/core/agent-run'; +import { decodeRuntimeEvent } from '@maka/core/runtime-event'; +import { createSqliteAgentRunStore } from '../agent-run-store.js'; +import { OPERATIONAL_STATE_DATABASE_NAME } from '../operational-state-store.js'; +import { + migrateSqliteRuntimeDatabase, + SQLITE_RUNTIME_SCHEMA_VERSION, +} from '../sqlite-runtime-schema.js'; +describe('invocation opening fact backfill', () => { + test('gives every header-only run the opening fact it never wrote', async () => { + await withHeaderOnlyRuns(async (databasePath) => { + const db = new DatabaseSync(databasePath); + try { + // One run already owns an immutable sequence; the backfill must leave it + // alone rather than rewrite its position one. + db.prepare(` + INSERT INTO runtime_events ( + event_id, session_id, invocation_id, run_id, turn_id, event_seq, + event_kind, payload_json, committed_at + ) VALUES ('existing-1', 'session-1', 'run-with-events', 'run-with-events', + 'turn-with-events', 1, 'text', '{}', 1) + `).run(); + rewindRuntimeSchemaToPreviousVersion(db); + migrateSqliteRuntimeDatabase(db); + assert.equal(readUserVersion(db), SQLITE_RUNTIME_SCHEMA_VERSION); + + const rows = db + .prepare(` + SELECT event_id, invocation_id, run_id, turn_id, event_seq, payload_json + FROM runtime_events + WHERE event_kind = 'invocation_opened' + ORDER BY run_id ASC + `) + .all() as Array<{ + event_id: string; + invocation_id: string; + run_id: string; + turn_id: string; + event_seq: number; + payload_json: string; + }>; + + assert.deepEqual( + rows.map((row) => row.run_id), + ['run-legacy-route', 'run-scheduled'], + 'only the header-only runs are backfilled, and the corrupt one is skipped', + ); + assert.deepEqual( + rows.map((row) => row.event_seq), + [1, 1], + 'a synthesized opening fact is event one of an otherwise empty invocation', + ); + + const legacy = decodeRuntimeEvent(JSON.parse(rows[0]!.payload_json)); + assert.equal(legacy.content?.kind, 'invocation_opened'); + if (legacy.content?.kind !== 'invocation_opened') throw new Error('unreachable'); + assert.equal( + legacy.content.route.provenance, + 'unknown', + 'a header with no Connection identity must not claim an authenticated route', + ); + assert.equal(legacy.content.route.modelId, 'legacy-model'); + assert.equal(legacy.content.source.kind, 'fresh'); + assert.equal(legacy.invocationId, 'run-legacy-route'); + + const scheduled = decodeRuntimeEvent(JSON.parse(rows[1]!.payload_json)); + if (scheduled.content?.kind !== 'invocation_opened') throw new Error('unreachable'); + assert.deepEqual(scheduled.content.root, { + kind: 'scheduled_task', + scheduledTaskId: 'task-9', + }); + assert.equal(scheduled.content.route.provenance, 'runtime'); + + const ordinals = db + .prepare('SELECT COUNT(*) AS total FROM runtime_session_event_ordinals') + .get() as { total: number }; + assert.equal( + ordinals.total, + rows.length, + 'every backfilled event joins the Session ordinal stream', + ); + + // The run that already owns an immutable sequence keeps it untouched: + // rewriting its position one would break digests other facts signed. + const withEvents = db + .prepare( + "SELECT event_id FROM runtime_events WHERE run_id = 'run-with-events' ORDER BY event_seq", + ) + .all() as Array<{ event_id: string }>; + assert.deepEqual( + withEvents.map((row) => row.event_id), + ['existing-1'], + ); + } finally { + db.close(); + } + }); + }); +}); + +/** Undo the v16 step so the migration under test runs against real header rows. */ +function rewindRuntimeSchemaToPreviousVersion(db: DatabaseSync): void { + db.exec('DROP INDEX IF EXISTS runtime_events_by_session_kind'); + db.exec("DELETE FROM runtime_events WHERE event_kind = 'invocation_opened'"); + db.exec(`PRAGMA user_version = ${SQLITE_RUNTIME_SCHEMA_VERSION - 1}`); +} + +function readUserVersion(db: DatabaseSync): number { + return (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version; +} + +async function withHeaderOnlyRuns(run: (databasePath: string) => Promise): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-opening-backfill-')); + try { + const store = createSqliteAgentRunStore(root); + await store.createRun( + header({ + runId: 'run-legacy-route', + turnId: 'turn-legacy', + modelId: 'legacy-model', + }), + ); + await store.createRun( + header({ + runId: 'run-scheduled', + turnId: 'turn-scheduled', + llmConnectionId: 'connection-1', + scheduledTaskId: 'task-9', + }), + ); + // A graph wake with no delivery attempt is corruption; the backfill must + // skip it rather than invent a root authority for it. + await store.createRun( + header({ + runId: 'run-corrupt-root', + turnId: 'turn-corrupt', + agentGraphWakeId: 'wake-1', + }), + ); + await store.createRun(header({ runId: 'run-with-events', turnId: 'turn-with-events' })); + store.close?.(); + + const databasePath = join(root, OPERATIONAL_STATE_DATABASE_NAME); + await run(databasePath); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +function header(overrides: Partial): AgentRunHeader { + return { + runId: 'run-1', + invocationId: overrides.runId ?? 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + status: 'completed', + backendKind: 'ai-sdk', + llmConnectionSlug: 'fake', + modelId: 'fake-model', + cwd: '/tmp/cwd', + permissionMode: 'ask', + createdAt: 1, + updatedAt: 2, + ...overrides, + }; +} diff --git a/packages/storage/src/__tests__/recovery-persistence-authority.test.ts b/packages/storage/src/__tests__/recovery-persistence-authority.test.ts index fefea71ba0..f8b86fc55d 100644 --- a/packages/storage/src/__tests__/recovery-persistence-authority.test.ts +++ b/packages/storage/src/__tests__/recovery-persistence-authority.test.ts @@ -108,7 +108,7 @@ describe('SQLite recovery persistence authority', () => { dispatch.ts, ); db.exec( - 'DROP TABLE runtime_session_event_ordinals; DROP TABLE runtime_partial_segments; DROP TABLE runtime_storage_root_binding; DROP TABLE runtime_managed_mutation_reservations; DROP TABLE runtime_workspace_heads; DROP TABLE runtime_workspace_versions; DROP TABLE runtime_workspace_epochs; DROP TABLE runtime_continuation_claims; DROP TABLE runtime_capabilities; PRAGMA user_version = 4;', + 'DROP INDEX runtime_events_by_session_kind; DROP TABLE runtime_session_event_ordinals; DROP TABLE runtime_partial_segments; DROP TABLE runtime_storage_root_binding; DROP TABLE runtime_managed_mutation_reservations; DROP TABLE runtime_workspace_heads; DROP TABLE runtime_workspace_versions; DROP TABLE runtime_workspace_epochs; DROP TABLE runtime_continuation_claims; DROP TABLE runtime_capabilities; PRAGMA user_version = 4;', ); db.close(); @@ -204,7 +204,7 @@ describe('SQLite recovery persistence authority', () => { 2, ); db.exec( - 'DROP TABLE runtime_session_event_ordinals; DROP TABLE runtime_partial_segments; DROP TABLE runtime_storage_root_binding; DROP TABLE runtime_managed_mutation_reservations; DROP TABLE runtime_workspace_heads; DROP TABLE runtime_workspace_versions; DROP TABLE runtime_workspace_epochs; DROP TABLE runtime_continuation_claims; DROP TABLE runtime_capabilities; PRAGMA user_version = 4;', + 'DROP INDEX runtime_events_by_session_kind; DROP TABLE runtime_session_event_ordinals; DROP TABLE runtime_partial_segments; DROP TABLE runtime_storage_root_binding; DROP TABLE runtime_managed_mutation_reservations; DROP TABLE runtime_workspace_heads; DROP TABLE runtime_workspace_versions; DROP TABLE runtime_workspace_epochs; DROP TABLE runtime_continuation_claims; DROP TABLE runtime_capabilities; PRAGMA user_version = 4;', ); db.close(); diff --git a/packages/storage/src/__tests__/sqlite-recovery-concurrency.test.ts b/packages/storage/src/__tests__/sqlite-recovery-concurrency.test.ts index 0f13e96866..1da1d8e6f6 100644 --- a/packages/storage/src/__tests__/sqlite-recovery-concurrency.test.ts +++ b/packages/storage/src/__tests__/sqlite-recovery-concurrency.test.ts @@ -340,6 +340,7 @@ describe('SQLite recovery authority multi-process races', () => { try { db.exec(` DROP TABLE runtime_managed_mutation_reservations; + DROP INDEX runtime_events_by_session_kind; DROP TABLE runtime_session_event_ordinals; PRAGMA user_version = 10; UPDATE operational_schema_migrations SET version = 10 WHERE scope = 'runtime'; diff --git a/packages/storage/src/__tests__/sqlite-runtime-schema.test.ts b/packages/storage/src/__tests__/sqlite-runtime-schema.test.ts index e8d45e944e..3c35682a9c 100644 --- a/packages/storage/src/__tests__/sqlite-runtime-schema.test.ts +++ b/packages/storage/src/__tests__/sqlite-runtime-schema.test.ts @@ -108,7 +108,17 @@ describe('SQLite runtime schema migration', () => { try { db.exec('PRAGMA foreign_keys = ON'); db.exec(` - CREATE TABLE runtime_events (event_id TEXT PRIMARY KEY); + CREATE TABLE runtime_events ( + event_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + invocation_id TEXT NOT NULL, + run_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + event_seq INTEGER NOT NULL, + event_kind TEXT NOT NULL, + payload_json TEXT NOT NULL, + committed_at INTEGER NOT NULL + ); CREATE TABLE runtime_continuation_claims ( claim_id TEXT PRIMARY KEY, source_session_id TEXT NOT NULL, @@ -144,7 +154,7 @@ describe('SQLite runtime schema migration', () => { migrateSqliteRuntimeDatabase(db); - assert.equal(SQLITE_RUNTIME_SCHEMA_VERSION, 15); + assert.equal(SQLITE_RUNTIME_SCHEMA_VERSION, 16); assert.equal( ( db diff --git a/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts b/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts index ecbfa62598..d59d15a8a9 100644 --- a/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts +++ b/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts @@ -1346,6 +1346,7 @@ function recreateWorkspaceTablesAsSchema12(database: DatabaseSync): void { DROP TABLE runtime_workspace_heads_schema_13; DROP TABLE runtime_workspace_versions_schema_13; DROP TABLE runtime_managed_mutation_reservations; + DROP INDEX runtime_events_by_session_kind; PRAGMA user_version = 12; COMMIT; PRAGMA foreign_keys = ON; diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index ed9714e275..bd1e7e09de 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -24,7 +24,10 @@ import type { AgentRunProjectionKey, } from '@maka/core/agent-run'; import type { RuntimeEvent, ToolBoundaryProtocol } from '@maka/core/runtime-event'; -import type { RuntimeContinuationAuthorityStore } from '@maka/core/runtime-event-store'; +import type { + RuntimeContinuationAuthorityStore, + RuntimeInvocationRecord, +} from '@maka/core/runtime-event-store'; import type { SessionHeader, SessionSummary, StoredMessage, TurnRecord } from '@maka/core/session'; import type { SessionListFilter } from '@maka/core/runtime-inputs'; import { @@ -209,6 +212,12 @@ export interface ExecutionAgentRunReader { } export interface ExecutionRuntimeEventReader { + /** + * Session run inventory read from the canonical events rather than the Run + * header table. Sits beside `listSessionRuns` so consumers can move one at a + * time; nothing writes or repairs it. + */ + listSessionInvocations(sessionId: string): Promise; readRuntimeEvents(sessionId: string, runId: string): Promise; readRuntimeEventsBounded( sessionId: string, @@ -555,6 +564,8 @@ async function createExecutionStoresForWrite runtimeEventStore.readImmutableRuntimeEvents(sessionId, runId)), readImmutableRuntimePrefix: (input) => run(() => runtimeEventStore.readImmutableRuntimePrefix(input)), + listSessionInvocations: (sessionId) => + run(() => runtimeEventStore.listSessionInvocations(sessionId)), readSessionRuntimeEvents: (sessionId) => run(() => runtimeEventStore.readSessionRuntimeEvents(sessionId)), readSessionRuntimeEventEntries: (sessionId) => @@ -666,6 +677,8 @@ async function openExecutionStoresForRead runtimeEventStore.readRuntimeEventsBounded(sessionId, runId, budget)), readImmutableRuntimeEvents: (sessionId, runId) => run(() => runtimeEventStore.readImmutableRuntimeEvents(sessionId, runId)), + listSessionInvocations: (sessionId) => + run(() => runtimeEventStore.listSessionInvocations(sessionId)), readSessionRuntimeEvents: (sessionId) => run(() => runtimeEventStore.readSessionRuntimeEvents(sessionId)), }, diff --git a/packages/storage/src/runtime-event-persistence.ts b/packages/storage/src/runtime-event-persistence.ts index 26b11fc6f0..806f30e361 100644 --- a/packages/storage/src/runtime-event-persistence.ts +++ b/packages/storage/src/runtime-event-persistence.ts @@ -19,6 +19,7 @@ import { join } from 'node:path'; import type { RuntimeEvent } from '@maka/core/runtime-event'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-event-store'; import type { BoundedEvidenceReadResult, EvidenceReadBudget } from './agent-run-store.js'; import { createSqliteRuntimeStore, type SqliteRuntimeStore } from './sqlite-runtime-store.js'; import { @@ -40,6 +41,7 @@ export type RuntimeEventReadPersistence = { }; export interface RuntimeEventReadStore { + listSessionInvocations(sessionId: string): Promise; readRuntimeEvents(sessionId: string, runId: string): Promise; readRuntimeEventsBounded( sessionId: string, @@ -79,6 +81,7 @@ export async function openRuntimeEventReadPersistence(input: { return { kind: 'sqlite', runtimeEventStore: Object.freeze({ + listSessionInvocations: (sessionId: string) => store.listSessionInvocations(sessionId), readRuntimeEvents: (sessionId: string, runId: string) => store.readRuntimeEvents(sessionId, runId), readRuntimeEventsBounded: (sessionId: string, runId: string, budget: EvidenceReadBudget) => diff --git a/packages/storage/src/sqlite-runtime-schema.ts b/packages/storage/src/sqlite-runtime-schema.ts index d9ecaee2cd..7b23d74b7b 100644 --- a/packages/storage/src/sqlite-runtime-schema.ts +++ b/packages/storage/src/sqlite-runtime-schema.ts @@ -18,8 +18,16 @@ */ import type { DatabaseSync } from 'node:sqlite'; +import { + decodePersistedAgentRunHeader, + runtimeInvocationOpeningFromRunHeader, + type AgentRunHeader, +} from '@maka/core/agent-run'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; +import { encodeCanonicalRuntimeEvent } from '@maka/core/canonical-runtime-event'; +import type { PersistedValue } from '@maka/core/persisted-value'; -export const SQLITE_RUNTIME_SCHEMA_VERSION = 15; +export const SQLITE_RUNTIME_SCHEMA_VERSION = 16; export const RUNTIME_RECOVERY_AUTHORITY_CAPABILITY = 'runtime_recovery_authority'; export const RUNTIME_RECOVERY_AUTHORITY_CAPABILITY_VERSION = 1; export const RUNTIME_CONTINUATION_AUTHORITY_CAPABILITY = 'runtime_continuation_authority'; @@ -484,8 +492,105 @@ const MIGRATIONS: ReadonlyMap = new Map([ ALTER TABLE runtime_continuation_claims_v15 RENAME TO runtime_continuation_claims; `, ], + [ + 16, + ` + CREATE INDEX runtime_events_by_session_kind + ON runtime_events(session_id, event_kind, invocation_id); + `, + ], ]); +/** + * Data migrations that a SQL statement cannot express, applied inside the same + * transaction as their schema step. They project persisted records through the + * one TypeScript mapping that owns that projection, so a migration and the live + * writer can never classify a field two different ways. + */ +const DATA_MIGRATIONS: ReadonlyMap void> = new Map([ + [16, backfillInvocationOpeningFacts], +]); + +/** + * Give every Run header that never wrote a RuntimeEvent the opening fact it + * would have written today. + * + * Only header-only runs are backfilled. A run that already has events owns an + * immutable sequence whose position 1, digests and coverage other facts point + * at; inserting into it would rewrite history that other records have already + * signed. Those runs keep the Run header as their opening evidence until the + * consumers move. + * + * A header this cannot project fails closed: it is skipped, and its transcript + * and tool evidence stay exactly as readable as before. + */ +function backfillInvocationOpeningFacts(db: DatabaseSync): void { + if (!hasTable(db, 'core_agent_runs')) return; + const rows = db + .prepare(` + SELECT r.session_id, r.run_id, r.record_json + FROM core_agent_runs r + WHERE NOT EXISTS ( + SELECT 1 FROM runtime_events e + WHERE e.session_id = r.session_id AND e.run_id = r.run_id + ) + ORDER BY r.created_at ASC, r.run_id ASC + `) + .all() as Array<{ session_id: string; run_id: string; record_json: string }>; + const insertEvent = db.prepare(` + INSERT INTO runtime_events ( + event_id, session_id, invocation_id, run_id, turn_id, event_seq, + event_kind, payload_json, committed_at + ) VALUES (?, ?, ?, ?, ?, 1, 'invocation_opened', ?, ?) + `); + const insertOrdinal = db.prepare(` + INSERT INTO runtime_session_event_ordinals(session_id, ordinal, event_id) + SELECT ?, COALESCE(MAX(ordinal), 0) + 1, ? + FROM runtime_session_event_ordinals WHERE session_id = ? + `); + for (const row of rows) { + let encoded: { event: RuntimeEvent; json: string }; + try { + const header = decodePersistedAgentRunHeader( + JSON.parse(row.record_json) as PersistedValue, + ); + encoded = encodeCanonicalRuntimeEvent({ + id: `invocation_opened:${header.runId}`, + invocationId: header.invocationId ?? header.runId, + runId: header.runId, + sessionId: header.sessionId, + turnId: header.turnId, + ts: header.createdAt, + partial: false, + role: 'system', + author: 'system', + modelVisibility: 'hidden', + content: runtimeInvocationOpeningFromRunHeader(header), + }); + } catch { + continue; + } + const event = encoded.event; + insertEvent.run( + event.id, + event.sessionId, + event.invocationId, + event.runId, + event.turnId, + encoded.json, + event.ts, + ); + insertOrdinal.run(event.sessionId, event.id, event.sessionId); + } +} + +function hasTable(db: DatabaseSync, name: string): boolean { + const row = db + .prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?") + .get(name) as { present?: unknown } | undefined; + return row?.present === 1; +} + export function configureSqliteRuntimeDatabase(db: DatabaseSync): void { // Bound lock acquisition before touching persistent journal state. WAL mode is // database-persistent, so established workspaces only need to verify it rather @@ -530,6 +635,7 @@ export function migrateSqliteRuntimeDatabase( const sql = MIGRATIONS.get(version); if (!sql) throw new Error(`Missing SQLite runtime migration ${version}`); db.exec(sql); + DATA_MIGRATIONS.get(version)?.(db); db.exec(`PRAGMA user_version = ${version}`); } if (ownsTransaction) db.exec('COMMIT'); diff --git a/packages/storage/src/sqlite-runtime-store.ts b/packages/storage/src/sqlite-runtime-store.ts index 3fee999240..671c63042a 100644 --- a/packages/storage/src/sqlite-runtime-store.ts +++ b/packages/storage/src/sqlite-runtime-store.ts @@ -45,6 +45,7 @@ import { decodeRuntimeEvent, isPartialRuntimeEvent, isTerminalRuntimeEvent, + runtimeEventInvocationOpening, TOOL_BOUNDARY_PROTOCOL_V1, type RuntimeEvent, type RuntimeEventManagedWorkspaceMutationV2, @@ -57,6 +58,7 @@ import { type ContinuationClaimResult, type ContinuationClaimStateV1, type RuntimeContinuationAuthorityStore, + type RuntimeInvocationRecord, type RuntimeRecoveryBundleCommit, type RuntimeRecoveryBundleStore, type RuntimeWorkspaceVersionAuthorityStore, @@ -530,6 +532,53 @@ export class SqliteRuntimeStore return this.readRuntimeEventsSync(sessionId, runId); } + /** + * Enumerate a Session's invocations straight from the event spine: the + * opening fact names each one, and its highest-sequence event says whether it + * ended. There is no derived table behind this, so dropping every index and + * rebuilding gives the same answer. + */ + async listSessionInvocations(sessionId: string): Promise { + assertRuntimeStorageSafeId(sessionId, 'Invalid session id'); + return this.readTransaction(() => { + const openings = this.db + .prepare(` + SELECT event_id, session_id, invocation_id, run_id, turn_id, payload_json + FROM runtime_events + WHERE session_id = ? AND event_kind = 'invocation_opened' + ORDER BY committed_at ASC, event_seq ASC, event_id ASC + `) + .all(sessionId) as unknown as RuntimeEventStorageRow[]; + const lastEvent = this.db.prepare(` + SELECT event_id, session_id, invocation_id, run_id, turn_id, payload_json + FROM runtime_events + WHERE invocation_id = ? + ORDER BY event_seq DESC + LIMIT 1 + `); + return openings.map((row) => { + const event = decodeRuntimeEventStorageRow(row); + const opening = runtimeEventInvocationOpening(event); + if (!opening) { + throw new Error(`RuntimeEvent ${event.id} is indexed as an opening fact but is not one`); + } + const lastRow = lastEvent.get(event.invocationId) as unknown as + | RuntimeEventStorageRow + | undefined; + const last = lastRow ? decodeRuntimeEventStorageRow(lastRow) : undefined; + return { + sessionId: event.sessionId, + invocationId: event.invocationId, + runId: event.runId, + turnId: event.turnId, + openedAt: event.ts, + opening, + ...(last && isTerminalRuntimeEvent(last) ? { terminalEvent: last } : {}), + } satisfies RuntimeInvocationRecord; + }); + }); + } + async scanRuntimeEvents( sessionId: string, runId: string, From 72fc4d6d22f9053ed6fe029e6fd6c864d6bedb8e Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 00:08:06 +0800 Subject: [PATCH 04/46] feat(storage): keep a migrated invocation's opening beside the ones events carry The v16 backfill could only give an opening fact to runs that had never written a RuntimeEvent. A run that already owns an immutable sequence cannot take one: its position 1, digests and coverage are signed by other facts, so inserting there would rewrite history. That left those invocations with their opening on the Run header alone, which is exactly the authority this work is retiring. Record their openings in `runtime_legacy_invocation_openings` instead. Only the migration writes it, it is keyed by the invocation id the invocation's own events already carry, and it holds the same projection the live writer emits, produced by the same function. `listSessionInvocations` merges the two shelves and says nothing about which one a record came from. An opening is an opening; a consumer that could tell would be encoding the migration window into its own logic, and would then have to be changed again when the window closes. Refs #4311 Generated-by: Claude Code --- .../invocation-opening-backfill.test.ts | 86 +++++++++++++++++++ .../recovery-persistence-authority.test.ts | 4 +- .../sqlite-recovery-concurrency.test.ts | 1 + ...pace-version-authority-persistence.test.ts | 1 + packages/storage/src/sqlite-runtime-schema.ts | 79 +++++++++++++---- packages/storage/src/sqlite-runtime-store.ts | 82 +++++++++++++----- 6 files changed, 216 insertions(+), 37 deletions(-) diff --git a/packages/storage/src/__tests__/invocation-opening-backfill.test.ts b/packages/storage/src/__tests__/invocation-opening-backfill.test.ts index 7964f7ab84..4ced678e25 100644 --- a/packages/storage/src/__tests__/invocation-opening-backfill.test.ts +++ b/packages/storage/src/__tests__/invocation-opening-backfill.test.ts @@ -24,9 +24,11 @@ import { join } from 'node:path'; import { describe, test } from 'node:test'; import { DatabaseSync } from 'node:sqlite'; import type { AgentRunHeader } from '@maka/core/agent-run'; +import { encodeCanonicalRuntimeEvent } from '@maka/core/canonical-runtime-event'; import { decodeRuntimeEvent } from '@maka/core/runtime-event'; import { createSqliteAgentRunStore } from '../agent-run-store.js'; import { OPERATIONAL_STATE_DATABASE_NAME } from '../operational-state-store.js'; +import { createSqliteRuntimeStore } from '../sqlite-runtime-store.js'; import { migrateSqliteRuntimeDatabase, SQLITE_RUNTIME_SCHEMA_VERSION, @@ -116,9 +118,91 @@ describe('invocation opening fact backfill', () => { withEvents.map((row) => row.event_id), ['existing-1'], ); + + // Its opening is not lost, though: it goes on the legacy shelf, keyed by + // the invocation id its own events already carry. + const legacyRows = db + .prepare(` + SELECT invocation_id, session_id, run_id, turn_id, opened_at, opening_json + FROM runtime_legacy_invocation_openings + ORDER BY invocation_id + `) + .all() as Array<{ + invocation_id: string; + session_id: string; + run_id: string; + turn_id: string; + opened_at: number; + opening_json: string; + }>; + assert.deepEqual( + legacyRows.map((row) => row.invocation_id), + ['run-with-events'], + 'only a run whose sequence is already immutable takes the legacy shelf', + ); + assert.equal(legacyRows[0]!.run_id, 'run-with-events'); + assert.equal(legacyRows[0]!.turn_id, 'turn-with-events'); + assert.equal(legacyRows[0]!.opened_at, 1); + assert.equal( + (JSON.parse(legacyRows[0]!.opening_json) as { kind: string }).kind, + 'invocation_opened', + ); + } finally { + db.close(); + } + }); + }); + + test('enumerates event openings and migrated ones as one inventory', async () => { + await withHeaderOnlyRuns(async (databasePath) => { + const db = new DatabaseSync(databasePath); + try { + const { json } = encodeCanonicalRuntimeEvent({ + id: 'existing-1', + invocationId: 'run-with-events', + runId: 'run-with-events', + sessionId: 'session-1', + turnId: 'turn-with-events', + ts: 1, + partial: false, + role: 'user', + author: 'user', + modelVisibility: 'visible', + content: { kind: 'text', text: 'already immutable' }, + }); + db.prepare(` + INSERT INTO runtime_events ( + event_id, session_id, invocation_id, run_id, turn_id, event_seq, + event_kind, payload_json, committed_at + ) VALUES ('existing-1', 'session-1', 'run-with-events', 'run-with-events', + 'turn-with-events', 1, 'text', ?, 1) + `).run(json); + rewindRuntimeSchemaToPreviousVersion(db); + migrateSqliteRuntimeDatabase(db); } finally { db.close(); } + + const store = createSqliteRuntimeStore(databasePath); + try { + const invocations = await store.listSessionInvocations('session-1'); + assert.deepEqual( + invocations.map((invocation) => invocation.invocationId), + ['run-legacy-route', 'run-scheduled', 'run-with-events'], + 'a migrated opening is enumerated beside the ones the events carry', + ); + for (const invocation of invocations) { + assert.equal(invocation.opening.kind, 'invocation_opened'); + assert.equal(invocation.sessionId, 'session-1'); + } + const migrated = invocations.find( + (invocation) => invocation.invocationId === 'run-with-events', + ); + assert.equal(migrated?.turnId, 'turn-with-events'); + assert.equal(migrated?.terminalEvent, undefined); + } finally { + store.close(); + } }); }); }); @@ -126,6 +210,8 @@ describe('invocation opening fact backfill', () => { /** Undo the v16 step so the migration under test runs against real header rows. */ function rewindRuntimeSchemaToPreviousVersion(db: DatabaseSync): void { db.exec('DROP INDEX IF EXISTS runtime_events_by_session_kind'); + db.exec('DROP INDEX IF EXISTS runtime_legacy_invocation_openings_by_session'); + db.exec('DROP TABLE IF EXISTS runtime_legacy_invocation_openings'); db.exec("DELETE FROM runtime_events WHERE event_kind = 'invocation_opened'"); db.exec(`PRAGMA user_version = ${SQLITE_RUNTIME_SCHEMA_VERSION - 1}`); } diff --git a/packages/storage/src/__tests__/recovery-persistence-authority.test.ts b/packages/storage/src/__tests__/recovery-persistence-authority.test.ts index f8b86fc55d..ace261161b 100644 --- a/packages/storage/src/__tests__/recovery-persistence-authority.test.ts +++ b/packages/storage/src/__tests__/recovery-persistence-authority.test.ts @@ -108,7 +108,7 @@ describe('SQLite recovery persistence authority', () => { dispatch.ts, ); db.exec( - 'DROP INDEX runtime_events_by_session_kind; DROP TABLE runtime_session_event_ordinals; DROP TABLE runtime_partial_segments; DROP TABLE runtime_storage_root_binding; DROP TABLE runtime_managed_mutation_reservations; DROP TABLE runtime_workspace_heads; DROP TABLE runtime_workspace_versions; DROP TABLE runtime_workspace_epochs; DROP TABLE runtime_continuation_claims; DROP TABLE runtime_capabilities; PRAGMA user_version = 4;', + 'DROP INDEX runtime_events_by_session_kind; DROP TABLE runtime_legacy_invocation_openings; DROP TABLE runtime_session_event_ordinals; DROP TABLE runtime_partial_segments; DROP TABLE runtime_storage_root_binding; DROP TABLE runtime_managed_mutation_reservations; DROP TABLE runtime_workspace_heads; DROP TABLE runtime_workspace_versions; DROP TABLE runtime_workspace_epochs; DROP TABLE runtime_continuation_claims; DROP TABLE runtime_capabilities; PRAGMA user_version = 4;', ); db.close(); @@ -204,7 +204,7 @@ describe('SQLite recovery persistence authority', () => { 2, ); db.exec( - 'DROP INDEX runtime_events_by_session_kind; DROP TABLE runtime_session_event_ordinals; DROP TABLE runtime_partial_segments; DROP TABLE runtime_storage_root_binding; DROP TABLE runtime_managed_mutation_reservations; DROP TABLE runtime_workspace_heads; DROP TABLE runtime_workspace_versions; DROP TABLE runtime_workspace_epochs; DROP TABLE runtime_continuation_claims; DROP TABLE runtime_capabilities; PRAGMA user_version = 4;', + 'DROP INDEX runtime_events_by_session_kind; DROP TABLE runtime_legacy_invocation_openings; DROP TABLE runtime_session_event_ordinals; DROP TABLE runtime_partial_segments; DROP TABLE runtime_storage_root_binding; DROP TABLE runtime_managed_mutation_reservations; DROP TABLE runtime_workspace_heads; DROP TABLE runtime_workspace_versions; DROP TABLE runtime_workspace_epochs; DROP TABLE runtime_continuation_claims; DROP TABLE runtime_capabilities; PRAGMA user_version = 4;', ); db.close(); diff --git a/packages/storage/src/__tests__/sqlite-recovery-concurrency.test.ts b/packages/storage/src/__tests__/sqlite-recovery-concurrency.test.ts index 1da1d8e6f6..9858ccb225 100644 --- a/packages/storage/src/__tests__/sqlite-recovery-concurrency.test.ts +++ b/packages/storage/src/__tests__/sqlite-recovery-concurrency.test.ts @@ -341,6 +341,7 @@ describe('SQLite recovery authority multi-process races', () => { db.exec(` DROP TABLE runtime_managed_mutation_reservations; DROP INDEX runtime_events_by_session_kind; + DROP TABLE runtime_legacy_invocation_openings; DROP TABLE runtime_session_event_ordinals; PRAGMA user_version = 10; UPDATE operational_schema_migrations SET version = 10 WHERE scope = 'runtime'; diff --git a/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts b/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts index d59d15a8a9..ef51faafa9 100644 --- a/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts +++ b/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts @@ -1347,6 +1347,7 @@ function recreateWorkspaceTablesAsSchema12(database: DatabaseSync): void { DROP TABLE runtime_workspace_versions_schema_13; DROP TABLE runtime_managed_mutation_reservations; DROP INDEX runtime_events_by_session_kind; + DROP TABLE runtime_legacy_invocation_openings; PRAGMA user_version = 12; COMMIT; PRAGMA foreign_keys = ON; diff --git a/packages/storage/src/sqlite-runtime-schema.ts b/packages/storage/src/sqlite-runtime-schema.ts index 7b23d74b7b..af01dd2aa9 100644 --- a/packages/storage/src/sqlite-runtime-schema.ts +++ b/packages/storage/src/sqlite-runtime-schema.ts @@ -497,6 +497,18 @@ const MIGRATIONS: ReadonlyMap = new Map([ ` CREATE INDEX runtime_events_by_session_kind ON runtime_events(session_id, event_kind, invocation_id); + + CREATE TABLE runtime_legacy_invocation_openings ( + invocation_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + run_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + opened_at INTEGER NOT NULL, + opening_json TEXT NOT NULL + ) WITHOUT ROWID; + + CREATE INDEX runtime_legacy_invocation_openings_by_session + ON runtime_legacy_invocation_openings(session_id, opened_at, invocation_id); `, ], ]); @@ -512,14 +524,16 @@ const DATA_MIGRATIONS: ReadonlyMap void> = new Map ]); /** - * Give every Run header that never wrote a RuntimeEvent the opening fact it - * would have written today. + * Give every Run header its opening fact, so that after this migration the + * opening lives in the runtime database rather than on the header. * - * Only header-only runs are backfilled. A run that already has events owns an - * immutable sequence whose position 1, digests and coverage other facts point - * at; inserting into it would rewrite history that other records have already - * signed. Those runs keep the Run header as their opening evidence until the - * consumers move. + * A run that never wrote a RuntimeEvent gets the real thing: the opening fact + * as event one of its own invocation. A run that already has events cannot, + * because it owns an immutable sequence whose position 1, digests and coverage + * other facts already point at; inserting into it would rewrite signed history. + * Its opening is recorded in `runtime_legacy_invocation_openings` instead, + * which only this migration ever writes. Readers merge the two, so nothing + * downstream has to know which shelf a given opening came off. * * A header this cannot project fails closed: it is skipped, and its transcript * and tool evidence stay exactly as readable as before. @@ -528,15 +542,24 @@ function backfillInvocationOpeningFacts(db: DatabaseSync): void { if (!hasTable(db, 'core_agent_runs')) return; const rows = db .prepare(` - SELECT r.session_id, r.run_id, r.record_json + SELECT + r.session_id, + r.run_id, + r.record_json, + ( + SELECT e.invocation_id FROM runtime_events e + WHERE e.session_id = r.session_id AND e.run_id = r.run_id + ORDER BY e.event_seq ASC LIMIT 1 + ) AS existing_invocation_id FROM core_agent_runs r - WHERE NOT EXISTS ( - SELECT 1 FROM runtime_events e - WHERE e.session_id = r.session_id AND e.run_id = r.run_id - ) ORDER BY r.created_at ASC, r.run_id ASC `) - .all() as Array<{ session_id: string; run_id: string; record_json: string }>; + .all() as Array<{ + session_id: string; + run_id: string; + record_json: string; + existing_invocation_id: string | null; + }>; const insertEvent = db.prepare(` INSERT INTO runtime_events ( event_id, session_id, invocation_id, run_id, turn_id, event_seq, @@ -548,12 +571,38 @@ function backfillInvocationOpeningFacts(db: DatabaseSync): void { SELECT ?, COALESCE(MAX(ordinal), 0) + 1, ? FROM runtime_session_event_ordinals WHERE session_id = ? `); + const insertLegacyOpening = db.prepare(` + INSERT OR IGNORE INTO runtime_legacy_invocation_openings ( + invocation_id, session_id, run_id, turn_id, opened_at, opening_json + ) VALUES (?, ?, ?, ?, ?, ?) + `); for (const row of rows) { - let encoded: { event: RuntimeEvent; json: string }; + let header: AgentRunHeader; + let opening: string; try { - const header = decodePersistedAgentRunHeader( + header = decodePersistedAgentRunHeader( JSON.parse(row.record_json) as PersistedValue, ); + opening = JSON.stringify(runtimeInvocationOpeningFromRunHeader(header)); + } catch { + continue; + } + if (row.existing_invocation_id !== null) { + // The invocation id its own events already carry is the one every reader + // joins on, so the legacy row is keyed by that rather than by the header's + // copy, which older builds minted independently. + insertLegacyOpening.run( + row.existing_invocation_id, + header.sessionId, + header.runId, + header.turnId, + header.createdAt, + opening, + ); + continue; + } + let encoded: { event: RuntimeEvent; json: string }; + try { encoded = encodeCanonicalRuntimeEvent({ id: `invocation_opened:${header.runId}`, invocationId: header.invocationId ?? header.runId, diff --git a/packages/storage/src/sqlite-runtime-store.ts b/packages/storage/src/sqlite-runtime-store.ts index 671c63042a..ce787628fc 100644 --- a/packages/storage/src/sqlite-runtime-store.ts +++ b/packages/storage/src/sqlite-runtime-store.ts @@ -43,6 +43,7 @@ import { } from '@maka/core/workspace-version-authority'; import { decodeRuntimeEvent, + decodeRuntimeInvocationOpened, isPartialRuntimeEvent, isTerminalRuntimeEvent, runtimeEventInvocationOpening, @@ -533,10 +534,15 @@ export class SqliteRuntimeStore } /** - * Enumerate a Session's invocations straight from the event spine: the - * opening fact names each one, and its highest-sequence event says whether it - * ended. There is no derived table behind this, so dropping every index and - * rebuilding gives the same answer. + * Enumerate a Session's invocations: the opening fact names each one, and its + * highest-sequence event says whether it ended. + * + * Invocations that predate the opening fact could not be given one without + * rewriting an immutable sequence, so the migration parked their openings in + * `runtime_legacy_invocation_openings`. Both shelves are merged here and the + * result says nothing about which one a record came from: an opening is an + * opening, and a consumer that branched on its storage would be encoding the + * migration window into its own logic. */ async listSessionInvocations(sessionId: string): Promise { assertRuntimeStorageSafeId(sessionId, 'Invalid session id'); @@ -546,39 +552,75 @@ export class SqliteRuntimeStore SELECT event_id, session_id, invocation_id, run_id, turn_id, payload_json FROM runtime_events WHERE session_id = ? AND event_kind = 'invocation_opened' - ORDER BY committed_at ASC, event_seq ASC, event_id ASC `) .all(sessionId) as unknown as RuntimeEventStorageRow[]; - const lastEvent = this.db.prepare(` - SELECT event_id, session_id, invocation_id, run_id, turn_id, payload_json - FROM runtime_events - WHERE invocation_id = ? - ORDER BY event_seq DESC - LIMIT 1 - `); - return openings.map((row) => { + const records = openings.map((row) => { const event = decodeRuntimeEventStorageRow(row); const opening = runtimeEventInvocationOpening(event); if (!opening) { throw new Error(`RuntimeEvent ${event.id} is indexed as an opening fact but is not one`); } - const lastRow = lastEvent.get(event.invocationId) as unknown as - | RuntimeEventStorageRow - | undefined; - const last = lastRow ? decodeRuntimeEventStorageRow(lastRow) : undefined; - return { + return this.completeInvocationRecordSync({ sessionId: event.sessionId, invocationId: event.invocationId, runId: event.runId, turnId: event.turnId, openedAt: event.ts, opening, - ...(last && isTerminalRuntimeEvent(last) ? { terminalEvent: last } : {}), - } satisfies RuntimeInvocationRecord; + }); }); + const opened = new Set(records.map((record) => record.invocationId)); + const legacy = this.db + .prepare(` + SELECT invocation_id, run_id, turn_id, opened_at, opening_json + FROM runtime_legacy_invocation_openings + WHERE session_id = ? + `) + .all(sessionId) as unknown as Array<{ + invocation_id: string; + run_id: string; + turn_id: string; + opened_at: number; + opening_json: string; + }>; + for (const row of legacy) { + if (opened.has(row.invocation_id)) continue; + records.push( + this.completeInvocationRecordSync({ + sessionId, + invocationId: row.invocation_id, + runId: row.run_id, + turnId: row.turn_id, + openedAt: row.opened_at, + opening: decodeRuntimeInvocationOpened(JSON.parse(row.opening_json)), + }), + ); + } + return records.sort( + (a, b) => a.openedAt - b.openedAt || a.invocationId.localeCompare(b.invocationId), + ); }); } + private completeInvocationRecordSync( + record: Omit, + ): RuntimeInvocationRecord { + const lastRow = this.db + .prepare(` + SELECT event_id, session_id, invocation_id, run_id, turn_id, payload_json + FROM runtime_events + WHERE invocation_id = ? + ORDER BY event_seq DESC + LIMIT 1 + `) + .get(record.invocationId) as unknown as RuntimeEventStorageRow | undefined; + const last = lastRow ? decodeRuntimeEventStorageRow(lastRow) : undefined; + return { + ...record, + ...(last && isTerminalRuntimeEvent(last) ? { terminalEvent: last } : {}), + }; + } + async scanRuntimeEvents( sessionId: string, runId: string, From de8a7016f9c061a354723b8ca177f8c98c5906a9 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 00:12:56 +0800 Subject: [PATCH 05/46] refactor(core): record the Run Composition on the ledger instead of the header The composition was the one header field that was neither open-time nor lifecycle: a late-bound, write-once snapshot committed as a patch. It forced the header's whole mutable surface to stay open for a value that is by construction a fact about one moment, and its immutability had to be defended inside `updateRun`, which exists for values that do change. Append it as `run_composition_recorded` instead. Same payload, same single writer, same moment before provider dispatch, and the same guard: an identical re-append is the writer retrying and is absorbed, a different one is refused. The guard moves to where the record now lives. Nothing outside the writer read `header.runComposition`, so the field and its entry in the mutable-field set go with it, and reads go through one function over the ledger. Refs #4311 Generated-by: Claude Code --- packages/core/src/agent-run.ts | 27 +++++---- .../execution-model-composition.test.ts | 13 +++-- packages/runtime/src/agent-run.ts | 17 +++++- .../sqlite-core-execution-store.test.ts | 30 ++++++++-- packages/storage/src/agent-run-store.ts | 56 +++++++++++++++---- 5 files changed, 109 insertions(+), 34 deletions(-) diff --git a/packages/core/src/agent-run.ts b/packages/core/src/agent-run.ts index f49ad8e8b0..4ac0fa6adc 100644 --- a/packages/core/src/agent-run.ts +++ b/packages/core/src/agent-run.ts @@ -195,8 +195,6 @@ export interface AgentRunHeader { agentSwarmAuthorization?: AgentSwarmAuthorizationSource; /** Effective tool protocol for this run. Optional on legacy runs. */ toolMode?: ToolMode; - /** Immutable composer-owned prompt and tool-surface snapshot committed before provider dispatch. */ - runComposition?: RunCompositionSnapshot; createdAt: number; updatedAt: number; completedAt?: number; @@ -439,6 +437,7 @@ export const AGENT_RUN_EVENT_TYPES = [ 'model_call_attempt_recorded', 'history_compact_checkpoint_recorded', 'model_projection_transition_recorded', + 'run_composition_recorded', 'task_gate_decided', 'abort_requested', 'run_completed', @@ -616,7 +615,6 @@ const AGENT_RUN_HEADER_SHAPE = defineObjectShape()( 'orchestrationSource', 'agentSwarmAuthorization', 'toolMode', - 'runComposition', ], ); @@ -686,7 +684,6 @@ export function decodeAgentRunHeader(value: unknown): AgentRunHeader { Number(value.agentGraphWakeId !== undefined) <= 1 && (value.toolMode === undefined || isToolMode(value.toolMode)) && - (value.runComposition === undefined || isRunCompositionSnapshot(value.runComposition)) && isFiniteNumber(value.createdAt) && isFiniteNumber(value.updatedAt) && isOptionalString(value.invocationId) && @@ -719,13 +716,23 @@ export function decodeAgentRunHeader(value: unknown): AgentRunHeader { return value as unknown as AgentRunHeader; } -function isRunCompositionSnapshot(value: unknown): value is RunCompositionSnapshot { - try { - decodeRunCompositionSnapshot(value); - return true; - } catch { - return false; +export const RUN_COMPOSITION_RECORDED_EVENT_TYPE = 'run_composition_recorded' as const; + +/** + * Read a run's composer snapshot back out of its ledger. + * + * The composition is written once, before provider dispatch, and the store + * refuses a second append that disagrees with the first. So the earliest + * matching row is the whole answer, and a reader never has to reduce a stream. + */ +export function agentRunCompositionFromEvents( + events: readonly AgentRunEvent[], +): RunCompositionSnapshot | undefined { + for (const event of events) { + if (event.type !== RUN_COMPOSITION_RECORDED_EVENT_TYPE) continue; + return decodeRunCompositionSnapshot(event.data?.runComposition); } + return undefined; } function isAgentRunContinuationSource(value: unknown): value is AgentRunContinuationSource { diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index 8408d2f940..fe2a693302 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -41,6 +41,7 @@ import { PROVIDER_REGISTRY } from '@maka/core/llm-connections'; import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; import { decodeRunCompositionSnapshot } from '@maka/core/run-composition'; import type { AgentRunHeader } from '@maka/core/agent-run'; +import { agentRunCompositionFromEvents } from '@maka/core/agent-run'; import type { BackendCompactHistoryInput } from '@maka/core/backend-types'; import { decodeCanonicalToolResultContent } from '@maka/core/tool-result-record-schema'; import { type ModelCallAttempt, type ModelCallKind } from '@maka/core/model-call-attempt'; @@ -2233,10 +2234,14 @@ test('production Host executes and durably supervises an Agent Graph over a real ); assert.equal(finish?.resultIds.length, 1); const rootRun = runs.find((run) => run.runId === initialTerminal.runId); - assert.equal(rootRun?.runComposition?.composerId, 'maka.interactive'); - assert.equal(rootRun?.runComposition?.contextWindow, 32_768); - assert.match(rootRun?.runComposition?.baseSystemPromptHash ?? '', /^sha256:[a-f0-9]{64}$/u); - assert.ok(rootRun?.runComposition?.toolNames.includes('view_agent_graph')); + assert.ok(rootRun); + const rootComposition = agentRunCompositionFromEvents( + await execution.agentRunStore.readEvents(session.id, rootRun.runId), + ); + assert.equal(rootComposition?.composerId, 'maka.interactive'); + assert.equal(rootComposition?.contextWindow, 32_768); + assert.match(rootComposition?.baseSystemPromptHash ?? '', /^sha256:[a-f0-9]{64}$/u); + assert.ok(rootComposition?.toolNames.includes('view_agent_graph')); const wakeRuns = runs.filter((run) => run.agentGraphWakeAttemptId !== undefined); assert.ok(wakeRuns.length > 0); assert.ok(wakeRuns.every((run) => run.status === 'completed')); diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index f861ca0b80..8b42cb31eb 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -23,7 +23,10 @@ import type { AgentRunStore, EmittedAgentRunEvent, } from '@maka/core/agent-run'; -import { runtimeInvocationOpeningFromRunHeader } from '@maka/core/agent-run'; +import { + RUN_COMPOSITION_RECORDED_EVENT_TYPE, + runtimeInvocationOpeningFromRunHeader, +} from '@maka/core/agent-run'; import type { RuntimeEvent, RuntimeEventInvocationOpenedContent, @@ -453,10 +456,18 @@ export class AgentRun { this.runComposition ??= normalized; if (this.runCompositionWrite) return this.runCompositionWrite; const write = this.enqueueRequiredRunStoreWrite('commit Run Composition', async () => { - await this.input.runStore?.updateRun( + await this.input.runStore?.appendEvent( this.sessionId, this.runId, - { runComposition: normalized }, + { + type: RUN_COMPOSITION_RECORDED_EVENT_TYPE, + id: this.input.newId(), + runId: this.runId, + sessionId: this.sessionId, + turnId: this.turnId, + ts: this.input.now(), + data: { runComposition: normalized }, + }, { durable: this.requiresDurablePersistence() }, ); }); diff --git a/packages/storage/src/__tests__/sqlite-core-execution-store.test.ts b/packages/storage/src/__tests__/sqlite-core-execution-store.test.ts index 8ef650997b..5b6ccf30c8 100644 --- a/packages/storage/src/__tests__/sqlite-core-execution-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-core-execution-store.test.ts @@ -24,6 +24,8 @@ import { join } from 'node:path'; import { after, describe, test } from 'node:test'; import { DatabaseSync } from 'node:sqlite'; import type { AgentRunHeader, EmittedAgentRunEvent } from '@maka/core/agent-run'; +import { agentRunCompositionFromEvents } from '@maka/core/agent-run'; +import type { RunCompositionSnapshot } from '@maka/core/run-composition'; import { MODEL_CALL_ATTEMPT_SCHEMA_VERSION, decodeModelCallAttempt, @@ -559,11 +561,17 @@ describe('SQLite core execution stores', () => { try { await store.createRun(runHeader()); const composition = runComposition('1'); - await store.updateRun('session-1', 'run-1', { runComposition: composition }); - await store.updateRun('session-1', 'run-1', { runComposition: composition }); - assert.deepEqual((await store.readRun('session-1', 'run-1')).runComposition, composition); + await store.appendEvent('session-1', 'run-1', compositionEvent('event-1', composition)); + await store.appendEvent('session-1', 'run-1', compositionEvent('event-2', composition)); + const events = await store.readEvents('session-1', 'run-1'); + assert.deepEqual(agentRunCompositionFromEvents(events), composition); + assert.equal( + events.filter((event) => event.type === 'run_composition_recorded').length, + 1, + 'an identical re-append is the writer retrying, not a second composition', + ); await assert.rejects( - store.updateRun('session-1', 'run-1', { runComposition: runComposition('2') }), + store.appendEvent('session-1', 'run-1', compositionEvent('event-3', runComposition('2'))), /AgentRun Run Composition is immutable/u, ); } finally { @@ -730,7 +738,19 @@ function modelCallAttempt(overrides: Partial = {}): ModelCallA }; } -function runComposition(seed: string): NonNullable { +function compositionEvent(id: string, composition: RunCompositionSnapshot): EmittedAgentRunEvent { + return { + type: 'run_composition_recorded', + id, + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + ts: 5, + data: { runComposition: composition }, + }; +} + +function runComposition(seed: string): RunCompositionSnapshot { return { schemaVersion: 1, composerId: 'maka.interactive', diff --git a/packages/storage/src/agent-run-store.ts b/packages/storage/src/agent-run-store.ts index b227cc2d75..a556b9d86c 100644 --- a/packages/storage/src/agent-run-store.ts +++ b/packages/storage/src/agent-run-store.ts @@ -63,6 +63,7 @@ import { MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_COUNT } from '@maka/core/attachmen import { MODEL_CALL_ATTEMPT_EVENT_TYPE } from '@maka/core/model-call-attempt'; import { LATEST_CONTEXT_PROJECTION_TYPE, + RUN_COMPOSITION_RECORDED_EVENT_TYPE, supersedesLatestContext, type LatestContextOrder, type AgentRunProjectionKey, @@ -396,17 +397,6 @@ class SqliteAgentRunStore implements DurableAgentRunStore { assertSafeId(runId, 'Invalid run id'); return this.#lease.transaction('write', () => { const current = readSqliteAgentRun(this.#lease.database, sessionId, runId); - if (Object.hasOwn(patch, 'runComposition')) { - if (!patch.runComposition) { - throw new Error('AgentRun Run Composition cannot be cleared'); - } - if ( - current.runComposition && - !isDeepStrictEqual(current.runComposition, patch.runComposition) - ) { - throw new Error('AgentRun Run Composition is immutable'); - } - } const next = normalizeCurrentAgentRunHeader( { ...current, ...patch, sessionId, runId }, sessionId, @@ -559,6 +549,20 @@ class SqliteAgentRunStore implements DurableAgentRunStore { turnId: header.turnId, }); const type = normalized.type as AgentRunEventType; + if (type === RUN_COMPOSITION_RECORDED_EVENT_TYPE) { + // Write-once, enforced where the record lives. The composition is what + // the run was dispatched against; a second, different one would claim + // the run ran on a prompt and tool surface it never saw. An identical + // re-append is the writer retrying, so it is absorbed rather than + // refused. + const recorded = readSqliteRunCompositionEvent(this.#lease.database, sessionId, runId); + if (recorded) { + if (!isDeepStrictEqual(recorded.data, normalized.data)) { + throw new Error('AgentRun Run Composition is immutable'); + } + return; + } + } const projectsCheckpoint = type === 'history_compact_checkpoint_recorded'; const projection = projectsCheckpoint ? inspectSqliteAgentRunProjection(this.#lease.database, sessionId, type) @@ -989,6 +993,35 @@ function readSqliteAgentRunEvents( }); } +/** The run's one composition row, or nothing if it has not been dispatched yet. */ +function readSqliteRunCompositionEvent( + db: DatabaseSync, + sessionId: string, + runId: string, +): AgentRunEvent | undefined { + const row = db + .prepare(` + SELECT record_json + FROM core_agent_run_events + WHERE session_id = ? AND run_id = ? AND event_type = ? + ORDER BY sequence + LIMIT 1 + `) + .get(sessionId, runId, RUN_COMPOSITION_RECORDED_EVENT_TYPE) as + | { record_json?: unknown } + | undefined; + if (!row) return undefined; + if (typeof row.record_json !== 'string') { + throw new Error('Invalid SQLite AgentRun event row'); + } + const header = readSqliteAgentRun(db, sessionId, runId); + return decodeAgentRunEvent(JSON.parse(row.record_json), { + sessionId, + runId, + turnId: header.turnId, + }); +} + function readSqliteAgentRunEventsForEvidence( db: DatabaseSync, sessionId: string, @@ -1351,7 +1384,6 @@ const MUTABLE_AGENT_RUN_HEADER_FIELDS = new Set([ 'status', 'updatedAt', 'completedAt', - 'runComposition', 'failureClass', 'failureMessage', 'abortSource', From e86f64951d965598a7c5846ce360e9a6f1009097 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 00:26:16 +0800 Subject: [PATCH 06/46] refactor(core): make a continuation claim name its target's opening, not a Run header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The claim embedded a whole pre-provider Run header, a second durable copy of a record that already exists, and then had to defend the copy against the original with two deep-equality checks over live lifecycle fields. Those checks could only ever fail for the wrong reason: the run's status and timestamps move as it runs, the copy's never do. What the claim is actually for is the start event. A continuation's start is event 1 of its target, so it is also that invocation's opening fact, and the claim has to say in advance exactly what that fact will be. So the claim carries the opening and nothing else. Everything the old header said is either the claim's own target identity, its `claimedAt`, or a restatement of its boundary, so the header is rebuilt from the claim where a header is still needed, and admission now round-trips through that rebuild — the run it computes must equal the one the claim reconstructs, which makes losing information a build failure rather than a silent drift. The start-event rule was implemented twice, once in the store and once in the runtime, and a fix to either left the other admitting what its twin rejected. There is one implementation now, in core, called from both. Lineage gains `resumedFromRunId` and `retriedFromRunId`. Without them the opening cannot say that a run resumes or retries another, which is the only thing that distinguishes a linked child's two admission kinds. The claim's opening is decoded strictly, with no legacy widening. A claim whose frozen opening cannot be read cannot authenticate the start it exists to authenticate, so the migration drops such a row rather than leaving one that would fail every later read and hold its boundary forever. Refs #4311 Generated-by: Claude Code --- .../src/__tests__/runtime-boundary.test.ts | 109 +++++++-- packages/core/src/agent-run.ts | 2 + packages/core/src/runtime-boundary.ts | 207 +++++++++++++++--- packages/core/src/runtime-event.ts | 7 + packages/runtime/src/runtime-kernel.ts | 10 +- packages/runtime/src/runtime-resume.ts | 91 ++------ packages/runtime/src/session-manager.ts | 68 ++---- .../sqlite-recovery-concurrency-child.ts | 50 +++-- .../invocation-opening-backfill.test.ts | 3 + .../__tests__/sqlite-runtime-schema.test.ts | 16 +- .../__tests__/sqlite-runtime-store.test.ts | 85 ++++--- packages/storage/src/sqlite-runtime-schema.ts | 43 +++- packages/storage/src/sqlite-runtime-store.ts | 68 +----- 13 files changed, 457 insertions(+), 302 deletions(-) diff --git a/packages/core/src/__tests__/runtime-boundary.test.ts b/packages/core/src/__tests__/runtime-boundary.test.ts index 5ec71103a3..9b728df7dd 100644 --- a/packages/core/src/__tests__/runtime-boundary.test.ts +++ b/packages/core/src/__tests__/runtime-boundary.test.ts @@ -23,7 +23,9 @@ import { decodeRuntimeEvent, type RuntimeEvent } from '../runtime-event.js'; import { buildImmutableRuntimePrefix, createRuntimeBoundaryCursor, + continuationTargetRunHeader, decodeContinuationClaim, + runHeaderMatchesClaimTarget, runtimePrefixSegment, type RuntimeBoundaryCursorV1, type RuntimePrefixIdentityV1, @@ -298,6 +300,63 @@ describe('immutable RuntimeEvent boundary', () => { /target turnId reuses source identity/, ); }); + + it('rejects a target opening that does not name the boundary the claim holds', () => { + const boundary = boundaryForRuns('run-source'); + const claim = claimForBoundary(boundary); + + assert.throws( + () => + decodeContinuationClaim({ + ...claim, + targetOpening: { + ...claim.targetOpening, + source: { ...claim.targetOpening.source, sourceRunId: 'run-elsewhere' }, + }, + }), + /target opening mismatch/, + ); + assert.throws( + () => + decodeContinuationClaim({ + ...claim, + targetOpening: { ...claim.targetOpening, source: { kind: 'fresh' } }, + }), + /target opening mismatch/, + ); + }); + + it('rebuilds the target Run header the claim authorises', () => { + const boundary = boundaryForRuns('run-source'); + const claim = decodeContinuationClaim(claimForBoundary(boundary)); + const header = continuationTargetRunHeader(claim); + const source = boundary.segments.at(-1)!; + + assert.equal(header.runId, claim.target.runId); + assert.equal(header.invocationId, claim.target.invocationId); + assert.equal(header.status, 'created'); + assert.equal(header.createdAt, claim.claimedAt); + assert.equal(header.updatedAt, claim.claimedAt); + assert.equal(header.parentRunId, source.identity.runId); + assert.deepEqual(header.continuationSource, { + protocol: 'continuation_source_v2', + claimId: claim.claimId, + boundaryDigest: claim.boundaryDigest, + sourceInvocationId: source.identity.invocationId, + sourceRunId: source.identity.runId, + sourceTurnId: source.identity.turnId, + sourceRuntimeEventHighWater: source.position.lastEventSeq, + sourcePrefixDigest: source.prefixDigest, + replayManifestDigest: boundary.manifestDigest, + }); + // The claim's opening and the header it rebuilds are one fact, not two. + assert.ok(runHeaderMatchesClaimTarget(header, claim)); + assert.ok(!runHeaderMatchesClaimTarget({ ...header, cwd: '/elsewhere' }, claim)); + assert.ok( + runHeaderMatchesClaimTarget({ ...header, status: 'running', updatedAt: 99 }, claim), + 'a running target still matches: lifecycle was never part of what the claim froze', + ); + }); }); function runtimeIdentity(runId: string): RuntimePrefixIdentityV1 { @@ -353,34 +412,36 @@ function claimForBoundary(boundary: RuntimeBoundaryCursorV1) { providerProjectionVersion: 1, providerReplayDigest: `sha256:${'b'.repeat(64)}`, target, - targetRunHeader: { - runId: target.runId, - invocationId: target.invocationId, - sessionId: target.sessionId, - turnId: target.turnId, - status: 'created', - backendKind: 'fake', - llmConnectionSlug: 'connection-1', - modelId: 'model-1', - cwd: '/workspace', - permissionMode: 'ask', - collaborationMode: 'agent', - orchestrationMode: 'default', - orchestrationSource: 'session', - createdAt: 1, - updatedAt: 1, - parentRunId: source.identity.runId, - parentTurnId: source.identity.turnId, - continuationSource: { - protocol: 'continuation_source_v2', - claimId: 'claim-1', - boundaryDigest: boundary.manifestDigest, + targetOpening: { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'unknown', + backendKind: 'fake', + llmConnectionSlug: 'connection-1', + modelId: 'model-1', + }, + configuration: { + cwd: '/workspace', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + root: { kind: 'user' }, + source: { + kind: 'continuation', sourceInvocationId: source.identity.invocationId, sourceRunId: source.identity.runId, sourceTurnId: source.identity.turnId, sourceRuntimeEventHighWater: source.position.lastEventSeq, - sourcePrefixDigest: source.prefixDigest, - replayManifestDigest: boundary.manifestDigest, + claimId: 'claim-1', + boundaryDigest: boundary.manifestDigest, + }, + lineage: { + parentRunId: source.identity.runId, + parentTurnId: source.identity.turnId, }, }, claimedAt: 1, diff --git a/packages/core/src/agent-run.ts b/packages/core/src/agent-run.ts index 4ac0fa6adc..75e75a82c2 100644 --- a/packages/core/src/agent-run.ts +++ b/packages/core/src/agent-run.ts @@ -873,6 +873,8 @@ export function runtimeInvocationOpeningFromRunHeader( ): RuntimeEventInvocationOpenedContent { const lineage: RuntimeInvocationLineage = { ...(header.parentRunId !== undefined ? { parentRunId: header.parentRunId } : {}), + ...(header.resumedFromRunId !== undefined ? { resumedFromRunId: header.resumedFromRunId } : {}), + ...(header.retriedFromRunId !== undefined ? { retriedFromRunId: header.retriedFromRunId } : {}), ...(header.parentTurnId !== undefined ? { parentTurnId: header.parentTurnId } : {}), ...(header.parentSessionId !== undefined ? { parentSessionId: header.parentSessionId } : {}), ...(header.retriedFromTurnId !== undefined diff --git a/packages/core/src/runtime-boundary.ts b/packages/core/src/runtime-boundary.ts index 6408b67b3b..4e880eed38 100644 --- a/packages/core/src/runtime-boundary.ts +++ b/packages/core/src/runtime-boundary.ts @@ -19,10 +19,11 @@ import * as nodeCrypto from 'node:crypto'; import type { Hash } from 'node:crypto'; -import { decodeAgentRunHeader, type AgentRunHeader } from './agent-run.js'; +import { runtimeInvocationOpeningFromRunHeader, type AgentRunHeader } from './agent-run.js'; import { encodeCanonicalRuntimeEvent } from './canonical-runtime-event.js'; import { isRecord } from './record-schema.js'; -import type { RuntimeEvent } from './runtime-event.js'; +import { decodeRuntimeInvocationOpened, TOOL_BOUNDARY_PROTOCOL_V1 } from './runtime-event.js'; +import type { RuntimeEvent, RuntimeEventInvocationOpenedContent } from './runtime-event.js'; import { stableJsonStringify } from './tool-args-identity.js'; export type RuntimeBoundaryDigest = `sha256:${string}`; @@ -79,8 +80,16 @@ export interface ContinuationClaimV1 { runId: string; turnId: string; }; - /** Exact pre-provider target Run header used by both normal admission and crash repair. */ - targetRunHeader: AgentRunHeader; + /** + * The opening fact the target invocation's first event must carry. + * + * This is what the claim is actually for: a continuation's start event is + * event 1 of its target, so it is also that invocation's opening fact, and the + * claim has to say in advance exactly what that fact will be. Everything else + * about the target is fixed by the claim's own fields, so the pre-provider Run + * header is a projection of this rather than a second record of it. + */ + targetOpening: RuntimeEventInvocationOpenedContent; claimedAt: number; } @@ -231,7 +240,7 @@ export function decodeContinuationClaim(value: unknown): ContinuationClaimV1 { 'providerProjectionVersion', 'providerReplayDigest', 'target', - 'targetRunHeader', + 'targetOpening', 'claimedAt', ]) || value.protocol !== 'continuation_claim_v1' || @@ -270,32 +279,18 @@ export function decodeContinuationClaim(value: unknown): ContinuationClaimV1 { if (boundary.segments.some((segment) => segment.identity.turnId === targetTurnId)) { throw new Error('Continuation claim target turnId reuses source identity'); } - const targetRunHeader = decodeAgentRunHeader(value.targetRunHeader); - const continuationSource = targetRunHeader.continuationSource; + const targetOpening = decodeRuntimeInvocationOpened(value.targetOpening); + const openSource = targetOpening.source; if ( - targetRunHeader.runId !== targetRunId || - targetRunHeader.invocationId !== targetInvocationId || - targetRunHeader.sessionId !== value.target.sessionId || - targetRunHeader.turnId !== targetTurnId || - targetRunHeader.status !== 'created' || - targetRunHeader.createdAt !== value.claimedAt || - targetRunHeader.updatedAt !== value.claimedAt || - targetRunHeader.completedAt !== undefined || - targetRunHeader.failureClass !== undefined || - targetRunHeader.failureMessage !== undefined || - !continuationSource || - !('protocol' in continuationSource) || - continuationSource.protocol !== 'continuation_source_v2' || - continuationSource.claimId !== value.claimId || - continuationSource.boundaryDigest !== boundaryDigest || - continuationSource.sourceInvocationId !== source.identity.invocationId || - continuationSource.sourceRunId !== source.identity.runId || - continuationSource.sourceTurnId !== source.identity.turnId || - continuationSource.sourceRuntimeEventHighWater !== source.position.lastEventSeq || - continuationSource.sourcePrefixDigest !== source.prefixDigest || - continuationSource.replayManifestDigest !== boundary.manifestDigest + openSource.kind !== 'continuation' || + openSource.claimId !== value.claimId || + openSource.boundaryDigest !== boundaryDigest || + openSource.sourceInvocationId !== source.identity.invocationId || + openSource.sourceRunId !== source.identity.runId || + openSource.sourceTurnId !== source.identity.turnId || + openSource.sourceRuntimeEventHighWater !== source.position.lastEventSeq ) { - throw new Error('Continuation claim target Run header mismatch'); + throw new Error('Continuation claim target opening mismatch'); } return { protocol: 'continuation_claim_v1', @@ -310,11 +305,163 @@ export function decodeContinuationClaim(value: unknown): ContinuationClaimV1 { runId: value.target.runId, turnId: value.target.turnId, }, - targetRunHeader, + targetOpening, claimedAt: value.claimedAt as number, }; } +/** + * The target's pre-provider Run header, rebuilt from the claim. + * + * A continuation target exists because of its claim and nothing else. Its + * identity is the claim's target, its clock is `claimedAt`, it has not started, + * and its continuation lineage is a restatement of the claim's own boundary. So + * every field here is read off the claim, and the header the claim used to carry + * was never independent evidence of anything. + */ +export function continuationTargetRunHeader(claim: ContinuationClaimV1): AgentRunHeader { + const opening = claim.targetOpening; + const source = claim.boundary.segments.at(-1)!; + const { route, configuration, lineage } = opening; + return { + runId: claim.target.runId, + invocationId: claim.target.invocationId, + sessionId: claim.target.sessionId, + turnId: claim.target.turnId, + status: 'created', + backendKind: route.backendKind, + ...(route.provenance === 'runtime' ? { llmConnectionId: route.llmConnectionId } : {}), + ...(route.provenance === 'runtime' && route.providerStateIdentity !== undefined + ? { providerStateIdentity: route.providerStateIdentity } + : {}), + llmConnectionSlug: route.llmConnectionSlug, + modelId: route.modelId, + cwd: configuration.cwd, + ...(configuration.workspaceIdentity !== undefined + ? { workspaceIdentity: configuration.workspaceIdentity } + : {}), + permissionMode: configuration.permissionMode, + collaborationMode: configuration.collaborationMode, + orchestrationMode: configuration.orchestrationMode, + orchestrationSource: configuration.orchestrationSource, + ...(configuration.agentSwarmAuthorization !== undefined + ? { agentSwarmAuthorization: configuration.agentSwarmAuthorization } + : {}), + toolMode: configuration.toolMode, + createdAt: claim.claimedAt, + updatedAt: claim.claimedAt, + parentRunId: source.identity.runId, + ...(lineage?.resumedFromRunId !== undefined + ? { resumedFromRunId: lineage.resumedFromRunId } + : {}), + ...(lineage?.retriedFromRunId !== undefined + ? { retriedFromRunId: lineage.retriedFromRunId } + : {}), + ...(lineage?.parentTurnId !== undefined ? { parentTurnId: lineage.parentTurnId } : {}), + ...(lineage?.retriedFromTurnId !== undefined + ? { retriedFromTurnId: lineage.retriedFromTurnId } + : {}), + ...(lineage?.regeneratedFromTurnId !== undefined + ? { regeneratedFromTurnId: lineage.regeneratedFromTurnId } + : {}), + ...(lineage?.branchOfTurnId !== undefined ? { branchOfTurnId: lineage.branchOfTurnId } : {}), + ...(lineage?.parentSessionId !== undefined ? { parentSessionId: lineage.parentSessionId } : {}), + ...(lineage?.agentId !== undefined ? { agentId: lineage.agentId } : {}), + ...(lineage?.agentName !== undefined ? { agentName: lineage.agentName } : {}), + continuationSource: { + protocol: 'continuation_source_v2', + claimId: claim.claimId, + boundaryDigest: claim.boundaryDigest, + sourceInvocationId: source.identity.invocationId, + sourceRunId: source.identity.runId, + sourceTurnId: source.identity.turnId, + sourceRuntimeEventHighWater: source.position.lastEventSeq, + sourcePrefixDigest: source.prefixDigest, + replayManifestDigest: claim.boundary.manifestDigest, + }, + }; +} + +/** + * Is this the Run the claim opened? + * + * The claim froze the target's opening, so the check is that the run still + * projects to it, plus the identity and clock the claim fixed. Lifecycle fields + * are deliberately out of scope: the run's status and timestamps move as it + * executes, and comparing them against a frozen copy only ever detected the + * copy going stale. + */ +export function runHeaderMatchesClaimTarget( + run: AgentRunHeader, + claim: ContinuationClaimV1, +): boolean { + return ( + run.sessionId === claim.target.sessionId && + run.invocationId === claim.target.invocationId && + run.runId === claim.target.runId && + run.turnId === claim.target.turnId && + run.createdAt === claim.claimedAt && + stableJsonStringify(runtimeInvocationOpeningFromRunHeader(run)) === + stableJsonStringify(claim.targetOpening) + ); +} + +/** + * Does this event discharge the claim as its target's first event? + * + * One rule, one implementation. The store refuses a start that fails it and the + * runtime refuses to resume across one; when those were two copies of the same + * predicate, a fix to either left the other admitting what the other rejected. + */ +export function continuationStartEventMatchesClaim( + event: RuntimeEvent | undefined, + claim: ContinuationClaimV1, + /** Undefined means the claim has not recorded a start yet, so nothing matches. */ + startKind: 'runtime_admission' | 'claim_repair' | undefined, +): boolean { + if (!event?.actions) return false; + const start = event.actions.continuationStart; + const runtimeProtocol = event.actions.runtimeProtocol; + const actionKeys = Object.keys(event.actions); + const source = claim.boundary.segments.at(-1)!; + return Boolean( + event.sessionId === claim.target.sessionId && + event.invocationId === claim.target.invocationId && + event.runId === claim.target.runId && + event.turnId === claim.target.turnId && + event.ts >= claim.claimedAt && + event.partial !== true && + event.role === 'system' && + event.author === 'system' && + event.status === undefined && + // Event 1 of a continuation target is also that invocation's opening fact, + // which is why the claim names it in advance. + stableJsonStringify(event.content) === stableJsonStringify(claim.targetOpening) && + actionKeys.includes('continuationStart') && + actionKeys.every((key) => key === 'continuationStart' || key === 'runtimeProtocol') && + actionKeys.length === (runtimeProtocol === undefined ? 1 : 2) && + (runtimeProtocol === undefined || + (startKind === 'runtime_admission' && + runtimeProtocol.toolBoundary === TOOL_BOUNDARY_PROTOCOL_V1)) && + start?.protocol === 'continuation_start_v2' && + start.provenance === startKind && + start.claimId === claim.claimId && + start.boundaryDigest === claim.boundaryDigest && + start.replayManifestDigest === claim.boundary.manifestDigest && + start.providerProjectionVersion === claim.providerProjectionVersion && + start.providerReplayDigest === claim.providerReplayDigest && + stableJsonStringify(start.immediateSource) === + stableJsonStringify({ + sessionId: source.identity.sessionId, + invocationId: source.identity.invocationId, + runId: source.identity.runId, + turnId: source.identity.turnId, + highWater: source.position.lastEventSeq, + prefixDigest: source.prefixDigest, + }), + ); +} + function canonicalizePrefixRows( identity: RuntimePrefixIdentityV1, rows: readonly RuntimePrefixRowV1[], diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index e134b53714..573359c55a 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -278,6 +278,9 @@ export type RuntimeInvocationRootAuthority = /** Turn/session lineage that is immutable once the invocation opens. */ export interface RuntimeInvocationLineage { parentRunId?: string; + /** The run this one continues, and the run it re-attempts. Never both. */ + resumedFromRunId?: string; + retriedFromRunId?: string; parentTurnId?: string; parentSessionId?: string; retriedFromTurnId?: string; @@ -721,6 +724,8 @@ const INVOCATION_LINEAGE_SHAPE = defineObjectShape()( [], [ 'parentRunId', + 'resumedFromRunId', + 'retriedFromRunId', 'parentTurnId', 'parentSessionId', 'retriedFromTurnId', @@ -1148,6 +1153,8 @@ function isRuntimeInvocationLineage(value: unknown): value is RuntimeInvocationL Object.keys(value).length > 0 && [ value.parentRunId, + value.resumedFromRunId, + value.retriedFromRunId, value.parentTurnId, value.parentSessionId, value.retriedFromTurnId, diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index 8fd0886db8..ef4f3a0c96 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -20,6 +20,7 @@ import type { AgentRunHeader, AgentRunStore } from '@maka/core/agent-run'; import { runtimeInvocationOpeningFromRunHeader } from '@maka/core/agent-run'; import { + continuationTargetRunHeader, decodeRuntimeBoundaryCursor, type ContinuationClaimV1, type ImmutableRuntimePrefixV1, @@ -840,7 +841,10 @@ export class RuntimeKernel implements RuntimeKernelLike { now: this.deps.now, workspaceIdentity: continuation.safetySnapshot.workspaceIdentity, effectiveOrchestration, - claimedRunHeader: claim.targetRunHeader, + // Round-tripped through the claim on purpose: createRunRecord compares it + // against the header it computes, so every continuation proves the claim's + // opening still reconstructs the run it authorised. + claimedRunHeader: continuationTargetRunHeader(claim), effectiveToolMode, continuationFailpoint: this.deps.continuationFailpoint, commitContinuationStart: async (startedAt) => { @@ -858,7 +862,7 @@ export class RuntimeKernel implements RuntimeKernelLike { modelVisibility: 'hidden', // The start event is event 1 of the target invocation, so it is // also where that invocation's opening fact lives. - content: runtimeInvocationOpeningFromRunHeader(claim.targetRunHeader), + content: claim.targetOpening, actions: { ...(continuationToolBoundaryProtocol ? { @@ -2869,7 +2873,7 @@ function continuationClaimForExecution( runId: continuation.runId, turnId: continuation.turnId, }, - targetRunHeader, + targetOpening: runtimeInvocationOpeningFromRunHeader(targetRunHeader), claimedAt, }; } diff --git a/packages/runtime/src/runtime-resume.ts b/packages/runtime/src/runtime-resume.ts index a5610be480..758774e38a 100644 --- a/packages/runtime/src/runtime-resume.ts +++ b/packages/runtime/src/runtime-resume.ts @@ -26,6 +26,10 @@ import { type RuntimeEventFunctionCallContent, type RuntimeEventFunctionResponseContent, } from '@maka/core/runtime-event'; +import { + continuationStartEventMatchesClaim, + runHeaderMatchesClaimTarget, +} from '@maka/core/runtime-boundary'; import type { ContinuationClaimV1, ImmutableRuntimePrefixV1, @@ -533,7 +537,7 @@ export class RuntimeContinuationPlanner { ); } const targetRun = run; - if (!claimTargetRunHeaderMatches(targetRun, claim)) { + if (!runHeaderMatchesClaimTarget(targetRun, claim)) { return parkedPlan( 'continuation_claim_repair_required', 'durable continuation claim target Run identity does not match its claim', @@ -557,7 +561,7 @@ export class RuntimeContinuationPlanner { if ( !state.startEventId || prefix.events[0]?.id !== state.startEventId || - !continuationStartMatchesClaim(prefix.events[0], claim, state.startKind) + !continuationStartEventMatchesClaim(prefix.events[0], claim, state.startKind) ) { return parkedPlan( 'continuation_claim_repair_required', @@ -776,8 +780,8 @@ export class RuntimeContinuationPlanner { state.claim.boundaryDigest !== edge.boundaryDigest || state.startEventId !== edge.startEvent.id || state.startKind !== edge.startKind || - !claimTargetRunHeaderMatches(edge.childRunHeader, state.claim) || - !continuationStartMatchesClaim(edge.startEvent, state.claim, state.startKind) + !runHeaderMatchesClaimTarget(edge.childRunHeader, state.claim) || + !continuationStartEventMatchesClaim(edge.startEvent, state.claim, state.startKind) ) { throw new RuntimeLineageError( 'runtime_lineage_claim_mismatch', @@ -798,8 +802,11 @@ export class RuntimeContinuationPlanner { providerProjectionVersion: edge.providerProjectionVersion, admissionRoute: { runHeaders, - targetProviderStateIdentity: state.claim.targetRunHeader.providerStateIdentity, - targetModelId: state.claim.targetRunHeader.modelId, + targetProviderStateIdentity: + state.claim.targetOpening.route.provenance === 'runtime' + ? state.claim.targetOpening.route.providerStateIdentity + : undefined, + targetModelId: state.claim.targetOpening.route.modelId, }, }); if ( @@ -1487,75 +1494,3 @@ function hasMatchingCall( ): boolean { return call !== undefined && call.name === response.name; } - -function claimTargetRunHeaderMatches(actual: AgentRunHeader, claim: ContinuationClaimV1): boolean { - const candidate = actual as unknown as Record; - const expected = claim.targetRunHeader as unknown as Record; - const immutable = (header: Record) => { - const { - status: _status, - updatedAt: _updatedAt, - completedAt: _completedAt, - failureClass: _failureClass, - failureMessage: _failureMessage, - abortSource: _abortSource, - traceWriteError: _traceWriteError, - ...rest - } = header; - return rest; - }; - return isDeepStrictEqual(immutable(candidate), immutable(expected)); -} - -function continuationStartMatchesClaim( - event: RuntimeEvent | undefined, - claim: ContinuationClaimV1, - startKind: ContinuationClaimStateV1['startKind'], -): boolean { - const start = event?.actions?.continuationStart; - const runtimeProtocol = event?.actions?.runtimeProtocol; - const actionKeys = event?.actions ? Object.keys(event.actions) : []; - const actionShapeMatches = - actionKeys.includes('continuationStart') && - actionKeys.every((key) => key === 'continuationStart' || key === 'runtimeProtocol') && - actionKeys.length === (runtimeProtocol === undefined ? 1 : 2); - const runtimeProtocolMatches = - runtimeProtocol === undefined || - (startKind === 'runtime_admission' && - runtimeProtocol.toolBoundary === TOOL_BOUNDARY_PROTOCOL_V1); - const source = claim.boundary.segments.at(-1)!; - return Boolean( - event && - event.sessionId === claim.target.sessionId && - event.invocationId === claim.target.invocationId && - event.runId === claim.target.runId && - event.turnId === claim.target.turnId && - event.partial !== true && - event.role === 'system' && - event.author === 'system' && - event.status === undefined && - // Event 1 of a continuation target is also that invocation's opening fact. - isDeepStrictEqual( - event.content, - runtimeInvocationOpeningFromRunHeader(claim.targetRunHeader), - ) && - event.actions && - actionShapeMatches && - runtimeProtocolMatches && - start?.protocol === 'continuation_start_v2' && - start.provenance === startKind && - start.claimId === claim.claimId && - start.boundaryDigest === claim.boundaryDigest && - start.replayManifestDigest === claim.boundary.manifestDigest && - start.providerProjectionVersion === claim.providerProjectionVersion && - start.providerReplayDigest === claim.providerReplayDigest && - isDeepStrictEqual(start.immediateSource, { - sessionId: source.identity.sessionId, - invocationId: source.identity.invocationId, - runId: source.identity.runId, - turnId: source.identity.turnId, - highWater: source.position.lastEventSeq, - prefixDigest: source.prefixDigest, - }), - ); -} diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index ea2b613cee..a85c8c685b 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -119,6 +119,10 @@ import type { RootExecutionDescriptor, } from '@maka/core/agent-run'; import type { ArtifactRecord } from '@maka/core/artifacts'; +import { + continuationTargetRunHeader, + runHeaderMatchesClaimTarget, +} from '@maka/core/runtime-boundary'; import type { ContinuationClaimV1 } from '@maka/core/runtime-boundary'; import type { RuntimeEventStore, @@ -4415,7 +4419,7 @@ export class SessionManager { } catch (error) { if (!isMissingRunError(error)) throw error; try { - await this.deps.runStore.createRun(claim.targetRunHeader, { durable: true }); + await this.deps.runStore.createRun(continuationTargetRunHeader(claim), { durable: true }); run = await this.deps.runStore.readRun(sessionId, claim.target.runId); } catch (createError) { try { @@ -4429,11 +4433,7 @@ export class SessionManager { let state = (await authority.readContinuationClaimStateByBoundary(claim.boundaryDigest)) ?? initialState; - if ( - state.startEventId - ? !claimTargetRunHeaderIsCompatible(run, claim.targetRunHeader) - : !isDeepStrictEqual(run, claim.targetRunHeader) - ) { + if (!runHeaderMatchesClaimTarget(run, claim)) { throw new Error( `Continuation claim target Run header conflicts with claim ${claim.claimId}`, ); @@ -4848,7 +4848,7 @@ function buildContinuationRepairStartEvent(claim: ContinuationClaimV1): RuntimeE role: 'system', author: 'system', modelVisibility: 'hidden', - content: runtimeInvocationOpeningFromRunHeader(claim.targetRunHeader), + content: claim.targetOpening, actions: { continuationStart: { protocol: 'continuation_start_v2', @@ -4893,38 +4893,26 @@ function assertClaimOwnsHostedLinkedChildAdmission( ) { throw new Error('Linked child admission conflicts with its continuation claim target'); } - const header = claim.targetRunHeader; + const { lineage, source: openSource } = claim.targetOpening; const source = claim.boundary.segments.at(-1)!; - const continuationSource = header.continuationSource; - const continuationSourceV2 = - continuationSource !== undefined && - 'protocol' in continuationSource && - continuationSource.protocol === 'continuation_source_v2' - ? continuationSource - : undefined; if ( - header.sessionId !== claim.target.sessionId || - header.invocationId !== claim.target.invocationId || - header.runId !== claim.target.runId || - header.turnId !== claim.target.turnId || - header.status !== 'created' || - header.agentId !== input.execution.agentId || - header.agentName !== input.execution.agentName || + lineage?.agentId !== input.execution.agentId || + lineage.agentName !== input.execution.agentName || source.identity.sessionId !== input.sessionId || source.identity.runId !== input.execution.sourceRunId || - !continuationSourceV2 || - continuationSourceV2.claimId !== claim.claimId || - continuationSourceV2.boundaryDigest !== claim.boundaryDigest || - continuationSourceV2.sourceRunId !== input.execution.sourceRunId + openSource.kind !== 'continuation' || + openSource.claimId !== claim.claimId || + openSource.boundaryDigest !== claim.boundaryDigest || + openSource.sourceRunId !== input.execution.sourceRunId ) { throw new Error('Linked child admission continuation claim identity is inconsistent'); } if ( input.execution.kind === 'linked_child_resume' - ? header.resumedFromRunId !== input.execution.sourceRunId || - header.retriedFromRunId !== undefined - : header.retriedFromRunId !== input.execution.sourceRunId || - header.resumedFromRunId !== undefined + ? lineage.resumedFromRunId !== input.execution.sourceRunId || + lineage.retriedFromRunId !== undefined + : lineage.retriedFromRunId !== input.execution.sourceRunId || + lineage.resumedFromRunId !== undefined ) { throw new Error('Linked child admission continuation lineage is inconsistent'); } @@ -4938,26 +4926,6 @@ async function readImmutableRuntimeEventsOrEmpty( return authority.readImmutableRuntimeEvents(sessionId, runId); } -function claimTargetRunHeaderIsCompatible( - actual: AgentRunHeader, - expected: AgentRunHeader, -): boolean { - const immutable = (header: AgentRunHeader) => { - const { - status: _status, - updatedAt: _updatedAt, - completedAt: _completedAt, - failureClass: _failureClass, - failureMessage: _failureMessage, - abortSource: _abortSource, - traceWriteError: _traceWriteError, - ...rest - } = header; - return rest; - }; - return isDeepStrictEqual(immutable(actual), immutable(expected)); -} - function isMissingRunError(error: unknown): boolean { return ( isNotFoundError(error) || diff --git a/packages/storage/src/__tests__/fixtures/sqlite-recovery-concurrency-child.ts b/packages/storage/src/__tests__/fixtures/sqlite-recovery-concurrency-child.ts index 8370226c09..f481536b5f 100644 --- a/packages/storage/src/__tests__/fixtures/sqlite-recovery-concurrency-child.ts +++ b/packages/storage/src/__tests__/fixtures/sqlite-recovery-concurrency-child.ts @@ -24,6 +24,7 @@ import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; import { type ToolRecoveryFactEnvelope } from '@maka/core/tool-recovery-fact'; import { type WorkspaceBaselineAuthorityInput } from '@maka/core/workspace-version-authority'; import { createRuntimeBoundaryCursor, runtimePrefixSegment } from '@maka/core/runtime-boundary'; +import { DEFAULT_TOOL_MODE } from '@maka/core/tool-mode'; import { createSqliteRuntimeStore } from '../../sqlite-runtime-store.js'; import { acquireOperationalStateDatabase } from '../../operational-state-store.js'; import { @@ -128,32 +129,37 @@ try { providerProjectionVersion: 1, providerReplayDigest: `sha256:${'a'.repeat(64)}`, target, - targetRunHeader: { - ...target, - status: 'created', - backendKind: 'fake', - llmConnectionSlug: 'connection-1', - modelId: 'model-1', - cwd: '/workspace/repo', - permissionMode: 'ask', - collaborationMode: 'agent', - orchestrationMode: 'default', - orchestrationSource: 'session', - agentSwarmAuthorization: 'none', - createdAt: process.pid, - updatedAt: process.pid, - parentRunId: source.identity.runId, - parentTurnId: source.identity.turnId, - continuationSource: { - protocol: 'continuation_source_v2', - claimId: `claim-${process.pid}`, - boundaryDigest: boundary.manifestDigest, + targetOpening: { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'unknown', + backendKind: 'fake', + llmConnectionSlug: 'connection-1', + modelId: 'model-1', + }, + configuration: { + cwd: '/workspace/repo', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: DEFAULT_TOOL_MODE, + agentSwarmAuthorization: 'none', + }, + root: { kind: 'user' }, + source: { + kind: 'continuation', sourceInvocationId: source.identity.invocationId, sourceRunId: source.identity.runId, sourceTurnId: source.identity.turnId, sourceRuntimeEventHighWater: source.position.lastEventSeq, - sourcePrefixDigest: source.prefixDigest, - replayManifestDigest: boundary.manifestDigest, + claimId: `claim-${process.pid}`, + boundaryDigest: boundary.manifestDigest, + }, + lineage: { + parentRunId: source.identity.runId, + parentTurnId: source.identity.turnId, }, }, claimedAt: process.pid, diff --git a/packages/storage/src/__tests__/invocation-opening-backfill.test.ts b/packages/storage/src/__tests__/invocation-opening-backfill.test.ts index 4ced678e25..57aa0aac74 100644 --- a/packages/storage/src/__tests__/invocation-opening-backfill.test.ts +++ b/packages/storage/src/__tests__/invocation-opening-backfill.test.ts @@ -213,6 +213,9 @@ function rewindRuntimeSchemaToPreviousVersion(db: DatabaseSync): void { db.exec('DROP INDEX IF EXISTS runtime_legacy_invocation_openings_by_session'); db.exec('DROP TABLE IF EXISTS runtime_legacy_invocation_openings'); db.exec("DELETE FROM runtime_events WHERE event_kind = 'invocation_opened'"); + db.exec( + 'ALTER TABLE runtime_continuation_claims RENAME COLUMN target_opening_json TO target_run_header_json', + ); db.exec(`PRAGMA user_version = ${SQLITE_RUNTIME_SCHEMA_VERSION - 1}`); } diff --git a/packages/storage/src/__tests__/sqlite-runtime-schema.test.ts b/packages/storage/src/__tests__/sqlite-runtime-schema.test.ts index 3c35682a9c..5bdd3984b7 100644 --- a/packages/storage/src/__tests__/sqlite-runtime-schema.test.ts +++ b/packages/storage/src/__tests__/sqlite-runtime-schema.test.ts @@ -146,7 +146,8 @@ describe('SQLite runtime schema migration', () => { INSERT INTO runtime_continuation_claims VALUES ( 'claim-v1', 'session', 'source-invocation', 'source-run', 'source-turn', 1, 'sha256:source', 'sha256:boundary-v1', '{}', 1, 'sha256:replay-v1', - 'session', 'target-invocation-v1', 'target-run-v1', 'target-turn-v1', '{}', + 'session', 'target-invocation-v1', 'target-run-v1', 'target-turn-v1', + '{"runId": "target-run-v1", "invocationId": "target-invocation-v1", "sessionId": "session", "turnId": "target-turn-v1", "status": "created", "backendKind": "fake", "llmConnectionSlug": "connection-1", "modelId": "model-1", "cwd": "/workspace", "permissionMode": "ask", "createdAt": 1, "updatedAt": 1}', 1, NULL, NULL, 1 ); PRAGMA user_version = 14; @@ -165,6 +166,19 @@ describe('SQLite runtime schema migration', () => { ).version, 1, ); + assert.equal( + JSON.parse( + ( + db + .prepare( + "SELECT target_opening_json AS opening FROM runtime_continuation_claims WHERE claim_id = 'claim-v1'", + ) + .get() as { opening: string } + ).opening, + ).kind, + 'invocation_opened', + 'an open claim carries the opening it always implied, not a copy of the Run header', + ); db.exec(` INSERT INTO runtime_continuation_claims VALUES ( 'claim-v2', 'session', 'source-invocation', 'source-run', 'source-turn', 2, diff --git a/packages/storage/src/__tests__/sqlite-runtime-store.test.ts b/packages/storage/src/__tests__/sqlite-runtime-store.test.ts index ce5afbdbaf..0dad3d0d49 100644 --- a/packages/storage/src/__tests__/sqlite-runtime-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-runtime-store.test.ts @@ -23,7 +23,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { describe, it } from 'node:test'; -import { runtimeInvocationOpeningFromRunHeader } from '@maka/core/agent-run'; +import { DEFAULT_TOOL_MODE } from '@maka/core/tool-mode'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { RunSealedError } from '@maka/core/runtime-event-store'; import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; @@ -824,7 +824,7 @@ describe('SqliteRuntimeStore', () => { }); }); - it('decodes a persisted continuation target without widening new claims', async () => { + it('refuses a continuation target opening it cannot read, stored or submitted', async () => { await withStore(async (store, dbPath) => { const claim = continuationClaim(); await persistImmutablePrefix(store, continuationSourcePrefix()); @@ -833,13 +833,16 @@ describe('SqliteRuntimeStore', () => { store.claimContinuation({ claim: { ...claim, - targetRunHeader: { - ...claim.targetRunHeader, - permissionMode: 'execute', - } as unknown as ContinuationClaimV1['targetRunHeader'], + targetOpening: { + ...claim.targetOpening, + configuration: { + ...claim.targetOpening.configuration, + permissionMode: 'execute', + }, + } as unknown as ContinuationClaimV1['targetOpening'], }, }), - /Invalid AgentRun header schema/, + /Invalid RuntimeEvent invocation_opened schema/, ); assert.equal((await store.claimContinuation({ claim })).kind, 'acquired'); @@ -847,9 +850,9 @@ describe('SqliteRuntimeStore', () => { try { database.exec(` UPDATE runtime_continuation_claims - SET target_run_header_json = json_set( - target_run_header_json, - '$.permissionMode', + SET target_opening_json = json_set( + target_opening_json, + '$.configuration.permissionMode', 'execute' ) WHERE claim_id = 'claim-1'; @@ -858,8 +861,15 @@ describe('SqliteRuntimeStore', () => { database.close(); } - const persisted = await store.readContinuationClaimByBoundary(claim.boundaryDigest); - assert.equal(persisted?.targetRunHeader.permissionMode, 'ask'); + // A persisted Run header used to be widened on read. The opening fact has + // no legacy layer and none is wanted: a claim whose frozen opening cannot + // be read cannot authenticate the start event it exists to authenticate, + // and admitting one against a guessed opening would be the failure this + // record is meant to prevent. + await assert.rejects( + store.readContinuationClaimByBoundary(claim.boundaryDigest), + /Invalid RuntimeEvent invocation_opened schema/, + ); }); }); @@ -1914,32 +1924,37 @@ function continuationClaimForBoundary( providerProjectionVersion: 1, providerReplayDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', target, - targetRunHeader: { - ...target, - status: 'created', - backendKind: 'fake', - llmConnectionSlug: 'connection-1', - modelId: 'model-1', - cwd: '/workspace/repo', - permissionMode: 'ask', - collaborationMode: 'agent', - orchestrationMode: 'default', - orchestrationSource: 'session', - agentSwarmAuthorization: 'none', - createdAt: claimedAt, - updatedAt: claimedAt, - parentRunId: source.identity.runId, - parentTurnId: source.identity.turnId, - continuationSource: { - protocol: 'continuation_source_v2', - claimId, - boundaryDigest: boundary.manifestDigest, + targetOpening: { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'unknown', + backendKind: 'fake', + llmConnectionSlug: 'connection-1', + modelId: 'model-1', + }, + configuration: { + cwd: '/workspace/repo', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: DEFAULT_TOOL_MODE, + agentSwarmAuthorization: 'none', + }, + root: { kind: 'user' }, + source: { + kind: 'continuation', sourceInvocationId: source.identity.invocationId, sourceRunId: source.identity.runId, sourceTurnId: source.identity.turnId, sourceRuntimeEventHighWater: source.position.lastEventSeq, - sourcePrefixDigest: source.prefixDigest, - replayManifestDigest: boundary.manifestDigest, + claimId, + boundaryDigest: boundary.manifestDigest, + }, + lineage: { + parentRunId: source.identity.runId, + parentTurnId: source.identity.turnId, }, }, claimedAt, @@ -2023,7 +2038,7 @@ function continuationStartEvent( role: 'system', author: 'system', modelVisibility: 'hidden', - content: runtimeInvocationOpeningFromRunHeader(claim.targetRunHeader), + content: claim.targetOpening, actions: { ...(overrides.toolBoundaryProtocol ? { runtimeProtocol: { toolBoundary: overrides.toolBoundaryProtocol } } diff --git a/packages/storage/src/sqlite-runtime-schema.ts b/packages/storage/src/sqlite-runtime-schema.ts index af01dd2aa9..3a518baee9 100644 --- a/packages/storage/src/sqlite-runtime-schema.ts +++ b/packages/storage/src/sqlite-runtime-schema.ts @@ -509,6 +509,9 @@ const MIGRATIONS: ReadonlyMap = new Map([ CREATE INDEX runtime_legacy_invocation_openings_by_session ON runtime_legacy_invocation_openings(session_id, opened_at, invocation_id); + + ALTER TABLE runtime_continuation_claims + RENAME COLUMN target_run_header_json TO target_opening_json; `, ], ]); @@ -520,9 +523,47 @@ const MIGRATIONS: ReadonlyMap = new Map([ * writer can never classify a field two different ways. */ const DATA_MIGRATIONS: ReadonlyMap void> = new Map([ - [16, backfillInvocationOpeningFacts], + [ + 16, + (db) => { + backfillInvocationOpeningFacts(db); + projectContinuationClaimOpenings(db); + }, + ], ]); +/** + * Replace each open claim's embedded target Run header with the opening fact it + * always implied. + * + * The header was only ever there so the start event could be checked against + * it, and the check went through the projection anyway. Projecting once, here, + * leaves one representation instead of a copy plus a derivation. + * + * A row this cannot project is dropped rather than left half-migrated: an + * undecodable claim could not have admitted a start event before this migration + * either, and keeping it would only block the boundary it holds. + */ +function projectContinuationClaimOpenings(db: DatabaseSync): void { + const rows = db + .prepare('SELECT claim_id, target_opening_json FROM runtime_continuation_claims') + .all() as Array<{ claim_id: string; target_opening_json: string }>; + const update = db.prepare( + 'UPDATE runtime_continuation_claims SET target_opening_json = ? WHERE claim_id = ?', + ); + const remove = db.prepare('DELETE FROM runtime_continuation_claims WHERE claim_id = ?'); + for (const row of rows) { + try { + const header = decodePersistedAgentRunHeader( + JSON.parse(row.target_opening_json) as PersistedValue, + ); + update.run(JSON.stringify(runtimeInvocationOpeningFromRunHeader(header)), row.claim_id); + } catch { + remove.run(row.claim_id); + } + } +} + /** * Give every Run header its opening fact, so that after this migration the * opening lives in the runtime database rather than on the header. diff --git a/packages/storage/src/sqlite-runtime-store.ts b/packages/storage/src/sqlite-runtime-store.ts index ce787628fc..30cf08b37b 100644 --- a/packages/storage/src/sqlite-runtime-store.ts +++ b/packages/storage/src/sqlite-runtime-store.ts @@ -83,6 +83,7 @@ import { } from '@maka/core/tool-ledger-scanner'; import { buildImmutableRuntimePrefix, + continuationStartEventMatchesClaim, decodeContinuationClaim, type ContinuationClaimV1, type ImmutableRuntimePrefixV1, @@ -1030,7 +1031,7 @@ export class SqliteRuntimeStore target_invocation_id, target_run_id, target_turn_id, - target_run_header_json, + target_opening_json, claimed_at, protocol_version ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1) @@ -1051,7 +1052,7 @@ export class SqliteRuntimeStore claim.target.invocationId, claim.target.runId, claim.target.turnId, - stableJsonStringify(claim.targetRunHeader), + stableJsonStringify(claim.targetOpening), claim.claimedAt, ); } catch (error) { @@ -1122,7 +1123,7 @@ export class SqliteRuntimeStore target_invocation_id, target_run_id, target_turn_id, - target_run_header_json, + target_opening_json, claimed_at, start_event_id, start_kind, @@ -3112,7 +3113,7 @@ export class SqliteRuntimeStore target_invocation_id, target_run_id, target_turn_id, - target_run_header_json, + target_opening_json, claimed_at, start_event_id, start_kind, @@ -3143,7 +3144,7 @@ export class SqliteRuntimeStore target_invocation_id, target_run_id, target_turn_id, - target_run_header_json, + target_opening_json, claimed_at, start_event_id, start_kind, @@ -4096,54 +4097,7 @@ function assertContinuationStartEvent( event: RuntimeEvent, startKind: 'runtime_admission' | 'claim_repair', ): void { - const start = event.actions?.continuationStart; - const runtimeProtocol = event.actions?.runtimeProtocol; - const actionKeys = event.actions ? Object.keys(event.actions) : []; - const validActionShape = - actionKeys.includes('continuationStart') && - actionKeys.every((key) => key === 'continuationStart' || key === 'runtimeProtocol') && - actionKeys.length === (runtimeProtocol === undefined ? 1 : 2); - const validRuntimeProtocol = - runtimeProtocol === undefined || - (startKind === 'runtime_admission' && - runtimeProtocol.toolBoundary === TOOL_BOUNDARY_PROTOCOL_V1); - const source = claim.boundary.segments.at(-1)!; - if ( - event.sessionId !== claim.target.sessionId || - event.invocationId !== claim.target.invocationId || - event.runId !== claim.target.runId || - event.turnId !== claim.target.turnId || - event.ts < claim.claimedAt || - event.partial || - event.role !== 'system' || - event.author !== 'system' || - event.status !== undefined || - // Event 1 of a continuation target is also that invocation's opening fact, - // and the claim's target header is what it must project from. - !isDeepStrictEqual( - event.content, - runtimeInvocationOpeningFromRunHeader(claim.targetRunHeader), - ) || - !event.actions || - !validActionShape || - !validRuntimeProtocol || - !start || - start.protocol !== 'continuation_start_v2' || - start.provenance !== startKind || - start.claimId !== claim.claimId || - start.boundaryDigest !== claim.boundaryDigest || - start.replayManifestDigest !== claim.boundary.manifestDigest || - start.providerProjectionVersion !== claim.providerProjectionVersion || - start.providerReplayDigest !== claim.providerReplayDigest || - !isDeepStrictEqual(start.immediateSource, { - sessionId: source.identity.sessionId, - invocationId: source.identity.invocationId, - runId: source.identity.runId, - turnId: source.identity.turnId, - highWater: source.position.lastEventSeq, - prefixDigest: source.prefixDigest, - }) - ) { + if (!continuationStartEventMatchesClaim(event, claim, startKind)) { throw new Error('Invalid continuation-start authority event'); } } @@ -4499,7 +4453,7 @@ interface ContinuationClaimStorageRow { target_invocation_id: string; target_run_id: string; target_turn_id: string; - target_run_header_json: string; + target_opening_json: string; claimed_at: number; start_event_id: string | null; start_kind: 'runtime_admission' | 'claim_repair' | null; @@ -4686,9 +4640,7 @@ function decodeContinuationClaimRow(row: ContinuationClaimStorageRow): Continuat throw new Error(`Unsupported continuation claim protocol ${row.protocol_version}`); } const boundary = JSON.parse(row.boundary_json) as unknown; - const targetRunHeader = decodePersistedAgentRunHeader( - markPersisted(JSON.parse(row.target_run_header_json)), - ); + const targetOpening = JSON.parse(row.target_opening_json) as unknown; const claim = decodeContinuationClaim({ protocol: 'continuation_claim_v1', claimId: row.claim_id, @@ -4702,7 +4654,7 @@ function decodeContinuationClaimRow(row: ContinuationClaimStorageRow): Continuat runId: row.target_run_id, turnId: row.target_turn_id, }, - targetRunHeader, + targetOpening, claimedAt: row.claimed_at, }); const source = claim.boundary.segments.at(-1)!; From 7ab80fbbe04f5d91ab979ef35239f20b77f2267e Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 00:29:00 +0800 Subject: [PATCH 07/46] refactor(core): match a hosted root against the opening's closed root union Matching a Run against the root the Host admitted took about 140 lines, almost all of it asserting that every other optional lineage and root-authority field was absent. That shape was forced by the header: an open bag of optionals where "this is a Goal root" could only be said as "goalId is set and the other four markers are not", and where adding one lineage field meant editing six negative lists or silently weakening every one of them. The opening fact names its root as a closed discriminated union, so each arm names the root it wants. What is left of lineage is one exactness check: an admitted root has the lineage its kind implies and no other edge, which is both stronger than the old per-field negatives and immune to a new field being forgotten. The matcher now takes the invocation rather than the Run header. Runtime Host still holds headers, so its one call site projects through the same mapping the rest of this work uses; phase 2c hands it the real opening. Refs #4311 Generated-by: Claude Code --- .../__tests__/agent-run-hosted-root.test.ts | 241 ++++++++++++++---- packages/core/src/agent-run.ts | 197 ++++++-------- .../src/server/hosted-execution-projection.ts | 17 +- 3 files changed, 282 insertions(+), 173 deletions(-) diff --git a/packages/core/src/__tests__/agent-run-hosted-root.test.ts b/packages/core/src/__tests__/agent-run-hosted-root.test.ts index 17a876a9cf..3973fbdd09 100644 --- a/packages/core/src/__tests__/agent-run-hosted-root.test.ts +++ b/packages/core/src/__tests__/agent-run-hosted-root.test.ts @@ -19,74 +19,217 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { agentRunMatchesHostedRootExecution, type AgentRunHeader } from '../agent-run.js'; +import { invocationMatchesHostedRootExecution } from '../agent-run.js'; +import type { + RuntimeEventInvocationOpenedContent, + RuntimeInvocationLineage, + RuntimeInvocationOpenSource, + RuntimeInvocationRootAuthority, +} from '../runtime-event.js'; -test('regenerate hosted root identity requires both source lineage fields', () => { - const run: AgentRunHeader = { - runId: 'run-1', - sessionId: 'session-1', - turnId: 'turn-2', - status: 'completed', - backendKind: 'fake', - llmConnectionSlug: 'fake', - modelId: 'fake-model', - cwd: '/workspace', - permissionMode: 'ask', - createdAt: 1, - updatedAt: 2, - completedAt: 2, - parentTurnId: 'turn-1', - regeneratedFromTurnId: 'turn-1', +const BOUNDARY_DIGEST = `sha256:${'a'.repeat(64)}` as const; + +function invocation( + root: RuntimeInvocationRootAuthority, + overrides: { + invocationId?: string; + source?: RuntimeInvocationOpenSource; + lineage?: RuntimeInvocationLineage; + orchestrationMode?: 'default' | 'graph'; + orchestrationSource?: 'session' | 'turn_override'; + } = {}, +): { invocationId: string; opening: RuntimeEventInvocationOpenedContent } { + const lineage = overrides.lineage; + return { + invocationId: overrides.invocationId ?? 'invocation-1', + opening: { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'unknown', + backendKind: 'fake', + llmConnectionSlug: 'fake', + modelId: 'fake-model', + }, + configuration: { + cwd: '/workspace', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: overrides.orchestrationMode ?? 'default', + orchestrationSource: overrides.orchestrationSource ?? 'session', + toolMode: 'direct', + agentSwarmAuthorization: 'none', + }, + root, + source: overrides.source ?? { kind: 'fresh' }, + ...(lineage ? { lineage } : {}), + }, }; +} + +test('regenerate root identity requires exactly its own turn lineage', () => { + const lineage = { parentTurnId: 'turn-1', regeneratedFromTurnId: 'turn-1' }; + const execution = { kind: 'regenerate', sourceTurnId: 'turn-1' } as const; assert.equal( - agentRunMatchesHostedRootExecution(run, { - kind: 'regenerate', - sourceTurnId: 'turn-1', - }), + invocationMatchesHostedRootExecution(invocation({ kind: 'user' }, { lineage }), execution), true, ); assert.equal( - agentRunMatchesHostedRootExecution( - { ...run, regeneratedFromTurnId: 'turn-other' }, - { kind: 'regenerate', sourceTurnId: 'turn-1' }, + invocationMatchesHostedRootExecution( + invocation({ kind: 'user' }, { lineage: { ...lineage, regeneratedFromTurnId: 'turn-x' } }), + execution, + ), + false, + ); + // One extra lineage edge is one edge too many: a regenerate root has no parent + // run, no agent and no branch. + assert.equal( + invocationMatchesHostedRootExecution( + invocation({ kind: 'user' }, { lineage: { ...lineage, parentRunId: 'run-0' } }), + execution, ), false, ); assert.equal( - agentRunMatchesHostedRootExecution( - { ...run, scheduledTaskId: 'scheduled-task-1' }, - { kind: 'regenerate', sourceTurnId: 'turn-1' }, + invocationMatchesHostedRootExecution( + invocation({ kind: 'scheduled_task', scheduledTaskId: 'task-1' }, { lineage }), + execution, ), false, ); }); -test('context compact hosted root identity rejects message lineage', () => { - const run: AgentRunHeader = { - runId: 'run-compact', - sessionId: 'session-1', - turnId: 'turn-compact', - status: 'completed', - backendKind: 'fake', - llmConnectionSlug: 'fake', - modelId: 'fake-model', - cwd: '/workspace', - permissionMode: 'ask', - createdAt: 1, - updatedAt: 2, - completedAt: 2, - rootExecutionKind: 'context_compact', - }; +test('context compact root identity rejects any lineage and any other root', () => { + const execution = { kind: 'context_compact' } as const; + + assert.equal( + invocationMatchesHostedRootExecution(invocation({ kind: 'context_compact' }), execution), + true, + ); + assert.equal( + invocationMatchesHostedRootExecution(invocation({ kind: 'user' }), execution), + false, + ); + assert.equal( + invocationMatchesHostedRootExecution( + invocation({ kind: 'context_compact' }, { lineage: { parentTurnId: 'turn-1' } }), + execution, + ), + false, + ); +}); + +test('each host authority root matches only its own kind and id', () => { + assert.equal( + invocationMatchesHostedRootExecution( + invocation({ kind: 'scheduled_task', scheduledTaskId: 'task-1' }), + { kind: 'scheduled_task', scheduledTaskId: 'task-1' }, + ), + true, + ); + assert.equal( + invocationMatchesHostedRootExecution( + invocation({ kind: 'scheduled_task', scheduledTaskId: 'task-2' }), + { kind: 'scheduled_task', scheduledTaskId: 'task-1' }, + ), + false, + ); + assert.equal( + invocationMatchesHostedRootExecution( + invocation({ kind: 'legacy_automation', legacyAutomationId: 'automation-1' }), + { kind: 'legacy_automation', automationId: 'automation-1' }, + ), + true, + ); + assert.equal( + invocationMatchesHostedRootExecution(invocation({ kind: 'goal', goalId: 'goal-1' }), { + kind: 'goal', + goalId: 'goal-1', + }), + true, + ); + // A Goal root is not a ScheduledTask root, and the union says so directly. + assert.equal( + invocationMatchesHostedRootExecution(invocation({ kind: 'goal', goalId: 'goal-1' }), { + kind: 'scheduled_task', + scheduledTaskId: 'goal-1', + }), + false, + ); +}); + +test('a supervisor wake root carries its graph prefix and graph orchestration', () => { + const root = { + kind: 'agent_graph_supervisor_wake', + wakeId: 'graph-1:wake-1', + attemptId: 'attempt-1', + } as const; + const execution = { + kind: 'agent_graph_supervisor_wake', + graphId: 'graph-1', + wakeId: 'graph-1:wake-1', + attemptId: 'attempt-1', + } as const; + const graphConfiguration = { + orchestrationMode: 'graph', + orchestrationSource: 'turn_override', + } as const; + + assert.equal( + invocationMatchesHostedRootExecution(invocation(root, graphConfiguration), execution), + true, + ); + assert.equal(invocationMatchesHostedRootExecution(invocation(root), execution), false); + assert.equal( + invocationMatchesHostedRootExecution(invocation(root, graphConfiguration), { + ...execution, + graphId: 'graph-2', + }), + false, + ); +}); + +test('a safe boundary continuation root matches its claim and its own invocation', () => { + const source = { + kind: 'continuation', + sourceInvocationId: 'invocation-0', + sourceRunId: 'run-0', + sourceTurnId: 'turn-0', + sourceRuntimeEventHighWater: 4, + claimId: 'claim-1', + boundaryDigest: BOUNDARY_DIGEST, + } as const; + const lineage = { parentRunId: 'run-0', parentTurnId: 'turn-0' }; + const execution = { + kind: 'safe_boundary_continuation', + sourceInvocationId: 'invocation-0', + sourceRunId: 'run-0', + sourceTurnId: 'turn-0', + sourceRuntimeEventHighWater: 4, + claimId: 'claim-1', + boundaryDigest: BOUNDARY_DIGEST, + providerReplayDigest: BOUNDARY_DIGEST, + safetyDigest: BOUNDARY_DIGEST, + targetInvocationId: 'invocation-1', + } as const; - assert.equal(agentRunMatchesHostedRootExecution(run, { kind: 'context_compact' }), true); - const { rootExecutionKind: _, ...ordinaryRun } = run; - assert.equal(agentRunMatchesHostedRootExecution(ordinaryRun, { kind: 'context_compact' }), false); assert.equal( - agentRunMatchesHostedRootExecution( - { ...run, parentTurnId: 'turn-1' }, - { kind: 'context_compact' }, + invocationMatchesHostedRootExecution( + invocation({ kind: 'user' }, { source, lineage }), + execution, ), + true, + ); + assert.equal( + invocationMatchesHostedRootExecution( + invocation({ kind: 'user' }, { source, lineage, invocationId: 'invocation-other' }), + execution, + ), + false, + ); + assert.equal( + invocationMatchesHostedRootExecution(invocation({ kind: 'user' }, { lineage }), execution), false, ); }); diff --git a/packages/core/src/agent-run.ts b/packages/core/src/agent-run.ts index 75e75a82c2..36866caeb3 100644 --- a/packages/core/src/agent-run.ts +++ b/packages/core/src/agent-run.ts @@ -244,147 +244,100 @@ type HostedRootExecutionDescriptor = Extract< } >; -export function agentRunMatchesHostedRootExecution( - run: AgentRunHeader, +/** + * Is this invocation the one the Host admitted for that root execution? + * + * The opening fact names its root as a closed union, so each arm names the root + * it wants instead of asserting that every other root marker is absent. What + * remains is lineage, and the rule there is exactness: an admitted root has the + * lineage its kind implies and no other, so one comparison replaces a list of + * per-field negatives that had to be extended every time a lineage field was + * added. + */ +export function invocationMatchesHostedRootExecution( + invocation: { invocationId: string; opening: RuntimeEventInvocationOpenedContent }, execution: HostedRootExecutionDescriptor, ): boolean { - if (execution.kind !== 'context_compact' && run.rootExecutionKind !== undefined) return false; - if (execution.kind === 'regenerate') { - return ( - run.parentTurnId === execution.sourceTurnId && - run.regeneratedFromTurnId === execution.sourceTurnId && - run.parentRunId === undefined && - run.resumedFromRunId === undefined && - run.retriedFromRunId === undefined && - run.agentId === undefined && - run.agentName === undefined && - run.retriedFromTurnId === undefined && - run.branchOfTurnId === undefined && - run.parentSessionId === undefined && - run.continuationSource === undefined && - run.scheduledTaskId === undefined && - run.legacyAutomationId === undefined && - run.goalId === undefined && - run.agentGraphWakeId === undefined && - run.agentGraphWakeAttemptId === undefined - ); - } - if (execution.kind === 'context_compact') { - return ( - run.rootExecutionKind === 'context_compact' && - run.parentTurnId === undefined && - run.regeneratedFromTurnId === undefined && - run.parentRunId === undefined && - run.resumedFromRunId === undefined && - run.retriedFromRunId === undefined && - run.agentId === undefined && - run.agentName === undefined && - run.retriedFromTurnId === undefined && - run.branchOfTurnId === undefined && - run.parentSessionId === undefined && - run.continuationSource === undefined && - run.scheduledTaskId === undefined && - run.legacyAutomationId === undefined && - run.goalId === undefined && - run.agentGraphWakeId === undefined && - run.agentGraphWakeAttemptId === undefined - ); - } - if (execution.kind === 'safe_boundary_continuation') { - const source = run.continuationSource; - return ( - run.invocationId === execution.targetInvocationId && - run.parentRunId === execution.sourceRunId && - run.parentTurnId === execution.sourceTurnId && - source !== undefined && - 'protocol' in source && - source.protocol === 'continuation_source_v2' && - source.sourceInvocationId === execution.sourceInvocationId && - source.sourceRunId === execution.sourceRunId && - source.sourceTurnId === execution.sourceTurnId && - source.sourceRuntimeEventHighWater === execution.sourceRuntimeEventHighWater && - source.claimId === execution.claimId && - source.boundaryDigest === execution.boundaryDigest && - source.replayManifestDigest === execution.boundaryDigest && - run.resumedFromRunId === undefined && - run.retriedFromRunId === undefined && - run.agentId === undefined && - run.agentName === undefined && - run.retriedFromTurnId === undefined && - run.regeneratedFromTurnId === undefined && - run.branchOfTurnId === undefined && - run.parentSessionId === undefined && - run.scheduledTaskId === undefined && - run.legacyAutomationId === undefined && - run.goalId === undefined && - run.agentGraphWakeId === undefined && - run.agentGraphWakeAttemptId === undefined - ); - } - const authorityMatches = hostedRootAuthorityMatches(run, execution); - return ( - authorityMatches && - run.parentRunId === undefined && - run.resumedFromRunId === undefined && - run.retriedFromRunId === undefined && - run.agentId === undefined && - run.agentName === undefined && - run.parentTurnId === undefined && - run.retriedFromTurnId === undefined && - run.regeneratedFromTurnId === undefined && - run.branchOfTurnId === undefined && - run.parentSessionId === undefined && - run.continuationSource === undefined - ); -} - -function hostedRootAuthorityMatches( - run: AgentRunHeader, - execution: Exclude< - HostedRootExecutionDescriptor, - { kind: 'regenerate' | 'context_compact' | 'safe_boundary_continuation' } - >, -): boolean { + const { root, source, configuration, lineage } = invocation.opening; switch (execution.kind) { + case 'regenerate': + return ( + root.kind === 'user' && + source.kind === 'fresh' && + lineageIsExactly(lineage, { + parentTurnId: execution.sourceTurnId, + regeneratedFromTurnId: execution.sourceTurnId, + }) + ); + case 'context_compact': + return ( + root.kind === 'context_compact' && source.kind === 'fresh' && lineageIsExactly(lineage, {}) + ); + case 'safe_boundary_continuation': + return ( + root.kind === 'user' && + source.kind === 'continuation' && + invocation.invocationId === execution.targetInvocationId && + source.sourceInvocationId === execution.sourceInvocationId && + source.sourceRunId === execution.sourceRunId && + source.sourceTurnId === execution.sourceTurnId && + source.sourceRuntimeEventHighWater === execution.sourceRuntimeEventHighWater && + source.claimId === execution.claimId && + source.boundaryDigest === execution.boundaryDigest && + lineageIsExactly(lineage, { + parentRunId: execution.sourceRunId, + parentTurnId: execution.sourceTurnId, + }) + ); case 'scheduled_task': return ( - run.scheduledTaskId === execution.scheduledTaskId && - run.legacyAutomationId === undefined && - run.goalId === undefined && - run.agentGraphWakeId === undefined && - run.agentGraphWakeAttemptId === undefined + root.kind === 'scheduled_task' && + root.scheduledTaskId === execution.scheduledTaskId && + source.kind === 'fresh' && + lineageIsExactly(lineage, {}) ); case 'legacy_automation': return ( - run.legacyAutomationId === execution.automationId && - run.scheduledTaskId === undefined && - run.goalId === undefined && - run.agentGraphWakeId === undefined && - run.agentGraphWakeAttemptId === undefined + root.kind === 'legacy_automation' && + root.legacyAutomationId === execution.automationId && + source.kind === 'fresh' && + lineageIsExactly(lineage, {}) ); case 'goal': return ( - run.goalId === execution.goalId && - run.scheduledTaskId === undefined && - run.legacyAutomationId === undefined && - run.agentGraphWakeId === undefined && - run.agentGraphWakeAttemptId === undefined + root.kind === 'goal' && + root.goalId === execution.goalId && + source.kind === 'fresh' && + lineageIsExactly(lineage, {}) ); case 'agent_graph_supervisor_wake': return ( + root.kind === 'agent_graph_supervisor_wake' && execution.wakeId.startsWith(`${execution.graphId}:`) && - run.agentGraphWakeId === execution.wakeId && - run.agentGraphWakeAttemptId === execution.attemptId && - run.orchestrationMode === 'graph' && - run.orchestrationSource === 'turn_override' && - run.agentSwarmAuthorization === 'none' && - run.scheduledTaskId === undefined && - run.legacyAutomationId === undefined && - run.goalId === undefined + root.wakeId === execution.wakeId && + root.attemptId === execution.attemptId && + configuration.orchestrationMode === 'graph' && + configuration.orchestrationSource === 'turn_override' && + configuration.agentSwarmAuthorization === 'none' && + source.kind === 'fresh' && + lineageIsExactly(lineage, {}) ); } } +/** An admitted root has the lineage its kind implies, and no other edge. */ +function lineageIsExactly( + lineage: RuntimeInvocationLineage | undefined, + expected: RuntimeInvocationLineage, +): boolean { + const actual = (lineage ?? {}) as Record; + const wanted = expected as Record; + const keys = Object.keys(wanted); + return ( + Object.keys(actual).length === keys.length && keys.every((key) => actual[key] === wanted[key]) + ); +} + export interface AgentRunInputSummary { textLength: number; attachmentCount: number; diff --git a/packages/runtime-host/src/server/hosted-execution-projection.ts b/packages/runtime-host/src/server/hosted-execution-projection.ts index b5ab57eb2d..75a7d90c76 100644 --- a/packages/runtime-host/src/server/hosted-execution-projection.ts +++ b/packages/runtime-host/src/server/hosted-execution-projection.ts @@ -18,7 +18,8 @@ */ import { - agentRunMatchesHostedRootExecution, + invocationMatchesHostedRootExecution, + runtimeInvocationOpeningFromRunHeader, type AgentRunHeader, type RootExecutionDescriptor, } from '@maka/core/agent-run'; @@ -109,7 +110,19 @@ function assertRunMatchesExecution( case 'goal': case 'agent_graph_supervisor_wake': case 'safe_boundary_continuation': - if (agentRunMatchesHostedRootExecution(run, execution)) return; + // Phase 2c hands this the invocation's own opening fact; until then the + // header is projected through the one mapping that owns that projection. + if ( + invocationMatchesHostedRootExecution( + { + invocationId: run.invocationId ?? run.runId, + opening: runtimeInvocationOpeningFromRunHeader(run), + }, + execution, + ) + ) { + return; + } break; case 'linked_child_initial': case 'claimed_agent_graph_intent': From aa975c55fa4b4f9c25bd24fc29975a682eaf73a2 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 00:33:20 +0800 Subject: [PATCH 08/46] refactor(core): retire three AgentRunEvent types nothing writes `provider_request_captured`, `provider_request_attempt_recorded` and `task_gate_decided` have no writer in this build. `AGENT_RUN_EVENT_TYPES` is the catalogue of what this build may append, not what it may read, so keeping them there only kept alive the copy rewriters that existed to move their payloads. Persisted rows of these kinds keep working exactly as before, because the ledger's `type` has always been an open string: the diagnostic reader that folds a legacy provider attempt into a prompt composition still reads them, and a conversation copy now drops them the way it already drops every type this build cannot emit, rather than carrying source-owned identities into the target it cannot check. One thing does survive the deletion: the copy still harvests provider trace ids from those rows. A copied RuntimeEvent may point at a trace only a retired writer recorded, and pointing the target at a fresh id is right where pointing it at the source's id would not be. Refs #4311 Generated-by: Claude Code --- packages/core/src/agent-run.ts | 3 - .../src/__tests__/context-diagnostics.test.ts | 4 +- .../src/__tests__/conversation-copy.test.ts | 119 ++---------------- packages/runtime/src/conversation-copy.ts | 80 ++++-------- 4 files changed, 36 insertions(+), 170 deletions(-) diff --git a/packages/core/src/agent-run.ts b/packages/core/src/agent-run.ts index 36866caeb3..686d65075a 100644 --- a/packages/core/src/agent-run.ts +++ b/packages/core/src/agent-run.ts @@ -385,13 +385,10 @@ export const AGENT_RUN_EVENT_TYPES = [ 'sandbox_escalation_applied', 'sandbox_escalation_failed', 'sandbox_denial_detected', - 'provider_request_captured', - 'provider_request_attempt_recorded', 'model_call_attempt_recorded', 'history_compact_checkpoint_recorded', 'model_projection_transition_recorded', 'run_composition_recorded', - 'task_gate_decided', 'abort_requested', 'run_completed', 'run_failed', diff --git a/packages/runtime/src/__tests__/context-diagnostics.test.ts b/packages/runtime/src/__tests__/context-diagnostics.test.ts index 9354d762a3..9754ab9aaf 100644 --- a/packages/runtime/src/__tests__/context-diagnostics.test.ts +++ b/packages/runtime/src/__tests__/context-diagnostics.test.ts @@ -1074,6 +1074,8 @@ function attemptEvent( segments: Array> = [], ): EmittedAgentRunEvent { const turnId = `turn-${runId}`; + // A row from a retired writer. This build cannot emit the type; the diagnostic + // reader still has to read what older builds persisted. return { type: 'provider_request_attempt_recorded', id: attemptId, @@ -1101,7 +1103,7 @@ function attemptEvent( latencyMs: 1, ...(inputTokens === undefined ? {} : { inputTokens }), }, - }; + } as unknown as EmittedAgentRunEvent; } /** diff --git a/packages/runtime/src/__tests__/conversation-copy.test.ts b/packages/runtime/src/__tests__/conversation-copy.test.ts index 9731fcc0d4..9319c30e7e 100644 --- a/packages/runtime/src/__tests__/conversation-copy.test.ts +++ b/packages/runtime/src/__tests__/conversation-copy.test.ts @@ -1631,77 +1631,6 @@ test('conversation copy rewrites the parent operation id of a nested Code Mode c } }); -test('conversation copy validates operational events before persisting target ledgers', async () => { - const root = await mkdtemp(join(tmpdir(), 'maka-conversation-copy-preflight-')); - try { - const runStore = createSqliteAgentRunStore(root); - const runtimeEventStore = createWorkspaceRuntimeStore(root); - await runStore.createRun( - agentRunHeader({ - runId: 'run-source', - invocationId: 'invocation-source', - turnId: 'turn-1', - cwd: root, - }), - ); - for (const event of [ - runtimeEvent({ - id: 'event-user', - role: 'user', - author: 'user', - content: { kind: 'text', text: 'copy this turn' }, - }), - runtimeEvent({ - id: 'event-terminal', - ts: 2, - status: 'completed', - }), - ]) { - await runtimeEventStore.appendRuntimeEvent('session-source', 'run-source', event); - } - await runStore.appendEvent('session-source', 'run-source', { - type: 'provider_request_captured', - id: 'capture-source', - runId: 'run-source', - sessionId: 'session-source', - turnId: 'turn-1', - ts: 1.5, - data: { - traceId: 'trace-source', - captureId: 'wrong-capture-id', - artifactId: 'artifact-source', - }, - }); - const source = await new RuntimeReadModel({ - runStore, - runtimeEventStore, - }).getSessionView('session-source'); - - await assert.rejects( - async () => - cloneConversationRuntimeLedger({ - plan: await prepareTestCopyPlan(source, source.messages, runStore, runtimeEventStore), - copiedMessages: source.messages, - referenceMap: { - mode: 'exact', - linkedChildren: { mode: 'reject' }, - sourceSessionId: 'session-source', - targetSessionId: 'session-target', - artifactIds: new Map([['artifact-source', 'artifact-target']]), - relativePaths: new Map(), - }, - runStore, - runtimeEventStore, - newId: () => crypto.randomUUID(), - }), - /Cannot copy invalid provider request capture capture-source/, - ); - assert.deepEqual(await runStore.listSessionRuns('session-target'), []); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); - test('conversation copy rewrites the nested identity of a model call attempt', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-conversation-model-call-copy-')); try { @@ -2076,7 +2005,7 @@ test('conversation copy clones one terminal Runtime ledger with new owned identi segments: [], artifactId: 'artifact-source', }, - }); + } as unknown as EmittedAgentRunEvent); await runStore.appendEvent('session-source', 'run-source', { type: 'provider_request_attempt_recorded', id: 'attempt-source', @@ -2102,7 +2031,7 @@ test('conversation copy clones one terminal Runtime ledger with new owned identi status: 'completed', latencyMs: 0.5, }, - }); + } as unknown as EmittedAgentRunEvent); await runStore.appendEvent('session-source', 'run-source', { type: 'provider_request_attempt_recorded', id: 'attempt-without-capture-source', @@ -2126,7 +2055,7 @@ test('conversation copy clones one terminal Runtime ledger with new owned identi status: 'completed', latencyMs: 0.05, }, - }); + } as unknown as EmittedAgentRunEvent); // A legacy event from the retired active-full writer is treated like any // other event this build cannot emit and is therefore not copied. await runStore.appendEvent('session-source', 'run-source', { @@ -2325,42 +2254,18 @@ test('conversation copy clones one terminal Runtime ledger with new owned identi 'artifact-target-deleted', ]); const targetOperationalEvents = await runStore.readEvents('session-target', 'run-target'); + // The retired provider-request writers are treated like any other type this + // build cannot emit: their rows are not carried into the target. assert.deepEqual( targetOperationalEvents.map((event) => event.type), - [ - 'provider_request_captured', - 'provider_request_attempt_recorded', - 'provider_request_attempt_recorded', - 'history_compact_checkpoint_recorded', - 'run_completed', - ], - ); - const targetCapture = targetOperationalEvents.find( - (event) => event.type === 'provider_request_captured', - ); - const targetAttempt = targetOperationalEvents.find( - (event) => - event.type === 'provider_request_attempt_recorded' && event.data?.providerId === 'provider', - ); - const targetAttemptWithoutCapture = targetOperationalEvents.find( - (event) => - event.type === 'provider_request_attempt_recorded' && - event.data?.providerId === 'provider-without-capture', + ['history_compact_checkpoint_recorded', 'run_completed'], ); - assert.ok(targetCapture); - assert.ok(targetAttempt); - assert.ok(targetAttemptWithoutCapture); - assert.equal(targetCapture.data?.captureId, targetCapture.id); - assert.equal(targetCapture.data?.artifactId, 'artifact-target'); - assert.notEqual(targetCapture.data?.traceId, 'provider-trace-source'); - assert.equal(targetAttempt.data?.attemptId, targetAttempt.id); - assert.equal(targetAttempt.data?.captureId, targetCapture.id); - assert.equal(targetAttempt.data?.captureArtifactId, 'artifact-target'); - assert.equal(targetAttempt.data?.traceId, targetCapture.data?.traceId); - assert.equal(targetAttemptWithoutCapture.data?.captureId, undefined); - assert.equal(targetAttemptWithoutCapture.data?.captureArtifactId, undefined); - assert.equal(targetEvents[1]?.refs?.providerRequestTraceId, targetCapture.data?.traceId); - assert.equal(targetEvents[1]?.refs?.traceEventId, targetCapture.id); + // A copied RuntimeEvent still points somewhere new, though. Carrying the + // source's trace identity into the target is the thing the copy exists to + // prevent, whether or not the record naming that trace came along. + assert.notEqual(targetEvents[1]?.refs?.providerRequestTraceId, 'provider-trace-source'); + assert.ok(targetEvents[1]?.refs?.providerRequestTraceId); + assert.equal(targetEvents[1]?.refs?.traceEventId, undefined); assert.doesNotMatch(JSON.stringify(targetOperationalEvents), /OPAQUE_SOURCE_COMPACTION_STATE/); const projectedCheckpoint = await runStore.readEventProjection?.( 'session-target', diff --git a/packages/runtime/src/conversation-copy.ts b/packages/runtime/src/conversation-copy.ts index ab1b3d8757..4b211ed107 100644 --- a/packages/runtime/src/conversation-copy.ts +++ b/packages/runtime/src/conversation-copy.ts @@ -871,17 +871,7 @@ function cloneAgentRunEvent( } let data = event.data; - if (event.type === 'provider_request_captured') { - data = rewriteProviderRequestCapture(event, ids.eventId, references, providerTraceIds); - } else if (event.type === 'provider_request_attempt_recorded') { - data = rewriteProviderRequestAttempt( - event, - ids.eventId, - references, - operationalEventIds, - providerTraceIds, - ); - } else if (event.type === MODEL_CALL_ATTEMPT_EVENT_TYPE) { + if (event.type === MODEL_CALL_ATTEMPT_EVENT_TYPE) { data = rewriteModelCallAttempt( event, { sessionId: ids.sessionId, runId: ids.runId, attemptId: ids.eventId }, @@ -1049,46 +1039,6 @@ function cloneModelProjectionTransition( return transition; } -function rewriteProviderRequestCapture( - event: AgentRunEvent, - eventId: string, - references: ConversationCopyReferenceMap, - providerTraceIds: ReadonlyMap, -): Record { - const data = providerRequestCapture(event); - return { - ...data, - traceId: requiredMappedId(providerTraceIds, data.traceId, 'provider trace'), - captureId: eventId, - artifactId: rewriteOwnedArtifactId(data.artifactId, references), - }; -} - -function rewriteProviderRequestAttempt( - event: AgentRunEvent, - eventId: string, - references: ConversationCopyReferenceMap, - operationalEventIds: ReadonlyMap, - providerTraceIds: ReadonlyMap, -): Record { - const data = providerRequestAttempt(event); - return { - ...data, - traceId: requiredMappedId(providerTraceIds, data.traceId, 'provider trace'), - attemptId: eventId, - ...(data.captureId !== undefined && data.captureArtifactId !== undefined - ? { - captureId: requiredMappedId( - operationalEventIds, - data.captureId, - 'provider request capture', - ), - captureArtifactId: rewriteOwnedArtifactId(data.captureArtifactId, references), - } - : {}), - }; -} - function rewriteModelCallAttempt( event: AgentRunEvent, ids: { @@ -1203,6 +1153,16 @@ function rewriteOwnedId(sourceId: string, ids: ReadonlyMap, kind return requiredMappedId(ids, sourceId, kind); } +const PROVIDER_TRACE_BEARING_EVENT_TYPES: ReadonlySet = new Set([ + MODEL_CALL_ATTEMPT_EVENT_TYPE, + 'provider_request_captured', + 'provider_request_attempt_recorded', +]); + +function isProviderTraceBearingEventType(type: string): boolean { + return PROVIDER_TRACE_BEARING_EVENT_TYPES.has(type); +} + function providerTraceIdMap( plans: readonly { readonly operationalEvents: readonly AgentRunEvent[] }[], newId: () => string, @@ -1210,13 +1170,11 @@ function providerTraceIdMap( const result = new Map(); for (const { operationalEvents } of plans) { for (const event of operationalEvents) { - if ( - event.type !== 'provider_request_captured' && - event.type !== 'provider_request_attempt_recorded' && - event.type !== MODEL_CALL_ATTEMPT_EVENT_TYPE - ) { - continue; - } + // Harvest from retired writers too. Their rows are not copied, but a + // copied RuntimeEvent may still point at a trace only they recorded, and + // carrying the source's trace id into the target would be worse than + // pointing at a fresh one nothing describes. + if (!isProviderTraceBearingEventType(event.type)) continue; const traceId = event.data?.traceId; if (typeof traceId === 'string' && !result.has(traceId)) result.set(traceId, newId()); } @@ -1231,7 +1189,11 @@ function logicalModelCallIdMap( const result = new Map(); for (const { operationalEvents } of plans) { for (const event of operationalEvents) { - if (event.type !== MODEL_CALL_ATTEMPT_EVENT_TYPE) continue; + // Harvest from retired writers too. Their rows are not copied, but a + // copied RuntimeEvent may still point at a trace only they recorded, and + // carrying the source's trace id into the target would be worse than + // pointing at a fresh one nothing describes. + if (!isProviderTraceBearingEventType(event.type)) continue; const logicalCallId = event.data?.logicalCallId; if (typeof logicalCallId === 'string' && !result.has(logicalCallId)) { result.set(logicalCallId, newId()); From bc9955821ec26de82ff0e388c2f07a1b73cce013 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 10:59:32 +0800 Subject: [PATCH 09/46] feat(storage): address, bound and page the invocation inventory The event spine could enumerate a Session's invocations but nothing else. Every remaining Run-header read is one of three other shapes: one invocation by id, a bounded identity search, and a newest-first page. Adding them here lets consumers move off the header without inventing their own scans. All four go through one ordered read of both opening shelves. Every writer of an opening event stamps `committed_at` with the event's own timestamp, so that column orders the event shelf by the same value the record reports as `openedAt`, and ordering, cursors and limits stay in SQL. A bounded caller therefore decodes only the openings it asked for. Generated-by: Claude Code --- packages/core/src/runtime-event-store.ts | 21 ++ .../invocation-opening-backfill.test.ts | 50 ++++ packages/storage/src/agent-run-store.ts | 19 +- packages/storage/src/execution-stores.ts | 24 ++ .../storage/src/runtime-event-persistence.ts | 22 +- packages/storage/src/sqlite-runtime-store.ts | 241 ++++++++++++++---- 6 files changed, 326 insertions(+), 51 deletions(-) diff --git a/packages/core/src/runtime-event-store.ts b/packages/core/src/runtime-event-store.ts index ea74c4f84a..d60086115a 100644 --- a/packages/core/src/runtime-event-store.ts +++ b/packages/core/src/runtime-event-store.ts @@ -89,6 +89,27 @@ export interface RuntimeInvocationRecord { terminalEvent?: RuntimeEvent; } +/** One invocation's position in a Session's opening order. */ +export interface RuntimeInvocationPageCursor { + readonly openedAt: number; + readonly invocationId: string; +} + +export interface RuntimeInvocationPageInput { + readonly before?: RuntimeInvocationPageCursor; + readonly limit: number; +} + +export interface RuntimeInvocationPageResult { + readonly invocations: readonly RuntimeInvocationRecord[]; + readonly nextCursor: RuntimeInvocationPageCursor | null; +} + +export interface RuntimeInvocationSearchResult { + readonly invocations: readonly RuntimeInvocationRecord[]; + readonly truncated: boolean; +} + export interface RuntimeEventStore { /** Canonical stores fail the active run closed on every durable write error. */ readonly durability?: 'best_effort' | 'canonical'; diff --git a/packages/storage/src/__tests__/invocation-opening-backfill.test.ts b/packages/storage/src/__tests__/invocation-opening-backfill.test.ts index 57aa0aac74..e2c8a4b3c6 100644 --- a/packages/storage/src/__tests__/invocation-opening-backfill.test.ts +++ b/packages/storage/src/__tests__/invocation-opening-backfill.test.ts @@ -205,6 +205,56 @@ describe('invocation opening fact backfill', () => { } }); }); + + test('bounds, pages and addresses the same inventory', async () => { + await withHeaderOnlyRuns(async (databasePath) => { + const db = new DatabaseSync(databasePath); + try { + rewindRuntimeSchemaToPreviousVersion(db); + migrateSqliteRuntimeDatabase(db); + } finally { + db.close(); + } + + const store = createSqliteRuntimeStore(databasePath); + try { + const bounded = await store.listSessionInvocationsBounded('session-1', 2); + assert.deepEqual( + bounded.invocations.map((invocation) => invocation.invocationId), + ['run-legacy-route', 'run-scheduled'], + ); + assert.equal(bounded.truncated, true, 'the extra row read past the limit reports the rest'); + + const first = await store.listSessionInvocationsPage('session-1', { limit: 2 }); + assert.deepEqual( + first.invocations.map((invocation) => invocation.invocationId), + ['run-with-events', 'run-scheduled'], + 'a page runs newest first', + ); + const second = await store.listSessionInvocationsPage('session-1', { + limit: 2, + ...(first.nextCursor ? { before: first.nextCursor } : {}), + }); + assert.deepEqual( + second.invocations.map((invocation) => invocation.invocationId), + ['run-legacy-route'], + 'the cursor resumes without repeating or skipping a tied opening time', + ); + assert.equal(second.nextCursor, null); + + const one = await store.readInvocation('session-1', 'run-scheduled'); + assert.equal(one.turnId, 'turn-scheduled'); + assert.deepEqual(one.opening.root, { kind: 'scheduled_task', scheduledTaskId: 'task-9' }); + await assert.rejects( + () => store.readInvocation('session-1', 'run-corrupt-root'), + /Runtime invocation not found/, + 'a header the backfill refused to project has no invocation to read', + ); + } finally { + store.close(); + } + }); + }); }); /** Undo the v16 step so the migration under test runs against real header rows. */ diff --git a/packages/storage/src/agent-run-store.ts b/packages/storage/src/agent-run-store.ts index a556b9d86c..3bafdc3da5 100644 --- a/packages/storage/src/agent-run-store.ts +++ b/packages/storage/src/agent-run-store.ts @@ -48,7 +48,14 @@ import { decodeSkillInvocationResult, type SkillInvocationResult, } from '@maka/core/skill-invocation'; -import { DurableStoreWriteError, type RuntimeEventStore } from '@maka/core/runtime-event-store'; +import { + DurableStoreWriteError, + type RuntimeEventStore, + type RuntimeInvocationPageInput, + type RuntimeInvocationPageResult, + type RuntimeInvocationRecord, + type RuntimeInvocationSearchResult, +} from '@maka/core/runtime-event-store'; import { aggregateMessageContents, decodeMessageContent, @@ -287,6 +294,16 @@ export interface RuntimeEventScanBudget { export type RuntimeEventScanResult = { readonly status: 'complete' | 'limit_exceeded' }; export interface DurableRuntimeEventStore extends RuntimeEventStore { + listSessionInvocations(sessionId: string): Promise; + listSessionInvocationsBounded( + sessionId: string, + limit: number, + ): Promise; + listSessionInvocationsPage( + sessionId: string, + input: RuntimeInvocationPageInput, + ): Promise; + readInvocation(sessionId: string, invocationId: string): Promise; /** Visit one ordered, bounded SQLite snapshot without retaining the immutable ledger. */ scanRuntimeEvents( sessionId: string, diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index bd1e7e09de..1fa1797ced 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -26,7 +26,10 @@ import type { import type { RuntimeEvent, ToolBoundaryProtocol } from '@maka/core/runtime-event'; import type { RuntimeContinuationAuthorityStore, + RuntimeInvocationPageInput, + RuntimeInvocationPageResult, RuntimeInvocationRecord, + RuntimeInvocationSearchResult, } from '@maka/core/runtime-event-store'; import type { SessionHeader, SessionSummary, StoredMessage, TurnRecord } from '@maka/core/session'; import type { SessionListFilter } from '@maka/core/runtime-inputs'; @@ -218,6 +221,15 @@ export interface ExecutionRuntimeEventReader { * time; nothing writes or repairs it. */ listSessionInvocations(sessionId: string): Promise; + listSessionInvocationsBounded( + sessionId: string, + limit: number, + ): Promise; + listSessionInvocationsPage( + sessionId: string, + input: RuntimeInvocationPageInput, + ): Promise; + readInvocation(sessionId: string, invocationId: string): Promise; readRuntimeEvents(sessionId: string, runId: string): Promise; readRuntimeEventsBounded( sessionId: string, @@ -566,6 +578,12 @@ async function createExecutionStoresForWrite runtimeEventStore.readImmutableRuntimePrefix(input)), listSessionInvocations: (sessionId) => run(() => runtimeEventStore.listSessionInvocations(sessionId)), + listSessionInvocationsBounded: (sessionId, limit) => + run(() => runtimeEventStore.listSessionInvocationsBounded(sessionId, limit)), + listSessionInvocationsPage: (sessionId, input) => + run(() => runtimeEventStore.listSessionInvocationsPage(sessionId, input)), + readInvocation: (sessionId, invocationId) => + run(() => runtimeEventStore.readInvocation(sessionId, invocationId)), readSessionRuntimeEvents: (sessionId) => run(() => runtimeEventStore.readSessionRuntimeEvents(sessionId)), readSessionRuntimeEventEntries: (sessionId) => @@ -679,6 +697,12 @@ async function openExecutionStoresForRead runtimeEventStore.readImmutableRuntimeEvents(sessionId, runId)), listSessionInvocations: (sessionId) => run(() => runtimeEventStore.listSessionInvocations(sessionId)), + listSessionInvocationsBounded: (sessionId, limit) => + run(() => runtimeEventStore.listSessionInvocationsBounded(sessionId, limit)), + listSessionInvocationsPage: (sessionId, input) => + run(() => runtimeEventStore.listSessionInvocationsPage(sessionId, input)), + readInvocation: (sessionId, invocationId) => + run(() => runtimeEventStore.readInvocation(sessionId, invocationId)), readSessionRuntimeEvents: (sessionId) => run(() => runtimeEventStore.readSessionRuntimeEvents(sessionId)), }, diff --git a/packages/storage/src/runtime-event-persistence.ts b/packages/storage/src/runtime-event-persistence.ts index 806f30e361..8c56c59d57 100644 --- a/packages/storage/src/runtime-event-persistence.ts +++ b/packages/storage/src/runtime-event-persistence.ts @@ -19,7 +19,12 @@ import { join } from 'node:path'; import type { RuntimeEvent } from '@maka/core/runtime-event'; -import type { RuntimeInvocationRecord } from '@maka/core/runtime-event-store'; +import type { + RuntimeInvocationPageInput, + RuntimeInvocationPageResult, + RuntimeInvocationRecord, + RuntimeInvocationSearchResult, +} from '@maka/core/runtime-event-store'; import type { BoundedEvidenceReadResult, EvidenceReadBudget } from './agent-run-store.js'; import { createSqliteRuntimeStore, type SqliteRuntimeStore } from './sqlite-runtime-store.js'; import { @@ -42,6 +47,15 @@ export type RuntimeEventReadPersistence = { export interface RuntimeEventReadStore { listSessionInvocations(sessionId: string): Promise; + listSessionInvocationsBounded( + sessionId: string, + limit: number, + ): Promise; + listSessionInvocationsPage( + sessionId: string, + input: RuntimeInvocationPageInput, + ): Promise; + readInvocation(sessionId: string, invocationId: string): Promise; readRuntimeEvents(sessionId: string, runId: string): Promise; readRuntimeEventsBounded( sessionId: string, @@ -82,6 +96,12 @@ export async function openRuntimeEventReadPersistence(input: { kind: 'sqlite', runtimeEventStore: Object.freeze({ listSessionInvocations: (sessionId: string) => store.listSessionInvocations(sessionId), + listSessionInvocationsBounded: (sessionId: string, limit: number) => + store.listSessionInvocationsBounded(sessionId, limit), + listSessionInvocationsPage: (sessionId: string, input: RuntimeInvocationPageInput) => + store.listSessionInvocationsPage(sessionId, input), + readInvocation: (sessionId: string, invocationId: string) => + store.readInvocation(sessionId, invocationId), readRuntimeEvents: (sessionId: string, runId: string) => store.readRuntimeEvents(sessionId, runId), readRuntimeEventsBounded: (sessionId: string, runId: string, budget: EvidenceReadBudget) => diff --git a/packages/storage/src/sqlite-runtime-store.ts b/packages/storage/src/sqlite-runtime-store.ts index 30cf08b37b..1acc6fac65 100644 --- a/packages/storage/src/sqlite-runtime-store.ts +++ b/packages/storage/src/sqlite-runtime-store.ts @@ -59,7 +59,11 @@ import { type ContinuationClaimResult, type ContinuationClaimStateV1, type RuntimeContinuationAuthorityStore, + type RuntimeInvocationPageCursor, + type RuntimeInvocationPageInput, + type RuntimeInvocationPageResult, type RuntimeInvocationRecord, + type RuntimeInvocationSearchResult, type RuntimeRecoveryBundleCommit, type RuntimeRecoveryBundleStore, type RuntimeWorkspaceVersionAuthorityStore, @@ -547,59 +551,192 @@ export class SqliteRuntimeStore */ async listSessionInvocations(sessionId: string): Promise { assertRuntimeStorageSafeId(sessionId, 'Invalid session id'); + return this.readTransaction(() => + this.readInvocationOpeningsSync(sessionId, { direction: 'asc' }).map((row) => + this.completeInvocationRecordSync(row), + ), + ); + } + + /** + * The first page of a Session's invocations, plus whether more exist. + * + * The extra row this reads past the limit is the whole truncation signal, so a + * caller never has to count a Session it declined to load. + */ + async listSessionInvocationsBounded( + sessionId: string, + limit: number, + ): Promise { + assertRuntimeStorageSafeId(sessionId, 'Invalid session id'); + assertInvocationSearchLimit(limit); return this.readTransaction(() => { - const openings = this.db - .prepare(` - SELECT event_id, session_id, invocation_id, run_id, turn_id, payload_json + const rows = this.readInvocationOpeningsSync(sessionId, { + direction: 'asc', + limit: limit + 1, + }); + return { + invocations: rows.slice(0, limit).map((row) => this.completeInvocationRecordSync(row)), + truncated: rows.length > limit, + }; + }); + } + + /** One newest-first page of a Session's invocations. */ + async listSessionInvocationsPage( + sessionId: string, + input: RuntimeInvocationPageInput, + ): Promise { + assertRuntimeStorageSafeId(sessionId, 'Invalid session id'); + assertInvocationSearchLimit(input.limit); + if (input.before) { + assertRuntimeStorageSafeId(input.before.invocationId, 'Invalid invocation page cursor'); + if (!Number.isFinite(input.before.openedAt)) { + throw new Error('Invalid invocation page cursor'); + } + } + return this.readTransaction(() => { + const rows = this.readInvocationOpeningsSync(sessionId, { + direction: 'desc', + limit: input.limit + 1, + ...(input.before ? { before: input.before } : {}), + }); + const page = rows.slice(0, input.limit); + const last = page.at(-1); + return { + invocations: page.map((row) => this.completeInvocationRecordSync(row)), + nextCursor: + rows.length > input.limit && last + ? { openedAt: last.openedAt, invocationId: last.invocationId } + : null, + }; + }); + } + + /** + * One invocation named by its own identity. + * + * Absence throws rather than returning `undefined`: every caller here holds an + * invocation id that some durable fact already handed it, so a missing opening + * is corruption and not a branch a reader should be asked to handle. + */ + async readInvocation(sessionId: string, invocationId: string): Promise { + assertRuntimeStorageSafeId(sessionId, 'Invalid session id'); + assertRuntimeStorageSafeId(invocationId, 'Invalid invocation id'); + return this.readTransaction(() => { + const row = this.readInvocationOpeningsSync(sessionId, { + direction: 'asc', + invocationId, + }).at(0); + if (!row) throw new Error(`Runtime invocation not found: ${invocationId}`); + return this.completeInvocationRecordSync(row); + }); + } + + /** + * Read invocation openings off both shelves as one ordered sequence. + * + * Every writer of an opening event stamps `committed_at` with the event's own + * timestamp, so that column orders the event shelf by the same value the + * record reports as `openedAt` and the legacy shelf keeps under `opened_at`. + * Ordering and paging therefore happen in SQL, and a bounded caller decodes + * only the openings it asked for. + */ + private readInvocationOpeningsSync( + sessionId: string, + options: { + direction: 'asc' | 'desc'; + limit?: number; + before?: RuntimeInvocationPageCursor; + invocationId?: string; + }, + ): Omit[] { + const order = options.direction === 'desc' ? 'DESC' : 'ASC'; + const rows = this.db + .prepare(` + SELECT * FROM ( + SELECT + event_id AS event_id, + invocation_id AS invocation_id, + run_id AS run_id, + turn_id AS turn_id, + committed_at AS opened_at, + payload_json AS opening_json, + 1 AS from_events FROM runtime_events - WHERE session_id = ? AND event_kind = 'invocation_opened' - `) - .all(sessionId) as unknown as RuntimeEventStorageRow[]; - const records = openings.map((row) => { - const event = decodeRuntimeEventStorageRow(row); - const opening = runtimeEventInvocationOpening(event); - if (!opening) { - throw new Error(`RuntimeEvent ${event.id} is indexed as an opening fact but is not one`); - } - return this.completeInvocationRecordSync({ - sessionId: event.sessionId, - invocationId: event.invocationId, - runId: event.runId, - turnId: event.turnId, - openedAt: event.ts, - opening, - }); + WHERE session_id = :sessionId AND event_kind = 'invocation_opened' + UNION ALL + SELECT + NULL, + legacy.invocation_id, + legacy.run_id, + legacy.turn_id, + legacy.opened_at, + legacy.opening_json, + 0 + FROM runtime_legacy_invocation_openings AS legacy + WHERE legacy.session_id = :sessionId + AND NOT EXISTS ( + SELECT 1 FROM runtime_events + WHERE runtime_events.invocation_id = legacy.invocation_id + AND runtime_events.event_kind = 'invocation_opened' + ) + ) + WHERE (:invocationId IS NULL OR invocation_id = :invocationId) + AND ( + :beforeOpenedAt IS NULL + OR opened_at < :beforeOpenedAt + OR (opened_at = :beforeOpenedAt AND invocation_id < :beforeInvocationId) + ) + ORDER BY opened_at ${order}, invocation_id ${order} + LIMIT :limit + `) + .all({ + sessionId, + invocationId: options.invocationId ?? null, + beforeOpenedAt: options.before?.openedAt ?? null, + beforeInvocationId: options.before?.invocationId ?? null, + limit: options.limit ?? -1, + }) as unknown as Array<{ + event_id: string | null; + invocation_id: string; + run_id: string; + turn_id: string; + opened_at: number; + opening_json: string; + from_events: number; + }>; + return rows.map((row) => { + if (row.from_events !== 1) { + return { + sessionId, + invocationId: row.invocation_id, + runId: row.run_id, + turnId: row.turn_id, + openedAt: row.opened_at, + opening: decodeRuntimeInvocationOpened(JSON.parse(row.opening_json)), + }; + } + const event = decodeRuntimeEventStorageRow({ + event_id: row.event_id ?? '', + session_id: sessionId, + invocation_id: row.invocation_id, + run_id: row.run_id, + turn_id: row.turn_id, + payload_json: row.opening_json, }); - const opened = new Set(records.map((record) => record.invocationId)); - const legacy = this.db - .prepare(` - SELECT invocation_id, run_id, turn_id, opened_at, opening_json - FROM runtime_legacy_invocation_openings - WHERE session_id = ? - `) - .all(sessionId) as unknown as Array<{ - invocation_id: string; - run_id: string; - turn_id: string; - opened_at: number; - opening_json: string; - }>; - for (const row of legacy) { - if (opened.has(row.invocation_id)) continue; - records.push( - this.completeInvocationRecordSync({ - sessionId, - invocationId: row.invocation_id, - runId: row.run_id, - turnId: row.turn_id, - openedAt: row.opened_at, - opening: decodeRuntimeInvocationOpened(JSON.parse(row.opening_json)), - }), - ); + const opening = runtimeEventInvocationOpening(event); + if (!opening) { + throw new Error(`RuntimeEvent ${event.id} is indexed as an opening fact but is not one`); } - return records.sort( - (a, b) => a.openedAt - b.openedAt || a.invocationId.localeCompare(b.invocationId), - ); + return { + sessionId: event.sessionId, + invocationId: event.invocationId, + runId: event.runId, + turnId: event.turnId, + openedAt: event.ts, + opening, + }; }); } @@ -4117,6 +4254,12 @@ function assertRuntimeStorageSafeId(value: string, message: string): void { if (!isRuntimeStorageSafeId(value)) throw new Error(message); } +function assertInvocationSearchLimit(limit: number): void { + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 256) { + throw new RangeError('Runtime invocation search limit must be an integer between 1 and 256'); + } +} + interface RuntimeEventStorageRow { event_id: string; session_id: string; From 8e91df4c7567286c4065b8611548bb90ebde61dc Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 11:23:57 +0800 Subject: [PATCH 10/46] refactor(core): retire the AgentRunHeader as a record of the run The header was a third authority over facts the event spine already owns: route, configuration, root, lineage and terminal status. Every reconciliation path in the codebase exists because those three could disagree. The opening RuntimeEvent is now the only record of how an invocation was opened, and the terminal RuntimeEvent the only record of how it ended. `@maka/core/runtime-invocation` owns the concept; `AgentRunStore` keeps only the AgentRunEvent operational ledger, and its `core_agent_runs` row keeps only what events hang off. The header's decoder moves to `@maka/storage` beside the migration that consumes it, which is what stops it becoming a live authority again. Generated-by: Claude Code --- packages/core/package.json | 2 + .../src/__tests__/agent-run-authority.test.ts | 103 --- .../agent-run-continuation-source.test.ts | 115 --- .../agent-run-event-contract.test.ts | 2 +- .../src/__tests__/runtime-boundary.test.ts | 47 +- ...=> runtime-invocation-hosted-root.test.ts} | 2 +- packages/core/src/agent-run.ts | 654 +----------------- packages/core/src/backend-types.ts | 12 +- packages/core/src/execution-inspect.ts | 48 +- packages/core/src/runtime-boundary.ts | 106 +-- packages/core/src/runtime-event-store.ts | 52 +- packages/core/src/runtime-invocation.ts | 277 ++++++++ .../daily-review-coordinator.test.ts | 4 +- .../__tests__/usage-pricing-protocol.test.ts | 4 +- ...nt-graph-supervisor-root-admission.test.ts | 2 +- ...claimed-agent-graph-root-admission.test.ts | 2 +- .../__tests__/fixtures/invocation-opening.ts | 91 +++ .../invocation-opening-backfill.test.ts | 86 ++- .../src/__tests__/legacy-run-header.test.ts | 180 +++++ .../src/__tests__/model-call-ledger.test.ts | 21 +- .../regenerate-root-admission.test.ts | 2 +- .../sqlite-core-execution-store.test.ts | 131 +--- .../src/__tests__/usage-stores.test.ts | 4 +- ...orkhub-coordination-root-admission.test.ts | 2 +- packages/storage/src/agent-run-store.ts | 434 ++++-------- .../storage/src/execution-record-codec.ts | 33 +- packages/storage/src/execution-stores.ts | 38 +- packages/storage/src/legacy-run-header.ts | 445 ++++++++++++ .../storage/src/runtime-event-persistence.ts | 2 +- .../src/sqlite-core-execution-schema.ts | 12 +- packages/storage/src/sqlite-runtime-schema.ts | 35 +- packages/storage/src/sqlite-runtime-store.ts | 18 +- 32 files changed, 1341 insertions(+), 1625 deletions(-) delete mode 100644 packages/core/src/__tests__/agent-run-authority.test.ts delete mode 100644 packages/core/src/__tests__/agent-run-continuation-source.test.ts rename packages/core/src/__tests__/{agent-run-hosted-root.test.ts => runtime-invocation-hosted-root.test.ts} (98%) create mode 100644 packages/core/src/runtime-invocation.ts create mode 100644 packages/storage/src/__tests__/fixtures/invocation-opening.ts create mode 100644 packages/storage/src/__tests__/legacy-run-header.test.ts create mode 100644 packages/storage/src/legacy-run-header.ts diff --git a/packages/core/package.json b/packages/core/package.json index 194034d62b..a862fe2ef6 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -30,6 +30,8 @@ "./collaboration": "./dist/collaboration.js", "./orchestration": "./dist/orchestration.js", "./tool-mode": "./dist/tool-mode.js", + "./record-schema": "./dist/record-schema.js", + "./runtime-invocation": "./dist/runtime-invocation.js", "./plan": "./dist/plan.js", "./agent-run": "./dist/agent-run.js", "./subagent-workspace": "./dist/subagent-workspace.js", diff --git a/packages/core/src/__tests__/agent-run-authority.test.ts b/packages/core/src/__tests__/agent-run-authority.test.ts deleted file mode 100644 index 7a5f7de488..0000000000 --- a/packages/core/src/__tests__/agent-run-authority.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { - decodeAgentRunHeader, - decodePersistedAgentRunHeader, - type AgentRunHeader, -} from '../agent-run.js'; -import { markPersisted } from '../persisted-value.js'; - -test('rejects a Run header with multiple hosted root authorities', () => { - assert.throws( - () => - decodeAgentRunHeader({ - ...runHeader(), - scheduledTaskId: 'scheduled-task-1', - goalId: 'goal-1', - }), - /Invalid AgentRun header schema/, - ); -}); - -test('decodes a released Automation Run as read-only legacy provenance', () => { - const decoded = decodePersistedAgentRunHeader( - markPersisted({ - ...runHeader(), - automationId: 'automation-1', - }), - ); - assert.equal(decoded.legacyAutomationId, 'automation-1'); - assert.equal(Object.hasOwn(decoded, 'automationId'), false); -}); - -test('folds all retired AgentRun values only at the persistence boundary', () => { - const persisted = { - ...runHeader(), - status: 'waiting_permission', - permissionMode: 'execute', - }; - - const decoded = decodePersistedAgentRunHeader(markPersisted(persisted)); - assert.equal(decoded.status, 'waiting_for_user'); - assert.equal(decoded.permissionMode, 'ask'); - - assert.throws(() => decodeAgentRunHeader(persisted), /Invalid AgentRun header schema/); - assert.throws( - () => decodeAgentRunHeader({ ...runHeader(), automationId: 'automation-1' }), - /Invalid AgentRun header schema/, - ); - assert.throws( - () => decodeAgentRunHeader({ ...runHeader(), permissionMode: 'execute' }), - /Invalid AgentRun header schema/, - ); -}); - -test('accepts both bound and legacy AgentRun connection identity', () => { - const legacy = decodePersistedAgentRunHeader(markPersisted(runHeader())); - assert.equal(legacy.llmConnectionId, undefined); - - const bound = decodeAgentRunHeader({ - ...runHeader(), - llmConnectionId: '11111111-1111-4111-8111-111111111111', - }); - assert.equal(bound.llmConnectionId, '11111111-1111-4111-8111-111111111111'); - assert.throws( - () => decodeAgentRunHeader({ ...runHeader(), llmConnectionId: '' }), - /Invalid AgentRun header schema/, - ); -}); - -function runHeader(): AgentRunHeader { - return { - runId: 'run-1', - sessionId: 'session-1', - turnId: 'turn-1', - status: 'created', - backendKind: 'fake', - llmConnectionSlug: 'fake', - modelId: 'fake-model', - cwd: '/workspace', - permissionMode: 'ask', - createdAt: 1, - updatedAt: 1, - }; -} diff --git a/packages/core/src/__tests__/agent-run-continuation-source.test.ts b/packages/core/src/__tests__/agent-run-continuation-source.test.ts deleted file mode 100644 index b8c1eba5c2..0000000000 --- a/packages/core/src/__tests__/agent-run-continuation-source.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { decodeAgentRunHeader, type AgentRunHeader } from '../agent-run.js'; - -describe('AgentRun continuation source decoding', () => { - it('rejects an empty V2 continuation claim identity', () => { - assert.throws( - () => - decodeAgentRunHeader( - headerWithContinuation({ - ...validV2ContinuationSource(), - claimId: '', - }), - ), - /Invalid AgentRun header schema/, - ); - }); - - it('rejects a zero V2 source high-water', () => { - assert.throws( - () => - decodeAgentRunHeader( - headerWithContinuation({ - ...validV2ContinuationSource(), - sourceRuntimeEventHighWater: 0, - }), - ), - /Invalid AgentRun header schema/, - ); - }); - - for (const field of ['sourceInvocationId', 'sourceRunId', 'sourceTurnId'] as const) { - it(`rejects an empty V2 ${field}`, () => { - assert.throws( - () => - decodeAgentRunHeader( - headerWithContinuation({ - ...validV2ContinuationSource(), - [field]: '', - }), - ), - /Invalid AgentRun header schema/, - ); - }); - } - - it('rejects a V2 replay manifest that does not identify its boundary', () => { - assert.throws( - () => - decodeAgentRunHeader( - headerWithContinuation({ - ...validV2ContinuationSource(), - replayManifestDigest: `sha256:${'c'.repeat(64)}`, - }), - ), - /Invalid AgentRun header schema/, - ); - }); -}); - -function headerWithContinuation( - continuationSource: AgentRunHeader['continuationSource'], -): AgentRunHeader { - return { - runId: 'target-run', - invocationId: 'target-invocation', - sessionId: 'session-1', - turnId: 'target-turn', - status: 'created', - backendKind: 'fake', - llmConnectionSlug: 'test', - modelId: 'test-model', - cwd: '/workspace', - permissionMode: 'ask', - createdAt: 1, - updatedAt: 1, - continuationSource, - }; -} - -function validV2ContinuationSource(): Extract< - NonNullable, - { protocol: 'continuation_source_v2' } -> { - return { - protocol: 'continuation_source_v2', - claimId: 'claim-1', - boundaryDigest: `sha256:${'a'.repeat(64)}`, - sourceInvocationId: 'source-invocation', - sourceRunId: 'source-run', - sourceTurnId: 'source-turn', - sourceRuntimeEventHighWater: 1, - sourcePrefixDigest: `sha256:${'b'.repeat(64)}`, - replayManifestDigest: `sha256:${'a'.repeat(64)}`, - }; -} diff --git a/packages/core/src/__tests__/agent-run-event-contract.test.ts b/packages/core/src/__tests__/agent-run-event-contract.test.ts index 406ea7a5cf..0be3f94129 100644 --- a/packages/core/src/__tests__/agent-run-event-contract.test.ts +++ b/packages/core/src/__tests__/agent-run-event-contract.test.ts @@ -73,7 +73,7 @@ test('AgentRun closes its write contract against a type this build does not emit store.appendEvent('session-1', 'run-1', retired); assert.equal(typeof appendRetired, 'function'); - const emitted: EmittedAgentRunEvent = { ...retired, type: 'run_started' }; + const emitted: EmittedAgentRunEvent = { ...retired, type: 'turn_started' }; const appendEmitted = (store: AgentRunStore) => store.appendEvent('session-1', 'run-1', emitted); assert.equal(typeof appendEmitted, 'function'); }); diff --git a/packages/core/src/__tests__/runtime-boundary.test.ts b/packages/core/src/__tests__/runtime-boundary.test.ts index 9b728df7dd..4705864e6e 100644 --- a/packages/core/src/__tests__/runtime-boundary.test.ts +++ b/packages/core/src/__tests__/runtime-boundary.test.ts @@ -23,9 +23,8 @@ import { decodeRuntimeEvent, type RuntimeEvent } from '../runtime-event.js'; import { buildImmutableRuntimePrefix, createRuntimeBoundaryCursor, - continuationTargetRunHeader, decodeContinuationClaim, - runHeaderMatchesClaimTarget, + invocationMatchesClaimTarget, runtimePrefixSegment, type RuntimeBoundaryCursorV1, type RuntimePrefixIdentityV1, @@ -326,35 +325,27 @@ describe('immutable RuntimeEvent boundary', () => { ); }); - it('rebuilds the target Run header the claim authorises', () => { + it('recognises the invocation the claim authorises', () => { const boundary = boundaryForRuns('run-source'); const claim = decodeContinuationClaim(claimForBoundary(boundary)); - const header = continuationTargetRunHeader(claim); - const source = boundary.segments.at(-1)!; - - assert.equal(header.runId, claim.target.runId); - assert.equal(header.invocationId, claim.target.invocationId); - assert.equal(header.status, 'created'); - assert.equal(header.createdAt, claim.claimedAt); - assert.equal(header.updatedAt, claim.claimedAt); - assert.equal(header.parentRunId, source.identity.runId); - assert.deepEqual(header.continuationSource, { - protocol: 'continuation_source_v2', - claimId: claim.claimId, - boundaryDigest: claim.boundaryDigest, - sourceInvocationId: source.identity.invocationId, - sourceRunId: source.identity.runId, - sourceTurnId: source.identity.turnId, - sourceRuntimeEventHighWater: source.position.lastEventSeq, - sourcePrefixDigest: source.prefixDigest, - replayManifestDigest: boundary.manifestDigest, - }); - // The claim's opening and the header it rebuilds are one fact, not two. - assert.ok(runHeaderMatchesClaimTarget(header, claim)); - assert.ok(!runHeaderMatchesClaimTarget({ ...header, cwd: '/elsewhere' }, claim)); + const invocation = { ...claim.target, opening: claim.targetOpening }; + + assert.ok(invocationMatchesClaimTarget(invocation, claim)); + assert.ok( + !invocationMatchesClaimTarget( + { + ...invocation, + opening: { + ...invocation.opening, + configuration: { ...invocation.opening.configuration, cwd: '/elsewhere' }, + }, + }, + claim, + ), + ); assert.ok( - runHeaderMatchesClaimTarget({ ...header, status: 'running', updatedAt: 99 }, claim), - 'a running target still matches: lifecycle was never part of what the claim froze', + !invocationMatchesClaimTarget({ ...invocation, runId: 'another-run' }, claim), + 'the claim fixes the target identity as well as its opening', ); }); }); diff --git a/packages/core/src/__tests__/agent-run-hosted-root.test.ts b/packages/core/src/__tests__/runtime-invocation-hosted-root.test.ts similarity index 98% rename from packages/core/src/__tests__/agent-run-hosted-root.test.ts rename to packages/core/src/__tests__/runtime-invocation-hosted-root.test.ts index 3973fbdd09..d9a5073b31 100644 --- a/packages/core/src/__tests__/agent-run-hosted-root.test.ts +++ b/packages/core/src/__tests__/runtime-invocation-hosted-root.test.ts @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { invocationMatchesHostedRootExecution } from '../agent-run.js'; +import { invocationMatchesHostedRootExecution } from '../runtime-invocation.js'; import type { RuntimeEventInvocationOpenedContent, RuntimeInvocationLineage, diff --git a/packages/core/src/agent-run.ts b/packages/core/src/agent-run.ts index 686d65075a..cf13e687ec 100644 --- a/packages/core/src/agent-run.ts +++ b/packages/core/src/agent-run.ts @@ -17,22 +17,15 @@ * under the License. */ -import { - decodePersistedPermissionMode, - isPermissionMode, - type PermissionMode, -} from './permission.js'; -import type { PersistedValue } from './persisted-value.js'; -import { isCollaborationMode, type CollaborationMode } from './collaboration.js'; -import { - isAgentSwarmAuthorizationSource, - isEffectiveOrchestrationSource, - isOrchestrationMode, - type AgentSwarmAuthorizationSource, - type EffectiveOrchestrationSource, - type OrchestrationMode, -} from './orchestration.js'; -import type { PersistedBackendKind } from './session.js'; +/** + * The operational ledger one invocation writes beside its canonical events. + * + * These records are metering, request attempts, permission decisions and + * diagnostics: facts with an operational demand of their own. What an + * invocation *is* — its route, configuration, lineage and outcome — belongs to + * the event spine in `runtime-invocation.ts`, not here. + */ + import { defineObjectShape, hasExactShape, @@ -40,312 +33,14 @@ import { isOptionalString, isRecord, } from './record-schema.js'; -import type { AgentGraphIntentClaim } from './agent-graph-control.js'; -import { DEFAULT_TOOL_MODE, isToolMode, type ToolMode } from './tool-mode.js'; -import type { - RuntimeEventInvocationOpenedContent, - RuntimeInvocationLineage, - RuntimeInvocationOpenSource, - RuntimeInvocationRootAuthority, - RuntimeInvocationRoute, -} from './runtime-event.js'; import { decodeRunCompositionSnapshot, type RunCompositionSnapshot } from './run-composition.js'; -export const AGENT_RUN_STATUSES = [ - 'created', - 'running', - 'waiting_for_user', - 'completed', - 'failed', - 'cancelled', -] as const; - -export type AgentRunStatus = (typeof AGENT_RUN_STATUSES)[number]; - -export interface AgentRunContinuationSourceV1 { - sourceInvocationId: string; - sourceRunId: string; - sourceTurnId: string; - sourceRuntimeEventHighWater: number; -} - -export interface AgentRunContinuationSourceV2 extends AgentRunContinuationSourceV1 { - protocol: 'continuation_source_v2'; - claimId: string; - boundaryDigest: `sha256:${string}`; - sourcePrefixDigest: `sha256:${string}`; - replayManifestDigest: `sha256:${string}`; -} - -export type AgentRunContinuationSource = - | AgentRunContinuationSourceV1 - | AgentRunContinuationSourceV2; - -export type RootExecutionDescriptor = - | { - kind: 'external_message'; - inputDigest?: `sha256:${string}`; - maxSteps?: number; - } - | { - /** Tool-free conversational execution admitted only by WorkHub authority. */ - kind: 'workhub_coordination'; - inputDigest: `sha256:${string}`; - } - | { kind: 'regenerate'; sourceTurnId: string } - | { kind: 'context_compact' } - | { - kind: 'scheduled_task'; - scheduledTaskId: string; - /** Includes the immutable Connection target for Agent ScheduledTasks. */ - executionFingerprint?: `sha256:${string}`; - } - | { kind: 'legacy_automation'; automationId: string } - | { kind: 'goal'; goalId: string } - | { - kind: 'agent_graph_supervisor_wake'; - graphId: string; - wakeId: string; - attemptId: string; - } - | { - kind: 'safe_boundary_continuation'; - sourceInvocationId: string; - sourceRunId: string; - sourceTurnId: string; - sourceRuntimeEventHighWater: number; - claimId: string; - boundaryDigest: `sha256:${string}`; - providerReplayDigest: `sha256:${string}`; - safetyDigest: `sha256:${string}`; - targetInvocationId: string; - } - | { - kind: 'linked_child_initial'; - agentId: string; - agentName: string; - } - | { - kind: 'linked_child_resume'; - agentId: string; - agentName: string; - sourceRunId: string; - } - | { - kind: 'linked_child_provider_retry'; - agentId: string; - agentName: string; - sourceRunId: string; - } - | { - kind: 'claimed_agent_graph_intent'; - claim: AgentGraphIntentClaim; - agentId: string; - agentName: string; - }; - -const AGENT_RUN_CONTINUATION_SOURCE_V1_SHAPE = defineObjectShape()( - ['sourceInvocationId', 'sourceRunId', 'sourceTurnId', 'sourceRuntimeEventHighWater'], - [], -); -const AGENT_RUN_CONTINUATION_SOURCE_V2_SHAPE = defineObjectShape()( - [ - 'protocol', - 'claimId', - 'boundaryDigest', - 'sourceInvocationId', - 'sourceRunId', - 'sourceTurnId', - 'sourceRuntimeEventHighWater', - 'sourcePrefixDigest', - 'replayManifestDigest', - ], - [], -); - -export interface AgentRunHeader { - runId: string; - /** Durable Runtime invocation spine. Optional only for legacy run headers. */ - invocationId?: string; - sessionId: string; - turnId: string; - status: AgentRunStatus; - backendKind: PersistedBackendKind; - /** Immutable Connection entity identity. Optional only on legacy run headers. */ - llmConnectionId?: string; - /** - * Opaque identity of the provider endpoint and credential ownership frozen - * before this run's first provider dispatch. Optional only on legacy or - * non-provider run headers. - */ - providerStateIdentity?: `sha256:${string}`; - llmConnectionSlug: string; - modelId: string; - cwd: string; - /** Authoritative host identity for the workspace observed when the run was created. */ - workspaceIdentity?: string; - permissionMode: PermissionMode; - /** Snapshot of the session collaboration mode. Optional on legacy runs. */ - collaborationMode?: CollaborationMode; - /** Effective orchestration mode for this run. Optional on legacy runs. */ - orchestrationMode?: OrchestrationMode; - /** Whether the effective mode came from the session or this turn. */ - orchestrationSource?: EffectiveOrchestrationSource; - /** Narrow authority for the parent agent_swarm envelope. */ - agentSwarmAuthorization?: AgentSwarmAuthorizationSource; - /** Effective tool protocol for this run. Optional on legacy runs. */ - toolMode?: ToolMode; - createdAt: number; - updatedAt: number; - completedAt?: number; - parentRunId?: string; - /** Immediate child AgentRun continued by this run. */ - resumedFromRunId?: string; - /** Immediate child AgentRun whose provider step is retried by this run. */ - retriedFromRunId?: string; - agentId?: string; - agentName?: string; - parentTurnId?: string; - retriedFromTurnId?: string; - regeneratedFromTurnId?: string; - branchOfTurnId?: string; - parentSessionId?: string; - /** Durable claim that this run is the continuation child for one source boundary. */ - continuationSource?: AgentRunContinuationSource; - /** ScheduledTask that triggered this host-authored Run. */ - scheduledTaskId?: string; - /** Removed Automation authority that triggered this historical Run. */ - legacyAutomationId?: string; - /** Host-owned Goal generation that triggered this continuation Run. */ - goalId?: string; - /** Durable graph milestone that caused this host-authored supervisor turn. */ - agentGraphWakeId?: string; - /** Durable delivery attempt for this host-authored supervisor turn. */ - agentGraphWakeAttemptId?: string; - /** Positive identity for a host-authored root that has no message lineage. */ - rootExecutionKind?: 'context_compact'; - failureClass?: string; - failureMessage?: string; - abortSource?: string; - traceWriteError?: string; -} - -type HostedRootExecutionDescriptor = Extract< - RootExecutionDescriptor, - { - kind: - | 'regenerate' - | 'context_compact' - | 'scheduled_task' - | 'legacy_automation' - | 'goal' - | 'agent_graph_supervisor_wake' - | 'safe_boundary_continuation'; - } ->; - -/** - * Is this invocation the one the Host admitted for that root execution? - * - * The opening fact names its root as a closed union, so each arm names the root - * it wants instead of asserting that every other root marker is absent. What - * remains is lineage, and the rule there is exactness: an admitted root has the - * lineage its kind implies and no other, so one comparison replaces a list of - * per-field negatives that had to be extended every time a lineage field was - * added. - */ -export function invocationMatchesHostedRootExecution( - invocation: { invocationId: string; opening: RuntimeEventInvocationOpenedContent }, - execution: HostedRootExecutionDescriptor, -): boolean { - const { root, source, configuration, lineage } = invocation.opening; - switch (execution.kind) { - case 'regenerate': - return ( - root.kind === 'user' && - source.kind === 'fresh' && - lineageIsExactly(lineage, { - parentTurnId: execution.sourceTurnId, - regeneratedFromTurnId: execution.sourceTurnId, - }) - ); - case 'context_compact': - return ( - root.kind === 'context_compact' && source.kind === 'fresh' && lineageIsExactly(lineage, {}) - ); - case 'safe_boundary_continuation': - return ( - root.kind === 'user' && - source.kind === 'continuation' && - invocation.invocationId === execution.targetInvocationId && - source.sourceInvocationId === execution.sourceInvocationId && - source.sourceRunId === execution.sourceRunId && - source.sourceTurnId === execution.sourceTurnId && - source.sourceRuntimeEventHighWater === execution.sourceRuntimeEventHighWater && - source.claimId === execution.claimId && - source.boundaryDigest === execution.boundaryDigest && - lineageIsExactly(lineage, { - parentRunId: execution.sourceRunId, - parentTurnId: execution.sourceTurnId, - }) - ); - case 'scheduled_task': - return ( - root.kind === 'scheduled_task' && - root.scheduledTaskId === execution.scheduledTaskId && - source.kind === 'fresh' && - lineageIsExactly(lineage, {}) - ); - case 'legacy_automation': - return ( - root.kind === 'legacy_automation' && - root.legacyAutomationId === execution.automationId && - source.kind === 'fresh' && - lineageIsExactly(lineage, {}) - ); - case 'goal': - return ( - root.kind === 'goal' && - root.goalId === execution.goalId && - source.kind === 'fresh' && - lineageIsExactly(lineage, {}) - ); - case 'agent_graph_supervisor_wake': - return ( - root.kind === 'agent_graph_supervisor_wake' && - execution.wakeId.startsWith(`${execution.graphId}:`) && - root.wakeId === execution.wakeId && - root.attemptId === execution.attemptId && - configuration.orchestrationMode === 'graph' && - configuration.orchestrationSource === 'turn_override' && - configuration.agentSwarmAuthorization === 'none' && - source.kind === 'fresh' && - lineageIsExactly(lineage, {}) - ); - } -} - -/** An admitted root has the lineage its kind implies, and no other edge. */ -function lineageIsExactly( - lineage: RuntimeInvocationLineage | undefined, - expected: RuntimeInvocationLineage, -): boolean { - const actual = (lineage ?? {}) as Record; - const wanted = expected as Record; - const keys = Object.keys(wanted); - return ( - Object.keys(actual).length === keys.length && keys.every((key) => actual[key] === wanted[key]) - ); -} - export interface AgentRunInputSummary { textLength: number; attachmentCount: number; } export const AGENT_RUN_EVENT_TYPES = [ - 'run_created', - 'run_started', 'turn_started', 'plan_context_resolved', 'plan_submitted', @@ -357,7 +52,6 @@ export const AGENT_RUN_EVENT_TYPES = [ 'plan_execution_resumed', 'plan_transition_failed', 'graph_supervisor_yielded', - 'run_status_changed', 'model_resolved', 'model_resolve_failed', 'model_stream_started', @@ -390,9 +84,6 @@ export const AGENT_RUN_EVENT_TYPES = [ 'model_projection_transition_recorded', 'run_composition_recorded', 'abort_requested', - 'run_completed', - 'run_failed', - 'run_cancelled', 'trace_write_failed', 'event_corrupt', ] as const; @@ -519,153 +210,11 @@ export function isEmittedAgentRunEventType(type: string): type is AgentRunEventT return EMITTED_AGENT_RUN_EVENT_TYPES.has(type); } -const AGENT_RUN_HEADER_SHAPE = defineObjectShape()( - [ - 'runId', - 'sessionId', - 'turnId', - 'status', - 'backendKind', - 'llmConnectionSlug', - 'modelId', - 'cwd', - 'permissionMode', - 'createdAt', - 'updatedAt', - ], - [ - 'invocationId', - 'llmConnectionId', - 'providerStateIdentity', - 'completedAt', - 'parentRunId', - 'resumedFromRunId', - 'retriedFromRunId', - 'agentId', - 'agentName', - 'parentTurnId', - 'retriedFromTurnId', - 'regeneratedFromTurnId', - 'branchOfTurnId', - 'parentSessionId', - 'workspaceIdentity', - 'continuationSource', - 'scheduledTaskId', - 'legacyAutomationId', - 'goalId', - 'agentGraphWakeId', - 'agentGraphWakeAttemptId', - 'rootExecutionKind', - 'failureClass', - 'failureMessage', - 'abortSource', - 'traceWriteError', - 'collaborationMode', - 'orchestrationMode', - 'orchestrationSource', - 'agentSwarmAuthorization', - 'toolMode', - ], -); - const AGENT_RUN_EVENT_SHAPE = defineObjectShape()( ['type', 'id', 'runId', 'sessionId', 'turnId', 'ts'], ['message', 'data'], ); -const RETIRED_AGENT_RUN_STATUSES: Readonly> = { - waiting_permission: 'waiting_for_user', -}; - -export function decodePersistedAgentRunHeader( - persisted: PersistedValue, -): AgentRunHeader { - let value = persisted as unknown; - if ( - isRecord(value) && - value.automationId !== undefined && - value.legacyAutomationId === undefined - ) { - const { automationId, ...current } = value; - value = { ...current, legacyAutomationId: automationId }; - } - if (isRecord(value)) { - const status = - typeof value.status === 'string' - ? (RETIRED_AGENT_RUN_STATUSES[value.status] ?? value.status) - : value.status; - const permissionMode = decodePersistedPermissionMode(value.permissionMode); - if (status !== value.status || permissionMode !== value.permissionMode) { - value = { ...value, status, permissionMode }; - } - } - return decodeAgentRunHeader(value); -} - -export function decodeAgentRunHeader(value: unknown): AgentRunHeader { - if (!isRecord(value) || !hasExactShape(value, AGENT_RUN_HEADER_SHAPE)) { - throw new Error('Invalid AgentRun header schema'); - } - const valid = - typeof value.runId === 'string' && - typeof value.sessionId === 'string' && - typeof value.turnId === 'string' && - (AGENT_RUN_STATUSES as readonly unknown[]).includes(value.status) && - isPersistedBackendKind(value.backendKind) && - (value.llmConnectionId === undefined || - (typeof value.llmConnectionId === 'string' && value.llmConnectionId.length > 0)) && - (value.providerStateIdentity === undefined || - (typeof value.providerStateIdentity === 'string' && - /^sha256:[0-9a-f]{64}$/.test(value.providerStateIdentity))) && - typeof value.llmConnectionSlug === 'string' && - typeof value.modelId === 'string' && - typeof value.cwd === 'string' && - isPermissionMode(value.permissionMode) && - (value.collaborationMode === undefined || isCollaborationMode(value.collaborationMode)) && - (value.orchestrationMode === undefined || isOrchestrationMode(value.orchestrationMode)) && - (value.orchestrationSource === undefined || - isEffectiveOrchestrationSource(value.orchestrationSource)) && - (value.agentSwarmAuthorization === undefined || - isAgentSwarmAuthorizationSource(value.agentSwarmAuthorization)) && - (value.rootExecutionKind === undefined || value.rootExecutionKind === 'context_compact') && - Number(value.scheduledTaskId !== undefined) + - Number(value.legacyAutomationId !== undefined) + - Number(value.goalId !== undefined) + - Number(value.agentGraphWakeId !== undefined) <= - 1 && - (value.toolMode === undefined || isToolMode(value.toolMode)) && - isFiniteNumber(value.createdAt) && - isFiniteNumber(value.updatedAt) && - isOptionalString(value.invocationId) && - (value.completedAt === undefined || isFiniteNumber(value.completedAt)) && - [ - value.parentRunId, - value.resumedFromRunId, - value.retriedFromRunId, - value.agentId, - value.agentName, - value.parentTurnId, - value.retriedFromTurnId, - value.regeneratedFromTurnId, - value.branchOfTurnId, - value.parentSessionId, - value.workspaceIdentity, - value.scheduledTaskId, - value.legacyAutomationId, - value.goalId, - value.agentGraphWakeId, - value.agentGraphWakeAttemptId, - value.failureClass, - value.failureMessage, - value.abortSource, - value.traceWriteError, - ].every(isOptionalString) && - (value.continuationSource === undefined || - isAgentRunContinuationSource(value.continuationSource)); - if (!valid) throw new Error('Invalid AgentRun header schema'); - return value as unknown as AgentRunHeader; -} - export const RUN_COMPOSITION_RECORDED_EVENT_TYPE = 'run_composition_recorded' as const; /** @@ -685,41 +234,6 @@ export function agentRunCompositionFromEvents( return undefined; } -function isAgentRunContinuationSource(value: unknown): value is AgentRunContinuationSource { - if (!isRecord(value)) return false; - const common = - typeof value.sourceInvocationId === 'string' && - typeof value.sourceRunId === 'string' && - typeof value.sourceTurnId === 'string' && - typeof value.sourceRuntimeEventHighWater === 'number' && - Number.isSafeInteger(value.sourceRuntimeEventHighWater) && - value.sourceRuntimeEventHighWater >= 0; - if (!common) return false; - if (hasExactShape(value, AGENT_RUN_CONTINUATION_SOURCE_V1_SHAPE)) return true; - return ( - hasExactShape(value, AGENT_RUN_CONTINUATION_SOURCE_V2_SHAPE) && - value.protocol === 'continuation_source_v2' && - typeof value.claimId === 'string' && - value.claimId.length > 0 && - typeof value.sourceInvocationId === 'string' && - value.sourceInvocationId.length > 0 && - typeof value.sourceRunId === 'string' && - value.sourceRunId.length > 0 && - typeof value.sourceTurnId === 'string' && - value.sourceTurnId.length > 0 && - typeof value.sourceRuntimeEventHighWater === 'number' && - value.sourceRuntimeEventHighWater > 0 && - isSha256Digest(value.boundaryDigest) && - isSha256Digest(value.sourcePrefixDigest) && - isSha256Digest(value.replayManifestDigest) && - value.replayManifestDigest === value.boundaryDigest - ); -} - -function isSha256Digest(value: unknown): value is `sha256:${string}` { - return typeof value === 'string' && /^sha256:[0-9a-f]{64}$/.test(value); -} - export function decodeAgentRunEvent(value: unknown): AgentRunEvent { if ( !isRecord(value) || @@ -739,24 +253,7 @@ export function decodeAgentRunEvent(value: unknown): AgentRunEvent { return value as unknown as AgentRunEvent; } -/** - * Decode guard for a durable run header. `'fake'` stays accepted: runs written - * by builds that shipped FakeBackend must keep decoding (#3211). - */ -function isPersistedBackendKind(value: unknown): value is PersistedBackendKind { - return value === 'ai-sdk' || value === 'fake'; -} - export interface AgentRunStore { - createRun(header: AgentRunHeader, options?: { durable?: boolean }): Promise; - updateRun( - sessionId: string, - runId: string, - patch: Partial, - options?: { durable?: boolean }, - ): Promise; - readRun(sessionId: string, runId: string): Promise; - listSessionRuns(sessionId: string): Promise; appendEvent( sessionId: string, runId: string, @@ -784,136 +281,3 @@ export interface AgentRunStore { options: { ifLedgerRevision: string; replaceEventId?: string }, ): Promise; } - -/** - * Whether a run contributes directly to the owning session's transcript. - * Top-level continuations carry parent lineage for recovery, but unlike - * child-agent runs their output remains part of the parent session - * conversation. A legacy child retry may also carry continuation authority; - * its agent identity keeps it outside the owning session transcript. - */ -export function isSessionInlineRun(run: { - readonly parentRunId?: string; - readonly continuationSource?: unknown; - readonly agentId?: string; -}): boolean { - return ( - run.parentRunId === undefined || - (run.continuationSource !== undefined && run.agentId === undefined) - ); -} - -/** - * Project one Run header onto its invocation opening fact. - * - * This is the single mapping from the old authority to the new one: the live - * writer and the storage backfill both go through it, so a header field can - * never be classified two different ways. - * - * Route provenance fails closed. A header with no Connection identity cannot - * prove which endpoint and credential owned the run, so it projects as - * `unknown` rather than as an authenticated route; its transcript and tool - * evidence stay readable either way. - * - * Throws when a root authority marker is present but incomplete — that is - * corruption, and inventing a root would be worse than refusing one. - */ -export function runtimeInvocationOpeningFromRunHeader( - header: AgentRunHeader, -): RuntimeEventInvocationOpenedContent { - const lineage: RuntimeInvocationLineage = { - ...(header.parentRunId !== undefined ? { parentRunId: header.parentRunId } : {}), - ...(header.resumedFromRunId !== undefined ? { resumedFromRunId: header.resumedFromRunId } : {}), - ...(header.retriedFromRunId !== undefined ? { retriedFromRunId: header.retriedFromRunId } : {}), - ...(header.parentTurnId !== undefined ? { parentTurnId: header.parentTurnId } : {}), - ...(header.parentSessionId !== undefined ? { parentSessionId: header.parentSessionId } : {}), - ...(header.retriedFromTurnId !== undefined - ? { retriedFromTurnId: header.retriedFromTurnId } - : {}), - ...(header.regeneratedFromTurnId !== undefined - ? { regeneratedFromTurnId: header.regeneratedFromTurnId } - : {}), - ...(header.branchOfTurnId !== undefined ? { branchOfTurnId: header.branchOfTurnId } : {}), - ...(header.agentId !== undefined ? { agentId: header.agentId } : {}), - ...(header.agentName !== undefined ? { agentName: header.agentName } : {}), - }; - return { - kind: 'invocation_opened', - protocol: 'invocation_opened_v1', - route: invocationRouteFromRunHeader(header), - configuration: { - cwd: header.cwd, - permissionMode: header.permissionMode, - collaborationMode: header.collaborationMode ?? 'agent', - orchestrationMode: header.orchestrationMode ?? 'default', - orchestrationSource: header.orchestrationSource ?? 'session', - toolMode: header.toolMode ?? DEFAULT_TOOL_MODE, - ...(header.agentSwarmAuthorization !== undefined - ? { agentSwarmAuthorization: header.agentSwarmAuthorization } - : {}), - ...(header.workspaceIdentity !== undefined - ? { workspaceIdentity: header.workspaceIdentity } - : {}), - }, - root: invocationRootFromRunHeader(header), - source: invocationOpenSourceFromRunHeader(header), - ...(Object.keys(lineage).length > 0 ? { lineage } : {}), - }; -} - -function invocationRouteFromRunHeader(header: AgentRunHeader): RuntimeInvocationRoute { - if (header.llmConnectionId === undefined) { - return { - provenance: 'unknown', - backendKind: header.backendKind, - llmConnectionSlug: header.llmConnectionSlug, - modelId: header.modelId, - }; - } - return { - provenance: 'runtime', - backendKind: header.backendKind, - llmConnectionId: header.llmConnectionId, - llmConnectionSlug: header.llmConnectionSlug, - modelId: header.modelId, - ...(header.providerStateIdentity !== undefined - ? { providerStateIdentity: header.providerStateIdentity } - : {}), - }; -} - -function invocationRootFromRunHeader(header: AgentRunHeader): RuntimeInvocationRootAuthority { - if (header.scheduledTaskId !== undefined) { - return { kind: 'scheduled_task', scheduledTaskId: header.scheduledTaskId }; - } - if (header.goalId !== undefined) return { kind: 'goal', goalId: header.goalId }; - if (header.legacyAutomationId !== undefined) { - return { kind: 'legacy_automation', legacyAutomationId: header.legacyAutomationId }; - } - if (header.agentGraphWakeId !== undefined) { - if (header.agentGraphWakeAttemptId === undefined) { - throw new Error(`AgentRun ${header.runId} has a graph wake with no delivery attempt`); - } - return { - kind: 'agent_graph_supervisor_wake', - wakeId: header.agentGraphWakeId, - attemptId: header.agentGraphWakeAttemptId, - }; - } - if (header.rootExecutionKind === 'context_compact') return { kind: 'context_compact' }; - return { kind: 'user' }; -} - -function invocationOpenSourceFromRunHeader(header: AgentRunHeader): RuntimeInvocationOpenSource { - const source = header.continuationSource; - if (!source) return { kind: 'fresh' }; - const v2 = 'protocol' in source ? source : undefined; - return { - kind: 'continuation', - sourceInvocationId: source.sourceInvocationId, - sourceRunId: source.sourceRunId, - sourceTurnId: source.sourceTurnId, - sourceRuntimeEventHighWater: source.sourceRuntimeEventHighWater, - ...(v2 ? { claimId: v2.claimId, boundaryDigest: v2.boundaryDigest } : {}), - }; -} diff --git a/packages/core/src/backend-types.ts b/packages/core/src/backend-types.ts index 7b9d45854f..ef98240d7e 100644 --- a/packages/core/src/backend-types.ts +++ b/packages/core/src/backend-types.ts @@ -42,7 +42,7 @@ import type { InteractionClosureReason, InteractionFormResult } from './interact import type { RuntimeEvent } from './runtime-event.js'; import type { SandboxBoundaryResponse, SandboxBoundarySettlement } from './sandbox-boundary.js'; import type { StoredMessage, PersistedBackendKind } from './session.js'; -import type { AgentRunHeader } from './agent-run.js'; +import type { RuntimeInvocationRecord } from './runtime-invocation.js'; import type { UserQuestionResponse } from './user-question.js'; import type { ContextBudgetDiagnostic } from './usage-stats/types.js'; import type { EffectiveOrchestration } from './orchestration.js'; @@ -94,11 +94,11 @@ export interface BackendSendInput { */ runtimeContext?: RuntimeEvent[]; /** - * Existing durable run headers for `runtimeContext`, used only to verify + * The invocations `runtimeContext` came from, used only to verify * provider-owned replay against the current model route. RuntimeEvents stay - * the transcript authority; route provenance remains owned by AgentRun. + * the transcript authority; route provenance is read off each opening fact. */ - runtimeContextRunHeaders?: readonly AgentRunHeader[]; + runtimeContextInvocations?: readonly RuntimeInvocationRecord[]; /** Continue from an already committed RuntimeEvent boundary without adding another user turn. */ continuation?: RuntimeContinuationMetadata; /** @@ -189,8 +189,8 @@ export interface BackendCompactHistoryInput { */ runId: string; runtimeContext: readonly RuntimeEvent[]; - /** Source-run route authority for provider-owned history projected into the compaction call. */ - runtimeContextRunHeaders?: readonly AgentRunHeader[]; + /** Source-invocation route authority for provider-owned history projected into the compaction call. */ + runtimeContextInvocations?: readonly RuntimeInvocationRecord[]; } export interface BackendCompactHistoryResult { diff --git a/packages/core/src/execution-inspect.ts b/packages/core/src/execution-inspect.ts index 5022e1b23e..f6ded9d36f 100644 --- a/packages/core/src/execution-inspect.ts +++ b/packages/core/src/execution-inspect.ts @@ -17,7 +17,6 @@ * under the License. */ -import { AGENT_RUN_STATUSES, type AgentRunHeader } from './agent-run.js'; import { EXECUTION_LOG_LEDGERS, type ExecutionLogCoverage } from './execution-log-coverage.js'; import { SESSION_STATUSES, type SessionHeader } from './session.js'; @@ -36,20 +35,22 @@ export interface ExecutionInspectDiagnostic { eventId?: string; } +export const AGENT_RUN_INSPECT_STATUSES = ['running', 'completed', 'failed', 'cancelled'] as const; + export interface AgentRunInspectIdentity { sessionId: string; agentRunId: string; - invocationId?: string; + invocationId: string; turnId: string; parentRunId?: string; resumedFromRunId?: string; retriedFromRunId?: string; parentTurnId?: string; agentId?: string; - status: AgentRunHeader['status']; - createdAt: number; - updatedAt: number; - completedAt?: number; + /** Derived from the terminal RuntimeEvent; `running` means there is none yet. */ + status: (typeof AGENT_RUN_INSPECT_STATUSES)[number]; + openedAt: number; + endedAt?: number; failureClass?: string; abortSource?: string; } @@ -80,8 +81,6 @@ export interface AgentRunInspectCompactionCheckpoint { export interface AgentRunInspectSourceHealth { runtimeLedger: 'present' | 'missing' | 'read_failed'; runtimeTerminalPresent: boolean; - operationalTerminalPresent: boolean; - statusConsistency: 'consistent' | 'inconsistent' | 'incomplete'; } export interface AgentRunInspectDocument { @@ -194,28 +193,28 @@ function isAgentRunIdentity(value: unknown): value is AgentRunInspectIdentity { return ( hasShape( value, - ['sessionId', 'agentRunId', 'turnId', 'status', 'createdAt', 'updatedAt'], + ['sessionId', 'agentRunId', 'invocationId', 'turnId', 'status', 'openedAt'], [ - 'invocationId', 'parentRunId', 'resumedFromRunId', 'retriedFromRunId', 'parentTurnId', 'agentId', - 'completedAt', + 'endedAt', 'failureClass', 'abortSource', ], ) && isString(value.sessionId) && isString(value.agentRunId) && + isString(value.invocationId) && isString(value.turnId) && - AGENT_RUN_STATUSES.includes(value.status as (typeof AGENT_RUN_STATUSES)[number]) && - isCount(value.createdAt) && - isCount(value.updatedAt) && - isOptionalCount(value.completedAt) && + AGENT_RUN_INSPECT_STATUSES.includes( + value.status as (typeof AGENT_RUN_INSPECT_STATUSES)[number], + ) && + isCount(value.openedAt) && + isOptionalCount(value.endedAt) && [ - value.invocationId, value.parentRunId, value.resumedFromRunId, value.retriedFromRunId, @@ -237,24 +236,11 @@ function isAgentRunSources(value: unknown): boolean { isCount(value.operationalEventCount) && isCount(value.runtimeEventCount) && (value.runtimeCoverage === undefined || isCoverage(value.runtimeCoverage)) && - hasShape( - value.health, - [ - 'runtimeLedger', - 'runtimeTerminalPresent', - 'operationalTerminalPresent', - 'statusConsistency', - ], - [], - ) && + hasShape(value.health, ['runtimeLedger', 'runtimeTerminalPresent'], []) && (value.health.runtimeLedger === 'present' || value.health.runtimeLedger === 'missing' || value.health.runtimeLedger === 'read_failed') && - typeof value.health.runtimeTerminalPresent === 'boolean' && - typeof value.health.operationalTerminalPresent === 'boolean' && - (value.health.statusConsistency === 'consistent' || - value.health.statusConsistency === 'inconsistent' || - value.health.statusConsistency === 'incomplete') + typeof value.health.runtimeTerminalPresent === 'boolean' ); } diff --git a/packages/core/src/runtime-boundary.ts b/packages/core/src/runtime-boundary.ts index 4e880eed38..8d9ff1ae68 100644 --- a/packages/core/src/runtime-boundary.ts +++ b/packages/core/src/runtime-boundary.ts @@ -19,7 +19,6 @@ import * as nodeCrypto from 'node:crypto'; import type { Hash } from 'node:crypto'; -import { runtimeInvocationOpeningFromRunHeader, type AgentRunHeader } from './agent-run.js'; import { encodeCanonicalRuntimeEvent } from './canonical-runtime-event.js'; import { isRecord } from './record-schema.js'; import { decodeRuntimeInvocationOpened, TOOL_BOUNDARY_PROTOCOL_V1 } from './runtime-event.js'; @@ -311,98 +310,29 @@ export function decodeContinuationClaim(value: unknown): ContinuationClaimV1 { } /** - * The target's pre-provider Run header, rebuilt from the claim. + * Is this the invocation the claim opened? * - * A continuation target exists because of its claim and nothing else. Its - * identity is the claim's target, its clock is `claimedAt`, it has not started, - * and its continuation lineage is a restatement of the claim's own boundary. So - * every field here is read off the claim, and the header the claim used to carry - * was never independent evidence of anything. + * The claim froze the target's opening, so the check is that the invocation + * still carries it, plus the identity the claim fixed. There is nothing else to + * compare: an invocation's lifecycle lives in its events, not in a record that + * a frozen copy could go stale against. */ -export function continuationTargetRunHeader(claim: ContinuationClaimV1): AgentRunHeader { - const opening = claim.targetOpening; - const source = claim.boundary.segments.at(-1)!; - const { route, configuration, lineage } = opening; - return { - runId: claim.target.runId, - invocationId: claim.target.invocationId, - sessionId: claim.target.sessionId, - turnId: claim.target.turnId, - status: 'created', - backendKind: route.backendKind, - ...(route.provenance === 'runtime' ? { llmConnectionId: route.llmConnectionId } : {}), - ...(route.provenance === 'runtime' && route.providerStateIdentity !== undefined - ? { providerStateIdentity: route.providerStateIdentity } - : {}), - llmConnectionSlug: route.llmConnectionSlug, - modelId: route.modelId, - cwd: configuration.cwd, - ...(configuration.workspaceIdentity !== undefined - ? { workspaceIdentity: configuration.workspaceIdentity } - : {}), - permissionMode: configuration.permissionMode, - collaborationMode: configuration.collaborationMode, - orchestrationMode: configuration.orchestrationMode, - orchestrationSource: configuration.orchestrationSource, - ...(configuration.agentSwarmAuthorization !== undefined - ? { agentSwarmAuthorization: configuration.agentSwarmAuthorization } - : {}), - toolMode: configuration.toolMode, - createdAt: claim.claimedAt, - updatedAt: claim.claimedAt, - parentRunId: source.identity.runId, - ...(lineage?.resumedFromRunId !== undefined - ? { resumedFromRunId: lineage.resumedFromRunId } - : {}), - ...(lineage?.retriedFromRunId !== undefined - ? { retriedFromRunId: lineage.retriedFromRunId } - : {}), - ...(lineage?.parentTurnId !== undefined ? { parentTurnId: lineage.parentTurnId } : {}), - ...(lineage?.retriedFromTurnId !== undefined - ? { retriedFromTurnId: lineage.retriedFromTurnId } - : {}), - ...(lineage?.regeneratedFromTurnId !== undefined - ? { regeneratedFromTurnId: lineage.regeneratedFromTurnId } - : {}), - ...(lineage?.branchOfTurnId !== undefined ? { branchOfTurnId: lineage.branchOfTurnId } : {}), - ...(lineage?.parentSessionId !== undefined ? { parentSessionId: lineage.parentSessionId } : {}), - ...(lineage?.agentId !== undefined ? { agentId: lineage.agentId } : {}), - ...(lineage?.agentName !== undefined ? { agentName: lineage.agentName } : {}), - continuationSource: { - protocol: 'continuation_source_v2', - claimId: claim.claimId, - boundaryDigest: claim.boundaryDigest, - sourceInvocationId: source.identity.invocationId, - sourceRunId: source.identity.runId, - sourceTurnId: source.identity.turnId, - sourceRuntimeEventHighWater: source.position.lastEventSeq, - sourcePrefixDigest: source.prefixDigest, - replayManifestDigest: claim.boundary.manifestDigest, - }, - }; -} - -/** - * Is this the Run the claim opened? - * - * The claim froze the target's opening, so the check is that the run still - * projects to it, plus the identity and clock the claim fixed. Lifecycle fields - * are deliberately out of scope: the run's status and timestamps move as it - * executes, and comparing them against a frozen copy only ever detected the - * copy going stale. - */ -export function runHeaderMatchesClaimTarget( - run: AgentRunHeader, +export function invocationMatchesClaimTarget( + invocation: { + sessionId: string; + invocationId: string; + runId: string; + turnId: string; + opening: RuntimeEventInvocationOpenedContent; + }, claim: ContinuationClaimV1, ): boolean { return ( - run.sessionId === claim.target.sessionId && - run.invocationId === claim.target.invocationId && - run.runId === claim.target.runId && - run.turnId === claim.target.turnId && - run.createdAt === claim.claimedAt && - stableJsonStringify(runtimeInvocationOpeningFromRunHeader(run)) === - stableJsonStringify(claim.targetOpening) + invocation.sessionId === claim.target.sessionId && + invocation.invocationId === claim.target.invocationId && + invocation.runId === claim.target.runId && + invocation.turnId === claim.target.turnId && + stableJsonStringify(invocation.opening) === stableJsonStringify(claim.targetOpening) ); } diff --git a/packages/core/src/runtime-event-store.ts b/packages/core/src/runtime-event-store.ts index d60086115a..a96011acb2 100644 --- a/packages/core/src/runtime-event-store.ts +++ b/packages/core/src/runtime-event-store.ts @@ -17,7 +17,8 @@ * under the License. */ -import type { RuntimeEvent, RuntimeEventInvocationOpenedContent } from './runtime-event.js'; +import type { RuntimeEvent } from './runtime-event.js'; +import type { RuntimeInvocationRecord } from './runtime-invocation.js'; import type { ContinuationClaimV1, ImmutableRuntimePrefixV1, @@ -69,53 +70,16 @@ export class DurableStoreWriteError extends Error { } } -/** - * One invocation as the event spine itself describes it: its opening fact and, - * once it has ended, its terminal event. - * - * This is a query, not a table. Nothing writes it and nothing repairs it, so - * clearing any physical index and rebuilding from the events produces the same - * inventory. Reserved control-plane invocation streams have no opening fact and - * therefore never appear here. - */ -export interface RuntimeInvocationRecord { - sessionId: string; - invocationId: string; - runId: string; - turnId: string; - /** Timestamp of the opening fact's own event. */ - openedAt: number; - opening: RuntimeEventInvocationOpenedContent; - terminalEvent?: RuntimeEvent; -} - -/** One invocation's position in a Session's opening order. */ -export interface RuntimeInvocationPageCursor { - readonly openedAt: number; - readonly invocationId: string; -} - -export interface RuntimeInvocationPageInput { - readonly before?: RuntimeInvocationPageCursor; - readonly limit: number; -} - -export interface RuntimeInvocationPageResult { - readonly invocations: readonly RuntimeInvocationRecord[]; - readonly nextCursor: RuntimeInvocationPageCursor | null; -} - -export interface RuntimeInvocationSearchResult { - readonly invocations: readonly RuntimeInvocationRecord[]; - readonly truncated: boolean; -} - export interface RuntimeEventStore { /** Canonical stores fail the active run closed on every durable write error. */ readonly durability?: 'best_effort' | 'canonical'; /** - * Enumerate a Session's invocations from the canonical events. Optional only - * while the Run header is still the enumeration authority consumers read. + * Enumerate a Session's invocations from the canonical events. + * + * This is a query, not a table. Nothing writes it and nothing repairs it, so + * clearing any physical index and rebuilding from the events produces the + * same inventory. Reserved control-plane invocation streams have no opening + * fact and therefore never appear here. */ listSessionInvocations?(sessionId: string): Promise; appendRuntimeEvent( diff --git a/packages/core/src/runtime-invocation.ts b/packages/core/src/runtime-invocation.ts new file mode 100644 index 0000000000..2856ed63a0 --- /dev/null +++ b/packages/core/src/runtime-invocation.ts @@ -0,0 +1,277 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * One physical execution attempt, as the event spine describes it. + * + * An invocation is an opening fact, the events that follow it, and — once it + * has ended — a terminal event. Everything a reader used to take from a mutable + * Run header is a projection of those three, so there is nothing here that a + * writer could set independently of the events. + */ + +import type { AgentGraphIntentClaim } from './agent-graph-control.js'; +import type { + RuntimeEvent, + RuntimeEventInvocationOpenedContent, + RuntimeInvocationLineage, +} from './runtime-event.js'; + +export interface RuntimeInvocationRecord { + sessionId: string; + invocationId: string; + runId: string; + turnId: string; + /** Timestamp of the opening fact's own event. */ + openedAt: number; + opening: RuntimeEventInvocationOpenedContent; + terminalEvent?: RuntimeEvent; +} + +/** One invocation's position in a Session's opening order. */ +export interface RuntimeInvocationPageCursor { + readonly openedAt: number; + readonly invocationId: string; +} + +export interface RuntimeInvocationPageInput { + readonly before?: RuntimeInvocationPageCursor; + readonly limit: number; +} + +export interface RuntimeInvocationPageResult { + readonly invocations: readonly RuntimeInvocationRecord[]; + readonly nextCursor: RuntimeInvocationPageCursor | null; +} + +export interface RuntimeInvocationSearchResult { + readonly invocations: readonly RuntimeInvocationRecord[]; + readonly truncated: boolean; +} + +export type RuntimeInvocationOutcome = 'completed' | 'failed' | 'cancelled'; + +/** + * How the invocation ended, according to the only fact that decides it. + * + * `undefined` covers both an invocation still running and one whose terminal + * event ends the stream without stating an outcome; a caller that needs to tell + * those apart looks at `terminalEvent` itself. + */ +export function runtimeInvocationOutcome(record: { + terminalEvent?: RuntimeEvent; +}): RuntimeInvocationOutcome | undefined { + switch (record.terminalEvent?.status) { + case 'completed': + return 'completed'; + case 'failed': + return 'failed'; + case 'aborted': + case 'cancelled': + return 'cancelled'; + default: + return undefined; + } +} + +/** + * Whether this invocation contributes directly to the owning session's + * transcript. Top-level continuations carry parent lineage for recovery, but + * unlike child-agent invocations their output remains part of the parent + * session conversation. A legacy child retry may also carry continuation + * authority; its agent identity keeps it outside the owning session transcript. + */ +export function isSessionInlineInvocation(opening: RuntimeEventInvocationOpenedContent): boolean { + const lineage = opening.lineage; + return ( + lineage?.parentRunId === undefined || + (opening.source.kind === 'continuation' && lineage.agentId === undefined) + ); +} + +export type RootExecutionDescriptor = + | { + kind: 'external_message'; + inputDigest?: `sha256:${string}`; + maxSteps?: number; + } + | { + /** Tool-free conversational execution admitted only by WorkHub authority. */ + kind: 'workhub_coordination'; + inputDigest: `sha256:${string}`; + } + | { kind: 'regenerate'; sourceTurnId: string } + | { kind: 'context_compact' } + | { + kind: 'scheduled_task'; + scheduledTaskId: string; + /** Includes the immutable Connection target for Agent ScheduledTasks. */ + executionFingerprint?: `sha256:${string}`; + } + | { kind: 'legacy_automation'; automationId: string } + | { kind: 'goal'; goalId: string } + | { + kind: 'agent_graph_supervisor_wake'; + graphId: string; + wakeId: string; + attemptId: string; + } + | { + kind: 'safe_boundary_continuation'; + sourceInvocationId: string; + sourceRunId: string; + sourceTurnId: string; + sourceRuntimeEventHighWater: number; + claimId: string; + boundaryDigest: `sha256:${string}`; + providerReplayDigest: `sha256:${string}`; + safetyDigest: `sha256:${string}`; + targetInvocationId: string; + } + | { + kind: 'linked_child_initial'; + agentId: string; + agentName: string; + } + | { + kind: 'linked_child_resume'; + agentId: string; + agentName: string; + sourceRunId: string; + } + | { + kind: 'linked_child_provider_retry'; + agentId: string; + agentName: string; + sourceRunId: string; + } + | { + kind: 'claimed_agent_graph_intent'; + claim: AgentGraphIntentClaim; + agentId: string; + agentName: string; + }; + +type HostedRootExecutionDescriptor = Extract< + RootExecutionDescriptor, + { + kind: + | 'regenerate' + | 'context_compact' + | 'scheduled_task' + | 'legacy_automation' + | 'goal' + | 'agent_graph_supervisor_wake' + | 'safe_boundary_continuation'; + } +>; + +/** + * Is this invocation the one the Host admitted for that root execution? + * + * The opening fact names its root as a closed union, so each arm names the root + * it wants instead of asserting that every other root marker is absent. What + * remains is lineage, and the rule there is exactness: an admitted root has the + * lineage its kind implies and no other, so one comparison replaces a list of + * per-field negatives that had to be extended every time a lineage field was + * added. + */ +export function invocationMatchesHostedRootExecution( + invocation: { invocationId: string; opening: RuntimeEventInvocationOpenedContent }, + execution: HostedRootExecutionDescriptor, +): boolean { + const { root, source, configuration, lineage } = invocation.opening; + switch (execution.kind) { + case 'regenerate': + return ( + root.kind === 'user' && + source.kind === 'fresh' && + lineageIsExactly(lineage, { + parentTurnId: execution.sourceTurnId, + regeneratedFromTurnId: execution.sourceTurnId, + }) + ); + case 'context_compact': + return ( + root.kind === 'context_compact' && source.kind === 'fresh' && lineageIsExactly(lineage, {}) + ); + case 'safe_boundary_continuation': + return ( + root.kind === 'user' && + source.kind === 'continuation' && + invocation.invocationId === execution.targetInvocationId && + source.sourceInvocationId === execution.sourceInvocationId && + source.sourceRunId === execution.sourceRunId && + source.sourceTurnId === execution.sourceTurnId && + source.sourceRuntimeEventHighWater === execution.sourceRuntimeEventHighWater && + source.claimId === execution.claimId && + source.boundaryDigest === execution.boundaryDigest && + lineageIsExactly(lineage, { + parentRunId: execution.sourceRunId, + parentTurnId: execution.sourceTurnId, + }) + ); + case 'scheduled_task': + return ( + root.kind === 'scheduled_task' && + root.scheduledTaskId === execution.scheduledTaskId && + source.kind === 'fresh' && + lineageIsExactly(lineage, {}) + ); + case 'legacy_automation': + return ( + root.kind === 'legacy_automation' && + root.legacyAutomationId === execution.automationId && + source.kind === 'fresh' && + lineageIsExactly(lineage, {}) + ); + case 'goal': + return ( + root.kind === 'goal' && + root.goalId === execution.goalId && + source.kind === 'fresh' && + lineageIsExactly(lineage, {}) + ); + case 'agent_graph_supervisor_wake': + return ( + root.kind === 'agent_graph_supervisor_wake' && + execution.wakeId.startsWith(`${execution.graphId}:`) && + root.wakeId === execution.wakeId && + root.attemptId === execution.attemptId && + configuration.orchestrationMode === 'graph' && + configuration.orchestrationSource === 'turn_override' && + configuration.agentSwarmAuthorization === 'none' && + source.kind === 'fresh' && + lineageIsExactly(lineage, {}) + ); + } +} + +/** An admitted root has the lineage its kind implies, and no other edge. */ +function lineageIsExactly( + lineage: RuntimeInvocationLineage | undefined, + expected: RuntimeInvocationLineage, +): boolean { + const actual = (lineage ?? {}) as Record; + const wanted = expected as Record; + const keys = Object.keys(wanted); + return ( + Object.keys(actual).length === keys.length && keys.every((key) => actual[key] === wanted[key]) + ); +} diff --git a/packages/runtime-host/src/__tests__/daily-review-coordinator.test.ts b/packages/runtime-host/src/__tests__/daily-review-coordinator.test.ts index 9df8437004..6eb94115c2 100644 --- a/packages/runtime-host/src/__tests__/daily-review-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/daily-review-coordinator.test.ts @@ -447,8 +447,8 @@ function appendCorruptAuthorityEvent(root: string, sessionId: string, runId: str lease.transaction('write', () => { lease.database .prepare(` - INSERT INTO core_agent_runs(session_id, run_id, created_at, record_json) - VALUES (?, ?, 0, '{}') + INSERT INTO core_agent_runs(session_id, run_id, created_at) + VALUES (?, ?, 0) `) .run(sessionId, runId); lease.database diff --git a/packages/runtime-host/src/__tests__/usage-pricing-protocol.test.ts b/packages/runtime-host/src/__tests__/usage-pricing-protocol.test.ts index 2f7efffb7c..37ceb87aec 100644 --- a/packages/runtime-host/src/__tests__/usage-pricing-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/usage-pricing-protocol.test.ts @@ -479,8 +479,8 @@ describe('Usage/Pricing protocol', () => { lease.transaction('write', () => { lease.database .prepare(` - INSERT INTO core_agent_runs(session_id, run_id, created_at, record_json) - VALUES ('session-b', 'run-b', 0, '{}') + INSERT INTO core_agent_runs(session_id, run_id, created_at) + VALUES ('session-b', 'run-b', 0) `) .run(); lease.database diff --git a/packages/storage/src/__tests__/agent-graph-supervisor-root-admission.test.ts b/packages/storage/src/__tests__/agent-graph-supervisor-root-admission.test.ts index 3cc98eac4c..d52f9143df 100644 --- a/packages/storage/src/__tests__/agent-graph-supervisor-root-admission.test.ts +++ b/packages/storage/src/__tests__/agent-graph-supervisor-root-admission.test.ts @@ -22,7 +22,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; -import type { RootExecutionDescriptor } from '@maka/core/agent-run'; +import type { RootExecutionDescriptor } from '@maka/core/runtime-invocation'; import { createSqliteAgentRunStore, type AdmitRootTurnInput } from '../agent-run-store.js'; test('Agent Graph supervisor admission durably binds wake identity and Graph orchestration', async () => { diff --git a/packages/storage/src/__tests__/claimed-agent-graph-root-admission.test.ts b/packages/storage/src/__tests__/claimed-agent-graph-root-admission.test.ts index 9b3e1c54cd..b39eccaa31 100644 --- a/packages/storage/src/__tests__/claimed-agent-graph-root-admission.test.ts +++ b/packages/storage/src/__tests__/claimed-agent-graph-root-admission.test.ts @@ -23,7 +23,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, test } from 'node:test'; import { AGENT_GRAPH_INTENT_CLAIM_SCHEMA_VERSION } from '@maka/core/agent-graph-control'; -import type { RootExecutionDescriptor } from '@maka/core/agent-run'; +import type { RootExecutionDescriptor } from '@maka/core/runtime-invocation'; import { createSqliteAgentRunStore, type AdmitRootTurnInput } from '../agent-run-store.js'; describe('claimed agent graph root admission', () => { diff --git a/packages/storage/src/__tests__/fixtures/invocation-opening.ts b/packages/storage/src/__tests__/fixtures/invocation-opening.ts new file mode 100644 index 0000000000..7601cf01b4 --- /dev/null +++ b/packages/storage/src/__tests__/fixtures/invocation-opening.ts @@ -0,0 +1,91 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { encodeCanonicalRuntimeEvent } from '@maka/core/canonical-runtime-event'; +import type { RuntimeEventInvocationOpenedContent } from '@maka/core/runtime-event'; +import { DEFAULT_TOOL_MODE } from '@maka/core/tool-mode'; +import { createWorkspaceRuntimeStore } from '../../runtime-event-persistence.js'; + +export interface InvocationIdentity { + sessionId: string; + invocationId?: string; + runId: string; + turnId: string; + openedAt?: number; +} + +export function invocationOpening( + overrides: Partial = {}, +): RuntimeEventInvocationOpenedContent { + return { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'runtime', + backendKind: 'fake', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'fake', + modelId: 'fake-model', + }, + configuration: { + cwd: '/tmp/cwd', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: DEFAULT_TOOL_MODE, + agentSwarmAuthorization: 'none', + }, + root: { kind: 'user' }, + source: { kind: 'fresh' }, + ...overrides, + }; +} + +/** + * Commit the one fact that makes an invocation exist, the way the Runtime Host + * does, so the AgentRunEvent ledger has an anchor to hang its events on. + */ +export async function openInvocation( + workspaceRoot: string, + identity: InvocationIdentity, + content: RuntimeEventInvocationOpenedContent = invocationOpening(), +): Promise { + const invocationId = identity.invocationId ?? identity.runId; + const openedAt = identity.openedAt ?? 1; + const { event } = encodeCanonicalRuntimeEvent({ + id: `invocation_opened:${invocationId}`, + invocationId, + runId: identity.runId, + sessionId: identity.sessionId, + turnId: identity.turnId, + ts: openedAt, + partial: false, + role: 'system', + author: 'system', + modelVisibility: 'hidden', + content, + }); + const store = createWorkspaceRuntimeStore(workspaceRoot); + try { + await store.appendRuntimeEvent(identity.sessionId, identity.runId, event); + } finally { + store.close(); + } +} diff --git a/packages/storage/src/__tests__/invocation-opening-backfill.test.ts b/packages/storage/src/__tests__/invocation-opening-backfill.test.ts index e2c8a4b3c6..a36f8eff80 100644 --- a/packages/storage/src/__tests__/invocation-opening-backfill.test.ts +++ b/packages/storage/src/__tests__/invocation-opening-backfill.test.ts @@ -23,11 +23,11 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, test } from 'node:test'; import { DatabaseSync } from 'node:sqlite'; -import type { AgentRunHeader } from '@maka/core/agent-run'; import { encodeCanonicalRuntimeEvent } from '@maka/core/canonical-runtime-event'; import { decodeRuntimeEvent } from '@maka/core/runtime-event'; -import { createSqliteAgentRunStore } from '../agent-run-store.js'; +import type { LegacyRunHeader } from '../legacy-run-header.js'; import { OPERATIONAL_STATE_DATABASE_NAME } from '../operational-state-store.js'; +import { migrateSqliteCoreExecutionDatabase } from '../sqlite-core-execution-schema.js'; import { createSqliteRuntimeStore } from '../sqlite-runtime-store.js'; import { migrateSqliteRuntimeDatabase, @@ -47,7 +47,6 @@ describe('invocation opening fact backfill', () => { ) VALUES ('existing-1', 'session-1', 'run-with-events', 'run-with-events', 'turn-with-events', 1, 'text', '{}', 1) `).run(); - rewindRuntimeSchemaToPreviousVersion(db); migrateSqliteRuntimeDatabase(db); assert.equal(readUserVersion(db), SQLITE_RUNTIME_SCHEMA_VERSION); @@ -177,7 +176,6 @@ describe('invocation opening fact backfill', () => { ) VALUES ('existing-1', 'session-1', 'run-with-events', 'run-with-events', 'turn-with-events', 1, 'text', ?, 1) `).run(json); - rewindRuntimeSchemaToPreviousVersion(db); migrateSqliteRuntimeDatabase(db); } finally { db.close(); @@ -210,7 +208,6 @@ describe('invocation opening fact backfill', () => { await withHeaderOnlyRuns(async (databasePath) => { const db = new DatabaseSync(databasePath); try { - rewindRuntimeSchemaToPreviousVersion(db); migrateSqliteRuntimeDatabase(db); } finally { db.close(); @@ -250,6 +247,19 @@ describe('invocation opening fact backfill', () => { /Runtime invocation not found/, 'a header the backfill refused to project has no invocation to read', ); + + await assert.rejects( + () => store.listSessionInvocationsPage('session-1', { limit: 0 }), + /between 1 and 256/, + ); + await assert.rejects( + () => + store.listSessionInvocationsPage('session-1', { + limit: 1, + before: { openedAt: Number.NaN, invocationId: 'run-scheduled' }, + }), + /Invalid invocation page cursor/, + ); } finally { store.close(); } @@ -257,8 +267,12 @@ describe('invocation opening fact backfill', () => { }); }); -/** Undo the v16 step so the migration under test runs against real header rows. */ -function rewindRuntimeSchemaToPreviousVersion(db: DatabaseSync): void { +/** + * Put the database back the way the header era left it: runtime schema one step + * behind, no opening facts, and a `core_agent_runs` row that still carries the + * header the migration under test has to read. + */ +function rewindToHeaderEra(db: DatabaseSync): void { db.exec('DROP INDEX IF EXISTS runtime_events_by_session_kind'); db.exec('DROP INDEX IF EXISTS runtime_legacy_invocation_openings_by_session'); db.exec('DROP TABLE IF EXISTS runtime_legacy_invocation_openings'); @@ -266,6 +280,7 @@ function rewindRuntimeSchemaToPreviousVersion(db: DatabaseSync): void { db.exec( 'ALTER TABLE runtime_continuation_claims RENAME COLUMN target_opening_json TO target_run_header_json', ); + db.exec('ALTER TABLE core_agent_runs ADD COLUMN record_json TEXT'); db.exec(`PRAGMA user_version = ${SQLITE_RUNTIME_SCHEMA_VERSION - 1}`); } @@ -276,42 +291,41 @@ function readUserVersion(db: DatabaseSync): number { async function withHeaderOnlyRuns(run: (databasePath: string) => Promise): Promise { const root = await mkdtemp(join(tmpdir(), 'maka-opening-backfill-')); try { - const store = createSqliteAgentRunStore(root); - await store.createRun( - header({ - runId: 'run-legacy-route', - turnId: 'turn-legacy', - modelId: 'legacy-model', - }), - ); - await store.createRun( - header({ - runId: 'run-scheduled', - turnId: 'turn-scheduled', - llmConnectionId: 'connection-1', - scheduledTaskId: 'task-9', - }), - ); - // A graph wake with no delivery attempt is corruption; the backfill must - // skip it rather than invent a root authority for it. - await store.createRun( - header({ - runId: 'run-corrupt-root', - turnId: 'turn-corrupt', - agentGraphWakeId: 'wake-1', - }), - ); - await store.createRun(header({ runId: 'run-with-events', turnId: 'turn-with-events' })); - store.close?.(); - const databasePath = join(root, OPERATIONAL_STATE_DATABASE_NAME); + const db = new DatabaseSync(databasePath); + try { + migrateSqliteRuntimeDatabase(db); + migrateSqliteCoreExecutionDatabase(db); + rewindToHeaderEra(db); + const insert = db.prepare( + 'INSERT INTO core_agent_runs(session_id, run_id, created_at, record_json) VALUES (?, ?, ?, ?)', + ); + for (const record of [ + header({ runId: 'run-legacy-route', turnId: 'turn-legacy', modelId: 'legacy-model' }), + header({ + runId: 'run-scheduled', + turnId: 'turn-scheduled', + llmConnectionId: 'connection-1', + scheduledTaskId: 'task-9', + }), + // A graph wake with no delivery attempt is corruption; the backfill must + // skip it rather than invent a root authority for it. + header({ runId: 'run-corrupt-root', turnId: 'turn-corrupt', agentGraphWakeId: 'wake-1' }), + header({ runId: 'run-with-events', turnId: 'turn-with-events' }), + ]) { + insert.run(record.sessionId, record.runId, record.createdAt, JSON.stringify(record)); + } + } finally { + db.close(); + } + await run(databasePath); } finally { await rm(root, { recursive: true, force: true }); } } -function header(overrides: Partial): AgentRunHeader { +function header(overrides: Partial): LegacyRunHeader { return { runId: 'run-1', invocationId: overrides.runId ?? 'run-1', diff --git a/packages/storage/src/__tests__/legacy-run-header.test.ts b/packages/storage/src/__tests__/legacy-run-header.test.ts new file mode 100644 index 0000000000..1415f551da --- /dev/null +++ b/packages/storage/src/__tests__/legacy-run-header.test.ts @@ -0,0 +1,180 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { + decodePersistedLegacyRunHeader, + invocationOpeningFromLegacyRunHeader, + type LegacyRunHeader, +} from '../legacy-run-header.js'; + +describe('legacy Run header decoding', () => { + test('rejects a header with multiple hosted root authorities', () => { + assert.throws( + () => + decodePersistedLegacyRunHeader({ + ...runHeader(), + scheduledTaskId: 'scheduled-task-1', + goalId: 'goal-1', + }), + /Invalid AgentRun header schema/, + ); + }); + + test('folds every retired persisted value', () => { + const decoded = decodePersistedLegacyRunHeader({ + ...runHeader(), + status: 'waiting_permission', + permissionMode: 'execute', + automationId: 'automation-1', + }); + assert.equal(decoded.status, 'waiting_for_user'); + assert.equal(decoded.permissionMode, 'ask'); + assert.equal(decoded.legacyAutomationId, 'automation-1'); + assert.equal(Object.hasOwn(decoded, 'automationId'), false); + }); + + test('accepts both bound and legacy connection identity', () => { + assert.equal(decodePersistedLegacyRunHeader(runHeader()).llmConnectionId, undefined); + const bound = decodePersistedLegacyRunHeader({ + ...runHeader(), + llmConnectionId: '11111111-1111-4111-8111-111111111111', + }); + assert.equal(bound.llmConnectionId, '11111111-1111-4111-8111-111111111111'); + assert.throws( + () => decodePersistedLegacyRunHeader({ ...runHeader(), llmConnectionId: '' }), + /Invalid AgentRun header schema/, + ); + }); + + test('projects an unbound connection as an unauthenticated route', () => { + const opening = invocationOpeningFromLegacyRunHeader( + decodePersistedLegacyRunHeader(runHeader()), + ); + assert.equal(opening.route.provenance, 'unknown'); + assert.equal(opening.source.kind, 'fresh'); + }); +}); + +describe('legacy continuation source decoding', () => { + test('rejects an empty V2 claim identity', () => { + assert.throws( + () => + decodePersistedLegacyRunHeader( + headerWithContinuation({ ...validV2ContinuationSource(), claimId: '' }), + ), + /Invalid AgentRun header schema/, + ); + }); + + test('rejects a zero V2 source high-water', () => { + assert.throws( + () => + decodePersistedLegacyRunHeader( + headerWithContinuation({ + ...validV2ContinuationSource(), + sourceRuntimeEventHighWater: 0, + }), + ), + /Invalid AgentRun header schema/, + ); + }); + + for (const field of ['sourceInvocationId', 'sourceRunId', 'sourceTurnId'] as const) { + test(`rejects an empty V2 ${field}`, () => { + assert.throws( + () => + decodePersistedLegacyRunHeader( + headerWithContinuation({ ...validV2ContinuationSource(), [field]: '' }), + ), + /Invalid AgentRun header schema/, + ); + }); + } + + test('rejects a V2 replay manifest that does not identify its boundary', () => { + assert.throws( + () => + decodePersistedLegacyRunHeader( + headerWithContinuation({ + ...validV2ContinuationSource(), + replayManifestDigest: `sha256:${'c'.repeat(64)}`, + }), + ), + /Invalid AgentRun header schema/, + ); + }); + + test('projects a V2 source onto the opening fact', () => { + const header = decodePersistedLegacyRunHeader( + headerWithContinuation(validV2ContinuationSource()), + ); + const source = invocationOpeningFromLegacyRunHeader(header).source; + assert.equal(source.kind, 'continuation'); + if (source.kind !== 'continuation') throw new Error('unreachable'); + assert.equal(source.claimId, 'claim-1'); + assert.equal(source.sourceRunId, 'source-run'); + }); +}); + +function runHeader(): Record { + return { + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + status: 'created', + backendKind: 'fake', + llmConnectionSlug: 'fake', + modelId: 'fake-model', + cwd: '/workspace', + permissionMode: 'ask', + createdAt: 1, + updatedAt: 1, + }; +} + +function headerWithContinuation( + continuationSource: LegacyRunHeader['continuationSource'], +): Record { + return { + ...runHeader(), + runId: 'target-run', + invocationId: 'target-invocation', + turnId: 'target-turn', + continuationSource, + }; +} + +function validV2ContinuationSource(): Extract< + NonNullable, + { protocol: 'continuation_source_v2' } +> { + return { + protocol: 'continuation_source_v2', + claimId: 'claim-1', + boundaryDigest: `sha256:${'a'.repeat(64)}`, + sourceInvocationId: 'source-invocation', + sourceRunId: 'source-run', + sourceTurnId: 'source-turn', + sourceRuntimeEventHighWater: 1, + sourcePrefixDigest: `sha256:${'b'.repeat(64)}`, + replayManifestDigest: `sha256:${'a'.repeat(64)}`, + }; +} diff --git a/packages/storage/src/__tests__/model-call-ledger.test.ts b/packages/storage/src/__tests__/model-call-ledger.test.ts index 1a4190d309..0fe026cf79 100644 --- a/packages/storage/src/__tests__/model-call-ledger.test.ts +++ b/packages/storage/src/__tests__/model-call-ledger.test.ts @@ -23,7 +23,6 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { describe, test } from 'node:test'; -import type { AgentRunHeader } from '@maka/core/agent-run'; import { MODEL_CALL_ATTEMPT_EVENT_TYPE, MODEL_CALL_ATTEMPT_SCHEMA_VERSION, @@ -37,6 +36,7 @@ import { } from '../model-call-ledger.js'; import { acquireOperationalStateDatabase } from '../operational-state-store.js'; import { createSqliteAgentRunStore } from '../agent-run-store.js'; +import { openInvocation } from './fixtures/invocation-opening.js'; const NOW = 1_750_000_000_000; @@ -90,8 +90,8 @@ function appendAuthorityEvent( lease.transaction('write', () => { lease.database .prepare(` - INSERT OR IGNORE INTO core_agent_runs(session_id, run_id, created_at, record_json) - VALUES (?, ?, ?, '{}') + INSERT OR IGNORE INTO core_agent_runs(session_id, run_id, created_at) + VALUES (?, ?, ?) `) .run(sessionId, runId, NOW - 1_000); lease.database @@ -356,21 +356,8 @@ describe('canonical model call ledger', () => { describe('catching the read model up from the AgentRun authority', () => { test('consumes the high-water published by the real AgentRun append path', async () => { await withLedger(async (ledger, root) => { + await openInvocation(root, { sessionId: 'session-1', runId: 'run-1', turnId: 'turn-1' }); const runStore = createSqliteAgentRunStore(root); - const header: AgentRunHeader = { - runId: 'run-1', - sessionId: 'session-1', - turnId: 'turn-1', - status: 'created', - backendKind: 'fake', - llmConnectionSlug: 'fake', - modelId: 'fake-model', - cwd: '/tmp/cwd', - permissionMode: 'ask', - createdAt: 1, - updatedAt: 1, - }; - await runStore.createRun(header); await runStore.appendEvent('session-1', 'run-1', { id: 'attempt-real-append', type: MODEL_CALL_ATTEMPT_EVENT_TYPE, diff --git a/packages/storage/src/__tests__/regenerate-root-admission.test.ts b/packages/storage/src/__tests__/regenerate-root-admission.test.ts index 4429d04de6..10a228f0a4 100644 --- a/packages/storage/src/__tests__/regenerate-root-admission.test.ts +++ b/packages/storage/src/__tests__/regenerate-root-admission.test.ts @@ -22,7 +22,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; -import type { RootExecutionDescriptor } from '@maka/core/agent-run'; +import type { RootExecutionDescriptor } from '@maka/core/runtime-invocation'; import { createSqliteAgentRunStore, type AdmitRootTurnInput } from '../agent-run-store.js'; test('regenerate admission durably binds the immutable source Turn', async () => { diff --git a/packages/storage/src/__tests__/sqlite-core-execution-store.test.ts b/packages/storage/src/__tests__/sqlite-core-execution-store.test.ts index 5b6ccf30c8..0890db5907 100644 --- a/packages/storage/src/__tests__/sqlite-core-execution-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-core-execution-store.test.ts @@ -23,7 +23,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { after, describe, test } from 'node:test'; import { DatabaseSync } from 'node:sqlite'; -import type { AgentRunHeader, EmittedAgentRunEvent } from '@maka/core/agent-run'; +import type { EmittedAgentRunEvent } from '@maka/core/agent-run'; import { agentRunCompositionFromEvents } from '@maka/core/agent-run'; import type { RunCompositionSnapshot } from '@maka/core/run-composition'; import { @@ -45,22 +45,22 @@ import { removeTrackedControlDirectories, trackControlDirectory, } from './fixtures/control-directory-hygiene.js'; +import { openInvocation } from './fixtures/invocation-opening.js'; // The control directory of each resolved root lives outside that root, so a // temporary root's removal leaves it behind; reclaim the recorded rootIds here. after(removeTrackedControlDirectories); describe('SQLite core execution stores', () => { - test('persists AgentRun header and events', async () => { + test('persists AgentRun events against the invocation that opened them', async () => { await withRoot(async (root) => { + await openRun(root); const store = createSqliteAgentRunStore(root); - await store.createRun(runHeader()); await store.appendEvent('session-1', 'run-1', runEvent()); store.close?.(); const reopened = createSqliteAgentRunStore(root); try { - assert.equal((await reopened.readRun('session-1', 'run-1')).runId, 'run-1'); assert.equal((await reopened.readEvents('session-1', 'run-1'))[0]?.id, 'event-1'); } finally { reopened.close?.(); @@ -68,53 +68,23 @@ describe('SQLite core execution stores', () => { }); }); - test('folds retired AgentRun values only when reading persisted rows', async () => { + test('refuses to hang an event on a run no invocation ever opened', async () => { await withRoot(async (root) => { const store = createSqliteAgentRunStore(root); - await store.createRun(runHeader()); - await assert.rejects( - () => - store.createRun({ - ...runHeader({ runId: 'run-retired', turnId: 'turn-retired' }), - permissionMode: 'execute', - } as unknown as AgentRunHeader), - /Invalid AgentRun header schema/, - ); - store.close?.(); - - const database = new DatabaseSync(join(root, 'runtime.sqlite')); try { - const row = database - .prepare("SELECT record_json AS recordJson FROM core_agent_runs WHERE run_id = 'run-1'") - .get() as { recordJson: string }; - const retired = JSON.parse(row.recordJson) as Record; - retired.status = 'waiting_permission'; - retired.permissionMode = 'execute'; - retired.automationId = 'automation-1'; - database - .prepare("UPDATE core_agent_runs SET record_json = ? WHERE run_id = 'run-1'") - .run(JSON.stringify(retired)); - } finally { - database.close(); - } - - const reopened = createSqliteAgentRunStore(root); - try { - const decoded = await reopened.readRun('session-1', 'run-1'); - assert.equal(decoded.status, 'waiting_for_user'); - assert.equal(decoded.permissionMode, 'ask'); - assert.equal(decoded.legacyAutomationId, 'automation-1'); - assert.equal(Object.hasOwn(decoded, 'automationId'), false); + await assert.rejects(store.appendEvent('session-1', 'run-missing', runEvent()), { + code: 'ENOENT', + }); } finally { - reopened.close?.(); + store.close?.(); } }); }); test('advances the model-call high-water index with the authority append', async () => { await withRoot(async (root) => { + await openRun(root); const store = createSqliteAgentRunStore(root); - await store.createRun(runHeader()); await store.appendEvent('session-1', 'run-1', runEvent()); await store.appendEvent('session-1', 'run-1', { ...runEvent(), @@ -144,8 +114,8 @@ describe('SQLite core execution stores', () => { test('commits canonical authority without guessing a malformed projection order', async () => { await withRoot(async (root) => { + await openRun(root); const store = createSqliteAgentRunStore(root); - await store.createRun(runHeader()); await store.appendEvent( 'session-1', 'run-1', @@ -256,8 +226,8 @@ describe('SQLite core execution stores', () => { test('does not repair a malformed projection from a stale ledger revision', async () => { await withRoot(async (root) => { + await openRun(root); const store = createSqliteAgentRunStore(root); - await store.createRun(runHeader()); await store.appendEvent('session-1', 'run-1', runEvent()); await store.repairEventProjection( 'session-1', @@ -323,8 +293,8 @@ describe('SQLite core execution stores', () => { test('rejects a projection repair without a canonical ledger revision', async () => { await withRoot(async (root) => { + await openRun(root); const store = createSqliteAgentRunStore(root); - await store.createRun(runHeader()); await store.appendEvent('session-1', 'run-1', runEvent()); const before = await store.readEventProjection( 'session-1', @@ -351,8 +321,8 @@ describe('SQLite core execution stores', () => { test('backfills the model-call high-water when upgrading existing AgentRun rows', async () => { await withRoot(async (root) => { + await openRun(root); const store = createSqliteAgentRunStore(root); - await store.createRun(runHeader()); await store.appendEvent('session-1', 'run-1', { ...runEvent(), id: 'legacy-model-call-event', @@ -458,58 +428,10 @@ describe('SQLite core execution stores', () => { }); }); - test('pages AgentRuns by stable creation and run identity order', async () => { - await withRoot(async (root) => { - const store = createSqliteAgentRunStore(root); - try { - await store.createRun(runHeader({ runId: 'run-a', turnId: 'turn-a', createdAt: 1 })); - await store.createRun(runHeader({ runId: 'run-b', turnId: 'turn-b', createdAt: 2 })); - await store.createRun(runHeader({ runId: 'run-c', turnId: 'turn-c', createdAt: 2 })); - - const first = await store.listSessionRunsPage('session-1', { limit: 2 }); - assert.deepEqual( - first.runs.map((run) => run.runId), - ['run-c', 'run-b'], - ); - assert.deepEqual(first.nextCursor, { createdAt: 2, runId: 'run-b' }); - - await store.createRun(runHeader({ runId: 'run-d', turnId: 'turn-d', createdAt: 3 })); - const older = await store.listSessionRunsPage('session-1', { - limit: 2, - before: first.nextCursor ?? undefined, - }); - assert.deepEqual( - older.runs.map((run) => run.runId), - ['run-a'], - ); - assert.equal(older.nextCursor, null); - } finally { - store.close?.(); - } - }); - }); - - test('rejects a non-finite AgentRun page cursor', async () => { - await withRoot(async (root) => { - const store = createSqliteAgentRunStore(root); - try { - await assert.rejects( - store.listSessionRunsPage('session-1', { - limit: 1, - before: { createdAt: Number.NaN, runId: 'run-1' }, - }), - /Invalid AgentRun page cursor/u, - ); - } finally { - store.close?.(); - } - }); - }); - test('preserves provider failure diagnostics in the AgentRun authority after reopen', async () => { await withRoot(async (root) => { + await openRun(root); const store = createSqliteAgentRunStore(root); - await store.createRun(runHeader()); await store.appendEvent('session-1', 'run-1', { type: 'model_call_attempt_recorded', id: 'attempt-1', @@ -557,9 +479,9 @@ describe('SQLite core execution stores', () => { test('commits one immutable Run Composition snapshot', async () => { await withRoot(async (root) => { + await openRun(root); const store = createSqliteAgentRunStore(root); try { - await store.createRun(runHeader()); const composition = runComposition('1'); await store.appendEvent('session-1', 'run-1', compositionEvent('event-1', composition)); await store.appendEvent('session-1', 'run-1', compositionEvent('event-2', composition)); @@ -582,8 +504,8 @@ describe('SQLite core execution stores', () => { test('reads an AgentRun event type this build does not write', async () => { await withRoot(async (root) => { + await openRun(root); const store = createSqliteAgentRunStore(root); - await store.createRun(runHeader()); await store.appendEvent('session-1', 'run-1', runEvent()); store.close?.(); @@ -683,26 +605,13 @@ async function withRoot(run: (root: string) => Promise): Promise { } } -function runHeader(overrides: Partial = {}): AgentRunHeader { - return { - runId: 'run-1', - sessionId: 'session-1', - turnId: 'turn-1', - status: 'created', - backendKind: 'fake', - llmConnectionSlug: 'fake', - modelId: 'fake-model', - cwd: '/tmp/cwd', - permissionMode: 'ask', - createdAt: 1, - updatedAt: 1, - ...overrides, - }; +function openRun(root: string): Promise { + return openInvocation(root, { sessionId: 'session-1', runId: 'run-1', turnId: 'turn-1' }); } function runEvent(): EmittedAgentRunEvent { return { - type: 'run_started', + type: 'turn_started', id: 'event-1', runId: 'run-1', sessionId: 'session-1', diff --git a/packages/storage/src/__tests__/usage-stores.test.ts b/packages/storage/src/__tests__/usage-stores.test.ts index f8b1512538..3eb88bba2e 100644 --- a/packages/storage/src/__tests__/usage-stores.test.ts +++ b/packages/storage/src/__tests__/usage-stores.test.ts @@ -735,8 +735,8 @@ function appendModelCallAuthorityEvent( lease.transaction('write', () => { lease.database .prepare(` - INSERT INTO core_agent_runs(session_id, run_id, created_at, record_json) - VALUES (?, ?, 0, '{}') + INSERT INTO core_agent_runs(session_id, run_id, created_at) + VALUES (?, ?, 0) `) .run(value.sessionId, value.runId); lease.database diff --git a/packages/storage/src/__tests__/workhub-coordination-root-admission.test.ts b/packages/storage/src/__tests__/workhub-coordination-root-admission.test.ts index 8da5ea98f3..7eca36f5a6 100644 --- a/packages/storage/src/__tests__/workhub-coordination-root-admission.test.ts +++ b/packages/storage/src/__tests__/workhub-coordination-root-admission.test.ts @@ -22,7 +22,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; -import type { RootExecutionDescriptor } from '@maka/core/agent-run'; +import type { RootExecutionDescriptor } from '@maka/core/runtime-invocation'; import { createSqliteAgentRunStore } from '../agent-run-store.js'; test('WorkHub Coordination admission preserves its bounded content identity across restart', async () => { diff --git a/packages/storage/src/agent-run-store.ts b/packages/storage/src/agent-run-store.ts index 3bafdc3da5..af39a40fcd 100644 --- a/packages/storage/src/agent-run-store.ts +++ b/packages/storage/src/agent-run-store.ts @@ -21,12 +21,7 @@ import { createHash } from 'node:crypto'; import { resolve } from 'node:path'; import { isDeepStrictEqual } from 'node:util'; import type { DatabaseSync } from 'node:sqlite'; -import { - decodeAgentRunEvent, - decodeAgentRunHeader, - decodeCurrentAgentRunHeader, - decodeRuntimeEvent, -} from './execution-record-codec.js'; +import { decodeAgentRunEvent, decodeRuntimeEvent } from './execution-record-codec.js'; import { immutableSteeringMessageId } from './runtime-event-invariants.js'; import { normalizeSubmittedTurnIntent, @@ -48,14 +43,13 @@ import { decodeSkillInvocationResult, type SkillInvocationResult, } from '@maka/core/skill-invocation'; -import { - DurableStoreWriteError, - type RuntimeEventStore, - type RuntimeInvocationPageInput, - type RuntimeInvocationPageResult, - type RuntimeInvocationRecord, - type RuntimeInvocationSearchResult, -} from '@maka/core/runtime-event-store'; +import { DurableStoreWriteError, type RuntimeEventStore } from '@maka/core/runtime-event-store'; +import type { + RuntimeInvocationPageInput, + RuntimeInvocationPageResult, + RuntimeInvocationRecord, + RuntimeInvocationSearchResult, +} from '@maka/core/runtime-invocation'; import { aggregateMessageContents, decodeMessageContent, @@ -78,12 +72,17 @@ import { type LatestContextProjectionInput, type AgentRunEvent, type AgentRunEventType, - type AgentRunHeader, type AgentRunStore, type EmittedAgentRunEvent, - type RootExecutionDescriptor, - isSessionInlineRun, } from '@maka/core/agent-run'; +import { + isSessionInlineInvocation, + type RootExecutionDescriptor, +} from '@maka/core/runtime-invocation'; +import { + decodeRuntimeInvocationOpened, + runtimeEventInvocationOpening, +} from '@maka/core/runtime-event'; import { encodeCanonicalRuntimeEvent } from '@maka/core/canonical-runtime-event'; import { isOrchestrationMode, @@ -224,8 +223,6 @@ export interface DurableAgentRunStore extends AgentRunStore, RootTurnAdmissionStore, RootTurnStartRejectionStore { - listSessionRunsBounded(sessionId: string, limit: number): Promise; - listSessionRunsPage(sessionId: string, input: AgentRunPageInput): Promise; readEventsBounded( sessionId: string, runId: string, @@ -237,7 +234,6 @@ export interface DurableAgentRunStore type: AgentRunEventType, budget: EvidenceReadBudget, ): Promise>; - listSessionRunsForRecovery(sessionId: string): Promise; readEventsForRecovery(sessionId: string, runId: string): Promise; readEventsForEvidence(sessionId: string, runId: string): Promise; readEventProjection( @@ -255,26 +251,6 @@ export interface DurableAgentRunStore close?(): void; } -export interface AgentRunIdentitySearchResult { - readonly runs: readonly AgentRunHeader[]; - readonly truncated: boolean; -} - -export interface AgentRunPageCursor { - readonly createdAt: number; - readonly runId: string; -} - -export interface AgentRunPageInput { - readonly before?: AgentRunPageCursor; - readonly limit: number; -} - -export interface AgentRunPageResult { - readonly runs: readonly AgentRunHeader[]; - readonly nextCursor: AgentRunPageCursor | null; -} - export type { BoundedEvidenceReadResult, EvidenceReadBudget } from './bounded-evidence.js'; export interface ConversationCopyRuntimeEventBatch { @@ -360,196 +336,6 @@ class SqliteAgentRunStore implements DurableAgentRunStore { return Promise.resolve(); } - async createRun( - header: AgentRunHeader, - _options: { durable?: boolean } = {}, - ): Promise { - const normalized = normalizeCurrentAgentRunHeader(header, header.sessionId, header.runId); - this.#lease.transaction('write', () => { - const inserted = this.#lease.database - .prepare(` - INSERT OR IGNORE INTO core_agent_runs( - session_id, run_id, created_at, record_json - ) VALUES (?, ?, ?, ?) - `) - .run( - normalized.sessionId, - normalized.runId, - normalized.createdAt, - JSON.stringify(normalized, sanitizeJson), - ); - if (inserted.changes !== 1) { - throw new Error(`Agent run already exists: ${normalized.runId}`); - } - const count = this.#lease.database - .prepare('SELECT COUNT(*) AS count FROM core_agent_runs WHERE session_id = ?') - .get(normalized.sessionId) as { count?: unknown }; - const projection = this.#lease.database - .prepare(` - SELECT 1 AS present - FROM core_agent_run_projections - WHERE session_id = ? AND event_type = 'history_compact_checkpoint_recorded' - `) - .get(normalized.sessionId); - if (count.count === 1 && !projection) { - this.#lease.database - .prepare(` - INSERT INTO core_agent_run_projections(session_id, event_type, event_json) - VALUES (?, 'history_compact_checkpoint_recorded', NULL) - `) - .run(normalized.sessionId); - } - }); - return normalized; - } - - async updateRun( - sessionId: string, - runId: string, - patch: Partial, - _options: { durable?: boolean } = {}, - ): Promise { - assertMutableRunHeaderPatch(patch); - assertSafeId(sessionId, 'Invalid session id'); - assertSafeId(runId, 'Invalid run id'); - return this.#lease.transaction('write', () => { - const current = readSqliteAgentRun(this.#lease.database, sessionId, runId); - const next = normalizeCurrentAgentRunHeader( - { ...current, ...patch, sessionId, runId }, - sessionId, - runId, - ); - const result = this.#lease.database - .prepare(` - UPDATE core_agent_runs - SET created_at = ?, record_json = ? - WHERE session_id = ? AND run_id = ? - `) - .run(next.createdAt, JSON.stringify(next, sanitizeJson), sessionId, runId); - if (result.changes !== 1) throw new Error(`Failed to update run ${runId}`); - return next; - }); - } - - async readRun(sessionId: string, runId: string): Promise { - assertSafeId(sessionId, 'Invalid session id'); - assertSafeId(runId, 'Invalid run id'); - return readSqliteAgentRun(this.#lease.database, sessionId, runId); - } - - async listSessionRuns(sessionId: string): Promise { - return this.listSessionRunsForRecovery(sessionId); - } - - async listSessionRunsBounded( - sessionId: string, - limit: number, - ): Promise { - assertSafeId(sessionId, 'Invalid session id'); - assertIdentitySearchLimit(limit); - const rows = this.#lease.database - .prepare(` - SELECT run_id, record_json - FROM core_agent_runs - WHERE session_id = ? - ORDER BY created_at, run_id - LIMIT ? - `) - .all(sessionId, limit + 1) as Array<{ run_id?: unknown; record_json?: unknown }>; - const truncated = rows.length > limit; - const runs = rows.slice(0, limit).map((row) => { - if (typeof row.run_id !== 'string' || typeof row.record_json !== 'string') { - throw new Error('Invalid SQLite AgentRun row'); - } - return decodePersistedAgentRunHeader(JSON.parse(row.record_json), sessionId, row.run_id); - }); - return { runs, truncated }; - } - - async listSessionRunsPage( - sessionId: string, - input: AgentRunPageInput, - ): Promise { - assertSafeId(sessionId, 'Invalid session id'); - assertIdentitySearchLimit(input.limit); - if (input.before) { - assertSafeId(input.before.runId, 'Invalid AgentRun page cursor'); - if (!Number.isFinite(input.before.createdAt)) { - throw new Error('Invalid AgentRun page cursor'); - } - } - const rows = this.#lease.database - .prepare( - input.before - ? ` - SELECT run_id, created_at, record_json - FROM core_agent_runs - WHERE session_id = ? - AND (created_at < ? OR (created_at = ? AND run_id < ?)) - ORDER BY created_at DESC, run_id DESC - LIMIT ? - ` - : ` - SELECT run_id, created_at, record_json - FROM core_agent_runs - WHERE session_id = ? - ORDER BY created_at DESC, run_id DESC - LIMIT ? - `, - ) - .all( - ...(input.before - ? [ - sessionId, - input.before.createdAt, - input.before.createdAt, - input.before.runId, - input.limit + 1, - ] - : [sessionId, input.limit + 1]), - ) as Array<{ run_id?: unknown; created_at?: unknown; record_json?: unknown }>; - const pageRows = rows.slice(0, input.limit); - const runs = pageRows.map((row) => { - if ( - typeof row.run_id !== 'string' || - typeof row.created_at !== 'number' || - typeof row.record_json !== 'string' - ) { - throw new Error('Invalid SQLite AgentRun page row'); - } - return decodePersistedAgentRunHeader(JSON.parse(row.record_json), sessionId, row.run_id); - }); - const last = pageRows.at(-1); - return { - runs, - nextCursor: - rows.length > input.limit && - last && - typeof last.run_id === 'string' && - typeof last.created_at === 'number' - ? { createdAt: last.created_at, runId: last.run_id } - : null, - }; - } - - async listSessionRunsForRecovery(sessionId: string): Promise { - assertSafeId(sessionId, 'Invalid session id'); - const rows = this.#lease.database - .prepare(` - SELECT run_id, record_json - FROM core_agent_runs - WHERE session_id = ? - ORDER BY created_at, run_id - `) - .all(sessionId) as Array<{ run_id?: unknown; record_json?: unknown }>; - return rows.map((row) => { - if (typeof row.run_id !== 'string' || typeof row.record_json !== 'string') { - throw new Error('Invalid SQLite AgentRun row'); - } - return decodePersistedAgentRunHeader(JSON.parse(row.record_json), sessionId, row.run_id); - }); - } - async appendEvent( sessionId: string, runId: string, @@ -559,11 +345,12 @@ class SqliteAgentRunStore implements DurableAgentRunStore { assertSafeId(sessionId, 'Invalid session id'); assertSafeId(runId, 'Invalid run id'); this.#lease.transaction('write', () => { - const header = readSqliteAgentRun(this.#lease.database, sessionId, runId); + const anchor = readSqliteRunAnchor(this.#lease.database, sessionId, runId); + this.#openLedgerStream(sessionId, runId, anchor.openedAt); const normalized = decodeAgentRunEvent(JSON.parse(JSON.stringify(event, sanitizeJson)), { sessionId, runId, - turnId: header.turnId, + turnId: anchor.turnId, }); const type = normalized.type as AgentRunEventType; if (type === RUN_COMPOSITION_RECORDED_EVENT_TYPE) { @@ -598,14 +385,54 @@ class SqliteAgentRunStore implements DurableAgentRunStore { // // Skipped for a subagent's run: those requests are real, but presenting // one as the SESSION's latest context attributes another agent's prompt - // to this one. The header is already loaded here, so the check is free. + // to this one. The opening fact is already loaded here, so the check is + // free. const latestContext = options.latestContext; - if (latestContext && isSessionInlineRun(header)) { + if (latestContext && anchor.sessionInline) { this.#writeLatestContextProjection(sessionId, normalized, latestContext); } }); } + /** + * Give this run's ledger its stream row, and the Session its first one. + * + * The row carries no semantic state: it is the parent `core_agent_run_events` + * hangs off and the place the model-call high water lives. Creating it on the + * first append is what stops it from being a second record of the run's + * existence — the opening fact already is that. + * + * The Session's first stream also initialises the compaction-checkpoint + * projection to an explicit empty, which is how a reader tells "no checkpoint + * yet" from "projection never built". + */ + #openLedgerStream(sessionId: string, runId: string, createdAt: number): void { + const inserted = this.#lease.database + .prepare( + 'INSERT OR IGNORE INTO core_agent_runs(session_id, run_id, created_at) VALUES (?, ?, ?)', + ) + .run(sessionId, runId, createdAt); + if (inserted.changes !== 1) return; + const count = this.#lease.database + .prepare('SELECT COUNT(*) AS count FROM core_agent_runs WHERE session_id = ?') + .get(sessionId) as { count?: unknown }; + if (count.count !== 1) return; + const projection = this.#lease.database + .prepare(` + SELECT 1 AS present + FROM core_agent_run_projections + WHERE session_id = ? AND event_type = 'history_compact_checkpoint_recorded' + `) + .get(sessionId); + if (projection) return; + this.#lease.database + .prepare(` + INSERT INTO core_agent_run_projections(session_id, event_type, event_json) + VALUES (?, 'history_compact_checkpoint_recorded', NULL) + `) + .run(sessionId); + } + /** * Monotonic by the request's own completion, not by arrival. * @@ -943,44 +770,84 @@ function readSqliteAgentRunLedgerRevision(db: DatabaseSync, sessionId: string): ); } -function normalizeCurrentAgentRunHeader( - value: unknown, - sessionId: string, - runId: string, -): AgentRunHeader { - assertSafeId(sessionId, 'Invalid session id'); - assertSafeId(runId, 'Invalid run id'); - return decodeCurrentAgentRunHeader(JSON.parse(JSON.stringify(value, sanitizeJson)), { - sessionId, - runId, - }); -} - -function decodePersistedAgentRunHeader( - value: unknown, - sessionId: string, - runId: string, -): AgentRunHeader { - assertSafeId(sessionId, 'Invalid session id'); - assertSafeId(runId, 'Invalid run id'); - return decodeAgentRunHeader(value, { sessionId, runId }); +/** + * What the operational ledger needs to know about the run it belongs to. + * + * All of it is read off the event spine rather than kept beside the ledger: the + * turn the records must agree with, when the invocation opened, and whether its + * output is the owning Session's own conversation. Copying any of it into a + * second row is what made the Run header a rival authority. + * + * An invocation whose opening the migration could not project keeps a readable + * ledger: its turn and clock come from the events it does have, and it fails + * closed on the one judgement the opening was needed for. + */ +interface LedgerRunAnchor { + turnId: string; + openedAt: number; + sessionInline: boolean; } -function readSqliteAgentRun(db: DatabaseSync, sessionId: string, runId: string): AgentRunHeader { - const row = db +function readSqliteRunAnchor(db: DatabaseSync, sessionId: string, runId: string): LedgerRunAnchor { + const opening = db .prepare(` - SELECT record_json - FROM core_agent_runs + SELECT turn_id, committed_at, payload_json + FROM runtime_events + WHERE session_id = ? AND run_id = ? AND event_kind = 'invocation_opened' + LIMIT 1 + `) + .get(sessionId, runId) as + | { turn_id: string; committed_at: number; payload_json: string } + | undefined; + if (opening) { + const content = runtimeEventInvocationOpening( + decodeRuntimeEvent(JSON.parse(opening.payload_json), { + sessionId, + runId, + turnId: opening.turn_id, + }), + ); + if (!content) throw new Error(`RuntimeEvent for run ${runId} is not an opening fact`); + return { + turnId: opening.turn_id, + openedAt: opening.committed_at, + sessionInline: isSessionInlineInvocation(content), + }; + } + const legacy = db + .prepare(` + SELECT turn_id, opened_at, opening_json + FROM runtime_legacy_invocation_openings + WHERE session_id = ? AND run_id = ? + LIMIT 1 + `) + .get(sessionId, runId) as + | { turn_id: string; opened_at: number; opening_json: string } + | undefined; + if (legacy) { + return { + turnId: legacy.turn_id, + openedAt: legacy.opened_at, + sessionInline: isSessionInlineInvocation( + decodeRuntimeInvocationOpened(JSON.parse(legacy.opening_json)), + ), + }; + } + const first = db + .prepare(` + SELECT turn_id, committed_at + FROM runtime_events WHERE session_id = ? AND run_id = ? + ORDER BY event_seq ASC + LIMIT 1 `) - .get(sessionId, runId) as { record_json?: unknown } | undefined; - if (!row) { - const error = new Error(`Agent run does not exist: ${runId}`) as NodeJS.ErrnoException; - error.code = 'ENOENT'; - throw error; + .get(sessionId, runId) as { turn_id: string; committed_at: number } | undefined; + if (first) { + return { turnId: first.turn_id, openedAt: first.committed_at, sessionInline: false }; } - if (typeof row.record_json !== 'string') throw new Error('Invalid SQLite AgentRun row'); - return decodePersistedAgentRunHeader(JSON.parse(row.record_json), sessionId, runId); + const error = new Error(`Agent run does not exist: ${runId}`) as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; } function readSqliteAgentRunEvents( @@ -997,7 +864,7 @@ function readSqliteAgentRunEvents( `) .all(sessionId, runId) as Array<{ record_json?: unknown }>; if (rows.length === 0) return []; - const header = readSqliteAgentRun(db, sessionId, runId); + const anchor = readSqliteRunAnchor(db, sessionId, runId); return rows.map((row) => { if (typeof row.record_json !== 'string') { throw new Error('Invalid SQLite AgentRun event row'); @@ -1005,7 +872,7 @@ function readSqliteAgentRunEvents( return decodeAgentRunEvent(JSON.parse(row.record_json), { sessionId, runId, - turnId: header.turnId, + turnId: anchor.turnId, }); }); } @@ -1031,11 +898,11 @@ function readSqliteRunCompositionEvent( if (typeof row.record_json !== 'string') { throw new Error('Invalid SQLite AgentRun event row'); } - const header = readSqliteAgentRun(db, sessionId, runId); + const anchor = readSqliteRunAnchor(db, sessionId, runId); return decodeAgentRunEvent(JSON.parse(row.record_json), { sessionId, runId, - turnId: header.turnId, + turnId: anchor.turnId, }); } @@ -1065,7 +932,7 @@ function readSqliteAgentRunEventsForEvidence( .all(sessionId, runId, type) ) as Array<{ sequence?: unknown; record_json?: unknown }>; if (rows.length === 0) return []; - const header = readSqliteAgentRun(db, sessionId, runId); + const anchor = readSqliteRunAnchor(db, sessionId, runId); return rows.map((row) => { const lineNumber = typeof row.sequence === 'number' && Number.isSafeInteger(row.sequence) ? row.sequence + 1 : 0; @@ -1076,7 +943,7 @@ function readSqliteAgentRunEventsForEvidence( return decodeAgentRunEvent(JSON.parse(row.record_json), { sessionId, runId, - turnId: header.turnId, + turnId: anchor.turnId, }); } catch (error) { return { @@ -1084,8 +951,8 @@ function readSqliteAgentRunEventsForEvidence( id: `run-event-corrupt-${lineNumber}`, runId, sessionId, - turnId: header.turnId, - ts: header.updatedAt, + turnId: anchor.turnId, + ts: anchor.openedAt, message: error instanceof Error ? error.message : 'Invalid SQLite AgentRun event row', data: { lineNumber }, }; @@ -1397,25 +1264,6 @@ export function rootTurnAdmissionRecordFits(input: AdmitRootTurnInput): boolean } } -const MUTABLE_AGENT_RUN_HEADER_FIELDS = new Set([ - 'status', - 'updatedAt', - 'completedAt', - 'failureClass', - 'failureMessage', - 'abortSource', - 'traceWriteError', -]); - -function assertMutableRunHeaderPatch(patch: Partial): void { - const immutable = Object.keys(patch).filter( - (key) => !MUTABLE_AGENT_RUN_HEADER_FIELDS.has(key as keyof AgentRunHeader), - ); - if (immutable.length > 0) { - throw new Error(`AgentRun admission identity is immutable: ${immutable.sort().join(', ')}`); - } -} - function shouldPreserveCheckpointProjectionDuringAppend( current: AgentRunEvent | null | undefined, candidate: AgentRunEvent, diff --git a/packages/storage/src/execution-record-codec.ts b/packages/storage/src/execution-record-codec.ts index d12f76046f..c5aa7c9048 100644 --- a/packages/storage/src/execution-record-codec.ts +++ b/packages/storage/src/execution-record-codec.ts @@ -19,10 +19,7 @@ import { decodeAgentRunEvent as decodeCanonicalAgentRunEvent, - decodeAgentRunHeader as decodeCanonicalAgentRunHeader, - decodePersistedAgentRunHeader, type AgentRunEvent, - type AgentRunHeader, } from '@maka/core/agent-run'; import { @@ -40,34 +37,6 @@ export function decodeStoredMessage(value: unknown): StoredMessage { return decodePersistedStoredMessage(markPersisted(value)); } -export function decodeAgentRunHeader( - value: unknown, - expected: { sessionId: string; runId: string }, -): AgentRunHeader { - try { - const header = decodePersistedAgentRunHeader(markPersisted(value)); - if (header.sessionId !== expected.sessionId || header.runId !== expected.runId) { - throw new Error('AgentRun header identity does not match its path'); - } - return header; - } catch (error) { - throw new Error(`Invalid AgentRun header for run ${expected.runId}: malformed fields`, { - cause: error, - }); - } -} - -export function decodeCurrentAgentRunHeader( - value: unknown, - expected: { sessionId: string; runId: string }, -): AgentRunHeader { - const header = decodeCanonicalAgentRunHeader(value); - if (header.sessionId !== expected.sessionId || header.runId !== expected.runId) { - throw new Error('AgentRun header identity does not match its path'); - } - return header; -} - export function decodeAgentRunEvent( value: unknown, expected: { sessionId: string; runId: string; turnId: string }, @@ -85,7 +54,7 @@ export function decodeAgentRunEvent( export function decodeRuntimeEvent( value: unknown, - expected: Pick, + expected: { sessionId: string; runId: string; turnId: string; invocationId?: string }, ): RuntimeEvent { const event = decodeCanonicalRuntimeEvent(value); if ( diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index 1fa1797ced..0d81d3eb6d 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -17,27 +17,19 @@ * under the License. */ -import type { - AgentRunEvent, - AgentRunEventType, - AgentRunHeader, - AgentRunProjectionKey, -} from '@maka/core/agent-run'; +import type { AgentRunEvent, AgentRunEventType, AgentRunProjectionKey } from '@maka/core/agent-run'; import type { RuntimeEvent, ToolBoundaryProtocol } from '@maka/core/runtime-event'; +import type { RuntimeContinuationAuthorityStore } from '@maka/core/runtime-event-store'; import type { - RuntimeContinuationAuthorityStore, RuntimeInvocationPageInput, RuntimeInvocationPageResult, RuntimeInvocationRecord, RuntimeInvocationSearchResult, -} from '@maka/core/runtime-event-store'; +} from '@maka/core/runtime-invocation'; import type { SessionHeader, SessionSummary, StoredMessage, TurnRecord } from '@maka/core/session'; import type { SessionListFilter } from '@maka/core/runtime-inputs'; import { createSqliteAgentRunStore, - type AgentRunIdentitySearchResult, - type AgentRunPageInput, - type AgentRunPageResult, type AdmitRootTurnInput, type AdmitRootTurnResult, type CommitRootTurnStartRejectionInput, @@ -101,9 +93,6 @@ export { } from './sqlite-session-metadata-store.js'; export type { - AgentRunIdentitySearchResult, - AgentRunPageInput, - AgentRunPageResult, AdmitRootTurnInput, AdmitRootTurnResult, CommitRootTurnStartRejectionInput, @@ -187,10 +176,6 @@ export interface ExecutionSessionReader { } export interface ExecutionAgentRunReader { - readRun(sessionId: string, runId: string): Promise; - listSessionRuns(sessionId: string): Promise; - listSessionRunsBounded(sessionId: string, limit: number): Promise; - listSessionRunsPage(sessionId: string, input: AgentRunPageInput): Promise; readEvents(sessionId: string, runId: string): Promise; readEventsBounded( sessionId: string, @@ -513,17 +498,6 @@ async function createExecutionStoresForWrite run(() => agentRunStore.createRun(header, options)), - updateRun: (sessionId, runId, patch, options) => - run(() => agentRunStore.updateRun(sessionId, runId, patch, options)), - readRun: (sessionId, runId) => run(() => agentRunStore.readRun(sessionId, runId)), - listSessionRuns: (sessionId) => run(() => agentRunStore.listSessionRuns(sessionId)), - listSessionRunsBounded: (sessionId, limit) => - run(() => agentRunStore.listSessionRunsBounded(sessionId, limit)), - listSessionRunsPage: (sessionId, input) => - run(() => agentRunStore.listSessionRunsPage(sessionId, input)), - listSessionRunsForRecovery: (sessionId) => - run(() => agentRunStore.listSessionRunsForRecovery(sessionId)), appendEvent: (sessionId, runId, event, options) => run(() => agentRunStore.appendEvent(sessionId, runId, event, options)), readEvents: (sessionId, runId) => run(() => agentRunStore.readEvents(sessionId, runId)), @@ -670,12 +644,6 @@ async function openExecutionStoresForRead run(() => agentRunStore.readRun(sessionId, runId)), - listSessionRuns: (sessionId) => run(() => agentRunStore.listSessionRuns(sessionId)), - listSessionRunsBounded: (sessionId, limit) => - run(() => agentRunStore.listSessionRunsBounded(sessionId, limit)), - listSessionRunsPage: (sessionId, input) => - run(() => agentRunStore.listSessionRunsPage(sessionId, input)), readEvents: (sessionId, runId) => run(() => agentRunStore.readEvents(sessionId, runId)), readEventsBounded: (sessionId, runId, budget) => run(() => agentRunStore.readEventsBounded(sessionId, runId, budget)), diff --git a/packages/storage/src/legacy-run-header.ts b/packages/storage/src/legacy-run-header.ts new file mode 100644 index 0000000000..d69a0965be --- /dev/null +++ b/packages/storage/src/legacy-run-header.ts @@ -0,0 +1,445 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * The Run header as builds before the invocation opening fact wrote it. + * + * Nothing writes this shape any more and no live code reads it. It lives here, + * beside the migration that consumes it, because a persisted row still carries + * it: reading old data is the only remaining reason the shape exists, and + * keeping it out of `@maka/core` is what stops it from being a second live + * authority again. + */ + +import { + decodePersistedPermissionMode, + isPermissionMode, + type PermissionMode, +} from '@maka/core/permission'; +import { isCollaborationMode, type CollaborationMode } from '@maka/core/collaboration'; +import { + isAgentSwarmAuthorizationSource, + isEffectiveOrchestrationSource, + isOrchestrationMode, + type AgentSwarmAuthorizationSource, + type EffectiveOrchestrationSource, + type OrchestrationMode, +} from '@maka/core/orchestration'; +import type { PersistedBackendKind } from '@maka/core/session'; +import { + defineObjectShape, + hasExactShape, + isFiniteNumber, + isOptionalString, + isRecord, +} from '@maka/core/record-schema'; +import { DEFAULT_TOOL_MODE, isToolMode, type ToolMode } from '@maka/core/tool-mode'; +import type { + RuntimeEventInvocationOpenedContent, + RuntimeInvocationLineage, + RuntimeInvocationOpenSource, + RuntimeInvocationRootAuthority, + RuntimeInvocationRoute, +} from '@maka/core/runtime-event'; + +const LEGACY_RUN_STATUSES = [ + 'created', + 'running', + 'waiting_for_user', + 'completed', + 'failed', + 'cancelled', +] as const; + +type LegacyRunStatus = (typeof LEGACY_RUN_STATUSES)[number]; + +interface LegacyContinuationSourceV1 { + sourceInvocationId: string; + sourceRunId: string; + sourceTurnId: string; + sourceRuntimeEventHighWater: number; +} + +interface LegacyContinuationSourceV2 extends LegacyContinuationSourceV1 { + protocol: 'continuation_source_v2'; + claimId: string; + boundaryDigest: `sha256:${string}`; + sourcePrefixDigest: `sha256:${string}`; + replayManifestDigest: `sha256:${string}`; +} + +type LegacyContinuationSource = LegacyContinuationSourceV1 | LegacyContinuationSourceV2; + +export interface LegacyRunHeader { + runId: string; + invocationId?: string; + sessionId: string; + turnId: string; + status: LegacyRunStatus; + backendKind: PersistedBackendKind; + llmConnectionId?: string; + providerStateIdentity?: `sha256:${string}`; + llmConnectionSlug: string; + modelId: string; + cwd: string; + workspaceIdentity?: string; + permissionMode: PermissionMode; + collaborationMode?: CollaborationMode; + orchestrationMode?: OrchestrationMode; + orchestrationSource?: EffectiveOrchestrationSource; + agentSwarmAuthorization?: AgentSwarmAuthorizationSource; + toolMode?: ToolMode; + createdAt: number; + updatedAt: number; + completedAt?: number; + parentRunId?: string; + resumedFromRunId?: string; + retriedFromRunId?: string; + agentId?: string; + agentName?: string; + parentTurnId?: string; + retriedFromTurnId?: string; + regeneratedFromTurnId?: string; + branchOfTurnId?: string; + parentSessionId?: string; + continuationSource?: LegacyContinuationSource; + scheduledTaskId?: string; + legacyAutomationId?: string; + goalId?: string; + agentGraphWakeId?: string; + agentGraphWakeAttemptId?: string; + rootExecutionKind?: 'context_compact'; + failureClass?: string; + failureMessage?: string; + abortSource?: string; + traceWriteError?: string; +} + +const LEGACY_RUN_HEADER_SHAPE = defineObjectShape()( + [ + 'runId', + 'sessionId', + 'turnId', + 'status', + 'backendKind', + 'llmConnectionSlug', + 'modelId', + 'cwd', + 'permissionMode', + 'createdAt', + 'updatedAt', + ], + [ + 'invocationId', + 'llmConnectionId', + 'providerStateIdentity', + 'completedAt', + 'parentRunId', + 'resumedFromRunId', + 'retriedFromRunId', + 'agentId', + 'agentName', + 'parentTurnId', + 'retriedFromTurnId', + 'regeneratedFromTurnId', + 'branchOfTurnId', + 'parentSessionId', + 'workspaceIdentity', + 'continuationSource', + 'scheduledTaskId', + 'legacyAutomationId', + 'goalId', + 'agentGraphWakeId', + 'agentGraphWakeAttemptId', + 'rootExecutionKind', + 'failureClass', + 'failureMessage', + 'abortSource', + 'traceWriteError', + 'collaborationMode', + 'orchestrationMode', + 'orchestrationSource', + 'agentSwarmAuthorization', + 'toolMode', + ], +); + +const LEGACY_CONTINUATION_SOURCE_V1_SHAPE = defineObjectShape()( + ['sourceInvocationId', 'sourceRunId', 'sourceTurnId', 'sourceRuntimeEventHighWater'], + [], +); + +const LEGACY_CONTINUATION_SOURCE_V2_SHAPE = defineObjectShape()( + [ + 'protocol', + 'sourceInvocationId', + 'sourceRunId', + 'sourceTurnId', + 'sourceRuntimeEventHighWater', + 'claimId', + 'boundaryDigest', + 'sourcePrefixDigest', + 'replayManifestDigest', + ], + [], +); + +const RETIRED_RUN_STATUSES: Readonly> = { + waiting_permission: 'waiting_for_user', +}; + +export function decodePersistedLegacyRunHeader(persisted: unknown): LegacyRunHeader { + let value = persisted; + if ( + isRecord(value) && + value.automationId !== undefined && + value.legacyAutomationId === undefined + ) { + const { automationId, ...current } = value; + value = { ...current, legacyAutomationId: automationId }; + } + if (isRecord(value)) { + const status = + typeof value.status === 'string' + ? (RETIRED_RUN_STATUSES[value.status] ?? value.status) + : value.status; + const permissionMode = decodePersistedPermissionMode(value.permissionMode); + if (status !== value.status || permissionMode !== value.permissionMode) { + value = { ...value, status, permissionMode }; + } + } + return decodeLegacyRunHeader(value); +} + +function decodeLegacyRunHeader(value: unknown): LegacyRunHeader { + if (!isRecord(value) || !hasExactShape(value, LEGACY_RUN_HEADER_SHAPE)) { + throw new Error('Invalid AgentRun header schema'); + } + const valid = + typeof value.runId === 'string' && + typeof value.sessionId === 'string' && + typeof value.turnId === 'string' && + (LEGACY_RUN_STATUSES as readonly unknown[]).includes(value.status) && + isPersistedBackendKind(value.backendKind) && + (value.llmConnectionId === undefined || + (typeof value.llmConnectionId === 'string' && value.llmConnectionId.length > 0)) && + (value.providerStateIdentity === undefined || isSha256Digest(value.providerStateIdentity)) && + typeof value.llmConnectionSlug === 'string' && + typeof value.modelId === 'string' && + typeof value.cwd === 'string' && + isPermissionMode(value.permissionMode) && + (value.collaborationMode === undefined || isCollaborationMode(value.collaborationMode)) && + (value.orchestrationMode === undefined || isOrchestrationMode(value.orchestrationMode)) && + (value.orchestrationSource === undefined || + isEffectiveOrchestrationSource(value.orchestrationSource)) && + (value.agentSwarmAuthorization === undefined || + isAgentSwarmAuthorizationSource(value.agentSwarmAuthorization)) && + (value.rootExecutionKind === undefined || value.rootExecutionKind === 'context_compact') && + Number(value.scheduledTaskId !== undefined) + + Number(value.legacyAutomationId !== undefined) + + Number(value.goalId !== undefined) + + Number(value.agentGraphWakeId !== undefined) <= + 1 && + (value.toolMode === undefined || isToolMode(value.toolMode)) && + isFiniteNumber(value.createdAt) && + isFiniteNumber(value.updatedAt) && + isOptionalString(value.invocationId) && + (value.completedAt === undefined || isFiniteNumber(value.completedAt)) && + [ + value.parentRunId, + value.resumedFromRunId, + value.retriedFromRunId, + value.agentId, + value.agentName, + value.parentTurnId, + value.retriedFromTurnId, + value.regeneratedFromTurnId, + value.branchOfTurnId, + value.parentSessionId, + value.workspaceIdentity, + value.scheduledTaskId, + value.legacyAutomationId, + value.goalId, + value.agentGraphWakeId, + value.agentGraphWakeAttemptId, + value.failureClass, + value.failureMessage, + value.abortSource, + value.traceWriteError, + ].every(isOptionalString) && + (value.continuationSource === undefined || + isLegacyContinuationSource(value.continuationSource)); + if (!valid) throw new Error('Invalid AgentRun header schema'); + return value as unknown as LegacyRunHeader; +} + +/** + * Project one legacy Run header onto its invocation opening fact. + * + * Route provenance fails closed. A header with no Connection identity cannot + * prove which endpoint and credential owned the run, so it projects as + * `unknown` rather than as an authenticated route; its transcript and tool + * evidence stay readable either way. + * + * Throws when a root authority marker is present but incomplete — that is + * corruption, and inventing a root would be worse than refusing one. + */ +export function invocationOpeningFromLegacyRunHeader( + header: LegacyRunHeader, +): RuntimeEventInvocationOpenedContent { + const lineage: RuntimeInvocationLineage = { + ...(header.parentRunId !== undefined ? { parentRunId: header.parentRunId } : {}), + ...(header.resumedFromRunId !== undefined ? { resumedFromRunId: header.resumedFromRunId } : {}), + ...(header.retriedFromRunId !== undefined ? { retriedFromRunId: header.retriedFromRunId } : {}), + ...(header.parentTurnId !== undefined ? { parentTurnId: header.parentTurnId } : {}), + ...(header.parentSessionId !== undefined ? { parentSessionId: header.parentSessionId } : {}), + ...(header.retriedFromTurnId !== undefined + ? { retriedFromTurnId: header.retriedFromTurnId } + : {}), + ...(header.regeneratedFromTurnId !== undefined + ? { regeneratedFromTurnId: header.regeneratedFromTurnId } + : {}), + ...(header.branchOfTurnId !== undefined ? { branchOfTurnId: header.branchOfTurnId } : {}), + ...(header.agentId !== undefined ? { agentId: header.agentId } : {}), + ...(header.agentName !== undefined ? { agentName: header.agentName } : {}), + }; + return { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: invocationRouteFromLegacyRunHeader(header), + configuration: { + cwd: header.cwd, + permissionMode: header.permissionMode, + collaborationMode: header.collaborationMode ?? 'agent', + orchestrationMode: header.orchestrationMode ?? 'default', + orchestrationSource: header.orchestrationSource ?? 'session', + toolMode: header.toolMode ?? DEFAULT_TOOL_MODE, + ...(header.agentSwarmAuthorization !== undefined + ? { agentSwarmAuthorization: header.agentSwarmAuthorization } + : {}), + ...(header.workspaceIdentity !== undefined + ? { workspaceIdentity: header.workspaceIdentity } + : {}), + }, + root: invocationRootFromLegacyRunHeader(header), + source: invocationOpenSourceFromLegacyRunHeader(header), + ...(Object.keys(lineage).length > 0 ? { lineage } : {}), + }; +} + +function invocationRouteFromLegacyRunHeader(header: LegacyRunHeader): RuntimeInvocationRoute { + if (header.llmConnectionId === undefined) { + return { + provenance: 'unknown', + backendKind: header.backendKind, + llmConnectionSlug: header.llmConnectionSlug, + modelId: header.modelId, + }; + } + return { + provenance: 'runtime', + backendKind: header.backendKind, + llmConnectionId: header.llmConnectionId, + llmConnectionSlug: header.llmConnectionSlug, + modelId: header.modelId, + ...(header.providerStateIdentity !== undefined + ? { providerStateIdentity: header.providerStateIdentity } + : {}), + }; +} + +function invocationRootFromLegacyRunHeader( + header: LegacyRunHeader, +): RuntimeInvocationRootAuthority { + if (header.scheduledTaskId !== undefined) { + return { kind: 'scheduled_task', scheduledTaskId: header.scheduledTaskId }; + } + if (header.goalId !== undefined) return { kind: 'goal', goalId: header.goalId }; + if (header.legacyAutomationId !== undefined) { + return { kind: 'legacy_automation', legacyAutomationId: header.legacyAutomationId }; + } + if (header.agentGraphWakeId !== undefined) { + if (header.agentGraphWakeAttemptId === undefined) { + throw new Error(`AgentRun ${header.runId} has a graph wake with no delivery attempt`); + } + return { + kind: 'agent_graph_supervisor_wake', + wakeId: header.agentGraphWakeId, + attemptId: header.agentGraphWakeAttemptId, + }; + } + if (header.rootExecutionKind === 'context_compact') return { kind: 'context_compact' }; + return { kind: 'user' }; +} + +function invocationOpenSourceFromLegacyRunHeader( + header: LegacyRunHeader, +): RuntimeInvocationOpenSource { + const source = header.continuationSource; + if (!source) return { kind: 'fresh' }; + const v2 = 'protocol' in source ? source : undefined; + return { + kind: 'continuation', + sourceInvocationId: source.sourceInvocationId, + sourceRunId: source.sourceRunId, + sourceTurnId: source.sourceTurnId, + sourceRuntimeEventHighWater: source.sourceRuntimeEventHighWater, + ...(v2 ? { claimId: v2.claimId, boundaryDigest: v2.boundaryDigest } : {}), + }; +} + +function isLegacyContinuationSource(value: unknown): value is LegacyContinuationSource { + if (!isRecord(value)) return false; + const common = + typeof value.sourceInvocationId === 'string' && + typeof value.sourceRunId === 'string' && + typeof value.sourceTurnId === 'string' && + typeof value.sourceRuntimeEventHighWater === 'number' && + Number.isSafeInteger(value.sourceRuntimeEventHighWater) && + value.sourceRuntimeEventHighWater >= 0; + if (!common) return false; + if (hasExactShape(value, LEGACY_CONTINUATION_SOURCE_V1_SHAPE)) return true; + return ( + hasExactShape(value, LEGACY_CONTINUATION_SOURCE_V2_SHAPE) && + value.protocol === 'continuation_source_v2' && + typeof value.claimId === 'string' && + value.claimId.length > 0 && + typeof value.sourceInvocationId === 'string' && + value.sourceInvocationId.length > 0 && + typeof value.sourceRunId === 'string' && + value.sourceRunId.length > 0 && + typeof value.sourceTurnId === 'string' && + value.sourceTurnId.length > 0 && + typeof value.sourceRuntimeEventHighWater === 'number' && + value.sourceRuntimeEventHighWater > 0 && + isSha256Digest(value.boundaryDigest) && + isSha256Digest(value.sourcePrefixDigest) && + isSha256Digest(value.replayManifestDigest) && + value.replayManifestDigest === value.boundaryDigest + ); +} + +/** `'fake'` stays accepted: runs written by builds that shipped FakeBackend must keep decoding (#3211). */ +function isPersistedBackendKind(value: unknown): value is PersistedBackendKind { + return value === 'ai-sdk' || value === 'fake'; +} + +function isSha256Digest(value: unknown): value is `sha256:${string}` { + return typeof value === 'string' && /^sha256:[0-9a-f]{64}$/.test(value); +} diff --git a/packages/storage/src/runtime-event-persistence.ts b/packages/storage/src/runtime-event-persistence.ts index 8c56c59d57..036e21021f 100644 --- a/packages/storage/src/runtime-event-persistence.ts +++ b/packages/storage/src/runtime-event-persistence.ts @@ -24,7 +24,7 @@ import type { RuntimeInvocationPageResult, RuntimeInvocationRecord, RuntimeInvocationSearchResult, -} from '@maka/core/runtime-event-store'; +} from '@maka/core/runtime-invocation'; import type { BoundedEvidenceReadResult, EvidenceReadBudget } from './agent-run-store.js'; import { createSqliteRuntimeStore, type SqliteRuntimeStore } from './sqlite-runtime-store.js'; import { diff --git a/packages/storage/src/sqlite-core-execution-schema.ts b/packages/storage/src/sqlite-core-execution-schema.ts index 9f49a23c19..caefc73fca 100644 --- a/packages/storage/src/sqlite-core-execution-schema.ts +++ b/packages/storage/src/sqlite-core-execution-schema.ts @@ -19,7 +19,7 @@ import type { DatabaseSync } from 'node:sqlite'; -export const SQLITE_CORE_EXECUTION_SCHEMA_VERSION = 6; +export const SQLITE_CORE_EXECUTION_SCHEMA_VERSION = 7; export function migrateSqliteCoreExecutionDatabase(db: DatabaseSync): void { db.exec(` @@ -27,7 +27,6 @@ export function migrateSqliteCoreExecutionDatabase(db: DatabaseSync): void { session_id TEXT NOT NULL, run_id TEXT NOT NULL, created_at INTEGER NOT NULL, - record_json TEXT NOT NULL, latest_model_call_sequence INTEGER CHECK (latest_model_call_sequence >= 0), PRIMARY KEY (session_id, run_id) ); @@ -148,6 +147,9 @@ export function migrateSqliteCoreExecutionDatabase(db: DatabaseSync): void { 'latest_model_call_sequence', 'INTEGER CHECK (latest_model_call_sequence >= 0)', ); + // The runtime migration runs first and has already turned every stored Run header into an + // invocation opening fact, so the row keeps only what the ledger needs to hang its events on. + dropColumn(db, 'core_agent_runs', 'record_json'); db.exec(` UPDATE core_agent_runs SET latest_model_call_sequence = ( @@ -182,3 +184,9 @@ function ensureColumn(db: DatabaseSync, table: string, column: string, definitio if (columns.some((candidate) => candidate.name === column)) return; db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); } + +function dropColumn(db: DatabaseSync, table: string, column: string): void { + const columns = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name?: unknown }>; + if (!columns.some((candidate) => candidate.name === column)) return; + db.exec(`ALTER TABLE ${table} DROP COLUMN ${column}`); +} diff --git a/packages/storage/src/sqlite-runtime-schema.ts b/packages/storage/src/sqlite-runtime-schema.ts index 3a518baee9..97021a995b 100644 --- a/packages/storage/src/sqlite-runtime-schema.ts +++ b/packages/storage/src/sqlite-runtime-schema.ts @@ -19,13 +19,12 @@ import type { DatabaseSync } from 'node:sqlite'; import { - decodePersistedAgentRunHeader, - runtimeInvocationOpeningFromRunHeader, - type AgentRunHeader, -} from '@maka/core/agent-run'; + decodePersistedLegacyRunHeader, + invocationOpeningFromLegacyRunHeader, + type LegacyRunHeader, +} from './legacy-run-header.js'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { encodeCanonicalRuntimeEvent } from '@maka/core/canonical-runtime-event'; -import type { PersistedValue } from '@maka/core/persisted-value'; export const SQLITE_RUNTIME_SCHEMA_VERSION = 16; export const RUNTIME_RECOVERY_AUTHORITY_CAPABILITY = 'runtime_recovery_authority'; @@ -554,10 +553,8 @@ function projectContinuationClaimOpenings(db: DatabaseSync): void { const remove = db.prepare('DELETE FROM runtime_continuation_claims WHERE claim_id = ?'); for (const row of rows) { try { - const header = decodePersistedAgentRunHeader( - JSON.parse(row.target_opening_json) as PersistedValue, - ); - update.run(JSON.stringify(runtimeInvocationOpeningFromRunHeader(header)), row.claim_id); + const header = decodePersistedLegacyRunHeader(JSON.parse(row.target_opening_json)); + update.run(JSON.stringify(invocationOpeningFromLegacyRunHeader(header)), row.claim_id); } catch { remove.run(row.claim_id); } @@ -581,6 +578,11 @@ function projectContinuationClaimOpenings(db: DatabaseSync): void { */ function backfillInvocationOpeningFacts(db: DatabaseSync): void { if (!hasTable(db, 'core_agent_runs')) return; + // The header column is dropped by the core-execution migration that follows + // this one, so its absence means every header it held is already an opening + // fact. Nothing left to project, and the two scopes stay independently + // replayable. + if (!hasColumn(db, 'core_agent_runs', 'record_json')) return; const rows = db .prepare(` SELECT @@ -618,13 +620,11 @@ function backfillInvocationOpeningFacts(db: DatabaseSync): void { ) VALUES (?, ?, ?, ?, ?, ?) `); for (const row of rows) { - let header: AgentRunHeader; + let header: LegacyRunHeader; let opening: string; try { - header = decodePersistedAgentRunHeader( - JSON.parse(row.record_json) as PersistedValue, - ); - opening = JSON.stringify(runtimeInvocationOpeningFromRunHeader(header)); + header = decodePersistedLegacyRunHeader(JSON.parse(row.record_json)); + opening = JSON.stringify(invocationOpeningFromLegacyRunHeader(header)); } catch { continue; } @@ -655,7 +655,7 @@ function backfillInvocationOpeningFacts(db: DatabaseSync): void { role: 'system', author: 'system', modelVisibility: 'hidden', - content: runtimeInvocationOpeningFromRunHeader(header), + content: invocationOpeningFromLegacyRunHeader(header), }); } catch { continue; @@ -674,6 +674,11 @@ function backfillInvocationOpeningFacts(db: DatabaseSync): void { } } +function hasColumn(db: DatabaseSync, table: string, column: string): boolean { + const columns = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name?: unknown }>; + return columns.some((candidate) => candidate.name === column); +} + function hasTable(db: DatabaseSync, name: string): boolean { const row = db .prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?") diff --git a/packages/storage/src/sqlite-runtime-store.ts b/packages/storage/src/sqlite-runtime-store.ts index 1acc6fac65..1a5fe861be 100644 --- a/packages/storage/src/sqlite-runtime-store.ts +++ b/packages/storage/src/sqlite-runtime-store.ts @@ -59,24 +59,20 @@ import { type ContinuationClaimResult, type ContinuationClaimStateV1, type RuntimeContinuationAuthorityStore, - type RuntimeInvocationPageCursor, - type RuntimeInvocationPageInput, - type RuntimeInvocationPageResult, - type RuntimeInvocationRecord, - type RuntimeInvocationSearchResult, type RuntimeRecoveryBundleCommit, type RuntimeRecoveryBundleStore, type RuntimeWorkspaceVersionAuthorityStore, } from '@maka/core/runtime-event-store'; +import type { + RuntimeInvocationPageCursor, + RuntimeInvocationPageInput, + RuntimeInvocationPageResult, + RuntimeInvocationRecord, + RuntimeInvocationSearchResult, +} from '@maka/core/runtime-invocation'; import { type ToolRecoveryDecisionFact } from '@maka/core/tool-recovery-fact'; import { canonicalToolArgsHash, stableJsonStringify } from '@maka/core/tool-args-identity'; import { encodeCanonicalRuntimeEvent } from '@maka/core/canonical-runtime-event'; -import { - decodePersistedAgentRunHeader, - runtimeInvocationOpeningFromRunHeader, - type AgentRunHeader, -} from '@maka/core/agent-run'; -import { markPersisted } from '@maka/core/persisted-value'; import { scanToolLedger, ToolLedgerCorruptionError, From 1c9d2bf4bd420883ceff6b9ed3e67540b086c4be Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 11:59:56 +0800 Subject: [PATCH 11/46] refactor(runtime): read every run off the invocation spine The runtime read side still went through the AgentRun header for everything a run was: its route, its configuration, its lineage, its status and its failure class. Every one of those now lives on the invocation's opening fact and its terminal event, so the header was a second copy that had to be kept in step, and a repair pass existed only to put the two back together when it was not. Enumerating a Session's runs is now one query over the events, and the facts a listing shows are derived from them in one place. What that retires: - the run-header/terminal-fact reconciliation: the read-model repair loop, `repairMissingTerminalFactOnce`, `firstRuntimeRepairRunId` and `effectiveRunHeaderFromTerminalFact`, plus the `repairRunRuntimeLedger` hook that carried it into every AgentRun; - the continuation claim's second target record: a claim freezes the target's opening fact and nothing else, and the lineage walk reads its edges off the continuation-start event that authenticated them; - the recovery classifier's status branches, which asked the header what the events already say; - the conversation copy's cloned Run header, whose lineage rewriting now happens on the opening event like every other reference. An admitted Turn that never reached a run is opened and closed on the spine by recovery, so it ends up shaped like every other Turn. Generated-by: Claude Code --- packages/core/src/runtime-event-store.ts | 2 +- packages/core/src/runtime-invocation.ts | 38 + .../src/__tests__/ai-sdk-backend.test.ts | 4 +- .../src/__tests__/history-compaction.test.ts | 51 +- .../src/agent-graph-supervisor-wake.ts | 12 +- packages/runtime/src/agent-graph-timeline.ts | 45 +- packages/runtime/src/agent-run-inspect.ts | 179 ++--- packages/runtime/src/agent-run-recovery.ts | 127 ++-- packages/runtime/src/agent-run.ts | 533 ++++---------- packages/runtime/src/ai-sdk-backend.ts | 4 +- .../runtime/src/ai-sdk-compaction-contract.ts | 4 +- packages/runtime/src/ai-sdk-compaction.ts | 49 +- packages/runtime/src/context-diagnostics.ts | 23 +- packages/runtime/src/continuation-replay.ts | 6 +- packages/runtime/src/conversation-copy.ts | 187 ++--- packages/runtime/src/execution-inspect.ts | 95 +-- .../history-compact-checkpoint-coordinator.ts | 26 +- .../runtime/src/history-compact-ledger.ts | 20 +- packages/runtime/src/history-compaction.ts | 24 +- packages/runtime/src/message-authority.ts | 4 +- packages/runtime/src/model-history.ts | 23 +- .../src/model-projection-transition-ledger.ts | 7 +- .../src/openai-codex-history-compactor.ts | 2 +- packages/runtime/src/prior-run-context.ts | 159 +--- .../runtime/src/runtime-event-backfill.ts | 55 +- .../runtime/src/runtime-event-read-model.ts | 134 ++-- packages/runtime/src/runtime-kernel.ts | 206 +++--- packages/runtime/src/runtime-ledger-repair.ts | 635 +++------------- packages/runtime/src/runtime-read-model.ts | 204 ++--- packages/runtime/src/runtime-resume.ts | 148 ++-- packages/runtime/src/session-manager.ts | 696 +++++++++--------- .../runtime/src/session-projection-helpers.ts | 10 +- .../runtime/src/stream-graph-coordinator.ts | 9 +- .../runtime/src/stream-graph-projection.ts | 55 +- packages/runtime/src/terminal-run-commit.ts | 256 ++----- 35 files changed, 1513 insertions(+), 2519 deletions(-) diff --git a/packages/core/src/runtime-event-store.ts b/packages/core/src/runtime-event-store.ts index a96011acb2..a599d521da 100644 --- a/packages/core/src/runtime-event-store.ts +++ b/packages/core/src/runtime-event-store.ts @@ -81,7 +81,7 @@ export interface RuntimeEventStore { * same inventory. Reserved control-plane invocation streams have no opening * fact and therefore never appear here. */ - listSessionInvocations?(sessionId: string): Promise; + listSessionInvocations(sessionId: string): Promise; appendRuntimeEvent( sessionId: string, runId: string, diff --git a/packages/core/src/runtime-invocation.ts b/packages/core/src/runtime-invocation.ts index 2856ed63a0..d1dc5fc27a 100644 --- a/packages/core/src/runtime-invocation.ts +++ b/packages/core/src/runtime-invocation.ts @@ -32,6 +32,7 @@ import type { RuntimeEventInvocationOpenedContent, RuntimeInvocationLineage, } from './runtime-event.js'; +import { isTerminalRuntimeEvent } from './runtime-event.js'; export interface RuntimeInvocationRecord { sessionId: string; @@ -44,6 +45,43 @@ export interface RuntimeInvocationRecord { terminalEvent?: RuntimeEvent; } +/** + * Rebuild a Session's invocation inventory from its events alone. + * + * This is the definition of the inventory, not a cache of it: a store that + * holds the Session's events can answer `listSessionInvocations` with this and + * get exactly what an indexed store returns. Events whose invocation never + * opened are control-plane streams and are absent by construction. + */ +export function runtimeInvocationsFromSessionEvents( + sessionId: string, + events: readonly RuntimeEvent[], +): RuntimeInvocationRecord[] { + const byInvocation = new Map(); + for (const event of events) { + if (event.sessionId !== sessionId || event.partial === true) continue; + if (event.content?.kind === 'invocation_opened') { + byInvocation.set(event.invocationId, { + sessionId, + invocationId: event.invocationId, + runId: event.runId, + turnId: event.turnId, + openedAt: event.ts, + opening: event.content, + }); + } + } + for (const event of events) { + if (event.sessionId !== sessionId || event.partial === true) continue; + if (!isTerminalRuntimeEvent(event)) continue; + const record = byInvocation.get(event.invocationId); + if (record) record.terminalEvent = event; + } + return [...byInvocation.values()].sort( + (a, b) => a.openedAt - b.openedAt || a.invocationId.localeCompare(b.invocationId), + ); +} + /** One invocation's position in a Session's opening order. */ export interface RuntimeInvocationPageCursor { readonly openedAt: number; diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 926ba7c698..e85997f7bf 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -4409,8 +4409,8 @@ describe('AiSdkBackend model history', () => { const result = await backend.compactHistory({ turnId: 'turn-compact', runId: 'run-1', - runtimeContextRunHeaders: [ - priorModelRunHeader({ connectionId: 'test-connection-id', modelId: 'mock-model-id' }), + runtimeContextInvocations: [ + priorModelInvocation({ connectionId: 'test-connection-id', modelId: 'mock-model-id' }), ], runtimeContext: [ runtimeTextEvent({ diff --git a/packages/runtime/src/__tests__/history-compaction.test.ts b/packages/runtime/src/__tests__/history-compaction.test.ts index 64432af88e..e4d66c4f10 100644 --- a/packages/runtime/src/__tests__/history-compaction.test.ts +++ b/packages/runtime/src/__tests__/history-compaction.test.ts @@ -19,7 +19,6 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import type { AgentRunHeader } from '@maka/core/agent-run'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { applyRuntimeEventHistoryCompact, @@ -28,6 +27,7 @@ import { type PlanHistoryCompactionInput, } from '../history-compaction.js'; import { HistoryCompactSummarizerError } from '../history-compact-summarizer.js'; +import { testInvocationRecord } from './invocation-fixture.js'; import { matchHistoryCompactCheckpointPrefix } from '../history-compact-checkpoint.js'; describe('safe compaction prefix selection', () => { @@ -192,7 +192,7 @@ describe('plan context compaction', () => { planInput({ phase: 'standalone', orderedEvents: events, - runHeaders: HEADERS_A, + invocations: RUNS_A, acceptedRoute: ROUTE_A, reserveTailEvents: 0, summarize: ({ coveredRuntimeEvents }) => { @@ -233,7 +233,7 @@ describe('plan context compaction', () => { planInput({ phase: 'standalone', orderedEvents: first, - runHeaders: HEADERS_A, + invocations: RUNS_A, acceptedRoute: ROUTE_A, reserveTailEvents: 0, summarize: () => { @@ -257,7 +257,7 @@ describe('plan context compaction', () => { planInput({ phase: 'standalone', orderedEvents: later, - runHeaders: HEADERS_A, + invocations: RUNS_A, acceptedRoute: ROUTE_A, reserveTailEvents: 0, previousCheckpoint: retreated.checkpoint, @@ -286,7 +286,7 @@ describe('plan context compaction', () => { planInput({ phase: 'standalone', orderedEvents: events, - runHeaders: HEADERS_A, + invocations: RUNS_A, acceptedRoute: ROUTE_A, reserveTailEvents: 0, summarize: () => { @@ -305,7 +305,7 @@ describe('plan context compaction', () => { test("a mixed-route session retreats to this route's own newest reply", async () => { // History can span runs on several routes. A span another model accepted // proves nothing about this summarizer's window, so the retreat targets the - // newest reply THIS route produced, found through the run headers. + // newest reply THIS route produced, found through each run's opening. const events = [ user('old-user', 'old-turn'), modelOnRun('mine', 'old-turn', 'run-1', 'accepted by this route'), @@ -317,10 +317,7 @@ describe('plan context compaction', () => { planInput({ phase: 'standalone', orderedEvents: events, - runHeaders: [ - runHeader('run-1', 'model-a', 'conn-a'), - runHeader('run-2', 'model-b', 'conn-b'), - ], + invocations: [runOn('run-1', 'model-a', 'conn-a'), runOn('run-2', 'model-b', 'conn-b')], acceptedRoute: ROUTE_A, reserveTailEvents: 0, summarize: ({ coveredRuntimeEvents }) => { @@ -348,7 +345,7 @@ describe('plan context compaction', () => { planInput({ phase: 'standalone', orderedEvents: [user('u1', 't1'), modelOnRun('theirs', 't1', 'run-2')], - runHeaders: [runHeader('run-2', 'model-b', 'conn-b')], + invocations: [runOn('run-2', 'model-b', 'conn-b')], acceptedRoute: ROUTE_A, reserveTailEvents: 0, summarize: () => { @@ -370,7 +367,7 @@ describe('plan context compaction', () => { planInput({ phase: 'standalone', orderedEvents: [user('u1', 't1'), user('u2', 't1'), user('u3', 't2')], - runHeaders: HEADERS_A, + invocations: RUNS_A, acceptedRoute: ROUTE_A, reserveTailEvents: 0, summarize: () => { @@ -560,24 +557,26 @@ function model(id: string, turnId: string, text: string = id): RuntimeEvent { function modelOnRun(id: string, turnId: string, runId: string, text: string = id): RuntimeEvent { return { ...model(id, turnId, text), runId, invocationId: runId }; } -function runHeader(runId: string, modelId: string, llmConnectionId: string): AgentRunHeader { - return { - runId, +/** A completed run opened on the named route. */ +function runOn(runId: string, modelId: string, llmConnectionId: string) { + return testInvocationRecord({ sessionId: 'session-1', + runId, turnId: 'turn-1', - status: 'completed', - backendKind: 'ai-sdk', - llmConnectionId, - llmConnectionSlug: llmConnectionId, - modelId, - cwd: '/tmp/maka', - permissionMode: 'ask', - createdAt: 1_800_000_000_000, - updatedAt: 1_800_000_000_000, - }; + outcome: 'completed', + opening: { + route: { + provenance: 'runtime', + backendKind: 'ai-sdk', + llmConnectionId, + llmConnectionSlug: llmConnectionId, + modelId, + }, + }, + }); } const ROUTE_A = { modelId: 'model-a', connectionId: 'conn-a' }; -const HEADERS_A = [runHeader('run-1', 'model-a', 'conn-a')]; +const RUNS_A = [runOn('run-1', 'model-a', 'conn-a')]; function call(id: string, callId: string, turnId: string): RuntimeEvent { return { diff --git a/packages/runtime/src/agent-graph-supervisor-wake.ts b/packages/runtime/src/agent-graph-supervisor-wake.ts index 30e96a28ae..30d088a552 100644 --- a/packages/runtime/src/agent-graph-supervisor-wake.ts +++ b/packages/runtime/src/agent-graph-supervisor-wake.ts @@ -23,9 +23,9 @@ import { type AgentGraphSupervisorWakeStore, } from '@maka/core/agent-graph-supervisor-wake'; import type { ContextCompactionOutcome } from '@maka/core/events'; -import { type AgentRunHeader } from '@maka/core/agent-run'; import { type SessionEvent } from '@maka/core/events'; import { type UserMessageInput } from '@maka/core/runtime-inputs'; +import type { RuntimeInvocationOutcome } from '@maka/core/runtime-invocation'; import type { GoalTurnOutcome, SessionActivityLease, @@ -174,6 +174,14 @@ export type AgentGraphSupervisorWakeDiagnostic = }; }; +/** + * What the delivering invocation has to say for itself when the wake is settled. + * + * An invocation the events never closed is `running`, whether it is still on a + * provider or was parked on an interaction the host restart threw away. + */ +export type AgentGraphWakeAttemptStatus = RuntimeInvocationOutcome | 'running' | 'missing'; + export interface AgentGraphSupervisorWakeInput { activityRegistry: SessionActivityRegistry; wakeStore: AgentGraphSupervisorWakeStore; @@ -189,7 +197,7 @@ export interface AgentGraphSupervisorWakeInput { rootSessionId: string, attemptId: string, turnId: string, - ): Promise; + ): Promise; shouldWake?( rootSessionId: string, result: AgentGraphScheduleReconciliationResult | undefined, diff --git a/packages/runtime/src/agent-graph-timeline.ts b/packages/runtime/src/agent-graph-timeline.ts index 08d31fb4ae..d282d08100 100644 --- a/packages/runtime/src/agent-graph-timeline.ts +++ b/packages/runtime/src/agent-graph-timeline.ts @@ -27,7 +27,8 @@ import type { AgentGraphTimelineMetadataSnapshot, AgentGraphTimelineMetadataStore, } from '@maka/core/agent-graph-timeline'; -import type { AgentRunHeader, AgentRunStore } from '@maka/core/agent-run'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; +import { runtimeInvocationOutcome } from '@maka/core/runtime-invocation'; import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; import { stableHash } from './request-shape.js'; import { @@ -72,7 +73,7 @@ export type AgentGraphTimelineEvent = | (AgentGraphTimelineEventBase & { kind: 'supervisor_turn_terminal'; run: AgentGraphTimelineRunRef; - status: Extract; + status: 'completed' | 'failed' | 'cancelled'; wake?: { wakeId: string; attemptId: string }; }) | (AgentGraphTimelineEventBase & { @@ -200,8 +201,10 @@ export interface ReadAgentGraphTimelinePageInput { rootSessionId: string; graphId: string; controlStore: AgentGraphTimelineMetadataStore; - runStore: Pick; - runtimeEventStore: Pick; + runtimeEventStore: Pick< + RuntimeEventStore, + 'readImmutableRuntimeEvents' | 'listSessionInvocations' + >; options?: AgentGraphTimelinePageOptions; } @@ -209,8 +212,8 @@ export interface BuildAgentGraphTimelineInput { rootSessionId: string; graphId: string; metadata: AgentGraphTimelineMetadataSnapshot; - rootRuns: readonly AgentRunHeader[]; - childRuns: readonly AgentRunHeader[]; + rootRuns: readonly RuntimeInvocationRecord[]; + childRuns: readonly RuntimeInvocationRecord[]; projection: AgentGraphProjection; } @@ -259,11 +262,10 @@ export async function readAgentGraphTimelinePage( sessionId: provision.targetSessionId, })); const [rootRuns, projected] = await Promise.all([ - input.runStore.listSessionRuns(input.rootSessionId), + input.runtimeEventStore.listSessionInvocations(input.rootSessionId), readCommittedAgentGraphProjectionWithRuns({ graphId: input.graphId, operators, - runStore: input.runStore, runtimeEventStore: input.runtimeEventStore, }), ]); @@ -303,9 +305,10 @@ export function buildAgentGraphTimeline( ), ); for (const run of input.rootRuns) { + const root = run.opening.root; const wake = - run.agentGraphWakeId && run.agentGraphWakeAttemptId - ? { wakeId: run.agentGraphWakeId, attemptId: run.agentGraphWakeAttemptId } + root.kind === 'agent_graph_supervisor_wake' + ? { wakeId: root.wakeId, attemptId: root.attemptId } : undefined; if (!relevantRootRunIds.has(run.runId) && !wakeAttemptIds.has(wake?.attemptId ?? '')) { continue; @@ -320,16 +323,14 @@ export function buildAgentGraphTimeline( sessionId: run.sessionId, runId: run.runId, }, - run.createdAt, + run.openedAt, ), kind: 'supervisor_turn_started', run: runRef, ...(wake ? { wake } : {}), }); - if ( - run.completedAt !== undefined && - (run.status === 'completed' || run.status === 'failed' || run.status === 'cancelled') - ) { + const outcome = runtimeInvocationOutcome(run); + if (outcome && run.terminalEvent) { push({ ...eventBase( input.graphId, @@ -337,13 +338,13 @@ export function buildAgentGraphTimeline( { sessionId: run.sessionId, runId: run.runId, - status: run.status, + status: outcome, }, - run.completedAt, + run.terminalEvent.ts, ), kind: 'supervisor_turn_terminal', run: runRef, - status: run.status, + status: outcome, ...(wake ? { wake } : {}), }); } @@ -408,7 +409,7 @@ export function buildAgentGraphTimeline( provision.targetSessionId, ]), ); - const childRunByIdentity = new Map(); + const childRunByIdentity = new Map(); for (const run of input.childRuns) { const key = `${run.sessionId}\0${run.runId}`; if (childRunByIdentity.has(key)) { @@ -447,7 +448,7 @@ export function buildAgentGraphTimeline( input.graphId, 'activation_started', { operatorId: claim.targetOperatorId, runId: run.runId }, - run.createdAt, + run.openedAt, ), kind: 'activation_started', operatorId: claim.targetOperatorId, @@ -751,7 +752,7 @@ function eventBase( }; } -function timelineRunRef(run: AgentRunHeader): AgentGraphTimelineRunRef { +function timelineRunRef(run: RuntimeInvocationRecord): AgentGraphTimelineRunRef { return { sessionId: run.sessionId, runId: run.runId, @@ -759,7 +760,7 @@ function timelineRunRef(run: AgentRunHeader): AgentGraphTimelineRunRef { }; } -function assertRunRef(run: AgentRunHeader, sessionId: string): void { +function assertRunRef(run: RuntimeInvocationRecord, sessionId: string): void { if (run.sessionId !== sessionId) { throw new Error(`AgentRun ${run.runId} belongs to ${run.sessionId}, expected ${sessionId}`); } diff --git a/packages/runtime/src/agent-run-inspect.ts b/packages/runtime/src/agent-run-inspect.ts index 977f97cd42..1f389a170d 100644 --- a/packages/runtime/src/agent-run-inspect.ts +++ b/packages/runtime/src/agent-run-inspect.ts @@ -17,9 +17,10 @@ * under the License. */ -import type { AgentRunEvent, AgentRunHeader, AgentRunStore } from '@maka/core/agent-run'; +import type { AgentRunEvent, AgentRunStore } from '@maka/core/agent-run'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { StoredMessage } from '@maka/core/session'; import { classifyRuntimeEventTerminalFact, @@ -35,11 +36,9 @@ import { export type AgentRunInspectDiagnosticCode = | 'operational_ledger_read_failed' | 'operational_event_corrupt' - | 'operational_terminal_missing' | 'missing_runtime_ledger' | 'runtime_ledger_read_failed' | 'runtime_terminal_missing' - | 'status_consistency_mismatch' | RuntimeEventReadModelDiagnostic['code']; export interface AgentRunInspectDiagnostic { @@ -54,8 +53,6 @@ export interface AgentRunInspectDiagnostic { export interface AgentRunInspectSourceHealth { runtimeLedger: 'present' | 'missing' | 'read_failed'; runtimeTerminalPresent: boolean; - operationalTerminalPresent: boolean; - statusConsistency: 'consistent' | 'inconsistent' | 'incomplete'; } export interface AgentRunInspectProjectionSummary { @@ -64,11 +61,10 @@ export interface AgentRunInspectProjectionSummary { } export interface AgentRunInspectModel { - header: AgentRunHeader; + invocation: RuntimeInvocationRecord; events: AgentRunEvent[]; runtimeEvents: RuntimeEvent[]; terminalRuntimeFact?: RuntimeEventTerminalFact; - operationalTerminalEvent?: AgentRunEvent; modelReplay?: RuntimeEventModelReplayPlan; projection?: AgentRunInspectProjectionSummary; sourceHealth: AgentRunInspectSourceHealth; @@ -78,63 +74,57 @@ export interface AgentRunInspectModel { export interface InspectAgentRunOptions { sessionId: string; runId: string; - header?: AgentRunHeader; + invocation?: RuntimeInvocationRecord; isFatalReadError?: (error: unknown) => boolean; includeModelReplay?: boolean; } -export type AgentRunInspectReader = Pick; +export type AgentRunInspectReader = Pick; -export type SessionAgentRunInspectReader = AgentRunInspectReader & - Pick; - -export type RuntimeEventInspectReader = Pick; +export type RuntimeEventInspectReader = Pick & + Required> & { + readInvocation?(sessionId: string, invocationId: string): Promise; + }; +/** + * One run, read from both ledgers it actually has: the RuntimeEvent spine that + * owns its facts, and the AgentRunEvent ledger that records what the runtime did + * operationally. There is no third record to reconcile them against any more. + */ export async function inspectAgentRunReadModel( runStore: AgentRunInspectReader, runtimeEventStore: RuntimeEventInspectReader, options: InspectAgentRunOptions, ): Promise { - const header = options.header ?? (await runStore.readRun(options.sessionId, options.runId)); + const invocation = options.invocation ?? (await readInvocation(runtimeEventStore, options)); const diagnostics: AgentRunInspectDiagnostic[] = []; const events = await readOperationalEvents( runStore, - header, + invocation, diagnostics, options.isFatalReadError, ); const runtimeRead = await readRuntimeEvents( runtimeEventStore, - header, + invocation, diagnostics, options.isFatalReadError, ); const runtimeEvents = runtimeRead.events; - const operationalTerminalEvent = latestOperationalTerminalEvent(events); - if (!operationalTerminalEvent) { - diagnostics.push( - inspectDiagnostic( - header, - 'operational_terminal_missing', - 'operational AgentRunEvent ledger has no terminal run event', - ), - ); - } - let terminalRuntimeFact: RuntimeEventTerminalFact | undefined; - if (runtimeRead.state === 'present') { - const terminalFactResult = classifyRuntimeEventTerminalFact(header, runtimeEvents); + if (runtimeRead.state === 'present' && invocation.terminalEvent) { + const terminalFactResult = classifyRuntimeEventTerminalFact(invocation, runtimeEvents); terminalRuntimeFact = terminalFactResult.fact; diagnostics.push( ...terminalFactResult.diagnostics.map((diagnostic) => - fromRuntimeReadModelDiagnostic(header, diagnostic), + fromRuntimeReadModelDiagnostic(invocation, diagnostic), ), ); if (!terminalRuntimeFact) { diagnostics.push( inspectDiagnostic( - header, + invocation, 'runtime_terminal_missing', 'runtime ledger has no complete terminal RuntimeEvent fact', ), @@ -144,12 +134,12 @@ export async function inspectAgentRunReadModel( const projection = runtimeEvents.length > 0 - ? projectRuntimeEventsToStoredMessages(runtimeEvents, { runHeaders: [header] }) + ? projectRuntimeEventsToStoredMessages(runtimeEvents, { invocations: [invocation] }) : undefined; if (projection) { diagnostics.push( ...projection.diagnostics.map((diagnostic) => - fromRuntimeReadModelDiagnostic(header, diagnostic), + fromRuntimeReadModelDiagnostic(invocation, diagnostic), ), ); } @@ -159,60 +149,35 @@ export async function inspectAgentRunReadModel( ? buildRuntimeEventModelReplayPlan(runtimeEvents) : undefined; - const statusConsistency = computeStatusConsistency( - header, - operationalTerminalEvent, - terminalRuntimeFact, - ); - if (statusConsistency === 'inconsistent') { - diagnostics.push( - inspectDiagnostic( - header, - 'status_consistency_mismatch', - 'AgentRunHeader, operational terminal event, and RuntimeEvent terminal fact disagree', - { - headerStatus: header.status, - operationalStatus: operationalTerminalEvent - ? operationalStatusFor(operationalTerminalEvent) - : undefined, - runtimeStatus: terminalRuntimeFact?.runStatus, - }, - ), - ); - } - return { - header, + invocation, events, runtimeEvents, ...(terminalRuntimeFact ? { terminalRuntimeFact } : {}), - ...(operationalTerminalEvent ? { operationalTerminalEvent } : {}), ...(modelReplay ? { modelReplay } : {}), ...(projection ? { projection } : {}), sourceHealth: { runtimeLedger: runtimeRead.state, runtimeTerminalPresent: terminalRuntimeFact !== undefined, - operationalTerminalPresent: operationalTerminalEvent !== undefined, - statusConsistency, }, diagnostics, }; } export async function inspectSessionRunReadModels( - runStore: SessionAgentRunInspectReader, + runStore: AgentRunInspectReader, runtimeEventStore: RuntimeEventInspectReader, sessionId: string, options: Pick = {}, ): Promise { - const headers = await runStore.listSessionRuns(sessionId); + const invocations = await runtimeEventStore.listSessionInvocations(sessionId); const models: AgentRunInspectModel[] = []; - for (const header of headers) { + for (const invocation of invocations) { models.push( await inspectAgentRunReadModel(runStore, runtimeEventStore, { sessionId, - runId: header.runId, - header, + runId: invocation.runId, + invocation, ...(options.isFatalReadError ? { isFatalReadError: options.isFatalReadError } : {}), }), ); @@ -220,19 +185,33 @@ export async function inspectSessionRunReadModels( return models; } +async function readInvocation( + runtimeEventStore: RuntimeEventInspectReader, + options: InspectAgentRunOptions, +): Promise { + if (runtimeEventStore.readInvocation) { + return runtimeEventStore.readInvocation(options.sessionId, options.runId); + } + const found = (await runtimeEventStore.listSessionInvocations(options.sessionId)).find( + (invocation) => invocation.runId === options.runId, + ); + if (!found) throw new Error(`Runtime invocation not found: ${options.runId}`); + return found; +} + async function readOperationalEvents( runStore: AgentRunInspectReader, - header: AgentRunHeader, + invocation: RuntimeInvocationRecord, diagnostics: AgentRunInspectDiagnostic[], isFatalReadError: InspectAgentRunOptions['isFatalReadError'], ): Promise { try { - const events = await runStore.readEvents(header.sessionId, header.runId); + const events = await runStore.readEvents(invocation.sessionId, invocation.runId); for (const event of events) { if (event.type !== 'event_corrupt') continue; diagnostics.push( inspectDiagnostic( - header, + invocation, 'operational_event_corrupt', 'operational AgentRunEvent ledger contains a corrupt row', event.data, @@ -245,7 +224,7 @@ async function readOperationalEvents( if (isFatalReadError?.(error)) throw error; diagnostics.push( inspectDiagnostic( - header, + invocation, 'operational_ledger_read_failed', 'AgentRunStore.readEvents failed', errorMessage(error), @@ -257,16 +236,19 @@ async function readOperationalEvents( async function readRuntimeEvents( runtimeEventStore: RuntimeEventInspectReader, - header: AgentRunHeader, + invocation: RuntimeInvocationRecord, diagnostics: AgentRunInspectDiagnostic[], isFatalReadError: InspectAgentRunOptions['isFatalReadError'], ): Promise<{ state: AgentRunInspectSourceHealth['runtimeLedger']; events: RuntimeEvent[] }> { try { - const events = await runtimeEventStore.readRuntimeEvents(header.sessionId, header.runId); + const events = await runtimeEventStore.readRuntimeEvents( + invocation.sessionId, + invocation.runId, + ); if (events.length === 0) { diagnostics.push( inspectDiagnostic( - header, + invocation, 'missing_runtime_ledger', 'runtime-events ledger is missing or empty for this run', ), @@ -278,7 +260,7 @@ async function readRuntimeEvents( if (isFatalReadError?.(error)) throw error; diagnostics.push( inspectDiagnostic( - header, + invocation, 'runtime_ledger_read_failed', 'RuntimeEventStore.readRuntimeEvents failed', errorMessage(error), @@ -288,55 +270,14 @@ async function readRuntimeEvents( } } -function computeStatusConsistency( - header: AgentRunHeader, - operationalTerminalEvent: AgentRunEvent | undefined, - terminalRuntimeFact: RuntimeEventTerminalFact | undefined, -): AgentRunInspectSourceHealth['statusConsistency'] { - const statuses = [ - isTerminalRunStatus(header.status) ? header.status : undefined, - operationalTerminalEvent ? operationalStatusFor(operationalTerminalEvent) : undefined, - terminalRuntimeFact?.runStatus, - ].filter((status): status is 'completed' | 'failed' | 'cancelled' => status !== undefined); - - if (statuses.length < 2) return 'incomplete'; - return statuses.every((status) => status === statuses[0]) ? 'consistent' : 'inconsistent'; -} - -function latestOperationalTerminalEvent( - events: readonly AgentRunEvent[], -): AgentRunEvent | undefined { - for (let index = events.length - 1; index >= 0; index -= 1) { - const event = events[index]; - if (!event) continue; - if (operationalStatusFor(event)) return event; - } - return undefined; -} - -function operationalStatusFor( - event: AgentRunEvent, -): 'completed' | 'failed' | 'cancelled' | undefined { - if (event.type === 'run_completed') return 'completed'; - if (event.type === 'run_failed') return 'failed'; - if (event.type === 'run_cancelled') return 'cancelled'; - return undefined; -} - -function isTerminalRunStatus( - status: AgentRunHeader['status'], -): status is 'completed' | 'failed' | 'cancelled' { - return status === 'completed' || status === 'failed' || status === 'cancelled'; -} - function fromRuntimeReadModelDiagnostic( - header: AgentRunHeader, + invocation: RuntimeInvocationRecord, diagnostic: RuntimeEventReadModelDiagnostic, ): AgentRunInspectDiagnostic { return { code: diagnostic.code, - runId: diagnostic.runId ?? header.runId, - turnId: diagnostic.turnId ?? header.turnId, + runId: diagnostic.runId ?? invocation.runId, + turnId: diagnostic.turnId ?? invocation.turnId, message: diagnostic.message, ...(diagnostic.eventId ? { eventId: diagnostic.eventId } : {}), ...(diagnostic.detail !== undefined ? { detail: diagnostic.detail } : {}), @@ -344,7 +285,7 @@ function fromRuntimeReadModelDiagnostic( } function inspectDiagnostic( - header: AgentRunHeader, + invocation: RuntimeInvocationRecord, code: AgentRunInspectDiagnosticCode, message: string, detail?: unknown, @@ -352,8 +293,8 @@ function inspectDiagnostic( ): AgentRunInspectDiagnostic { return { code, - runId: header.runId, - turnId: header.turnId, + runId: invocation.runId, + turnId: invocation.turnId, message, ...(eventId ? { eventId } : {}), ...(detail !== undefined ? { detail } : {}), diff --git a/packages/runtime/src/agent-run-recovery.ts b/packages/runtime/src/agent-run-recovery.ts index 7a04ef94b0..c38c5a7076 100644 --- a/packages/runtime/src/agent-run-recovery.ts +++ b/packages/runtime/src/agent-run-recovery.ts @@ -21,7 +21,9 @@ import { SANDBOX_BOUNDARY_RESTART_CLOSURE_CLASS, isSandboxBoundaryRestartClosure, } from '@maka/core/sandbox-boundary'; -import type { AgentRunEvent, AgentRunHeader } from '@maka/core/agent-run'; +import type { AgentRunEvent } from '@maka/core/agent-run'; +import type { RuntimeInvocationLineage } from '@maka/core/runtime-event'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { SandboxBoundaryRequest } from '@maka/core/sandbox-boundary'; export interface AgentRunRecoveryDecision { @@ -34,78 +36,49 @@ export interface AgentRunRecoveryDecision { lineage: AgentRunRecoveryLineage; } -type AgentRunRecoveryLineage = Partial< - Pick< - AgentRunHeader, - | 'parentRunId' - | 'parentTurnId' - | 'retriedFromTurnId' - | 'regeneratedFromTurnId' - | 'branchOfTurnId' - | 'parentSessionId' - > +type AgentRunRecoveryLineage = Pick< + RuntimeInvocationLineage, + | 'parentRunId' + | 'parentTurnId' + | 'retriedFromTurnId' + | 'regeneratedFromTurnId' + | 'branchOfTurnId' + | 'parentSessionId' >; +/** + * Why a run the events never closed has to be failed closed. + * + * The caller has already established that there is no terminal event, so the + * outcome is settled before this runs. All that is left is to say what the run + * was doing when the host went away, and only its own ledger can say that. + */ export function classifyAgentRunRecovery( - header: AgentRunHeader, + invocation: RuntimeInvocationRecord, events: readonly AgentRunEvent[], -): AgentRunRecoveryDecision | undefined { - if (isTerminalRunStatus(header.status)) return undefined; - +): AgentRunRecoveryDecision { const lastEvent = lastNonCorruptEvent(events); const hasCorruptEvent = events.some((event) => event.type === 'event_corrupt'); const lastEventType = lastEvent?.type; - if (lastEventType === 'model_stream_completed' && !hasTerminalRunEvent(events)) { - return failedDecision( - header, - 'app_restarted', - diagnostic('model_stream_completed_without_runtime_terminal', lastEventType, hasCorruptEvent), - ); - } - - if ( - header.status === 'waiting_for_user' || - lastEventType === 'permission_requested' || - lastEventType === 'permission_failed' - ) { - return failedDecision( - header, - 'app_restarted', - diagnostic('stale_user_wait', lastEventType, hasCorruptEvent), - ); - } - - if (lastEventType === 'tool_started') { - return failedDecision( - header, - 'app_restarted', - diagnostic('tool_interrupted', lastEventType, hasCorruptEvent), - ); - } - - if ( - header.status === 'created' || - header.status === 'running' || - lastEventType === undefined || - lastEventType === 'run_created' || - lastEventType === 'run_started' || - lastEventType === 'turn_started' || - lastEventType === 'model_resolved' || - lastEventType === 'model_stream_started' || - lastEventType === 'run_status_changed' - ) { - return failedDecision( - header, - 'app_restarted', - diagnostic('run_interrupted', lastEventType, hasCorruptEvent), - ); - } + const reason = + lastEventType === 'model_stream_completed' && !hasTerminalRunEvent(events) + ? 'model_stream_completed_without_runtime_terminal' + : lastEventType === 'permission_requested' || lastEventType === 'permission_failed' + ? 'stale_user_wait' + : lastEventType === 'tool_started' + ? 'tool_interrupted' + : lastEventType === undefined || + lastEventType === 'turn_started' || + lastEventType === 'model_resolved' || + lastEventType === 'model_stream_started' + ? 'run_interrupted' + : 'non_terminal_run_recovered'; return failedDecision( - header, + invocation, 'app_restarted', - diagnostic('non_terminal_run_recovered', lastEventType, hasCorruptEvent), + diagnostic(reason, lastEventType, hasCorruptEvent), ); } @@ -148,24 +121,20 @@ export function attributeSandboxBoundaryRestartClosure( } function failedDecision( - header: AgentRunHeader, + invocation: RuntimeInvocationRecord, failureClass: string, diagnostic?: Record, ): AgentRunRecoveryDecision { return { - runId: header.runId, - turnId: header.turnId, + runId: invocation.runId, + turnId: invocation.turnId, status: 'failed', failureClass, diagnostic, - lineage: headerLineage(header), + lineage: openingLineage(invocation), }; } -function isTerminalRunStatus(status: AgentRunHeader['status']): boolean { - return status === 'completed' || status === 'failed' || status === 'cancelled'; -} - function hasTerminalRunEvent(events: readonly AgentRunEvent[]): boolean { return events.some( (event) => @@ -197,15 +166,17 @@ function diagnostic( }; } -function headerLineage(header: AgentRunHeader): AgentRunRecoveryLineage { +function openingLineage(invocation: RuntimeInvocationRecord): AgentRunRecoveryLineage { + const lineage = invocation.opening.lineage; + if (!lineage) return {}; return { - ...(header.parentRunId ? { parentRunId: header.parentRunId } : {}), - ...(header.parentTurnId ? { parentTurnId: header.parentTurnId } : {}), - ...(header.retriedFromTurnId ? { retriedFromTurnId: header.retriedFromTurnId } : {}), - ...(header.regeneratedFromTurnId - ? { regeneratedFromTurnId: header.regeneratedFromTurnId } + ...(lineage.parentRunId ? { parentRunId: lineage.parentRunId } : {}), + ...(lineage.parentTurnId ? { parentTurnId: lineage.parentTurnId } : {}), + ...(lineage.retriedFromTurnId ? { retriedFromTurnId: lineage.retriedFromTurnId } : {}), + ...(lineage.regeneratedFromTurnId + ? { regeneratedFromTurnId: lineage.regeneratedFromTurnId } : {}), - ...(header.branchOfTurnId ? { branchOfTurnId: header.branchOfTurnId } : {}), - ...(header.parentSessionId ? { parentSessionId: header.parentSessionId } : {}), + ...(lineage.branchOfTurnId ? { branchOfTurnId: lineage.branchOfTurnId } : {}), + ...(lineage.parentSessionId ? { parentSessionId: lineage.parentSessionId } : {}), }; } diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index 8b42cb31eb..70e4ac1fc2 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -17,26 +17,21 @@ * under the License. */ -import type { - AgentRunEvent, - AgentRunHeader, - AgentRunStore, - EmittedAgentRunEvent, -} from '@maka/core/agent-run'; -import { - RUN_COMPOSITION_RECORDED_EVENT_TYPE, - runtimeInvocationOpeningFromRunHeader, -} from '@maka/core/agent-run'; +import type { AgentRunEvent, AgentRunStore, EmittedAgentRunEvent } from '@maka/core/agent-run'; +import { RUN_COMPOSITION_RECORDED_EVENT_TYPE } from '@maka/core/agent-run'; import type { RuntimeEvent, RuntimeEventInvocationOpenedContent, + RuntimeInvocationRootAuthority, ToolBoundaryProtocol, } from '@maka/core/runtime-event'; import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; import type { RunCompositionSnapshot } from '@maka/core/run-composition'; import { decodeRunCompositionSnapshot } from '@maka/core/run-composition'; import { DurableStoreWriteError, RunSealedError } from '@maka/core/runtime-event-store'; -import { isSessionInlineRun } from '@maka/core/agent-run'; +import { isSessionInlineInvocation } from '@maka/core/runtime-invocation'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; +import type { RuntimeInvocationLineage } from '@maka/core/runtime-event'; import { MODEL_PROJECTION_TRANSITION_EVENT_TYPE, type ModelProjectionTransition, @@ -132,7 +127,7 @@ export interface AgentRunHooks { } export type AgentRunLineage = Partial< - Pick & + Pick & Pick< UserMessageInput, | 'parentTurnId' @@ -151,20 +146,21 @@ export interface AgentRunInput { userInput: UserMessageInput; /** Internal lineage for runtime-owned continuations; never accepted by live turn input. */ runLineage?: Pick; - rootExecutionKind?: AgentRunHeader['rootExecutionKind']; + rootExecutionKind?: 'context_compact'; runId?: string; userMessageId?: string | null; durability?: AgentRunDurability; store: AgentRunSessionStore; runStore?: AgentRunStore; runtimeEventStore?: RuntimeEventStore; - repairRunRuntimeLedger?: (sessionId: string, runId: string) => Promise; newId: () => string; now: () => number; workspaceIdentity?: string; continuationFailpoint?: (point: RuntimeContinuationFailpoint) => Promise; - /** Exact target header already committed inside the durable continuation claim. */ - claimedRunHeader?: AgentRunHeader; + /** Exact target opening fact already committed inside the durable continuation claim. */ + claimedOpening?: RuntimeEventInvocationOpenedContent; + /** The moment that claim was taken; the target invocation opens at it. */ + claimedOpenedAt?: number; /** Commits the claimed continuation provider-call T1 after Run creation. */ commitContinuationStart?: (startedAt: number) => Promise<{ startEventId: string; created: true }>; hooks: AgentRunHooks; @@ -186,8 +182,7 @@ export type RuntimeContinuationFailpoint = | 'after_continuation_claim_committed' | 'after_run_created' | 'after_continuation_start_committed' - | 'after_terminal_event_committed' - | 'after_terminal_header_committed'; + | 'after_terminal_event_committed'; export class ContinuationStartCommitError extends Error { readonly name = 'ContinuationStartCommitError'; @@ -210,7 +205,7 @@ export interface AgentRunBeginResult { export interface AgentRunOperationBeginResult { backend: AgentBackend; runtimeContext: RuntimeEvent[]; - runtimeContextRunHeaders: AgentRunHeader[]; + runtimeContextInvocations: RuntimeInvocationRecord[]; startedAt: number; } @@ -257,7 +252,7 @@ export class AgentRun { private finalStatus: { status: SessionStatus; blockedReason?: SessionBlockedReason } | undefined; private turnFailed = false; private finalized = false; - private terminalRunHeaderCommitted = false; + private terminalRunFactCommitted = false; private continuationActive = false; private providerStateIdentity: `sha256:${string}` | undefined; private invocationOpening: RuntimeEventInvocationOpenedContent | undefined; @@ -348,7 +343,7 @@ export class AgentRun { } bindProviderStateIdentity(identity: `sha256:${string}` | undefined): void { - const claimed = this.input.claimedRunHeader?.providerStateIdentity; + const claimed = claimedProviderStateIdentity(this.input.claimedOpening); const expected = claimed ?? this.providerStateIdentity; if (expected !== undefined && expected !== identity) { throw new Error('Prepared backend provider state does not match the AgentRun admission'); @@ -357,10 +352,11 @@ export class AgentRun { } isSessionInline(): boolean { - return isSessionInlineRun({ - ...(this.lineage.parentRunId ? { parentRunId: this.lineage.parentRunId } : {}), - ...(this.continuationActive ? { continuationSource: true } : {}), - }); + const opening = this.invocationOpening; + if (opening) return isSessionInlineInvocation(opening); + // Before the opening fact exists there is only the lineage the turn was + // admitted with, which decides the same question the same way. + return this.lineage.parentRunId === undefined; } hasPendingStop(): boolean { @@ -383,7 +379,7 @@ export class AgentRun { * produces its own terminal event finds the claim taken and writes nothing. */ async settleStopTerminal(): Promise { - if (this.terminalClaim?.owner !== 'stop' || this.terminalRunHeaderCommitted) return; + if (this.terminalClaim?.owner !== 'stop' || this.terminalRunFactCommitted) return; // Nothing durable is configured, so there is no fact to land. Every other // failure below is real and must reach the stop's caller: a stop that // reports success while the run stays non-terminal is the silent loss this @@ -409,7 +405,7 @@ export class AgentRun { // the latch, one that cannot fails the settlement loudly so the stop // stays retryable. try { - await runStore.readRun(this.sessionId, this.runId); + await runStore.readEvents(this.sessionId, this.runId); this.runStoreAvailable = true; } catch (error) { throw new Error('AgentRun store is unavailable for stop settlement', { cause: error }); @@ -704,14 +700,13 @@ export class AgentRun { }); this.active = await this.input.hooks.reserveRun(this.sessionId, this.header, this); - await this.markRunStarted(this.lastTs); await this.input.hooks.updateStatus(this.sessionId, 'running', undefined, this.lastTs); const priorRuntimeContext = await this.buildPriorRuntimeContext(); const projectionContext = priorRuntimeContext ? projectRuntimeEventsToStoredMessages(priorRuntimeContext.events, { - runHeaders: priorRuntimeContext.runs, + invocations: priorRuntimeContext.invocations, }).messages : []; @@ -736,7 +731,7 @@ export class AgentRun { ...(priorRuntimeContext ? { runtimeContext: priorRuntimeContext.events, - runtimeContextRunHeaders: priorRuntimeContext.runs, + runtimeContextInvocations: priorRuntimeContext.invocations, } : {}), }), @@ -755,7 +750,6 @@ export class AgentRun { }); this.active = await this.input.hooks.reserveRun(this.sessionId, this.header, this); - await this.markRunStarted(startedAt); await this.input.hooks.updateStatus(this.sessionId, 'running', undefined, startedAt); @@ -763,7 +757,7 @@ export class AgentRun { return { backend: this.active.backend, runtimeContext: priorRuntimeContext?.events ?? [], - runtimeContextRunHeaders: priorRuntimeContext?.runs ?? [], + runtimeContextInvocations: priorRuntimeContext?.invocations ?? [], startedAt, }; } @@ -800,7 +794,6 @@ export class AgentRun { }); this.active = await this.input.hooks.reserveRun(this.sessionId, this.header, this); - await this.markRunStarted(startedAt); await this.input.hooks.updateStatus(this.sessionId, 'running', undefined, startedAt); return { @@ -892,7 +885,6 @@ export class AgentRun { this.markRunFailed( turnStatus.errorClass, `turn ended with stopReason=${ev.type === 'complete' ? ev.stopReason : 'unknown'}`, - ev.ts, ); } } @@ -911,15 +903,7 @@ export class AgentRun { ev.ts, ); }; - // On resume, advance the Run before the Session so an interrupted pair - // remains conservatively waiting rather than advertising false readiness. - if (this.requiresDurablePersistence() && isInteractionResumeAck(ev)) { - await this.recordStatusFromTransition(ev, transition, ev.ts); - await updateSessionStatus(); - } else { - await updateSessionStatus(); - await this.recordStatusFromTransition(ev, transition, ev.ts); - } + await updateSessionStatus(); } if (turnStatus && !this.stopped) { const appendTurnState = this.input.hooks.appendTurnState( @@ -957,7 +941,7 @@ export class AgentRun { }) .catch((error) => this.enqueueTraceWriteFailure(error, 'terminal session projection')); - this.markRunFailed(ev.reason ?? ev.code ?? 'unknown', ev.message, ev.ts); + this.markRunFailed(ev.reason ?? ev.code ?? 'unknown', ev.message); } } } @@ -1072,11 +1056,7 @@ export class AgentRun { }) .catch(() => {}); - this.markRunFailed( - error instanceof Error ? error.name : 'unknown', - errorMessage(error), - this.input.now(), - ); + this.markRunFailed(error instanceof Error ? error.name : 'unknown', errorMessage(error)); } async finalize(): Promise { @@ -1087,11 +1067,7 @@ export class AgentRun { if (this.stopped) this.finalStatus = { status: 'aborted' }; if (!this.finalStatus) { this.finalStatus = { status: 'blocked', blockedReason: 'unknown' }; - this.markRunFailed( - 'missing_terminal_event', - 'run finalized without a terminal SessionEvent', - lastTs, - ); + this.markRunFailed('missing_terminal_event', 'run finalized without a terminal SessionEvent'); } this.reserveFinalizationTerminal(this.finalStatus, lastTs); if (this.active) { @@ -1130,120 +1106,111 @@ export class AgentRun { return; } const createdAt = - continuation && this.input.claimedRunHeader - ? this.input.claimedRunHeader.createdAt + continuation && this.input.claimedOpenedAt !== undefined + ? this.input.claimedOpenedAt : this.input.now(); const providerStateIdentity = - this.input.claimedRunHeader?.providerStateIdentity ?? this.providerStateIdentity; + claimedProviderStateIdentity(this.input.claimedOpening) ?? this.providerStateIdentity; this.providerStateIdentity = providerStateIdentity; - const computedHeader: AgentRunHeader = { - runId: this.runId, - invocationId: this.invocationId, - sessionId: this.sessionId, - turnId: this.turnId, - status: 'created', - backendKind: this.header.backend, - ...(this.header.llmConnectionId === undefined - ? {} - : { llmConnectionId: this.header.llmConnectionId }), - ...(providerStateIdentity ? { providerStateIdentity } : {}), - llmConnectionSlug: this.header.llmConnectionSlug, - modelId: this.header.model, - cwd: this.header.cwd, - ...(this.input.workspaceIdentity ? { workspaceIdentity: this.input.workspaceIdentity } : {}), - permissionMode: this.header.permissionMode, - collaborationMode: this.header.collaborationMode ?? 'agent', - orchestrationMode: this.effectiveOrchestration.mode, - orchestrationSource: this.effectiveOrchestration.source, - agentSwarmAuthorization: this.effectiveOrchestration.agentSwarmAuthorization, - toolMode: this.toolMode, - createdAt, - updatedAt: createdAt, - ...this.lineage, - ...(continuation - ? { - continuationSource: - continuation.claimId && continuation.boundary - ? { - protocol: 'continuation_source_v2' as const, - claimId: continuation.claimId, - boundaryDigest: continuation.boundary.manifestDigest, - sourceInvocationId: continuation.sourceInvocationId, - sourceRunId: continuation.sourceRunId, - sourceTurnId: continuation.sourceTurnId, - sourceRuntimeEventHighWater: continuation.sourceRuntimeEventHighWater, - sourcePrefixDigest: continuation.boundary.segments.at(-1)!.prefixDigest, - replayManifestDigest: continuation.boundary.manifestDigest, - } - : { - sourceInvocationId: continuation.sourceInvocationId, - sourceRunId: continuation.sourceRunId, - sourceTurnId: continuation.sourceTurnId, - sourceRuntimeEventHighWater: continuation.sourceRuntimeEventHighWater, - }, - } - : {}), - ...(this.input.userInput.agentId ? { agentId: this.input.userInput.agentId } : {}), - ...(this.input.userInput.agentName ? { agentName: this.input.userInput.agentName } : {}), - ...(this.input.userInput.origin?.kind === 'scheduled_task' - ? { scheduledTaskId: this.input.userInput.origin.scheduledTaskId } - : {}), - ...(this.input.userInput.origin?.kind === 'goal' - ? { goalId: this.input.userInput.origin.goalId } - : {}), - ...(this.input.userInput.origin?.kind === 'agent_graph' - ? { - agentGraphWakeId: this.input.userInput.origin.wakeId, - agentGraphWakeAttemptId: this.input.userInput.origin.attemptId, - } - : {}), - ...(this.input.rootExecutionKind ? { rootExecutionKind: this.input.rootExecutionKind } : {}), - }; - const header = - continuation && this.input.claimedRunHeader ? this.input.claimedRunHeader : computedHeader; + const computedOpening = this.buildInvocationOpening(continuation, providerStateIdentity); if ( continuation && - this.input.claimedRunHeader && - !isDeepStrictEqual(this.input.claimedRunHeader, computedHeader) + this.input.claimedOpening && + !isDeepStrictEqual(this.input.claimedOpening, computedOpening) ) { - throw new Error('Claimed continuation target Run header no longer matches execution'); + throw new Error('Claimed continuation target opening no longer matches execution'); } - this.invocationOpening = runtimeInvocationOpeningFromRunHeader(header); + this.invocationOpening = this.input.claimedOpening ?? computedOpening; // A continuation's opening fact rides its continuation-start event, which // the store requires to be event 1 of the target invocation. Every other - // invocation opens with its own event, committed before the run row and - // before any provider or tool dispatch. + // invocation opens with its own event, committed before any provider or + // tool dispatch. if (!continuation) await this.commitInvocationOpening(createdAt); - try { - const durable = this.requiresDurablePersistence(); - await this.input.runStore.createRun(header, { durable }); - await this.input.runStore.appendEvent( - this.sessionId, - this.runId, - { - type: 'run_created', - id: this.input.newId(), - runId: this.runId, - sessionId: this.sessionId, - turnId: this.turnId, - ts: createdAt, - data: { - textLength: this.input.userInput.text.length, - attachmentCount: this.input.userInput.attachments?.length ?? 0, - orchestrationMode: this.effectiveOrchestration.mode, - orchestrationSource: this.effectiveOrchestration.source, - agentSwarmAuthorization: this.effectiveOrchestration.agentSwarmAuthorization, - toolMode: this.toolMode, - }, - }, - { durable }, - ); - } catch (error) { - this.runStoreAvailable = false; - if (this.requiresDurablePersistence()) throw error; - this.enqueueTraceWriteFailure(error); - if (continuation) throw error; + } + + /** + * The one immutable statement of how this invocation was opened. + * + * Everything a later reader needs to know about the run's route, + * configuration, root authority and lineage is decided here, once, and never + * restated anywhere else. + */ + private buildInvocationOpening( + continuation: RuntimeContinuation | undefined, + providerStateIdentity: `sha256:${string}` | undefined, + ): RuntimeEventInvocationOpenedContent { + const lineage = { + ...this.lineage, + ...(this.input.userInput.agentId ? { agentId: this.input.userInput.agentId } : {}), + ...(this.input.userInput.agentName ? { agentName: this.input.userInput.agentName } : {}), + ...(continuation ? { parentRunId: continuation.sourceRunId } : {}), + }; + return { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: + this.header.llmConnectionId === undefined + ? { + provenance: 'unknown', + backendKind: this.header.backend, + llmConnectionSlug: this.header.llmConnectionSlug, + modelId: this.header.model, + } + : { + provenance: 'runtime', + backendKind: this.header.backend, + llmConnectionId: this.header.llmConnectionId, + llmConnectionSlug: this.header.llmConnectionSlug, + modelId: this.header.model, + ...(providerStateIdentity ? { providerStateIdentity } : {}), + }, + configuration: { + cwd: this.header.cwd, + permissionMode: this.header.permissionMode, + collaborationMode: this.header.collaborationMode ?? 'agent', + orchestrationMode: this.effectiveOrchestration.mode, + orchestrationSource: this.effectiveOrchestration.source, + toolMode: this.toolMode, + ...(this.effectiveOrchestration.agentSwarmAuthorization !== undefined + ? { agentSwarmAuthorization: this.effectiveOrchestration.agentSwarmAuthorization } + : {}), + ...(this.input.workspaceIdentity + ? { workspaceIdentity: this.input.workspaceIdentity } + : {}), + }, + root: this.invocationRootAuthority(), + source: continuation + ? { + kind: 'continuation', + sourceInvocationId: continuation.sourceInvocationId, + sourceRunId: continuation.sourceRunId, + sourceTurnId: continuation.sourceTurnId, + sourceRuntimeEventHighWater: continuation.sourceRuntimeEventHighWater, + ...(continuation.claimId ? { claimId: continuation.claimId } : {}), + ...(continuation.boundary + ? { boundaryDigest: continuation.boundary.manifestDigest } + : {}), + } + : { kind: 'fresh' }, + ...(Object.keys(lineage).length > 0 ? { lineage } : {}), + }; + } + + private invocationRootAuthority(): RuntimeInvocationRootAuthority { + const origin = this.input.userInput.origin; + if (origin?.kind === 'scheduled_task') { + return { kind: 'scheduled_task', scheduledTaskId: origin.scheduledTaskId }; } + if (origin?.kind === 'goal') return { kind: 'goal', goalId: origin.goalId }; + if (origin?.kind === 'agent_graph') { + return { + kind: 'agent_graph_supervisor_wake', + wakeId: origin.wakeId, + attemptId: origin.attemptId, + }; + } + if (this.input.rootExecutionKind === 'context_compact') return { kind: 'context_compact' }; + return { kind: 'user' }; } /** @@ -1288,215 +1255,41 @@ export class AgentRun { sessionId: this.sessionId, currentRunId: this.runId, currentTurnId: this.turnId, - runStore: this.input.runStore, runtimeEventStore: this.input.runtimeEventStore, - runStoreAvailable: this.runStoreAvailable, runtimeEventStoreAvailable: this.runtimeEventStoreAvailable, - repairRunRuntimeLedger: this.input.repairRunRuntimeLedger, - readMessages: () => this.input.store.readMessages(this.sessionId), }); } - private async markRunStarted(ts: number): Promise { - if (!this.input.runStore || !this.runStoreAvailable) return; - const durable = this.requiresDurablePersistence(); - const write = this.enqueueRunStore( - 'mark run started', - async () => { - await this.input.runStore?.appendEvent( - this.sessionId, - this.runId, - { - type: 'run_started', - id: this.input.newId(), - runId: this.runId, - sessionId: this.sessionId, - turnId: this.turnId, - ts, - }, - { durable }, - ); - await this.input.runStore?.updateRun( - this.sessionId, - this.runId, - { status: 'running', updatedAt: ts }, - { durable }, - ); - }, - { rethrow: durable }, - ); - if (durable) await write; - } - - private async recordStatusFromTransition( - ev: SessionEvent, - transition: { status: SessionStatus; blockedReason?: SessionBlockedReason }, - ts: number, - ): Promise { - const durable = this.requiresDurablePersistence(); - const runStore = this.input.runStore; - if (!runStore) { - if (durable) { - throw new Error('AgentRun store is unavailable for a required status transition'); - } - return; - } - const status = - transition.status === 'waiting_for_user' - ? 'waiting_for_user' - : transition.status === 'aborted' - ? 'cancelled' - : transition.status === 'blocked' - ? 'failed' - : transition.status === 'active' - ? 'completed' - : 'running'; - if (isTerminalRunStatus(status)) return; - const appendAudit = async (): Promise => { - await runStore.appendEvent( - this.sessionId, - this.runId, - { - type: 'run_status_changed', - id: this.input.newId(), - runId: this.runId, - sessionId: this.sessionId, - turnId: this.turnId, - ts, - data: { - sessionStatus: transition.status, - ...(transition.blockedReason ? { blockedReason: transition.blockedReason } : {}), - }, - }, - { durable }, - ); - }; - if (durable) { - await this.enqueueRequiredRunStoreWrite('record required run status', async () => { - await runStore.updateRun( - this.sessionId, - this.runId, - { status, updatedAt: ts }, - { durable: true }, - ); - }); - // The audit remains best-effort, but its physical write belongs to this - // required transition and must settle before the resume acknowledgement. - await this.enqueueRunStore('append run status audit', appendAudit); - } else { - this.enqueueRunStore('record run status', async () => { - await runStore.updateRun(this.sessionId, this.runId, { status, updatedAt: ts }); - await appendAudit(); - }); - } - if (ev.type === 'abort') { - this.markRunCancelled(ev.reason, ts); - } - } - - private markRunFailed(failureClass: string, message: string, ts: number): void { - if (!this.input.runStore || !this.runStoreAvailable) return; + /** + * Remember why this run is going to fail. + * + * Nothing is written here: the terminal RuntimeEvent carries the failure, and + * it is committed once, at the end, by `commitTerminalRun`. + */ + private markRunFailed(failureClass: string, message: string): void { this.failureClass = failureClass; this.failureMessage = redactTraceString(message); - if (this.input.runtimeEventStore) return; - this.enqueueRunStore('mark run failed', async () => { - await this.input.runStore?.updateRun(this.sessionId, this.runId, { - status: 'failed', - updatedAt: ts, - completedAt: ts, - failureClass, - failureMessage: this.failureMessage, - }); - await this.input.runStore?.appendEvent(this.sessionId, this.runId, { - type: 'run_failed', - id: this.input.newId(), - runId: this.runId, - sessionId: this.sessionId, - turnId: this.turnId, - ts, - message: redactTraceString(message), - data: { failureClass }, - }); - }); - } - - private markRunCancelled(reason: string | undefined, ts: number): void { - if (!this.input.runStore || !this.runStoreAvailable) return; - if (this.input.runtimeEventStore) return; - this.enqueueRunStore('mark run cancelled', async () => { - await this.input.runStore?.updateRun(this.sessionId, this.runId, { - status: 'cancelled', - updatedAt: ts, - completedAt: ts, - }); - await this.input.runStore?.appendEvent(this.sessionId, this.runId, { - type: 'run_cancelled', - id: this.input.newId(), - runId: this.runId, - sessionId: this.sessionId, - turnId: this.turnId, - ts, - ...(reason ? { message: redactTraceString(reason) } : {}), - }); - }); } + /** + * End the run by committing its terminal RuntimeEvent, and nothing else. + * + * A turn that parks on an interaction has not ended, so it commits nothing: + * the absence of a terminal event is exactly what "still open" means. + */ private async finishRun( finalStatus: { status: SessionStatus; blockedReason?: SessionBlockedReason } | undefined, ts: number, ): Promise { await this.traceQueue.catch(() => {}); - if (!this.input.runStore || !this.runStoreAvailable) return; - const status = this.runStatusForFinalStatus(finalStatus); - const isTerminal = status === 'completed' || status === 'failed' || status === 'cancelled'; - if (isTerminal && this.input.runtimeEventStore) { - await this.commitTerminalRun(finalStatus, ts); - return; - } - await this.enqueueRunStore('finish run', async () => { - await this.input.runStore?.updateRun(this.sessionId, this.runId, { - status, - updatedAt: ts, - ...(isTerminal ? { completedAt: ts } : {}), - ...(status === 'failed' - ? { - failureClass: this.failureClass ?? finalStatus?.blockedReason ?? 'unknown', - ...(this.failureMessage ? { failureMessage: this.failureMessage } : {}), - } - : {}), - }); - await this.input.runStore?.appendEvent(this.sessionId, this.runId, { - type: - status === 'cancelled' - ? 'run_cancelled' - : status === 'failed' - ? 'run_failed' - : status === 'completed' - ? 'run_completed' - : 'run_status_changed', - id: this.input.newId(), - runId: this.runId, - sessionId: this.sessionId, - turnId: this.turnId, - ts, - ...(status === 'failed' - ? { data: { failureClass: this.failureClass ?? finalStatus?.blockedReason ?? 'unknown' } } - : status === 'waiting_for_user' - ? { - data: { - sessionStatus: 'waiting_for_user', - blockedReason: finalStatus?.blockedReason ?? 'permission_required', - }, - } - : {}), - }); - }); - await this.traceQueue.catch(() => {}); + if (!this.input.runtimeEventStore) return; + if (this.runStatusForFinalStatus(finalStatus) === 'waiting_for_user') return; + await this.commitTerminalRun(finalStatus, ts); } private runStatusForFinalStatus( finalStatus: { status: SessionStatus; blockedReason?: SessionBlockedReason } | undefined, - ): AgentRunHeader['status'] { + ): 'completed' | 'failed' | 'cancelled' | 'waiting_for_user' { if (this.stopped || finalStatus?.status === 'aborted') return 'cancelled'; if (this.failureClass || finalStatus?.status === 'blocked') return 'failed'; if (finalStatus?.status === 'waiting_for_user') return 'waiting_for_user'; @@ -1507,10 +1300,9 @@ export class AgentRun { finalStatus: { status: SessionStatus; blockedReason?: SessionBlockedReason } | undefined, ts: number, ): Promise { - if (this.terminalRunHeaderCommitted) return; - const runStore = this.input.runStore; + if (this.terminalRunFactCommitted) return; const runtimeEventStore = this.input.runtimeEventStore; - if (!runStore || !runtimeEventStore) return; + if (!runtimeEventStore) return; // A latched RuntimeEvent store normally keeps the skip below: the latch // marks a write failure, and a transient one leaves the run non-terminal // on purpose so startup recovery repairs it with its own bookkeeping. @@ -1526,7 +1318,6 @@ export class AgentRun { if (!(this.runtimeEventStoreFailure instanceof ToolLedgerCorruptionError)) return; corruptionRecovery = true; } - if (!this.runStoreAvailable) return; const fallbackStatus = this.stopped || finalStatus?.status === 'aborted' ? 'cancelled' : 'failed'; const fallbackFailureClass = 'missing_terminal_event'; @@ -1550,9 +1341,8 @@ export class AgentRun { // Re-check after the await, not only at entry. Two callers — a stop // settling the claim and the stream's own finalize — can both pass the // entry guard and then queue behind the same write. The claim slot - // dedupes the RuntimeEvent, but the run-store projection would append a - // second terminal AgentRunEvent for the one run. - if (this.terminalRunHeaderCommitted) return; + // dedupes the RuntimeEvent, so a second pass has nothing left to do. + if (this.terminalRunFactCommitted) return; // On the recovery path the claimed event's write never committed, so // the boundary named after that commit must wait for the durability // barrier inside commitOrCreateTerminalRunFact; firing it here would @@ -1563,7 +1353,6 @@ export class AgentRun { await this.input.continuationFailpoint?.('after_terminal_event_committed'); } const commit = commitOrCreateTerminalRunFact({ - runStore, runtimeEventStore, ...(this.continuationActive && deferContinuationBoundary ? { @@ -1582,38 +1371,30 @@ export class AgentRun { ? { failureClass: this.failureClass ?? finalStatus?.blockedReason } : {}), ...(this.failureMessage ? { failureMessage: this.failureMessage } : {}), - ...(this.traceWriteError ? { traceWriteError: this.traceWriteError } : {}), ...(this.abortSource || fallbackStatus === 'cancelled' ? { abortSource: this.abortSource ?? 'user_stop' } : {}), fallbackStatus, fallbackInvocationId: this.runId, ...(fallbackStatus === 'failed' ? { fallbackFailureClass, fallbackFailureMessage } : {}), - allowHeaderCommitFailure: true, }); if (!terminalClaim.write) { terminalClaim.write = commit.then(() => undefined); void terminalClaim.write.catch(() => {}); } - const result = await commit; - this.terminalRunHeaderCommitted = result.headerCommitted; - if (result.headerCommitted && this.continuationActive) { - await this.input.continuationFailpoint?.('after_terminal_header_committed'); - } - if (result.headerCommitError !== undefined) { - await this.enqueueTraceWriteFailure(result.headerCommitError, 'commit terminal run header'); - } + await commit; + this.terminalRunFactCommitted = true; } catch (error) { if (corruptionRecovery) { // The scoped barrier lost its bet: the ledger refused even the // terminal fact. The latch never lifted, so there is nothing to // restore; record the failure and keep the finalize path's // historical silence for a store that stays broken. - await this.enqueueTraceWriteFailure(error, 'commit terminal run header'); + await this.enqueueTraceWriteFailure(error, 'commit terminal run fact'); return; } this.runStoreAvailable = false; - await this.enqueueTraceWriteFailure(error, 'commit terminal run header'); + await this.enqueueTraceWriteFailure(error, 'commit terminal run fact'); throw error; } await this.traceQueue.catch(() => {}); @@ -1841,14 +1622,6 @@ export class AgentRun { ): Promise { const message = errorMessage(error); this.traceWriteError ??= `${label}: ${message}`; - try { - await this.input.runStore?.updateRun(this.sessionId, this.runId, { - traceWriteError: this.traceWriteError, - updatedAt: this.input.now(), - }); - } catch { - // The terminal header commit retries the in-memory latch. - } try { await this.input.runStore?.appendEvent(this.sessionId, this.runId, { type: 'trace_write_failed', @@ -1986,3 +1759,11 @@ function isAtomicToolBoundaryProjection( if (!protocol || event.refs?.operationId === undefined) return false; return event.content?.kind === 'function_call' || event.content?.kind === 'function_response'; } + +/** The provider endpoint identity a continuation claim froze, if it named one. */ +function claimedProviderStateIdentity( + opening: RuntimeEventInvocationOpenedContent | undefined, +): `sha256:${string}` | undefined { + const route = opening?.route; + return route?.provenance === 'runtime' ? route.providerStateIdentity : undefined; +} diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 39ae42a93c..8f641c56dd 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -1943,7 +1943,7 @@ export class AiSdkBackend implements AgentBackend { projectionCheckpoint, compatibleProviderReasoningReplayEventIds( replayEvents, - input.runtimeContextRunHeaders, + input.runtimeContextInvocations, this.input.providerStateIdentity, this.input.modelId, scope.runId, @@ -3670,7 +3670,7 @@ export class AiSdkBackend implements AgentBackend { const priorRuntimeContext = preparedContextBudget.events; const providerReasoningReplayEventIds = compatibleProviderReasoningReplayEventIds( priorRuntimeContext, - input.runtimeContextRunHeaders, + input.runtimeContextInvocations, this.input.providerStateIdentity, this.input.modelId, ); diff --git a/packages/runtime/src/ai-sdk-compaction-contract.ts b/packages/runtime/src/ai-sdk-compaction-contract.ts index 698d7de64e..62831805fa 100644 --- a/packages/runtime/src/ai-sdk-compaction-contract.ts +++ b/packages/runtime/src/ai-sdk-compaction-contract.ts @@ -22,7 +22,7 @@ import type { HistoryCompactRoute } from '@maka/core/model-call-attempt'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { ModelProjectionTransition } from '@maka/core/model-projection-transition'; import type { LoadedModelProjectionTransitions } from './model-projection-transition-ledger.js'; -import type { AgentRunHeader } from '@maka/core/agent-run'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { ProviderRequestTracker } from './provider-request-telemetry.js'; import type { ContextBudgetPolicy } from './context-budget.js'; @@ -47,7 +47,7 @@ export interface HistoryCompactSummaryInput { runId?: string; source: { foldedRuntimeEvents: RuntimeEvent[]; - runHeaders?: readonly AgentRunHeader[]; + invocations?: readonly RuntimeInvocationRecord[]; }; previousCheckpoint?: HistoryCompactCheckpoint; newlyFoldedRuntimeEvents?: RuntimeEvent[]; diff --git a/packages/runtime/src/ai-sdk-compaction.ts b/packages/runtime/src/ai-sdk-compaction.ts index fec8bcbfbc..4199e4541a 100644 --- a/packages/runtime/src/ai-sdk-compaction.ts +++ b/packages/runtime/src/ai-sdk-compaction.ts @@ -29,7 +29,7 @@ */ import type { RuntimeEvent } from '@maka/core/runtime-event'; -import type { AgentRunHeader } from '@maka/core/agent-run'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { BackendCompactHistoryInput, BackendCompactHistoryResult, @@ -366,7 +366,9 @@ export class AiSdkCompaction { sessionId: this.sessionId, phase: 'standalone', orderedEvents: runtimeContext, - ...(input.runtimeContextRunHeaders ? { runHeaders: input.runtimeContextRunHeaders } : {}), + ...(input.runtimeContextInvocations + ? { invocations: input.runtimeContextInvocations } + : {}), acceptedRoute: { modelId: this.input.modelId, ...(this.targetConnectionId !== undefined @@ -388,8 +390,8 @@ export class AiSdkCompaction { runId: input.runId, source: { foldedRuntimeEvents: [...coveredRuntimeEvents], - ...(input.runtimeContextRunHeaders - ? { runHeaders: input.runtimeContextRunHeaders } + ...(input.runtimeContextInvocations + ? { invocations: input.runtimeContextInvocations } : {}), }, newlyFoldedRuntimeEvents: [...newlyFoldedRuntimeEvents], @@ -479,13 +481,16 @@ export class AiSdkCompaction { input: HistoryCompactSummaryInput, ): Promise { const foldedRunIds = new Set(input.source.foldedRuntimeEvents.map((event) => event.runId)); - const sourceRunRoutes = input.source.runHeaders - ?.filter((run) => foldedRunIds.has(run.runId)) - .map((run) => ({ - runId: run.runId, - connectionId: run.llmConnectionId, - modelId: run.modelId, - })) + const sourceRunRoutes = input.source.invocations + ?.filter((invocation) => foldedRunIds.has(invocation.runId)) + .map((invocation) => { + const route = invocation.opening.route; + return { + runId: invocation.runId, + ...(route.provenance === 'runtime' ? { connectionId: route.llmConnectionId } : {}), + modelId: route.modelId, + }; + }) .sort((left, right) => left.runId.localeCompare(right.runId)); const fingerprint = sha256( stableStringifyForSignature({ @@ -805,7 +810,7 @@ export class AiSdkCompaction { const state = new MidTurnCapacityCompactState( headAnchor, priorContentEvents, - input.runtimeContextRunHeaders ?? [], + input.runtimeContextInvocations ?? [], resolveDeclaredContextWindow(this.input.connection, this.input.modelId), ); // Seed the turn's FIRST request with the last request the provider @@ -814,7 +819,7 @@ export class AiSdkCompaction { // decides, and its rejection is recovered from. const persisted = persistedRequestAnchor( input.runtimeContext ?? [], - state.priorRunHeaders, + state.priorInvocations, this.input.modelId, this.targetConnectionId, ); @@ -1077,7 +1082,7 @@ export class AiSdkCompaction { phase: input.phase ?? 'mid_turn', orderedEvents, headAnchor: { runtimeEventId: state.headAnchor.id, turnId }, - runHeaders: state.priorRunHeaders, + invocations: state.priorInvocations, acceptedRoute: { modelId: this.input.modelId, ...(this.targetConnectionId !== undefined ? { connectionId: this.targetConnectionId } : {}), @@ -1106,7 +1111,7 @@ export class AiSdkCompaction { ...(input.origin.runId ? { runId: input.origin.runId } : {}), source: { foldedRuntimeEvents: [...coveredRuntimeEvents], - runHeaders: state.priorRunHeaders, + invocations: state.priorInvocations, }, ...(previousCheckpoint ? { previousCheckpoint } : {}), newlyFoldedRuntimeEvents: [...newlyFoldedRuntimeEvents], @@ -1154,7 +1159,7 @@ export class AiSdkCompaction { plan.checkpoint, compatibleProviderReasoningReplayEventIds( plan.replacementEvents, - state.priorRunHeaders, + state.priorInvocations, this.targetProviderStateIdentity, this.input.modelId, input.origin.runId, @@ -1484,7 +1489,7 @@ export class MidTurnCapacityCompactState { constructor( readonly headAnchor: RuntimeEvent, readonly priorContentEvents: readonly RuntimeEvent[], - readonly priorRunHeaders: readonly AgentRunHeader[], + readonly priorInvocations: readonly RuntimeInvocationRecord[], /** * The Maka window: the context window the USER declared for this model, * a compaction target and nothing else. Absent when none is declared, @@ -1538,7 +1543,7 @@ function usageBaselineTokens(usage: NormalizedUsage | undefined): number | undef */ function persistedRequestAnchor( events: readonly RuntimeEvent[], - runHeaders: readonly AgentRunHeader[], + invocations: readonly RuntimeInvocationRecord[], modelId: string, connectionId: string | undefined, ): LastRequestAnchor | undefined { @@ -1546,8 +1551,12 @@ function persistedRequestAnchor( const event = events[index]; const anchor = event?.actions?.tokenUsage?.lastRequestAnchor; if (!anchor) continue; - const header = runHeaders.find((candidate) => candidate.runId === event?.runId); - if (!header || header.modelId !== modelId || header.llmConnectionId !== connectionId) { + const route = invocations.find((candidate) => candidate.runId === event?.runId)?.opening.route; + if ( + route?.provenance !== 'runtime' || + route.modelId !== modelId || + route.llmConnectionId !== connectionId + ) { return undefined; } return anchor; diff --git a/packages/runtime/src/context-diagnostics.ts b/packages/runtime/src/context-diagnostics.ts index bc45466ab0..81416800d7 100644 --- a/packages/runtime/src/context-diagnostics.ts +++ b/packages/runtime/src/context-diagnostics.ts @@ -18,7 +18,6 @@ */ import { - isSessionInlineRun, supersedesLatestContext, type AgentRunEvent, type AgentRunStore, @@ -112,11 +111,7 @@ export interface ContextDiagnosticsComposition { type ContextRunStore = Pick< AgentRunStore, - | 'listSessionRuns' - | 'readEvents' - | 'readEventProjection' - | 'readEventLedgerRevision' - | 'repairEventProjection' + 'readEvents' | 'readEventProjection' | 'readEventLedgerRevision' | 'repairEventProjection' >; /** @@ -141,6 +136,8 @@ type ContextRunStore = Pick< export async function readLatestContextDiagnostics( runStore: ContextRunStore, sessionId: string, + /** The session-inline runs to scan on the cold path, from the event spine. */ + runIds: readonly string[], ): Promise { try { let replaceProjectionId: string | undefined; @@ -165,7 +162,13 @@ export async function readLatestContextDiagnostics( runStore.readEventLedgerRevision && runStore.repairEventProjection ? await runStore.readEventLedgerRevision(sessionId) : undefined; - return await rebuildContextFromLedger(runStore, sessionId, replaceProjectionId, ledgerRevision); + return await rebuildContextFromLedger( + runStore, + sessionId, + runIds, + replaceProjectionId, + ledgerRevision, + ); } catch { return { status: 'unavailable', reason: 'trace_unavailable' }; } @@ -184,10 +187,10 @@ export async function readLatestContextDiagnostics( async function rebuildContextFromLedger( runStore: ContextRunStore, sessionId: string, + runIds: readonly string[], replaceProjectionId?: string, ledgerRevision?: string, ): Promise { - const runs = (await runStore.listSessionRuns(sessionId)).filter(isSessionInlineRun); let anchor: MeteringAnchor | undefined; // Only consulted when the scan finds no canonical attempt at all: a session // written before canonical metering existed has provider attempts and @@ -207,8 +210,8 @@ async function rebuildContextFromLedger( const historicalAttempts: LegacyProviderAnchor[] = []; const checkpoints: CheckpointCandidate[] = []; - for (const run of runs) { - for (const event of await runStore.readEvents(sessionId, run.runId)) { + for (const runId of runIds) { + for (const event of await runStore.readEvents(sessionId, runId)) { if (event.type === METERING_EVENT_TYPE) { sawCanonicalRecord = true; const candidate = meteringAnchor(event); diff --git a/packages/runtime/src/continuation-replay.ts b/packages/runtime/src/continuation-replay.ts index f9d6fc6e28..c26b7c6ad2 100644 --- a/packages/runtime/src/continuation-replay.ts +++ b/packages/runtime/src/continuation-replay.ts @@ -18,7 +18,7 @@ */ import { createHash } from 'node:crypto'; -import type { AgentRunHeader } from '@maka/core/agent-run'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { stableJsonStringify } from '@maka/core/tool-args-identity'; import { @@ -83,7 +83,7 @@ export type ContinuationReplayPlanResult = }; export interface ContinuationReplayAdmissionRoute { - runHeaders: readonly AgentRunHeader[]; + invocations: readonly RuntimeInvocationRecord[]; targetProviderStateIdentity: `sha256:${string}` | undefined; targetModelId: string; } @@ -112,7 +112,7 @@ export function buildContinuationReplayPlan(input: { const runtimeContext = segments.flatMap((segment) => segment.replayRuntimeEvents); const providerReasoningReplayEventIds = compatibleProviderReasoningReplayEventIds( runtimeContext, - input.admissionRoute.runHeaders, + input.admissionRoute.invocations, input.admissionRoute.targetProviderStateIdentity, input.admissionRoute.targetModelId, ); diff --git a/packages/runtime/src/conversation-copy.ts b/packages/runtime/src/conversation-copy.ts index 4b211ed107..cd6ab2f7ae 100644 --- a/packages/runtime/src/conversation-copy.ts +++ b/packages/runtime/src/conversation-copy.ts @@ -17,20 +17,17 @@ * under the License. */ -import type { - AgentRunEvent, - AgentRunHeader, - AgentRunStore, - EmittedAgentRunEvent, -} from '@maka/core/agent-run'; -import type { RuntimeEvent } from '@maka/core/runtime-event'; +import type { AgentRunEvent, AgentRunStore, EmittedAgentRunEvent } from '@maka/core/agent-run'; +import type { RuntimeEvent, RuntimeEventInvocationOpenedContent } from '@maka/core/runtime-event'; +import { isSessionInlineInvocation } from '@maka/core/runtime-invocation'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; import { type StorageRef, type ToolResultContent } from '@maka/core/events'; import { parseAttachmentResourceRef } from '@maka/core/attachments'; import { markPersisted } from '@maka/core/persisted-value'; import type { StoredMessage } from '@maka/core/session'; import { decodePersistedToolResultContent } from '@maka/core/tool-result-record-schema'; -import { isEmittedAgentRunEventType, isSessionInlineRun } from '@maka/core/agent-run'; +import { isEmittedAgentRunEventType } from '@maka/core/agent-run'; import { decodeModelCallAttempt, MODEL_CALL_ATTEMPT_EVENT_TYPE, @@ -154,7 +151,7 @@ export interface ConversationRuntimeLedgerCopyPlan { readonly copyTurnIds: readonly string[]; readonly inlineRuntimeEvents: readonly RuntimeEvent[]; readonly runs: readonly { - readonly run: AgentRunHeader; + readonly run: RuntimeInvocationRecord; readonly runtimeEvents: readonly RuntimeEvent[]; readonly operationalEvents: readonly AgentRunEvent[]; }[]; @@ -319,10 +316,13 @@ export async function prepareConversationRuntimeLedgerCopy(input: { readonly sourceSessionId: string; readonly sourceEvents: readonly RuntimeEvent[]; readonly copiedMessages: readonly StoredMessage[]; - readonly runStore: Pick; - readonly runtimeEventStore: Pick; + readonly runStore: Pick; + readonly runtimeEventStore: Pick< + RuntimeEventStore, + 'readRuntimeEvents' | 'listSessionInvocations' + >; }): Promise { - const sourceRuns = await input.runStore.listSessionRuns(input.sourceSessionId); + const sourceRuns = await input.runtimeEventStore.listSessionInvocations(input.sourceSessionId); const transcriptTurnIds = [ ...new Set( input.copiedMessages.map(messageTurnId).filter((turnId): turnId is string => !!turnId), @@ -342,7 +342,7 @@ export async function prepareConversationRuntimeLedgerCopy(input: { throw new Error(`Cannot copy AgentRun ${run.runId} without RuntimeEvent facts`); } const terminal = classifyTerminalRuntimeLedger(run, events); - if (isTerminalRunStatus(run.status) && terminal.kind !== 'fact') { + if (run.terminalEvent && terminal.kind !== 'fact') { throw new Error(`Cannot copy terminal AgentRun ${run.runId} without one terminal fact`); } return { run, runtimeEvents: events, operationalEvents }; @@ -376,15 +376,18 @@ export async function prepareConversationRuntimeLedgerCopy(input: { */ async function rebuildCopiedProjectionTransitions( sessionId: string, - sourceRuns: readonly AgentRunHeader[], + sourceRuns: readonly RuntimeInvocationRecord[], runs: readonly { - readonly run: AgentRunHeader; + readonly run: RuntimeInvocationRecord; readonly runtimeEvents: readonly RuntimeEvent[]; readonly operationalEvents: AgentRunEvent[]; }[], runStore: Pick, ): Promise { - const owningRun = new Map(); + const owningRun = new Map< + string, + { run: RuntimeInvocationRecord; operationalEvents: AgentRunEvent[] } + >(); const copiedRuntimeEvents: RuntimeEvent[] = []; for (const { run, runtimeEvents, operationalEvents } of runs) { for (const event of runtimeEvents) { @@ -453,7 +456,8 @@ function assertConversationRuntimeLedgerCopySupported( ): void { const unsupported = plan.runs.some( ({ run, runtimeEvents }) => - run.continuationSource !== undefined || runtimeEvents.some(isContinuationStartRuntimeEvent), + run.opening.source.kind === 'continuation' || + runtimeEvents.some(isContinuationStartRuntimeEvent), ); if (!unsupported) return; @@ -552,7 +556,6 @@ export async function cloneConversationRuntimeLedger( >(); const preparedPlans = flattenedPlans.map((plan) => { const runId = runIds.get(plan.run.runId)!; - const invocationId = targetInvocationIds.get(plan.run.runId)!; const clonedOperationalEvents = plan.operationalEvents.flatMap((event) => { const clonedEvent = cloneAgentRunEvent( event, @@ -574,22 +577,15 @@ export async function cloneConversationRuntimeLedger( return clonedEvent ? [clonedEvent] : []; }); const terminalEvent = - plan.terminal.kind === 'fact' && isTerminalRunStatus(plan.run.status) + plan.terminal.kind === 'fact' ? clonedEventBySourceId.get(plan.terminal.fact.terminalEvent.id) : undefined; - if (plan.terminal.kind === 'fact' && isTerminalRunStatus(plan.run.status) && !terminalEvent) { + if (plan.terminal.kind === 'fact' && !terminalEvent) { throw new Error(`Copied AgentRun ${plan.run.runId} lost its terminal RuntimeEvent`); } return { plan, runId, - clonedRun: cloneRunHeader( - plan.run, - input.referenceMap.targetSessionId, - runId, - invocationId, - references, - ), clonedOperationalEvents, terminalEvent, }; @@ -598,10 +594,6 @@ export async function cloneConversationRuntimeLedger( rewriteConversationCopyMessage(message, references), ); - for (const { clonedRun } of preparedPlans) { - await input.runStore.createRun(clonedRun); - } - const importedSourceEventIds = new Set(); const orderedBatches = input.plan.inlineRuntimeEvents.flatMap((event) => { const cloned = clonedEventBySourceId.get(event.id); @@ -628,9 +620,8 @@ export async function cloneConversationRuntimeLedger( await input.runStore.appendEvent(input.referenceMap.targetSessionId, runId, clonedEvent); } - if (plan.terminal.kind === 'fact' && isTerminalRunStatus(plan.run.status) && terminalEvent) { + if (plan.terminal.kind === 'fact' && terminalEvent) { await commitTerminalRunWithRuntimeFact({ - runStore: input.runStore, runtimeEventStore: input.runtimeEventStore, newId: input.newId, sessionId: input.referenceMap.targetSessionId, @@ -642,14 +633,7 @@ export async function cloneConversationRuntimeLedger( ...(plan.terminal.fact.failureClass ? { failureClass: plan.terminal.fact.failureClass } : {}), - ...(plan.run.failureMessage ? { failureMessage: plan.run.failureMessage } : {}), ...(plan.terminal.fact.abortSource ? { abortSource: plan.terminal.fact.abortSource } : {}), - runEventData: { - recovered: true, - recoveryReason: 'conversation_runtime_ledger_clone', - sourceSessionId: plan.run.sessionId, - sourceRunId: plan.run.runId, - }, }); } } @@ -664,12 +648,12 @@ export async function cloneConversationRuntimeLedger( } interface ConversationCopyRunEvents { - readonly run: AgentRunHeader; + readonly run: RuntimeInvocationRecord; readonly events: readonly RuntimeEvent[]; } async function loadConversationCopyRunEvents( - sourceRuns: readonly AgentRunHeader[], + sourceRuns: readonly RuntimeInvocationRecord[], sourceEvents: readonly RuntimeEvent[], copyTurnIds: readonly string[], runtimeEventStore: Pick, @@ -1205,7 +1189,7 @@ function logicalModelCallIdMap( function toolOperationIdMap( plans: readonly { - readonly run: AgentRunHeader; + readonly run: RuntimeInvocationRecord; readonly events: readonly RuntimeEvent[]; }[], targetInvocationIds: ReadonlyMap, @@ -1236,12 +1220,7 @@ function isCopiedAgentRunEvent(event: AgentRunEvent): event is EmittedAgentRunEv // into the target with source identities intact. The ledger's `type` is open, so such an event // may predate a retired writer or postdate this build entirely (#1942). if (!isEmittedAgentRunEventType(event.type)) return false; - return ( - event.type !== 'run_completed' && - event.type !== 'run_failed' && - event.type !== 'run_cancelled' && - event.type !== 'event_corrupt' - ); + return event.type !== 'event_corrupt'; } function cloneRuntimeEvent( @@ -1288,60 +1267,52 @@ function cloneRuntimeEvent( return cloned; } -function cloneRunHeader( - source: AgentRunHeader, - targetSessionId: string, - runId: string, - invocationId: string, +/** + * Rewrite the lineage a copied invocation's opening fact carries. + * + * The opening is an ordinary RuntimeEvent, so the copy rewrites its owned ids + * the way it rewrites every other reference. Its `source` needs no rewriting: + * a copy that contains a continuation is refused before it gets this far. + */ +function rewriteInvocationOpening( + opening: RuntimeEventInvocationOpenedContent, references: ConversationCopyReferenceMap, -): AgentRunHeader { - const cloned: AgentRunHeader = { - ...source, - invocationId, - sessionId: targetSessionId, - runId, - ...(source.parentRunId - ? { parentRunId: rewriteOwnedId(source.parentRunId, references.runIds, 'AgentRun') } - : {}), - ...(source.resumedFromRunId - ? { - resumedFromRunId: rewriteOwnedId(source.resumedFromRunId, references.runIds, 'AgentRun'), - } - : {}), - ...(source.retriedFromRunId - ? { - retriedFromRunId: rewriteOwnedId(source.retriedFromRunId, references.runIds, 'AgentRun'), - } - : {}), - ...(source.parentSessionId === references.sourceSessionId - ? { parentSessionId: targetSessionId } - : {}), - ...(source.continuationSource +): RuntimeEventInvocationOpenedContent { + const lineage = opening.lineage; + return { + ...opening, + ...(lineage ? { - continuationSource: { - ...source.continuationSource, - sourceInvocationId: rewriteOwnedId( - source.continuationSource.sourceInvocationId, - references.invocationIds, - 'invocation', - ), - sourceRunId: rewriteOwnedId( - source.continuationSource.sourceRunId, - references.runIds, - 'AgentRun', - ), + lineage: { + ...lineage, + ...(lineage.parentRunId + ? { parentRunId: rewriteOwnedId(lineage.parentRunId, references.runIds, 'AgentRun') } + : {}), + ...(lineage.resumedFromRunId + ? { + resumedFromRunId: rewriteOwnedId( + lineage.resumedFromRunId, + references.runIds, + 'AgentRun', + ), + } + : {}), + ...(lineage.retriedFromRunId + ? { + retriedFromRunId: rewriteOwnedId( + lineage.retriedFromRunId, + references.runIds, + 'AgentRun', + ), + } + : {}), + ...(lineage.parentSessionId === references.sourceSessionId + ? { parentSessionId: references.targetSessionId } + : {}), }, } : {}), }; - if (isTerminalRunStatus(source.status)) { - cloned.status = 'running'; - delete cloned.completedAt; - delete cloned.failureClass; - delete cloned.failureMessage; - delete cloned.abortSource; - } - return cloned; } function rewriteRuntimeEventReferences( @@ -1377,7 +1348,9 @@ function rewriteRuntimeEventReferences( } : {}), } - : event.content; + : event.content?.kind === 'invocation_opened' + ? rewriteInvocationOpening(event.content, references) + : event.content; const refs = event.refs ? (() => { const { @@ -1856,7 +1829,7 @@ function messageTurnId(message: StoredMessage): string | undefined { } function conversationCopyTurnClosure( - runs: readonly AgentRunHeader[], + runs: readonly RuntimeInvocationRecord[], retainedTurnIds: readonly string[], ): string[] { const result = [...new Set(retainedTurnIds)]; @@ -1868,9 +1841,9 @@ function conversationCopyTurnClosure( changed = false; for (const run of runs) { if ( - isSessionInlineRun(run) || - !run.parentRunId || - !includedRunIds.has(run.parentRunId) || + isSessionInlineInvocation(run.opening) || + !run.opening.lineage?.parentRunId || + !includedRunIds.has(run.opening.lineage.parentRunId) || includedRunIds.has(run.runId) ) { continue; @@ -1888,7 +1861,7 @@ function conversationCopyTurnClosure( function sourceCompactableEventsByRunId( plans: readonly { - readonly run: AgentRunHeader; + readonly run: RuntimeInvocationRecord; readonly events: readonly RuntimeEvent[]; }[], sessionEvents: readonly RuntimeEvent[], @@ -1898,7 +1871,7 @@ function sourceCompactableEventsByRunId( const result = new Map(); for (const plan of plans) { - if (isSessionInlineRun(plan.run)) { + if (isSessionInlineInvocation(plan.run.opening)) { result.set(plan.run.runId, inlineEvents); continue; } @@ -1914,7 +1887,7 @@ function sourceCompactableEventsByRunId( } visited.add(cursor.run.runId); reverseChain.push(cursor); - const sourceRunId = cursor.run.resumedFromRunId; + const sourceRunId = cursor.run.opening.lineage?.resumedFromRunId; if (!sourceRunId) break; cursor = plansByRunId.get(sourceRunId); if (!cursor) { @@ -1934,7 +1907,3 @@ function sourceCompactableEventsByRunId( return result; } - -function isTerminalRunStatus(status: AgentRunHeader['status']): boolean { - return status === 'completed' || status === 'failed' || status === 'cancelled'; -} diff --git a/packages/runtime/src/execution-inspect.ts b/packages/runtime/src/execution-inspect.ts index 5647c313e5..22b8eeb72d 100644 --- a/packages/runtime/src/execution-inspect.ts +++ b/packages/runtime/src/execution-inspect.ts @@ -17,8 +17,9 @@ * under the License. */ -import type { AgentRunHeader } from '@maka/core/agent-run'; import type { RuntimeEvent } from '@maka/core/runtime-event'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; +import { runtimeInvocationOutcome } from '@maka/core/runtime-invocation'; import type { SessionHeader } from '@maka/core/session'; import { AGENT_RUN_INSPECT_DOCUMENT_VERSION, @@ -39,7 +40,6 @@ import { type AgentRunInspectDiagnostic as SourceDiagnostic, type InspectAgentRunOptions, type RuntimeEventInspectReader, - type SessionAgentRunInspectReader, } from './agent-run-inspect.js'; import { isSupersededHistoryCompactCheckpoint, @@ -53,7 +53,7 @@ export interface SessionHeaderReader { export interface InspectSessionDocumentOptions { header?: SessionHeader; - runHeaders?: readonly AgentRunHeader[]; + invocations?: readonly RuntimeInvocationRecord[]; isFatalReadError?: InspectAgentRunOptions['isFatalReadError']; } @@ -63,30 +63,30 @@ export async function inspectAgentRunDocument( input: { sessionId: string; agentRunId: string; - header?: AgentRunHeader; + invocation?: RuntimeInvocationRecord; isFatalReadError?: InspectAgentRunOptions['isFatalReadError']; }, ): Promise { const model = await inspectAgentRunReadModel(runStore, runtimeEventStore, { sessionId: input.sessionId, runId: input.agentRunId, - ...(input.header ? { header: input.header } : {}), + ...(input.invocation ? { invocation: input.invocation } : {}), ...(input.isFatalReadError ? { isFatalReadError: input.isFatalReadError } : {}), includeModelReplay: false, }); - const diagnostics = model.diagnostics.map((item) => sourceDiagnostic(model.header, item)); - const tools = inspectTools(model.header, model.runtimeEvents, diagnostics); + const diagnostics = model.diagnostics.map((item) => sourceDiagnostic(model.invocation, item)); + const tools = inspectTools(model.invocation, model.runtimeEvents, diagnostics); const compactionCheckpoints = inspectCompactionCheckpoints( - model.header, + model.invocation, model.events, diagnostics, ); - const runtimeCoverage = coverageFor(model.header.runId, model.runtimeEvents); + const runtimeCoverage = coverageFor(model.invocation.runId, model.runtimeEvents); return { schemaVersion: AGENT_RUN_INSPECT_DOCUMENT_VERSION, kind: 'agent_run', - agentRun: inspectIdentity(model.header), + agentRun: inspectIdentity(model.invocation), sources: { operationalEventCount: model.events.length, runtimeEventCount: model.runtimeEvents.length, @@ -101,20 +101,21 @@ export async function inspectAgentRunDocument( export async function inspectSessionDocument( sessionStore: SessionHeaderReader, - runStore: SessionAgentRunInspectReader, + runStore: AgentRunInspectReader, runtimeEventStore: RuntimeEventInspectReader, sessionId: string, options: InspectSessionDocumentOptions = {}, ): Promise { const resolvedHeader = options.header ?? (await sessionStore.readHeader(sessionId)); - const runHeaders = options.runHeaders ?? (await runStore.listSessionRuns(sessionId)); + const invocations = + options.invocations ?? (await runtimeEventStore.listSessionInvocations(sessionId)); const agentRuns: AgentRunInspectDocument[] = []; - for (const runHeader of runHeaders) { + for (const invocation of invocations) { agentRuns.push( await inspectAgentRunDocument(runStore, runtimeEventStore, { sessionId, - agentRunId: runHeader.runId, - header: runHeader, + agentRunId: invocation.runId, + invocation, ...(options.isFatalReadError ? { isFatalReadError: options.isFatalReadError } : {}), }), ); @@ -162,7 +163,7 @@ export function renderAgentRunInspectTree(document: AgentRunInspectDocument): st `├─ Turn ${run.turnId}`, `├─ Runtime Events ${formatCoverage(document.sources.runtimeCoverage)} (${document.sources.runtimeEventCount})`, `├─ Operational Events ${document.sources.operationalEventCount}`, - `├─ Source Health [${document.sources.health.statusConsistency}]`, + `├─ Source Health [runtime ledger ${document.sources.health.runtimeLedger}]`, `├─ Tools ${document.tools.callCount} calls / ${document.tools.responseCount} responses`, ]; for (const checkpoint of document.compactionCheckpoints) { @@ -198,28 +199,34 @@ export function renderSessionInspectTree(document: SessionInspectDocument): stri return `${lines.join('\n')}\n`; } -function inspectIdentity(header: AgentRunHeader): AgentRunInspectIdentity { +function inspectIdentity(invocation: RuntimeInvocationRecord): AgentRunInspectIdentity { + const lineage = invocation.opening.lineage; + const terminal = invocation.terminalEvent; + const stateDelta = terminal?.actions?.stateDelta; + const failureClass = + typeof stateDelta?.failureClass === 'string' ? stateDelta.failureClass : undefined; + const abortSource = + typeof stateDelta?.abortSource === 'string' ? stateDelta.abortSource : undefined; return { - sessionId: header.sessionId, - agentRunId: header.runId, - ...(header.invocationId ? { invocationId: header.invocationId } : {}), - turnId: header.turnId, - ...(header.parentRunId ? { parentRunId: header.parentRunId } : {}), - ...(header.resumedFromRunId ? { resumedFromRunId: header.resumedFromRunId } : {}), - ...(header.retriedFromRunId ? { retriedFromRunId: header.retriedFromRunId } : {}), - ...(header.parentTurnId ? { parentTurnId: header.parentTurnId } : {}), - ...(header.agentId ? { agentId: header.agentId } : {}), - status: header.status, - createdAt: header.createdAt, - updatedAt: header.updatedAt, - ...(header.completedAt !== undefined ? { completedAt: header.completedAt } : {}), - ...(header.failureClass ? { failureClass: header.failureClass } : {}), - ...(header.abortSource ? { abortSource: header.abortSource } : {}), + sessionId: invocation.sessionId, + agentRunId: invocation.runId, + invocationId: invocation.invocationId, + turnId: invocation.turnId, + ...(lineage?.parentRunId ? { parentRunId: lineage.parentRunId } : {}), + ...(lineage?.resumedFromRunId ? { resumedFromRunId: lineage.resumedFromRunId } : {}), + ...(lineage?.retriedFromRunId ? { retriedFromRunId: lineage.retriedFromRunId } : {}), + ...(lineage?.parentTurnId ? { parentTurnId: lineage.parentTurnId } : {}), + ...(lineage?.agentId ? { agentId: lineage.agentId } : {}), + status: runtimeInvocationOutcome(invocation) ?? 'running', + openedAt: invocation.openedAt, + ...(terminal ? { endedAt: terminal.ts } : {}), + ...(failureClass ? { failureClass } : {}), + ...(abortSource ? { abortSource } : {}), }; } function inspectTools( - header: AgentRunHeader, + invocation: RuntimeInvocationRecord, events: readonly RuntimeEvent[], diagnostics: ExecutionInspectDiagnostic[], ): AgentRunInspectToolSummary { @@ -250,7 +257,7 @@ function inspectTools( for (const call of callsWithoutResponse) { diagnostics.push( diagnostic( - header, + invocation, 'tool_response_missing', 'warning', `Tool Call ${call.toolCallId} has no committed Runtime response; its outcome and external side effects are unknown.`, @@ -261,7 +268,7 @@ function inspectTools( for (const response of responsesWithoutCall) { diagnostics.push( diagnostic( - header, + invocation, 'tool_call_missing', 'warning', `Tool response ${response.toolCallId} has no matching Runtime call fact.`, @@ -279,7 +286,7 @@ function inspectTools( } function inspectCompactionCheckpoints( - header: AgentRunHeader, + invocation: RuntimeInvocationRecord, events: readonly { type: string; id: string; data?: Record }[], diagnostics: ExecutionInspectDiagnostic[], ): AgentRunInspectCompactionCheckpoint[] { @@ -287,7 +294,7 @@ function inspectCompactionCheckpoints( for (const event of events) { if (event.type !== 'history_compact_checkpoint_recorded') continue; const checkpoint = event.data?.checkpoint; - if (!validateHistoryCompactCheckpointShape(checkpoint, header.sessionId)) { + if (!validateHistoryCompactCheckpointShape(checkpoint, invocation.sessionId)) { // A checkpoint recorded under an older source policy is expected history, // not corruption: the ledger keeps every checkpoint it ever wrote, and // every consumer fails open on it. Reporting it as an error would drown @@ -295,7 +302,7 @@ function inspectCompactionCheckpoints( const superseded = isSupersededHistoryCompactCheckpoint(checkpoint); diagnostics.push( diagnostic( - header, + invocation, superseded ? 'compaction_checkpoint_superseded' : 'compaction_checkpoint_invalid', superseded ? 'info' : 'error', superseded @@ -323,7 +330,7 @@ function inspectCompactionCheckpoints( } function sourceDiagnostic( - header: AgentRunHeader, + invocation: RuntimeInvocationRecord, source: SourceDiagnostic, ): ExecutionInspectDiagnostic { const severity: ExecutionInspectSeverity = /read_failed|corrupt|mismatch/.test(source.code) @@ -331,11 +338,11 @@ function sourceDiagnostic( : source.code.includes('missing') ? 'warning' : 'info'; - return diagnostic(header, source.code, severity, source.message, source.eventId); + return diagnostic(invocation, source.code, severity, source.message, source.eventId); } function diagnostic( - header: AgentRunHeader, + invocation: RuntimeInvocationRecord, code: string, severity: ExecutionInspectSeverity, message: string, @@ -345,9 +352,9 @@ function diagnostic( severity, code, message, - sessionId: header.sessionId, - agentRunId: header.runId, - turnId: header.turnId, + sessionId: invocation.sessionId, + agentRunId: invocation.runId, + turnId: invocation.turnId, ...(eventId ? { eventId } : {}), }; } diff --git a/packages/runtime/src/history-compact-checkpoint-coordinator.ts b/packages/runtime/src/history-compact-checkpoint-coordinator.ts index 684e71c7b5..05533e500f 100644 --- a/packages/runtime/src/history-compact-checkpoint-coordinator.ts +++ b/packages/runtime/src/history-compact-checkpoint-coordinator.ts @@ -20,7 +20,7 @@ import type { AgentRunStore } from '@maka/core/agent-run'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; -import { isSessionInlineRun } from '@maka/core/agent-run'; +import { isSessionInlineInvocation } from '@maka/core/runtime-invocation'; import { loadLatestHistoryCompactCheckpointFromRunLedger } from './history-compact-ledger.js'; import { canReplaceHistoryCompactCheckpoint, @@ -54,10 +54,14 @@ export class HistoryCompactCheckpointCoordinator { } const existing = this.loads.get(sessionId); if (existing) return existing; - if (!this.deps.runStore) return Promise.resolve(undefined); + const runStore = this.deps.runStore; + if (!runStore) return Promise.resolve(undefined); let guardedLoad: Promise; - guardedLoad = loadLatestHistoryCompactCheckpointFromRunLedger(this.deps.runStore, sessionId) + guardedLoad = this.inlineRunIds(sessionId) + .then((runIds) => + loadLatestHistoryCompactCheckpointFromRunLedger(runStore, sessionId, runIds), + ) .then((checkpoint) => { if (checkpoint) this.scheduleCleanup(sessionId, checkpoint); if (this.loads.get(sessionId) === guardedLoad && !this.checkpoints.has(sessionId)) { @@ -107,6 +111,15 @@ export class HistoryCompactCheckpointCoordinator { this.loads.delete(sessionId); } + /** The session's own runs, enumerated from the event spine that defines them. */ + private async inlineRunIds(sessionId: string): Promise { + const store = this.deps.runtimeEventStore; + if (!store) return []; + return (await store.listSessionInvocations(sessionId)) + .filter((invocation) => isSessionInlineInvocation(invocation.opening)) + .map((invocation) => invocation.runId); + } + private scheduleCleanup(sessionId: string, checkpoint: HistoryCompactCheckpoint): void { if ( !this.deps.cleanupHistoryCompactArtifacts || @@ -119,13 +132,10 @@ export class HistoryCompactCheckpointCoordinator { tracked = previous .catch(() => {}) .then(async () => { - const runs = (await this.deps.runStore!.listSessionRuns(sessionId)).filter( - isSessionInlineRun, - ); const runtimeEvents: RuntimeEvent[] = []; - for (const run of runs) { + for (const runId of await this.inlineRunIds(sessionId)) { runtimeEvents.push( - ...(await this.deps.runtimeEventStore!.readRuntimeEvents(sessionId, run.runId)), + ...(await this.deps.runtimeEventStore!.readRuntimeEvents(sessionId, runId)), ); } await this.deps.cleanupHistoryCompactArtifacts!({ diff --git a/packages/runtime/src/history-compact-ledger.ts b/packages/runtime/src/history-compact-ledger.ts index 9698b92a44..6eb9c0fb65 100644 --- a/packages/runtime/src/history-compact-ledger.ts +++ b/packages/runtime/src/history-compact-ledger.ts @@ -54,12 +54,13 @@ function hasLoadableHistoryCompactSummary(checkpoint: HistoryCompactCheckpoint): } export async function loadHistoryCompactCheckpointsFromRunLedger( - runStore: Pick, + runStore: Pick, sessionId: string, + runIds: readonly string[], ): Promise { const checkpoints = new Map(); - for (const run of await runStore.listSessionRuns(sessionId)) { - for (const event of await runStore.readEvents(sessionId, run.runId)) { + for (const runId of runIds) { + for (const event of await runStore.readEvents(sessionId, runId)) { if (event.type !== 'history_compact_checkpoint_recorded') continue; const checkpoint = event.data?.checkpoint; if ( @@ -76,13 +77,10 @@ export async function loadHistoryCompactCheckpointsFromRunLedger( export async function loadLatestHistoryCompactCheckpointFromRunLedger( runStore: Pick< AgentRunStore, - | 'listSessionRuns' - | 'readEvents' - | 'readEventProjection' - | 'readEventLedgerRevision' - | 'repairEventProjection' + 'readEvents' | 'readEventProjection' | 'readEventLedgerRevision' | 'repairEventProjection' >, sessionId: string, + runIds: readonly string[], ): Promise { let replaceEventId: string | undefined; if (runStore.readEventProjection) { @@ -108,11 +106,9 @@ export async function loadLatestHistoryCompactCheckpointFromRunLedger( runStore.readEventLedgerRevision && runStore.repairEventProjection ? await runStore.readEventLedgerRevision(sessionId) : undefined; - const runs = await runStore.listSessionRuns(sessionId); const candidates: LedgerCheckpointCandidate[] = []; - for (let runIndex = runs.length - 1; runIndex >= 0; runIndex -= 1) { - const run = runs[runIndex]!; - const events = await runStore.readEvents(sessionId, run.runId); + for (let runIndex = runIds.length - 1; runIndex >= 0; runIndex -= 1) { + const events = await runStore.readEvents(sessionId, runIds[runIndex]!); for (let eventIndex = events.length - 1; eventIndex >= 0; eventIndex -= 1) { const event = events[eventIndex]!; if (event.type !== 'history_compact_checkpoint_recorded') continue; diff --git a/packages/runtime/src/history-compaction.ts b/packages/runtime/src/history-compaction.ts index 268aa59f50..afccbada2b 100644 --- a/packages/runtime/src/history-compaction.ts +++ b/packages/runtime/src/history-compaction.ts @@ -17,7 +17,7 @@ * under the License. */ -import type { AgentRunHeader } from '@maka/core/agent-run'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { ContextBudgetDiagnostic } from '@maka/core/usage-stats/types'; import { finitePositive } from './context-budget-helpers.js'; @@ -185,12 +185,12 @@ export interface PlanHistoryCompactionInput { highWaterSeq?: number; previousCheckpoint?: HistoryCompactCheckpoint; /** - * Run headers for the ordered events, and the route this fold is dispatched - * on. Together they name the newest reply this route produced, which is the - * only span a retreat may target: a rejection of a larger one says nothing - * about a span another model accepted. + * The invocations behind the ordered events, and the route this fold is + * dispatched on. Together they name the newest reply this route produced, + * which is the only span a retreat may target: a rejection of a larger one + * says nothing about a span another model accepted. */ - runHeaders?: readonly AgentRunHeader[]; + invocations?: readonly RuntimeInvocationRecord[]; acceptedRoute?: { modelId: string; connectionId?: string }; /** Present only when this automatic Compaction should create a Memory task. */ memoryExtractionBoundary?: HistoryCompactMemoryExtractionBoundary; @@ -229,22 +229,22 @@ export type HistoryCompactionFailReason = 'no_safe_completed_span' | 'summarizer * A span is only proven for the model and connection that accepted it: a token * count is a number in one tokenizer, and a session's history can span runs on * several routes. So the newest reply produced on the summarizer's own route - * ends the span, found through the run headers rather than by role alone — + * ends the span, found through each run's opening rather than by role alone — * everything before its first event was in a request that route accepted. * A ledger with no reply from this route has nothing proven, and the caller * must not invent a boundary. */ function acceptedInputBoundary( events: readonly RuntimeEvent[], - runHeaders: readonly AgentRunHeader[], + invocations: readonly RuntimeInvocationRecord[], route: { modelId: string; connectionId?: string } | undefined, ): number | undefined { if (!route) return undefined; const onRoute = (event: RuntimeEvent | undefined): boolean => { if (event?.role !== 'model') return false; - const header = runHeaders.find((candidate) => candidate.runId === event.runId); - if (!header || header.modelId !== route.modelId) return false; - return header.llmConnectionId === route.connectionId; + const opened = invocations.find((candidate) => candidate.runId === event.runId)?.opening.route; + if (opened?.provenance !== 'runtime' || opened.modelId !== route.modelId) return false; + return opened.llmConnectionId === route.connectionId; }; let index = -1; for (let cursor = events.length - 1; cursor >= 0; cursor -= 1) { @@ -337,7 +337,7 @@ export async function planHistoryCompaction( // (#4559). const proven = acceptedInputBoundary( input.orderedEvents, - input.runHeaders ?? [], + input.invocations ?? [], input.acceptedRoute, ); if (proven === undefined || proven >= boundary.coveredCount) { diff --git a/packages/runtime/src/message-authority.ts b/packages/runtime/src/message-authority.ts index 55498687f8..ad9df40ad3 100644 --- a/packages/runtime/src/message-authority.ts +++ b/packages/runtime/src/message-authority.ts @@ -17,8 +17,8 @@ * under the License. */ -import type { SteeringLease } from '@maka/core/backend-types'; -import type { RootExecutionDescriptor } from '@maka/core/agent-run'; +import type { BackendStopMode, SteeringLease } from '@maka/core/backend-types'; +import type { RootExecutionDescriptor } from '@maka/core/runtime-invocation'; import type { MessageContent, SessionEvent } from '@maka/core/events'; import type { StopSessionInput } from './session-manager.js'; diff --git a/packages/runtime/src/model-history.ts b/packages/runtime/src/model-history.ts index 840e7392f0..64feb3605e 100644 --- a/packages/runtime/src/model-history.ts +++ b/packages/runtime/src/model-history.ts @@ -63,7 +63,7 @@ import { } from '@maka/core/runtime-event'; import { formatAttachmentResourceRef } from '@maka/core/attachments'; import type { AttachmentRef, DirectoryReference, QuoteRef } from '@maka/core/events'; -import type { AgentRunHeader } from '@maka/core/agent-run'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { ModelMessage, ToolResultOutput, @@ -83,24 +83,29 @@ export const PROVIDER_REPLAY_PROJECTION_VERSION = 2; /** * Resolve the RuntimeEvents whose provider-owned reasoning may cross the - * current provider boundary. Route provenance remains on AgentRunHeader; - * current-run events are same-route by construction during mid-turn replay. + * current provider boundary. + * + * Route provenance is stated once, by the opening fact of the invocation that + * produced the events, and joined here by `runId`. Current-run events are + * same-route by construction during mid-turn replay. */ export function compatibleProviderReasoningReplayEventIds( events: readonly RuntimeEvent[], - runHeaders: readonly AgentRunHeader[] | undefined, + invocations: readonly RuntimeInvocationRecord[] | undefined, targetProviderStateIdentity: `sha256:${string}` | undefined, targetModelId: string, currentRunId?: string, ): ReadonlySet { const compatibleRunIds = new Set(currentRunId ? [currentRunId] : []); - if (targetProviderStateIdentity && runHeaders) { - for (const run of runHeaders) { + if (targetProviderStateIdentity && invocations) { + for (const invocation of invocations) { + const route = invocation.opening.route; if ( - run.providerStateIdentity === targetProviderStateIdentity && - run.modelId === targetModelId + route.provenance === 'runtime' && + route.providerStateIdentity === targetProviderStateIdentity && + route.modelId === targetModelId ) { - compatibleRunIds.add(run.runId); + compatibleRunIds.add(invocation.runId); } } } diff --git a/packages/runtime/src/model-projection-transition-ledger.ts b/packages/runtime/src/model-projection-transition-ledger.ts index 93cd9aec2e..c4285f5d0e 100644 --- a/packages/runtime/src/model-projection-transition-ledger.ts +++ b/packages/runtime/src/model-projection-transition-ledger.ts @@ -86,14 +86,15 @@ export interface EffectiveModelProjectionReduction { * here: the whole set is the state. */ export async function loadModelProjectionTransitionsFromRunLedger( - runStore: Pick, + runStore: Pick, sessionId: string, + runIds: readonly string[], ): Promise { const byId = new Map(); const unreadableTargets = new Set(); let unscopedUnreadable = 0; - for (const run of await runStore.listSessionRuns(sessionId)) { - for (const event of await runStore.readEvents(sessionId, run.runId)) { + for (const runId of runIds) { + for (const event of await runStore.readEvents(sessionId, runId)) { if (event.type !== MODEL_PROJECTION_TRANSITION_EVENT_TYPE) continue; const transition = decodeLedgerTransition(event, sessionId); if (!transition) { diff --git a/packages/runtime/src/openai-codex-history-compactor.ts b/packages/runtime/src/openai-codex-history-compactor.ts index 8cb442d4a8..c9018cbdce 100644 --- a/packages/runtime/src/openai-codex-history-compactor.ts +++ b/packages/runtime/src/openai-codex-history-compactor.ts @@ -78,7 +78,7 @@ export function buildOpenAiCodexHistoryCompactor(options: BuildOpenAiCodexHistor : input.source.foldedRuntimeEvents; const providerReasoningReplayEventIds = compatibleProviderReasoningReplayEventIds( events, - input.source.runHeaders, + input.source.invocations, options.providerStateIdentity, options.modelId, input.runId, diff --git a/packages/runtime/src/prior-run-context.ts b/packages/runtime/src/prior-run-context.ts index 01533dfafe..baf31ce6d8 100644 --- a/packages/runtime/src/prior-run-context.ts +++ b/packages/runtime/src/prior-run-context.ts @@ -17,162 +17,59 @@ * under the License. */ -import type { AgentRunHeader, AgentRunStore } from '@maka/core/agent-run'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; -import { isSessionInlineRun } from '@maka/core/agent-run'; -import { isTerminalRuntimeEvent } from '@maka/core/runtime-event'; -import type { StoredMessage } from '@maka/core/session'; +import { isSessionInlineInvocation } from '@maka/core/runtime-invocation'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import { buildRuntimeEventModelReplayPlan } from './model-history.js'; -import { backfillRuntimeEventsFromStoredMessages } from './runtime-event-backfill.js'; -import { classifyRuntimeEventTerminalFact } from './runtime-event-read-model.js'; -import { isTerminalRunStatus } from './session-projection-helpers.js'; -import { effectiveRunHeaderFromTerminalFact } from './terminal-run-commit.js'; export interface PriorRuntimeContext { events: RuntimeEvent[]; - runs: AgentRunHeader[]; + invocations: RuntimeInvocationRecord[]; } export interface BuildPriorRuntimeContextInput { sessionId: string; currentRunId: string; currentTurnId: string; - runStore?: AgentRunStore; runtimeEventStore?: RuntimeEventStore; - runStoreAvailable: boolean; runtimeEventStoreAvailable: boolean; - repairRunRuntimeLedger?: (sessionId: string, runId: string) => Promise; - readMessages: () => Promise; -} - -interface PriorRunTerminalFactContext { - events: RuntimeEvent[]; - run: AgentRunHeader; } +/** + * The conversation the model must see before this turn: every earlier + * session-inline invocation's events, in the order the Session committed them. + * + * A prior invocation that never reached a terminal event is still replayed. It + * was stopped while parked on an interaction, or the process died mid-turn, and + * its turn — the user's message included — is conversation either way. There is + * nothing left to reconcile here: the events are the run, so an invocation + * cannot claim an outcome its ledger does not show. + */ export async function buildPriorRuntimeContext( input: BuildPriorRuntimeContextInput, ): Promise { - if ( - !input.runStore || - !input.runtimeEventStore || - !input.runStoreAvailable || - !input.runtimeEventStoreAvailable - ) - return undefined; + const store = input.runtimeEventStore; + if (!store || !input.runtimeEventStoreAvailable) return undefined; - const runs = await input.runStore.listSessionRuns(input.sessionId); - const priorRuns = runs.filter( - (run) => - run.runId !== input.currentRunId && - run.turnId !== input.currentTurnId && - isSessionInlineRun(run), + const invocations = (await store.listSessionInvocations(input.sessionId)).filter( + (invocation) => + invocation.runId !== input.currentRunId && + invocation.turnId !== input.currentTurnId && + isSessionInlineInvocation(invocation.opening), ); - if (priorRuns.length === 0) return undefined; + if (invocations.length === 0) return undefined; - const ordered: Array<{ event: RuntimeEvent; runIndex: number; eventIndex: number }> = []; - for (let runIndex = 0; runIndex < priorRuns.length; runIndex += 1) { - const run = priorRuns[runIndex]!; - if (!isTerminalRunStatus(run.status)) { - const nonTerminal = await readNonTerminalPriorRun(input, run); - if (nonTerminal.run) priorRuns[runIndex] = nonTerminal.run; - appendEvents(ordered, nonTerminal.events, runIndex, input); - continue; - } - let events = await input.runtimeEventStore.readRuntimeEvents(input.sessionId, run.runId); - if (events.length === 0 && (await input.repairRunRuntimeLedger?.(input.sessionId, run.runId))) { - events = await input.runtimeEventStore.readRuntimeEvents(input.sessionId, run.runId); - } - if (events.length === 0) { - const recovered = await backfillMissingPriorRuntimeEvents(input, run); - if (recovered.length === 0 || !recovered.some(isTerminalRuntimeEvent)) { - throw new Error( - `Cannot build model context: RuntimeEvent ledger is missing for prior run ${run.runId}`, - ); + const events: RuntimeEvent[] = []; + for (const invocation of invocations) { + const committed = await store.readRuntimeEvents(input.sessionId, invocation.runId); + for (const event of committed) { + if (event.runId !== input.currentRunId && event.turnId !== input.currentTurnId) { + events.push(event); } - events = recovered; - } - if ( - !events.some(isTerminalRuntimeEvent) && - (await input.repairRunRuntimeLedger?.(input.sessionId, run.runId)) - ) { - events = await input.runtimeEventStore.readRuntimeEvents(input.sessionId, run.runId); - } - if (!events.some(isTerminalRuntimeEvent)) { - throw new Error( - `Cannot build model context: RuntimeEvent ledger has no terminal fact for prior run ${run.runId}`, - ); } - let terminalFact = classifyRuntimeEventTerminalFact(run, events).fact; - if (!terminalFact && (await input.repairRunRuntimeLedger?.(input.sessionId, run.runId))) { - events = await input.runtimeEventStore.readRuntimeEvents(input.sessionId, run.runId); - terminalFact = classifyRuntimeEventTerminalFact(run, events).fact; - } - if (!terminalFact) { - throw new Error( - `Cannot build model context: RuntimeEvent ledger has no valid terminal fact for prior run ${run.runId}`, - ); - } - priorRuns[runIndex] = effectiveRunHeaderFromTerminalFact(run, terminalFact); - appendEvents(ordered, events, runIndex, input); } - - ordered.sort((a, b) => a.runIndex - b.runIndex || a.eventIndex - b.eventIndex); - const events = ordered.map((item) => item.event); if (events.length === 0 || buildRuntimeEventModelReplayPlan(events).items.length === 0) return undefined; - return { events, runs: priorRuns }; -} - -/** - * 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 - * still conversation the model must see, so the ledger it does have is - * replayed either way. Dropping the run instead would delete a whole turn — - * the user message included — from every later turn's context, silently and - * for good, because the header never becomes terminal on its own. - */ -async function readNonTerminalPriorRun( - input: BuildPriorRuntimeContextInput, - run: AgentRunHeader, -): Promise<{ events: RuntimeEvent[]; run?: AgentRunHeader }> { - if (!input.runtimeEventStore) return { events: [] }; - // No repair attempt here, unlike the terminal branch: `repairRunTerminalFact` - // returns false for a non-terminal header before reading anything, so calling - // it would be a promise that only ever answers "no". - const events = await input.runtimeEventStore - .readRuntimeEvents(input.sessionId, run.runId) - .catch(() => []); - const terminalFact = classifyRuntimeEventTerminalFact(run, events).fact; - return terminalFact - ? { events, run: effectiveRunHeaderFromTerminalFact(run, terminalFact) } - : { events }; -} - -async function backfillMissingPriorRuntimeEvents( - input: BuildPriorRuntimeContextInput, - run: AgentRunHeader, -): Promise { - let messages: StoredMessage[]; - try { - messages = await input.readMessages(); - } catch { - return []; - } - return backfillRuntimeEventsFromStoredMessages({ run, messages }).events; -} - -function appendEvents( - ordered: Array<{ event: RuntimeEvent; runIndex: number; eventIndex: number }>, - events: readonly RuntimeEvent[], - runIndex: number, - input: BuildPriorRuntimeContextInput, -): void { - for (let eventIndex = 0; eventIndex < events.length; eventIndex += 1) { - const event = events[eventIndex]!; - if (event.runId !== input.currentRunId && event.turnId !== input.currentTurnId) { - ordered.push({ event, runIndex, eventIndex }); - } - } + return { events, invocations }; } diff --git a/packages/runtime/src/runtime-event-backfill.ts b/packages/runtime/src/runtime-event-backfill.ts index ac02be04bb..dcc3bdfb36 100644 --- a/packages/runtime/src/runtime-event-backfill.ts +++ b/packages/runtime/src/runtime-event-backfill.ts @@ -17,7 +17,7 @@ * under the License. */ -import type { AgentRunHeader } from '@maka/core/agent-run'; +import type { RunIdentity, TerminalAgentRunStatus } from './terminal-run-commit.js'; import type { PermissionDecisionMessage, StoredMessage, @@ -44,8 +44,22 @@ export interface RuntimeEventBackfillDiagnostic { detail?: unknown; } +/** + * How the imported turn ended, as the importer read it off the transcript. + * + * Without one there is no terminal RuntimeEvent to write: nothing else in a + * StoredMessage transcript states an outcome the ledger can be held to. + */ +export interface RuntimeEventBackfillOutcome { + status: TerminalAgentRunStatus; + ts: number; + failureClass?: string; + abortSource?: string; +} + export interface RuntimeEventBackfillInput { - run: AgentRunHeader; + run: RunIdentity & { invocationId?: string }; + outcome?: RuntimeEventBackfillOutcome; messages: readonly StoredMessage[]; invocationId?: string; modelHistory?: 'full' | 'conversation_text'; @@ -358,7 +372,14 @@ export function backfillRuntimeEventsFromStoredMessages( } } - const terminal = terminalRuntimeEvent({ run: input.run, turnMessages, invocationId, newId, now }); + const terminal = terminalRuntimeEvent({ + run: input.run, + outcome: input.outcome, + turnMessages, + invocationId, + newId, + now, + }); if (terminal.event) { events.push(terminal.event); } else if (terminal.diagnostic) { @@ -418,14 +439,15 @@ function terminalRecoveryState( } function terminalRuntimeEvent(input: { - run: AgentRunHeader; + run: RunIdentity; + outcome: RuntimeEventBackfillOutcome | undefined; turnMessages: readonly StoredMessage[]; invocationId: string; newId: () => string; now: () => number; }): { event?: RuntimeEvent; diagnostic?: RuntimeEventBackfillDiagnostic } { const turnState = latestTurnState(input.turnMessages); - const status = terminalStatus(input.run, turnState); + const status = terminalStatus(input.outcome, turnState); if (!status) { return { diagnostic: { @@ -435,19 +457,19 @@ function terminalRuntimeEvent(input: { detail: { runId: input.run.runId, turnId: input.run.turnId, - runStatus: input.run.status, + declaredStatus: input.outcome?.status, turnStatus: turnState?.status, }, }, }; } - const ts = turnState?.ts ?? input.run.completedAt ?? input.run.updatedAt; + const ts = turnState?.ts ?? input.outcome?.ts ?? input.now(); const failureClass = - status === 'failed' ? (turnState?.errorClass ?? input.run.failureClass) : undefined; + status === 'failed' ? (turnState?.errorClass ?? input.outcome?.failureClass) : undefined; const abortSource = status === 'aborted' ? (turnState?.abortSource ?? - input.run.abortSource ?? + input.outcome?.abortSource ?? (turnState?.status === 'aborted' ? 'unknown' : undefined)) : undefined; return { @@ -476,19 +498,20 @@ function terminalRuntimeEvent(input: { } function terminalStatus( - run: AgentRunHeader, + outcome: RuntimeEventBackfillOutcome | undefined, turnState: TurnStateMessage | undefined, ): RuntimeEventStatus | undefined { const legacyStatus = turnState?.status; - if (legacyStatus === 'completed' || run.status === 'completed') return 'completed'; - if (legacyStatus === 'failed' && run.status === 'failed') return 'failed'; + const declared = outcome?.status; + if (legacyStatus === 'completed' || declared === 'completed') return 'completed'; + if (legacyStatus === 'failed' && declared === 'failed') return 'failed'; if ( - (legacyStatus === 'failed' || run.status === 'failed') && - (run.failureClass || turnState?.errorClass) + (legacyStatus === 'failed' || declared === 'failed') && + (outcome?.failureClass || turnState?.errorClass) ) return 'failed'; - if (legacyStatus === 'aborted' && run.status === 'cancelled') return 'aborted'; - if ((legacyStatus === 'aborted' || run.status === 'cancelled') && turnState?.abortSource) + if (legacyStatus === 'aborted' && declared === 'cancelled') return 'aborted'; + if ((legacyStatus === 'aborted' || declared === 'cancelled') && turnState?.abortSource) return 'aborted'; return undefined; } diff --git a/packages/runtime/src/runtime-event-read-model.ts b/packages/runtime/src/runtime-event-read-model.ts index 72f6e332f9..3189e761a0 100644 --- a/packages/runtime/src/runtime-event-read-model.ts +++ b/packages/runtime/src/runtime-event-read-model.ts @@ -17,7 +17,7 @@ * under the License. */ -import type { AgentRunHeader } from '@maka/core/agent-run'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { AssistantStepContentKind, StoredMessage, TurnStatus } from '@maka/core/session'; import type { RuntimeEvent, RuntimeEventStatus } from '@maka/core/runtime-event'; import type { ToolActivityKind, ToolResultContent } from '@maka/core/events'; @@ -148,7 +148,9 @@ export interface RuntimeEventReadModelProjection { } export interface ProjectRuntimeEventsToStoredMessagesOptions { - runHeaders: readonly AgentRunHeader[] | Readonly>; + invocations: + | readonly RuntimeInvocationRecord[] + | Readonly>; canonicalPermissionOutcomes?: ReadonlyMap; } @@ -179,7 +181,7 @@ export interface RuntimeEventTerminalFactResult { } interface ProjectionState { - headers: Map; + invocations: Map; diagnostics: RuntimeEventReadModelDiagnostic[]; toolNameByUseId: Map; permissionRequestById: Map< @@ -217,7 +219,7 @@ export function projectRuntimeEventsToStoredMessages( options: ProjectRuntimeEventsToStoredMessagesOptions, ): RuntimeEventReadModelProjection { const state: ProjectionState = { - headers: normalizeHeaders(options.runHeaders), + invocations: normalizeInvocations(options.invocations), diagnostics: [], toolNameByUseId: new Map(), permissionRequestById: new Map(), @@ -518,15 +520,15 @@ export function compareRuntimeReadModelMessages( } export function classifyRuntimeEventTerminalFact( - header: AgentRunHeader, + invocation: Pick, events: readonly RuntimeEvent[], ): RuntimeEventTerminalFactResult { const diagnostics: RuntimeEventReadModelDiagnostic[] = []; if (events.length === 0) { diagnostics.push( readModelDiagnostic('incomplete_event', 'runtime ledger has no readable RuntimeEvents', { - runId: header.runId, - turnId: header.turnId, + runId: invocation.runId, + turnId: invocation.turnId, }), ); return { diagnostics }; @@ -535,9 +537,9 @@ export function classifyRuntimeEventTerminalFact( const terminalSignals = events.filter( (event) => !isPartialRuntimeEvent(event) && - event.sessionId === header.sessionId && - event.runId === header.runId && - event.turnId === header.turnId && + event.sessionId === invocation.sessionId && + event.runId === invocation.runId && + event.turnId === invocation.turnId && isTerminalRuntimeEvent(event), ); @@ -546,7 +548,7 @@ export function classifyRuntimeEventTerminalFact( readModelDiagnostic( 'incomplete_event', 'runtime ledger has no matching terminal RuntimeEvent', - { runId: header.runId, turnId: header.turnId }, + { runId: invocation.runId, turnId: invocation.turnId }, ), ); return { diagnostics }; @@ -557,8 +559,8 @@ export function classifyRuntimeEventTerminalFact( 'incomplete_event', 'runtime ledger has multiple matching terminal RuntimeEvents', { - runId: header.runId, - turnId: header.turnId, + runId: invocation.runId, + turnId: invocation.turnId, eventIds: terminalSignals.map((event) => event.id), }, ), @@ -580,8 +582,8 @@ export function classifyRuntimeEventTerminalFact( if (terminalEvent.status === 'completed') { const fact: RuntimeEventTerminalFact = { - runId: header.runId, - turnId: header.turnId, + runId: invocation.runId, + turnId: invocation.turnId, runStatus: 'completed', turnStatus: 'completed', terminalEvent, @@ -591,7 +593,7 @@ export function classifyRuntimeEventTerminalFact( } if (terminalEvent.status === 'failed') { - const failureClass = failureClassFromRuntimeEvent(terminalEvent, header); + const failureClass = failureClassFromRuntimeEvent(terminalEvent); if (!failureClass) { diagnostics.push( readModelDiagnostic( @@ -603,8 +605,8 @@ export function classifyRuntimeEventTerminalFact( return { diagnostics }; } const fact: RuntimeEventTerminalFact = { - runId: header.runId, - turnId: header.turnId, + runId: invocation.runId, + turnId: invocation.turnId, runStatus: 'failed', turnStatus: 'failed', terminalEvent, @@ -614,7 +616,7 @@ export function classifyRuntimeEventTerminalFact( return { fact, diagnostics }; } - const abortSource = abortSourceFromRuntime(terminalEvent, header); + const abortSource = abortSourceFromRuntime(terminalEvent); if (!abortSource) { diagnostics.push( readModelDiagnostic( @@ -626,8 +628,8 @@ export function classifyRuntimeEventTerminalFact( return { diagnostics }; } const fact: RuntimeEventTerminalFact = { - runId: header.runId, - turnId: header.turnId, + runId: invocation.runId, + turnId: invocation.turnId, runStatus: 'cancelled', turnStatus: 'aborted', terminalEvent, @@ -651,13 +653,13 @@ function projectText( } if (event.role === 'model') { - const header = state.headers.get(event.runId); - if (!header?.modelId) { + const invocation = state.invocations.get(event.runId); + if (!invocation?.opening.route.modelId) { diagnostic( state, event, 'incomplete_event', - 'model text RuntimeEvent requires AgentRunHeader.modelId', + 'model text RuntimeEvent requires the opening fact of its invocation', ); return false; } @@ -673,7 +675,7 @@ function projectText( ? { providerOptions: structuredClone(event.content.providerOptions) } : {}), ...(contentOrder ? { contentOrder } : {}), - modelId: header.modelId, + modelId: invocation.opening.route.modelId, }); attachPendingThinking(event, state, messages, assistantId); return true; @@ -1144,17 +1146,18 @@ function projectTerminalTurnState( state: ProjectionState, messages: StoredMessage[], ): boolean { - const header = state.headers.get(event.runId); - if (!header) { + const invocation = state.invocations.get(event.runId); + if (!invocation) { diagnostic( state, event, 'incomplete_event', - 'terminal RuntimeEvent requires an AgentRunHeader', + 'terminal RuntimeEvent requires the opening fact of its invocation', ); return false; } - const status = turnStatusFor(event.status, header.status); + const lineage = invocation.opening.lineage; + const status = turnStatusFor(event.status); if (!status) { diagnostic( state, @@ -1164,9 +1167,8 @@ function projectTerminalTurnState( ); return false; } - const abortSource = status === 'aborted' ? abortSourceFromRuntime(event, header) : undefined; - const failureClass = - status === 'failed' ? failureClassFromRuntimeEvent(event, header) : undefined; + const abortSource = status === 'aborted' ? abortSourceFromRuntime(event) : undefined; + const failureClass = status === 'failed' ? failureClassFromRuntimeEvent(event) : undefined; const partialOutputRetained = messages.some( (message) => message.turnId === event.turnId && @@ -1179,13 +1181,13 @@ function projectTerminalTurnState( turnId: event.turnId, ts: event.ts, status, - ...(header.parentTurnId ? { parentTurnId: header.parentTurnId } : {}), - ...(header.retriedFromTurnId ? { retriedFromTurnId: header.retriedFromTurnId } : {}), - ...(header.regeneratedFromTurnId - ? { regeneratedFromTurnId: header.regeneratedFromTurnId } + ...(lineage?.parentTurnId ? { parentTurnId: lineage.parentTurnId } : {}), + ...(lineage?.retriedFromTurnId ? { retriedFromTurnId: lineage.retriedFromTurnId } : {}), + ...(lineage?.regeneratedFromTurnId + ? { regeneratedFromTurnId: lineage.regeneratedFromTurnId } : {}), - ...(header.branchOfTurnId ? { branchOfTurnId: header.branchOfTurnId } : {}), - ...(header.parentSessionId ? { parentSessionId: header.parentSessionId } : {}), + ...(lineage?.branchOfTurnId ? { branchOfTurnId: lineage.branchOfTurnId } : {}), + ...(lineage?.parentSessionId ? { parentSessionId: lineage.parentSessionId } : {}), ...(status === 'aborted' ? { abortedAt: event.ts } : {}), ...(abortSource ? { abortSource } : {}), ...(status === 'failed' ? { errorClass: failureClass ?? 'unknown' } : {}), @@ -1205,7 +1207,7 @@ function projectTerminalTurnState( state, event, 'incomplete_event', - 'failed terminal event did not carry an exact AgentRunHeader.failureClass', + 'failed terminal event did not carry an exact failure class', ); } if (status === 'aborted' && !abortSource) { @@ -1213,7 +1215,7 @@ function projectTerminalTurnState( state, event, 'incomplete_event', - 'abortSource is not present in RuntimeEvent or AgentRunHeader metadata', + 'abortSource is not present in the terminal RuntimeEvent', ); } return true; @@ -1278,28 +1280,37 @@ function thinkingMessageId(event: RuntimeEvent): string { return event.refs?.providerEventId ?? event.refs?.storedMessageId ?? event.id; } -function abortSourceFromRuntime(event: RuntimeEvent, header: AgentRunHeader): string | undefined { +/** + * Why this invocation failed, according to its own terminal event. + * + * `undefined` for an invocation that is still running or did not fail. There is + * no second place to look: the event that ends the run also states the class. + */ +export function runtimeInvocationFailureClass(invocation: { + terminalEvent?: RuntimeEvent; +}): string | undefined { + const terminalEvent = invocation.terminalEvent; + if (terminalEvent?.status !== 'failed') return undefined; + return failureClassFromRuntimeEvent(terminalEvent); +} + +function abortSourceFromRuntime(event: RuntimeEvent): string | undefined { return ( stringStateDelta(event, 'abortSource') ?? stringStateDelta(event, 'source') ?? stringRecordValue(event.refs, 'abortSource') ?? - stringRecordValue(event.refs, 'source') ?? - stringRecordValue(header as unknown as Record, 'abortSource') + stringRecordValue(event.refs, 'source') ); } -function failureClassFromRuntimeEvent( - event: RuntimeEvent, - header: AgentRunHeader, -): string | undefined { +function failureClassFromRuntimeEvent(event: RuntimeEvent): string | undefined { const failureClass = stringStateDelta(event, 'failureClass') ?? stringStateDelta(event, 'errorClass') ?? stringStateDelta(event, 'reason') ?? stringStateDelta(event, 'code') ?? (event.content?.kind === 'error' ? nonEmptyString(event.content.reason) : undefined) ?? - (event.content?.kind === 'error' ? nonEmptyString(event.content.code) : undefined) ?? - header.failureClass; + (event.content?.kind === 'error' ? nonEmptyString(event.content.code) : undefined); // Retired outcome. The runtime no longer decides locally that a request // cannot be shaped to fit — the provider rejects it and recovery compacts and // retries — so a turn that ends over the window is a context overflow like any @@ -1342,25 +1353,22 @@ function toolUseIdFor(event: RuntimeEvent): string | undefined { return event.content.id || event.refs?.toolCallId; } -function normalizeHeaders( - headers: readonly AgentRunHeader[] | Readonly>, -): Map { - if (Array.isArray(headers)) { - return new Map(headers.map((header) => [header.runId, header])); - } - return new Map(Object.values(headers).map((header) => [header.runId, header])); +function normalizeInvocations( + invocations: + | readonly RuntimeInvocationRecord[] + | Readonly>, +): Map { + const values = Array.isArray(invocations) + ? (invocations as readonly RuntimeInvocationRecord[]) + : Object.values(invocations as Readonly>); + return new Map(values.map((invocation) => [invocation.runId, invocation])); } -function turnStatusFor( - eventStatus: RuntimeEventStatus | undefined, - runStatus: AgentRunHeader['status'], -): TurnStatus | undefined { +/** The terminal event states the outcome; nothing else is allowed to disagree. */ +function turnStatusFor(eventStatus: RuntimeEventStatus | undefined): TurnStatus | undefined { if (eventStatus === 'completed') return 'completed'; if (eventStatus === 'failed') return 'failed'; if (eventStatus === 'aborted' || eventStatus === 'cancelled') return 'aborted'; - if (runStatus === 'completed') return 'completed'; - if (runStatus === 'failed') return 'failed'; - if (runStatus === 'cancelled') return 'aborted'; return undefined; } diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index ef4f3a0c96..ad6f32073a 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -17,10 +17,9 @@ * under the License. */ -import type { AgentRunHeader, AgentRunStore } from '@maka/core/agent-run'; -import { runtimeInvocationOpeningFromRunHeader } from '@maka/core/agent-run'; +import type { AgentRunStore } from '@maka/core/agent-run'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import { - continuationTargetRunHeader, decodeRuntimeBoundaryCursor, type ContinuationClaimV1, type ImmutableRuntimePrefixV1, @@ -28,6 +27,7 @@ import { import { isTerminalRuntimeEvent, type RuntimeEvent, + type RuntimeEventInvocationOpenedContent, type ToolBoundaryProtocol, } from '@maka/core/runtime-event'; import type { @@ -269,7 +269,6 @@ export interface RuntimeKernelDeps { now: () => number; childTools?: readonly MakaTool[]; resolveChildTools?: (sessionId: string) => Promise; - repairRunRuntimeLedger?: (sessionId: string, runId: string) => Promise; shellRuns?: ShellRunProcessManager; cleanupHistoryCompactArtifacts?: (input: HistoryCompactCleanupRequest) => Promise; inspectContinuationSafety?: (sessionId: string) => Promise; @@ -666,7 +665,6 @@ export class RuntimeKernel implements RuntimeKernelLike { ...(this.deps.toolBoundaryProtocol ? { toolBoundaryProtocol: this.deps.toolBoundaryProtocol } : {}), - repairRunRuntimeLedger: this.deps.repairRunRuntimeLedger, newId: this.deps.newId, now: this.deps.now, ...(workspaceIdentity ? { workspaceIdentity } : {}), @@ -749,10 +747,16 @@ export class RuntimeKernel implements RuntimeKernelLike { } const header = await this.deps.store.readHeader(continuation.sessionId); - const [sourceRun, sessionRuns] = await Promise.all([ - this.deps.runStore.readRun(continuation.sessionId, continuation.sourceRunId), - this.deps.runStore.listSessionRuns(continuation.sessionId), - ]); + const sessionRuns = await this.deps.runtimeEventStore.listSessionInvocations( + continuation.sessionId, + ); + const sourceRun = sessionRuns.find((run) => run.runId === continuation.sourceRunId); + if (!sourceRun) { + throw new RuntimeContinuationRevalidationError( + 'source_identity_changed', + 'Runtime continuation source run no longer exists', + ); + } const targetProviderStateIdentity = ( await this.deps.backends.prepare(header.backend, { sessionId: continuation.sessionId, @@ -762,7 +766,7 @@ export class RuntimeKernel implements RuntimeKernelLike { }) ).providerStateIdentity; const admissionRoute: ContinuationReplayAdmissionRoute = { - runHeaders: sessionRuns, + invocations: sessionRuns, targetProviderStateIdentity, targetModelId: header.model, }; @@ -782,7 +786,7 @@ export class RuntimeKernel implements RuntimeKernelLike { const effectiveOrchestration = effectiveOrchestrationForRun(sourceRun, header); const effectiveToolMode = effectiveToolModeForRun(sourceRun); const claimedAt = this.deps.now(); - const targetRunHeader = continuationTargetRunHeaderForExecution({ + const targetOpening = continuationTargetOpeningForExecution({ continuation, sessionHeader: header, userInput, @@ -790,9 +794,8 @@ export class RuntimeKernel implements RuntimeKernelLike { effectiveOrchestration, effectiveToolMode, targetProviderStateIdentity, - claimedAt, }); - const claim = continuationClaimForExecution(continuation, claimedAt, targetRunHeader); + const claim = continuationClaimForExecution(continuation, claimedAt, targetOpening); const claimResult = await continuationAuthority.claimContinuation({ claim }); if (claimResult.kind !== 'acquired') { throw new RuntimeContinuationRevalidationError( @@ -802,19 +805,21 @@ export class RuntimeKernel implements RuntimeKernelLike { } await this.deps.continuationFailpoint?.('after_continuation_claim_committed'); - const existingClaim = sessionRuns.find( - (runHeader) => - runHeader.continuationSource?.sourceRunId === continuation.sourceRunId && - runHeader.continuationSource.sourceRuntimeEventHighWater === - continuation.sourceRuntimeEventHighWater, - ); + const existingClaim = sessionRuns.find((candidate) => { + const source = candidate.opening.source; + return ( + source.kind === 'continuation' && + source.sourceRunId === continuation.sourceRunId && + source.sourceRuntimeEventHighWater === continuation.sourceRuntimeEventHighWater + ); + }); if (existingClaim) { throw new RuntimeContinuationRevalidationError( 'continuation_claim_conflict', `Runtime continuation source already has a continuation child: ${existingClaim.runId}`, ); } - const existingTarget = sessionRuns.find((runHeader) => runHeader.runId === continuation.runId); + const existingTarget = sessionRuns.find((candidate) => candidate.runId === continuation.runId); if (existingTarget) { throw new RuntimeContinuationRevalidationError( 'target_run_conflict', @@ -836,15 +841,15 @@ export class RuntimeKernel implements RuntimeKernelLike { ...(continuationToolBoundaryProtocol ? { toolBoundaryProtocol: continuationToolBoundaryProtocol } : {}), - repairRunRuntimeLedger: this.deps.repairRunRuntimeLedger, newId: this.deps.newId, now: this.deps.now, workspaceIdentity: continuation.safetySnapshot.workspaceIdentity, effectiveOrchestration, // Round-tripped through the claim on purpose: createRunRecord compares it - // against the header it computes, so every continuation proves the claim's - // opening still reconstructs the run it authorised. - claimedRunHeader: continuationTargetRunHeader(claim), + // against the opening it computes, so every continuation proves the claim + // still authorises the run about to execute. + claimedOpening: claim.targetOpening, + claimedOpenedAt: claimedAt, effectiveToolMode, continuationFailpoint: this.deps.continuationFailpoint, commitContinuationStart: async (startedAt) => { @@ -996,7 +1001,6 @@ export class RuntimeKernel implements RuntimeKernelLike { ...(this.deps.toolBoundaryProtocol ? { toolBoundaryProtocol: this.deps.toolBoundaryProtocol } : {}), - repairRunRuntimeLedger: this.deps.repairRunRuntimeLedger, newId: this.deps.newId, now: this.deps.now, effectiveOrchestration: resolveEffectiveOrchestration('default', undefined), @@ -1054,7 +1058,7 @@ export class RuntimeKernel implements RuntimeKernelLike { turnId: run.turnId, runId: run.runId, runtimeContext: begin.runtimeContext, - runtimeContextRunHeaders: begin.runtimeContextRunHeaders, + runtimeContextInvocations: begin.runtimeContextInvocations, }); if (run.isStopped()) return; const tokenUsageEvent: TokenUsageEvent = { @@ -1389,7 +1393,7 @@ export class RuntimeKernel implements RuntimeKernelLike { text: '', context: [], runtimeContext: continuation.runtimeContext, - runtimeContextRunHeaders: admissionRoute.runHeaders, + runtimeContextInvocations: admissionRoute.invocations, continuation: continuationMetadata, }, onSessionEvent: async (sessionEvent, runtimeEvent) => { @@ -2279,6 +2283,13 @@ export class RuntimeKernel implements RuntimeKernelLike { ); } + /** Every run this Session has opened, enumerated from the event spine. */ + private async sessionRunIds(sessionId: string): Promise { + const store = this.deps.runtimeEventStore; + if (!store) return []; + return (await store.listSessionInvocations(sessionId)).map((invocation) => invocation.runId); + } + private buildBackendRecorderHooks(input: { sessionId: string; }): Pick< @@ -2322,8 +2333,12 @@ export class RuntimeKernel implements RuntimeKernelLike { checkpoint: HistoryCompactCheckpoint, turnId: string, ) => this.historyCompactCoordinator.record(sessionId, checkpoint, runFor(turnId)), - loadModelProjectionTransitions: () => - loadModelProjectionTransitionsFromRunLedger(this.deps.runStore!, sessionId), + loadModelProjectionTransitions: async () => + loadModelProjectionTransitionsFromRunLedger( + this.deps.runStore!, + sessionId, + await this.sessionRunIds(sessionId), + ), recordModelProjectionTransition: ( transition: ModelProjectionTransition, turnId: string, @@ -2847,7 +2862,7 @@ async function revalidateContinuationBoundary( function continuationClaimForExecution( continuation: RuntimeContinuation, claimedAt: number, - targetRunHeader: AgentRunHeader, + targetOpening: RuntimeEventInvocationOpenedContent, ): ContinuationClaimV1 { if ( !continuation.claimId || @@ -2873,12 +2888,19 @@ function continuationClaimForExecution( runId: continuation.runId, turnId: continuation.turnId, }, - targetOpening: runtimeInvocationOpeningFromRunHeader(targetRunHeader), + targetOpening, claimedAt, }; } -function continuationTargetRunHeaderForExecution(input: { +/** + * The opening fact the claim freezes for its target invocation. + * + * It has to be byte-identical to the one the target's own AgentRun computes: + * the run compares them before it starts, so a claim can only admit the + * execution it actually authorised. + */ +function continuationTargetOpeningForExecution(input: { continuation: RuntimeContinuation; sessionHeader: SessionHeader; userInput: UserMessageInput; @@ -2886,48 +2908,16 @@ function continuationTargetRunHeaderForExecution(input: { effectiveOrchestration: EffectiveOrchestration; effectiveToolMode: ToolMode; targetProviderStateIdentity: `sha256:${string}` | undefined; - claimedAt: number; -}): AgentRunHeader { - const { - continuation, - sessionHeader, - userInput, - effectiveOrchestration, - effectiveToolMode, - claimedAt, - } = input; +}): RuntimeEventInvocationOpenedContent { + const { continuation, sessionHeader, userInput, effectiveOrchestration, effectiveToolMode } = + input; if (!continuation.claimId || !continuation.boundary) { throw new RuntimeContinuationRevalidationError( 'source_identity_changed', 'Runtime continuation is missing its durable target-header identity', ); } - const source = continuation.boundary.segments.at(-1)!; - return { - runId: continuation.runId, - invocationId: continuation.invocationId, - sessionId: continuation.sessionId, - turnId: continuation.turnId, - status: 'created', - backendKind: sessionHeader.backend, - ...(sessionHeader.llmConnectionId === undefined - ? {} - : { llmConnectionId: sessionHeader.llmConnectionId }), - ...(input.targetProviderStateIdentity - ? { providerStateIdentity: input.targetProviderStateIdentity } - : {}), - llmConnectionSlug: sessionHeader.llmConnectionSlug, - modelId: sessionHeader.model, - cwd: sessionHeader.cwd, - workspaceIdentity: input.workspaceIdentity, - permissionMode: sessionHeader.permissionMode, - collaborationMode: sessionHeader.collaborationMode ?? 'agent', - orchestrationMode: effectiveOrchestration.mode, - orchestrationSource: effectiveOrchestration.source, - agentSwarmAuthorization: effectiveOrchestration.agentSwarmAuthorization, - toolMode: effectiveToolMode, - createdAt: claimedAt, - updatedAt: claimedAt, + const lineage = { parentRunId: continuation.sourceRunId, ...(userInput.parentTurnId ? { parentTurnId: userInput.parentTurnId } : {}), ...(userInput.retriedFromTurnId ? { retriedFromTurnId: userInput.retriedFromTurnId } : {}), @@ -2938,17 +2928,51 @@ function continuationTargetRunHeaderForExecution(input: { ...(userInput.parentSessionId ? { parentSessionId: userInput.parentSessionId } : {}), ...(userInput.agentId ? { agentId: userInput.agentId } : {}), ...(userInput.agentName ? { agentName: userInput.agentName } : {}), - continuationSource: { - protocol: 'continuation_source_v2', + }; + return { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: + sessionHeader.llmConnectionId === undefined + ? { + provenance: 'unknown', + backendKind: sessionHeader.backend, + llmConnectionSlug: sessionHeader.llmConnectionSlug, + modelId: sessionHeader.model, + } + : { + provenance: 'runtime', + backendKind: sessionHeader.backend, + llmConnectionId: sessionHeader.llmConnectionId, + llmConnectionSlug: sessionHeader.llmConnectionSlug, + modelId: sessionHeader.model, + ...(input.targetProviderStateIdentity + ? { providerStateIdentity: input.targetProviderStateIdentity } + : {}), + }, + configuration: { + cwd: sessionHeader.cwd, + permissionMode: sessionHeader.permissionMode, + collaborationMode: sessionHeader.collaborationMode ?? 'agent', + orchestrationMode: effectiveOrchestration.mode, + orchestrationSource: effectiveOrchestration.source, + toolMode: effectiveToolMode, + ...(effectiveOrchestration.agentSwarmAuthorization !== undefined + ? { agentSwarmAuthorization: effectiveOrchestration.agentSwarmAuthorization } + : {}), + workspaceIdentity: input.workspaceIdentity, + }, + root: { kind: 'user' }, + source: { + kind: 'continuation', + sourceInvocationId: continuation.sourceInvocationId, + sourceRunId: continuation.sourceRunId, + sourceTurnId: continuation.sourceTurnId, + sourceRuntimeEventHighWater: continuation.sourceRuntimeEventHighWater, claimId: continuation.claimId, boundaryDigest: continuation.boundary.manifestDigest, - sourceInvocationId: source.identity.invocationId, - sourceRunId: source.identity.runId, - sourceTurnId: source.identity.turnId, - sourceRuntimeEventHighWater: source.position.lastEventSeq, - sourcePrefixDigest: source.prefixDigest, - replayManifestDigest: continuation.boundary.manifestDigest, }, + lineage, }; } @@ -3003,7 +3027,7 @@ function consumeAdmittedRuntimeContinuation(input: { const replay = buildRuntimeEventModelReplayPlan(continuation.runtimeContext); const providerReasoningReplayEventIds = compatibleProviderReasoningReplayEventIds( continuation.runtimeContext, - input.admissionRoute.runHeaders, + input.admissionRoute.invocations, input.admissionRoute.targetProviderStateIdentity, input.admissionRoute.targetModelId, ); @@ -3066,7 +3090,7 @@ function assertRuntimeContinuationEnvelope(continuation: RuntimeContinuation): v function assertContinuationSourceUnchanged( continuation: RuntimeContinuation, - sourceRun: AgentRunHeader, + sourceRun: RuntimeInvocationRecord, sourceEvents: readonly RuntimeEvent[], ): void { if ( @@ -3080,9 +3104,10 @@ function assertContinuationSourceUnchanged( ); } const terminalEvents = matchingTerminalRuntimeEvents(sourceRun, sourceEvents); - const terminalStatus = - terminalEvents.length === 1 ? terminalRunStatusFromRuntimeEvent(terminalEvents[0]!) : undefined; - if (terminalStatus === undefined || terminalStatus !== sourceRun.status) { + if ( + terminalEvents.length !== 1 || + terminalRunStatusFromRuntimeEvent(terminalEvents[0]!) === undefined + ) { throw new RuntimeContinuationRevalidationError( 'source_terminal_changed', 'Runtime continuation source is no longer terminal', @@ -3305,25 +3330,22 @@ class RuntimeRunOwnerScope { } function effectiveOrchestrationForRun( - run: AgentRunHeader, + run: RuntimeInvocationRecord, session: SessionHeader, ): EffectiveOrchestration { - if ( - run.orchestrationMode !== undefined && - run.orchestrationSource !== undefined && - run.agentSwarmAuthorization !== undefined - ) { + const configuration = run.opening.configuration; + if (configuration.agentSwarmAuthorization !== undefined) { return { - mode: run.orchestrationMode, - source: run.orchestrationSource, - agentSwarmAuthorization: run.agentSwarmAuthorization, + mode: configuration.orchestrationMode, + source: configuration.orchestrationSource, + agentSwarmAuthorization: configuration.agentSwarmAuthorization, }; } return resolveEffectiveOrchestration(session.orchestrationMode, undefined); } -function effectiveToolModeForRun(run: AgentRunHeader): ToolMode { - return run.toolMode ?? DEFAULT_TOOL_MODE; +function effectiveToolModeForRun(run: RuntimeInvocationRecord): ToolMode { + return run.opening.configuration.toolMode; } function assertNoRemovedChildAgentRunLineage(input: UserMessageInput): void { diff --git a/packages/runtime/src/runtime-ledger-repair.ts b/packages/runtime/src/runtime-ledger-repair.ts index 39d2a50bd1..d21d005e1e 100644 --- a/packages/runtime/src/runtime-ledger-repair.ts +++ b/packages/runtime/src/runtime-ledger-repair.ts @@ -19,33 +19,23 @@ import { createHash } from 'node:crypto'; import { deriveTurnRecords } from '@maka/core/session'; -import { isSessionInlineRun } from '@maka/core/agent-run'; +import { DEFAULT_TOOL_MODE } from '@maka/core/tool-mode'; import { isTerminalRuntimeEvent } from '@maka/core/runtime-event'; -import type { AgentRunHeader, AgentRunStore } from '@maka/core/agent-run'; -import type { RuntimeEvent } from '@maka/core/runtime-event'; +import type { RuntimeEvent, RuntimeEventInvocationOpenedContent } from '@maka/core/runtime-event'; import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; +import { isSessionInlineInvocation } from '@maka/core/runtime-invocation'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { SessionHeader } from '@maka/core/session'; import type { StoredMessage, TurnRecord } from '@maka/core/session'; -import type { AgentRunLineage } from './agent-run.js'; import { backfillRuntimeEventsFromStoredMessages } from './runtime-event-backfill.js'; +import type { RuntimeEventBackfillOutcome } from './runtime-event-backfill.js'; import { projectRuntimeEventUserMessage } from './runtime-event-read-model.js'; -import { - buildRecoveredTerminalRuntimeEvent, - commitTerminalRunWithRuntimeFact, -} from './terminal-run-commit.js'; +import type { TerminalAgentRunStatus } from './terminal-run-commit.js'; export interface RuntimeLedgerRepairDeps { - runStore: AgentRunStore; runtimeEventStore: RuntimeEventStore; readMessages(sessionId: string): Promise; appendMessage(sessionId: string, message: StoredMessage): Promise; - appendTurnState( - sessionId: string, - turnId: string, - status: TurnRecord['status'], - lineage?: AgentRunLineage, - options?: { ts?: number; errorClass?: string; abortSource?: string }, - ): Promise; newId: () => string; now: () => number; } @@ -76,24 +66,23 @@ export class RuntimeLedgerRepair { constructor(private readonly deps: RuntimeLedgerRepairDeps) {} - async repairMissingTerminalFactOnce(sessionId: string, runId: string): Promise { - const run = await this.deps.runStore.readRun(sessionId, runId).catch(() => undefined); - if (!run) return false; - return this.repairRunTerminalFact(sessionId, run); - } - + /** + * Give an imported transcript a runtime spine: one invocation per turn, opened + * by its own opening fact and closed by its own terminal event. + * + * The transcript is the only evidence there is, so a turn it cannot close is + * refused rather than imported half-formed. Re-running is a no-op: a turn + * whose invocation already exists is left exactly as it is. + */ async materializeTranscriptLedger(header: SessionHeader): Promise { const sessionId = header.id; return this.withRepairQueue(sessionId, 'transcript-runs', async () => { - const [messages, runs] = await Promise.all([ - this.deps.readMessages(sessionId), - this.deps.runStore.listSessionRuns(sessionId), - ]); + const messages = await this.deps.readMessages(sessionId); const ledgerMessages = messages.filter( (message) => message.type !== 'user' || message.steeringEventId === undefined, ); - const inlineRunsByTurn = new Map( - runs.filter(isSessionInlineRun).map((run) => [run.turnId, run] as const), + const openedTurnIds = new Set( + (await this.listInlineInvocations(sessionId)).map((invocation) => invocation.turnId), ); const messagesByTurn = groupMessagesByTurn(ledgerMessages); const turns = deriveTurnRecords(ledgerMessages).filter((turn) => @@ -101,33 +90,30 @@ export class RuntimeLedgerRepair { ); if (turns.length === 0) return; - const firstCreatedAt = Math.max(0, header.createdAt - turns.length); + const firstOpenedAt = Math.max(0, header.createdAt - turns.length); for (const [index, turn] of turns.entries()) { + if (openedTurnIds.has(turn.turnId)) continue; const turnMessages = messagesByTurn.get(turn.turnId) ?? []; const runId = transcriptRunId(sessionId, turn.turnId); - const existing = inlineRunsByTurn.get(turn.turnId); - if (existing && existing.runId !== runId) continue; - const run = - existing ?? - (await this.deps.runStore.createRun( - transcriptRunHeader({ - header, - turn, - turnMessages, - runId, - createdAt: firstCreatedAt + index, - }), - )); - if (!(await this.materializeTranscriptRun(sessionId, run))) { - throw new Error(`Imported transcript Run ${run.runId} could not be materialized`); + const openedAt = firstOpenedAt + index; + const run = { sessionId, runId, turnId: turn.turnId, invocationId: runId }; + const events = [ + transcriptOpeningEvent({ header, run, openedAt, newId: this.deps.newId }), + ...backfillRuntimeEventsFromStoredMessages({ + run, + outcome: transcriptOutcome(turn, turnMessages, openedAt), + messages: turnMessages, + modelHistory: 'conversation_text', + newId: this.deps.newId, + now: this.deps.now, + }).events, + ]; + if (!events.some(isTerminalRuntimeEvent)) { + throw new Error(`Imported transcript Run ${runId} has no terminal RuntimeEvent`); } - const runtimeEvents = await this.deps.runtimeEventStore.readRuntimeEvents( - sessionId, - run.runId, - ); - if (!runtimeEvents.some((event) => isMatchingTerminalRuntimeEvent(run, event))) { - throw new Error(`Imported transcript Run ${run.runId} has no terminal RuntimeEvent`); + for (const event of events) { + await this.deps.runtimeEventStore.appendRuntimeEvent(sessionId, runId, event); } } }); @@ -138,9 +124,7 @@ export class RuntimeLedgerRepair { const messages = await this.deps.readMessages(sessionId); const messageIds = new Set(messages.map((message) => message.id)); const inlineRunIds = new Set( - (await this.deps.runStore.listSessionRuns(sessionId)) - .filter(isSessionInlineRun) - .map((run) => run.runId), + (await this.listInlineInvocations(sessionId)).map((invocation) => invocation.runId), ); let repaired = 0; for (const event of await this.deps.runtimeEventStore.readSessionRuntimeEvents(sessionId)) { @@ -155,169 +139,10 @@ export class RuntimeLedgerRepair { }); } - private async repairRunTerminalFact( - sessionId: string, - staleRun: AgentRunHeader, - ): Promise { - return this.withRepairQueue(sessionId, staleRun.runId, async () => { - const run = await this.deps.runStore.readRun(sessionId, staleRun.runId).catch(() => staleRun); - if (!isTerminalRunStatus(run.status)) return false; - const runtimeEvents = await this.deps.runtimeEventStore - .readRuntimeEvents(sessionId, run.runId) - .catch(() => undefined); - if (!runtimeEvents) return false; - const messages = await this.deps.readMessages(sessionId).catch(() => undefined); - if (!messages) return false; - return this.repairRunTerminalFactFromSnapshot( - sessionId, - run, - runtimeEvents, - messages, - 'full', - ); - }); - } - - private async materializeTranscriptRun( - sessionId: string, - createdRun: AgentRunHeader, - ): Promise { - return this.withRepairQueue(sessionId, createdRun.runId, async () => { - const run = await this.deps.runStore.readRun(sessionId, createdRun.runId); - if (!isTerminalRunStatus(run.status)) return false; - const [runtimeEvents, messages] = await Promise.all([ - this.deps.runtimeEventStore.readRuntimeEvents(sessionId, run.runId), - this.deps.readMessages(sessionId), - ]); - return this.repairRunTerminalFactFromSnapshot( - sessionId, - run, - runtimeEvents, - messages, - 'conversation_text', - ); - }); - } - - private async repairRunTerminalFactFromSnapshot( - sessionId: string, - run: AgentRunHeader, - runtimeEvents: readonly RuntimeEvent[], - messages: readonly StoredMessage[], - modelHistory: 'full' | 'conversation_text', - ): Promise { - const recovered = backfillRuntimeEventsFromStoredMessages({ - run, - messages, - modelHistory, - invocationId: runtimeEvents[0]?.invocationId, - newId: this.deps.newId, - now: this.deps.now, - }).events; - const recoveredTerminal = recovered.find((event) => isMatchingTerminalRuntimeEvent(run, event)); - const legacyTerminal = latestTurnState(messages, run.turnId); - const canTrustRecoveredTerminal = recoveredTerminal - ? isTrustworthyRecoveredTerminal(run, legacyTerminal, recoveredTerminal) - : false; - const recoveredEventsToPersist = canTrustRecoveredTerminal - ? recovered - : recovered.filter((event) => !isMatchingTerminalRuntimeEvent(run, event)); - const eventsToAppend = missingRecoveredRuntimeEvents( - run, - runtimeEvents, - recoveredEventsToPersist, - ); - for (const event of eventsToAppend) { - await this.deps.runtimeEventStore.appendRuntimeEvent(sessionId, run.runId, event); - } - - const existingTerminal = [...runtimeEvents, ...eventsToAppend].find((event) => - isMatchingTerminalRuntimeEvent(run, event), + private async listInlineInvocations(sessionId: string): Promise { + return (await this.deps.runtimeEventStore.listSessionInvocations(sessionId)).filter( + (invocation) => isSessionInlineInvocation(invocation.opening), ); - if (existingTerminal) { - return ( - (await this.repairRunHeaderFromExistingTerminal( - sessionId, - run, - messages, - legacyTerminal, - existingTerminal, - )) || eventsToAppend.length > 0 - ); - } - - await this.repairMissingTerminalAsFailed(sessionId, run, messages, [ - ...runtimeEvents, - ...eventsToAppend, - ]); - return true; - } - - private async repairRunHeaderFromExistingTerminal( - sessionId: string, - run: AgentRunHeader, - messages: readonly StoredMessage[], - turnState: Extract | undefined, - terminal: RuntimeEvent, - ): Promise { - const status = terminalRunStatusFromEvent(run, terminal); - if (!status) return false; - const ts = run.completedAt ?? terminal.ts ?? run.updatedAt ?? this.deps.now(); - const failureClass = - status === 'failed' - ? (failureClassFromExistingTerminal(terminal) ?? - (turnState?.status === 'failed' ? turnState.errorClass : undefined) ?? - 'missing_terminal_event') - : undefined; - const abortSource = - status === 'cancelled' - ? (abortSourceFromExistingTerminal(terminal) ?? - (turnState?.status === 'aborted' ? turnState.abortSource : undefined) ?? - 'unknown') - : undefined; - const existingEvents = await this.deps.runStore - .readEvents(sessionId, run.runId) - .catch(() => []); - await commitTerminalRunWithRuntimeFact({ - runStore: this.deps.runStore, - runtimeEventStore: this.deps.runtimeEventStore, - newId: this.deps.newId, - sessionId, - runId: run.runId, - turnId: run.turnId, - status, - ts, - terminalEvent: terminal, - ...(failureClass ? { failureClass } : {}), - ...(abortSource ? { abortSource } : {}), - runEventData: { - recovered: true, - recoveryReason: 'runtime_event_terminal_fact', - runtimeEventId: terminal.id, - runtimeEventStatus: terminal.status, - }, - existingEvents, - }); - await this.appendTerminalTurnStateIfNeeded( - sessionId, - messages, - run, - { - runId: run.runId, - turnId: run.turnId, - status, - ...(failureClass ? { failureClass } : {}), - diagnostic: { recoveryReason: 'runtime_event_terminal_fact', runtimeEventId: terminal.id }, - lineage: headerLineage(run), - }, - terminalTurnStatus(status), - { - ts, - ...(failureClass ? { errorClass: failureClass } : {}), - ...(abortSource ? { abortSource } : {}), - }, - ).catch(() => {}); - return true; } private async withRepairQueue( @@ -341,72 +166,6 @@ export class RuntimeLedgerRepair { } } } - - private async repairMissingTerminalAsFailed( - sessionId: string, - run: AgentRunHeader, - messages: readonly StoredMessage[], - runtimeEvents: readonly RuntimeEvent[], - ): Promise { - const ts = run.completedAt ?? run.updatedAt ?? this.deps.now(); - const failureClass = 'missing_terminal_event'; - const terminalEvent = buildRecoveredTerminalRuntimeEvent({ - id: this.deps.newId(), - run, - status: 'failed', - ts, - invocationId: runtimeEvents[0]?.invocationId ?? `recovery-${run.runId}`, - failureClass, - recoveryReason: failureClass, - message: 'terminal run header had no terminal RuntimeEvent', - }); - const existingEvents = await this.deps.runStore - .readEvents(sessionId, run.runId) - .catch(() => []); - await commitTerminalRunWithRuntimeFact({ - runStore: this.deps.runStore, - runtimeEventStore: this.deps.runtimeEventStore, - newId: this.deps.newId, - sessionId, - runId: run.runId, - turnId: run.turnId, - status: 'failed', - ts, - terminalEvent, - failureClass, - runEventData: { recovered: true, recoveryReason: failureClass }, - existingEvents, - }); - await this.appendTerminalTurnStateIfNeeded( - sessionId, - messages, - run, - { - runId: run.runId, - turnId: run.turnId, - status: 'failed', - failureClass, - diagnostic: { recoveryReason: failureClass }, - lineage: headerLineage(run), - }, - 'failed', - { ts, errorClass: failureClass }, - ).catch(() => {}); - } - - private async appendTerminalTurnStateIfNeeded( - sessionId: string, - messages: readonly StoredMessage[], - run: AgentRunHeader, - decision: RuntimeLedgerRepairDecision, - status: TurnRecord['status'], - options: { ts: number; errorClass?: string; abortSource?: string }, - ): Promise { - if (!isSessionInlineRun(run)) return; - const latest = latestTurnState(messages, decision.turnId); - if (latest && isTerminalTurnStatus(latest.status) && latest.status === status) return; - await this.deps.appendTurnState(sessionId, decision.turnId, status, decision.lineage, options); - } } function transcriptRunId(sessionId: string, turnId: string): string { @@ -414,48 +173,76 @@ function transcriptRunId(sessionId: string, turnId: string): string { return `transcript-${digest.slice(0, 48)}`; } -function transcriptRunHeader(input: { +/** + * The opening fact of an imported turn. + * + * Its route is `unknown` on purpose: an external transcript records which model + * produced the text, never which credential the host would have used, so the + * import must not let anything treat the route as authenticated. + */ +function transcriptOpeningEvent(input: { header: SessionHeader; - turn: TurnRecord; - turnMessages: readonly StoredMessage[]; - runId: string; - createdAt: number; -}): AgentRunHeader { - const updatedAt = Math.max(input.createdAt, ...input.turnMessages.map((message) => message.ts)); - const status = transcriptRunStatus(input.turn.status); + run: { sessionId: string; runId: string; turnId: string; invocationId: string }; + openedAt: number; + newId: () => string; +}): RuntimeEvent { + const opening: RuntimeEventInvocationOpenedContent = { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'unknown', + backendKind: input.header.backend, + llmConnectionSlug: input.header.llmConnectionSlug, + modelId: input.header.model, + }, + configuration: { + cwd: input.header.cwd, + permissionMode: input.header.permissionMode, + collaborationMode: input.header.collaborationMode ?? 'agent', + orchestrationMode: input.header.orchestrationMode ?? 'default', + orchestrationSource: 'session', + toolMode: DEFAULT_TOOL_MODE, + }, + root: { kind: 'user' }, + source: { kind: 'fresh' }, + }; + return { + id: input.newId(), + invocationId: input.run.invocationId, + runId: input.run.runId, + sessionId: input.run.sessionId, + turnId: input.run.turnId, + ts: input.openedAt, + partial: false, + role: 'system', + author: 'system', + modelVisibility: 'hidden', + content: opening, + }; +} + +/** How the imported turn ended, read off the transcript's own turn record. */ +function transcriptOutcome( + turn: TurnRecord, + turnMessages: readonly StoredMessage[], + openedAt: number, +): RuntimeEventBackfillOutcome { + const ts = Math.max(openedAt, ...turnMessages.map((message) => message.ts)); + const status = transcriptOutcomeStatus(turn.status); return { - runId: input.runId, - // One physical execution attempt, one identity. A derived `invocation-` - // prefix bought nothing and made the two names look independent. - invocationId: input.runId, - sessionId: input.header.id, - turnId: input.turn.turnId, status, - backendKind: input.header.backend, - ...(input.header.llmConnectionId === undefined - ? {} - : { llmConnectionId: input.header.llmConnectionId }), - llmConnectionSlug: input.header.llmConnectionSlug, - modelId: input.header.model, - cwd: input.header.cwd, - permissionMode: input.header.permissionMode, - collaborationMode: input.header.collaborationMode, - orchestrationMode: input.header.orchestrationMode, - createdAt: input.createdAt, - updatedAt, - completedAt: updatedAt, + ts, ...(status === 'failed' - ? { failureClass: input.turn.errorClass ?? 'external_transcript_failed' } + ? { failureClass: turn.errorClass ?? 'external_transcript_failed' } : {}), ...(status === 'cancelled' - ? { abortSource: input.turn.abortSource ?? 'external_session_snapshot' } + ? { abortSource: turn.abortSource ?? 'external_session_snapshot' } : {}), }; } -function transcriptRunStatus(status: TurnRecord['status']): AgentRunHeader['status'] { +function transcriptOutcomeStatus(status: TurnRecord['status']): TerminalAgentRunStatus { if (status === 'failed') return 'failed'; - if (status === 'aborted') return 'cancelled'; if (status === 'completed') return 'completed'; return 'cancelled'; } @@ -472,50 +259,6 @@ function groupMessagesByTurn(messages: readonly StoredMessage[]): Map; - lineage: AgentRunLineage; -} - -export function firstRuntimeRepairRunId( - diagnostics: readonly { code: string; message: string; runId?: string; detail?: unknown }[], - alreadyRepaired: ReadonlySet = new Set(), -): string | undefined { - for (const diagnostic of diagnostics) { - const runId = diagnostic.runId ?? diagnosticDetailRunId(diagnostic.detail); - if (!runId || alreadyRepaired.has(runId)) continue; - if (diagnostic.code !== 'incomplete_event') continue; - if ( - diagnostic.message === 'terminal run recovered from legacy projection cache' || - diagnostic.message === 'terminal run has no readable RuntimeEvent ledger' || - diagnostic.message === 'terminal run has no terminal RuntimeEvent' || - diagnostic.message === 'terminal run header does not match RuntimeEvent terminal fact' || - diagnostic.message === 'failed terminal RuntimeEvent requires a stable failure class' || - diagnostic.message === - 'failed terminal event did not carry an exact AgentRunHeader.failureClass' || - diagnostic.message === 'aborted terminal RuntimeEvent requires an abort source' || - diagnostic.message === 'abortSource is not present in RuntimeEvent or AgentRunHeader metadata' - ) { - return runId; - } - } - return undefined; -} - -function diagnosticDetailRunId(detail: unknown): string | undefined { - if (!detail || typeof detail !== 'object') return undefined; - const runId = (detail as { runId?: unknown }).runId; - return typeof runId === 'string' && runId.length > 0 ? runId : undefined; -} - -function isTerminalRunStatus(status: AgentRunHeader['status']): boolean { - return status === 'completed' || status === 'failed' || status === 'cancelled'; -} - function steeringMessageFromRuntimeEvent(event: RuntimeEvent): StoredMessage | undefined { const messageId = event.refs?.providerEventId; if ( @@ -529,183 +272,3 @@ function steeringMessageFromRuntimeEvent(event: RuntimeEvent): StoredMessage | u } return projectRuntimeEventUserMessage(event, messageId); } - -function isTerminalTurnStatus(status: TurnRecord['status']): boolean { - return status === 'completed' || status === 'failed' || status === 'aborted'; -} - -function terminalRunStatusFromEvent( - run: AgentRunHeader, - event: RuntimeEvent, -): 'completed' | 'failed' | 'cancelled' | undefined { - if (event.status === 'completed') return 'completed'; - if (event.status === 'failed') return 'failed'; - if (event.status === 'aborted' || event.status === 'cancelled') return 'cancelled'; - if (run.status === 'completed' || run.status === 'failed' || run.status === 'cancelled') - return run.status; - return undefined; -} - -function terminalTurnStatus(status: 'completed' | 'failed' | 'cancelled'): TurnRecord['status'] { - if (status === 'cancelled') return 'aborted'; - return status; -} - -function isMatchingTerminalRuntimeEvent(run: AgentRunHeader, event: RuntimeEvent): boolean { - return ( - !event.partial && - event.sessionId === run.sessionId && - event.runId === run.runId && - event.turnId === run.turnId && - (run.invocationId === undefined || event.invocationId === run.invocationId) && - isTerminalRuntimeEvent(event) - ); -} - -function missingRecoveredRuntimeEvents( - run: AgentRunHeader, - existing: readonly RuntimeEvent[], - recovered: readonly RuntimeEvent[], -): RuntimeEvent[] { - const recoveredEventKeys = new Set( - existing.map(recoveredEventKey).filter((key): key is string => key !== undefined), - ); - const hasTerminal = existing.some((event) => isMatchingTerminalRuntimeEvent(run, event)); - const matchedExistingEventIndexes = new Set(); - const missing: RuntimeEvent[] = []; - for (const event of recovered) { - if (isMatchingTerminalRuntimeEvent(run, event)) { - if (!hasTerminal) missing.push(event); - continue; - } - const eventKey = recoveredEventKey(event); - if (!eventKey) continue; - if (recoveredEventKeys.has(eventKey)) continue; - recoveredEventKeys.add(eventKey); - const existingIndex = existing.findIndex( - (candidate, index) => - !matchedExistingEventIndexes.has(index) && isSameRecoveredRuntimeEvent(candidate, event), - ); - if (existingIndex >= 0) { - matchedExistingEventIndexes.add(existingIndex); - } else { - missing.push(event); - } - } - return missing; -} - -function recoveredEventKey(event: RuntimeEvent): string | undefined { - const storedMessageId = event.refs?.storedMessageId; - if (typeof storedMessageId !== 'string' || storedMessageId.length === 0) return undefined; - return JSON.stringify({ - storedMessageId, - role: event.role, - author: event.author, - status: event.status, - content: event.content, - toolCallId: event.refs?.toolCallId, - tokenUsage: event.actions?.tokenUsage, - permissionDecision: event.actions?.permissionDecision, - }); -} - -function failureClassFromExistingTerminal(event: RuntimeEvent): string | undefined { - return ( - stringStateDelta(event, 'failureClass') ?? - stringStateDelta(event, 'errorClass') ?? - stringStateDelta(event, 'reason') ?? - stringStateDelta(event, 'code') ?? - (event.content?.kind === 'error' ? nonEmptyString(event.content.reason) : undefined) ?? - (event.content?.kind === 'error' ? nonEmptyString(event.content.code) : undefined) - ); -} - -function abortSourceFromExistingTerminal(event: RuntimeEvent): string | undefined { - return ( - stringStateDelta(event, 'abortSource') ?? - stringStateDelta(event, 'source') ?? - stringRecordValue(event.refs, 'abortSource') ?? - stringRecordValue(event.refs, 'source') - ); -} - -function stringStateDelta(event: RuntimeEvent, key: string): string | undefined { - const value = event.actions?.stateDelta?.[key]; - return typeof value === 'string' && value.length > 0 ? value : undefined; -} - -function stringRecordValue(value: unknown, key: string): string | undefined { - if (!value || typeof value !== 'object') return undefined; - const result = (value as Record)[key]; - return typeof result === 'string' && result.length > 0 ? result : undefined; -} - -function nonEmptyString(value: unknown): string | undefined { - return typeof value === 'string' && value.length > 0 ? value : undefined; -} - -function isSameRecoveredRuntimeEvent(existing: RuntimeEvent, recovered: RuntimeEvent): boolean { - return ( - !existing.partial && - existing.sessionId === recovered.sessionId && - existing.runId === recovered.runId && - existing.turnId === recovered.turnId && - existing.role === recovered.role && - existing.author === recovered.author && - existing.status === recovered.status && - JSON.stringify(existing.content) === JSON.stringify(recovered.content) && - JSON.stringify(existing.actions?.tokenUsage) === - JSON.stringify(recovered.actions?.tokenUsage) && - JSON.stringify(existing.actions?.permissionDecision) === - JSON.stringify(recovered.actions?.permissionDecision) - ); -} - -function isTrustworthyRecoveredTerminal( - run: AgentRunHeader, - turnState: Extract | undefined, - terminal: RuntimeEvent, -): boolean { - if (!turnState || !isTerminalTurnStatus(turnState.status)) return false; - if (terminal.status === 'completed') { - return run.status === 'completed' && turnState.status === 'completed'; - } - if (terminal.status === 'failed') { - return ( - run.status === 'failed' && - turnState.status === 'failed' && - (!run.failureClass || !turnState.errorClass || turnState.errorClass === run.failureClass) - ); - } - if (terminal.status === 'aborted' || terminal.status === 'cancelled') { - return run.status === 'cancelled' && turnState.status === 'aborted'; - } - return false; -} - -function latestTurnState( - messages: readonly StoredMessage[], - turnId: string, -): Extract | undefined { - for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = messages[index]; - if (message?.type === 'turn_state' && message.turnId === turnId) return message; - } - return undefined; -} - -function headerLineage(header: AgentRunHeader): AgentRunLineage { - return { - ...(header.parentRunId ? { parentRunId: header.parentRunId } : {}), - ...(header.resumedFromRunId ? { resumedFromRunId: header.resumedFromRunId } : {}), - ...(header.retriedFromRunId ? { retriedFromRunId: header.retriedFromRunId } : {}), - ...(header.parentTurnId ? { parentTurnId: header.parentTurnId } : {}), - ...(header.retriedFromTurnId ? { retriedFromTurnId: header.retriedFromTurnId } : {}), - ...(header.regeneratedFromTurnId - ? { regeneratedFromTurnId: header.regeneratedFromTurnId } - : {}), - ...(header.branchOfTurnId ? { branchOfTurnId: header.branchOfTurnId } : {}), - ...(header.parentSessionId ? { parentSessionId: header.parentSessionId } : {}), - }; -} diff --git a/packages/runtime/src/runtime-read-model.ts b/packages/runtime/src/runtime-read-model.ts index eca372de48..e103ae0dd5 100644 --- a/packages/runtime/src/runtime-read-model.ts +++ b/packages/runtime/src/runtime-read-model.ts @@ -17,13 +17,12 @@ * under the License. */ -import type { AgentRunHeader, AgentRunStore } from '@maka/core/agent-run'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { StoredMessage, TurnRecord } from '@maka/core/session'; import { deriveTurnRecords } from '@maka/core/session'; -import { isSessionInlineRun } from '@maka/core/agent-run'; -import { isTerminalRuntimeEvent } from '@maka/core/runtime-event'; +import { isSessionInlineInvocation } from '@maka/core/runtime-invocation'; import type { CanonicalPermissionOutcomeReader, CanonicalPermissionOutcomeRecord, @@ -40,11 +39,6 @@ import { buildRuntimeEventModelReplayPlan, type RuntimeEventModelReplayPlan, } from './model-history.js'; -import { backfillRuntimeEventsFromStoredMessages } from './runtime-event-backfill.js'; -import { - effectiveRunHeaderFromTerminalFact, - terminalRunHeaderMatchesFact, -} from './terminal-run-commit.js'; const CANONICAL_PERMISSION_READ_CONCURRENCY = 8; @@ -53,7 +47,6 @@ export interface RuntimeReadModelProjectionCache { } export interface RuntimeReadModelDeps { - runStore: AgentRunStore; runtimeEventStore: RuntimeEventStore; projectionCache?: RuntimeReadModelProjectionCache; canonicalPermissionOutcomes?: CanonicalPermissionOutcomeReader; @@ -64,7 +57,7 @@ export interface RuntimeReadModelSessionView { messages: StoredMessage[]; turns: TurnRecord[]; events: RuntimeEvent[]; - runs: AgentRunHeader[]; + invocations: RuntimeInvocationRecord[]; diagnostics: RuntimeEventReadModelDiagnostic[]; terminalFacts: RuntimeEventTerminalFact[]; replayPlan: RuntimeEventModelReplayPlan; @@ -94,21 +87,25 @@ export class RuntimeReadModel { async getSessionView(sessionId: string): Promise { const diagnostics: RuntimeEventReadModelDiagnostic[] = []; const inFlightTurnIds = new Set(); - let runs: AgentRunHeader[]; + let invocations: RuntimeInvocationRecord[]; try { - runs = await this.deps.runStore.listSessionRuns(sessionId); + invocations = (await this.deps.runtimeEventStore.listSessionInvocations(sessionId)).filter( + (invocation) => isSessionInlineInvocation(invocation.opening), + ); } catch (error) { - throw new RuntimeReadModelError('RuntimeReadModel could not list AgentRun headers', [ - readModelDiagnostic('unsupported_event', 'AgentRunStore.listSessionRuns failed', { - error: errorMessage(error), - }), + throw new RuntimeReadModelError('RuntimeReadModel could not list Session invocations', [ + readModelDiagnostic( + 'unsupported_event', + 'RuntimeEventStore.listSessionInvocations failed', + { + error: errorMessage(error), + }, + ), ]); } - const inlineRuns = runs.filter(isSessionInlineRun); - - if (inlineRuns.length === 0) { - return this.buildView({ runs: inlineRuns, events: [], diagnostics }); + if (invocations.length === 0) { + return this.buildView({ invocations, events: [], diagnostics }); } const durableEventOrdinals = await this.readSessionRuntimeEventOrdinals(sessionId); @@ -117,98 +114,51 @@ export class RuntimeReadModel { ); const ordered: OrderedRuntimeEvent[] = []; const terminalFacts: RuntimeEventTerminalFact[] = []; - for (let runIndex = 0; runIndex < inlineRuns.length; runIndex += 1) { - const run = inlineRuns[runIndex]!; - if (!isTerminalRunStatus(run.status)) { - const activeRunContext = await this.readNonTerminalRunContext(sessionId, run); - if (activeRunContext?.fact) { - inlineRuns[runIndex] = effectiveRunHeaderFromTerminalFact(run, activeRunContext.fact); - terminalFacts.push(activeRunContext.fact); - diagnostics.push(...activeRunContext.fact.diagnostics); - appendOrderedEvents(ordered, activeRunContext.events, runIndex, durableEventOrdinalById); - continue; - } - - const diagnostic = readModelDiagnostic( - 'incomplete_event', - 'active run is using the in-flight projection cache', - { - runId: run.runId, - turnId: run.turnId, - status: run.status, - }, - ); - diagnostics.push(diagnostic); - inFlightTurnIds.add(run.turnId); - if (!this.deps.projectionCache) { - throw new RuntimeReadModelError('RuntimeEvent ledger is incomplete for an active run', [ - readModelDiagnostic( - 'incomplete_event', - 'active run has no stable RuntimeEvent read projection', - { - runId: run.runId, - turnId: run.turnId, - status: run.status, - }, - ), - ]); - } - const overlayEvents = activeRunContext?.events.flatMap(activeInteractionOverlayEvent) ?? []; - appendOrderedEvents(ordered, overlayEvents, runIndex); - continue; - } - + for (let runIndex = 0; runIndex < invocations.length; runIndex += 1) { + const invocation = invocations[runIndex]!; let runEvents: RuntimeEvent[]; try { - runEvents = await this.deps.runtimeEventStore.readRuntimeEvents(sessionId, run.runId); + runEvents = await this.deps.runtimeEventStore.readRuntimeEvents( + sessionId, + invocation.runId, + ); } catch (error) { throw new RuntimeReadModelError('RuntimeEvent ledger read failed', [ readModelDiagnostic('unsupported_event', 'RuntimeEventStore.readRuntimeEvents failed', { - runId: run.runId, + runId: invocation.runId, error: errorMessage(error), }), ]); } - if (runEvents.length === 0) { - const recovered = await this.backfillMissingRuntimeEvents(sessionId, run); - if (recovered.length === 0 || !recovered.some(isTerminalRuntimeEvent)) { - throw new RuntimeReadModelError('RuntimeEvent ledger is missing for a terminal run', [ - readModelDiagnostic( - 'incomplete_event', - 'terminal run has no readable RuntimeEvent ledger', - { - runId: run.runId, - turnId: run.turnId, - }, - ), - ]); - } + // No terminal event yet: the invocation is still open, or the process died + // holding it. Either way the ledger is the whole truth about it, so the + // in-flight projection cache supplies the rows a live turn has not + // committed instead of a status field claiming otherwise. + if (!invocation.terminalEvent) { diagnostics.push( readModelDiagnostic( 'incomplete_event', - 'terminal run recovered from legacy projection cache', - { - runId: run.runId, - turnId: run.turnId, - }, + 'active run is using the in-flight projection cache', + { runId: invocation.runId, turnId: invocation.turnId }, ), ); - runEvents = recovered; - } - if (!runEvents.some(isTerminalRuntimeEvent)) { - throw new RuntimeReadModelError( - 'RuntimeEvent ledger has no terminal fact for a terminal run', - [ - readModelDiagnostic('incomplete_event', 'terminal run has no terminal RuntimeEvent', { - runId: run.runId, - turnId: run.turnId, - }), - ], - ); + inFlightTurnIds.add(invocation.turnId); + if (!this.deps.projectionCache) { + throw new RuntimeReadModelError('RuntimeEvent ledger is incomplete for an active run', [ + readModelDiagnostic( + 'incomplete_event', + 'active run has no stable RuntimeEvent read projection', + { runId: invocation.runId, turnId: invocation.turnId }, + ), + ]); + } + const overlayEvents = runEvents.flatMap(activeInteractionOverlayEvent); + appendOrderedEvents(ordered, overlayEvents, runIndex); + continue; } - const terminalFact = classifyRuntimeEventTerminalFact(run, runEvents); + const terminalFact = classifyRuntimeEventTerminalFact(invocation, runEvents); diagnostics.push(...terminalFact.diagnostics); if (!terminalFact.fact) { throw new RuntimeReadModelError( @@ -216,25 +166,6 @@ export class RuntimeReadModel { diagnostics, ); } - if (!terminalRunHeaderMatchesFact(run, terminalFact.fact)) { - diagnostics.push( - readModelDiagnostic( - 'incomplete_event', - 'terminal run header does not match RuntimeEvent terminal fact', - { - runId: run.runId, - turnId: run.turnId, - headerStatus: run.status, - factStatus: terminalFact.fact.runStatus, - headerFailureClass: run.failureClass, - factFailureClass: terminalFact.fact.failureClass, - headerAbortSource: run.abortSource, - factAbortSource: terminalFact.fact.abortSource, - }, - ), - ); - } - inlineRuns[runIndex] = effectiveRunHeaderFromTerminalFact(run, terminalFact.fact); terminalFacts.push(terminalFact.fact); appendOrderedEvents(ordered, runEvents, runIndex, durableEventOrdinalById); @@ -243,7 +174,7 @@ export class RuntimeReadModel { ordered.sort(compareOrderedRuntimeEvents); return this.buildView({ - runs: inlineRuns, + invocations, events: ordered.map((item) => item.event), diagnostics, terminalFacts, @@ -251,23 +182,6 @@ export class RuntimeReadModel { }); } - private async readNonTerminalRunContext( - sessionId: string, - run: AgentRunHeader, - ): Promise<{ events: RuntimeEvent[]; fact?: RuntimeEventTerminalFact } | undefined> { - let runEvents: RuntimeEvent[]; - try { - runEvents = await this.deps.runtimeEventStore.readRuntimeEvents(sessionId, run.runId); - } catch { - return undefined; - } - const fact = classifyRuntimeEventTerminalFact(run, runEvents).fact; - return { - events: runEvents, - ...(fact ? { fact } : {}), - }; - } - private async readSessionRuntimeEventOrdinals( sessionId: string, ): Promise> { @@ -284,22 +198,8 @@ export class RuntimeReadModel { } } - private async backfillMissingRuntimeEvents( - sessionId: string, - run: AgentRunHeader, - ): Promise { - if (!this.deps.projectionCache) return []; - let messages: StoredMessage[]; - try { - messages = await this.deps.projectionCache.readMessages(sessionId); - } catch { - return []; - } - return backfillRuntimeEventsFromStoredMessages({ run, messages }).events; - } - private async buildView(input: { - runs: AgentRunHeader[]; + invocations: RuntimeInvocationRecord[]; events: RuntimeEvent[]; diagnostics: RuntimeEventReadModelDiagnostic[]; terminalFacts?: RuntimeEventTerminalFact[]; @@ -307,7 +207,7 @@ export class RuntimeReadModel { }): Promise { const canonicalPermissionRead = await this.readCanonicalPermissionOutcomes(input.events); const projected = projectRuntimeEventsToStoredMessages(input.events, { - runHeaders: input.runs, + invocations: input.invocations, canonicalPermissionOutcomes: canonicalPermissionRead.outcomes, }); const diagnostics = [ @@ -322,7 +222,7 @@ export class RuntimeReadModel { throw new RuntimeReadModelError('RuntimeEvent read projection is incomplete', diagnostics); } - const sessionId = input.runs[0]?.sessionId; + const sessionId = input.invocations[0]?.sessionId; let cachedMessages: StoredMessage[] | undefined; if (sessionId && this.deps.projectionCache) { try { @@ -363,7 +263,7 @@ export class RuntimeReadModel { messages, turns: deriveTurnRecords(messages), events: input.events, - runs: input.runs, + invocations: input.invocations, diagnostics, terminalFacts: input.terminalFacts ?? [], replayPlan: buildRuntimeEventModelReplayPlan(input.events), @@ -505,10 +405,6 @@ function readModelDiagnostic( }; } -function isTerminalRunStatus(status: AgentRunHeader['status']): boolean { - return status === 'completed' || status === 'failed' || status === 'cancelled'; -} - function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/packages/runtime/src/runtime-resume.ts b/packages/runtime/src/runtime-resume.ts index 758774e38a..d368aaad2e 100644 --- a/packages/runtime/src/runtime-resume.ts +++ b/packages/runtime/src/runtime-resume.ts @@ -28,7 +28,7 @@ import { } from '@maka/core/runtime-event'; import { continuationStartEventMatchesClaim, - runHeaderMatchesClaimTarget, + invocationMatchesClaimTarget, } from '@maka/core/runtime-boundary'; import type { ContinuationClaimV1, @@ -36,7 +36,8 @@ import type { RuntimeBoundaryCursorV1, RuntimeBoundaryDigest, } from '@maka/core/runtime-boundary'; -import { runtimeInvocationOpeningFromRunHeader, type AgentRunHeader } from '@maka/core/agent-run'; +import type { RuntimeEventInvocationOpenedContent } from '@maka/core/runtime-event'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { ContinuationClaimStateV1 } from '@maka/core/runtime-event-store'; import { isDeepStrictEqual } from 'node:util'; import { @@ -50,7 +51,6 @@ import { } from './model-history.js'; import { resolveRuntimeRecovery, type RuntimeRecoveryResolution } from './recovery-resolver.js'; import { classifyRuntimeEventTerminalFact } from './runtime-event-read-model.js'; -import { terminalRunHeaderMatchesFact } from './terminal-run-commit.js'; export type ToolOperationStatus = | 'succeeded' @@ -374,7 +374,7 @@ export interface RuntimeContinuationPlannerInput { } export interface RuntimeContinuationPlannerDeps { - readSourceRun(sessionId: string, runId: string): Promise; + readSourceInvocation(sessionId: string, runId: string): Promise; readImmutableRuntimePrefix(input: { sessionId: string; runId: string; @@ -395,9 +395,9 @@ export class RuntimeContinuationPlanner { constructor(private readonly deps: RuntimeContinuationPlannerDeps) {} async plan(input: RuntimeContinuationPlannerInput): Promise { - let sourceRun: Awaited>; + let sourceInvocation: RuntimeInvocationRecord; try { - sourceRun = await this.deps.readSourceRun(input.sessionId, input.sourceRunId); + sourceInvocation = await this.deps.readSourceInvocation(input.sessionId, input.sourceRunId); } catch { return parkedPlan('source_run_unreadable', 'source AgentRun could not be read'); } @@ -407,8 +407,8 @@ export class RuntimeContinuationPlanner { prefixes = await this.readLineagePrefixes( input.sessionId, input.sourceRunId, - sourceRun, - input.admissionRoute.runHeaders, + sourceInvocation.opening, + input.admissionRoute.invocations, ); } catch (error) { if (error instanceof RuntimeLineageError) { @@ -492,8 +492,8 @@ export class RuntimeContinuationPlanner { return buildSafeBoundaryContinuationPlan(events, { ledgerReadable: true, - terminalRepairSucceeded: hasConsistentTerminalBoundary(sourceRun, events), - sourceCwd: sourceRun.cwd, + terminalRepairSucceeded: hasConsistentTerminalBoundary(events), + sourceCwd: sourceInvocation.opening.configuration.cwd, currentCwd: input.currentCwd, sourceWorkspaceIdentity: input.sourceWorkspaceIdentity, currentWorkspaceIdentity: input.currentWorkspaceIdentity, @@ -526,9 +526,9 @@ export class RuntimeContinuationPlanner { continuationClaimId: claim.claimId, continuationRunId: claim.target.runId, }; - let run: Awaited>; + let targetInvocation: RuntimeInvocationRecord; try { - run = await this.deps.readSourceRun(sessionId, claim.target.runId); + targetInvocation = await this.deps.readSourceInvocation(sessionId, claim.target.runId); } catch { return parkedPlan( 'continuation_claim_repair_required', @@ -536,8 +536,7 @@ export class RuntimeContinuationPlanner { detail, ); } - const targetRun = run; - if (!runHeaderMatchesClaimTarget(targetRun, claim)) { + if (!invocationMatchesClaimTarget(targetInvocation, claim)) { return parkedPlan( 'continuation_claim_repair_required', 'durable continuation claim target Run identity does not match its claim', @@ -569,7 +568,10 @@ export class RuntimeContinuationPlanner { detail, ); } - const terminalClassification = classifyRuntimeEventTerminalFact(targetRun, prefix.events); + const terminalClassification = classifyRuntimeEventTerminalFact( + targetInvocation, + prefix.events, + ); const terminal = prefix.events.find(isTerminalRuntimeEvent); if (terminal && prefix.events.at(-1)?.id !== terminal.id) { return parkedPlan( @@ -585,31 +587,13 @@ export class RuntimeContinuationPlanner { detail, ); } - if (terminalClassification.fact && !isTerminalRunStatus(targetRun.status)) { - return parkedPlan( - 'continuation_claim_repair_required', - 'continuation target has a terminal fact whose Run header requires repair', - detail, - ); - } - if ( - terminalClassification.fact && - isTerminalRunStatus(targetRun.status) && - terminalRunHeaderMatchesFact(targetRun, terminalClassification.fact) - ) { + if (terminalClassification.fact) { return parkedPlan( 'continuation_already_exists', 'source boundary already has a terminal continuation', detail, ); } - if (terminalClassification.fact || isTerminalRunStatus(targetRun.status)) { - return parkedPlan( - 'continuation_claim_repair_required', - 'continuation target terminal Run header does not match its RuntimeEvent fact', - detail, - ); - } if (start) { return parkedPlan( 'continuation_started_indeterminate', @@ -627,8 +611,8 @@ export class RuntimeContinuationPlanner { private async readLineagePrefixes( sessionId: string, sourceRunId: string, - sourceRun: Awaited>, - runHeaders: readonly AgentRunHeader[], + sourceOpening: RuntimeEventInvocationOpenedContent, + invocations: readonly RuntimeInvocationRecord[], ): Promise<[ImmutableRuntimePrefixV1, ...ImmutableRuntimePrefixV1[]]> { const immediate = await this.deps.readImmutableRuntimePrefix({ sessionId, @@ -636,9 +620,9 @@ export class RuntimeContinuationPlanner { }); const segments: ImmutableRuntimePrefixV1[] = [immediate]; const seen = new Set([sourceRunId]); - const v2Edges: Array<{ + const claimedEdges: Array<{ childRunId: string; - childRunHeader: AgentRunHeader; + childInvocation: RuntimeInvocationRecord; startEvent: RuntimeEvent; startKind: 'runtime_admission' | 'claim_repair'; claimId: string; @@ -646,48 +630,56 @@ export class RuntimeContinuationPlanner { providerProjectionVersion: 1 | typeof PROVIDER_REPLAY_PROJECTION_VERSION; providerReplayDigest: RuntimeBoundaryDigest; }> = []; - let childRun = sourceRun; + let childInvocation: RuntimeInvocationRecord = { + sessionId, + invocationId: immediate.identity.invocationId, + runId: sourceRunId, + turnId: immediate.identity.turnId, + openedAt: 0, + opening: sourceOpening, + }; let childRunId = sourceRunId; let childPrefix = immediate; let depth = 1; while (true) { - const current = childRun.continuationSource; + const opened = childInvocation.opening.source; + const current = opened.kind === 'continuation' ? opened : undefined; const start = childPrefix.events[0]?.actions?.continuationStart; - const currentV2 = - current && 'protocol' in current && current.protocol === 'continuation_source_v2' - ? current + // A migrated opening keeps the lineage edge but names no claim, so only + // an edge that names one can be authenticated against a durable claim. + const claimed = + current?.claimId !== undefined && current.boundaryDigest !== undefined + ? { ...current, claimId: current.claimId, boundaryDigest: current.boundaryDigest } : undefined; - if (start && !currentV2) { + if (start && !claimed) { throw new RuntimeLineageError( 'runtime_lineage_start_mismatch', `canonical continuation-start cannot be downgraded to legacy lineage for ${childRunId}`, ); } - if (currentV2) { + if (claimed) { if ( !start || - start.claimId !== currentV2.claimId || - start.boundaryDigest !== currentV2.boundaryDigest || - start.replayManifestDigest !== currentV2.replayManifestDigest || + start.claimId !== claimed.claimId || + start.boundaryDigest !== claimed.boundaryDigest || start.immediateSource.sessionId !== sessionId || - start.immediateSource.invocationId !== currentV2.sourceInvocationId || - start.immediateSource.runId !== currentV2.sourceRunId || - start.immediateSource.turnId !== currentV2.sourceTurnId || - start.immediateSource.highWater !== currentV2.sourceRuntimeEventHighWater || - start.immediateSource.prefixDigest !== currentV2.sourcePrefixDigest + start.immediateSource.invocationId !== claimed.sourceInvocationId || + start.immediateSource.runId !== claimed.sourceRunId || + start.immediateSource.turnId !== claimed.sourceTurnId || + start.immediateSource.highWater !== claimed.sourceRuntimeEventHighWater ) { throw new RuntimeLineageError( 'runtime_lineage_start_mismatch', `continuation-start does not authenticate lineage edge for ${childRunId}`, ); } - v2Edges.push({ + claimedEdges.push({ childRunId, - childRunHeader: childRun, + childInvocation, startEvent: childPrefix.events[0]!, startKind: start.provenance, claimId: start.claimId, - boundaryDigest: currentV2.boundaryDigest, + boundaryDigest: claimed.boundaryDigest, providerProjectionVersion: start.providerProjectionVersion, providerReplayDigest: start.providerReplayDigest, }); @@ -706,11 +698,11 @@ export class RuntimeContinuationPlanner { ); } seen.add(current.sourceRunId); - let run: Awaited>; + let invocation: RuntimeInvocationRecord; let prefix: ImmutableRuntimePrefixV1; try { - [run, prefix] = await Promise.all([ - this.deps.readSourceRun(sessionId, current.sourceRunId), + [invocation, prefix] = await Promise.all([ + this.deps.readSourceInvocation(sessionId, current.sourceRunId), this.deps.readImmutableRuntimePrefix({ sessionId, runId: current.sourceRunId, @@ -735,11 +727,9 @@ export class RuntimeContinuationPlanner { `continuation ancestor ${current.sourceRunId} identity does not match its lineage edge`, ); } - if ( - 'protocol' in current && - current.protocol === 'continuation_source_v2' && - current.sourcePrefixDigest !== prefix.prefixDigest - ) { + // The child's continuation-start is what froze the ancestor's prefix, so + // it is also the only record that can say the prefix has since changed. + if (start && start.immediateSource.prefixDigest !== prefix.prefixDigest) { throw new RuntimeLineageError( 'source_prefix_digest_mismatch', `continuation ancestor ${current.sourceRunId} prefix digest changed`, @@ -747,11 +737,11 @@ export class RuntimeContinuationPlanner { } segments.unshift(prefix); childPrefix = prefix; - childRun = run; + childInvocation = invocation; childRunId = current.sourceRunId; depth += 1; } - for (const edge of v2Edges) { + for (const edge of claimedEdges) { const childIndex = segments.findIndex((prefix) => prefix.identity.runId === edge.childRunId); if (childIndex <= 0) { throw new RuntimeLineageError( @@ -780,7 +770,7 @@ export class RuntimeContinuationPlanner { state.claim.boundaryDigest !== edge.boundaryDigest || state.startEventId !== edge.startEvent.id || state.startKind !== edge.startKind || - !runHeaderMatchesClaimTarget(edge.childRunHeader, state.claim) || + !invocationMatchesClaimTarget(edge.childInvocation, state.claim) || !continuationStartEventMatchesClaim(edge.startEvent, state.claim, state.startKind) ) { throw new RuntimeLineageError( @@ -801,7 +791,7 @@ export class RuntimeContinuationPlanner { ], providerProjectionVersion: edge.providerProjectionVersion, admissionRoute: { - runHeaders, + invocations, targetProviderStateIdentity: state.claim.targetOpening.route.provenance === 'runtime' ? state.claim.targetOpening.route.providerStateIdentity @@ -843,21 +833,13 @@ class RuntimeLineageError extends Error { } } -function isTerminalRunStatus(status: string): boolean { - return status === 'completed' || status === 'failed' || status === 'cancelled'; -} - -function hasConsistentTerminalBoundary( - run: AgentRunHeader, - events: readonly RuntimeEvent[], -): boolean { - if (!isTerminalRunStatus(run.status)) return false; - const classification = classifyRuntimeEventTerminalFact(run, events); - return ( - classification.fact !== undefined && - events.at(-1)?.id === classification.fact.terminalEvent.id && - terminalRunHeaderMatchesFact(run, classification.fact) - ); +/** + * Has this run ended, with the terminal event last where a sealed run must + * leave it? There is nothing else to agree with: the events are the run. + */ +function hasConsistentTerminalBoundary(events: readonly RuntimeEvent[]): boolean { + const last = events.at(-1); + return last !== undefined && isTerminalRuntimeEvent(last); } export const INDETERMINATE_TOOL_RESULT_DIRECTIVE = [ diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index a85c8c685b..7aa5dab10a 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -100,8 +100,13 @@ import { decodeAgentGraphIntentClaim } from '@maka/core/agent-graph-control'; import { executionBoundaryContains } from '@maka/core/sandbox-boundary'; import { failureClassFromCompleteStopReason } from '@maka/core/events'; import { isActiveShellRunStatus } from '@maka/core/shell-run'; -import { isSessionInlineRun, runtimeInvocationOpeningFromRunHeader } from '@maka/core/agent-run'; import { isTerminalRuntimeEvent } from '@maka/core/runtime-event'; +import { + isSessionInlineInvocation, + runtimeInvocationOutcome, + type RootExecutionDescriptor, + type RuntimeInvocationRecord, +} from '@maka/core/runtime-invocation'; import type { AgentGraphIntentClaim, AgentGraphIntentClaimStore, @@ -112,23 +117,22 @@ import type { AgentGraphProvisionedEdge, } from '@maka/core/agent-graph-topology'; import type { AgentGraphScheduleUpdateSource } from '@maka/core/agent-graph-schedule'; -import type { - AgentRunEvent, - AgentRunHeader, - AgentRunStore, - RootExecutionDescriptor, -} from '@maka/core/agent-run'; +import type { AgentRunEvent, AgentRunStore } from '@maka/core/agent-run'; import type { ArtifactRecord } from '@maka/core/artifacts'; -import { - continuationTargetRunHeader, - runHeaderMatchesClaimTarget, -} from '@maka/core/runtime-boundary'; +import { invocationMatchesClaimTarget } from '@maka/core/runtime-boundary'; import type { ContinuationClaimV1 } from '@maka/core/runtime-boundary'; import type { RuntimeEventStore, RuntimeContinuationAuthorityStore, } from '@maka/core/runtime-event-store'; -import type { RuntimeEvent, ToolBoundaryProtocol } from '@maka/core/runtime-event'; +import type { + RuntimeEvent, + RuntimeEventInvocationOpenedContent, + RuntimeInvocationConfiguration, + RuntimeInvocationLineage, + RuntimeInvocationRootAuthority, + ToolBoundaryProtocol, +} from '@maka/core/runtime-event'; import type { RunCompositionSnapshot } from '@maka/core/run-composition'; import type { SubagentWorkspaceBinding, @@ -137,7 +141,10 @@ 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 type { RuntimeEventTerminalFact } from './runtime-event-read-model.js'; +import { + runtimeInvocationFailureClass, + type RuntimeEventTerminalFact, +} from './runtime-event-read-model.js'; import { RuntimeReadModel, RuntimeReadModelError, @@ -145,12 +152,11 @@ import { type RuntimeReadModelSessionView, } from './runtime-read-model.js'; import { inspectAgentRunReadModel, type AgentRunInspectModel } from './agent-run-inspect.js'; -import { firstRuntimeRepairRunId, RuntimeLedgerRepair } from './runtime-ledger-repair.js'; +import { RuntimeLedgerRepair } from './runtime-ledger-repair.js'; import { buildRecoveredTerminalRuntimeEvent, classifyTerminalRuntimeLedger, commitTerminalRunWithRuntimeFact, - effectiveRunHeaderFromTerminalFact, terminalRunStatusFromRuntimeEvent, } from './terminal-run-commit.js'; @@ -202,6 +208,7 @@ import { buildStatusPatch, buildTurnStateMessage, turnHasRetainedOutput as messagesHaveRetainedOutput, + type RunLifecycleStatus, } from './session-projection-helpers.js'; import { assertAgentDefinitionRunnable, @@ -402,7 +409,6 @@ type ResolvedClaimedAgentGraphIntentInput = Omit< }; const CHILD_AGENT_SUMMARY_MAX_CHARS = 4_000; -const MAX_RUNTIME_LEDGER_REPAIR_ATTEMPTS = 8; export interface AgentListItem { runId: string; @@ -410,8 +416,8 @@ export interface AgentListItem { parentRunId: string; agentId?: string; agentName?: string; - status: AgentRunHeader['status']; - permissionMode: AgentRunHeader['permissionMode']; + status: RunLifecycleStatus; + permissionMode: PermissionMode; createdAt: number; updatedAt: number; completedAt?: number; @@ -425,7 +431,7 @@ export interface SubagentExecutionListItem { agentName?: string; profile?: string; turnId?: string; - status: AgentRunHeader['status']; + status: RunLifecycleStatus; permissionMode: PermissionMode; createdAt: number; updatedAt: number; @@ -457,7 +463,7 @@ export type AgentOutputView = 'result' | 'events' | 'runtime_events' | 'all'; export interface AgentOutputCommittedResult { schemaVersion: 1; - status: AgentRunHeader['status']; + status: RunLifecycleStatus; graph?: { graphId: string; workId: string; @@ -477,7 +483,7 @@ export interface AgentOutputCommittedResult { export interface AgentOutputResult { execution: SubagentExecutionRef; - header: AgentRunHeader; + invocation: RuntimeInvocationRecord; result?: AgentOutputCommittedResult; events: AgentRunEvent[]; runtimeEvents: RuntimeEvent[]; @@ -638,7 +644,6 @@ export interface StrictRecoverySessionStore extends SessionStore { } export interface StrictRecoveryAgentRunStore extends AgentRunStore { - listSessionRunsForRecovery(sessionId: string): Promise; readEventsForRecovery(sessionId: string, runId: string): Promise; } @@ -895,23 +900,14 @@ export class SessionManager { deps.runtimeCommitSink ?? runtimeCommitSinkFromEventStore(deps.runtimeEventStore); if (deps.runStore && deps.runtimeEventStore) { this.runtimeLedgerRepair = new RuntimeLedgerRepair({ - runStore: deps.runStore, runtimeEventStore: deps.runtimeEventStore, readMessages: (sessionId) => deps.store.readMessages(sessionId), appendMessage: (sessionId, message) => deps.store.appendMessage(sessionId, message), - appendTurnState: (sessionId, turnId, status, lineage, options) => - this.appendTurnState(sessionId, turnId, status, lineage, options), newId: deps.newId, now: deps.now, }); } - this.runtimeKernel = - deps.runtimeKernel ?? - new RuntimeKernel({ - ...deps, - repairRunRuntimeLedger: (sessionId, runId) => - this.repairMissingTerminalFactOnce(sessionId, runId), - }); + this.runtimeKernel = deps.runtimeKernel ?? new RuntimeKernel({ ...deps }); } // -------------------------------------------------------------------------- @@ -1025,7 +1021,7 @@ export class SessionManager { private async finalizeAndListChildTurnArtifacts( sessionId: string, turnId: string, - status: AgentRunHeader['status'], + status: RunLifecycleStatus, ): Promise { const list = this.deps.listArtifactsForTurn; if (!list) return []; @@ -1046,6 +1042,31 @@ export class SessionManager { return finalized; } + /** + * The Session's invocations, enumerated from the events that define them. + * + * There is no run table to consult: an invocation exists because its opening + * fact does, and it has ended because its terminal event does. + */ + private async listInvocations(sessionId: string): Promise { + const store = this.deps.runtimeEventStore; + if (!store) return []; + return store.listSessionInvocations(sessionId); + } + + /** One invocation by run id. Absent means no opening fact ever named it. */ + private async readInvocation(sessionId: string, runId: string): Promise { + const invocation = (await this.listInvocations(sessionId)).find( + (candidate) => candidate.runId === runId, + ); + if (!invocation) { + const error = new Error(`AgentRun ${runId} not found`) as Error & { code?: string }; + error.code = 'ENOENT'; + throw error; + } + return invocation; + } + /** Publish the recoverable write-back owed by the latest terminal worktree child Run. */ async finalizeChildWorkspacePatches(sessionId: string): Promise { if (!this.hasWorktreePatchWriteBack() || !this.deps.runStore) return; @@ -1053,13 +1074,13 @@ export class SessionManager { const binding = header.subagentWorkspace; if (!binding) return; - const latest = (await this.deps.runStore.listSessionRuns(sessionId)) - .filter(isSessionInlineRun) - .sort( - (left, right) => right.createdAt - left.createdAt || right.runId.localeCompare(left.runId), - )[0]; + const latest = latestInvocation( + (await this.listInvocations(sessionId)).filter((run) => + isSessionInlineInvocation(run.opening), + ), + ); if (!latest) return; - if (!isTerminalRunStatus(latest.status)) { + if (!latest.terminalEvent) { throw new Error( `Child Session ${sessionId} cannot finalize its workspace while Run ${latest.runId} is nonterminal`, ); @@ -1250,7 +1271,13 @@ export class SessionManager { async getContextDiagnostics(sessionId: string): Promise { const runStore = this.deps.runStore; return runStore - ? readLatestContextDiagnostics(runStore, sessionId) + ? readLatestContextDiagnostics( + runStore, + sessionId, + (await this.listInvocations(sessionId)) + .filter((run) => isSessionInlineInvocation(run.opening)) + .map((run) => run.runId), + ) : { status: 'unavailable', reason: 'trace_unavailable' }; } @@ -2034,9 +2061,9 @@ export class SessionManager { let admissionRoute: RuntimeContinuationPlannerInput['admissionRoute']; try { if (!this.deps.runStore) throw new Error('AgentRunStore is not configured'); - const [header, runHeaders] = await Promise.all([ + const [header, invocations] = await Promise.all([ this.deps.store.readHeader(sessionId), - this.deps.runStore.listSessionRuns(sessionId), + this.listInvocations(sessionId), ]); const targetProviderStateIdentity = ( await this.deps.backends.prepare(header.backend, { @@ -2046,7 +2073,7 @@ export class SessionManager { }) ).providerStateIdentity; admissionRoute = { - runHeaders, + invocations, targetProviderStateIdentity, targetModelId: header.model, }; @@ -2065,9 +2092,9 @@ export class SessionManager { return plan; } const planner = new RuntimeContinuationPlanner({ - readSourceRun: async (targetSessionId, runId) => { + readSourceInvocation: async (targetSessionId, runId) => { if (!this.deps.runStore) throw new Error('AgentRunStore is not configured'); - return this.deps.runStore.readRun(targetSessionId, runId); + return this.readInvocation(targetSessionId, runId); }, readImmutableRuntimePrefix: async (prefixInput) => { const authority = runtimeContinuationAuthority(this.deps.runtimeEventStore); @@ -2087,11 +2114,14 @@ export class SessionManager { sourceRuntimeEventHighWater, ) => { if (!this.deps.runStore) throw new Error('AgentRunStore is not configured'); - return (await this.deps.runStore.listSessionRuns(targetSessionId)).find( - (run) => - run.continuationSource?.sourceRunId === sourceRunId && - run.continuationSource.sourceRuntimeEventHighWater === sourceRuntimeEventHighWater, - ); + return (await this.listInvocations(targetSessionId)).find((run) => { + const source = run.opening.source; + return ( + source.kind === 'continuation' && + source.sourceRunId === sourceRunId && + source.sourceRuntimeEventHighWater === sourceRuntimeEventHighWater + ); + }); }, newId: this.deps.newId, }); @@ -2123,9 +2153,9 @@ export class SessionManager { this.recordContinuationPlan(sessionId, input.sourceRunId, plan); return plan; } - const sourceRun = await this.deps.runStore - .readRun(sessionId, input.sourceRunId) - .catch(() => undefined); + const sourceRun = await this.readInvocation(sessionId, input.sourceRunId).catch( + () => undefined, + ); if (!sourceRun) { const plan: SafeBoundaryContinuationPlan = { disposition: 'park', @@ -2137,7 +2167,7 @@ export class SessionManager { this.recordContinuationPlan(sessionId, input.sourceRunId, plan); return plan; } - if (!sourceRun.workspaceIdentity) { + if (!sourceRun.opening.configuration.workspaceIdentity) { const plan: SafeBoundaryContinuationPlan = { disposition: 'park', rejectionReasons: ['workspace_identity_missing'], @@ -2172,7 +2202,7 @@ export class SessionManager { return this.planSafeBoundaryContinuation(sessionId, { sourceRunId: input.sourceRunId, currentCwd: header.cwd, - sourceWorkspaceIdentity: sourceRun.workspaceIdentity, + sourceWorkspaceIdentity: sourceRun.opening.configuration.workspaceIdentity, currentWorkspaceIdentity: observation.workspaceIdentity, backgroundOperationsSettled: observation.backgroundOperationsSettled, availableToolNames: observation.availableToolNames, @@ -2207,13 +2237,15 @@ export class SessionManager { this.recordContinuationPlan(sessionId, '', plan); return plan; } - const candidate = (await this.deps.runStore.listSessionRuns(sessionId)) - .filter( - (run) => (run.status === 'failed' || run.status === 'cancelled') && isSessionInlineRun(run), - ) - .sort( - (left, right) => right.createdAt - left.createdAt || right.runId.localeCompare(left.runId), - )[0]; + const candidate = latestInvocation( + (await this.listInvocations(sessionId)).filter((run) => { + const outcome = runtimeInvocationOutcome(run); + return ( + (outcome === 'failed' || outcome === 'cancelled') && + isSessionInlineInvocation(run.opening) + ); + }), + ); if (!candidate) { const plan: SafeBoundaryContinuationPlan = { disposition: 'park', @@ -2393,7 +2425,7 @@ export class SessionManager { } const [parentHeader, sourceRun, parentBoundary] = await Promise.all([ this.deps.store.readHeader(input.source.sessionId), - this.deps.runStore.readRun(input.source.sessionId, input.source.runId), + this.readInvocation(input.source.sessionId, input.source.runId), this.deps.store.readExecutionBoundary(input.source.sessionId), ]); if ( @@ -2720,7 +2752,7 @@ export class SessionManager { return readyNotification; }; - let run = await this.deps.runStore.readRun(child.id, claim.targetRunId).catch((error) => { + let run = await this.readInvocation(child.id, claim.targetRunId).catch((error) => { if (isNotFoundError(error)) return undefined; throw error; }); @@ -2744,7 +2776,7 @@ export class SessionManager { ); }, }); - run = await this.deps.runStore.readRun(child.id, claim.targetRunId); + run = await this.readInvocation(child.id, claim.targetRunId); this.assertClaimedAgentGraphRun(child, snapshot, claim, run); await this.assertClaimedAgentGraphPrompt( child.id, @@ -2761,15 +2793,15 @@ export class SessionManager { await this.assertClaimedAgentGraphPrompt(child.id, claim.targetTurnId, input.prompt); await notifyReady(); while ( - !isTerminalRunStatus(run.status) && + !run.terminalEvent && this.runtimeKernel.hasActiveRun?.(child.id, run.runId, run.turnId) ) { await delay(25, undefined, input.abortSignal ? { signal: input.abortSignal } : undefined); - run = await this.deps.runStore.readRun(child.id, claim.targetRunId); + run = await this.readInvocation(child.id, claim.targetRunId); } - if (!isTerminalRunStatus(run.status)) { + if (!run.terminalEvent) { await this.recoverAgentRunsFromLedger(child.id); - run = await this.deps.runStore.readRun(child.id, claim.targetRunId); + run = await this.readInvocation(child.id, claim.targetRunId); } this.assertClaimedAgentGraphRun(child, snapshot, claim, run); await this.assertClaimedAgentGraphPrompt(child.id, claim.targetTurnId, input.prompt); @@ -2779,7 +2811,7 @@ export class SessionManager { await this.finalizeChildWorkspacePatches(child.id); const [runs, messages] = await Promise.all([ - this.deps.runStore.listSessionRuns(child.id), + this.listInvocations(child.id), this.deps.store.readMessages(child.id), ]); const turnOwner = runs.find((candidate) => candidate.turnId === claim.targetTurnId); @@ -2864,13 +2896,14 @@ export class SessionManager { } const completedAt = this.deps.now(); - const completedRun = await this.deps.runStore.readRun(child.id, claim.targetRunId); + const completedRun = await this.readInvocation(child.id, claim.targetRunId); this.assertClaimedAgentGraphRun(child, snapshot, claim, completedRun); - const failureClass = completedRun.failureClass ?? summary.failureClass; + const completedFacts = invocationListingFacts(completedRun); + const failureClass = completedFacts.failureClass ?? summary.failureClass; const artifacts = await this.finalizeAndListChildTurnArtifacts( child.id, claim.targetTurnId, - completedRun.status, + completedFacts.status, ); return { claimId: claim.claimId, @@ -2883,7 +2916,7 @@ export class SessionManager { profile: snapshot.profile, turnId: claim.targetTurnId, runId: claim.targetRunId, - status: agentRunStatusForSpawnResult(completedRun.status), + status: agentRunStatusForSpawnResult(completedFacts.status), permissionMode: child.permissionMode, summary: summary.text(), artifactIds: artifacts.map((artifact) => artifact.id), @@ -2948,15 +2981,16 @@ export class SessionManager { child: SessionHeader, snapshot: NonNullable, claim: AgentGraphIntentClaim, - run: AgentRunHeader, + run: RuntimeInvocationRecord, ): void { + const lineage = run.opening.lineage; if ( run.sessionId !== child.id || run.runId !== claim.targetRunId || run.turnId !== claim.targetTurnId || - !isSessionInlineRun(run) || - run.agentId !== snapshot.agentId || - (run.agentName !== undefined && run.agentName !== snapshot.agentName) + !isSessionInlineInvocation(run.opening) || + lineage?.agentId !== snapshot.agentId || + (lineage?.agentName !== undefined && lineage.agentName !== snapshot.agentName) ) { throw new Error('Existing AgentRun does not match the claimed graph activation identity'); } @@ -3003,7 +3037,7 @@ export class SessionManager { } const [parentHeader, parentRun, parentBoundary] = await Promise.all([ this.deps.store.readHeader(parentSessionId), - this.deps.runStore.readRun(parentSessionId, input.spawnedBy.parentRunId), + this.readInvocation(parentSessionId, input.spawnedBy.parentRunId), this.deps.store.readExecutionBoundary(parentSessionId), ]); this.assertActiveParentRun(parentSessionId, parentRun, input.spawnedBy.parentTurnId); @@ -3133,7 +3167,7 @@ export class SessionManager { // crash boundary. Revalidate admission after the lookup: the parent or // caller may have settled while durable state was being inspected. try { - const latestParentRun = await this.deps.runStore.readRun( + const latestParentRun = await this.readInvocation( parentSessionId, input.spawnedBy.parentRunId, ); @@ -3207,9 +3241,10 @@ export class SessionManager { 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) + const facts = run ? invocationListingFacts(run) : undefined; + const failureClass = facts?.failureClass ?? summary.failureClass; + const artifacts = facts + ? await this.finalizeAndListChildTurnArtifacts(child.id, turnId, facts.status) : []; return { childSessionId: child.id, @@ -3218,7 +3253,7 @@ export class SessionManager { profile: snapshot.profile, turnId, runId, - status: run ? agentRunStatusForSpawnResult(run.status) : summary.status(aborted), + status: facts ? agentRunStatusForSpawnResult(facts.status) : summary.status(aborted), permissionMode: child.permissionMode, summary: summary.text(), artifactIds: artifacts.map((artifact) => artifact.id), @@ -3244,7 +3279,7 @@ export class SessionManager { if (!snapshot || !spawn) { throw new Error('Stored child session is missing its durable runtime or spawn identity'); } - let run = await this.deps.runStore.readRun(child.id, spawn.initialRunId).catch((error) => { + let run = await this.readInvocation(child.id, spawn.initialRunId).catch((error) => { if (isNotFoundError(error)) return undefined; throw error; }); @@ -3252,32 +3287,33 @@ export class SessionManager { await notifyReady(); while ( - !isTerminalRunStatus(run.status) && + !run.terminalEvent && this.runtimeKernel.hasActiveRun?.(child.id, run.runId, run.turnId) ) { await delay(25, undefined, input.abortSignal ? { signal: input.abortSignal } : undefined); - run = await this.deps.runStore.readRun(child.id, spawn.initialRunId); + run = await this.readInvocation(child.id, spawn.initialRunId); } - if (!isTerminalRunStatus(run.status)) { + if (!run.terminalEvent) { await this.recoverAgentRunsFromLedger(child.id); - run = await this.deps.runStore.readRun(child.id, spawn.initialRunId); + run = await this.readInvocation(child.id, spawn.initialRunId); } return await this.projectExistingChildSpawn(child, run); } private async projectExistingChildSpawn( child: SessionHeader, - run: AgentRunHeader, + run: RuntimeInvocationRecord, ): Promise { if (!this.deps.runtimeEventStore) { throw new Error('Child session projection requires RuntimeEventStore'); } const snapshot = child.subagentRuntime; if (!snapshot) throw new Error('Stored child session is missing its durable runtime snapshot'); + const facts = invocationListingFacts(run); const [messages, runtimeEvents, artifacts] = await Promise.all([ this.deps.store.readMessages(child.id), this.deps.runtimeEventStore.readRuntimeEvents(child.id, run.runId), - this.finalizeAndListChildTurnArtifacts(child.id, run.turnId, run.status), + this.finalizeAndListChildTurnArtifacts(child.id, run.turnId, facts.status), ]); const storedSummary = messages @@ -3299,7 +3335,6 @@ export class SessionManager { .filter((event) => event.partial) .map((event) => event.content.text) .join(''); - const completedAt = run.completedAt ?? run.updatedAt; return { childSessionId: child.id, agentId: snapshot.agentId, @@ -3307,15 +3342,15 @@ export class SessionManager { profile: snapshot.profile, turnId: run.turnId, runId: run.runId, - status: agentRunStatusForSpawnResult(run.status), + status: agentRunStatusForSpawnResult(facts.status), permissionMode: child.permissionMode, summary: trimSummary(durableRuntimeSummary ?? (storedSummary || partialRuntimeSummary)), artifactIds: artifacts.map((artifact) => artifact.id), - startedAt: run.createdAt, - completedAt, - durationMs: Math.max(0, completedAt - run.createdAt), + startedAt: facts.createdAt, + completedAt: facts.updatedAt, + durationMs: facts.durationMs ?? 0, eventCount: runtimeEvents.length, - ...(run.failureClass ? { failureClass: run.failureClass } : {}), + ...(facts.failureClass ? { failureClass: facts.failureClass } : {}), }; } @@ -3377,36 +3412,20 @@ export class SessionManager { }); const presets = this.deps.subagentCatalog ? await this.deps.subagentCatalog.list() : []; if (!this.deps.runStore) return { definitions, presets, executions: [], runs: [] }; - const runs = await this.deps.runStore.listSessionRuns(sessionId); - const childRuns = await Promise.all( - runs - .filter( - (run): run is AgentRunHeader & { parentRunId: string } => - !!run.parentRunId && !isSessionInlineRun(run), - ) - .map( - async (run): Promise => ({ - ...(await this.effectiveRunHeaderFromRuntimeLedger(run)), - parentRunId: run.parentRunId, - }), - ), + const childRuns = (await this.listInvocations(sessionId)).filter( + (run) => !!run.opening.lineage?.parentRunId && !isSessionInlineInvocation(run.opening), ); - const legacyRuns = childRuns.map((run) => ({ - runId: run.runId, - turnId: run.turnId, - parentRunId: run.parentRunId, - ...(run.agentId ? { agentId: run.agentId } : {}), - ...(run.agentName ? { agentName: run.agentName } : {}), - status: run.status, - permissionMode: run.permissionMode, - createdAt: run.createdAt, - updatedAt: run.updatedAt, - ...(run.completedAt !== undefined ? { completedAt: run.completedAt } : {}), - ...(run.completedAt !== undefined - ? { durationMs: Math.max(0, run.completedAt - run.createdAt) } - : {}), - ...(run.failureClass ? { failureClass: run.failureClass } : {}), - })); + const legacyRuns = childRuns.map((run) => { + const facts = invocationListingFacts(run); + return { + runId: run.runId, + turnId: run.turnId, + parentRunId: run.opening.lineage!.parentRunId!, + ...(run.opening.lineage?.agentId ? { agentId: run.opening.lineage.agentId } : {}), + ...(run.opening.lineage?.agentName ? { agentName: run.opening.lineage.agentName } : {}), + ...facts, + }; + }); const childSessionHeaders = await Promise.all( (await this.listChildSessions(sessionId)).map((child) => this.deps.store.readHeader(child.id), @@ -3414,16 +3433,8 @@ export class SessionManager { ); const childSessionExecutions = await Promise.all( childSessionHeaders.map(async (child): Promise => { - const childRuns = await this.deps.runStore!.listSessionRuns(child.id); - const latest = childRuns - .slice() - .sort( - (left, right) => - right.createdAt - left.createdAt || - right.updatedAt - left.updatedAt || - right.runId.localeCompare(left.runId), - )[0]; - const run = latest ? await this.effectiveRunHeaderFromRuntimeLedger(latest) : undefined; + const run = latestInvocation(await this.listInvocations(child.id)); + const facts = run ? invocationListingFacts(run) : undefined; const currentRunId = run?.runId ?? child.subagentSpawn?.initialRunId; return { execution: { @@ -3437,15 +3448,13 @@ export class SessionManager { : {}), ...(child.subagentRuntime?.profile ? { profile: child.subagentRuntime.profile } : {}), ...(run?.turnId ? { turnId: run.turnId } : {}), - status: run?.status ?? (child.status === 'aborted' ? 'cancelled' : 'created'), - permissionMode: run?.permissionMode ?? child.permissionMode, - createdAt: run?.createdAt ?? child.createdAt, - updatedAt: run?.updatedAt ?? child.lastMessageAt ?? child.createdAt, - ...(run?.completedAt !== undefined ? { completedAt: run.completedAt } : {}), - ...(run?.completedAt !== undefined - ? { durationMs: Math.max(0, run.completedAt - run.createdAt) } - : {}), - ...(run?.failureClass ? { failureClass: run.failureClass } : {}), + status: facts?.status ?? (child.status === 'aborted' ? 'cancelled' : 'running'), + permissionMode: facts?.permissionMode ?? child.permissionMode, + createdAt: facts?.createdAt ?? child.createdAt, + updatedAt: facts?.updatedAt ?? child.lastMessageAt ?? child.createdAt, + ...(facts?.completedAt !== undefined ? { completedAt: facts.completedAt } : {}), + ...(facts?.durationMs !== undefined ? { durationMs: facts.durationMs } : {}), + ...(facts?.failureClass ? { failureClass: facts.failureClass } : {}), }; }), ); @@ -3454,7 +3463,7 @@ export class SessionManager { presets, executions: [ ...childSessionExecutions, - ...childRuns.map( + ...legacyRuns.map( (run): SubagentExecutionListItem => ({ execution: { kind: 'legacy_child_run', @@ -3469,9 +3478,7 @@ export class SessionManager { createdAt: run.createdAt, updatedAt: run.updatedAt, ...(run.completedAt !== undefined ? { completedAt: run.completedAt } : {}), - ...(run.completedAt !== undefined - ? { durationMs: Math.max(0, run.completedAt - run.createdAt) } - : {}), + ...(run.durationMs !== undefined ? { durationMs: run.durationMs } : {}), ...(run.failureClass ? { failureClass: run.failureClass } : {}), }), ), @@ -3493,27 +3500,23 @@ export class SessionManager { throw new Error('agent_output requires AgentRunStore and RuntimeEventStore'); } const located = await this.findChildRunForOutput(sessionId, input); - const { header } = located; + const { invocation } = located; const inspected = await inspectAgentRunReadModel( this.deps.runStore, this.deps.runtimeEventStore, - { - sessionId: header.sessionId, - runId: header.runId, - header, - }, + { sessionId: invocation.sessionId, runId: invocation.runId, invocation }, ); const artifacts = await this.finalizeAndListChildTurnArtifacts( - header.sessionId, - header.turnId, - inspected.header.status, + invocation.sessionId, + invocation.turnId, + runtimeInvocationOutcome(inspected.invocation) ?? 'running', ); const maxEvents = normalizeAgentOutputMaxEvents(input.maxEvents); const maxBytes = normalizeAgentOutputMaxBytes(input.maxBytes); const view = input.view ?? 'runtime_events'; if (view === 'result') { const boundedResult = buildAgentOutputCommittedResult({ - header: inspected.header, + invocation: inspected.invocation, runtimeEvents: inspected.runtimeEvents, artifacts, maxArtifacts: maxEvents, @@ -3522,7 +3525,7 @@ export class SessionManager { }); return { execution: located.execution, - header: inspected.header, + invocation: inspected.invocation, result: boundedResult.result, events: [], runtimeEvents: [], @@ -3554,7 +3557,7 @@ export class SessionManager { ); return { execution: located.execution, - header: inspected.header, + invocation: inspected.invocation, events: bounded.events, runtimeEvents: bounded.runtimeEvents, sourceHealth: inspected.sourceHealth, @@ -3662,30 +3665,44 @@ export class SessionManager { throw new Error('Hosted admission recovery requires execution stores'); } const session = await this.deps.store.readHeader(input.sessionId); - const headerExtras: Partial = {}; + let root: RuntimeInvocationRootAuthority = { kind: 'user' }; + let orchestration: Pick< + RuntimeInvocationConfiguration, + 'orchestrationMode' | 'orchestrationSource' | 'agentSwarmAuthorization' + > = { + orchestrationMode: session.orchestrationMode ?? 'default', + orchestrationSource: 'session', + agentSwarmAuthorization: 'none', + }; + const lineage: RuntimeInvocationLineage = {}; let recoveryReason: string; let diagnostic: Record; let workspaceIdentity: string | undefined; if (input.execution.kind === 'goal') { - headerExtras.goalId = input.execution.goalId; + root = { kind: 'goal', goalId: input.execution.goalId }; recoveryReason = 'goal_internal_admission_without_run'; diagnostic = { executionKind: input.execution.kind, goalId: input.execution.goalId, }; } else if (input.execution.kind === 'legacy_automation') { - headerExtras.legacyAutomationId = input.execution.automationId; + root = { kind: 'legacy_automation', legacyAutomationId: input.execution.automationId }; recoveryReason = 'legacy_automation_authority_removed'; diagnostic = { executionKind: input.execution.kind, automationId: input.execution.automationId, }; } else if (input.execution.kind === 'agent_graph_supervisor_wake') { - headerExtras.agentGraphWakeId = input.execution.wakeId; - headerExtras.agentGraphWakeAttemptId = input.execution.attemptId; - headerExtras.orchestrationMode = 'graph'; - headerExtras.orchestrationSource = 'turn_override'; - headerExtras.agentSwarmAuthorization = 'none'; + root = { + kind: 'agent_graph_supervisor_wake', + wakeId: input.execution.wakeId, + attemptId: input.execution.attemptId, + }; + orchestration = { + orchestrationMode: 'graph', + orchestrationSource: 'turn_override', + agentSwarmAuthorization: 'none', + }; recoveryReason = 'agent_graph_supervisor_internal_admission_without_run'; diagnostic = { executionKind: input.execution.kind, @@ -3737,27 +3754,24 @@ export class SessionManager { input.execution.kind === 'linked_child_resume' || input.execution.kind === 'linked_child_provider_retry' ) { - const sourceRun = await this.deps.runStore.readRun( - input.sessionId, - input.execution.sourceRunId, - ); + const sourceRun = await this.readInvocation(input.sessionId, input.execution.sourceRunId); if ( - sourceRun.agentId !== input.execution.agentId || - sourceRun.agentName !== input.execution.agentName + sourceRun.opening.lineage?.agentId !== input.execution.agentId || + sourceRun.opening.lineage.agentName !== input.execution.agentName ) { throw new Error( `Admitted Turn ${input.turnId} source changed its trusted agent identity`, ); } - workspaceIdentity = sourceRun.workspaceIdentity; + workspaceIdentity = sourceRun.opening.configuration.workspaceIdentity; if (input.execution.kind === 'linked_child_resume') { - headerExtras.resumedFromRunId = input.execution.sourceRunId; + lineage.resumedFromRunId = input.execution.sourceRunId; } else { - headerExtras.retriedFromRunId = input.execution.sourceRunId; + lineage.retriedFromRunId = input.execution.sourceRunId; } } - headerExtras.agentId = input.execution.agentId; - headerExtras.agentName = input.execution.agentName; + lineage.agentId = input.execution.agentId; + lineage.agentName = input.execution.agentName; recoveryReason = 'child_internal_admission_without_run'; diagnostic = { executionKind: input.execution.kind, @@ -3770,27 +3784,55 @@ export class SessionManager { throw new Error('External message recovery closure is not supported'); } - const run: AgentRunHeader = { - runId: input.runId, - invocationId: input.runId, + const run = { sessionId: input.sessionId, + invocationId: input.runId, + runId: input.runId, turnId: input.turnId, - status: 'created', - backendKind: session.backend, - ...(session.llmConnectionId === undefined - ? {} - : { llmConnectionId: session.llmConnectionId }), - llmConnectionSlug: session.llmConnectionSlug, - modelId: session.model, - cwd: session.cwd, - ...(workspaceIdentity !== undefined ? { workspaceIdentity } : {}), - permissionMode: session.permissionMode, - collaborationMode: session.collaborationMode ?? 'agent', - createdAt: input.admittedAt, - updatedAt: input.admittedAt, - ...headerExtras, }; - await this.deps.runStore.createRun(run, { durable: true }); + const opening: RuntimeEventInvocationOpenedContent = { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: + session.llmConnectionId === undefined + ? { + provenance: 'unknown', + backendKind: session.backend, + llmConnectionSlug: session.llmConnectionSlug, + modelId: session.model, + } + : { + provenance: 'runtime', + backendKind: session.backend, + llmConnectionId: session.llmConnectionId, + llmConnectionSlug: session.llmConnectionSlug, + modelId: session.model, + }, + configuration: { + cwd: session.cwd, + permissionMode: session.permissionMode, + collaborationMode: session.collaborationMode ?? 'agent', + toolMode: DEFAULT_TOOL_MODE, + ...orchestration, + ...(workspaceIdentity !== undefined ? { workspaceIdentity } : {}), + }, + root, + source: { kind: 'fresh' }, + ...(Object.keys(lineage).length > 0 ? { lineage } : {}), + }; + // The admission never reached an AgentRun, so nothing else will ever open + // this invocation. Recovery opens and closes it in one pass so the Turn + // ends up on the spine like any other, with its own reason for ending. + await this.deps.runtimeEventStore.appendRuntimeEvent(input.sessionId, input.runId, { + id: this.deps.newId(), + ...run, + ts: input.admittedAt, + partial: false, + role: 'system', + author: 'system', + modelVisibility: 'hidden', + content: opening, + }); const ts = this.deps.now(); const terminalEvent = buildRecoveredTerminalRuntimeEvent({ @@ -3804,7 +3846,6 @@ export class SessionManager { message: 'app_restarted', }); await commitTerminalRunWithRuntimeFact({ - runStore: this.deps.runStore, runtimeEventStore: this.deps.runtimeEventStore, newId: this.deps.newId, sessionId: input.sessionId, @@ -3814,12 +3855,6 @@ export class SessionManager { ts, terminalEvent, failureClass: 'app_restarted', - runEventData: { - recovered: true, - recoveryReason, - ...diagnostic, - }, - existingEvents: [], }); } @@ -3952,7 +3987,7 @@ export class SessionManager { } const readMessages = readMessagesSnapshot.bind(this.deps.store); const view = await this.getSessionView(sessionId, { readMessages }); - if (view.runs.length > 0 || view.messages.length > 0) return view; + if (view.invocations.length > 0 || view.messages.length > 0) return view; const messages = await readMessages(sessionId); if (messages.length === 0) return view; return { @@ -3990,16 +4025,15 @@ export class SessionManager { private async findRunByTurnId( sessionId: string, turnId: string, - ): Promise { + ): Promise { if (!this.deps.runStore) return undefined; - const runs = await this.deps.runStore.listSessionRuns(sessionId).catch(() => []); - const run = runs.find((candidate) => candidate.turnId === turnId); - return run ? this.effectiveRunHeaderFromRuntimeLedger(run) : undefined; + const runs = await this.listInvocations(sessionId).catch(() => []); + return runs.find((candidate) => candidate.turnId === turnId); } private assertActiveParentRun( parentSessionId: string, - parentRun: AgentRunHeader, + parentRun: RuntimeInvocationRecord, parentTurnId: string, ): void { if ( @@ -4062,7 +4096,7 @@ export class SessionManager { sessionId: string, input: AgentOutputInput, ): Promise<{ - header: AgentRunHeader; + invocation: RuntimeInvocationRecord; execution: SubagentExecutionRef; graph?: NonNullable; }> { @@ -4082,27 +4116,19 @@ export class SessionManager { ) { throw new Error('agent_output could not find the requested child session'); } - const runs = await this.deps.runStore?.listSessionRuns(child.id); + const runs = await this.listInvocations(child.id); const selected = execution.currentRunId - ? runs?.find((run) => run.runId === execution.currentRunId) - : runs - ?.slice() - .sort( - (left, right) => - right.createdAt - left.createdAt || - right.updatedAt - left.updatedAt || - right.runId.localeCompare(left.runId), - )[0]; - if (!selected || !isSessionInlineRun(selected)) { + ? runs.find((run) => run.runId === execution.currentRunId) + : latestInvocation(runs); + if (!selected || !isSessionInlineInvocation(selected.opening)) { throw new Error('agent_output could not find the requested child session run'); } - const header = await this.effectiveRunHeaderFromRuntimeLedger(selected); return { - header, + invocation: selected, execution: { kind: 'child_session', sessionId: child.id, - currentRunId: header.runId, + currentRunId: selected.runId, }, ...(child.subagentParent.graph ? { graph: child.subagentParent.graph } : {}), }; @@ -4113,8 +4139,7 @@ export class SessionManager { if (legacyExecution && legacyExecution.sessionId !== sessionId) { throw new Error('agent_output could not find the requested legacy child run'); } - const runs = await this.deps.runStore?.listSessionRuns(sessionId); - const header = runs?.find((run) => + const invocation = (await this.listInvocations(sessionId)).find((run) => legacyExecution ? run.runId === legacyExecution.runId : input.runId @@ -4123,30 +4148,20 @@ export class SessionManager { ? run.turnId === input.turnId : false, ); - if (!header) throw new Error('agent_output could not find the requested child agent run'); - if (!header.parentRunId || isSessionInlineRun(header)) { + if (!invocation) throw new Error('agent_output could not find the requested child agent run'); + if (!invocation.opening.lineage?.parentRunId || isSessionInlineInvocation(invocation.opening)) { throw new Error('agent_output only reads child agent runs'); } return { - header: await this.effectiveRunHeaderFromRuntimeLedger(header), + invocation, execution: { kind: 'legacy_child_run', sessionId, - runId: header.runId, + runId: invocation.runId, }, }; } - private async effectiveRunHeaderFromRuntimeLedger(run: AgentRunHeader): Promise { - if (!this.deps.runtimeEventStore) return run; - const runtimeEvents = await this.deps.runtimeEventStore - .readRuntimeEvents(run.sessionId, run.runId) - .catch(() => undefined); - if (!runtimeEvents) return run; - const ledger = classifyTerminalRuntimeLedger(run, runtimeEvents); - return ledger.kind === 'fact' ? effectiveRunHeaderFromTerminalFact(run, ledger.fact) : run; - } - private async updateStatus( sessionId: string, status: SessionStatus, @@ -4327,22 +4342,6 @@ export class SessionManager { sessionId: string, projectionCache: RuntimeReadModelProjectionCache = this.deps.store, ): Promise { - const repaired = new Set(); - for (let attempt = 0; attempt < MAX_RUNTIME_LEDGER_REPAIR_ATTEMPTS; attempt += 1) { - try { - const view = await this.readModel(projectionCache).getSessionView(sessionId); - const runId = firstRuntimeRepairRunId(view.diagnostics, repaired); - if (!runId) return view; - if (!(await this.repairMissingTerminalFactOnce(sessionId, runId))) return view; - repaired.add(runId); - } catch (error) { - if (!(error instanceof RuntimeReadModelError)) throw error; - const runId = firstRuntimeRepairRunId(error.diagnostics, repaired); - if (!runId) throw error; - if (!(await this.repairMissingTerminalFactOnce(sessionId, runId))) throw error; - repaired.add(runId); - } - } return this.readModel(projectionCache).getSessionView(sessionId); } @@ -4353,7 +4352,6 @@ export class SessionManager { throw new Error('RuntimeReadModel requires AgentRunStore and RuntimeEventStore'); } return new RuntimeReadModel({ - runStore: this.deps.runStore, runtimeEventStore: this.deps.runtimeEventStore, projectionCache, ...(this.deps.canonicalPermissionOutcomes @@ -4362,12 +4360,6 @@ export class SessionManager { }); } - private async repairMissingTerminalFactOnce(sessionId: string, runId: string): Promise { - return ( - (await this.runtimeLedgerRepair?.repairMissingTerminalFactOnce(sessionId, runId)) ?? false - ); - } - async prepareImportedSessionHistory(sessionId: string): Promise { const repair = this.runtimeLedgerRepair; if (!repair) throw new Error('Imported Session history requires canonical Runtime stores'); @@ -4413,29 +4405,19 @@ export class SessionManager { let recovered = false; for (const initialState of states) { const { claim } = initialState; - let run: AgentRunHeader; - try { - run = await this.deps.runStore.readRun(sessionId, claim.target.runId); - } catch (error) { - if (!isMissingRunError(error)) throw error; - try { - await this.deps.runStore.createRun(continuationTargetRunHeader(claim), { durable: true }); - run = await this.deps.runStore.readRun(sessionId, claim.target.runId); - } catch (createError) { - try { - run = await this.deps.runStore.readRun(sessionId, claim.target.runId); - } catch { - throw createError; - } - } - recovered = true; - } + // The target's opening fact rides its continuation-start event, so an + // invocation that does not exist yet is exactly the case the repair + // start below commits. There is no separate run record to create. + const invocation = await this.readInvocation(sessionId, claim.target.runId).catch((error) => { + if (isMissingRunError(error)) return undefined; + throw error; + }); let state = (await authority.readContinuationClaimStateByBoundary(claim.boundaryDigest)) ?? initialState; - if (!runHeaderMatchesClaimTarget(run, claim)) { + if (invocation && !invocationMatchesClaimTarget(invocation, claim)) { throw new Error( - `Continuation claim target Run header conflicts with claim ${claim.claimId}`, + `Continuation claim target invocation conflicts with claim ${claim.claimId}`, ); } @@ -4474,7 +4456,7 @@ export class SessionManager { const failureClass = 'continuation_abandoned_before_provider_dispatch'; const expectedTerminal = buildRecoveredTerminalRuntimeEvent({ id: continuationRepairEventId('terminal', claim.claimId), - run, + run: claim.target, status: 'failed', ts: Math.max(start.ts + 1, claim.claimedAt + 1), recoveryReason: failureClass, @@ -4486,18 +4468,8 @@ export class SessionManager { if (terminal && !isDeepStrictEqual(terminal, expectedTerminal)) { throw new Error(`Continuation claim ${claim.claimId} has a conflicting repair terminal`); } - const existingRunEvents = await this.deps.runStore.readEvents( - claim.target.sessionId, - claim.target.runId, - ); - const projectionComplete = - terminal !== undefined && - run.status === 'failed' && - run.failureClass === failureClass && - existingRunEvents.some((event) => event.type === 'run_failed'); - if (projectionComplete) continue; + if (terminal) continue; await commitTerminalRunWithRuntimeFact({ - runStore: this.deps.runStore, runtimeEventStore: authority, newId: () => continuationRepairEventId('run-terminal', claim.claimId), sessionId: claim.target.sessionId, @@ -4507,12 +4479,6 @@ export class SessionManager { ts: expectedTerminal.ts, terminalEvent: expectedTerminal, failureClass, - runEventData: { - recovered: true, - recoveryReason: failureClass, - continuationClaimId: claim.claimId, - }, - existingEvents: existingRunEvents, }); recovered = true; } @@ -4525,10 +4491,7 @@ export class SessionManager { ): Promise<{ hasLedger: boolean; recovered: boolean }> { if (!this.deps.runStore || !this.deps.runtimeEventStore) return { hasLedger: false, recovered: false }; - const runs = - policy.kind === 'strict' - ? await policy.stores.agentRunStore.listSessionRunsForRecovery(sessionId) - : await this.deps.runStore.listSessionRuns(sessionId); + const runs = await this.listInvocations(sessionId); if (runs.length === 0) return { hasLedger: false, recovered: false }; const continuationAuthority = runtimeContinuationAuthority(this.deps.runtimeEventStore); const claimOwnedUnsettledRunIds = new Set(); @@ -4570,7 +4533,7 @@ export class SessionManager { let inspected = await inspectAgentRunReadModel( this.deps.runStore, this.deps.runtimeEventStore, - { sessionId, runId: run.runId, header: run }, + { sessionId, runId: run.runId, invocation: run }, ); if (inspected.sourceHealth.runtimeLedger === 'read_failed') { if (policy.kind === 'strict') { @@ -4601,7 +4564,7 @@ export class SessionManager { const interruptedOutcomes = buildInterruptedCodeModeOutcomeCommits( inspected.runtimeEvents, this.deps.now(), - run.toolMode ?? DEFAULT_TOOL_MODE, + run.opening.configuration.toolMode, ); let outcomeCommitFailed = false; for (const outcome of interruptedOutcomes) { @@ -4623,7 +4586,7 @@ export class SessionManager { inspected = await inspectAgentRunReadModel( this.deps.runStore, this.deps.runtimeEventStore, - { sessionId, runId: run.runId, header: run }, + { sessionId, runId: run.runId, invocation: run }, ); } } @@ -4634,15 +4597,6 @@ export class SessionManager { } continue; } - if (isTerminalRunStatus(run.status) && !inspected.terminalRuntimeFact) { - const repaired = await this.repairMissingTerminalFactOnce(sessionId, run.runId); - if (repaired) { - recovered = true; - } else if (policy.kind === 'strict') { - throw new Error(`Unable to repair the terminal RuntimeEvent fact for run ${run.runId}`); - } - continue; - } const runtimeDecision = this.classifyRuntimeEventRecovery(inspected); const classified = runtimeDecision ?? classifyAgentRunRecovery(run, inspected.events); if (!classified) continue; @@ -4660,9 +4614,11 @@ export class SessionManager { private classifyRuntimeEventRecovery( inspected: AgentRunInspectModel, ): AgentRunRecoveryDecision | undefined { - if (isTerminalRunStatus(inspected.header.status) || !inspected.terminalRuntimeFact) - return undefined; - return runtimeTerminalFactToRecoveryDecision(inspected.header, inspected.terminalRuntimeFact); + if (!inspected.terminalRuntimeFact) return undefined; + return runtimeTerminalFactToRecoveryDecision( + inspected.invocation, + inspected.terminalRuntimeFact, + ); } private async applyAgentRunRecovery( @@ -4673,7 +4629,10 @@ export class SessionManager { ): Promise { if (!this.deps.runStore || !this.deps.runtimeEventStore) return false; const ts = this.deps.now(); - const terminalLedger = classifyTerminalRuntimeLedger(inspected.header, inspected.runtimeEvents); + const terminalLedger = classifyTerminalRuntimeLedger( + inspected.invocation, + inspected.runtimeEvents, + ); const existingTerminal = inspected.terminalRuntimeFact?.terminalEvent ?? (terminalLedger.kind === 'incomplete_single_terminal' @@ -4689,7 +4648,7 @@ export class SessionManager { existingTerminal ?? buildRecoveredTerminalRuntimeEvent({ id: this.deps.newId(), - run: inspected.header, + run: inspected.invocation, status, ts, recoveryReason: diagnosticRecoveryReason(decision.diagnostic), @@ -4702,7 +4661,6 @@ export class SessionManager { }); try { await commitTerminalRunWithRuntimeFact({ - runStore: this.deps.runStore, runtimeEventStore: this.deps.runtimeEventStore, newId: this.deps.newId, sessionId, @@ -4713,8 +4671,6 @@ export class SessionManager { terminalEvent, ...(failureClass ? { failureClass } : {}), ...(abortSource ? { abortSource } : {}), - runEventData: { recovered: true, ...decision.diagnostic }, - existingEvents: inspected.events, }); } catch (error) { if (policy.kind === 'strict') throw error; @@ -4726,7 +4682,7 @@ export class SessionManager { () => this.appendTerminalTurnStateIfNeeded( sessionId, - inspected.header, + inspected.invocation, decision, terminalTurnStatus(status), { @@ -4743,13 +4699,13 @@ export class SessionManager { private async appendTerminalTurnStateIfNeeded( sessionId: string, - run: AgentRunHeader, + run: RuntimeInvocationRecord, decision: AgentRunRecoveryDecision, status: TurnRecord['status'], options: { ts: number; errorClass?: string; abortSource?: string }, policy: RecoveryPolicy = { kind: 'best_effort' }, ): Promise { - if (!isSessionInlineRun(run)) return; + if (!isSessionInlineInvocation(run.opening)) return; const messages = await recoverOr( policy, () => this.deps.store.readMessages(sessionId), @@ -4978,6 +4934,48 @@ export function headerToSummary(h: SessionHeader): SessionSummary { return summary; } +/** + * What a listing shows about one invocation, read entirely off its own facts. + * + * Every field here used to be a mutable column on the Run header that a writer + * had to keep in step with the events. Deriving them means a listing cannot + * disagree with the ledger it is listing. + */ +function invocationListingFacts(invocation: RuntimeInvocationRecord): { + status: RunLifecycleStatus; + permissionMode: PermissionMode; + createdAt: number; + updatedAt: number; + completedAt?: number; + durationMs?: number; + failureClass?: string; +} { + const completedAt = invocation.terminalEvent?.ts; + const failureClass = runtimeInvocationFailureClass(invocation); + return { + status: runtimeInvocationOutcome(invocation) ?? 'running', + permissionMode: invocation.opening.configuration.permissionMode, + createdAt: invocation.openedAt, + updatedAt: completedAt ?? invocation.openedAt, + ...(completedAt !== undefined ? { completedAt } : {}), + ...(completedAt !== undefined + ? { durationMs: Math.max(0, completedAt - invocation.openedAt) } + : {}), + ...(failureClass ? { failureClass } : {}), + }; +} + +/** The most recently opened invocation, breaking ties on run id. */ +function latestInvocation( + invocations: readonly RuntimeInvocationRecord[], +): RuntimeInvocationRecord | undefined { + return invocations + .slice() + .sort( + (left, right) => right.openedAt - left.openedAt || right.runId.localeCompare(left.runId), + )[0]; +} + function isNotFoundError(error: unknown): error is NodeJS.ErrnoException { return error instanceof Error && 'code' in error && error.code === 'ENOENT'; } @@ -5108,12 +5106,12 @@ function narrowsExecutionAuthority( } function agentRunStatusForSpawnResult( - status: AgentRunHeader['status'], + status: RunLifecycleStatus, ): SpawnChildSessionResult['status'] { if (status === 'waiting_for_user') return 'waiting_for_user'; if (status === 'cancelled') return 'cancelled'; if (status === 'failed') return 'failed'; - if (status === 'running' || status === 'created') return 'running'; + if (status === 'running') return 'running'; return 'completed'; } @@ -5275,7 +5273,7 @@ function turnStateLineage( }; } -function isTerminalRunStatus(status: AgentRunHeader['status']): boolean { +function isTerminalRunStatus(status: RunLifecycleStatus): boolean { return status === 'completed' || status === 'failed' || status === 'cancelled'; } @@ -5307,7 +5305,7 @@ function latestTurnState( } function runtimeTerminalFactToRecoveryDecision( - header: AgentRunHeader, + invocation: RuntimeInvocationRecord, fact: RuntimeEventTerminalFact, ): AgentRunRecoveryDecision { return { @@ -5321,20 +5319,22 @@ function runtimeTerminalFactToRecoveryDecision( runtimeEventId: fact.terminalEvent.id, runtimeEventStatus: fact.terminalEvent.status, }, - lineage: headerLineage(header), + lineage: openingLineage(invocation), }; } -function headerLineage(header: AgentRunHeader): AgentRunRecoveryDecision['lineage'] { +function openingLineage(invocation: RuntimeInvocationRecord): AgentRunRecoveryDecision['lineage'] { + const lineage = invocation.opening.lineage; + if (!lineage) return {}; return { - ...(header.parentRunId ? { parentRunId: header.parentRunId } : {}), - ...(header.parentTurnId ? { parentTurnId: header.parentTurnId } : {}), - ...(header.retriedFromTurnId ? { retriedFromTurnId: header.retriedFromTurnId } : {}), - ...(header.regeneratedFromTurnId - ? { regeneratedFromTurnId: header.regeneratedFromTurnId } + ...(lineage.parentRunId ? { parentRunId: lineage.parentRunId } : {}), + ...(lineage.parentTurnId ? { parentTurnId: lineage.parentTurnId } : {}), + ...(lineage.retriedFromTurnId ? { retriedFromTurnId: lineage.retriedFromTurnId } : {}), + ...(lineage.regeneratedFromTurnId + ? { regeneratedFromTurnId: lineage.regeneratedFromTurnId } : {}), - ...(header.branchOfTurnId ? { branchOfTurnId: header.branchOfTurnId } : {}), - ...(header.parentSessionId ? { parentSessionId: header.parentSessionId } : {}), + ...(lineage.branchOfTurnId ? { branchOfTurnId: lineage.branchOfTurnId } : {}), + ...(lineage.parentSessionId ? { parentSessionId: lineage.parentSessionId } : {}), }; } @@ -5354,7 +5354,7 @@ function normalizeAgentOutputMaxBytes(value: number | undefined): number { } function buildAgentOutputCommittedResult(input: { - header: AgentRunHeader; + invocation: RuntimeInvocationRecord; runtimeEvents: readonly RuntimeEvent[]; artifacts: readonly ArtifactRecord[]; maxArtifacts: number; @@ -5381,9 +5381,9 @@ function buildAgentOutputCommittedResult(input: { { operator: { operatorId: input.graph.operatorId, - sessionId: input.header.sessionId, + sessionId: input.invocation.sessionId, }, - run: input.header, + run: input.invocation, events: input.runtimeEvents, }, ], @@ -5404,7 +5404,7 @@ function buildAgentOutputCommittedResult(input: { ); const base = (): AgentOutputCommittedResult => ({ schemaVersion: 1, - status: input.header.status, + status: runtimeInvocationOutcome(input.invocation) ?? 'running', ...(input.graph ? { graph: { ...input.graph } } : {}), ...(outputRecord || terminalRecord ? { resultRecordId: (outputRecord ?? terminalRecord)!.recordId } @@ -5415,7 +5415,9 @@ function buildAgentOutputCommittedResult(input: { textTruncated: false, artifactIds, omittedArtifactIds: Math.max(0, input.artifacts.length - artifactIds.length), - ...(input.header.failureClass ? { failureClass: input.header.failureClass } : {}), + ...(runtimeInvocationFailureClass(input.invocation) + ? { failureClass: runtimeInvocationFailureClass(input.invocation) } + : {}), }); while (artifactIds.length > 0 && serializedBytes(base()) > input.maxBytes) { diff --git a/packages/runtime/src/session-projection-helpers.ts b/packages/runtime/src/session-projection-helpers.ts index f470699290..456f0ddd7a 100644 --- a/packages/runtime/src/session-projection-helpers.ts +++ b/packages/runtime/src/session-projection-helpers.ts @@ -18,8 +18,8 @@ */ import { createHash } from 'node:crypto'; -import type { AgentRunHeader } from '@maka/core/agent-run'; import { failureClassFromCompleteStopReason, type SessionEvent } from '@maka/core/events'; +import type { RuntimeInvocationOutcome } from '@maka/core/runtime-invocation'; import type { SessionBlockedReason, SessionHeader, @@ -122,7 +122,13 @@ export function workHubDirectStopAbortSource(actionId: string | undefined): stri return `workhub.direct_stop.${suffix}`; } -export function isTerminalRunStatus(status: AgentRunHeader['status']): boolean { +/** + * What a live run says about itself before its events close it. Only the + * outcomes are durable; the other two describe a run still in flight. + */ +export type RunLifecycleStatus = RuntimeInvocationOutcome | 'running' | 'waiting_for_user'; + +export function isTerminalRunStatus(status: RunLifecycleStatus): boolean { return status === 'completed' || status === 'failed' || status === 'cancelled'; } diff --git a/packages/runtime/src/stream-graph-coordinator.ts b/packages/runtime/src/stream-graph-coordinator.ts index fe838a7a8c..8ebb0885ca 100644 --- a/packages/runtime/src/stream-graph-coordinator.ts +++ b/packages/runtime/src/stream-graph-coordinator.ts @@ -106,8 +106,10 @@ export interface AgentGraphCoordinatorRuntime { export interface AgentGraphCoordinatorInput { sessionStore: AgentGraphCoordinatorSessionStore; - runStore: Pick; - runtimeEventStore: Pick; + runtimeEventStore: Pick< + RuntimeEventStore, + 'readImmutableRuntimeEvents' | 'listSessionInvocations' + >; controlStore: AgentGraphScheduleControlStore & AgentGraphClientProjectionStore & AgentGraphTimelineMetadataStore; @@ -405,7 +407,6 @@ export class AgentGraphCoordinator { rootSessionId, graphId, controlStore: this.#input.controlStore, - runStore: this.#input.runStore, runtimeEventStore: this.#input.runtimeEventStore, options, }); @@ -547,7 +548,6 @@ export class AgentGraphCoordinator { readCommittedAgentGraphProjection({ graphId, operators: topology.operators, - runStore: this.#input.runStore, runtimeEventStore: this.#input.runtimeEventStore, }), this.#input.controlStore.listAgentGraphIntentClaims(graphId), @@ -1242,7 +1242,6 @@ export class AgentGraphCoordinator { const projection = await readCommittedAgentGraphProjection({ graphId: sourceGraphId, operators: topology.operators, - runStore: this.#input.runStore, runtimeEventStore: this.#input.runtimeEventStore, }); recordsBySource.set( diff --git a/packages/runtime/src/stream-graph-projection.ts b/packages/runtime/src/stream-graph-projection.ts index 3a6068af95..f0f42f6acf 100644 --- a/packages/runtime/src/stream-graph-projection.ts +++ b/packages/runtime/src/stream-graph-projection.ts @@ -17,10 +17,11 @@ * under the License. */ -import type { AgentRunHeader, AgentRunStore } from '@maka/core/agent-run'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; +import { runtimeInvocationOutcome } from '@maka/core/runtime-invocation'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; -import { isSessionInlineRun } from '@maka/core/agent-run'; +import { isSessionInlineInvocation } from '@maka/core/runtime-invocation'; import { stableHash, stableStringify } from './request-shape.js'; import { compareAgentGraphIdentity } from './stream-graph-identity.js'; @@ -185,7 +186,7 @@ export interface AgentGraphReplayState { export interface AgentGraphRunStream { operator: AgentGraphOperatorBinding; - run: AgentRunHeader; + run: RuntimeInvocationRecord; events: readonly RuntimeEvent[]; } @@ -206,18 +207,20 @@ export interface AgentGraphProjection { export interface ReadCommittedAgentGraphProjectionInput { graphId: string; operators: readonly AgentGraphOperatorBinding[]; - runStore: Pick; - runtimeEventStore: Pick; + runtimeEventStore: Pick< + RuntimeEventStore, + 'readImmutableRuntimeEvents' | 'listSessionInvocations' + >; } export interface AgentGraphProjectionWithRuns { projection: AgentGraphProjection; - runs: AgentRunHeader[]; + runs: RuntimeInvocationRecord[]; } interface OrderedRuntimeEvent { operator: AgentGraphOperatorBinding; - run: AgentRunHeader; + run: RuntimeInvocationRecord; event: RuntimeEvent; committedEventOrdinal: number; } @@ -244,10 +247,10 @@ export async function readCommittedAgentGraphProjectionWithRuns( const streams = ( await Promise.all( input.operators.map(async (operator) => { - const runs = await input.runStore.listSessionRuns(operator.sessionId); + const runs = await input.runtimeEventStore.listSessionInvocations(operator.sessionId); const orderedRuns = runs - .filter(isSessionInlineRun) - .sort((a, b) => a.createdAt - b.createdAt || compareAgentGraphIdentity(a.runId, b.runId)); + .filter((run) => isSessionInlineInvocation(run.opening)) + .sort((a, b) => a.openedAt - b.openedAt || compareAgentGraphIdentity(a.runId, b.runId)); return await Promise.all( orderedRuns.map(async (run): Promise => { if (run.sessionId !== operator.sessionId) { @@ -350,7 +353,7 @@ export function projectAgentGraphRecords(input: ProjectAgentGraphRecordsInput): agentRunId: item.run.runId, eventTime: item.event.ts, orderKey: { - runCreatedAt: item.run.createdAt, + runCreatedAt: item.run.openedAt, operatorId: item.operator.operatorId, runId: item.run.runId, committedEventOrdinal: item.committedEventOrdinal, @@ -499,7 +502,10 @@ export function replayAgentGraphRecords( }; } -function runtimeEventFacets(event: RuntimeEvent, run: AgentRunHeader): AgentGraphRecordFacet[] { +function runtimeEventFacets( + event: RuntimeEvent, + run: RuntimeInvocationRecord, +): AgentGraphRecordFacet[] { const facets: AgentGraphRecordFacet[] = []; switch (event.content?.kind) { case 'text': @@ -537,7 +543,7 @@ function runtimeEventFacets(event: RuntimeEvent, run: AgentRunHeader): AgentGrap function runtimeEventSupervisorSignals( event: RuntimeEvent, - run: AgentRunHeader, + run: RuntimeInvocationRecord, ): AgentGraphSupervisorSignal[] { const signals: AgentGraphSupervisorSignal[] = []; if (event.actions?.permissionRequest) { @@ -558,7 +564,7 @@ function runtimeEventSupervisorSignals( function runtimeEventTerminalStatus( event: RuntimeEvent, - run: AgentRunHeader, + run: RuntimeInvocationRecord, ): | Extract | undefined { @@ -574,18 +580,13 @@ function runtimeEventTerminalStatus( } function terminalStatusFromRun( - run: AgentRunHeader, + run: RuntimeInvocationRecord, ): Extract { - switch (run.status) { - case 'completed': - case 'failed': - case 'cancelled': - return run.status; - default: - throw new Error( - `RuntimeEvent ended invocation ${run.runId} while its AgentRun is ${run.status}`, - ); - } + const outcome = runtimeInvocationOutcome(run); + if (outcome) return outcome; + throw new Error( + `RuntimeEvent ended invocation ${run.runId} while its ledger records no terminal fact`, + ); } function activationStatusAfterRecord( @@ -661,7 +662,7 @@ function assertRunStream(stream: AgentGraphRunStream): void { `Run ${stream.run.runId} belongs to ${stream.run.sessionId}, expected ${stream.operator.sessionId}`, ); } - if (!isSessionInlineRun(stream.run)) { + if (!isSessionInlineInvocation(stream.run.opening)) { throw new Error(`Graph activation ${stream.run.runId} must be a session-inline AgentRun`); } } @@ -681,7 +682,7 @@ function assertRuntimeEventIdentity(stream: AgentGraphRunStream, event: RuntimeE function compareOrderedRuntimeEvents(a: OrderedRuntimeEvent, b: OrderedRuntimeEvent): number { return ( a.event.ts - b.event.ts || - a.run.createdAt - b.run.createdAt || + a.run.openedAt - b.run.openedAt || compareAgentGraphIdentity(a.operator.operatorId, b.operator.operatorId) || compareAgentGraphIdentity(a.run.runId, b.run.runId) || a.committedEventOrdinal - b.committedEventOrdinal || diff --git a/packages/runtime/src/terminal-run-commit.ts b/packages/runtime/src/terminal-run-commit.ts index f93555bc3e..6849f0c72b 100644 --- a/packages/runtime/src/terminal-run-commit.ts +++ b/packages/runtime/src/terminal-run-commit.ts @@ -18,23 +18,23 @@ */ import { isPartialRuntimeEvent, isTerminalRuntimeEvent } from '@maka/core/runtime-event'; -import type { - AgentRunEvent, - AgentRunHeader, - AgentRunEventType, - AgentRunStore, -} from '@maka/core/agent-run'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; +import type { RuntimeInvocationOutcome } from '@maka/core/runtime-invocation'; import { classifyRuntimeEventTerminalFact, type RuntimeEventTerminalFact, } from './runtime-event-read-model.js'; -export type TerminalAgentRunStatus = Extract< - AgentRunHeader['status'], - 'completed' | 'failed' | 'cancelled' ->; +/** How a run ended. One terminal RuntimeEvent decides it, once. */ +export type TerminalAgentRunStatus = RuntimeInvocationOutcome; + +/** The three ids every RuntimeEvent of one run carries. */ +export interface RunIdentity { + sessionId: string; + runId: string; + turnId: string; +} export type TerminalRuntimeLedgerClassification = | { @@ -57,7 +57,7 @@ export type TerminalRuntimeLedgerClassification = }; export function classifyTerminalRuntimeLedger( - run: AgentRunHeader, + run: RunIdentity, events: readonly RuntimeEvent[], ): TerminalRuntimeLedgerClassification { const terminalEvents = matchingTerminalRuntimeEvents(run, events); @@ -79,101 +79,41 @@ export function classifyTerminalRuntimeLedger( }; } -export interface CommitTerminalRunWithRuntimeFactInput { - runStore: AgentRunStore; +export interface CommitTerminalRunWithRuntimeFactInput extends RunIdentity { runtimeEventStore: RuntimeEventStore; newId: () => string; - sessionId: string; - runId: string; - turnId: string; status: TerminalAgentRunStatus; ts: number; terminalEvent: RuntimeEvent; failureClass?: string; failureMessage?: string; - traceWriteError?: string; abortSource?: string; - runEventData?: Record; - runEventMessage?: string; - existingEvents?: readonly Pick[]; } +/** + * Put one run's ending beyond doubt: the terminal RuntimeEvent, on stable + * storage, and nothing else. + * + * There is no projection to commit alongside it any more. The event states the + * outcome, the failure class and the abort source, so a second record could only + * ever disagree with it. + */ export async function commitTerminalRunWithRuntimeFact( input: CommitTerminalRunWithRuntimeFactInput, ): Promise { - if (isPartialRuntimeEvent(input.terminalEvent)) { - throw new Error('terminal RuntimeEvent must be final before terminal run header'); - } - const terminalStatus = terminalRunStatusFromRuntimeEvent(input.terminalEvent); - if (!terminalStatus) { - throw new Error('terminal RuntimeEvent must carry a terminal status'); - } - if (terminalStatus !== input.status) { - throw new Error( - `terminal RuntimeEvent status ${input.terminalEvent.status} cannot commit ${input.status} run header`, - ); - } - if ( - input.terminalEvent.sessionId !== input.sessionId || - input.terminalEvent.runId !== input.runId || - input.terminalEvent.turnId !== input.turnId - ) { - throw new Error('terminal RuntimeEvent identity does not match run header commit'); - } + assertCommittableTerminalEvent(input.terminalEvent, input, input.status); await input.runtimeEventStore.ensureTerminalRuntimeEventDurable( input.sessionId, input.runId, input.terminalEvent, ); - - await commitTerminalRunProjection(input); -} - -async function commitTerminalRunProjection( - input: CommitTerminalRunWithRuntimeFactInput, -): Promise { - const failureClass = input.status === 'failed' ? (input.failureClass ?? 'unknown') : undefined; - const abortSource = input.status === 'cancelled' ? input.abortSource : undefined; - await input.runStore.updateRun( - input.sessionId, - input.runId, - { - status: input.status, - updatedAt: input.ts, - completedAt: input.ts, - ...(failureClass ? { failureClass } : {}), - ...(input.failureMessage ? { failureMessage: input.failureMessage } : {}), - ...(input.traceWriteError ? { traceWriteError: input.traceWriteError } : {}), - ...(abortSource ? { abortSource } : {}), - }, - { durable: true }, - ); - - if (hasTerminalAgentRunEvent(input.existingEvents ?? [])) return; - const data = terminalRunEventData(input.status, failureClass, input.runEventData); - await input.runStore.appendEvent( - input.sessionId, - input.runId, - { - type: terminalAgentRunEventType(input.status), - id: input.newId(), - runId: input.runId, - sessionId: input.sessionId, - turnId: input.turnId, - ts: input.ts, - ...(input.runEventMessage ? { message: input.runEventMessage } : {}), - ...(Object.keys(data).length > 0 ? { data } : {}), - }, - { durable: true }, - ); } export interface CommitOrCreateTerminalRunFactInput extends Omit { - /** Runs after the terminal durability barrier, before the header commit. */ + /** Runs after the terminal durability barrier. */ afterTerminalDurable?: () => Promise; terminalEvent?: RuntimeEvent; - allowHeaderCommitFailure?: boolean; fallbackStatus: TerminalAgentRunStatus; fallbackInvocationId: string; fallbackFailureClass?: string; @@ -185,8 +125,6 @@ export interface CommitOrCreateTerminalRunFactResult { status: TerminalAgentRunStatus; failureClass?: string; createdTerminalEvent: boolean; - headerCommitted: boolean; - headerCommitError?: unknown; } export async function commitOrCreateTerminalRunFact( @@ -200,11 +138,7 @@ export async function commitOrCreateTerminalRunFact( buildSyntheticTerminalRuntimeEvent({ id: input.newId(), invocationId: input.fallbackInvocationId, - run: { - sessionId: input.sessionId, - runId: input.runId, - turnId: input.turnId, - }, + run: input, status: input.fallbackStatus, ts: input.ts, ...(input.fallbackFailureClass ? { failureClass: input.fallbackFailureClass } : {}), @@ -213,20 +147,7 @@ export async function commitOrCreateTerminalRunFact( ? { message: input.fallbackFailureMessage ?? input.failureMessage } : {}), }); - const status = terminalRunStatusFromRuntimeEvent(terminalEvent); - if (!status) { - throw new Error('terminal RuntimeEvent must carry a terminal status'); - } - if (isPartialRuntimeEvent(terminalEvent)) { - throw new Error('terminal RuntimeEvent must be final before terminal run header'); - } - if ( - terminalEvent.sessionId !== input.sessionId || - terminalEvent.runId !== input.runId || - terminalEvent.turnId !== input.turnId - ) { - throw new Error('terminal RuntimeEvent identity does not match run header commit'); - } + const status = assertCommittableTerminalEvent(terminalEvent, input); const failureClass = status === 'failed' ? (runtimeEventFailureClass(terminalEvent) ?? input.failureClass ?? 'unknown') @@ -236,41 +157,48 @@ export async function commitOrCreateTerminalRunFact( input.runId, terminalEvent, ); - // Between the terminal durability barrier and the header commit: the one - // point where "the terminal fact is durable" is true and nothing else has - // been projected yet. Callers that must order a crash boundary against - // the barrier itself hang it here (#2313 corruption recovery, where the - // claimed event's own write never ran). + // The one point where "the terminal fact is durable" is true and nothing has + // read it yet. Callers that must order a crash boundary against the barrier + // itself hang it here (#2313 corruption recovery, where the claimed event's + // own write never ran). await input.afterTerminalDurable?.(); - let headerCommitted = false; - let headerCommitError: unknown; - try { - await commitTerminalRunProjection({ - ...input, - terminalEvent, - status, - ...(failureClass ? { failureClass } : {}), - ...(effectiveAbortSource ? { abortSource: effectiveAbortSource } : {}), - }); - headerCommitted = true; - } catch (error) { - if (!input.allowHeaderCommitFailure) throw error; - headerCommitError = error; - } return { terminalEvent, status, ...(failureClass ? { failureClass } : {}), createdTerminalEvent, - headerCommitted, - ...(headerCommitError !== undefined ? { headerCommitError } : {}), }; } +function assertCommittableTerminalEvent( + event: RuntimeEvent, + identity: RunIdentity, + expected?: TerminalAgentRunStatus, +): TerminalAgentRunStatus { + if (isPartialRuntimeEvent(event)) { + throw new Error('terminal RuntimeEvent must be final before it is committed'); + } + const status = terminalRunStatusFromRuntimeEvent(event); + if (!status) { + throw new Error('terminal RuntimeEvent must carry a terminal status'); + } + if (expected !== undefined && status !== expected) { + throw new Error(`terminal RuntimeEvent status ${event.status} cannot commit a ${expected} run`); + } + if ( + event.sessionId !== identity.sessionId || + event.runId !== identity.runId || + event.turnId !== identity.turnId + ) { + throw new Error('terminal RuntimeEvent identity does not match the run it ends'); + } + return status; +} + export interface BuildSyntheticTerminalRuntimeEventInput { id: string; invocationId: string; - run: Pick; + run: RunIdentity; status: TerminalAgentRunStatus; ts: number; failureClass?: string; @@ -320,7 +248,7 @@ export function buildSyntheticTerminalRuntimeEvent( export interface BuildRecoveredTerminalRuntimeEventInput { id: string; - run: Pick; + run: RunIdentity & { invocationId?: string }; status: TerminalAgentRunStatus; ts: number; invocationId?: string; @@ -348,32 +276,6 @@ export function buildRecoveredTerminalRuntimeEvent( }); } -export function hasTerminalAgentRunEvent(events: readonly Pick[]): boolean { - return events.some( - (event) => - event.type === 'run_completed' || - event.type === 'run_failed' || - event.type === 'run_cancelled', - ); -} - -function terminalAgentRunEventType(status: TerminalAgentRunStatus): AgentRunEventType { - if (status === 'cancelled') return 'run_cancelled'; - if (status === 'failed') return 'run_failed'; - return 'run_completed'; -} - -function terminalRunEventData( - status: TerminalAgentRunStatus, - failureClass: string | undefined, - runEventData: Record | undefined, -): Record { - return { - ...(status === 'failed' && failureClass ? { failureClass } : {}), - ...(runEventData ?? {}), - }; -} - function runtimeEventFailureClass(event: RuntimeEvent): string | undefined { const stateDelta = event.actions?.stateDelta; if (typeof stateDelta?.failureClass === 'string' && stateDelta.failureClass.length > 0) { @@ -385,16 +287,6 @@ function runtimeEventFailureClass(event: RuntimeEvent): string | undefined { return undefined; } -/** - * The terminal event's own message is the only source of a run's failure text. - * The header copy was written from this same message, so preferring the header - * only let a stale projection outlive the fact that produced it. - */ -function runtimeEventFailureMessage(event: RuntimeEvent): string | undefined { - if (event.content?.kind !== 'error') return undefined; - return event.content.message.length > 0 ? event.content.message : undefined; -} - export function terminalRunStatusFromRuntimeEvent( event: RuntimeEvent, ): TerminalAgentRunStatus | undefined { @@ -404,44 +296,8 @@ export function terminalRunStatusFromRuntimeEvent( return undefined; } -export function effectiveRunHeaderFromTerminalFact( - run: AgentRunHeader, - fact: RuntimeEventTerminalFact, -): AgentRunHeader { - const completedAt = run.completedAt ?? fact.terminalEvent.ts; - const base = { ...run }; - delete base.failureClass; - delete base.failureMessage; - delete base.abortSource; - return { - ...base, - status: fact.runStatus, - updatedAt: Math.max(run.updatedAt, completedAt), - completedAt, - ...(fact.runStatus === 'failed' && fact.failureClass - ? { failureClass: fact.failureClass } - : {}), - ...(fact.runStatus === 'failed' && runtimeEventFailureMessage(fact.terminalEvent) - ? { failureMessage: runtimeEventFailureMessage(fact.terminalEvent)! } - : {}), - ...(fact.runStatus === 'cancelled' && fact.abortSource - ? { abortSource: fact.abortSource } - : {}), - }; -} - -export function terminalRunHeaderMatchesFact( - run: AgentRunHeader, - fact: RuntimeEventTerminalFact, -): boolean { - if (run.status !== fact.runStatus) return false; - if (fact.runStatus === 'failed' && run.failureClass !== fact.failureClass) return false; - if (fact.runStatus === 'cancelled' && run.abortSource !== fact.abortSource) return false; - return true; -} - export function matchingTerminalRuntimeEvents( - run: AgentRunHeader, + run: RunIdentity, events: readonly RuntimeEvent[], ): RuntimeEvent[] { return events.filter( From b38fd2d94fbb1c5db7cbfda5099b7bb2ad106095 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 12:20:26 +0800 Subject: [PATCH 12/46] refactor(runtime-host): project every hosted Turn off the invocation spine The Host read its Turn state from the Run header: the canonical Turn snapshot compared the header's status against the terminal RuntimeEvent, recovery enumerated headers, inspection read one, and the revision path walked header lineage. With the header gone, each of those reads the events that already decide the answer. Two duplicates go with it. A live Turn's `waiting_for_user` came from the header restating what the pending-interaction store owns, so the snapshot now asks that store directly. And the RuntimeEvent that carries an opening fact was assembled by hand in four places; `buildInvocationOpenedEvent` in @maka/core writes the envelope once, for the runtime, the recovery closure, the transcript import and the storage migration alike. Generated-by: Claude Code --- packages/core/src/runtime-invocation.ts | 28 +++ .../canonical-session-projection.test.ts | 98 ++++---- .../__tests__/execution-composition.test.ts | 51 ++-- .../execution-host-continuation.test.ts | 16 +- .../__tests__/execution-host-message.test.ts | 1 - .../__tests__/execution-host-queue.test.ts | 36 +-- .../__tests__/execution-host-recovery.test.ts | 7 +- .../src/__tests__/execution-host.test.ts | 5 +- .../execution-inspect-coordinator.test.ts | 76 +++--- .../execution-inspect-protocol.test.ts | 8 +- .../execution-model-composition.test.ts | 128 ++++++---- .../fixtures/execution-host-suite.ts | 223 ++++++++++-------- .../src/__tests__/fixtures/seed-invocation.ts | 167 +++++++++++++ .../src/__tests__/goal-coordinator.test.ts | 41 ++-- .../src/__tests__/goal-root-authority.test.ts | 86 +++---- .../__tests__/root-turn-coordinator.test.ts | 177 ++++++++------ .../session-revision-graph-references.test.ts | 53 +++-- .../session-revision-two-client-uds.test.ts | 132 ++++++----- .../session-transcript-reader.test.ts | 44 ++-- .../src/server/canonical-turn-snapshot.ts | 50 ++-- .../server/client-capability-coordinator.ts | 2 +- .../src/server/execution-composition.ts | 73 ++++-- .../server/execution-inspect-coordinator.ts | 103 ++++---- .../src/server/host-session-availability.ts | 2 +- .../src/server/hosted-execution-authority.ts | 3 +- .../src/server/hosted-execution-projection.ts | 58 ++--- .../src/server/hosted-execution-recovery.ts | 16 +- .../server/interactive-turn-coordinator.ts | 2 +- .../src/server/root-turn-coordinator.ts | 18 +- .../server/session-revision-coordinator.ts | 2 +- .../session-revision-graph-references.ts | 35 +-- .../src/server/session-transcript-reader.ts | 4 +- packages/runtime/src/agent-run.ts | 24 +- packages/runtime/src/runtime-ledger-repair.ts | 19 +- packages/runtime/src/session-manager.ts | 21 +- packages/storage/src/sqlite-runtime-schema.ts | 27 ++- 36 files changed, 1107 insertions(+), 729 deletions(-) create mode 100644 packages/runtime-host/src/__tests__/fixtures/seed-invocation.ts diff --git a/packages/core/src/runtime-invocation.ts b/packages/core/src/runtime-invocation.ts index d1dc5fc27a..7dca48feb3 100644 --- a/packages/core/src/runtime-invocation.ts +++ b/packages/core/src/runtime-invocation.ts @@ -82,6 +82,34 @@ export function runtimeInvocationsFromSessionEvents( ); } +/** + * Wrap an opening fact in the event that carries it. + * + * Every writer that opens an invocation goes through here, so the envelope the + * inventory reads back is decided once. It is hidden from the model: the + * opening is a fact about the run, not something the run said. + */ +export function buildInvocationOpenedEvent(input: { + id: string; + run: { sessionId: string; invocationId: string; runId: string; turnId: string }; + openedAt: number; + opening: RuntimeEventInvocationOpenedContent; +}): RuntimeEvent { + return { + id: input.id, + sessionId: input.run.sessionId, + invocationId: input.run.invocationId, + runId: input.run.runId, + turnId: input.run.turnId, + ts: input.openedAt, + partial: false, + role: 'system', + author: 'system', + modelVisibility: 'hidden', + content: input.opening, + }; +} + /** One invocation's position in a Session's opening order. */ export interface RuntimeInvocationPageCursor { readonly openedAt: number; diff --git a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts index 06eff1cae3..97927e868d 100644 --- a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts +++ b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts @@ -22,7 +22,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; -import type { AgentRunHeader } from '@maka/core/agent-run'; +import { seedInvocation } from './fixtures/seed-invocation.js'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { openInteractiveExecutionStoresForWrite, @@ -90,18 +90,28 @@ test('projects the canonical root lifecycle and the attachment queue from real S assert.ok(admittedProjection); assert.equal(admittedProjection.rootTurn?.status, 'admitted'); - await stores.agentRunStore.createRun(runHeader(session.id)); - await stores.agentRunStore.appendEvent(session.id, 'run-1', { - type: 'run_started', - id: 'run-started-1', + await seedInvocation(stores.runtimeEventStore, { sessionId: session.id, - turnId: 'turn-1', runId: 'run-1', - ts: 11, - }); - await stores.agentRunStore.updateRun(session.id, 'run-1', { - status: 'running', - updatedAt: 11, + turnId: 'turn-1', + openedAt: 10, + opening: { + route: { + provenance: 'runtime', + backendKind: 'fake', + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + llmConnectionSlug: 'fake', + modelId: 'fake-model', + }, + configuration: { + cwd: '/private/runtime-cwd', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + }, }); messages.reserveRootTurn({ sessionId: session.id, turnId: 'turn-1', runId: 'run-1' }); @@ -130,11 +140,6 @@ test('projects the canonical root lifecycle and the attachment queue from real S const terminal = terminalEvent(session.id); await stores.runtimeEventStore.appendRuntimeEvent(session.id, 'run-1', terminal); - await stores.agentRunStore.updateRun(session.id, 'run-1', { - status: 'completed', - updatedAt: 12, - completedAt: 12, - }); const completed = await reader.read(session.id); assert.ok(completed); assert.deepEqual(completed.rootTurn, { @@ -397,13 +402,6 @@ test('projects a failed Turn message from the canonical terminal event', async ( ); await stores.runtimeEventStore.appendRuntimeEvent(sessionId, 'run-1', errorEvent); await stores.runtimeEventStore.appendRuntimeEvent(sessionId, 'run-1', terminalEvent); - await stores.agentRunStore.updateRun(sessionId, 'run-1', { - status: 'failed', - updatedAt: 13, - completedAt: 13, - failureClass: 'provider_error', - failureMessage: 'stale Run header failure', - }); const reader = new CanonicalSessionProjectionReader({ stores, @@ -469,12 +467,6 @@ test('a legacy context_budget_exhausted terminal event still projects, as a cont }, }; await stores.runtimeEventStore.appendRuntimeEvent(sessionId, 'run-1', terminalEvent); - await stores.agentRunStore.updateRun(sessionId, 'run-1', { - status: 'failed', - updatedAt: 13, - completedAt: 13, - failureClass: 'context_budget_exhausted', - }); const reader = new CanonicalSessionProjectionReader({ stores, @@ -637,24 +629,6 @@ function sessionInput(root: string) { }; } -function runHeader(sessionId: string): AgentRunHeader { - return { - runId: 'run-1', - invocationId: 'run-1', - sessionId, - turnId: 'turn-1', - status: 'created', - backendKind: 'fake', - llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', - llmConnectionSlug: 'fake', - modelId: 'fake-model', - cwd: '/private/runtime-cwd', - permissionMode: 'ask', - createdAt: 10, - updatedAt: 10, - }; -} - async function createRunningRoot( root: string, stores: ExecutionStoresWriter<'interactive'>, @@ -672,18 +646,28 @@ async function createRunningRoot( sourceMessages: [], admittedAt: 10, }); - await stores.agentRunStore.createRun(runHeader(session.id)); - await stores.agentRunStore.appendEvent(session.id, 'run-1', { - type: 'run_started', - id: 'run-started-1', + await seedInvocation(stores.runtimeEventStore, { sessionId: session.id, - turnId: 'turn-1', runId: 'run-1', - ts: 11, - }); - await stores.agentRunStore.updateRun(session.id, 'run-1', { - status: 'running', - updatedAt: 11, + turnId: 'turn-1', + openedAt: 10, + opening: { + route: { + provenance: 'runtime', + backendKind: 'fake', + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + llmConnectionSlug: 'fake', + modelId: 'fake-model', + }, + configuration: { + cwd: '/private/runtime-cwd', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + }, }); return { sessionId: session.id, rootAdmissions }; } diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index a21b24c4bf..21a95001a9 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -18,6 +18,8 @@ */ import assert from 'node:assert/strict'; +import { runtimeInvocationOutcome } from '@maka/core/runtime-invocation'; +import { runtimeInvocationFailureClass } from '@maka/runtime/runtime-event-read-model'; import { parseNoRealConnectionError } from '@maka/core/connection-error-copy'; import { createRequire } from 'node:module'; import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'; @@ -465,10 +467,16 @@ test('production recovery preserves legacy Automation history and closes an orph kind: 'legacy_automation', automationId: 'historical-automation', }); - const recoveredRun = await stores.agentRunStore.readRun(pending.id, 'legacy-automation-run'); - assert.equal(recoveredRun.status, 'failed'); - assert.equal(recoveredRun.legacyAutomationId, 'legacy-automation'); - assert.equal(recoveredRun.failureClass, 'app_restarted'); + const recoveredRun = ( + await stores.runtimeEventStore.listSessionInvocations(pending.id) + ).find((candidate) => candidate.runId === 'legacy-automation-run'); + assert.ok(recoveredRun); + assert.equal(recoveredRun && runtimeInvocationOutcome(recoveredRun), 'failed'); + assert.deepEqual(recoveredRun?.opening.root, { + kind: 'legacy_automation', + legacyAutomationId: 'legacy-automation', + }); + assert.equal(recoveredRun && runtimeInvocationFailureClass(recoveredRun), 'app_restarted'); } finally { await composition.close(); } @@ -1398,26 +1406,22 @@ test('production composition validates graph stop before aborting a claimed chil ); assert.ok(abortedAdmission?.userMessageId); assert.deepEqual(abortedAdmission?.execution, graphExecutionDescriptor(abortedClaim)); - const abortedRun = await stores.agentRunStore.readRun( - abortedClaim.targetSessionId, - abortedClaim.targetRunId, - ); - assert.equal(abortedRun.status, 'cancelled'); + const abortedRun = ( + await stores.runtimeEventStore.listSessionInvocations(abortedClaim.targetSessionId) + ).find((candidate) => candidate.runId === abortedClaim.targetRunId); + assert.ok(abortedRun); + assert.equal(abortedRun && runtimeInvocationOutcome(abortedRun), 'cancelled'); await assertUniqueGraphExecutionFacts( stores, abortedClaim, abortedAdmission.userMessageId, 'run_cancelled', ); - assert.equal( - ( - await stores.agentRunStore.readRun( - completedClaim.targetSessionId, - completedClaim.targetRunId, - ) - ).status, - 'completed', - ); + const completedRun = ( + await stores.runtimeEventStore.listSessionInvocations(completedClaim.targetSessionId) + ).find((candidate) => candidate.runId === completedClaim.targetRunId); + assert.ok(completedRun); + assert.equal(completedRun && runtimeInvocationOutcome(completedRun), 'completed'); } catch (error) { journeyError = error; throw error; @@ -1692,10 +1696,9 @@ async function assertUniqueGraphExecutionFacts( userMessageId: string, expectedTerminal: 'run_completed' | 'run_cancelled' = 'run_completed', ): Promise { - const [runs, messages, runEvents, runtimeEvents] = await Promise.all([ - stores.agentRunStore.listSessionRuns(claim.targetSessionId), + const [runs, messages, runtimeEvents] = await Promise.all([ + stores.runtimeEventStore.listSessionInvocations(claim.targetSessionId), stores.sessionStore.readMessages(claim.targetSessionId), - stores.agentRunStore.readEvents(claim.targetSessionId, claim.targetRunId), stores.runtimeEventStore.readImmutableRuntimeEvents(claim.targetSessionId, claim.targetRunId), ]); assert.deepEqual( @@ -1708,8 +1711,10 @@ async function assertUniqueGraphExecutionFacts( .map((message) => message.id), [userMessageId], ); - assert.equal(runEvents.filter((event) => event.type === 'run_started').length, 1); - assert.equal(runEvents.filter((event) => event.type === expectedTerminal).length, 1); + assert.equal( + runtimeEvents.filter((event) => event.content?.kind === 'invocation_opened').length, + 1, + ); assert.equal( runtimeEvents.filter( (event) => event.status === (expectedTerminal === 'run_cancelled' ? 'aborted' : 'completed'), diff --git a/packages/runtime-host/src/__tests__/execution-host-continuation.test.ts b/packages/runtime-host/src/__tests__/execution-host-continuation.test.ts index 6b4a1039b9..92ac57f1d9 100644 --- a/packages/runtime-host/src/__tests__/execution-host-continuation.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host-continuation.test.ts @@ -101,16 +101,14 @@ test('two Clients idempotently start one Host-owned safe-boundary continuation', const ledger = await fixture.readTurn(turnId); assert.equal(ledger.runs.length, 1); const run = ledger.runs[0]; - assert.equal(run?.parentRunId, source.sourceRunId); - assert.equal(run?.parentTurnId, source.sourceTurnId); + assert.equal(run?.opening.lineage?.parentRunId, source.sourceRunId); + assert.equal(run?.opening.lineage?.parentTurnId, source.sourceTurnId); assert.equal(run?.invocationId, admission.execution.targetInvocationId); - assert.equal(run?.continuationSource?.sourceRunId, source.sourceRunId); - assert.equal( - run?.continuationSource && 'protocol' in run.continuationSource - ? run.continuationSource.claimId - : undefined, - admission.execution.claimId, - ); + const openSource = run?.opening.source; + assert.equal(openSource?.kind, 'continuation'); + if (openSource?.kind !== 'continuation') return; + assert.equal(openSource.sourceRunId, source.sourceRunId); + assert.equal(openSource.claimId, admission.execution.claimId); } finally { if (!clientsClosed) { await first.close(); diff --git a/packages/runtime-host/src/__tests__/execution-host-message.test.ts b/packages/runtime-host/src/__tests__/execution-host-message.test.ts index 76f171a765..e3d544a23d 100644 --- a/packages/runtime-host/src/__tests__/execution-host-message.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host-message.test.ts @@ -38,7 +38,6 @@ import { dirname, join } from 'node:path'; import { test } from 'node:test'; import { TOOL_BOUNDARY_PROTOCOL_V1 } from '@maka/core/runtime-event'; import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; -import type { AgentRunHeader } from '@maka/core/agent-run'; import type { MessageContent } from '@maka/core/events'; import type { ConnectionCatalogEntry } from '@maka/core/runtime-policy'; import type { StoredMessage } from '@maka/core/session'; diff --git a/packages/runtime-host/src/__tests__/execution-host-queue.test.ts b/packages/runtime-host/src/__tests__/execution-host-queue.test.ts index b67513f8dd..25052409c6 100644 --- a/packages/runtime-host/src/__tests__/execution-host-queue.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host-queue.test.ts @@ -38,12 +38,16 @@ import { dirname, join } from 'node:path'; import { test } from 'node:test'; import { TOOL_BOUNDARY_PROTOCOL_V1 } from '@maka/core/runtime-event'; import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; -import type { AgentRunHeader } from '@maka/core/agent-run'; +import { + runtimeInvocationOutcome, + type RuntimeInvocationRecord, +} from '@maka/core/runtime-invocation'; +import { runtimeInvocationFailureClass } from '@maka/runtime/runtime-event-read-model'; import type { MessageContent } from '@maka/core/events'; import type { ConnectionCatalogEntry } from '@maka/core/runtime-policy'; import type { StoredMessage } from '@maka/core/session'; import { isTerminalRuntimeEvent } from '@maka/core/runtime-event'; -import type { RuntimeEvent } from '@maka/core/runtime-event'; +import type { RuntimeEvent, RuntimeInvocationLineage } from '@maka/core/runtime-event'; import { buildRecoveredTerminalRuntimeEvent, classifyTerminalRuntimeLedger, @@ -727,21 +731,25 @@ test('startup recovery canonically closes pending linked child admissions withou try { stores = await openInteractiveExecutionStoresForRead(reader.lease); for (const recovered of [initial, resume, retry, graph]) { - const run = await stores.agentRunStore.readRun(recovered.sessionId, recovered.runId); - assert.equal(run.status, 'failed'); - assert.equal(run.failureClass, 'app_restarted'); - assert.equal(run.agentId, recovered.agentId); - assert.equal(run.agentName, recovered.agentName); - assert.equal(run.workspaceIdentity, undefined); + const run: RuntimeInvocationRecord | undefined = ( + await stores.runtimeEventStore.listSessionInvocations(recovered.sessionId) + ).find((candidate) => candidate.runId === recovered.runId); + assert.ok(run); + assert.equal(runtimeInvocationOutcome(run), 'failed'); + assert.equal(runtimeInvocationFailureClass(run), 'app_restarted'); + const lineage: RuntimeInvocationLineage | undefined = run.opening.lineage; + assert.equal(lineage?.agentId, recovered.agentId); + assert.equal(lineage?.agentName, recovered.agentName); + assert.equal(run.opening.configuration.workspaceIdentity, undefined); if (recovered.kind === 'linked_child_resume') { - assert.equal(run.resumedFromRunId, recovered.sourceRunId); - assert.equal(run.retriedFromRunId, undefined); + assert.equal(lineage?.resumedFromRunId, recovered.sourceRunId); + assert.equal(lineage?.retriedFromRunId, undefined); } else if (recovered.kind === 'linked_child_provider_retry') { - assert.equal(run.retriedFromRunId, recovered.sourceRunId); - assert.equal(run.resumedFromRunId, undefined); + assert.equal(lineage?.retriedFromRunId, recovered.sourceRunId); + assert.equal(lineage?.resumedFromRunId, undefined); } else { - assert.equal(run.resumedFromRunId, undefined); - assert.equal(run.retriedFromRunId, undefined); + assert.equal(lineage?.resumedFromRunId, undefined); + assert.equal(lineage?.retriedFromRunId, undefined); } const runtimeEvents = await stores.runtimeEventStore.readImmutableRuntimeEvents( recovered.sessionId, diff --git a/packages/runtime-host/src/__tests__/execution-host-recovery.test.ts b/packages/runtime-host/src/__tests__/execution-host-recovery.test.ts index c06127a15b..7288617e0d 100644 --- a/packages/runtime-host/src/__tests__/execution-host-recovery.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host-recovery.test.ts @@ -29,7 +29,6 @@ import { join } from 'node:path'; import { test } from 'node:test'; import { TOOL_BOUNDARY_PROTOCOL_V1 } from '@maka/core/runtime-event'; import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; -import type { AgentRunHeader } from '@maka/core/agent-run'; import type { MessageContent } from '@maka/core/events'; import type { ConnectionCatalogEntry } from '@maka/core/runtime-policy'; import type { StoredMessage } from '@maka/core/session'; @@ -205,8 +204,8 @@ test('startup recovery replays an admitted regenerate with its source lineage', const ledger = await fixture.readTurn(regeneratedTurnId); assert.equal(ledger.runs.length, 1); assert.equal(ledger.userMessages.length, 1); - assert.equal(ledger.runs[0]?.parentTurnId, sourceTurnId); - assert.equal(ledger.runs[0]?.regeneratedFromTurnId, sourceTurnId); + assert.equal(ledger.runs[0]?.opening.lineage?.parentTurnId, sourceTurnId); + assert.equal(ledger.runs[0]?.opening.lineage?.regeneratedFromTurnId, sourceTurnId); }); }); @@ -328,7 +327,7 @@ test('startup recovery rejects an unproven legacy non-terminal Run before closin await fixture.assertOwnerAvailable(); const ledger = await fixture.readTurn(legacy.turnId); assert.equal(ledger.runs.length, 1); - assert.equal(ledger.runs[0]?.status, 'created'); + assert.equal(ledger.runs[0]?.terminalEvent, undefined); assert.equal(ledger.terminalEvents.length, 0); assert.deepEqual( (await fixture.readSessionUserMessages()).filter((message) => diff --git a/packages/runtime-host/src/__tests__/execution-host.test.ts b/packages/runtime-host/src/__tests__/execution-host.test.ts index cf523aaa67..096479011b 100644 --- a/packages/runtime-host/src/__tests__/execution-host.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host.test.ts @@ -39,7 +39,6 @@ import { DatabaseSync } from 'node:sqlite'; import { test } from 'node:test'; import { TOOL_BOUNDARY_PROTOCOL_V1 } from '@maka/core/runtime-event'; import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; -import type { AgentRunHeader } from '@maka/core/agent-run'; import type { MessageContent } from '@maka/core/events'; import type { ConnectionCatalogEntry } from '@maka/core/runtime-policy'; import { @@ -920,8 +919,8 @@ test('regenerate replays the durable source content with one recoverable root id const ledger = await fixture.readTurn(regeneratedTurnId); assert.equal(ledger.runs.length, 1); assert.equal(ledger.userMessages.length, 1); - assert.equal(ledger.runs[0]?.parentTurnId, sourceTurnId); - assert.equal(ledger.runs[0]?.regeneratedFromTurnId, sourceTurnId); + assert.equal(ledger.runs[0]?.opening.lineage?.parentTurnId, sourceTurnId); + assert.equal(ledger.runs[0]?.opening.lineage?.regeneratedFromTurnId, sourceTurnId); assert.deepEqual( { text: ledger.userMessages[0]?.text, diff --git a/packages/runtime-host/src/__tests__/execution-inspect-coordinator.test.ts b/packages/runtime-host/src/__tests__/execution-inspect-coordinator.test.ts index 753368305b..2f3adeda30 100644 --- a/packages/runtime-host/src/__tests__/execution-inspect-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/execution-inspect-coordinator.test.ts @@ -23,7 +23,8 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { describe, test } from 'node:test'; -import type { AgentRunHeader, EmittedAgentRunEvent } from '@maka/core/agent-run'; +import type { EmittedAgentRunEvent } from '@maka/core/agent-run'; +import { seedInvocation } from './fixtures/seed-invocation.js'; import { MODEL_CALL_ATTEMPT_SCHEMA_VERSION } from '@maka/core/model-call-attempt'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { openInteractiveExecutionStoresForWrite } from '@maka/storage/execution-stores'; @@ -41,7 +42,7 @@ describe('HostExecutionInspectCoordinator', () => { await withCoordinator(async ({ root, stores, coordinator }) => { const session = await stores.sessionStore.create(sessionInput('Corrupt model call')); const runId = 'corrupt-model-call-run'; - await stores.agentRunStore.createRun(runHeader(session.id, runId, 1)); + await seedInvocation(stores.runtimeEventStore, runHeader(session.id, runId, 1)); await stores.agentRunStore.appendEvent(session.id, runId, { type: 'model_call_attempt_recorded', id: 'corrupt-model-call-event', @@ -77,7 +78,7 @@ describe('HostExecutionInspectCoordinator', () => { const session = await stores.sessionStore.create(sessionInput('Compaction diagnostics')); const runId = 'compact-run'; const turnId = `turn-${runId}`; - await stores.agentRunStore.createRun(runHeader(session.id, runId, 1)); + await seedInvocation(stores.runtimeEventStore, runHeader(session.id, runId, 1)); await stores.agentRunStore.appendEvent(session.id, runId, { type: 'model_call_attempt_recorded', id: 'attempt-compact-1', @@ -147,8 +148,8 @@ describe('HostExecutionInspectCoordinator', () => { await withCoordinator(async ({ stores, coordinator }) => { const first = await stores.sessionStore.create(sessionInput('First')); const second = await stores.sessionStore.create(sessionInput('Second')); - await stores.agentRunStore.createRun(runHeader(first.id, 'shared-run', 1)); - await stores.agentRunStore.createRun(runHeader(second.id, 'shared-run', 2)); + await seedInvocation(stores.runtimeEventStore, runHeader(first.id, 'shared-run', 1)); + await seedInvocation(stores.runtimeEventStore, runHeader(second.id, 'shared-run', 2)); const run = await coordinator.handlers['execution.inspect.query']( { kind: 'agent_run', sessionId: second.id, agentRunId: 'shared-run' }, @@ -176,7 +177,7 @@ describe('HostExecutionInspectCoordinator', () => { 'shared-run', runtimeEvent(first.id, 'shared-run', 4), ); - await stores.agentRunStore.createRun(runHeader(first.id, 'older-run', 0)); + await seedInvocation(stores.runtimeEventStore, runHeader(first.id, 'older-run', 0)); await stores.runtimeEventStore.appendRuntimeEvent( first.id, 'older-run', @@ -201,7 +202,7 @@ describe('HostExecutionInspectCoordinator', () => { await withCoordinator(async ({ stores, coordinator }) => { const session = await stores.sessionStore.create(sessionInput('Large')); for (let index = 0; index <= EXECUTION_INSPECT_SESSION_MAX_RUNS; index += 1) { - await stores.agentRunStore.createRun(runHeader(session.id, `run-${index}`, index)); + await seedInvocation(stores.runtimeEventStore, runHeader(session.id, `run-${index}`, index)); } const oversized = await coordinator.handlers['execution.inspect.query']( @@ -232,7 +233,7 @@ describe('HostExecutionInspectCoordinator', () => { const runCount = EXECUTION_INSPECT_TRACE_PAGE_MAX_TURNS + 8; for (let index = 0; index < runCount; index += 1) { const runId = `paged-run-${index}`; - await stores.agentRunStore.createRun(runHeader(session.id, runId, index)); + await seedInvocation(stores.runtimeEventStore, runHeader(session.id, runId, index)); await stores.runtimeEventStore.appendRuntimeEvent( session.id, runId, @@ -272,7 +273,8 @@ describe('HostExecutionInspectCoordinator', () => { await withCoordinator(async ({ stores, coordinator }) => { const session = await stores.sessionStore.create(sessionInput('Legacy timestamps')); for (let index = 0; index <= EXECUTION_INSPECT_TRACE_PAGE_MAX_TURNS; index += 1) { - await stores.agentRunStore.createRun( + await seedInvocation( + stores.runtimeEventStore, runHeader(session.id, `legacy-run-${index}`, index + 0.5), ); } @@ -301,7 +303,7 @@ describe('HostExecutionInspectCoordinator', () => { await withCoordinator(async ({ stores, coordinator }) => { const session = await stores.sessionStore.create(sessionInput('Target Turn')); for (let index = 0; index <= EXECUTION_INSPECT_SESSION_MAX_RUNS; index += 1) { - await stores.agentRunStore.createRun(runHeader(session.id, `unrelated-${index}`, index)); + await seedInvocation(stores.runtimeEventStore, runHeader(session.id, `unrelated-${index}`, index)); } const runId = 'target-run'; const turnId = `turn-${runId}`; @@ -316,7 +318,7 @@ describe('HostExecutionInspectCoordinator', () => { sourceMessages: [], admittedAt: 100, }); - await stores.agentRunStore.createRun(runHeader(session.id, runId, 100)); + await seedInvocation(stores.runtimeEventStore, runHeader(session.id, runId, 100)); await stores.runtimeEventStore.appendRuntimeEvent( session.id, runId, @@ -344,9 +346,9 @@ describe('HostExecutionInspectCoordinator', () => { test('rejects oversized evidence at the bounded Store read boundary', async () => { await withCoordinator(async ({ stores, coordinator }) => { const session = await stores.sessionStore.create(sessionInput('Large evidence')); - await stores.agentRunStore.createRun(runHeader(session.id, 'large-run', 1)); + await seedInvocation(stores.runtimeEventStore, runHeader(session.id, 'large-run', 1)); await stores.agentRunStore.appendEvent(session.id, 'large-run', { - type: 'run_started', + type: 'turn_started', id: 'large-event', sessionId: session.id, runId: 'large-run', @@ -374,9 +376,9 @@ describe('HostExecutionInspectCoordinator', () => { test('does not charge unrelated AgentRun diagnostics to the Session trace budget', async () => { await withCoordinator(async ({ stores, coordinator }) => { const session = await stores.sessionStore.create(sessionInput('Trace evidence')); - await stores.agentRunStore.createRun(runHeader(session.id, 'trace-run', 1)); + await seedInvocation(stores.runtimeEventStore, runHeader(session.id, 'trace-run', 1)); await stores.agentRunStore.appendEvent(session.id, 'trace-run', { - type: 'run_started', + type: 'turn_started', id: 'large-unrelated-event', sessionId: session.id, runId: 'trace-run', @@ -404,12 +406,12 @@ describe('HostExecutionInspectCoordinator', () => { test('keeps a Session trace pageable when one run exceeds the evidence budget', async () => { await withCoordinator(async ({ stores, coordinator }) => { const session = await stores.sessionStore.create(sessionInput('Oversized trace page')); - await stores.agentRunStore.createRun(runHeader(session.id, 'oversized-run', 2)); + await seedInvocation(stores.runtimeEventStore, runHeader(session.id, 'oversized-run', 2)); await stores.runtimeEventStore.appendRuntimeEvent(session.id, 'oversized-run', { ...runtimeEvent(session.id, 'oversized-run', 2), content: { kind: 'text', text: 'x'.repeat(EXECUTION_INSPECT_EVIDENCE_MAX_BYTES) }, }); - await stores.agentRunStore.createRun(runHeader(session.id, 'older-run', 1)); + await seedInvocation(stores.runtimeEventStore, runHeader(session.id, 'older-run', 1)); await stores.runtimeEventStore.appendRuntimeEvent( session.id, 'older-run', @@ -445,7 +447,7 @@ describe('HostExecutionInspectCoordinator', () => { test('keeps earlier Session history reachable when one projected page exceeds the result limit', async () => { await withCoordinator(async ({ stores, coordinator }) => { const session = await stores.sessionStore.create(sessionInput('Oversized trace result')); - await stores.agentRunStore.createRun(runHeader(session.id, 'oversized-result-run', 2)); + await seedInvocation(stores.runtimeEventStore, runHeader(session.id, 'oversized-result-run', 2)); for (let index = 0; index < 128; index += 1) { await stores.runtimeEventStore.appendRuntimeEvent(session.id, 'oversized-result-run', { ...runtimeEvent(session.id, 'oversized-result-run', index + 2), @@ -453,7 +455,7 @@ describe('HostExecutionInspectCoordinator', () => { content: { kind: 'error', message: 'x'.repeat(EXECUTION_INSPECT_RESULT_MAX_BYTES / 64) }, }); } - await stores.agentRunStore.createRun(runHeader(session.id, 'older-run', 1)); + await seedInvocation(stores.runtimeEventStore, runHeader(session.id, 'older-run', 1)); await stores.runtimeEventStore.appendRuntimeEvent( session.id, 'older-run', @@ -509,9 +511,9 @@ describe('HostExecutionInspectCoordinator', () => { test('accepts evidence that exactly consumes the shared byte budget before an empty ledger', async () => { await withCoordinator(async ({ stores, coordinator }) => { const session = await stores.sessionStore.create(sessionInput('Exact evidence budget')); - await stores.agentRunStore.createRun(runHeader(session.id, 'exact-run', 1)); + await seedInvocation(stores.runtimeEventStore, runHeader(session.id, 'exact-run', 1)); const baseEvent: EmittedAgentRunEvent = { - type: 'run_started', + type: 'turn_started', id: 'exact-event', sessionId: session.id, runId: 'exact-run', @@ -552,7 +554,7 @@ describe('HostExecutionInspectCoordinator', () => { await withCoordinator(async ({ stores, coordinator }) => { const session = await stores.sessionStore.create(sessionInput('Aggregate evidence')); for (const [index, runId] of ['aggregate-run-1', 'aggregate-run-2'].entries()) { - await stores.agentRunStore.createRun(runHeader(session.id, runId, index + 1)); + await seedInvocation(stores.runtimeEventStore, runHeader(session.id, runId, index + 1)); await stores.runtimeEventStore.appendRuntimeEvent(session.id, runId, { id: `aggregate-event-${index + 1}`, invocationId: runId, @@ -598,21 +600,29 @@ function sessionInput(name: string) { } as const; } -function runHeader(sessionId: string, runId: string, createdAt: number): AgentRunHeader { +function runHeader(sessionId: string, runId: string, createdAt: number) { return { sessionId, runId, turnId: `turn-${runId}`, - status: 'completed', - backendKind: 'fake', - llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', - llmConnectionSlug: 'fake', - modelId: 'fake-model', - cwd: '/tmp/workspace', - permissionMode: 'ask', - createdAt, - updatedAt: createdAt, - completedAt: createdAt, + openedAt: createdAt, + opening: { + route: { + provenance: 'runtime' as const, + backendKind: 'fake' as const, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + llmConnectionSlug: 'fake', + modelId: 'fake-model', + }, + configuration: { + cwd: '/tmp/workspace', + permissionMode: 'ask' as const, + collaborationMode: 'agent' as const, + orchestrationMode: 'default' as const, + orchestrationSource: 'session' as const, + toolMode: 'direct' as const, + }, + }, }; } diff --git a/packages/runtime-host/src/__tests__/execution-inspect-protocol.test.ts b/packages/runtime-host/src/__tests__/execution-inspect-protocol.test.ts index 7febc0924d..944f2deecf 100644 --- a/packages/runtime-host/src/__tests__/execution-inspect-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/execution-inspect-protocol.test.ts @@ -263,11 +263,11 @@ function agentRunDocument(sessionId = 'session-1', agentRunId = 'run-1'): AgentR agentRun: { sessionId, agentRunId, + invocationId: agentRunId, turnId: 'turn-1', status: 'completed', - createdAt: 1, - updatedAt: 2, - completedAt: 2, + openedAt: 1, + endedAt: 2, }, sources: { operationalEventCount: 0, @@ -275,8 +275,6 @@ function agentRunDocument(sessionId = 'session-1', agentRunId = 'run-1'): AgentR health: { runtimeLedger: 'missing', runtimeTerminalPresent: false, - operationalTerminalPresent: false, - statusConsistency: 'incomplete', }, }, tools: { diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index fe2a693302..8d07a16f60 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -40,7 +40,8 @@ import { import { PROVIDER_REGISTRY } from '@maka/core/llm-connections'; import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; import { decodeRunCompositionSnapshot } from '@maka/core/run-composition'; -import type { AgentRunHeader } from '@maka/core/agent-run'; +import { readInvocation, testInvocationRecord } from './fixtures/seed-invocation.js'; +import { runtimeInvocationOutcome } from '@maka/core/runtime-invocation'; import { agentRunCompositionFromEvents } from '@maka/core/agent-run'; import type { BackendCompactHistoryInput } from '@maka/core/backend-types'; import { decodeCanonicalToolResultContent } from '@maka/core/tool-result-record-schema'; @@ -329,7 +330,7 @@ test('production Host executes Bash against the current live sandbox boundary', ), context, ); - const firstRun = await execution.agentRunStore.readRun(session.id, firstTerminal.runId); + const firstRun = await readInvocation(execution, session.id, firstTerminal.runId); const firstRunEvents = await execution.agentRunStore.readEvents( session.id, firstTerminal.runId, @@ -1042,38 +1043,58 @@ test('Codex OAuth history compaction falls back to a text checkpoint after nativ turnId: 'turn-compact', runId: 'run-compact', runtimeContext, - runtimeContextRunHeaders: [ - { - runId: 'compact-source-run', + runtimeContextInvocations: [ + testInvocationRecord({ sessionId: 'backend-creation-session', + runId: 'compact-source-run', turnId: 'turn-old-model', - status: 'completed', - backendKind: 'ai-sdk', - llmConnectionId: '11111111-1111-4111-8111-111111111111', - llmConnectionSlug: 'backend-creation-connection', - modelId: 'gpt-5.2', - cwd: '/workspace', - permissionMode: 'bypass', - createdAt: 1, - updatedAt: 2, - completedAt: 2, - } satisfies AgentRunHeader, - { - runId: 'compact-same-route-run', + openedAt: 1, + closedAt: 2, + outcome: 'completed', + opening: { + route: { + provenance: 'runtime', + backendKind: 'ai-sdk', + llmConnectionId: '11111111-1111-4111-8111-111111111111', + llmConnectionSlug: 'backend-creation-connection', + modelId: 'gpt-5.2', + }, + configuration: { + cwd: '/workspace', + permissionMode: 'bypass', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + }, + }), + testInvocationRecord({ sessionId: 'backend-creation-session', + runId: 'compact-same-route-run', turnId: 'turn-current-route-model', - status: 'completed', - backendKind: 'ai-sdk', - llmConnectionId: '11111111-1111-4111-8111-111111111111', - llmConnectionSlug: 'backend-creation-connection', - modelId, - providerStateIdentity, - cwd: '/workspace', - permissionMode: 'bypass', - createdAt: 2, - updatedAt: 3, - completedAt: 3, - } satisfies AgentRunHeader, + openedAt: 2, + closedAt: 3, + outcome: 'completed', + opening: { + route: { + provenance: 'runtime', + backendKind: 'ai-sdk', + llmConnectionId: '11111111-1111-4111-8111-111111111111', + llmConnectionSlug: 'backend-creation-connection', + modelId, + providerStateIdentity, + }, + configuration: { + cwd: '/workspace', + permissionMode: 'bypass', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + }, + }), ], } satisfies BackendCompactHistoryInput; const result = await backend.compactHistory(compactInput); @@ -1899,6 +1920,9 @@ test('production Host executes a canonical ai-sdk Session against a real provide const hostedCheckpoints = await loadHistoryCompactCheckpointsFromRunLedger( execution.agentRunStore, session.id, + (await execution.runtimeEventStore.listSessionInvocations(session.id)).map( + (invocation) => invocation.runId, + ), ); const hostedMemoryBoundary = hostedCheckpoints.find( (checkpoint) => checkpoint.memoryExtractionBoundary, @@ -2202,20 +2226,22 @@ test('production Host executes and durably supervises an Agent Graph over a real graphStore = createAgentGraphControlStore(root); const graphId = agentGraphIdForRootSession(session.id); let updates = await graphStore.listAgentGraphScheduleUpdates(graphId); - let runs = await execution.agentRunStore.listSessionRuns(session.id); + let runs = await execution.runtimeEventStore.listSessionInvocations(session.id); for (let attempt = 0; attempt < 400; attempt += 1) { - const wakeRuns = runs.filter((run) => run.agentGraphWakeAttemptId !== undefined); + const wakeRuns = runs.filter( + (run) => run.opening.root.kind === 'agent_graph_supervisor_wake', + ); if ( updates.at(-1)?.finish && wakeRuns.length > 0 && - wakeRuns.every((run) => ['completed', 'failed', 'cancelled'].includes(run.status)) && + wakeRuns.every((run) => runtimeInvocationOutcome(run) !== undefined) && liveResidencies === 0 ) { break; } await new Promise((resolve) => setTimeout(resolve, 10)); updates = await graphStore.listAgentGraphScheduleUpdates(graphId); - runs = await execution.agentRunStore.listSessionRuns(session.id); + runs = await execution.runtimeEventStore.listSessionInvocations(session.id); } const finish = updates.at(-1)?.finish; @@ -2226,8 +2252,8 @@ test('production Host executes and durably supervises an Agent Graph over a real lastUpdate: updates.at(-1), runs: runs.map((run) => ({ runId: run.runId, - status: run.status, - wakeAttemptId: run.agentGraphWakeAttemptId, + status: runtimeInvocationOutcome(run) ?? 'running', + root: run.opening.root, })), requests: providerRequestTrace(provider.requests), }), @@ -2242,10 +2268,14 @@ test('production Host executes and durably supervises an Agent Graph over a real assert.equal(rootComposition?.contextWindow, 32_768); assert.match(rootComposition?.baseSystemPromptHash ?? '', /^sha256:[a-f0-9]{64}$/u); assert.ok(rootComposition?.toolNames.includes('view_agent_graph')); - const wakeRuns = runs.filter((run) => run.agentGraphWakeAttemptId !== undefined); + const wakeRuns = runs.filter( + (run) => run.opening.root.kind === 'agent_graph_supervisor_wake', + ); assert.ok(wakeRuns.length > 0); - assert.ok(wakeRuns.every((run) => run.status === 'completed')); - assert.ok(wakeRuns.every((run) => run.orchestrationMode === 'graph')); + assert.ok(wakeRuns.every((run) => runtimeInvocationOutcome(run) === 'completed')); + assert.ok( + wakeRuns.every((run) => run.opening.configuration.orchestrationMode === 'graph'), + ); assert.equal(liveResidencies, 0); const sessions = await execution.sessionStore.listForRecovery(); @@ -2255,9 +2285,9 @@ test('production Host executes and durably supervises an Agent Graph over a real assert.ok(child); assert.equal(child?.subagentRuntime?.profile, 'local_read'); assert.equal(child?.subagentParent?.parentSessionId, session.id); - const childRuns = child ? await execution.agentRunStore.listSessionRuns(child.id) : []; + const childRuns = child ? await execution.runtimeEventStore.listSessionInvocations(child.id) : []; assert.equal(childRuns.length, 1); - assert.equal(childRuns[0]?.status, 'completed'); + assert.equal(childRuns[0] && runtimeInvocationOutcome(childRuns[0]), 'completed'); const graphRequests = provider.requests.filter( (request) => @@ -2384,7 +2414,7 @@ test('production Host executes a durable runnable child with an exact tool ceili ), context, ); - const parentRun = await execution.agentRunStore.readRun(parent.id, terminal.runId); + const parentRun = await readInvocation(execution, parent.id, terminal.runId); const parentRunEvents = await execution.agentRunStore.readEvents(parent.id, terminal.runId); assert.equal( terminal.status, @@ -2430,10 +2460,10 @@ test('production Host executes a durable runnable child with an exact tool ceili if (!child) return; assert.equal(child.subagentWorkspace, undefined); assert.equal(child.cwd, project); - const childRuns = await execution.agentRunStore.listSessionRuns(child.id); + const childRuns = await execution.runtimeEventStore.listSessionInvocations(child.id); assert.equal(childRuns.length, 1); - assert.equal(childRuns[0]?.status, 'completed'); - assert.equal(childRuns[0]?.parentRunId, undefined); + assert.equal(childRuns[0] && runtimeInvocationOutcome(childRuns[0]), 'completed'); + assert.equal(childRuns[0]?.opening.lineage?.parentRunId, undefined); const childMessages = await execution.sessionStore.readMessagesSnapshot(child.id); assert.equal( childMessages.find((message) => message.type === 'assistant')?.text, @@ -2582,7 +2612,7 @@ test('production Host publishes and retires an implementation child patch', asyn ), context, ); - const parentRun = await execution.agentRunStore.readRun(parent.id, terminal.runId); + const parentRun = await readInvocation(execution, parent.id, terminal.runId); const parentRunEvents = await execution.agentRunStore.readEvents(parent.id, terminal.runId); assert.equal( terminal.status, @@ -2647,10 +2677,10 @@ test('production Host publishes and retires an implementation child patch', asyn assert.equal(child.cwd, child.subagentWorkspace?.worktreePath); assert.equal(await fileExists(join(project, 'implementation.txt')), false); assert.equal(await fileExists(join(child.cwd, 'implementation.txt')), true); - const childRuns = await execution.agentRunStore.listSessionRuns(child.id); + const childRuns = await execution.runtimeEventStore.listSessionInvocations(child.id); assert.equal(childRuns.length, 1); - assert.equal(childRuns[0]?.status, 'completed'); - assert.equal(childRuns[0]?.parentRunId, undefined); + assert.equal(childRuns[0] && runtimeInvocationOutcome(childRuns[0]), 'completed'); + assert.equal(childRuns[0]?.opening.lineage?.parentRunId, undefined); const childMessages = await execution.sessionStore.readMessagesSnapshot(child.id); assert.equal( childMessages.find((message) => message.type === 'assistant')?.text, diff --git a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts index 1cdf00afd2..caaa421a33 100644 --- a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts +++ b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts @@ -39,7 +39,11 @@ import { DatabaseSync } from 'node:sqlite'; import { test } from 'node:test'; import { TOOL_BOUNDARY_PROTOCOL_V1 } from '@maka/core/runtime-event'; import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; -import type { AgentRunHeader } from '@maka/core/agent-run'; +import { + runtimeInvocationOutcome, + type RuntimeInvocationRecord, +} from '@maka/core/runtime-invocation'; +import { seedInvocation } from './seed-invocation.js'; import { aggregateMessageContents, messageContentDigest, @@ -121,7 +125,7 @@ export interface ExecutionHostHandle { } export interface TurnLedger { - runs: AgentRunHeader[]; + runs: RuntimeInvocationRecord[]; userMessages: Array>; runtimeEvents: RuntimeEvent[]; terminalEvents: RuntimeEvent[]; @@ -176,24 +180,31 @@ export class ExecutionFixture { const sourceTurnId = randomUUID(); const createdAt = Date.now(); const workspace = await resolveWorkspaceIdentity({ path: this.root }); - const sourceRun: AgentRunHeader = { - runId: sourceRunId, - invocationId: sourceInvocationId, + const sourceRun = await seedInvocation(stores.runtimeEventStore, { sessionId: this.sessionId, + invocationId: sourceInvocationId, + runId: sourceRunId, turnId: sourceTurnId, - status: 'created', - backendKind: 'fake', - llmConnectionId: FAKE_CONNECTION_ID, - llmConnectionSlug: 'fake', - modelId: 'fake-model', - cwd: this.root, - workspaceIdentity: workspace.workspaceIdentity, - permissionMode: 'ask', - collaborationMode: 'agent', - createdAt, - updatedAt: createdAt, - }; - await stores.agentRunStore.createRun(sourceRun, { durable: true }); + openedAt: createdAt, + opening: { + route: { + provenance: 'runtime', + backendKind: 'fake', + llmConnectionId: FAKE_CONNECTION_ID, + llmConnectionSlug: 'fake', + modelId: 'fake-model', + }, + configuration: { + cwd: this.root, + workspaceIdentity: workspace.workspaceIdentity, + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + }, + }); await stores.runtimeEventStore.appendRuntimeEvent(this.sessionId, sourceRunId, { id: randomUUID(), sessionId: this.sessionId, @@ -251,7 +262,6 @@ export class ExecutionFixture { recoveryReason: 'test_safe_boundary_source', }); await commitTerminalRunWithRuntimeFact({ - runStore: stores.agentRunStore, runtimeEventStore: stores.runtimeEventStore, newId: randomUUID, sessionId: this.sessionId, @@ -549,25 +559,31 @@ export class ExecutionFixture { assert.equal(child.created, true); if (sourceRunId) { const sourceTs = Date.now(); - const sourceRun: AgentRunHeader = { - runId: sourceRunId, - invocationId: sourceRunId, + const sourceRun = await seedInvocation(stores.runtimeEventStore, { sessionId: child.header.id, + invocationId: sourceRunId, + runId: sourceRunId, turnId: `source-turn-${kind}`, - status: 'created', - backendKind: 'fake', - llmConnectionId: FAKE_CONNECTION_ID, - llmConnectionSlug: 'fake', - modelId: 'fake-model', - cwd: this.root, - permissionMode: 'explore', - collaborationMode: 'agent', - createdAt: sourceTs, - updatedAt: sourceTs, - agentId, - agentName, - }; - await stores.agentRunStore.createRun(sourceRun, { durable: true }); + openedAt: sourceTs, + opening: { + route: { + provenance: 'runtime', + backendKind: 'fake', + llmConnectionId: FAKE_CONNECTION_ID, + llmConnectionSlug: 'fake', + modelId: 'fake-model', + }, + configuration: { + cwd: this.root, + permissionMode: 'explore', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + lineage: { agentId, agentName }, + }, + }); const sourceTerminal = buildRecoveredTerminalRuntimeEvent({ id: randomUUID(), run: sourceRun, @@ -577,7 +593,6 @@ export class ExecutionFixture { recoveryReason: 'test_source_terminal', }); await commitTerminalRunWithRuntimeFact({ - runStore: stores.agentRunStore, runtimeEventStore: stores.runtimeEventStore, newId: randomUUID, sessionId: child.header.id, @@ -649,28 +664,35 @@ export class ExecutionFixture { try { stores = await openInteractiveExecutionStoresForWrite(owner.lease); const ts = Date.now(); - await stores.agentRunStore.createRun( - { - runId: graph.runId, - invocationId: graph.runId, - sessionId: graph.sessionId, - turnId: graph.turnId, - status: 'created', - backendKind: 'fake', - llmConnectionId: FAKE_CONNECTION_ID, - llmConnectionSlug: 'fake', - modelId: 'fake-model', - cwd: this.root, - permissionMode: 'explore', - collaborationMode: 'agent', - createdAt: ts, - updatedAt: ts, - resumedFromRunId: randomUUID(), - agentId: graph.agentId, - agentName: graph.agentName, + await seedInvocation(stores.runtimeEventStore, { + sessionId: graph.sessionId, + invocationId: graph.runId, + runId: graph.runId, + turnId: graph.turnId, + openedAt: ts, + opening: { + route: { + provenance: 'runtime', + backendKind: 'fake', + llmConnectionId: FAKE_CONNECTION_ID, + llmConnectionSlug: 'fake', + modelId: 'fake-model', + }, + configuration: { + cwd: this.root, + permissionMode: 'explore', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + lineage: { + resumedFromRunId: randomUUID(), + agentId: graph.agentId, + agentName: graph.agentName, + }, }, - { durable: true }, - ); + }); } finally { await stores?.sessionStore.close?.(); await owner.close(); @@ -798,22 +820,31 @@ export class ExecutionFixture { admittedAt, }); assert.equal(admission.kind, 'admitted'); - const run: AgentRunHeader = { - runId, - invocationId: runId, - sessionId: this.sessionId, - turnId, - status: 'created', - backendKind: 'fake', - llmConnectionSlug: 'fake', - modelId: 'fake-model', - cwd: this.root, - permissionMode: 'ask', - createdAt: admittedAt, - updatedAt: admittedAt, - }; + const run = { runId, invocationId: runId, sessionId: this.sessionId, turnId }; if (runState !== 'missing') { - await stores.agentRunStore.createRun(run, { durable: true }); + await seedInvocation(stores.runtimeEventStore, { + sessionId: this.sessionId, + invocationId: runId, + runId, + turnId, + openedAt: admittedAt, + opening: { + route: { + provenance: 'unknown', + backendKind: 'fake', + llmConnectionSlug: 'fake', + modelId: 'fake-model', + }, + configuration: { + cwd: this.root, + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + }, + }); } if (runState === 'terminal') { const terminalAt = admittedAt + 1; @@ -826,7 +857,6 @@ export class ExecutionFixture { recoveryReason: 'test_legacy_terminal_root', }); await commitTerminalRunWithRuntimeFact({ - runStore: stores.agentRunStore, runtimeEventStore: stores.runtimeEventStore, newId: randomUUID, sessionId: this.sessionId, @@ -1002,20 +1032,29 @@ export class ExecutionFixture { }); assert.equal(result.kind, 'admitted'); if (createRun) { - await stores.agentRunStore.createRun({ - runId: result.admission.runId, - invocationId: result.admission.runId, + await seedInvocation(stores.runtimeEventStore, { sessionId: this.sessionId, + invocationId: result.admission.runId, + runId: result.admission.runId, turnId, - status: 'created', - backendKind: 'fake', - llmConnectionId: FAKE_CONNECTION_ID, - llmConnectionSlug: 'fake', - modelId: 'fake-model', - cwd: this.root, - permissionMode: 'ask', - createdAt: admittedAt, - updatedAt: admittedAt, + openedAt: admittedAt, + opening: { + route: { + provenance: 'runtime', + backendKind: 'fake', + llmConnectionId: FAKE_CONNECTION_ID, + llmConnectionSlug: 'fake', + modelId: 'fake-model', + }, + configuration: { + cwd: this.root, + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + }, }); } assert.ok(result.admission.userMessageId); @@ -1107,10 +1146,10 @@ export class ExecutionFixture { stores = await openInteractiveExecutionStoresForRead(reader.lease); const admission = await stores.agentRunStore.readRootTurnAdmission(this.sessionId, turnId); assert.ok(admission); - const runs = (await stores.agentRunStore.listSessionRuns(this.sessionId)).filter( - (candidate) => candidate.turnId === turnId, - ); - const run = await stores.agentRunStore.readRun(this.sessionId, admission.runId); + const invocations = await stores.runtimeEventStore.listSessionInvocations(this.sessionId); + const runs = invocations.filter((candidate) => candidate.turnId === turnId); + const run = invocations.find((candidate) => candidate.runId === admission.runId); + assert.ok(run); const messages = await stores.sessionStore.readMessages(this.sessionId); const runtimeEvents = await stores.runtimeEventStore.readImmutableRuntimeEvents( this.sessionId, @@ -1149,7 +1188,7 @@ export class ExecutionFixture { let stores: Awaited> | undefined; try { stores = await openInteractiveExecutionStoresForRead(reader.lease); - return (await stores.agentRunStore.listSessionRuns(this.sessionId)).filter( + return (await stores.runtimeEventStore.listSessionInvocations(this.sessionId)).filter( (candidate) => candidate.turnId === turnId, ); } finally { @@ -1197,7 +1236,7 @@ export class ExecutionFixture { stores = await openInteractiveExecutionStoresForRead(reader.lease); const [admission, runs, messages] = await Promise.all([ stores.agentRunStore.readRootTurnAdmission(this.sessionId, turnId), - stores.agentRunStore.listSessionRuns(this.sessionId), + stores.runtimeEventStore.listSessionInvocations(this.sessionId), stores.sessionStore.readMessages(this.sessionId), ]); return { diff --git a/packages/runtime-host/src/__tests__/fixtures/seed-invocation.ts b/packages/runtime-host/src/__tests__/fixtures/seed-invocation.ts new file mode 100644 index 0000000000..d3196f99d7 --- /dev/null +++ b/packages/runtime-host/src/__tests__/fixtures/seed-invocation.ts @@ -0,0 +1,167 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { randomUUID } from 'node:crypto'; +import type { + RuntimeEvent, + RuntimeEventInvocationOpenedContent, +} from '@maka/core/runtime-event'; +import { + buildInvocationOpenedEvent, + type RuntimeInvocationRecord, +} from '@maka/core/runtime-invocation'; + +export interface SeededInvocationIdentity { + readonly sessionId: string; + readonly invocationId: string; + readonly runId: string; + readonly turnId: string; +} + +export interface SeedInvocationInput { + readonly sessionId: string; + readonly runId: string; + readonly turnId: string; + readonly invocationId?: string; + readonly openedAt?: number; + readonly opening?: Partial; +} + +/** The opening a test gets when it does not care what the run was routed to. */ +export function testInvocationOpening( + overrides: Partial = {}, +): RuntimeEventInvocationOpenedContent { + return { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'runtime', + backendKind: 'fake', + llmConnectionId: 'fake-connection', + llmConnectionSlug: 'fake', + modelId: 'fake-model', + }, + configuration: { + cwd: '/tmp', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + root: { kind: 'user' }, + source: { kind: 'fresh' }, + ...overrides, + }; +} + +/** + * One invocation as a reader sees it, without a store. + * + * `outcome` writes the terminal event that decides it; leaving it out leaves the + * invocation running, which is what "no terminal event" means everywhere else. + */ +export function testInvocationRecord(input: { + sessionId: string; + runId: string; + turnId: string; + invocationId?: string; + openedAt?: number; + closedAt?: number; + outcome?: 'completed' | 'failed' | 'aborted'; + failureClass?: string; + opening?: Partial; +}): RuntimeInvocationRecord { + const invocationId = input.invocationId ?? input.runId; + const openedAt = input.openedAt ?? 1; + const identity = { + sessionId: input.sessionId, + invocationId, + runId: input.runId, + turnId: input.turnId, + }; + return { + ...identity, + openedAt, + opening: testInvocationOpening(input.opening), + ...(input.outcome + ? { + terminalEvent: { + id: `${invocationId}-terminal`, + ...identity, + ts: input.closedAt ?? openedAt + 1, + partial: false, + role: 'system', + author: 'system', + status: input.outcome, + ...(input.failureClass ? { failureClass: input.failureClass } : {}), + }, + } + : {}), + }; +} + +/** The event that opens one invocation, ready to append. */ +export function testInvocationOpenedEvent(input: SeedInvocationInput): RuntimeEvent { + return buildInvocationOpenedEvent({ + id: randomUUID(), + run: { + sessionId: input.sessionId, + invocationId: input.invocationId ?? input.runId, + runId: input.runId, + turnId: input.turnId, + }, + openedAt: input.openedAt ?? Date.now(), + opening: testInvocationOpening(input.opening), + }); +} + +/** The one invocation that opened this run, or a failure naming what is missing. */ +export async function readInvocation( + stores: { + runtimeEventStore: { + listSessionInvocations(sessionId: string): Promise; + }; + }, + sessionId: string, + runId: string, +): Promise { + const found = (await stores.runtimeEventStore.listSessionInvocations(sessionId)).find( + (candidate) => candidate.runId === runId, + ); + if (!found) throw new Error(`Session ${sessionId} has no invocation for run ${runId}`); + return found; +} + +/** Open one invocation on the spine, the way the runtime would. */ +export async function seedInvocation( + runtimeEventStore: { + appendRuntimeEvent(sessionId: string, runId: string, event: RuntimeEvent): Promise; + }, + input: SeedInvocationInput, +): Promise { + const event = testInvocationOpenedEvent(input); + await runtimeEventStore.appendRuntimeEvent(input.sessionId, input.runId, event); + return { + sessionId: event.sessionId, + invocationId: event.invocationId, + runId: event.runId, + turnId: event.turnId, + }; +} diff --git a/packages/runtime-host/src/__tests__/goal-coordinator.test.ts b/packages/runtime-host/src/__tests__/goal-coordinator.test.ts index 09b7ea9953..c840ca82f2 100644 --- a/packages/runtime-host/src/__tests__/goal-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/goal-coordinator.test.ts @@ -23,6 +23,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; import type { GoalAuthorityRecord } from '@maka/core/goal'; +import { seedInvocation } from './fixtures/seed-invocation.js'; import type { GoalTurnOutcome } from '@maka/runtime/goal-continuation'; import { openInteractiveExecutionStoresForWrite } from '@maka/storage/execution-stores'; import { openInteractiveGoalAuthorityForWrite } from '@maka/storage/goal-authority'; @@ -354,21 +355,30 @@ test('restart settles the durable current Goal execution through Hosted Executio admittedAt: 1, }); assert.equal(admission.kind, 'admitted'); - await stores.agentRunStore.createRun({ - runId: execution.runId, - invocationId: execution.runId, + await seedInvocation(stores.runtimeEventStore, { sessionId: session.id, + invocationId: execution.runId, + runId: execution.runId, turnId: execution.turnId, - status: 'created', - backendKind: 'fake', - llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', - llmConnectionSlug: 'fake', - modelId: 'fake-model', - cwd: capability.canonicalPath, - permissionMode: 'ask', - goalId: record.goal.id, - createdAt: 2, - updatedAt: 2, + openedAt: 2, + opening: { + route: { + provenance: 'runtime', + backendKind: 'fake', + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + llmConnectionSlug: 'fake', + modelId: 'fake-model', + }, + configuration: { + cwd: capability.canonicalPath, + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + root: { kind: 'goal', goalId: record.goal.id }, + }, }); await stores.runtimeEventStore.appendRuntimeEvent(session.id, execution.runId, { id: 'goal_recovery_terminal', @@ -383,11 +393,6 @@ test('restart settles the durable current Goal execution through Hosted Executio author: 'agent', content: { kind: 'text', text: 'done' }, }); - await stores.agentRunStore.updateRun(session.id, execution.runId, { - status: 'completed', - updatedAt: 3, - completedAt: 3, - }); let drainRequested = false; const executionProjection = new HostedExecutionProjectionReader(stores); diff --git a/packages/runtime-host/src/__tests__/goal-root-authority.test.ts b/packages/runtime-host/src/__tests__/goal-root-authority.test.ts index fcbc66a1a3..be4814e2c1 100644 --- a/packages/runtime-host/src/__tests__/goal-root-authority.test.ts +++ b/packages/runtime-host/src/__tests__/goal-root-authority.test.ts @@ -24,7 +24,12 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; -import type { AgentRunHeader } from '@maka/core/agent-run'; +import { + runtimeInvocationOutcome, + type RuntimeInvocationRecord, +} from '@maka/core/runtime-invocation'; +import { runtimeInvocationFailureClass } from '@maka/runtime/runtime-event-read-model'; +import { seedInvocation } from './fixtures/seed-invocation.js'; import { BackendRegistry, SessionManager } from '@maka/runtime/session-manager'; import { FakeBackend } from '@maka/runtime/test-only/fake-backend'; import { GOAL_SET_TOOL_NAME } from '@maka/runtime/goal-tools'; @@ -98,11 +103,8 @@ test('Goal continuation uses the canonical root admission and durable origin', { assert.deepEqual(durableAdmission?.execution, { kind: 'goal', goalId: created.id }); assert.ok(durableAdmission); if (!durableAdmission) return; - const run = await fixture.stores.agentRunStore.readRun( - fixture.sessionId, - durableAdmission.runId, - ); - assert.equal(run.goalId, created.id); + const run = await readInvocation(fixture, durableAdmission.runId); + assert.deepEqual(run?.opening.root, { kind: 'goal', goalId: created.id }); const user = (await fixture.stores.sessionStore.readMessages(fixture.sessionId)).find( (message) => message.type === 'user' && message.turnId === admission.turnId, ); @@ -164,8 +166,8 @@ test('queued Goal control revokes a prepared root before durable admission', asy undefined, ); assert.equal( - (await fixture.stores.agentRunStore.listSessionRuns(fixture.sessionId)).some( - (run) => run.goalId === created.id, + (await fixture.stores.runtimeEventStore.listSessionInvocations(fixture.sessionId)).some( + (run) => run.opening.root.kind === 'goal' && run.opening.root.goalId === created.id, ), false, ); @@ -306,7 +308,7 @@ test('drain revokes pending ScheduledTask before durable root admission', async undefined, ); assert.equal( - (await fixture.stores.agentRunStore.listSessionRuns(fixture.sessionId)).some( + (await fixture.stores.runtimeEventStore.listSessionInvocations(fixture.sessionId)).some( (run) => run.runId === runId, ), false, @@ -393,7 +395,7 @@ test('Host Goal continuation bridges its exact generation into root authority', if (!resumed) return; const run = await waitForGoalRun(fixture, resumed.id); - assert.equal(run.goalId, resumed.id); + assert.deepEqual(run.opening.root, { kind: 'goal', goalId: resumed.id }); const admission = await fixture.stores.agentRunStore.readRootTurnAdmission( fixture.sessionId, run.turnId, @@ -424,10 +426,10 @@ test('restart closes an admitted Goal without a Run instead of replaying it', as }); await fixture.coordinator.prepareRecovery(); - const run = await fixture.stores.agentRunStore.readRun(fixture.sessionId, runId); - assert.equal(run.goalId, 'goal-restart'); - assert.equal(run.status, 'failed'); - assert.equal(run.failureClass, 'app_restarted'); + const run = await readInvocation(fixture, runId); + assert.deepEqual(run?.opening.root, { kind: 'goal', goalId: 'goal-restart' }); + assert.equal(run && runtimeInvocationOutcome(run), 'failed'); + assert.equal(run && runtimeInvocationFailureClass(run), 'app_restarted'); const user = (await fixture.stores.sessionStore.readMessages(fixture.sessionId)).find( (message) => message.type === 'user' && message.turnId === turnId, ); @@ -491,15 +493,15 @@ test('restart rejects a Goal Run carrying delegated execution lineage', async () sourceMessages: [], admittedAt: 1, }); - await fixture.stores.agentRunStore.createRun( - runHeader({ - sessionId: fixture.sessionId, - turnId, - runId, - goalId, - parentRunId: 'foreign-parent-run', - }), - ); + await seedInvocation(fixture.stores.runtimeEventStore, { + sessionId: fixture.sessionId, + turnId, + runId, + opening: { + root: { kind: 'goal', goalId }, + lineage: { parentRunId: 'foreign-parent-run' }, + }, + }); await assert.rejects( () => fixture.coordinator.prepareRecovery(), @@ -734,13 +736,22 @@ async function createFixture(options: { recoverAdmissions?: boolean } = {}): Pro }, }; } -async function waitForGoalRun( +async function readInvocation( fixture: Fixture, - goalId: string, -): Promise>> { + runId: string, +): Promise { + return (await fixture.stores.runtimeEventStore.listSessionInvocations(fixture.sessionId)).find( + (candidate) => candidate.runId === runId, + ); +} + +async function waitForGoalRun(fixture: Fixture, goalId: string): Promise { for (let attempt = 0; attempt < 100; attempt += 1) { - const run = (await fixture.stores.agentRunStore.listSessionRuns(fixture.sessionId)).find( - (candidate) => candidate.goalId === goalId, + const run = ( + await fixture.stores.runtimeEventStore.listSessionInvocations(fixture.sessionId) + ).find( + (candidate) => + candidate.opening.root.kind === 'goal' && candidate.opening.root.goalId === goalId, ); if (run) return run; await new Promise((resolve) => setImmediate(resolve)); @@ -748,25 +759,6 @@ async function waitForGoalRun( throw new Error('Goal continuation did not reach the root authority'); } -function runHeader(overrides: Partial): AgentRunHeader { - return { - runId: 'run-1', - invocationId: 'run-1', - sessionId: 'session-1', - turnId: 'turn-1', - status: 'created', - backendKind: 'fake', - llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', - llmConnectionSlug: 'fake', - modelId: 'fake-model', - cwd: '/workspace', - permissionMode: 'ask', - createdAt: 1, - updatedAt: 1, - ...overrides, - }; -} - function operationContext() { return { hostEpoch: 'goal-root-epoch', 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 9d780773b5..90daec709a 100644 --- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts @@ -19,6 +19,9 @@ import { deferred, withTimeout } from '@maka/core/test-only/async-primitives'; import assert from 'node:assert/strict'; +import { readInvocation, seedInvocation } from './fixtures/seed-invocation.js'; +import { runtimeInvocationOutcome } from '@maka/core/runtime-invocation'; +import { runtimeInvocationFailureClass } from '@maka/runtime/runtime-event-read-model'; import { randomUUID } from 'node:crypto'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -520,26 +523,31 @@ test('startup recovery closes a ScheduledTask Run after its pending fire was set text: 'Continue the scheduled work.', origin: { kind: 'scheduled_task', scheduledTaskId: 'task-settled-fire' }, }); - await fixture.stores.agentRunStore.createRun( - { - runId, - invocationId: runId, - sessionId: fixture.sessionId, - turnId, - status: 'created', - backendKind: 'fake', - llmConnectionId: session.llmConnectionId, - llmConnectionSlug: session.llmConnectionSlug, - modelId: session.model, - cwd: session.cwd, - scheduledTaskId: 'task-settled-fire', - permissionMode: session.permissionMode, - collaborationMode: session.collaborationMode, - createdAt: admittedAt, - updatedAt: admittedAt, + await seedInvocation(fixture.stores.runtimeEventStore, { + sessionId: fixture.sessionId, + invocationId: runId, + runId, + turnId, + openedAt: admittedAt, + opening: { + route: { + provenance: 'runtime', + backendKind: 'fake', + llmConnectionId: session.llmConnectionId!, + llmConnectionSlug: session.llmConnectionSlug, + modelId: session.model, + }, + configuration: { + cwd: session.cwd, + permissionMode: session.permissionMode, + collaborationMode: session.collaborationMode ?? 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + root: { kind: 'scheduled_task', scheduledTaskId: 'task-settled-fire' }, }, - { durable: true }, - ); + }); recovery = fixture.createRecoveryCoordinator(); await recovery.prepareRecovery(); @@ -547,9 +555,9 @@ test('startup recovery closes a ScheduledTask Run after its pending fire was set await fixture.manager.recoverInterruptedSessionsStrict(fixture.stores); await recovery.recover(); - const run = await fixture.stores.agentRunStore.readRun(fixture.sessionId, runId); - assert.equal(run.status, 'failed'); - assert.equal(run.failureClass, 'app_restarted'); + const run = await readInvocation(fixture.stores, fixture.sessionId, runId); + assert.equal(runtimeInvocationOutcome(run), 'failed'); + assert.equal(runtimeInvocationFailureClass(run), 'app_restarted'); assert.deepEqual(recovery.readRootState(fixture.sessionId), { kind: 'idle' }); } finally { await recovery?.close(); @@ -678,7 +686,7 @@ test('a failed exact Capability retry does not poison the parked continuation bi assert.equal(terminal.ok, true); if (terminal.ok) assert.equal(terminal.result.status, 'completed'); assert.equal( - (await fixture.stores.agentRunStore.listSessionRuns(fixture.sessionId)).filter( + (await fixture.stores.runtimeEventStore.listSessionInvocations(fixture.sessionId)).filter( (run) => run.turnId === pending.targetTurnId, ).length, 1, @@ -800,13 +808,13 @@ test('turn.start durably applies one exact per-Turn orchestration override', asy if (!started.ok) return; assertStartedTurn(started); - const run = await fixture.stores.agentRunStore.readRun( + const run = await readInvocation(fixture.stores, fixture.sessionId, started.result.turn.runId, ); - assert.equal(run.orchestrationMode, 'swarm'); - assert.equal(run.orchestrationSource, 'turn_override'); - assert.equal(run.agentSwarmAuthorization, 'turn_override'); + assert.equal(run.opening.configuration.orchestrationMode, 'swarm'); + assert.equal(run.opening.configuration.orchestrationSource, 'turn_override'); + assert.equal(run.opening.configuration.agentSwarmAuthorization, 'turn_override'); assert.deepEqual( (await fixture.stores.agentRunStore.readRootTurnAdmission(fixture.sessionId, input.turnId)) ?.turnOrchestration, @@ -1657,7 +1665,7 @@ test('linked child Sessions reject public safe-boundary continuation', async () assert.deepEqual(recoveryCoordinator.readRootState(child.id), { kind: 'reserved' }); assert.equal( - (await fixture.stores.agentRunStore.listSessionRuns(child.id)).some( + (await fixture.stores.runtimeEventStore.listSessionInvocations(child.id)).some( (run) => run.turnId === targetTurnId, ), false, @@ -1834,7 +1842,7 @@ test('worktree child Sessions reject roots outside managed child execution', asy (await fixture.stores.agentRunStore.listRootTurnAdmissionsForRecovery(child.id)).length, 1, ); - assert.equal((await fixture.stores.agentRunStore.listSessionRuns(child.id)).length, 1); + assert.equal((await fixture.stores.runtimeEventStore.listSessionInvocations(child.id)).length, 1); backend?.release(); await managed; @@ -1861,7 +1869,7 @@ test('worktree child Sessions reject roots outside managed child execution', asy () => recovery.recover(), /Unable to recover admitted Turn legacy-external-child-turn: operation_unavailable/, ); - assert.equal((await fixture.stores.agentRunStore.listSessionRuns(child.id)).length, 1); + assert.equal((await fixture.stores.runtimeEventStore.listSessionInvocations(child.id)).length, 1); } finally { backend?.release(); await recoveryCoordinator?.close(); @@ -2089,14 +2097,17 @@ test('Agent Graph supervisor wake waits for root idle and binds one durable exec source: 'host_api', }); - const graphRun = await fixture.stores.agentRunStore.readRun( + const graphRun = await readInvocation(fixture.stores, fixture.sessionId, graphAdmission!.runId, ); - assert.equal(graphRun.agentGraphWakeId, wakeId); - assert.equal(graphRun.agentGraphWakeAttemptId, attemptId); - assert.equal(graphRun.orchestrationMode, 'graph'); - assert.equal(graphRun.orchestrationSource, 'turn_override'); + assert.deepEqual(graphRun.opening.root, { + kind: 'agent_graph_supervisor_wake', + wakeId, + attemptId, + }); + assert.equal(graphRun.opening.configuration.orchestrationMode, 'graph'); + assert.equal(graphRun.opening.configuration.orchestrationSource, 'turn_override'); const userMessage = (await fixture.stores.sessionStore.readMessages(fixture.sessionId)).find( (message) => message.id === graphAdmission?.userMessageId, ); @@ -2342,7 +2353,7 @@ test('startup recovery replays an admitted context compact with its exact Run id assert.equal(stopped.ok, true); if (stopped.ok) assert.equal(stopped.result.status, 'cancelled'); assert.equal( - (await fixture.stores.agentRunStore.listSessionRuns(fixture.sessionId)).filter( + (await fixture.stores.runtimeEventStore.listSessionInvocations(fixture.sessionId)).filter( (run) => run.turnId === turnId, ).length, 1, @@ -2501,7 +2512,7 @@ test('Agent Graph supervisor wake revalidates freshness before durable root admi await fixture.stores.agentRunStore.listRootTurnAdmissionsForRecovery(fixture.sessionId), [], ); - assert.deepEqual(await fixture.stores.agentRunStore.listSessionRuns(fixture.sessionId), []); + assert.deepEqual(await fixture.stores.runtimeEventStore.listSessionInvocations(fixture.sessionId), []); assert.deepEqual(await fixture.stores.sessionStore.readMessages(fixture.sessionId), []); assert.equal(fixture.drainRequested(), false); } finally { @@ -2550,13 +2561,16 @@ test('Agent Graph supervisor recovery closes a durable admission that has no Run await recovery.prepareRecovery(); await recovery.recover(); - const run = await fixture.stores.agentRunStore.readRun(fixture.sessionId, runId); - assert.equal(run.status, 'failed'); - assert.equal(run.failureClass, 'app_restarted'); - assert.equal(run.agentGraphWakeId, wakeId); - assert.equal(run.agentGraphWakeAttemptId, attemptId); - assert.equal(run.orchestrationMode, 'graph'); - assert.equal(run.orchestrationSource, 'turn_override'); + const run = await readInvocation(fixture.stores, fixture.sessionId, runId); + assert.equal(runtimeInvocationOutcome(run), 'failed'); + assert.equal(runtimeInvocationFailureClass(run), 'app_restarted'); + assert.deepEqual(run.opening.root, { + kind: 'agent_graph_supervisor_wake', + wakeId, + attemptId, + }); + assert.equal(run.opening.configuration.orchestrationMode, 'graph'); + assert.equal(run.opening.configuration.orchestrationSource, 'turn_override'); const message = (await fixture.stores.sessionStore.readMessages(fixture.sessionId)).find( (candidate) => candidate.id === userMessageId, ); @@ -3003,7 +3017,7 @@ test('hosted linked child roots share admission, message, terminal, and stop aut const joinedInterrupted = await joinedInitial; assert.equal(interrupted.status, 'cancelled'); assert.deepEqual(joinedInterrupted, interrupted); - const interruptedRun = await stores.agentRunStore.readRun( + const interruptedRun = await readInvocation(stores, interrupted.childSessionId, interrupted.runId, ); @@ -3351,7 +3365,7 @@ test('shutdown contains a successor backend start rejected by Interaction drain' assert.equal(admissions.length, 2); const successor = admissions[1]; assert.ok(successor); - const run = await fixture.stores.agentRunStore.readRun(fixture.sessionId, successor.runId); + const run = await readInvocation(fixture.stores, fixture.sessionId, successor.runId); const runtimeEvents = await fixture.stores.runtimeEventStore.readImmutableRuntimeEvents( fixture.sessionId, successor.runId, @@ -3881,7 +3895,9 @@ async function assertSessionSuccessorCapabilityDegradation( const followup = admissions[1]; assert.ok(followup); assert.equal( - (await fixture.stores.agentRunStore.readRun(fixture.sessionId, followup.runId)).status, + runtimeInvocationOutcome( + await readInvocation(fixture.stores, fixture.sessionId, followup.runId), + ), 'completed', ); assert.equal(fixture.drainRequested(), false); @@ -4471,7 +4487,7 @@ test('post-start backend failure closes its owner without draining an unrelated runId: unrelatedStarted.result.turn.runId, }); assert.equal(unrelatedBackend.stopCount, 0); - const run = await fixture.stores.agentRunStore.readRun( + const run = await readInvocation(fixture.stores, fixture.sessionId, started.result.turn.runId, ); @@ -4649,7 +4665,7 @@ test('post-start backend AggregateError is contained after its failed terminal t await waitUntil(() => fixture.coordinator.readRootState(fixture.sessionId).kind === 'idle'); assert.equal(fixture.drainRequested(), false); - const run = await fixture.stores.agentRunStore.readRun( + const run = await readInvocation(fixture.stores, fixture.sessionId, started.result.turn.runId, ); @@ -4666,7 +4682,10 @@ test('post-start backend AggregateError is contained after its failed terminal t ); assert.equal(queried.ok, true); if (queried.ok && queried.result.status === 'failed') { - assert.equal(queried.result.failureMessage, run.failureMessage); + assert.equal( + queried.result.failureMessage, + run.terminalEvent?.content?.kind === 'error' ? run.terminalEvent.content.message : undefined, + ); assert.ok(queried.result.failureMessage); } @@ -4724,7 +4743,7 @@ test('post-start message owner cleanup failure drains after its failed terminal await waitUntil(() => fixture.drainRequested()); await waitUntil(() => fixture.coordinator.readRootState(fixture.sessionId).kind === 'idle'); - const run = await fixture.stores.agentRunStore.readRun( + const run = await readInvocation(fixture.stores, fixture.sessionId, started.result.turn.runId, ); @@ -4984,32 +5003,39 @@ async function seedPendingSafeBoundaryContinuation( const targetTurnId = `target-turn-${identitySuffix}`; const session = await fixture.stores.sessionStore.readHeaderSnapshot(fixture.sessionId); const createdAt = Date.now(); - const sourceRun = { - runId: sourceRunId, - invocationId: sourceInvocationId, + const sourceRun = await seedInvocation(fixture.stores.runtimeEventStore, { sessionId: fixture.sessionId, + invocationId: sourceInvocationId, + runId: sourceRunId, turnId: sourceTurnId, - status: 'created' as const, - backendKind: 'fake' as const, - llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', - llmConnectionSlug: 'fake', - modelId: 'fake-model', - cwd: session.cwd, - workspaceIdentity, - permissionMode: session.permissionMode, - collaborationMode: session.collaborationMode, - ...(sourceOrchestrationMode - ? { - orchestrationMode: sourceOrchestrationMode, - orchestrationSource: 'session' as const, - agentSwarmAuthorization: - sourceOrchestrationMode === 'swarm' ? ('session_mode' as const) : ('none' as const), - } - : {}), - createdAt, - updatedAt: createdAt, - }; - await fixture.stores.agentRunStore.createRun(sourceRun, { durable: true }); + openedAt: createdAt, + opening: { + route: { + provenance: 'runtime', + backendKind: 'fake', + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + llmConnectionSlug: 'fake', + modelId: 'fake-model', + }, + configuration: { + cwd: session.cwd, + workspaceIdentity, + permissionMode: session.permissionMode, + collaborationMode: session.collaborationMode ?? 'agent', + toolMode: 'direct', + ...(sourceOrchestrationMode + ? { + orchestrationMode: sourceOrchestrationMode, + orchestrationSource: 'session' as const, + agentSwarmAuthorization: + sourceOrchestrationMode === 'swarm' + ? ('session_mode' as const) + : ('none' as const), + } + : { orchestrationMode: 'default' as const, orchestrationSource: 'session' as const }), + }, + }, + }); await fixture.stores.runtimeEventStore.appendRuntimeEvent(fixture.sessionId, sourceRunId, { id: `source-user-${identitySuffix}`, sessionId: fixture.sessionId, @@ -5024,7 +5050,6 @@ async function seedPendingSafeBoundaryContinuation( }); const terminalAt = createdAt + 1; await commitTerminalRunWithRuntimeFact({ - runStore: fixture.stores.agentRunStore, runtimeEventStore: fixture.stores.runtimeEventStore, newId: randomUUID, sessionId: fixture.sessionId, diff --git a/packages/runtime-host/src/__tests__/session-revision-graph-references.test.ts b/packages/runtime-host/src/__tests__/session-revision-graph-references.test.ts index 3d09d00e3b..6debd5c27e 100644 --- a/packages/runtime-host/src/__tests__/session-revision-graph-references.test.ts +++ b/packages/runtime-host/src/__tests__/session-revision-graph-references.test.ts @@ -19,7 +19,8 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import type { AgentRunHeader } from '@maka/core/agent-run'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; +import { testInvocationRecord } from './fixtures/seed-invocation.js'; import type { SessionHeader, StoredMessage } from '@maka/core/session'; import { agentGraphIdForRootSession } from '@maka/runtime/stream-graph-coordinator'; import { collectConversationCopyLinkedChildReferences } from '@maka/runtime/conversation-copy'; @@ -199,7 +200,7 @@ test('Agent Graph revision references reject incomplete or mismatched provenance }, { name: 'active child Run', - input: { runs: [agentRun({ status: 'running', completedAt: undefined })] }, + input: { runs: [agentRun({ status: 'running' })] }, code: 'session_busy', }, { @@ -321,7 +322,7 @@ interface PrepareOverrides { readonly messages?: readonly StoredMessage[]; readonly archivedResults?: readonly string[]; readonly sessionHeaders?: readonly SessionHeader[]; - readonly runs?: readonly AgentRunHeader[]; + readonly runs?: readonly RuntimeInvocationRecord[]; readonly sessionGraphState?: 'absent' | 'live' | 'terminal'; readonly graphState?: 'absent' | 'live' | 'terminal'; readonly artifactTurnId?: string; @@ -347,8 +348,8 @@ async function prepare(overrides: PrepareOverrides = {}) { }), }, { - agentRunStore: { - listSessionRuns: async () => overrides.runs ?? [agentRun()], + runtimeEventStore: { + listSessionInvocations: async () => overrides.runs ?? [agentRun()], }, artifacts: { getInSession: async (sessionId, artifactId) => ({ @@ -508,22 +509,30 @@ function childHeader( }; } -function agentRun(overrides: Partial = {}): AgentRunHeader { - return { - runId: CHILD_RUN_ID, - invocationId: 'child-invocation', - sessionId: CHILD_SESSION_ID, - turnId: CHILD_TURN_ID, - status: 'completed', - backendKind: 'fake', - llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', - llmConnectionSlug: 'fake', - modelId: 'fake-model', - cwd: '/workspace', - permissionMode: 'ask', - createdAt: 1, - updatedAt: 2, - completedAt: 2, - ...overrides, +function agentRun( + overrides: { + runId?: string; + turnId?: string; + status?: 'completed' | 'failed' | 'cancelled' | 'running'; + resumedFromRunId?: string; + retriedFromRunId?: string; + } = {}, +): RuntimeInvocationRecord { + const status = overrides.status ?? 'completed'; + const lineage = { + ...(overrides.resumedFromRunId ? { resumedFromRunId: overrides.resumedFromRunId } : {}), + ...(overrides.retriedFromRunId ? { retriedFromRunId: overrides.retriedFromRunId } : {}), }; + return testInvocationRecord({ + sessionId: CHILD_SESSION_ID, + runId: overrides.runId ?? CHILD_RUN_ID, + turnId: overrides.turnId ?? CHILD_TURN_ID, + invocationId: overrides.runId ?? 'child-invocation', + openedAt: 1, + closedAt: 2, + ...(status === 'running' + ? {} + : { outcome: status === 'cancelled' ? ('aborted' as const) : status }), + ...(Object.keys(lineage).length > 0 ? { opening: { lineage } } : {}), + }); } diff --git a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts index bf5f2de201..18f0f4f1ef 100644 --- a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts @@ -27,7 +27,7 @@ import { join } from 'node:path'; import { test } from 'node:test'; import { decodeCanonicalToolResultContent } from '@maka/core/tool-result-record-schema'; import { type AgentGraphOperatorProvisionRequest } from '@maka/core/agent-graph-topology'; -import { type AgentRunHeader } from '@maka/core/agent-run'; +import { seedInvocation, type SeedInvocationInput } from './fixtures/seed-invocation.js'; import { type RuntimeEvent } from '@maka/core/runtime-event'; import { WORKHUB_COORDINATION_SESSION_ID } from '@maka/core/session'; import { @@ -866,28 +866,35 @@ async function seedSource( 'continuation-parent-invocation', 'continuation-parent-turn', ); - const continuationChild: AgentRunHeader = { - ...agentRunHeader( - root, - continuationSource.id, - 'continuation-child-run', - 'continuation-child-invocation', - 'continuation-child-turn', - ), - parentRunId: continuationParent.runId, - agentId: 'child-agent', - agentName: 'Child Agent', - retriedFromRunId: continuationParent.runId, - retriedFromTurnId: continuationParent.turnId, - continuationSource: { - sourceInvocationId: continuationParent.invocationId!, - sourceRunId: continuationParent.runId, - sourceTurnId: continuationParent.turnId, - sourceRuntimeEventHighWater: 1, + const continuationChildBase = agentRunHeader( + root, + continuationSource.id, + 'continuation-child-run', + 'continuation-child-invocation', + 'continuation-child-turn', + ); + const continuationChild: SeedInvocationInput = { + ...continuationChildBase, + opening: { + ...continuationChildBase.opening, + source: { + kind: 'continuation', + sourceInvocationId: continuationParent.invocationId!, + sourceRunId: continuationParent.runId, + sourceTurnId: continuationParent.turnId, + sourceRuntimeEventHighWater: 1, + }, + lineage: { + parentRunId: continuationParent.runId, + agentId: 'child-agent', + agentName: 'Child Agent', + retriedFromRunId: continuationParent.runId, + retriedFromTurnId: continuationParent.turnId, + }, }, }; for (const run of [continuationParent, continuationChild]) { - await execution.agentRunStore.createRun(run); + await seedInvocation(execution.runtimeEventStore, run); if (run.runId === continuationParent.runId) { await execution.runtimeEventStore.appendRuntimeEvent( run.sessionId, @@ -909,16 +916,19 @@ async function seedSource( }), ); } - const persistedContinuationRuns = await execution.agentRunStore.listSessionRuns( + const persistedContinuationRuns = await execution.runtimeEventStore.listSessionInvocations( continuationSource.id, ); const persistedContinuationChild = persistedContinuationRuns.find( (run) => run.runId === continuationChild.runId, ); - assert.equal(persistedContinuationChild?.agentId, continuationChild.agentId); + assert.equal( + persistedContinuationChild?.opening.lineage?.agentId, + continuationChild.opening?.lineage?.agentId, + ); assert.deepEqual( - persistedContinuationChild?.continuationSource, - continuationChild.continuationSource, + persistedContinuationChild?.opening.source, + continuationChild.opening?.source, ); const artifact = await artifacts.create({ id: 'source-artifact', @@ -1002,18 +1012,18 @@ async function seedSource( const sourceRuns = [ agentRunHeader(root, source.id, 'run-turn-1', 'invocation-turn-1', 'turn-1'), agentRunHeader(root, source.id, 'run-turn-2', 'invocation-turn-2', 'turn-2'), - { - ...agentRunHeader( + withParentRun( + agentRunHeader( root, source.id, 'legacy-child-run', 'legacy-child-invocation', 'legacy-child-turn', ), - parentRunId: 'run-turn-1', - }, + 'run-turn-1', + ), ]; - for (const run of sourceRuns) await execution.agentRunStore.createRun(run); + for (const run of sourceRuns) await seedInvocation(execution.runtimeEventStore, run); const sourceRuntimeEvents = [ runtimeEvent(source.id, 'run-turn-1', 'invocation-turn-1', 'turn-1', { id: 'user-1', @@ -1224,7 +1234,8 @@ async function seedSource( source: 'tool_result', now: 3, }); - await execution.agentRunStore.createRun( + await seedInvocation( + execution.runtimeEventStore, agentRunHeader( root, graphChild.header.id, @@ -1328,7 +1339,7 @@ async function seedSource( 'linked-after-turn', ), ]) { - await execution.agentRunStore.createRun(run); + await seedInvocation(execution.runtimeEventStore, run); } const graphRootEvents = [ runtimeEvent(linkedChildSource.id, 'graph-root-run', 'graph-root-invocation', 'linked-turn', { @@ -1486,18 +1497,18 @@ async function seedSource( 'archived-owned-parent-invocation', 'archived-owned-turn', ), - { - ...agentRunHeader( + withParentRun( + agentRunHeader( root, archivedOwnedSource.id, 'archived-owned-child-run', 'archived-owned-child-invocation', 'archived-owned-child-turn', ), - parentRunId: 'archived-owned-parent-run', - }, + 'archived-owned-parent-run', + ), ]; - for (const run of archivedOwnedRuns) await execution.agentRunStore.createRun(run); + for (const run of archivedOwnedRuns) await seedInvocation(execution.runtimeEventStore, run); const archivedOwnedRuntimeEvents = [ runtimeEvent( archivedOwnedSource.id, @@ -1719,13 +1730,13 @@ async function verifyDurableBranch( .sort(), ['Legacy child task', 'Retained task'], ); - const copiedRuns = await execution.agentRunStore.listSessionRuns(branchSessionId); + const copiedRuns = await execution.runtimeEventStore.listSessionInvocations(branchSessionId); assert.equal(copiedRuns.length, 2); const copiedChild = copiedRuns.find((run) => run.turnId === 'legacy-child-turn'); const copiedParent = copiedRuns.find((run) => run.turnId === 'turn-1'); assert.ok(copiedChild); assert.ok(copiedParent); - assert.equal(copiedChild.parentRunId, copiedParent.runId); + assert.equal(copiedChild.opening.lineage?.parentRunId, copiedParent.runId); const copiedProjectionResult = ( await execution.runtimeEventStore.readRuntimeEvents(branchSessionId, copiedParent.runId) ).find((event) => event.content?.kind === 'function_response'); @@ -1782,7 +1793,7 @@ async function verifyDurableBranch( ); assert.equal((await artifacts.listPage('revision-target', { offset: 0, limit: 10 })).total, 0); assert.deepEqual(await todos.readOrBootstrap('revision-target'), { items: [] }); - assert.deepEqual(await execution.agentRunStore.listSessionRuns('revision-target'), []); + assert.deepEqual(await execution.runtimeEventStore.listSessionInvocations('revision-target'), []); await assert.rejects( () => execution.sessionStore.readHeaderSnapshot('revision-target'), /not found/i, @@ -1833,7 +1844,7 @@ async function verifyDurableBranch( ), ); await assertCopiedUpload(activeSourceSideConversationTargetId); - const sideConversationRuns = await execution.agentRunStore.listSessionRuns( + const sideConversationRuns = await execution.runtimeEventStore.listSessionInvocations( graphSideConversationTargetId, ); const sideConversationRun = sideConversationRuns.find((run) => run.turnId === 'linked-turn'); @@ -1867,7 +1878,7 @@ async function verifyDurableBranch( text: 'graph child result', }, ); - const archivedSideConversationRuns = await execution.agentRunStore.listSessionRuns( + const archivedSideConversationRuns = await execution.runtimeEventStore.listSessionInvocations( archivedSideConversationTargetId, ); const archivedSideConversationChildRun = archivedSideConversationRuns.find( @@ -1919,7 +1930,7 @@ async function verifyDurableBranch( assert.equal(graphResult.content.items[0]?.childSessionId, graphChildSessionId); assert.equal(graphResult.content.items[0]?.runId, 'graph-child-run'); assert.deepEqual(graphResult.content.items[0]?.artifactIds, ['graph-child-artifact']); - const graphRevisionRuns = await execution.agentRunStore.listSessionRuns(graphRevisionTargetId); + const graphRevisionRuns = await execution.runtimeEventStore.listSessionInvocations(graphRevisionTargetId); const graphRevisionRun = graphRevisionRuns.find((run) => run.turnId === 'linked-turn'); assert.ok(graphRevisionRun); const graphRuntimeResult = ( @@ -2101,28 +2112,41 @@ function operationError(code: RuntimeHostOperationError['code']) { error instanceof RuntimeHostOperationError && error.code === code; } +/** The same seed input, with the lineage edge back to the run that spawned it. */ +function withParentRun(input: SeedInvocationInput, parentRunId: string): SeedInvocationInput { + return { ...input, opening: { ...input.opening, lineage: { parentRunId } } }; +} + function agentRunHeader( cwd: string, sessionId: string, runId: string, invocationId: string, turnId: string, -): AgentRunHeader { +): SeedInvocationInput { return { + sessionId, runId, invocationId, - sessionId, turnId, - status: 'completed', - backendKind: 'fake', - llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', - llmConnectionSlug: 'fake', - modelId: 'fake-model', - cwd, - permissionMode: 'ask', - createdAt: 1, - updatedAt: 5, - completedAt: 5, + openedAt: 1, + opening: { + route: { + provenance: 'runtime', + backendKind: 'fake', + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + llmConnectionSlug: 'fake', + modelId: 'fake-model', + }, + configuration: { + cwd, + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + }, }; } diff --git a/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts b/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts index c34c30db73..3892ee79b2 100644 --- a/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts +++ b/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts @@ -22,7 +22,8 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; -import type { AgentRunHeader } from '@maka/core/agent-run'; +import { seedInvocation, testInvocationOpening } from './fixtures/seed-invocation.js'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { type ExecutionStoresWriter, @@ -55,7 +56,12 @@ test('keeps durable history separate from the canonical active overlay', async ( ts: 1, kind: 'session_start', }); - await stores.agentRunStore.createRun(runHeader(session.id)); + await seedInvocation(stores.runtimeEventStore, { + sessionId: session.id, + runId: 'run-1', + turnId: 'turn-1', + openedAt: 1, + }); await stores.runtimeEventStore.appendRuntimeEvent( session.id, 'run-1', @@ -237,8 +243,9 @@ test('stops scanning a control-only ledger at the cumulative immutable event lim ); let scanned = 0; const stores = { - agentRunStore: { readRun: async () => runHeader(sessionId) }, + agentRunStore: {}, runtimeEventStore: { + listSessionInvocations: async () => [testInvocation(sessionId)], readRuntimeEventsBounded: async () => ({ status: 'limit_exceeded' as const }), scanRuntimeEvents: async ( _sessionId: string, @@ -289,8 +296,9 @@ test('stops an oversized active projection before retaining the full RuntimeEven ); let visited = 0; const stores = { - agentRunStore: { readRun: async () => runHeader(sessionId) }, + agentRunStore: {}, runtimeEventStore: { + listSessionInvocations: async () => [testInvocation(sessionId)], scanRuntimeEvents: async ( _sessionId: string, _runId: string, @@ -322,23 +330,6 @@ test('stops an oversized active projection before retaining the full RuntimeEven assert.equal(visited, 8_193); }); -function runHeader(sessionId: string): AgentRunHeader { - return { - runId: 'run-1', - invocationId: 'run-1', - sessionId, - turnId: 'turn-1', - status: 'running', - backendKind: 'fake', - llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', - llmConnectionSlug: 'fake', - modelId: 'fake-model', - cwd: '/tmp', - permissionMode: 'ask', - createdAt: 1, - updatedAt: 1, - }; -} function runtimeEvent(sessionId: string, overrides: Partial): RuntimeEvent { return { @@ -354,3 +345,14 @@ function runtimeEvent(sessionId: string, overrides: Partial): Runt ...overrides, }; } + +function testInvocation(sessionId: string): RuntimeInvocationRecord { + return { + sessionId, + invocationId: 'run-1', + runId: 'run-1', + turnId: 'turn-1', + openedAt: 1, + opening: testInvocationOpening(), + }; +} diff --git a/packages/runtime-host/src/server/canonical-turn-snapshot.ts b/packages/runtime-host/src/server/canonical-turn-snapshot.ts index bdf7a3ae17..36ef68eef0 100644 --- a/packages/runtime-host/src/server/canonical-turn-snapshot.ts +++ b/packages/runtime-host/src/server/canonical-turn-snapshot.ts @@ -17,17 +17,17 @@ * under the License. */ -import type { AgentRunHeader } from '@maka/core/agent-run'; import { type ContextCompactionOutcome } from '@maka/core/events'; import { truncateUtf8 } from '@maka/core/diagnostic-log'; import { redactSecrets } from '@maka/core/redaction'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import { classifyTerminalRuntimeLedger } from '@maka/runtime/terminal-run-commit'; import type { ExecutionStoresWriter } from '@maka/storage/execution-stores'; import { TURN_FAILURE_MESSAGE_MAX_BYTES, type TurnSnapshot } from '../protocol/index.js'; type CanonicalTurnStores = Pick< ExecutionStoresWriter<'interactive'>, - 'agentRunStore' | 'runtimeEventStore' + 'runtimeEventStore' | 'interactionStore' | 'sessionStore' >; export interface CanonicalTurnIdentity { @@ -39,19 +39,16 @@ export interface CanonicalTurnIdentity { export async function readCanonicalTurnSnapshot( stores: CanonicalTurnStores, identity: CanonicalTurnIdentity, - knownRun?: AgentRunHeader, + knownRun?: RuntimeInvocationRecord, ): Promise { const { sessionId, turnId, runId } = identity; - const run = knownRun ?? (await readRunIfPresent(stores, sessionId, runId)); + const run = knownRun ?? (await readInvocationIfPresent(stores, sessionId, runId)); if (!run) return { sessionId, turnId, runId, status: 'admitted' }; if (run.turnId !== turnId) { - throw new Error('Admitted Turn identity does not match its Run header'); + throw new Error('Admitted Turn identity does not match its invocation'); } - const [runEvents, runtimeEvents] = await Promise.all([ - stores.agentRunStore.readEvents(sessionId, runId), - stores.runtimeEventStore.readImmutableRuntimeEvents(sessionId, runId), - ]); + const runtimeEvents = await stores.runtimeEventStore.readImmutableRuntimeEvents(sessionId, runId); const terminal = classifyTerminalRuntimeLedger(run, runtimeEvents); if (terminal.kind === 'fact') { const fact = terminal.fact; @@ -101,13 +98,26 @@ export async function readCanonicalTurnSnapshot( if (terminal.kind !== 'none') { throw new Error('Runtime ledger does not contain one canonical terminal fact'); } - if (run.status === 'completed' || run.status === 'failed' || run.status === 'cancelled') { - throw new Error('Terminal Run header has no canonical terminal RuntimeEvent'); - } - if (run.status !== 'created' && !runEvents.some((event) => event.type === 'run_started')) { - throw new Error('Non-created Run has no durable start fact'); - } - return { sessionId, turnId, runId, status: run.status }; + // No terminal event means the run is still open. Whether it is parked is the + // pending-interaction store's answer, not something the run restates. + const parked = await hasPendingInteraction(stores, sessionId, runId); + return { sessionId, turnId, runId, status: parked ? 'waiting_for_user' : 'running' }; +} + +/** Is this run waiting on a request the user has not answered? */ +async function hasPendingInteraction( + stores: CanonicalTurnStores, + sessionId: string, + runId: string, +): Promise { + const [interactions, boundaries] = await Promise.all([ + stores.interactionStore.listSessionPending(sessionId), + stores.sessionStore.listPendingSandboxBoundaryRequests(sessionId), + ]); + return ( + interactions.some((request) => request.runId === runId) || + boundaries.some((request) => request.runId === runId) + ); } function readContextCompactionOutcome(value: unknown): ContextCompactionOutcome | undefined { @@ -136,13 +146,15 @@ export function worstCaseFailedTurnSnapshot(identity: CanonicalTurnIdentity): Tu }; } -async function readRunIfPresent( +async function readInvocationIfPresent( stores: CanonicalTurnStores, sessionId: string, runId: string, -): Promise { +): Promise { try { - return await stores.agentRunStore.readRun(sessionId, runId); + return (await stores.runtimeEventStore.listSessionInvocations(sessionId)).find( + (invocation) => invocation.runId === runId, + ); } catch (error) { if (isMissingFile(error)) return undefined; throw error; diff --git a/packages/runtime-host/src/server/client-capability-coordinator.ts b/packages/runtime-host/src/server/client-capability-coordinator.ts index ba69bda1f6..0a82ffd537 100644 --- a/packages/runtime-host/src/server/client-capability-coordinator.ts +++ b/packages/runtime-host/src/server/client-capability-coordinator.ts @@ -26,7 +26,7 @@ import { type McpToolProvider, } from '@maka/runtime/mcp-tools'; import { type MakaTool } from '@maka/runtime/tool-runtime'; -import type { RootExecutionDescriptor } from '@maka/core/agent-run'; +import type { RootExecutionDescriptor } from '@maka/core/runtime-invocation'; import { clientCapabilityScopeIdentity, type ClientCapabilityGrantTarget, diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 27d3e69c4b..2fbaba0ba6 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -26,6 +26,10 @@ import type { RuntimeExecutionConnection } from '@maka/core/llm-connections'; import { generalizedErrorMessage } from '@maka/core/redaction'; import { emptyPlanSessionState } from '@maka/core/plan'; import type { PermissionMode } from '@maka/core/permission'; +import { + runtimeInvocationOutcome, + type RuntimeInvocationRecord, +} from '@maka/core/runtime-invocation'; import { isDeepResearchSession, type SessionHeader, @@ -701,10 +705,18 @@ export async function createExecutionRuntimeHostComposition( stores.runtimeEventStore.readSessionRuntimeEventEntries(sessionId), }, historyCompaction: { - readLatestCheckpoint: (sessionId) => - loadLatestHistoryCompactCheckpointFromRunLedger(stores.agentRunStore, sessionId), - readCheckpoints: (sessionId) => - loadHistoryCompactCheckpointsFromRunLedger(stores.agentRunStore, sessionId), + readLatestCheckpoint: async (sessionId) => + loadLatestHistoryCompactCheckpointFromRunLedger( + stores.agentRunStore, + sessionId, + await sessionRunIds(stores.runtimeEventStore, sessionId), + ), + readCheckpoints: async (sessionId) => + loadHistoryCompactCheckpointsFromRunLedger( + stores.agentRunStore, + sessionId, + await sessionRunIds(stores.runtimeEventStore, sessionId), + ), }, model: createHostMemoryExtractionModel({ runtimePolicy: runtimePolicyStores, @@ -951,7 +963,6 @@ export async function createExecutionRuntimeHostComposition( requestDrain: context.requestDrain, }), readModel: new RuntimeReadModel({ - runStore: stores.agentRunStore, runtimeEventStore: stores.runtimeEventStore, projectionCache: stores.sessionStore, canonicalPermissionOutcomes, @@ -1016,7 +1027,7 @@ export async function createExecutionRuntimeHostComposition( graph.hasLiveSessionState(sessionId), hasLiveLinkedDescendantState( requireSessionManager(manager), - stores.agentRunStore, + stores.runtimeEventStore, sessionId, async (descendantSessionId) => (await runtimeResources!.hasLiveSessionResources(descendantSessionId)) || @@ -1071,7 +1082,6 @@ export async function createExecutionRuntimeHostComposition( }); graphCoordinator = new AgentGraphCoordinator({ sessionStore: stores.sessionStore, - runStore: stores.agentRunStore, runtimeEventStore: stores.runtimeEventStore, controlStore: openedGraphControlStore, epochStore: openedGraphControlStore, @@ -1263,15 +1273,24 @@ export async function createExecutionRuntimeHostComposition( startTurn: (sessionId, input, _activity, abortSignal, isCurrent) => graphExecutions.run(sessionId, input, abortSignal, isCurrent), inspectAttempt: async (rootSessionId, attemptId, turnId) => { - const runs = (await stores.agentRunStore.listSessionRuns(rootSessionId)).filter( - (run) => run.agentGraphWakeAttemptId === attemptId && run.turnId === turnId, - ); + const runs = ( + await stores.runtimeEventStore.listSessionInvocations(rootSessionId) + ).filter((run) => { + const root = run.opening.root; + return ( + root.kind === 'agent_graph_supervisor_wake' && + root.attemptId === attemptId && + run.turnId === turnId + ); + }); if (runs.length > 1) { throw new Error( `Agent graph supervisor wake attempt ${attemptId} has multiple AgentRuns`, ); } - return runs[0]?.status ?? 'missing'; + const attempt = runs[0]; + if (!attempt) return 'missing'; + return runtimeInvocationOutcome(attempt) ?? 'running'; }, recoverContextOverflow: (rootSessionId, { abortSignal }) => graphExecutions.recoverContextOverflow(rootSessionId, randomUUID(), abortSignal), @@ -2236,11 +2255,23 @@ function requireGoal(coordinator: HostGoalCoordinator | undefined): HostGoalCoor return coordinator; } +/** Every run this Session has opened, named by the event spine that defines it. */ +async function sessionRunIds( + runtimeEventStore: SessionInvocationLister, + sessionId: string, +): Promise { + return (await runtimeEventStore.listSessionInvocations(sessionId)).map( + (invocation) => invocation.runId, + ); +} + +interface SessionInvocationLister { + listSessionInvocations(sessionId: string): Promise; +} + async function hasLiveLinkedDescendantState( manager: SessionManager, - runStore: { - listSessionRuns(sessionId: string): Promise; - }, + runtimeEventStore: SessionInvocationLister, rootSessionId: string, hasLiveSessionState: (sessionId: string) => Promise, ): Promise { @@ -2254,20 +2285,12 @@ async function hasLiveLinkedDescendantState( seen.add(child.id); pending.push(child.id); const [runs, liveState] = await Promise.all([ - runStore.listSessionRuns(child.id), + runtimeEventStore.listSessionInvocations(child.id), hasLiveSessionState(child.id), ]); if (liveState) return true; - if ( - runs.some( - (run) => - run.status === 'created' || - run.status === 'running' || - run.status === 'waiting_for_user', - ) - ) { - return true; - } + // A run whose events never closed it is still live. + if (runs.some((run) => runtimeInvocationOutcome(run) === undefined)) return true; } } return false; diff --git a/packages/runtime-host/src/server/execution-inspect-coordinator.ts b/packages/runtime-host/src/server/execution-inspect-coordinator.ts index 3419a17a2c..49961dba32 100644 --- a/packages/runtime-host/src/server/execution-inspect-coordinator.ts +++ b/packages/runtime-host/src/server/execution-inspect-coordinator.ts @@ -23,6 +23,10 @@ import { type ModelCallAttempt, } from '@maka/core/model-call-attempt'; import type { RuntimeEvent } from '@maka/core/runtime-event'; +import type { + RuntimeInvocationPageCursor, + RuntimeInvocationRecord, +} from '@maka/core/runtime-invocation'; import { inspectAgentRunDocument, inspectSessionDocument } from '@maka/runtime/execution-inspect'; import { projectSessionTrace } from '@maka/runtime/session-trace-projection'; import { @@ -53,14 +57,16 @@ interface InspectStores { readonly sessionStore: Pick; readonly agentRunStore: Pick< ExecutionAgentRunReader, - | 'readRun' - | 'listSessionRunsBounded' - | 'listSessionRunsPage' - | 'readEventsBounded' - | 'readEventsByTypeBounded' - | 'readRootTurnAdmission' + 'readEventsBounded' | 'readEventsByTypeBounded' | 'readRootTurnAdmission' + >; + readonly runtimeEventStore: Pick< + ExecutionRuntimeEventReader, + | 'readRuntimeEventsBounded' + | 'listSessionInvocations' + | 'listSessionInvocationsBounded' + | 'listSessionInvocationsPage' + | 'readInvocation' >; - readonly runtimeEventStore: Pick; } /** Host-owned, payload-safe read model for live Interactive execution evidence. */ @@ -118,19 +124,22 @@ export class HostExecutionInspectCoordinator { sessionId: string, agentRunId: string, ): Promise { - let header; + let invocation; try { - header = await this.#stores.agentRunStore.readRun(sessionId, agentRunId); + invocation = (await this.#stores.runtimeEventStore.listSessionInvocations(sessionId)).find( + (candidate) => candidate.runId === agentRunId, + ); } catch (error) { if (isMissing(error)) return undefined; throw error; } + if (!invocation) return undefined; const document: AgentRunInspectDocument = await inspectAgentRunDocument( ...this.#budgetedReaders('AgentRun'), { sessionId, agentRunId, - header, + invocation, isFatalReadError: isInspectQueryTooLargeError, }, ); @@ -145,7 +154,7 @@ export class HostExecutionInspectCoordinator { if (isMissing(error)) return undefined; throw error; } - const runPage = await this.#stores.agentRunStore.listSessionRunsBounded( + const runPage = await this.#stores.runtimeEventStore.listSessionInvocationsBounded( sessionId, EXECUTION_INSPECT_SESSION_MAX_RUNS, ); @@ -157,15 +166,12 @@ export class HostExecutionInspectCoordinator { const readers = this.#budgetedReaders('Session'); const document: SessionInspectDocument = await inspectSessionDocument( { readHeader: (id) => this.#stores.sessionStore.readHeaderSnapshot(id) }, - { - ...readers[0], - listSessionRuns: async () => [...runPage.runs], - }, + readers[0], readers[1], sessionId, { header, - runHeaders: runPage.runs, + invocations: runPage.invocations, isFatalReadError: isInspectQueryTooLargeError, }, ); @@ -186,17 +192,20 @@ export class HostExecutionInspectCoordinator { } const before = input.kind === 'session_trace_continue' ? decodeTraceCursor(input.cursor) : undefined; - const runPage = await this.#stores.agentRunStore.listSessionRunsPage(input.sessionId, { - ...(before ? { before } : {}), - limit: EXECUTION_INSPECT_TRACE_PAGE_MAX_TURNS, - }); + const runPage = await this.#stores.runtimeEventStore.listSessionInvocationsPage( + input.sessionId, + { + ...(before ? { before } : {}), + limit: EXECUTION_INSPECT_TRACE_PAGE_MAX_TURNS, + }, + ); const budget = new InspectEvidenceBudget('Session'); const runtimeEvents: RuntimeEvent[] = []; const modelCallAttempts: ModelCallAttempt[] = []; let unreadableRecords = 0; let includedRuns = 0; let acceptedPage: ExecutionInspectQueryResult | undefined; - for (const run of runPage.runs) { + for (const run of runPage.invocations) { let evidence: { readonly runtimeEvents: RuntimeEvent[]; readonly modelCallAttempts: ModelCallAttempt[]; @@ -209,7 +218,7 @@ export class HostExecutionInspectCoordinator { if (includedRuns > 0) break; return oversizedTracePage( input.sessionId, - tracePageCursorAfter(runPage.runs, 1, runPage.nextCursor), + tracePageCursorAfter(runPage.invocations, 1, runPage.nextCursor), ); } const candidateRuntimeEvents = [...runtimeEvents, ...evidence.runtimeEvents]; @@ -227,7 +236,7 @@ export class HostExecutionInspectCoordinator { const candidatePage: ExecutionInspectQueryResult = { kind: 'session_trace_page', ...candidateTrace, - nextCursor: tracePageCursorAfter(runPage.runs, candidateRunCount, runPage.nextCursor), + nextCursor: tracePageCursorAfter(runPage.invocations, candidateRunCount, runPage.nextCursor), }; if ( candidateTrace.turns.length > EXECUTION_INSPECT_TRACE_PAGE_MAX_TURNS || @@ -236,7 +245,7 @@ export class HostExecutionInspectCoordinator { if (includedRuns === 0) { return oversizedTracePage( input.sessionId, - tracePageCursorAfter(runPage.runs, 1, runPage.nextCursor), + tracePageCursorAfter(runPage.invocations, 1, runPage.nextCursor), ); } break; @@ -255,7 +264,7 @@ export class HostExecutionInspectCoordinator { runtimeEvents: [], modelCallAttempts: [], }), - nextCursor: tracePageCursorAfter(runPage.runs, 0, runPage.nextCursor), + nextCursor: tracePageCursorAfter(runPage.invocations, 0, runPage.nextCursor), } ); } @@ -272,15 +281,19 @@ export class HostExecutionInspectCoordinator { } const admission = await this.#stores.agentRunStore.readRootTurnAdmission(sessionId, turnId); if (!admission) return undefined; + let run; try { - const run = await this.#stores.agentRunStore.readRun(sessionId, admission.runId); - if (run.turnId !== turnId) { - throw new InspectQueryInvalidError('Turn trace admission does not match its AgentRun'); - } + run = (await this.#stores.runtimeEventStore.listSessionInvocations(sessionId)).find( + (candidate) => candidate.runId === admission.runId, + ); } catch (error) { if (isMissing(error)) return undefined; throw error; } + if (!run) return undefined; + if (run.turnId !== turnId) { + throw new InspectQueryInvalidError('Turn trace admission does not match its AgentRun'); + } const evidence = await this.#readRunTraceEvidence( sessionId, admission.runId, @@ -344,8 +357,6 @@ export class HostExecutionInspectCoordinator { const budget = new InspectEvidenceBudget(label); return [ { - readRun: (sessionId: string, runId: string) => - this.#stores.agentRunStore.readRun(sessionId, runId), readEvents: (sessionId: string, runId: string) => budget.read((remaining) => this.#stores.agentRunStore.readEventsBounded(sessionId, runId, remaining), @@ -356,6 +367,10 @@ export class HostExecutionInspectCoordinator { budget.read((remaining) => this.#stores.runtimeEventStore.readRuntimeEventsBounded(sessionId, runId, remaining), ), + listSessionInvocations: (sessionId: string) => + this.#stores.runtimeEventStore.listSessionInvocations(sessionId), + readInvocation: (sessionId: string, invocationId: string) => + this.#stores.runtimeEventStore.readInvocation(sessionId, invocationId), }, ] as const; } @@ -416,21 +431,23 @@ function oversizedTracePage( } function tracePageCursorAfter( - runs: readonly { readonly runId: string; readonly createdAt: number }[], + invocations: readonly RuntimeInvocationRecord[], includedRuns: number, - sourceNextCursor: { readonly createdAt: number; readonly runId: string } | null, + sourceNextCursor: RuntimeInvocationPageCursor | null, ): string | null { - const last = runs[includedRuns - 1]; + const last = invocations[includedRuns - 1]; if (!last) return null; - const hasMore = includedRuns < runs.length || sourceNextCursor !== null; - return hasMore ? encodeTraceCursor({ createdAt: last.createdAt, runId: last.runId }) : null; + const hasMore = includedRuns < invocations.length || sourceNextCursor !== null; + return hasMore + ? encodeTraceCursor({ openedAt: last.openedAt, invocationId: last.invocationId }) + : null; } -function encodeTraceCursor(cursor: { readonly createdAt: number; readonly runId: string }): string { +function encodeTraceCursor(cursor: RuntimeInvocationPageCursor): string { return Buffer.from(JSON.stringify({ v: 1, ...cursor }), 'utf8').toString('base64url'); } -function decodeTraceCursor(cursor: string): { readonly createdAt: number; readonly runId: string } { +function decodeTraceCursor(cursor: string): RuntimeInvocationPageCursor { try { const value = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as Record< string, @@ -438,15 +455,15 @@ function decodeTraceCursor(cursor: string): { readonly createdAt: number; readon >; if ( value.v !== 1 || - typeof value.createdAt !== 'number' || - !Number.isFinite(value.createdAt) || - typeof value.runId !== 'string' || - !/^[A-Za-z0-9_-]{1,128}$/.test(value.runId) || + typeof value.openedAt !== 'number' || + !Number.isFinite(value.openedAt) || + typeof value.invocationId !== 'string' || + !/^[A-Za-z0-9_-]{1,128}$/.test(value.invocationId) || Object.keys(value).length !== 3 ) { throw new Error('invalid cursor'); } - return { createdAt: value.createdAt, runId: value.runId }; + return { openedAt: value.openedAt, invocationId: value.invocationId }; } catch { throw new InspectQueryInvalidError('Session trace continuation cursor is invalid'); } diff --git a/packages/runtime-host/src/server/host-session-availability.ts b/packages/runtime-host/src/server/host-session-availability.ts index 2fced24a0b..cd69d98945 100644 --- a/packages/runtime-host/src/server/host-session-availability.ts +++ b/packages/runtime-host/src/server/host-session-availability.ts @@ -17,7 +17,7 @@ * under the License. */ -import type { RootExecutionDescriptor } from '@maka/core/agent-run'; +import type { RootExecutionDescriptor } from '@maka/core/runtime-invocation'; import { isWorkHubCoordinationSession, isWorkHubCoordinationSessionId, diff --git a/packages/runtime-host/src/server/hosted-execution-authority.ts b/packages/runtime-host/src/server/hosted-execution-authority.ts index 41c23e5555..c8672e2f24 100644 --- a/packages/runtime-host/src/server/hosted-execution-authority.ts +++ b/packages/runtime-host/src/server/hosted-execution-authority.ts @@ -17,7 +17,8 @@ * under the License. */ -import type { RootExecutionDescriptor } from '@maka/core/agent-run'; +import type { BackendStopMode } from '@maka/core/backend-types'; +import type { RootExecutionDescriptor } from '@maka/core/runtime-invocation'; import type { MessageContent, SessionEvent } from '@maka/core/events'; import type { UserMessageInput } from '@maka/core/runtime-inputs'; import type { StopSessionInput } from '@maka/runtime/session-manager'; diff --git a/packages/runtime-host/src/server/hosted-execution-projection.ts b/packages/runtime-host/src/server/hosted-execution-projection.ts index 75a7d90c76..0d9cf57c30 100644 --- a/packages/runtime-host/src/server/hosted-execution-projection.ts +++ b/packages/runtime-host/src/server/hosted-execution-projection.ts @@ -19,10 +19,9 @@ import { invocationMatchesHostedRootExecution, - runtimeInvocationOpeningFromRunHeader, - type AgentRunHeader, type RootExecutionDescriptor, -} from '@maka/core/agent-run'; + type RuntimeInvocationRecord, +} from '@maka/core/runtime-invocation'; import { RuntimeMessageAuthorityInvariantError } from '@maka/runtime/message-authority'; import type { ExecutionStoresWriter } from '@maka/storage/execution-stores'; import { readCanonicalTurnSnapshot } from './canonical-turn-snapshot.js'; @@ -33,7 +32,7 @@ export class HostedExecutionProjectionReader { async read( execution: HostedExecutionRef, - knownRun?: AgentRunHeader, + knownRun?: RuntimeInvocationRecord, ): Promise { const run = knownRun ?? (await this.readRunIfPresent(execution.sessionId, execution.runId)); if (run && run.turnId !== execution.turnId) { @@ -44,21 +43,30 @@ export class HostedExecutionProjectionReader { return readCanonicalTurnSnapshot(this.stores, execution, run); } - async readRunIfPresent(sessionId: string, runId: string): Promise { + async readRunIfPresent( + sessionId: string, + runId: string, + ): Promise { try { - return await this.stores.agentRunStore.readRun(sessionId, runId); + return (await this.stores.runtimeEventStore.listSessionInvocations(sessionId)).find( + (invocation) => invocation.runId === runId, + ); } catch (error) { if (isMissingFile(error)) return undefined; throw error; } } - assertRunIdentity(run: AgentRunHeader, turnId: string, execution: RootExecutionDescriptor): void { + assertRunIdentity( + run: RuntimeInvocationRecord, + turnId: string, + execution: RootExecutionDescriptor, + ): void { assertRunMatchesExecution(run, turnId, execution); } async assertRunIdentityAndContinuation( - run: AgentRunHeader, + run: RuntimeInvocationRecord, turnId: string, execution: RootExecutionDescriptor, ): Promise { @@ -90,7 +98,7 @@ export class HostedExecutionProjectionReader { } function assertRunMatchesExecution( - run: AgentRunHeader, + run: RuntimeInvocationRecord, turnId: string, execution: RootExecutionDescriptor, ): void { @@ -99,6 +107,7 @@ function assertRunMatchesExecution( `Admitted Turn ${turnId} does not match Run ${run.runId}`, ); } + const lineage = run.opening.lineage ?? {}; switch (execution.kind) { case 'external_message': case 'workhub_coordination': @@ -110,34 +119,28 @@ function assertRunMatchesExecution( case 'goal': case 'agent_graph_supervisor_wake': case 'safe_boundary_continuation': - // Phase 2c hands this the invocation's own opening fact; until then the - // header is projected through the one mapping that owns that projection. - if ( - invocationMatchesHostedRootExecution( - { - invocationId: run.invocationId ?? run.runId, - opening: runtimeInvocationOpeningFromRunHeader(run), - }, - execution, - ) - ) { - return; - } + if (invocationMatchesHostedRootExecution(run, execution)) return; break; case 'linked_child_initial': case 'claimed_agent_graph_intent': assertTrustedAgentIdentity(run, turnId, execution); - if (run.resumedFromRunId === undefined && run.retriedFromRunId === undefined) return; + if (lineage.resumedFromRunId === undefined && lineage.retriedFromRunId === undefined) return; break; case 'linked_child_resume': assertTrustedAgentIdentity(run, turnId, execution); - if (run.resumedFromRunId === execution.sourceRunId && run.retriedFromRunId === undefined) { + if ( + lineage.resumedFromRunId === execution.sourceRunId && + lineage.retriedFromRunId === undefined + ) { return; } break; case 'linked_child_provider_retry': assertTrustedAgentIdentity(run, turnId, execution); - if (run.retriedFromRunId === execution.sourceRunId && run.resumedFromRunId === undefined) { + if ( + lineage.retriedFromRunId === execution.sourceRunId && + lineage.resumedFromRunId === undefined + ) { return; } break; @@ -150,7 +153,7 @@ function assertRunMatchesExecution( } function assertTrustedAgentIdentity( - run: AgentRunHeader, + run: RuntimeInvocationRecord, turnId: string, execution: Exclude< RootExecutionDescriptor, @@ -168,7 +171,8 @@ function assertTrustedAgentIdentity( } >, ): void { - if (run.agentId !== execution.agentId || run.agentName !== execution.agentName) { + const lineage = run.opening.lineage; + if (lineage?.agentId !== execution.agentId || lineage.agentName !== execution.agentName) { throw new RuntimeMessageAuthorityInvariantError( `Admitted Turn ${turnId} changed its trusted agent identity`, ); diff --git a/packages/runtime-host/src/server/hosted-execution-recovery.ts b/packages/runtime-host/src/server/hosted-execution-recovery.ts index 1fac7a4c72..82133c7e9d 100644 --- a/packages/runtime-host/src/server/hosted-execution-recovery.ts +++ b/packages/runtime-host/src/server/hosted-execution-recovery.ts @@ -18,7 +18,10 @@ */ import { isDeepStrictEqual } from 'node:util'; -import type { RootExecutionDescriptor } from '@maka/core/agent-run'; +import { + runtimeInvocationOutcome, + type RootExecutionDescriptor, +} from '@maka/core/runtime-invocation'; import { messageContentsEqual, normalizeMessageContent, @@ -60,7 +63,7 @@ export async function prepareHostedExecutionRecovery( for (const session of sessions) { const admissions = await input.rootAdmissions.recoverSession(session.id); const messages = await input.stores.sessionStore.readMessagesForRecovery(session.id); - const runs = await input.stores.agentRunStore.listSessionRunsForRecovery(session.id); + const runs = await input.stores.runtimeEventStore.listSessionInvocations(session.id); const runsById = new Map(runs.map((run) => [run.runId, run])); for (const run of runs) { await input.stores.agentRunStore.readEventsForRecovery(session.id, run.runId); @@ -80,7 +83,10 @@ export async function prepareHostedExecutionRecovery( ? (messageIndex.messagesById.get(admission.userMessageId) ?? []) : []; const executionContract = recoveryExecutionContract(admission.execution); - if (admission.execution.kind === 'scheduled_task' && (!run || !isTerminalRun(run.status))) { + if ( + admission.execution.kind === 'scheduled_task' && + (!run || runtimeInvocationOutcome(run) === undefined) + ) { if (!input.assertScheduledTaskAdmission) { throw new RuntimeMessageAuthorityInvariantError( 'ScheduledTask recovery admission has no canonical authority validator', @@ -443,10 +449,6 @@ function usesHostRecoveryClosure(execution: RootExecutionDescriptor): execution ); } -function isTerminalRun(status: string): boolean { - return status === 'completed' || status === 'failed' || status === 'cancelled'; -} - function indexRecoveryMessages(messages: readonly StoredMessage[]): RecoveryMessageIndex { const index: RecoveryMessageIndex = { userMessagesByTurnId: new Map(), diff --git a/packages/runtime-host/src/server/interactive-turn-coordinator.ts b/packages/runtime-host/src/server/interactive-turn-coordinator.ts index 79a0e4881c..54660dc2d4 100644 --- a/packages/runtime-host/src/server/interactive-turn-coordinator.ts +++ b/packages/runtime-host/src/server/interactive-turn-coordinator.ts @@ -19,7 +19,7 @@ import { createHash } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; -import type { RootExecutionDescriptor } from '@maka/core/agent-run'; +import type { RootExecutionDescriptor } from '@maka/core/runtime-invocation'; import { normalizeMessageContent, type MessageContent } from '@maka/core/events'; import type { SkillInvocationResult } from '@maka/core/skill-invocation'; import { RuntimeMessageAuthorityInvariantError } from '@maka/runtime/message-authority'; diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index f9c5f1278f..0060331933 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -20,7 +20,10 @@ import { createHash, randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; import type { BackendStopMode } from '@maka/core/backend-types'; -import type { AgentRunHeader, RootExecutionDescriptor } from '@maka/core/agent-run'; +import type { + RootExecutionDescriptor, + RuntimeInvocationRecord, +} from '@maka/core/runtime-invocation'; import { INLINE_REFERENCE_MAX_COUNT, messageContentDigest, @@ -2666,7 +2669,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { sessionId: string, turnId: string, runId: string, - knownRun?: AgentRunHeader, + knownRun?: RuntimeInvocationRecord, ): Promise { return this.executionProjection.read({ sessionId, turnId, runId }, knownRun); } @@ -2674,7 +2677,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { private async readRunIfPresent( sessionId: string, runId: string, - ): Promise { + ): Promise { return this.executionProjection.readRunIfPresent(sessionId, runId); } @@ -2686,11 +2689,8 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { const mode = admission.execution.kind === 'safe_boundary_continuation' ? (( - await this.stores.agentRunStore.readRun( - admission.sessionId, - admission.execution.sourceRunId, - ) - ).orchestrationMode ?? + await this.readRunIfPresent(admission.sessionId, admission.execution.sourceRunId) + )?.opening.configuration.orchestrationMode ?? resolveEffectiveOrchestration(session.orchestrationMode, undefined).mode) : resolveEffectiveOrchestration(session.orchestrationMode, admission.turnOrchestration) .mode; @@ -2716,7 +2716,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { } private async assertRunMatchesDurableExecution( - run: AgentRunHeader, + run: RuntimeInvocationRecord, turnId: string, execution: RootTurnAdmission['execution'], ): Promise { diff --git a/packages/runtime-host/src/server/session-revision-coordinator.ts b/packages/runtime-host/src/server/session-revision-coordinator.ts index dff61f5c56..3972b9baf8 100644 --- a/packages/runtime-host/src/server/session-revision-coordinator.ts +++ b/packages/runtime-host/src/server/session-revision-coordinator.ts @@ -445,7 +445,7 @@ export class HostSessionRevisionCoordinator { requests: linkedChildRequests, }, { - agentRunStore: this.#stores.agentRunStore, + runtimeEventStore: this.#stores.runtimeEventStore, artifacts: this.#artifacts, graph: this.options.graph, isSessionActive: this.options.isSessionActive, diff --git a/packages/runtime-host/src/server/session-revision-graph-references.ts b/packages/runtime-host/src/server/session-revision-graph-references.ts index 9cd3070324..d358f992df 100644 --- a/packages/runtime-host/src/server/session-revision-graph-references.ts +++ b/packages/runtime-host/src/server/session-revision-graph-references.ts @@ -17,7 +17,10 @@ * under the License. */ -import type { AgentRunHeader } from '@maka/core/agent-run'; +import { + runtimeInvocationOutcome, + type RuntimeInvocationRecord, +} from '@maka/core/runtime-invocation'; import { sessionRevisionFamilyId, type SessionHeader } from '@maka/core/session'; import { type AgentGraphCoordinator } from '@maka/runtime/stream-graph-coordinator'; import { @@ -42,8 +45,8 @@ export type AgentGraphRevisionReferencePreparation = type GraphReader = Pick; interface GraphRevisionDependencies { - readonly agentRunStore: { - listSessionRuns(sessionId: string): Promise; + readonly runtimeEventStore: { + listSessionInvocations(sessionId: string): Promise; }; readonly artifacts: Pick; readonly graph: GraphReader; @@ -176,7 +179,7 @@ export async function prepareAgentGraphRevisionReferences( } const references = new Map(); - const runsByChildSession = new Map>(); + const runsByChildSession = new Map>(); for (const request of requests) { const childSessionId = request.childSessionId; const child = headersById.get(childSessionId); @@ -204,16 +207,16 @@ export async function prepareAgentGraphRevisionReferences( let runsById = runsByChildSession.get(childSessionId); if (!runsById) { - let runs: readonly AgentRunHeader[]; + let runs: readonly RuntimeInvocationRecord[]; try { - runs = await dependencies.agentRunStore.listSessionRuns(childSessionId); + runs = await dependencies.runtimeEventStore.listSessionInvocations(childSessionId); } catch { return failure( 'operation_unavailable', 'Retained Agent Graph child lineage is unavailable', ); } - if (runs.some((run) => !isTerminalRunStatus(run.status))) { + if (runs.some((run) => runtimeInvocationOutcome(run) === undefined)) { return failure('session_busy', 'A retained Agent Graph child is not terminal'); } runsById = new Map(runs.map((run) => [run.runId, run])); @@ -296,29 +299,29 @@ function isTerminalRunStatus(status: string): boolean { function linkedResultStatusMatchesRun( request: ConversationCopyLinkedChildReference, - run: AgentRunHeader, + run: RuntimeInvocationRecord, ): boolean { + const outcome = runtimeInvocationOutcome(run); return ( - run.status === request.status || - (request.status === 'failed' && - request.failureClass === 'Timeout' && - run.status === 'cancelled') + outcome === request.status || + (request.status === 'failed' && request.failureClass === 'Timeout' && outcome === 'cancelled') ); } function traceChildRunLineage( - current: AgentRunHeader, - runsById: ReadonlyMap, + current: RuntimeInvocationRecord, + runsById: ReadonlyMap, childSessionId: string, ): { readonly runIds: ReadonlySet; readonly turnIds: ReadonlySet } | undefined { const runIds = new Set(); const turnIds = new Set(); - let cursor: AgentRunHeader | undefined = current; + let cursor: RuntimeInvocationRecord | undefined = current; while (cursor) { if (cursor.sessionId !== childSessionId || runIds.has(cursor.runId)) return undefined; runIds.add(cursor.runId); turnIds.add(cursor.turnId); - const previousRunId = cursor.retriedFromRunId ?? cursor.resumedFromRunId; + const lineage = cursor.opening.lineage; + const previousRunId = lineage?.retriedFromRunId ?? lineage?.resumedFromRunId; if (!previousRunId) break; cursor = runsById.get(previousRunId); if (!cursor) return undefined; diff --git a/packages/runtime-host/src/server/session-transcript-reader.ts b/packages/runtime-host/src/server/session-transcript-reader.ts index 442d3b8851..cc51d9bd4a 100644 --- a/packages/runtime-host/src/server/session-transcript-reader.ts +++ b/packages/runtime-host/src/server/session-transcript-reader.ts @@ -60,14 +60,14 @@ export function createSessionTranscriptReader(input: { readActiveOverlay: async (sessionId, rootTurn) => { if (!rootTurn || isTerminalTurn(rootTurn)) return []; - const run = await input.stores.agentRunStore.readRun(sessionId, rootTurn.runId); + const invocations = await input.stores.runtimeEventStore.listSessionInvocations(sessionId); const events = await readActiveProjectionEvents(input.stores, sessionId, rootTurn.runId); const canonicalPermissionOutcomes = await readCanonicalPermissionOutcomes( events, input.canonicalPermissionOutcomes, ); const projected = projectRuntimeEventsToStoredMessages(activePresentationEvents(events), { - runHeaders: [run], + invocations: invocations.filter((invocation) => invocation.runId === rootTurn.runId), canonicalPermissionOutcomes, }); if (projected.diagnostics.some(isHardRuntimeEventReadModelDiagnostic)) { diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index 70e4ac1fc2..477292bed6 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -29,7 +29,7 @@ import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; import type { RunCompositionSnapshot } from '@maka/core/run-composition'; import { decodeRunCompositionSnapshot } from '@maka/core/run-composition'; import { DurableStoreWriteError, RunSealedError } from '@maka/core/runtime-event-store'; -import { isSessionInlineInvocation } from '@maka/core/runtime-invocation'; +import { buildInvocationOpenedEvent, isSessionInlineInvocation } from '@maka/core/runtime-invocation'; import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { RuntimeInvocationLineage } from '@maka/core/runtime-event'; import { @@ -1225,17 +1225,17 @@ export class AgentRun { await this.recordRuntimeEvents( [ { - id: this.input.newId(), - invocationId: this.invocationId, - runId: this.runId, - sessionId: this.sessionId, - turnId: this.turnId, - ts, - partial: false, - role: 'system', - author: 'system', - modelVisibility: 'hidden', - content: opening, + ...buildInvocationOpenedEvent({ + id: this.input.newId(), + run: { + sessionId: this.sessionId, + invocationId: this.invocationId, + runId: this.runId, + turnId: this.turnId, + }, + openedAt: ts, + opening, + }), ...(this.toolBoundaryProtocol ? { actions: { runtimeProtocol: { toolBoundary: this.toolBoundaryProtocol } } } : {}), diff --git a/packages/runtime/src/runtime-ledger-repair.ts b/packages/runtime/src/runtime-ledger-repair.ts index d21d005e1e..acdd8b9044 100644 --- a/packages/runtime/src/runtime-ledger-repair.ts +++ b/packages/runtime/src/runtime-ledger-repair.ts @@ -23,7 +23,7 @@ import { DEFAULT_TOOL_MODE } from '@maka/core/tool-mode'; import { isTerminalRuntimeEvent } from '@maka/core/runtime-event'; import type { RuntimeEvent, RuntimeEventInvocationOpenedContent } from '@maka/core/runtime-event'; import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; -import { isSessionInlineInvocation } from '@maka/core/runtime-invocation'; +import { buildInvocationOpenedEvent, isSessionInlineInvocation } from '@maka/core/runtime-invocation'; import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { SessionHeader } from '@maka/core/session'; import type { StoredMessage, TurnRecord } from '@maka/core/session'; @@ -206,19 +206,12 @@ function transcriptOpeningEvent(input: { root: { kind: 'user' }, source: { kind: 'fresh' }, }; - return { + return buildInvocationOpenedEvent({ id: input.newId(), - invocationId: input.run.invocationId, - runId: input.run.runId, - sessionId: input.run.sessionId, - turnId: input.run.turnId, - ts: input.openedAt, - partial: false, - role: 'system', - author: 'system', - modelVisibility: 'hidden', - content: opening, - }; + run: input.run, + openedAt: input.openedAt, + opening, + }); } /** How the imported turn ended, read off the transcript's own turn record. */ diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 7aa5dab10a..0ecf1e3819 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -102,6 +102,7 @@ import { failureClassFromCompleteStopReason } from '@maka/core/events'; import { isActiveShellRunStatus } from '@maka/core/shell-run'; import { isTerminalRuntimeEvent } from '@maka/core/runtime-event'; import { + buildInvocationOpenedEvent, isSessionInlineInvocation, runtimeInvocationOutcome, type RootExecutionDescriptor, @@ -3823,16 +3824,16 @@ export class SessionManager { // The admission never reached an AgentRun, so nothing else will ever open // this invocation. Recovery opens and closes it in one pass so the Turn // ends up on the spine like any other, with its own reason for ending. - await this.deps.runtimeEventStore.appendRuntimeEvent(input.sessionId, input.runId, { - id: this.deps.newId(), - ...run, - ts: input.admittedAt, - partial: false, - role: 'system', - author: 'system', - modelVisibility: 'hidden', - content: opening, - }); + await this.deps.runtimeEventStore.appendRuntimeEvent( + input.sessionId, + input.runId, + buildInvocationOpenedEvent({ + id: this.deps.newId(), + run, + openedAt: input.admittedAt, + opening, + }), + ); const ts = this.deps.now(); const terminalEvent = buildRecoveredTerminalRuntimeEvent({ diff --git a/packages/storage/src/sqlite-runtime-schema.ts b/packages/storage/src/sqlite-runtime-schema.ts index 97021a995b..0ad7591856 100644 --- a/packages/storage/src/sqlite-runtime-schema.ts +++ b/packages/storage/src/sqlite-runtime-schema.ts @@ -25,6 +25,7 @@ import { } from './legacy-run-header.js'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { encodeCanonicalRuntimeEvent } from '@maka/core/canonical-runtime-event'; +import { buildInvocationOpenedEvent } from '@maka/core/runtime-invocation'; export const SQLITE_RUNTIME_SCHEMA_VERSION = 16; export const RUNTIME_RECOVERY_AUTHORITY_CAPABILITY = 'runtime_recovery_authority'; @@ -644,19 +645,19 @@ function backfillInvocationOpeningFacts(db: DatabaseSync): void { } let encoded: { event: RuntimeEvent; json: string }; try { - encoded = encodeCanonicalRuntimeEvent({ - id: `invocation_opened:${header.runId}`, - invocationId: header.invocationId ?? header.runId, - runId: header.runId, - sessionId: header.sessionId, - turnId: header.turnId, - ts: header.createdAt, - partial: false, - role: 'system', - author: 'system', - modelVisibility: 'hidden', - content: invocationOpeningFromLegacyRunHeader(header), - }); + encoded = encodeCanonicalRuntimeEvent( + buildInvocationOpenedEvent({ + id: `invocation_opened:${header.runId}`, + run: { + sessionId: header.sessionId, + invocationId: header.invocationId ?? header.runId, + runId: header.runId, + turnId: header.turnId, + }, + openedAt: header.createdAt, + opening: invocationOpeningFromLegacyRunHeader(header), + }), + ); } catch { continue; } From 4e0aa1b1ba4fbe5e05bee4be3db54d95fb180911 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 12:35:09 +0800 Subject: [PATCH 13/46] test(runtime): assert every run off its invocation, not a header The Session Manager suite read run state through `AgentRunHeader`, so it asserted the header's copy of what the ledger already said and, in three places, tested that a tampered header was detected. Those tests describe a mechanism this change removes. The opening fact is immutable and the terminal event is the only outcome, so there is nothing left to tamper with and no second commit left to interrupt. The three header-drift tests go with the drift; the rest read `runtimeInvocationOutcome`, the terminal event, and the opening's route, configuration and lineage. Generated-by: Claude Code --- .../src/__tests__/invocation-fixture.ts | 167 +++ .../src/__tests__/session-manager.test.ts | 1068 +++++++++-------- 2 files changed, 726 insertions(+), 509 deletions(-) create mode 100644 packages/runtime/src/__tests__/invocation-fixture.ts diff --git a/packages/runtime/src/__tests__/invocation-fixture.ts b/packages/runtime/src/__tests__/invocation-fixture.ts new file mode 100644 index 0000000000..d3196f99d7 --- /dev/null +++ b/packages/runtime/src/__tests__/invocation-fixture.ts @@ -0,0 +1,167 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { randomUUID } from 'node:crypto'; +import type { + RuntimeEvent, + RuntimeEventInvocationOpenedContent, +} from '@maka/core/runtime-event'; +import { + buildInvocationOpenedEvent, + type RuntimeInvocationRecord, +} from '@maka/core/runtime-invocation'; + +export interface SeededInvocationIdentity { + readonly sessionId: string; + readonly invocationId: string; + readonly runId: string; + readonly turnId: string; +} + +export interface SeedInvocationInput { + readonly sessionId: string; + readonly runId: string; + readonly turnId: string; + readonly invocationId?: string; + readonly openedAt?: number; + readonly opening?: Partial; +} + +/** The opening a test gets when it does not care what the run was routed to. */ +export function testInvocationOpening( + overrides: Partial = {}, +): RuntimeEventInvocationOpenedContent { + return { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'runtime', + backendKind: 'fake', + llmConnectionId: 'fake-connection', + llmConnectionSlug: 'fake', + modelId: 'fake-model', + }, + configuration: { + cwd: '/tmp', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + root: { kind: 'user' }, + source: { kind: 'fresh' }, + ...overrides, + }; +} + +/** + * One invocation as a reader sees it, without a store. + * + * `outcome` writes the terminal event that decides it; leaving it out leaves the + * invocation running, which is what "no terminal event" means everywhere else. + */ +export function testInvocationRecord(input: { + sessionId: string; + runId: string; + turnId: string; + invocationId?: string; + openedAt?: number; + closedAt?: number; + outcome?: 'completed' | 'failed' | 'aborted'; + failureClass?: string; + opening?: Partial; +}): RuntimeInvocationRecord { + const invocationId = input.invocationId ?? input.runId; + const openedAt = input.openedAt ?? 1; + const identity = { + sessionId: input.sessionId, + invocationId, + runId: input.runId, + turnId: input.turnId, + }; + return { + ...identity, + openedAt, + opening: testInvocationOpening(input.opening), + ...(input.outcome + ? { + terminalEvent: { + id: `${invocationId}-terminal`, + ...identity, + ts: input.closedAt ?? openedAt + 1, + partial: false, + role: 'system', + author: 'system', + status: input.outcome, + ...(input.failureClass ? { failureClass: input.failureClass } : {}), + }, + } + : {}), + }; +} + +/** The event that opens one invocation, ready to append. */ +export function testInvocationOpenedEvent(input: SeedInvocationInput): RuntimeEvent { + return buildInvocationOpenedEvent({ + id: randomUUID(), + run: { + sessionId: input.sessionId, + invocationId: input.invocationId ?? input.runId, + runId: input.runId, + turnId: input.turnId, + }, + openedAt: input.openedAt ?? Date.now(), + opening: testInvocationOpening(input.opening), + }); +} + +/** The one invocation that opened this run, or a failure naming what is missing. */ +export async function readInvocation( + stores: { + runtimeEventStore: { + listSessionInvocations(sessionId: string): Promise; + }; + }, + sessionId: string, + runId: string, +): Promise { + const found = (await stores.runtimeEventStore.listSessionInvocations(sessionId)).find( + (candidate) => candidate.runId === runId, + ); + if (!found) throw new Error(`Session ${sessionId} has no invocation for run ${runId}`); + return found; +} + +/** Open one invocation on the spine, the way the runtime would. */ +export async function seedInvocation( + runtimeEventStore: { + appendRuntimeEvent(sessionId: string, runId: string, event: RuntimeEvent): Promise; + }, + input: SeedInvocationInput, +): Promise { + const event = testInvocationOpenedEvent(input); + await runtimeEventStore.appendRuntimeEvent(input.sessionId, input.runId, event); + return { + sessionId: event.sessionId, + invocationId: event.invocationId, + runId: event.runId, + turnId: event.turnId, + }; +} diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index c18c0ab65b..1b480805d8 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -18,8 +18,26 @@ */ import { nextId } from '@maka/core/test-only/async-primitives'; +import { runtimeInvocationFailureClass } from '../runtime-event-read-model.js'; import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; +import { DEFAULT_TOOL_MODE } from '@maka/core/tool-mode'; +import { + buildInvocationOpenedEvent, + isSessionInlineInvocation, + runtimeInvocationOutcome, + runtimeInvocationsFromSessionEvents, + type RootExecutionDescriptor, + type RuntimeInvocationRecord, +} from '@maka/core/runtime-invocation'; +import type { + RuntimeEventInvocationOpenedContent, + RuntimeInvocationLineage, + RuntimeInvocationRootAuthority, +} from '@maka/core/runtime-event'; +import type { PermissionMode } from '@maka/core/permission'; +import type { PersistedBackendKind } from '@maka/core/session'; +import type { ToolMode } from '@maka/core/tool-mode'; import { setTimeout as timerDelay } from 'node:timers/promises'; import { createHash } from 'node:crypto'; import { @@ -31,7 +49,6 @@ import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-pro import { DEEP_RESEARCH_SESSION_LABEL } from '@maka/core/deep-research'; import { RUNTIME_CONTINUATION_AUTHORITY_V1 } from '@maka/core/runtime-event-store'; import { deriveTurnRecords } from '@maka/core/session'; -import { isSessionInlineRun } from '@maka/core/agent-run'; import { isTerminalRuntimeEvent } from '@maka/core/runtime-event'; import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; import { buildImmutableRuntimePrefix, decodeContinuationClaim } from '@maka/core/runtime-boundary'; @@ -57,13 +74,7 @@ import type { AgentGraphOperatorProvisionRequest, AgentGraphOperatorProvisionResult, } from '@maka/core/agent-graph-topology'; -import type { - AgentRunEvent, - EmittedAgentRunEvent, - AgentRunHeader, - AgentRunStore, - RootExecutionDescriptor, -} from '@maka/core/agent-run'; +import type { AgentRunEvent, AgentRunStore, EmittedAgentRunEvent } from '@maka/core/agent-run'; import type { ArtifactRecord } from '@maka/core/artifacts'; import type { ContinuationClaimV1, RuntimeBoundaryDigest } from '@maka/core/runtime-boundary'; import type { @@ -192,7 +203,7 @@ test('sendMessage rejects removed child AgentRun lineage as a live trigger', asy ), /removed child AgentRun lineage/, ); - assert.deepStrictEqual(await runStore.listSessionRuns(session.id), []); + assert.deepStrictEqual(await runStore.listSessionInvocations(session.id), []); } }); @@ -529,7 +540,7 @@ describe('SessionManager graph operator provisioning', () => { .sendMessage(parent.id, { turnId: 'supervisor-turn', text: 'schedule graph work' }) [Symbol.asyncIterator](); await parentTurn.next(); - const sourceRun = (await runStore.listSessionRuns(parent.id))[0]; + const sourceRun = (await runStore.listSessionInvocations(parent.id))[0]; if (!sourceRun) throw new Error('Supervisor Run was not recorded'); let provisionSettled = false; @@ -587,7 +598,7 @@ describe('SessionManager graph operator provisioning', () => { } as never, }); const parent = await manager.createSession(makeInput({ permissionMode: 'bypass' })); - await runStore.createRun( + await seedInvocationFromHeader(runStore, makeRunHeader({ sessionId: parent.id, runId: 'supervisor-run', @@ -676,7 +687,7 @@ describe('SessionManager graph operator provisioning', () => { permissionMode: 'ask', }), ); - await runStore.createRun( + await seedInvocationFromHeader(runStore, makeRunHeader({ sessionId: parent.id, runId: 'supervisor-run', @@ -716,7 +727,7 @@ describe('SessionManager graph operator provisioning', () => { assert.strictEqual(result.header.permissionMode, 'explore'); assert.strictEqual(result.provision.initialTurnId, result.header.subagentSpawn?.initialTurnId); assert.strictEqual(result.provision.initialRunId, result.header.subagentSpawn?.initialRunId); - assert.deepStrictEqual(await runStore.listSessionRuns(result.header.id), []); + assert.deepStrictEqual(await runStore.listSessionInvocations(result.header.id), []); }); test('keeps four large graph branches and a replacement off the supervisor data plane', async () => { @@ -732,7 +743,7 @@ describe('SessionManager graph operator provisioning', () => { now: nextNow(90), }); const parent = await manager.createSession(makeInput({ permissionMode: 'ask' })); - await runStore.createRun( + await seedInvocationFromHeader(runStore, makeRunHeader({ sessionId: parent.id, runId: 'large-supervisor-run', @@ -946,7 +957,7 @@ describe('SessionManager graph operator provisioning', () => { permissionMode: 'ask', }), ); - await runStore.createRun( + await seedInvocationFromHeader(runStore, makeRunHeader({ sessionId: parent.id, runId: 'supervisor-run', @@ -1200,7 +1211,7 @@ describe('SessionManager claimed graph intent execution', () => { /requires its trusted graph execution capability/, ); - assert.deepStrictEqual(await runStore.listSessionRuns(child.id), []); + assert.deepStrictEqual(await runStore.listSessionInvocations(child.id), []); assert.deepStrictEqual(await store.readMessages(child.id), []); assert.strictEqual(backendBuilds, 0); }); @@ -1372,7 +1383,7 @@ describe('SessionManager claimed graph intent execution', () => { assert.strictEqual(hostedExecutions, 0); assert.strictEqual(backendBuilds, 0); - assert.deepStrictEqual(await runStore.listSessionRuns(child.id), []); + assert.deepStrictEqual(await runStore.listSessionInvocations(child.id), []); assert.deepStrictEqual(await store.readMessages(child.id), []); assert.strictEqual( await runStore.readRootTurnAdmission(child.id, proposedClaim.targetTurnId), @@ -1471,7 +1482,7 @@ describe('SessionManager claimed graph intent execution', () => { }, 'must not be backfilled', ); - await runStore.createRun( + await seedInvocationFromHeader(runStore, makeRunHeader({ sessionId: child.id, runId: claim.targetRunId, @@ -1511,7 +1522,7 @@ describe('SessionManager claimed graph intent execution', () => { assert.strictEqual(hostedExecutions, 0); assert.strictEqual(backendBuilds, 0); assert.deepStrictEqual(await store.readMessages(child.id), []); - assert.strictEqual((await runStore.readRun(child.id, claim.targetRunId)).status, 'completed'); + assert.strictEqual(runtimeInvocationOutcome(await readInvocation(runStore, child.id, claim.targetRunId)), 'completed'); }); test('hosted explicit abort stops only the exact claimed root identity', async () => { @@ -1608,16 +1619,16 @@ describe('SessionManager claimed graph intent execution', () => { }, }); - const run = await runStore.readRun(child.id, claim.targetRunId); - assert.partialDeepStrictEqual(run, { - status: 'failed', - failureClass: 'app_restarted', + const run = await readInvocation(runStore, child.id, claim.targetRunId); + assert.strictEqual(runtimeInvocationOutcome(run), 'failed'); + assert.strictEqual(runtimeInvocationFailureClass(run), 'app_restarted'); + assert.partialDeepStrictEqual(run.opening.lineage, { agentId: LOCAL_READ_AGENT_ID, agentName: LOCAL_READ_AGENT_DEFINITION.name, }); - assert.strictEqual(run.workspaceIdentity, undefined); - assert.strictEqual(run.resumedFromRunId, undefined); - assert.strictEqual(run.retriedFromRunId, undefined); + assert.strictEqual(run.opening.configuration.workspaceIdentity, undefined); + assert.strictEqual(run.opening.lineage?.resumedFromRunId, undefined); + assert.strictEqual(run.opening.lineage?.retriedFromRunId, undefined); const terminalEvents = (await runStore.readRuntimeEvents(child.id, claim.targetRunId)).filter( (event) => event.status === 'failed', ); @@ -1694,9 +1705,9 @@ describe('SessionManager claimed graph intent execution', () => { agentName: LOCAL_READ_AGENT_DEFINITION.name, }, ]); - const run = await runStore.readRun(child.id, 'graph-run'); - assert.strictEqual(isSessionInlineRun(run), true); - assert.strictEqual(run.parentRunId, undefined); + const run = await readInvocation(runStore, child.id, 'graph-run'); + assert.strictEqual(isSessionInlineInvocation(run.opening), true); + assert.strictEqual(run.opening.lineage?.parentRunId, undefined); assert.strictEqual(run.turnId, 'graph-turn'); assert.partialDeepStrictEqual( (await store.readMessages(child.id)).find( @@ -1717,7 +1728,7 @@ describe('SessionManager claimed graph intent execution', () => { status: 'completed', summary: 'ok', }); - assert.strictEqual((await runStore.listSessionRuns(child.id)).length, 1); + assert.strictEqual((await runStore.listSessionInvocations(child.id)).length, 1); assert.strictEqual(backendsBySession.get(child.id)?.sendInputs.length, 1); await expectRejects( manager.runClaimedAgentGraphIntent({ @@ -1770,7 +1781,7 @@ describe('SessionManager claimed graph intent execution', () => { const [firstResult, joinedResult] = await Promise.all([first, joined]); assert.deepStrictEqual(joinedResult, firstResult); assert.strictEqual(childBackend?.sendInputs.length, 1); - assert.strictEqual((await runStore.listSessionRuns(child.id)).length, 1); + assert.strictEqual((await runStore.listSessionInvocations(child.id)).length, 1); }); test('serializes different claims per child session without letting a queued abort stop active work', async () => { @@ -1794,7 +1805,7 @@ describe('SessionManager claimed graph intent execution', () => { queuedAbort.abort(); await new Promise((resolve) => setImmediate(resolve)); assert.strictEqual(backend?.stopCalls, 0); - assert.strictEqual((await runStore.listSessionRuns(child.id)).length, 1); + assert.strictEqual((await runStore.listSessionInvocations(child.id)).length, 1); activeGate.release(); assert.strictEqual((await first).status, 'completed'); @@ -1808,7 +1819,7 @@ describe('SessionManager claimed graph intent execution', () => { ); assert.strictEqual(third.status, 'completed'); assert.strictEqual(backend?.sendInputs.length, 2); - assert.strictEqual((await runStore.listSessionRuns(child.id)).length, 2); + assert.strictEqual((await runStore.listSessionInvocations(child.id)).length, 2); }); test('evaluates execution admission only after a claimed child-session slot is available', async () => { @@ -1866,7 +1877,7 @@ describe('SessionManager claimed graph intent execution', () => { await expectRejects(queued, /cancelled before runtime admission/); assert.strictEqual(admissionChecks, 1); assert.strictEqual(backend?.sendInputs.length, 1); - assert.strictEqual((await runStore.listSessionRuns(child.id)).length, 1); + assert.strictEqual((await runStore.listSessionInvocations(child.id)).length, 1); }); test('keeps a stop pending across graph admission with an idle cached backend', async () => { @@ -1927,7 +1938,7 @@ describe('SessionManager claimed graph intent execution', () => { assert.strictEqual(result.status, 'cancelled'); assert.strictEqual(backend?.stopCalls, 1); assert.strictEqual(backend?.sendInputs?.length, 1); - assert.strictEqual((await runStore.readRun(child.id, claim.targetRunId)).status, 'cancelled'); + assert.strictEqual(runtimeInvocationOutcome(await readInvocation(runStore, child.id, claim.targetRunId)), 'cancelled'); }); test('runtime stop settles queued graph claims without letting their slots pass the active claim', async () => { @@ -1974,7 +1985,7 @@ describe('SessionManager claimed graph intent execution', () => { [firstClaim.targetTurnId], ); assert.deepStrictEqual( - (await runStore.listSessionRuns(child.id)).map((run) => run.turnId), + (await runStore.listSessionInvocations(child.id)).map((run) => run.turnId), [firstClaim.targetTurnId], ); assert.deepStrictEqual( @@ -2026,7 +2037,7 @@ describe('SessionManager claimed graph intent execution', () => { sessionId: child.id, runId: claim.targetRunId, turnId: claim.targetTurnId, - type: 'run_started', + type: 'turn_started', ts: 81, }), ], @@ -2039,7 +2050,7 @@ describe('SessionManager claimed graph intent execution', () => { assert.strictEqual(recovered.status, 'failed'); assert.strictEqual(recovered.failureClass, 'app_restarted'); assert.strictEqual(backendBuilds, 0); - assert.strictEqual((await runStore.listSessionRuns(child.id)).length, 1); + assert.strictEqual((await runStore.listSessionInvocations(child.id)).length, 1); }); test('target Session stop owns a claimed graph execution before its first runtime preflight', async () => { @@ -2089,7 +2100,7 @@ describe('SessionManager claimed graph intent execution', () => { const [result] = await Promise.all([executing, stopping]); assert.strictEqual(result.status, 'cancelled'); assert.deepStrictEqual(backend?.sendInputs, []); - assert.strictEqual((await runStore.readRun(child.id, claim.targetRunId)).status, 'cancelled'); + assert.strictEqual(runtimeInvocationOutcome(await readInvocation(runStore, child.id, claim.targetRunId)), 'cancelled'); assert.strictEqual((await store.readHeader(child.id)).status === 'blocked', false); }); @@ -2146,7 +2157,7 @@ describe('SessionManager claimed graph intent execution', () => { const child = await createGraphOperatorSession(store, parent.id); const claim = graphIntentClaim({ targetSessionId: child.id }, 'must not run'); - await runStore.createRun( + await seedInvocationFromHeader(runStore, makeRunHeader({ sessionId: child.id, runId: 'different-run', @@ -2250,7 +2261,7 @@ describe('SessionManager child-session runtime primitive', () => { .sendMessage(parent.id, { turnId: 'parent-turn', text: 'private parent history' }) [Symbol.asyncIterator](); await parentTurn.next(); - const [parentRun] = await runStore.listSessionRuns(parent.id); + const [parentRun] = await runStore.listSessionInvocations(parent.id); if (!parentRun) throw new Error('parent run was not recorded'); const result = await manager.spawnChildSession(parent.id, { @@ -2304,12 +2315,12 @@ describe('SessionManager child-session runtime primitive', () => { assert.strictEqual(childHeader.subagentSpawn?.initialTurnId, result.turnId); assert.strictEqual(childHeader.subagentSpawn?.initialRunId, result.runId); - const [childRun] = await runStore.listSessionRuns(result.childSessionId); + const [childRun] = await runStore.listSessionInvocations(result.childSessionId); if (!childRun) throw new Error('child run was not recorded'); assert.strictEqual(childRun.runId, result.runId); - assert.strictEqual(childRun.parentRunId, undefined); - assert.strictEqual(childRun.agentId, LOCAL_READ_AGENT_ID); - assert.strictEqual(isSessionInlineRun(childRun), true); + assert.strictEqual(childRun.opening.lineage?.parentRunId, undefined); + assert.strictEqual(childRun.opening.lineage?.agentId, LOCAL_READ_AGENT_ID); + assert.strictEqual(isSessionInlineInvocation(childRun.opening), true); assert.strictEqual(result.status, 'completed'); assert.deepStrictEqual(backendActivationSessions, [parent.id, result.childSessionId]); assert.strictEqual( @@ -2375,8 +2386,8 @@ describe('SessionManager child-session runtime primitive', () => { sessionId: result.childSessionId, currentRunId: result.runId, }); - assert.strictEqual(output.header.sessionId, result.childSessionId); - assert.strictEqual(output.header.runId, result.runId); + assert.strictEqual(output.invocation.sessionId, result.childSessionId); + assert.strictEqual(output.invocation.runId, result.runId); const unrelatedParent = await manager.createSession(makeInput({ name: 'Unrelated parent' })); await expectRejects( manager.readChildAgentOutput(unrelatedParent.id, { @@ -2450,7 +2461,7 @@ describe('SessionManager child-session runtime primitive', () => { .sendMessage(parent.id, { turnId: 'parent-turn-preset', text: 'delegate' }) [Symbol.asyncIterator](); await parentTurn.next(); - const [parentRun] = await runStore.listSessionRuns(parent.id); + const [parentRun] = await runStore.listSessionInvocations(parent.id); if (!parentRun) throw new Error('parent run was not recorded'); const result = await manager.spawnChildSession(parent.id, { @@ -2501,7 +2512,7 @@ describe('SessionManager child-session runtime primitive', () => { .sendMessage(parent.id, { turnId: 'parent-turn', text: 'keep the parent active' }) [Symbol.asyncIterator](); await parentTurn.next(); - const [parentRun] = await runStore.listSessionRuns(parent.id); + const [parentRun] = await runStore.listSessionInvocations(parent.id); if (!parentRun) throw new Error('parent run was not recorded'); const child = await manager.spawnChildSession(parent.id, { @@ -2546,7 +2557,7 @@ describe('SessionManager child-session runtime primitive', () => { .sendMessage(parent.id, { turnId: 'parent-turn', text: 'keep parent active' }) [Symbol.asyncIterator](); await parentTurn.next(); - const [parentRun] = await runStore.listSessionRuns(parent.id); + const [parentRun] = await runStore.listSessionInvocations(parent.id); if (!parentRun) throw new Error('parent run was not recorded'); const spawnInput = { spawnedBy: { @@ -2574,7 +2585,7 @@ describe('SessionManager child-session runtime primitive', () => { const [firstResult, joinedResult] = await Promise.all([first, joined]); assert.strictEqual(joinedResult.childSessionId, firstResult.childSessionId); assert.strictEqual(joinedResult.runId, firstResult.runId); - assert.strictEqual((await runStore.listSessionRuns(firstResult.childSessionId)).length, 1); + assert.strictEqual((await runStore.listSessionInvocations(firstResult.childSessionId)).length, 1); const durableRetry = await manager.spawnChildSession(parent.id, spawnInput); assert.strictEqual(durableRetry.childSessionId, firstResult.childSessionId); @@ -2591,10 +2602,8 @@ describe('SessionManager child-session runtime primitive', () => { 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(); - } + beforeListSessionRuns: (sessionId) => { + if (sessionId === 'session-3') abortController.abort(); }, }); const backends = new BackendRegistry(); @@ -2617,7 +2626,7 @@ describe('SessionManager child-session runtime primitive', () => { .sendMessage(parent.id, { turnId: 'parent-turn', text: 'keep parent active' }) [Symbol.asyncIterator](); await parentTurn.next(); - const [parentRun] = await runStore.listSessionRuns(parent.id); + const [parentRun] = await runStore.listSessionInvocations(parent.id); if (!parentRun) throw new Error('parent run was not recorded'); const seedMetadataOnlyChild = async ( @@ -2700,7 +2709,7 @@ describe('SessionManager child-session runtime primitive', () => { assert.strictEqual(resumed.childSessionId, metadataOnly.id); assert.strictEqual(resumed.runId, 'metadata-only-run'); assert.strictEqual(readyCalls, 1); - assert.strictEqual((await runStore.listSessionRuns(metadataOnly.id)).length, 1); + assert.strictEqual((await runStore.listSessionInvocations(metadataOnly.id)).length, 1); const cancelled = await seedMetadataOnlyChild( 'cancelled-metadata-tool', @@ -2726,7 +2735,7 @@ describe('SessionManager child-session runtime primitive', () => { /cancelled before its first run/, ); assert.strictEqual(cancelledReadyCalls, 0); - assert.deepStrictEqual(await runStore.listSessionRuns(cancelled.id), []); + assert.deepStrictEqual(await runStore.listSessionInvocations(cancelled.id), []); parentGate.release(); while (!(await parentTurn.next()).done) {} @@ -2756,7 +2765,7 @@ describe('SessionManager child-session runtime primitive', () => { .sendMessage(parent.id, { turnId: 'parent-turn', text: 'keep parent active' }) [Symbol.asyncIterator](); await parentTurn.next(); - const [parentRun] = await runStore.listSessionRuns(parent.id); + const [parentRun] = await runStore.listSessionInvocations(parent.id); if (!parentRun) throw new Error('parent run was not recorded'); const child = await manager.spawnChildSession(parent.id, { spawnedBy: { @@ -2855,7 +2864,7 @@ describe('SessionManager child-session runtime primitive', () => { .sendMessage(parent.id, { turnId: 'parent-turn', text: 'private parent context' }) [Symbol.asyncIterator](); await parentTurn.next(); - const [parentRun] = await runStore.listSessionRuns(parent.id); + const [parentRun] = await runStore.listSessionInvocations(parent.id); if (!parentRun) throw new Error('parent run was not recorded'); const child = await manager.spawnChildSession(parent.id, { @@ -2960,7 +2969,7 @@ describe('SessionManager child-session runtime primitive', () => { .sendMessage(parent.id, { turnId: 'parent-turn', text: 'keep parent active' }) [Symbol.asyncIterator](); await parentTurn.next(); - const [parentRun] = await runStore.listSessionRuns(parent.id); + const [parentRun] = await runStore.listSessionInvocations(parent.id); if (!parentRun) throw new Error('parent run was not recorded'); const toolCallId = 'recovery-tool-call'; const prompt = 'recover this exact request'; @@ -3030,7 +3039,7 @@ describe('SessionManager child-session runtime primitive', () => { sessionId: child.id, runId: 'stale-child-run', turnId: 'stale-child-turn', - type: 'run_started', + type: 'turn_started', ts: 191, }), ], @@ -3073,7 +3082,7 @@ describe('SessionManager child-session runtime primitive', () => { await drain( manager.sendMessage(parent.id, { turnId: 'parent-turn', text: 'already complete' }), ); - const [parentRun] = await runStore.listSessionRuns(parent.id); + const [parentRun] = await runStore.listSessionInvocations(parent.id); if (!parentRun) throw new Error('parent run was not recorded'); await expectRejects( @@ -3119,7 +3128,7 @@ describe('SessionManager child-session runtime primitive', () => { }); const parent = await manager.createSession(makeInput()); await drain(manager.sendMessage(parent.id, { turnId: 'parent-turn', text: 'parent' })); - const [parentRun] = await runStore.listSessionRuns(parent.id); + const [parentRun] = await runStore.listSessionInvocations(parent.id); if (!parentRun) throw new Error('parent run was not recorded'); externalParent = { sessionId: parent.id, @@ -3169,7 +3178,7 @@ describe('SessionManager child-session runtime primitive', () => { .sendMessage(parent.id, { turnId: 'parent-turn', text: 'coordinate children' }) [Symbol.asyncIterator](); await parentTurn.next(); - const [parentRun] = await runStore.listSessionRuns(parent.id); + const [parentRun] = await runStore.listSessionInvocations(parent.id); if (!parentRun) throw new Error('parent run was not recorded'); const childOneStarted = makeGate(); @@ -3216,7 +3225,10 @@ describe('SessionManager child-session runtime primitive', () => { assert.strictEqual(backendsBySession.get(childOneId)?.stopCalls, 1); assert.strictEqual(backendsBySession.get(childTwoId)?.stopCalls, 0); assert.strictEqual(backendsBySession.get(parent.id)?.stopCalls, 0); - assert.strictEqual((await runStore.readRun(parent.id, parentRun.runId)).status, 'running'); + assert.strictEqual( + runtimeInvocationOutcome(await readInvocation(runStore, parent.id, parentRun.runId)), + undefined, + ); await manager.stopSession(parent.id, { source: 'stop_button' }); assert.strictEqual(backendsBySession.get(parent.id)?.stopCalls, 1); @@ -3302,7 +3314,7 @@ describe('SessionManager child-session runtime primitive', () => { sessionId: child.id, runId: 'child-run', turnId: 'child-turn', - type: 'run_started', + type: 'turn_started', ts: 11, }), makeRunEvent({ @@ -3319,11 +3331,11 @@ describe('SessionManager child-session runtime primitive', () => { const recovered = await manager.recoverInterruptedSessions(); assert.deepStrictEqual(recovered, [child.id]); - const recoveredRun = await runStore.readRun(child.id, 'child-run'); - assert.strictEqual(recoveredRun.parentRunId, undefined); - assert.strictEqual(isSessionInlineRun(recoveredRun), true); - assert.strictEqual(recoveredRun.status, 'failed'); - assert.strictEqual(recoveredRun.failureClass, 'app_restarted'); + const recoveredRun = await readInvocation(runStore, child.id, 'child-run'); + assert.strictEqual(recoveredRun.opening.lineage?.parentRunId, undefined); + assert.strictEqual(isSessionInlineInvocation(recoveredRun.opening), true); + assert.strictEqual(runtimeInvocationOutcome(recoveredRun), 'failed'); + assert.strictEqual(runtimeInvocationFailureClass(recoveredRun), 'app_restarted'); assert.strictEqual( (await store.readMessages(child.id)).some( (message) => @@ -3357,10 +3369,12 @@ describe('SessionManager manual compaction and quiescent session changes', () => ); await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'hello' })); - const sourceRun = (await runStore.listSessionRuns(session.id)).find( + const sourceRun = (await runStore.listSessionInvocations(session.id)).find( (run) => run.turnId === 'turn-1', ); assert.ok(sourceRun); + const sourceRoute = sourceRun.opening.route; + assert.equal(sourceRoute.provenance, 'runtime'); runStore.operations = []; const events = await collectSessionEvents( manager.compactSession(session.id, { turnId: 'turn-compact' }), @@ -3374,8 +3388,9 @@ describe('SessionManager manual compaction and quiescent session changes', () => sourceRoutes: [ { runId: sourceRun.runId, - connectionId: sourceRun.llmConnectionId, - modelId: sourceRun.modelId, + connectionId: + sourceRoute.provenance === 'runtime' ? sourceRoute.llmConnectionId : undefined, + modelId: sourceRoute.modelId, }, ], }, @@ -3412,11 +3427,11 @@ describe('SessionManager manual compaction and quiescent session changes', () => true, ); - const compactRun = (await runStore.listSessionRuns(session.id)).find( + const compactRun = (await runStore.listSessionInvocations(session.id)).find( (run) => run.turnId === 'turn-compact', ); - assert.strictEqual(compactRun?.status, 'completed'); - assert.deepStrictEqual(runStore.operations, ['terminalRuntimeEvent', 'completedRunHeader']); + assert.strictEqual(compactRun && runtimeInvocationOutcome(compactRun), 'completed'); + assert.deepStrictEqual(runStore.operations, ['terminalRuntimeEvent']); assert.strictEqual( (await runStore.readRuntimeEvents(session.id, compactRun!.runId)).some( (event) => event.actions?.stateDelta?.contextCompactionOutcome, @@ -3519,7 +3534,7 @@ describe('SessionManager manual compaction and quiescent session changes', () => 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( + const compactRun = (await runStore.listSessionInvocations(session.id)).find( (run) => run.turnId === 'turn-compact', ); assert.ok(compactRun, 'the kernel opens a run for a manual compaction'); @@ -3614,10 +3629,10 @@ describe('SessionManager manual compaction and quiescent session changes', () => compactEvents.some((event) => event.type === 'token_usage'), false, ); - const compactRun = (await runStore.listSessionRuns(session.id)).find( + const compactRun = (await runStore.listSessionInvocations(session.id)).find( (run) => run.turnId === 'turn-compact', ); - assert.strictEqual(compactRun?.status, 'cancelled'); + assert.strictEqual(compactRun && runtimeInvocationOutcome(compactRun), 'cancelled'); }); test('cold manual compaction normalizes only its execution cancellation reason', async () => { @@ -3682,10 +3697,10 @@ describe('SessionManager manual compaction and quiescent session changes', () => }); await Promise.all([abortErrorRejection, abortErrorStop]); - const [cancelledRun] = await runStore.listSessionRuns(cancelledSession.id); - const [abortErrorRun] = await runStore.listSessionRuns(abortErrorSession.id); - assert.strictEqual(cancelledRun?.status, 'cancelled'); - assert.strictEqual(abortErrorRun?.status, 'cancelled'); + const [cancelledRun] = await runStore.listSessionInvocations(cancelledSession.id); + const [abortErrorRun] = await runStore.listSessionInvocations(abortErrorSession.id); + assert.strictEqual(cancelledRun && runtimeInvocationOutcome(cancelledRun), 'cancelled'); + assert.strictEqual(abortErrorRun && runtimeInvocationOutcome(abortErrorRun), 'cancelled'); }); test('stopSession waits for compaction blocked before Run reservation', async () => { @@ -3725,10 +3740,10 @@ describe('SessionManager manual compaction and quiescent session changes', () => await compact.catch(() => []); assert.deepStrictEqual(compactCalls, []); - const compactRun = (await runStore.listSessionRuns(session.id)).find( + const compactRun = (await runStore.listSessionInvocations(session.id)).find( (run) => run.turnId === 'turn-compact-pending', ); - assert.strictEqual(compactRun?.status, 'cancelled'); + assert.strictEqual(compactRun && runtimeInvocationOutcome(compactRun), 'cancelled'); }); test('manual compaction is stopped through the active runtime run lifecycle', async () => { @@ -3773,10 +3788,10 @@ describe('SessionManager manual compaction and quiescent session changes', () => compactEvents.some((event) => event.type === 'token_usage'), false, ); - const compactRun = (await runStore.listSessionRuns(session.id)).find( + const compactRun = (await runStore.listSessionInvocations(session.id)).find( (run) => run.turnId === 'turn-compact', ); - assert.strictEqual(compactRun?.status, 'cancelled'); + assert.strictEqual(compactRun && runtimeInvocationOutcome(compactRun), 'cancelled'); }); test('compactSession rejects while a turn is running and writes no compact artifacts', async () => { @@ -3822,7 +3837,7 @@ describe('SessionManager manual compaction and quiescent session changes', () => messages.some((message) => message.turnId === 'turn-compact'), false, ); - const compactRun = (await runStore.listSessionRuns(session.id)).find( + const compactRun = (await runStore.listSessionInvocations(session.id)).find( (run) => run.turnId === 'turn-compact', ); assert.strictEqual(compactRun, undefined); @@ -4484,9 +4499,14 @@ describe('SessionManager permission mode updates', () => { await first.next(); await first.next(); assert.strictEqual((await store.readHeader(session.id)).status, 'running'); - const afterFirstRuns = await runStore.listSessionRuns(session.id); - assert.strictEqual(afterFirstRuns.find((run) => run.turnId === 'turn-1')?.status, 'completed'); - assert.strictEqual(afterFirstRuns.find((run) => run.turnId === 'turn-2')?.status, 'running'); + const afterFirstRuns = await runStore.listSessionInvocations(session.id); + assert.deepStrictEqual( + afterFirstRuns.map((run) => [run.turnId, runtimeInvocationOutcome(run)]), + [ + ['turn-1', 'completed'], + ['turn-2', undefined], + ], + ); await expectRejects(manager.setPermissionMode(session.id, 'bypass'), /当前任务正在运行/); @@ -4494,9 +4514,9 @@ describe('SessionManager permission mode updates', () => { await second.next(); await second.next(); assert.strictEqual((await store.readHeader(session.id)).status, 'active'); - const finalRuns = await runStore.listSessionRuns(session.id); + const finalRuns = await runStore.listSessionInvocations(session.id); assert.deepStrictEqual( - finalRuns.map((run) => [run.turnId, run.status]), + finalRuns.map((run) => [run.turnId, runtimeInvocationOutcome(run)]), [ ['turn-1', 'completed'], ['turn-2', 'completed'], @@ -4504,8 +4524,6 @@ describe('SessionManager permission mode updates', () => { ); const firstEvents = await runStore.readEvents(session.id, finalRuns[0]!.runId); assert.ok(firstEvents.map((event) => event.type).includes('run_created')); - assert.ok(firstEvents.map((event) => event.type).includes('run_started')); - assert.ok(firstEvents.map((event) => event.type).includes('run_completed')); const summary = await manager.setPermissionMode(session.id, 'bypass'); assert.strictEqual(summary.permissionMode, 'bypass'); @@ -4569,9 +4587,12 @@ describe('SessionManager permission mode updates', () => { events.map((event) => event.type), ['text_complete', 'complete'], ); - const [run] = await runStore.listSessionRuns(session.id); - assert.strictEqual(run?.llmConnectionId, '11111111-1111-4111-8111-111111111111'); - assert.strictEqual(run?.workspaceIdentity, undefined); + const [run] = await runStore.listSessionInvocations(session.id); + assert.partialDeepStrictEqual(run?.opening.route, { + provenance: 'runtime', + llmConnectionId: '11111111-1111-4111-8111-111111111111', + }); + assert.strictEqual(run?.opening.configuration.workspaceIdentity, undefined); }); test('does not inspect continuation safety on normal turns while resume is disabled', async () => { @@ -4606,8 +4627,8 @@ describe('SessionManager permission mode updates', () => { ); assert.strictEqual(inspectionCalls, 0); - const [run] = await runStore.listSessionRuns(session.id); - assert.strictEqual(run?.workspaceIdentity, undefined); + const [run] = await runStore.listSessionInvocations(session.id); + assert.strictEqual(run?.opening.configuration.workspaceIdentity, undefined); }); test('declares the T1 protocol for an AiSdk run when the host wires the durable boundary', async () => { @@ -4633,7 +4654,7 @@ describe('SessionManager permission mode updates', () => { }), ); - const [run] = await runStore.listSessionRuns(session.id); + const [run] = await runStore.listSessionInvocations(session.id); if (!run) throw new Error('expected run'); const events = await runStore.readRuntimeEvents(session.id, run.runId); assert.deepStrictEqual(events[0]?.actions?.runtimeProtocol, { @@ -4735,7 +4756,7 @@ describe('SessionManager permission mode updates', () => { }); const session = await manager.createSession(makeInput()); const header = await store.readHeader(session.id); - await runStore.createRun( + await seedInvocationFromHeader(runStore, makeRunHeader({ runId: 'source-run-safety-failure', sessionId: session.id, @@ -4892,7 +4913,7 @@ describe('SessionManager permission mode updates', () => { ); assert.strictEqual(backend?.sendInputs[0]?.toolMode, 'code_mode'); - const [run] = await runStore.listSessionRuns(session.id); + const [run] = await runStore.listSessionInvocations(session.id); if (!run) throw new Error('AgentRunStore run was not created'); const runtimeEvents = await runtimeEventStore.readRuntimeEvents(session.id, run.runId); assert.deepStrictEqual(backend?.sendInputs[0]?.headAnchorRuntimeEvent, runtimeEvents[1]); @@ -5031,6 +5052,7 @@ describe('SessionManager permission mode updates', () => { readSessionRuntimeEventEntries: (sessionId) => durableEvents.readSessionRuntimeEventEntries(sessionId), readSessionRuntimeEvents: (sessionId) => durableEvents.readSessionRuntimeEvents(sessionId), + listSessionInvocations: (sessionId) => durableEvents.listSessionInvocations(sessionId), }; const backends = new BackendRegistry(); let providerInput: BackendSendInput | undefined; @@ -5130,7 +5152,7 @@ describe('SessionManager permission mode updates', () => { assert.strictEqual(Object.isFrozen(providerInput?.headAnchorRuntimeEvent), true); assert.strictEqual(Object.isFrozen(headContent), true); - const [run] = await runStore.listSessionRuns(session.id); + const [run] = await runStore.listSessionInvocations(session.id); if (!run) throw new Error('AgentRunStore run was not created'); const [openingFact, storedUserEvent] = await durableEvents.readRuntimeEvents( session.id, @@ -5290,7 +5312,7 @@ describe('SessionManager permission mode updates', () => { const sourceRunId = 'source-run'; const sourceTurnId = 'source-turn'; const sourceInvocationId = 'source-invocation'; - await runStore.createRun({ + await seedInvocationFromHeader(runStore, { runId: sourceRunId, invocationId: sourceInvocationId, sessionId: session.id, @@ -5416,15 +5438,18 @@ describe('SessionManager permission mode updates', () => { sessionEvents.map((event) => event.type), ['text_complete', 'complete'], ); - const continuationRun = await runStore.readRun(session.id, plan.continuation.runId); + const continuationRun = await readInvocation(runStore, session.id, plan.continuation.runId); assert.strictEqual(continuationRun.invocationId, plan.continuation.invocationId); assert.strictEqual(continuationRun.turnId, plan.continuation.turnId); - assert.strictEqual(continuationRun.parentRunId, sourceRunId); - assert.strictEqual(continuationRun.parentTurnId, sourceTurnId); - assert.strictEqual(continuationRun.cwd, movedCwd); - assert.strictEqual(continuationRun.status, 'completed'); - assert.strictEqual(continuationRun.providerStateIdentity, providerStateIdentity); - assert.partialDeepStrictEqual(continuationRun, { + assert.strictEqual(continuationRun.opening.lineage?.parentRunId, sourceRunId); + assert.strictEqual(continuationRun.opening.lineage?.parentTurnId, sourceTurnId); + assert.strictEqual(continuationRun.opening.configuration.cwd, movedCwd); + assert.strictEqual(runtimeInvocationOutcome(continuationRun), 'completed'); + assert.partialDeepStrictEqual(continuationRun.opening.route, { + provenance: 'runtime', + providerStateIdentity, + }); + assert.partialDeepStrictEqual(continuationRun.opening.configuration, { orchestrationMode: 'swarm', orchestrationSource: 'turn_override', agentSwarmAuthorization: 'turn_override', @@ -5432,18 +5457,21 @@ describe('SessionManager permission mode updates', () => { }); assert.strictEqual(backend?.sendInputs[0]?.toolMode, 'code_mode'); assert.deepStrictEqual( - backend?.sendInputs[0]?.runtimeContextRunHeaders?.map((runHeader) => ({ - runId: runHeader.runId, - llmConnectionId: runHeader.llmConnectionId, - modelId: runHeader.modelId, - providerStateIdentity: runHeader.providerStateIdentity, + backend?.sendInputs[0]?.runtimeContextInvocations?.map((invocation) => ({ + runId: invocation.runId, + route: invocation.opening.route, })), [ { runId: sourceRunId, - llmConnectionId: header.llmConnectionId, - modelId: header.model, - providerStateIdentity, + route: { + provenance: 'runtime', + backendKind: 'ai-sdk', + llmConnectionId: header.llmConnectionId, + llmConnectionSlug: header.llmConnectionSlug, + modelId: header.model, + providerStateIdentity, + }, }, ], ); @@ -5496,10 +5524,13 @@ describe('SessionManager permission mode updates', () => { followUpContext.some((event) => event.runId === plan.continuation?.runId), true, ); - const followUpRun = (await runStore.listSessionRuns(session.id)).find( + const followUpRun = (await runStore.listSessionInvocations(session.id)).find( (runHeader) => runHeader.turnId === 'turn-after-continuation', ); - assert.strictEqual(followUpRun?.providerStateIdentity, providerStateIdentity); + assert.partialDeepStrictEqual(followUpRun?.opening.route, { + provenance: 'runtime', + providerStateIdentity, + }); }); test('authenticates the exact target-aware continuation projection that reaches the provider', async () => { @@ -5587,7 +5618,7 @@ describe('SessionManager permission mode updates', () => { const sourceRunId = 'source-run-cross-route'; const sourceInvocationId = 'source-invocation-cross-route'; const sourceTurnId = 'source-turn-cross-route'; - await runStore.createRun({ + await seedInvocationFromHeader(runStore, { runId: sourceRunId, invocationId: sourceInvocationId, sessionId: session.id, @@ -5956,57 +5987,17 @@ describe('SessionManager permission mode updates', () => { assert.deepStrictEqual(repeatedPlan.rejectionReasons, ['continuation_already_exists']); const targetRunId = firstPlan.continuation.runId; - const targetRun = await runStore.readRun(session.id, targetRunId); - await runStore.updateRun(session.id, targetRunId, { - modelId: 'tampered-model', - updatedAt: targetRun.updatedAt + 1, - }); - const targetIdentityMismatch = await manager.planSafeBoundaryContinuation(session.id, { - sourceRunId, - currentCwd: header.cwd, - sourceWorkspaceIdentity: 'workspace-1', - currentWorkspaceIdentity: 'workspace-1', - backgroundOperationsSettled: true, - availableToolNames: [], - }); - assert.deepStrictEqual(targetIdentityMismatch.rejectionReasons, [ - 'continuation_claim_repair_required', - ]); - - await runStore.updateRun(session.id, targetRunId, { - modelId: targetRun.modelId, - status: 'failed', - failureClass: 'tampered_terminal_state', - updatedAt: targetRun.updatedAt + 2, - }); - const targetTerminalMismatch = await manager.planSafeBoundaryContinuation(session.id, { - sourceRunId, - currentCwd: header.cwd, - sourceWorkspaceIdentity: 'workspace-1', - currentWorkspaceIdentity: 'workspace-1', - backgroundOperationsSettled: true, - availableToolNames: [], - }); - assert.deepStrictEqual(targetTerminalMismatch.rejectionReasons, [ - 'continuation_claim_repair_required', - ]); - - await runStore.updateRun(session.id, targetRunId, { - status: targetRun.status, - failureClass: targetRun.failureClass, - completedAt: targetRun.completedAt, - updatedAt: targetRun.updatedAt, - }); + const targetRun = await readInvocation(runStore, session.id, targetRunId); await runStore.appendRuntimeEvent( session.id, targetRunId, runtimeEvent({ id: 'post-terminal-continuation-output', - invocationId: targetRun.invocationId ?? targetRun.runId, + invocationId: targetRun.invocationId, runId: targetRun.runId, sessionId: targetRun.sessionId, turnId: targetRun.turnId, - ts: targetRun.updatedAt + 1, + ts: (targetRun.terminalEvent?.ts ?? targetRun.openedAt) + 1, role: 'model', author: 'agent', content: { kind: 'text', text: 'must not follow a terminal fact' }, @@ -6342,7 +6333,7 @@ describe('SessionManager permission mode updates', () => { const sourceRunId = 'source-run-write-failure'; const sourceTurnId = 'source-turn-write-failure'; const sourceInvocationId = 'source-invocation-write-failure'; - await runStore.createRun({ + await seedInvocationFromHeader(runStore, { runId: sourceRunId, sessionId: session.id, turnId: sourceTurnId, @@ -6403,9 +6394,8 @@ describe('SessionManager permission mode updates', () => { ); assert.strictEqual(backendCalls, 0); - const targetRun = await runStore.readRun(session.id, plan.continuation.runId); - assert.strictEqual(['created', 'running'].includes(targetRun.status), true); - assert.strictEqual(targetRun.completedAt, undefined); + const targetRun = await readInvocation(runStore, session.id, plan.continuation.runId); + assert.strictEqual(targetRun.terminalEvent, undefined); assert.deepStrictEqual( await runStore.readRuntimeEvents(session.id, plan.continuation.runId), [], @@ -6413,11 +6403,11 @@ describe('SessionManager permission mode updates', () => { await manager.recoverInterruptedSessions(); - const recoveredRun = await runStore.readRun(session.id, plan.continuation.runId); + const recoveredRun = await readInvocation(runStore, session.id, plan.continuation.runId); const recoveredEvents = await runStore.readRuntimeEvents(session.id, plan.continuation.runId); - assert.strictEqual(recoveredRun.status, 'failed'); + assert.strictEqual(runtimeInvocationOutcome(recoveredRun), 'failed'); assert.strictEqual( - recoveredRun.failureClass, + runtimeInvocationFailureClass(recoveredRun), 'continuation_abandoned_before_provider_dispatch', ); assert.strictEqual(recoveredEvents.length, 2); @@ -6523,8 +6513,8 @@ describe('SessionManager permission mode updates', () => { await execution.catch(() => []); assert.strictEqual(backendCalls, 0); - const targetRun = await runStore.readRun(session.id, plan.continuation.runId); - assert.strictEqual(targetRun.status, 'cancelled'); + const targetRun = await readInvocation(runStore, session.id, plan.continuation.runId); + assert.strictEqual(runtimeInvocationOutcome(targetRun), 'cancelled'); const targetEvents = await runStore.readRuntimeEvents(session.id, plan.continuation.runId); assert.strictEqual( targetEvents.filter((event) => event.actions?.continuationStart !== undefined).length, @@ -6611,7 +6601,7 @@ describe('SessionManager permission mode updates', () => { ); assert.strictEqual(backendCalls, 0); - await expectRejects(runStore.readRun(session.id, plan.continuation.runId), /unknown run/i); + await expectRejects(readInvocation(runStore, session.id, plan.continuation.runId), /unknown run/i); }); test('revalidates terminal ledger consistency before executing a planned continuation', async () => { @@ -6687,11 +6677,20 @@ describe('SessionManager permission mode updates', () => { }); if (!plan.continuation) throw new Error('expected continuation'); - await runStore.updateRun(session.id, sourceRunId, { - status: 'completed', - updatedAt: 3, - completedAt: 3, - }); + await runStore.appendRuntimeEvent( + session.id, + sourceRunId, + runtimeEvent({ + id: 'source-second-terminal-race', + invocationId: sourceInvocationId, + runId: sourceRunId, + sessionId: session.id, + turnId: sourceTurnId, + ts: 3, + status: 'completed', + actions: { endInvocation: true }, + }), + ); await expectRejects( collectSessionEvents(manager.resumeSafeBoundaryContinuation(plan.continuation)), @@ -6702,7 +6701,7 @@ describe('SessionManager permission mode updates', () => { test('startup recovery retries claim-only terminal projection without dispatching the provider', async () => { const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore({ failUpdateRunStatusOnce: 'failed' }); + const runStore = new MemoryAgentRunStore(); const backends = new BackendRegistry(); let backendCalls = 0; let failOnce = true; @@ -6786,7 +6785,7 @@ describe('SessionManager permission mode updates', () => { collectSessionEvents(manager.resumeSafeBoundaryContinuation(plan.continuation)), /simulated claim-only crash/, ); - await expectRejects(runStore.readRun(session.id, plan.continuation.runId), /unknown run/i); + await expectRejects(readInvocation(runStore, session.id, plan.continuation.runId), /unknown run/i); assert.strictEqual(backendCalls, 0); assert.ok(!(await manager.recoverInterruptedSessions()).includes(session.id)); @@ -6797,26 +6796,19 @@ describe('SessionManager permission mode updates', () => { assert.strictEqual(durableRepairEvents.length, 2); assert.strictEqual(durableRepairEvents.filter(isTerminalRuntimeEvent).length, 1); assert.strictEqual( - (await runStore.readRun(session.id, plan.continuation.runId)).status, - 'created', + (await readInvocation(runStore, session.id, plan.continuation.runId)).terminalEvent, + undefined, ); assert.ok((await manager.recoverInterruptedSessions()).includes(session.id)); - const repairedRun = await runStore.readRun(session.id, plan.continuation.runId); - assert.strictEqual(repairedRun.status, 'failed'); - assert.strictEqual(repairedRun.failureClass, 'continuation_abandoned_before_provider_dispatch'); - assert.strictEqual( - repairedRun.continuationSource && 'protocol' in repairedRun.continuationSource - ? repairedRun.continuationSource.protocol - : undefined, - 'continuation_source_v2', - ); - assert.strictEqual( - repairedRun.continuationSource && 'claimId' in repairedRun.continuationSource - ? repairedRun.continuationSource.claimId - : undefined, - plan.continuation.claimId, - ); + const repairedRun = await readInvocation(runStore, session.id, plan.continuation.runId); + assert.strictEqual(runtimeInvocationOutcome(repairedRun), 'failed'); + assert.strictEqual(runtimeInvocationFailureClass(repairedRun),'continuation_abandoned_before_provider_dispatch'); + assert.partialDeepStrictEqual(repairedRun.opening.source, { + kind: 'continuation', + sourceRunId, + claimId: plan.continuation.claimId, + }); const repairedEvents = await runStore.readRuntimeEvents(session.id, plan.continuation.runId); assert.strictEqual(repairedEvents.length, 2); assert.strictEqual( @@ -6833,7 +6825,7 @@ describe('SessionManager permission mode updates', () => { await manager.recoverInterruptedSessions(); assert.strictEqual( JSON.stringify({ - run: await runStore.readRun(session.id, plan.continuation.runId), + run: await readInvocation(runStore, session.id, plan.continuation.runId), events: await runStore.readRuntimeEvents(session.id, plan.continuation.runId), }), snapshot, @@ -7146,7 +7138,7 @@ describe('SessionManager permission mode updates', () => { ); assert.strictEqual(backendCalls, 0); - await expectRejects(runStore.readRun(session.id, plan.continuation.runId), /Unknown run/); + await expectRejects(readInvocation(runStore, session.id, plan.continuation.runId), /Unknown run/); }); test('fails closed when continuation execution has no authoritative safety inspector', async () => { @@ -7277,7 +7269,7 @@ describe('SessionManager permission mode updates', () => { partialOutputRetained: true, }, ]); - await runStore.createRun( + await seedInvocationFromHeader(runStore, makeRunHeader({ sessionId: session.id, runId: 'run-1', @@ -7456,7 +7448,7 @@ describe('SessionManager permission mode updates', () => { ], ); assert.strictEqual((await store.readHeader(session.id)).transcriptLedgerVersion, 1); - const repairedRuns = await runStore.listSessionRuns(session.id); + const repairedRuns = await runStore.listSessionInvocations(session.id); assert.strictEqual(repairedRuns.filter((run) => run.turnId === 'turn-1').length, 1); assert.strictEqual(repairedRuns.filter((run) => run.turnId === 'turn-2').length, 1); }); @@ -7482,7 +7474,7 @@ describe('SessionManager permission mode updates', () => { /history is still being prepared/, ); - assert.strictEqual((await runStore.listSessionRuns(session.id)).length, 0); + assert.strictEqual((await runStore.listSessionInvocations(session.id)).length, 0); }); test('sendMessage rejects prior runtime context without a valid terminal fact', async () => { @@ -7561,12 +7553,12 @@ describe('SessionManager permission mode updates', () => { /valid terminal fact/, ); assert.strictEqual(backend?.sendInputs.length ?? 0, 0); - const currentRun = (await runStore.listSessionRuns(session.id)).find( + const currentRun = (await runStore.listSessionInvocations(session.id)).find( (run) => run.turnId === 'turn-2', ); if (!currentRun) throw new Error('current AgentRunStore run was not created'); - assert.strictEqual(currentRun.status, 'failed'); - assert.strictEqual(currentRun.failureClass, 'missing_terminal_event'); + assert.strictEqual(runtimeInvocationOutcome(currentRun), 'failed'); + assert.strictEqual(runtimeInvocationFailureClass(currentRun), 'missing_terminal_event'); const currentTerminalEvents = ( await runStore.readRuntimeEvents(session.id, currentRun.runId) ).filter(isTerminalRuntimeEvent); @@ -7658,7 +7650,6 @@ describe('SessionManager permission mode updates', () => { store.failReadMessagesFor.add(session.id); const view = await new RuntimeReadModel({ - runStore, runtimeEventStore: runStore, }).getSessionView(session.id); @@ -7740,7 +7731,6 @@ describe('SessionManager permission mode updates', () => { ); const cachedView = await new RuntimeReadModel({ - runStore, runtimeEventStore: runStore, projectionCache: { readMessages: async () => @@ -7826,7 +7816,6 @@ describe('SessionManager permission mode updates', () => { await seedCanonicalPermissionRun(runStore, header); await assert.rejects( new RuntimeReadModel({ - runStore, runtimeEventStore: runStore, canonicalPermissionOutcomes: { readPermissionOutcome: async () => outcome, @@ -7892,7 +7881,6 @@ describe('SessionManager permission mode updates', () => { initialWorkersStarted = resolve; }); const viewPromise = new RuntimeReadModel({ - runStore, runtimeEventStore: runStore, canonicalPermissionOutcomes: { readPermissionOutcome: async (requestId) => { @@ -7959,12 +7947,11 @@ describe('SessionManager permission mode updates', () => { }); const view = await new RuntimeReadModel({ - runStore, runtimeEventStore: runStore, }).getSessionView(session.id); assert.deepStrictEqual( - view.runs.map((run) => run.runId), + view.invocations.map((run) => run.runId), ['parent-run'], ); assert.deepStrictEqual( @@ -8216,69 +8203,6 @@ describe('SessionManager permission mode updates', () => { ); }); - test('getMessages can retry repair when the failed header update is interrupted', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore({ failUpdateRunOnce: true }); - const manager = makeManagerForReadCutover(store, runStore); - const session = await manager.createSession(makeInput()); - await store.appendMessages(session.id, [ - { type: 'user', id: 'legacy-user', turnId: 'turn-1', ts: 101, text: 'question' }, - { - type: 'assistant', - id: 'legacy-assistant', - turnId: 'turn-1', - ts: 102, - text: 'answer', - modelId: 'fake-model', - }, - ]); - await seedRuntimeRun( - runStore, - makeRunHeader({ - sessionId: session.id, - runId: 'run-1', - turnId: 'turn-1', - status: 'completed', - createdAt: 100, - updatedAt: 103, - completedAt: 103, - }), - [ - runtimeEvent({ - id: 'rt-user', - sessionId: session.id, - runId: 'run-1', - turnId: 'turn-1', - ts: 101, - role: 'user', - author: 'user', - content: { kind: 'text', text: 'question' }, - }), - runtimeEvent({ - id: 'rt-assistant', - sessionId: session.id, - runId: 'run-1', - turnId: 'turn-1', - ts: 102, - role: 'model', - author: 'agent', - content: { kind: 'text', text: 'answer' }, - }), - ], - ); - - await expectRejects(manager.getMessages(session.id), /update run failed/); - - const messages = await manager.getMessages(session.id); - const repairedRun = await runStore.readRun(session.id, 'run-1'); - const runtimeEvents = await runStore.readRuntimeEvents(session.id, 'run-1'); - - assert.strictEqual(repairedRun.status, 'failed'); - assert.strictEqual(repairedRun.failureClass, 'missing_terminal_event'); - assert.strictEqual(messages.at(-1)?.type, 'turn_state'); - assert.strictEqual(runtimeEvents.filter((event) => event.status === 'failed').length, 1); - }); - test('getMessages repairs missing failed header class from an existing terminal RuntimeEvent', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); @@ -8350,10 +8274,10 @@ describe('SessionManager permission mode updates', () => { const messages = await manager.getMessages(session.id); await manager.getMessages(session.id); - const repairedRun = await runStore.readRun(session.id, 'run-1'); + const repairedRun = await readInvocation(runStore, session.id, 'run-1'); const runtimeEvents = await runStore.readRuntimeEvents(session.id, 'run-1'); - assert.strictEqual(repairedRun.failureClass, 'tool_failed'); + assert.strictEqual(runtimeInvocationFailureClass(repairedRun),'tool_failed'); assert.deepStrictEqual(messages.at(-1), { type: 'turn_state', id: 'rt-failed', @@ -8431,10 +8355,10 @@ describe('SessionManager permission mode updates', () => { await manager.getMessages(session.id); await manager.getMessages(session.id); - const repairedRun = await runStore.readRun(session.id, 'run-1'); + const repairedRun = await readInvocation(runStore, session.id, 'run-1'); const runtimeEvents = await runStore.readRuntimeEvents(session.id, 'run-1'); - assert.strictEqual(repairedRun.failureClass, 'missing_terminal_event'); + assert.strictEqual(runtimeInvocationFailureClass(repairedRun),'missing_terminal_event'); assert.strictEqual(runtimeEvents.filter((event) => event.status === 'failed').length, 1); }); @@ -8697,7 +8621,7 @@ describe('SessionManager permission mode updates', () => { }, ]; await store.appendMessages(session.id, activeMessages); - await runStore.createRun( + await seedInvocationFromHeader(runStore, makeRunHeader({ sessionId: session.id, runId: 'run-2', @@ -8726,7 +8650,6 @@ describe('SessionManager permission mode updates', () => { ]); const view = await new RuntimeReadModel({ - runStore, runtimeEventStore: runStore, projectionCache: store, }).getSessionView(session.id); @@ -8752,7 +8675,7 @@ describe('SessionManager permission mode updates', () => { createdAt: 100, updatedAt: 125, }); - await runStore.createRun(header); + await seedInvocationFromHeader(runStore, header); await store.appendMessages(session.id, [ { type: 'user', @@ -8861,7 +8784,7 @@ describe('SessionManager permission mode updates', () => { createdAt: 100, updatedAt: 125, }); - await runStore.createRun(header); + await seedInvocationFromHeader(runStore, header); await store.appendMessages(session.id, [ { type: 'user', id: 'active-user', turnId: header.turnId, ts: 100, text: 'build it' }, { @@ -8920,7 +8843,6 @@ describe('SessionManager permission mode updates', () => { ); const view = await new RuntimeReadModel({ - runStore, runtimeEventStore: runStore, projectionCache: store, }).getSessionView(session.id); @@ -9195,10 +9117,10 @@ describe('SessionManager permission mode updates', () => { await stopping; assert.strictEqual((await firstEvent).done, true); assert.deepStrictEqual(backend?.sendInputs, []); - const regenerated = (await runStore.listSessionRuns(session.id)).find( + const regenerated = (await runStore.listSessionInvocations(session.id)).find( (run) => run.turnId === 'regen-stopped-preflight', ); - assert.strictEqual(regenerated?.status, 'cancelled'); + assert.strictEqual(regenerated && runtimeInvocationOutcome(regenerated), 'cancelled'); assert.strictEqual((await store.readHeader(session.id)).status === 'blocked', false); }); @@ -9431,62 +9353,6 @@ describe('SessionManager permission mode updates', () => { assert.strictEqual(turnState.errorClass, 'tool_failed'); }); - 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(); - 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; - }); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - newId: nextId(), - now: nextNow(6_812), - }); - const session = await manager.createSession(makeInput()); - - 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'); - assert.strictEqual(firstRun.status, 'running'); - const firstRuntimeEvents = await runStore.readRuntimeEvents(session.id, firstRun.runId); - const firstTerminalEvents = firstRuntimeEvents.filter(isTerminalRuntimeEvent); - assert.strictEqual(firstTerminalEvents.length, 1); - assert.strictEqual(firstTerminalEvents[0]?.status, 'failed'); - assert.strictEqual(firstTerminalEvents[0]?.actions?.stateDelta?.failureClass, 'runtime_error'); - - 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'); - assert.deepStrictEqual( - secondInput.runtimeContext?.map((event) => event.turnId), - ['turn-1', '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'); - assert.strictEqual(turnState.status, 'failed'); - assert.strictEqual(turnState.errorClass, 'runtime_error'); - const terminalEventsAfterSecondTurn = ( - await runStore.readRuntimeEvents(session.id, firstRun.runId) - ).filter(isTerminalRuntimeEvent); - assert.strictEqual(terminalEventsAfterSecondTurn.length, 1); - }); - test('next parent turn excludes child run RuntimeEvents from model context', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); @@ -9508,8 +9374,9 @@ describe('SessionManager permission mode updates', () => { const session = await manager.createSession(makeInput()); await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'first' })); - const [parentRun] = await runStore.listSessionRuns(session.id); + const [parentRun] = await runStore.listSessionInvocations(session.id); if (!parentRun) throw new Error('parent run was not recorded'); + const parentRunEndedAt = parentRun.terminalEvent?.ts ?? parentRun.openedAt; await seedRuntimeRun( runStore, makeRunHeader({ @@ -9517,9 +9384,9 @@ describe('SessionManager permission mode updates', () => { runId: 'child-run', turnId: 'child-turn', status: 'completed', - createdAt: parentRun.updatedAt + 1, - updatedAt: parentRun.updatedAt + 4, - completedAt: parentRun.updatedAt + 4, + createdAt: parentRunEndedAt + 1, + updatedAt: parentRunEndedAt + 4, + completedAt: parentRunEndedAt + 4, parentRunId: parentRun.runId, agentName: 'Researcher', }), @@ -9529,7 +9396,7 @@ describe('SessionManager permission mode updates', () => { sessionId: session.id, runId: 'child-run', turnId: 'child-turn', - ts: parentRun.updatedAt + 2, + ts: parentRunEndedAt + 2, role: 'user', author: 'user', content: { kind: 'text', text: 'child prompt' }, @@ -9539,7 +9406,7 @@ describe('SessionManager permission mode updates', () => { sessionId: session.id, runId: 'child-run', turnId: 'child-turn', - ts: parentRun.updatedAt + 3, + ts: parentRunEndedAt + 3, role: 'model', author: 'agent', content: { kind: 'text', text: 'child private answer' }, @@ -9549,7 +9416,7 @@ describe('SessionManager permission mode updates', () => { sessionId: session.id, runId: 'child-run', turnId: 'child-turn', - ts: parentRun.updatedAt + 4, + ts: parentRunEndedAt + 4, role: 'system', author: 'system', status: 'completed', @@ -9630,14 +9497,15 @@ describe('SessionManager permission mode updates', () => { backend?.sendInputs.map((input) => input.turnId), ['active-parent-turn'], ); - const runs = await runStore.listSessionRuns(session.id); - assert.strictEqual( - runs.find((run) => run.turnId === 'active-parent-turn')?.status, - 'cancelled', - ); - assert.strictEqual( - runs.find((run) => run.turnId === 'pending-parent-turn')?.status, - 'cancelled', + const runs = await runStore.listSessionInvocations(session.id); + assert.deepStrictEqual( + runs + .filter((run) => run.turnId.endsWith('-parent-turn')) + .map((run) => [run.turnId, runtimeInvocationOutcome(run)]), + [ + ['active-parent-turn', 'cancelled'], + ['pending-parent-turn', 'cancelled'], + ], ); assert.strictEqual((await store.readHeader(session.id)).status, 'aborted'); }); @@ -9683,12 +9551,9 @@ describe('SessionManager permission mode updates', () => { releaseBuild.release(); await expectRejects(firstEvent, /backend activation timed out/); await stopping; - const run = await runStore.readRun( - session.id, - (await runStore.listSessionRuns(session.id))[0]!.runId, - ); - assert.strictEqual(run.status, 'cancelled'); - assert.strictEqual(run.failureClass, undefined); + const run = (await runStore.listSessionInvocations(session.id))[0]!; + assert.strictEqual(runtimeInvocationOutcome(run), 'cancelled'); + assert.strictEqual(runtimeInvocationFailureClass(run), undefined); assert.strictEqual((await store.readHeader(session.id)).status, 'aborted'); }); @@ -9734,8 +9599,8 @@ describe('SessionManager permission mode updates', () => { assert.strictEqual(factorySignal?.aborted, true); assert.strictEqual((await firstEvent).done, true); assert.strictEqual(dispatches, 0); - const [run] = await runStore.listSessionRuns(session.id); - assert.strictEqual(run?.status, 'cancelled'); + const [run] = await runStore.listSessionInvocations(session.id); + assert.strictEqual(run && runtimeInvocationOutcome(run), 'cancelled'); }); test('node timers AbortError is cancellation only when its cause is this execution stop', async () => { @@ -9770,9 +9635,9 @@ describe('SessionManager permission mode updates', () => { await manager.stopSession(session.id, { source: 'stop_button' }); assert.strictEqual((await firstEvent).done, true); - const [run] = await runStore.listSessionRuns(session.id); - assert.strictEqual(run?.status, 'cancelled'); - assert.strictEqual(run?.failureClass, undefined); + const [run] = await runStore.listSessionInvocations(session.id); + assert.strictEqual(run && runtimeInvocationOutcome(run), 'cancelled'); + assert.strictEqual(run && runtimeInvocationFailureClass(run),undefined); }); test('late ignored-signal backend is disposed once and never cached or dispatched', async () => { @@ -9829,8 +9694,8 @@ describe('SessionManager permission mode updates', () => { assert.strictEqual((await firstEvent).done, true); assert.strictEqual(firstDisposeCalls, 1); assert.strictEqual(firstDispatches, 0); - const [stoppedRun] = await runStore.listSessionRuns(session.id); - assert.strictEqual(stoppedRun?.status, 'cancelled'); + const [stoppedRun] = await runStore.listSessionInvocations(session.id); + assert.strictEqual(stoppedRun && runtimeInvocationOutcome(stoppedRun), 'cancelled'); await drain( manager.sendMessage(session.id, { @@ -9901,8 +9766,8 @@ describe('SessionManager permission mode updates', () => { await Promise.all([streamRejection, stopping]); assert.strictEqual(disposeCalls, 1); - const [run] = await runStore.listSessionRuns(session.id); - assert.strictEqual(run?.status, 'cancelled'); + const [run] = await runStore.listSessionInvocations(session.id); + assert.strictEqual(run && runtimeInvocationOutcome(run), 'cancelled'); await expectRejects( drain( @@ -10022,10 +9887,10 @@ describe('SessionManager permission mode updates', () => { backend?.sendInputs.map((input) => input.turnId), ['turn-warm-cache'], ); - const registeringRun = (await runStore.listSessionRuns(session.id)).find( + const registeringRun = (await runStore.listSessionInvocations(session.id)).find( (run) => run.turnId === 'turn-registering', ); - assert.strictEqual(registeringRun?.status, 'cancelled'); + assert.strictEqual(registeringRun && runtimeInvocationOutcome(registeringRun), 'cancelled'); assert.strictEqual( (await runStore.readRuntimeEvents(session.id, registeringRun!.runId)).filter( isTerminalRuntimeEvent, @@ -10073,10 +9938,10 @@ describe('SessionManager permission mode updates', () => { releaseHook.release(); assert.strictEqual((await firstEvent).done, true); assert.deepStrictEqual(backend?.sendInputs, []); - const stoppedRun = (await runStore.listSessionRuns(session.id)).find( + const stoppedRun = (await runStore.listSessionInvocations(session.id)).find( (run) => run.turnId === 'turn-post-start-hook-stop', ); - assert.strictEqual(stoppedRun?.status, 'cancelled'); + assert.strictEqual(stoppedRun && runtimeInvocationOutcome(stoppedRun), 'cancelled'); }); test('concurrent cold turns share one backend generation without accepting an ownerless response', async () => { @@ -10438,7 +10303,7 @@ describe('SessionManager permission mode updates', () => { assert.strictEqual(list.runs[0]?.durationMs, 10); const output = await manager.readChildAgentOutput(session.id, { runId: 'child-run' }); - assert.strictEqual(output.header.runId, 'child-run'); + assert.strictEqual(output.invocation.runId, 'child-run'); assert.deepStrictEqual( output.runtimeEvents.map((event) => event.id), ['child-user', 'child-answer', 'child-complete'], @@ -10532,8 +10397,8 @@ describe('SessionManager permission mode updates', () => { assert.strictEqual(list.runs[0]?.durationMs, 10); const output = await manager.readChildAgentOutput(session.id, { runId: 'child-run' }); - assert.strictEqual(output.header.status, 'completed'); - assert.strictEqual(output.header.completedAt, 140); + assert.strictEqual(runtimeInvocationOutcome(output.invocation), 'completed'); + assert.strictEqual(output.invocation.terminalEvent?.ts, 140); }); test('agent output returns a bounded child inspection instead of full replay internals', async () => { @@ -10563,7 +10428,7 @@ describe('SessionManager permission mode updates', () => { agentName: 'Researcher', permissionMode: 'explore', }); - await runStore.createRun(header); + await seedInvocationFromHeader(runStore, header); for (let index = 0; index < 25; index += 1) { await runStore.appendEvent( session.id, @@ -10599,7 +10464,7 @@ describe('SessionManager permission mode updates', () => { view: 'all', }); - assert.strictEqual(output.header.runId, 'child-run'); + assert.strictEqual(output.invocation.runId, 'child-run'); assert.deepStrictEqual( output.events.map((event) => event.id), ['op-20', 'op-21', 'op-22', 'op-23', 'op-24'], @@ -10630,7 +10495,7 @@ describe('SessionManager permission mode updates', () => { now: nextNow(6_849), }); const session = await manager.createSession(makeInput()); - await runStore.createRun( + await seedInvocationFromHeader(runStore, makeRunHeader({ sessionId: session.id, runId: 'child-run', @@ -10729,10 +10594,10 @@ describe('SessionManager permission mode updates', () => { (candidate) => candidate.turnId === 'turn-1', ); assert.strictEqual(turn?.status, 'failed'); - const [run] = await runStore.listSessionRuns(session.id); + const [run] = await runStore.listSessionInvocations(session.id); if (!run) throw new Error('AgentRunStore run was not created'); - assert.strictEqual(run.status, 'failed'); - assert.strictEqual(run.failureClass, 'missing_terminal_event'); + assert.strictEqual(runtimeInvocationOutcome(run), 'failed'); + assert.strictEqual(runtimeInvocationFailureClass(run), 'missing_terminal_event'); const terminalEvents = (await runStore.readRuntimeEvents(session.id, run.runId)).filter( isTerminalRuntimeEvent, ); @@ -10780,8 +10645,8 @@ describe('SessionManager permission mode updates', () => { }); assert.strictEqual((await store.readHeader(session.id)).status, 'waiting_for_user'); - const [run] = await runStore.listSessionRuns(session.id); - assert.strictEqual(run?.status, 'waiting_for_user'); + const [run] = await runStore.listSessionInvocations(session.id); + assert.strictEqual(run?.terminalEvent, undefined); await expectRejects(manager.setPermissionMode(session.id, 'bypass'), /当前任务正在运行/); assert.strictEqual((await store.readHeader(session.id)).permissionMode, 'ask'); @@ -10956,7 +10821,6 @@ describe('SessionManager permission mode updates', () => { assert.strictEqual(turns.find((turn) => turn.turnId === 'turn-1')?.status, 'completed'); const view = await new RuntimeReadModel({ - runStore, runtimeEventStore: runStore, }).getSessionView(session.id); assert.strictEqual( @@ -10990,7 +10854,7 @@ describe('SessionManager permission mode updates', () => { // drain } - const [run] = await runStore.listSessionRuns(session.id); + const [run] = await runStore.listSessionInvocations(session.id); await runStore.appendRuntimeEvent( session.id, run!.runId, @@ -11007,7 +10871,7 @@ describe('SessionManager permission mode updates', () => { ); await assert.rejects( - new RuntimeReadModel({ runStore, runtimeEventStore: runStore }).getSessionView(session.id), + new RuntimeReadModel({ runtimeEventStore: runStore }).getSessionView(session.id), (error: unknown) => error instanceof RuntimeReadModelError && error.diagnostics.some((diagnostic) => diagnostic.code === 'unsupported_event'), @@ -11041,8 +10905,8 @@ describe('SessionManager permission mode updates', () => { const [turn] = await store.listTurns(session.id); assert.strictEqual(turn?.status, 'failed'); assert.strictEqual(turn?.errorClass, 'runtime_error'); - const [run] = await runStore.listSessionRuns(session.id); - assert.strictEqual(run?.failureClass, 'runtime_error'); + const [run] = await runStore.listSessionInvocations(session.id); + assert.strictEqual(run && runtimeInvocationFailureClass(run),'runtime_error'); }); test('marks an explicit step limit incomplete without blocking the session', async () => { @@ -11069,9 +10933,9 @@ describe('SessionManager permission mode updates', () => { const [turn] = await store.listTurns(session.id); assert.strictEqual(turn?.status, 'failed'); assert.strictEqual(turn?.errorClass, 'tool_step_cap_reached'); - const [run] = await runStore.listSessionRuns(session.id); - assert.strictEqual(run?.status, 'failed'); - assert.strictEqual(run?.failureClass, 'tool_step_cap_reached'); + const [run] = await runStore.listSessionInvocations(session.id); + assert.strictEqual(run && runtimeInvocationOutcome(run), 'failed'); + assert.strictEqual(run && runtimeInvocationFailureClass(run),'tool_step_cap_reached'); const terminal = (await runStore.readRuntimeEvents(session.id, run!.runId)).find( (event) => event.actions?.endInvocation, ); @@ -11166,7 +11030,7 @@ describe('SessionManager permission mode updates', () => { await stopPromise; while (!(await iterator.next()).done) {} - const [run] = await runStore.listSessionRuns(session.id); + const [run] = await runStore.listSessionInvocations(session.id); const runtimeEvents = await runStore.readRuntimeEvents(session.id, run!.runId); const terminalEvents = runtimeEvents.filter((event) => event.status === 'aborted'); assert.strictEqual(terminalEvents.length, 1); @@ -11218,7 +11082,7 @@ describe('SessionManager permission mode updates', () => { const emitted = await collectSessionEvents( manager.sendMessage(session.id, { turnId: 'turn-1', text: 'hello' }), ); - const [run] = await runStore.listSessionRuns(session.id); + const [run] = await runStore.listSessionInvocations(session.id); const runtimeEvents = await runStore.readRuntimeEvents(session.id, run!.runId); const turnStates = (await store.readMessages(session.id)).filter( (message) => @@ -11240,8 +11104,8 @@ describe('SessionManager permission mode updates', () => { const abortedEvents = runtimeEvents.filter((event) => event.status === 'aborted'); assert.strictEqual(abortedEvents.length, 1); assert.strictEqual(abortedEvents[0]?.actions?.stateDelta?.abortSource, 'user_stop'); - assert.strictEqual(run?.status, 'cancelled'); - assert.strictEqual(run?.abortSource, 'user_stop'); + assert.strictEqual(run && runtimeInvocationOutcome(run), 'cancelled'); + assert.strictEqual(run?.terminalEvent?.actions?.stateDelta?.abortSource, 'user_stop'); assert.strictEqual(turnStates.length, 1); assert.strictEqual( turnStates[0]?.type === 'turn_state' ? turnStates[0].status : undefined, @@ -11271,7 +11135,7 @@ describe('SessionManager permission mode updates', () => { const emitted = await collectSessionEvents( manager.sendMessage(session.id, { turnId: 'turn-1', text: 'hello' }), ); - const [run] = await runStore.listSessionRuns(session.id); + const [run] = await runStore.listSessionInvocations(session.id); const runtimeEvents = await runStore.readRuntimeEvents(session.id, run!.runId); const turnStates = (await store.readMessages(session.id)).filter( (message) => @@ -11284,7 +11148,7 @@ describe('SessionManager permission mode updates', () => { emitted.map((event) => event.type), ['text_delta', 'complete'], ); - assert.strictEqual(run?.status, 'completed'); + assert.strictEqual(run && runtimeInvocationOutcome(run), 'completed'); assert.deepStrictEqual( runtimeEvents .filter((event) => event.role === 'model' && event.content?.kind === 'text') @@ -11331,9 +11195,9 @@ describe('SessionManager permission mode updates', () => { const [turn] = await store.listTurns(session.id); assert.strictEqual(turn?.status, 'aborted'); assert.strictEqual(turn?.abortSource, 'renderer.stop_button'); - const [run] = await runStore.listSessionRuns(session.id); - assert.strictEqual(run?.status, 'cancelled'); - assert.strictEqual(run?.failureClass, undefined); + const [run] = await runStore.listSessionInvocations(session.id); + assert.strictEqual(run && runtimeInvocationOutcome(run), 'cancelled'); + assert.strictEqual(run && runtimeInvocationFailureClass(run),undefined); const events = (await runStore.readEvents(session.id, run!.runId)).map((event) => event.type); assert.ok(events.includes('run_cancelled')); assert.strictEqual(events.includes('run_failed'), false); @@ -11356,12 +11220,15 @@ describe('SessionManager permission mode updates', () => { await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'hello' })); - const [run] = await runStore.listSessionRuns(session.id); - assert.strictEqual(run?.backendKind, 'ai-sdk'); - assert.strictEqual(run?.llmConnectionSlug, 'fake'); - assert.strictEqual(run?.modelId, 'fake-model'); - assert.strictEqual(run?.permissionMode, 'ask'); - assert.strictEqual(run?.status, 'completed'); + const [run] = await runStore.listSessionInvocations(session.id); + assert.partialDeepStrictEqual(run?.opening.route, { + provenance: 'runtime', + backendKind: 'ai-sdk', + llmConnectionSlug: 'fake', + modelId: 'fake-model', + }); + assert.strictEqual(run?.opening.configuration.permissionMode, 'ask'); + assert.strictEqual(run && runtimeInvocationOutcome(run), 'completed'); const events = await runStore.readEvents(session.id, run!.runId); assert.ok(events.map((event) => event.type).includes('model_stream_started')); assert.ok(events.map((event) => event.type).includes('model_stream_completed')); @@ -11537,7 +11404,7 @@ describe('SessionManager permission mode updates', () => { 'same-coverage-replacement:fulfilled', ]); const checkpoints: HistoryCompactCheckpoint[] = []; - for (const run of await runStore.listSessionRuns(session.id)) { + for (const run of await runStore.listSessionInvocations(session.id)) { for (const event of await runStore.readEvents(session.id, run.runId)) { if (event.type === 'history_compact_checkpoint_recorded') { checkpoints.push(event.data?.checkpoint as HistoryCompactCheckpoint); @@ -11555,7 +11422,7 @@ describe('SessionManager permission mode updates', () => { const writeOutcomes: string[] = []; const runStore = new MemoryAgentRunStore({ beforeAgentRunEventAppend: async (_sessionId, _runId, event) => { - if (event.type === 'run_started') throw new Error('run ledger append failed'); + if (event.type === 'run_created') throw new Error('run ledger append failed'); if (event.type === 'trace_write_failed') runStoreUnavailable.release(); }, }); @@ -11651,7 +11518,7 @@ describe('SessionManager permission mode updates', () => { assert.deepStrictEqual(writeOutcomes, ['cold-stale-after-projection-loss:rejected']); assert.strictEqual(runStore.repairedProjection?.id, durableEvent.id); const checkpointCoverage: number[] = []; - for (const run of await runStore.listSessionRuns(session.id)) { + for (const run of await runStore.listSessionInvocations(session.id)) { for (const event of await runStore.readEvents(session.id, run.runId)) { if (event.type === 'history_compact_checkpoint_recorded') { checkpointCoverage.push( @@ -11899,7 +11766,7 @@ describe('SessionManager permission mode updates', () => { await manager.recoverInterruptedSessions(); - assert.equal((await runStore.readRun(session.id, 'run-1')).status, 'running'); + assert.equal((await readInvocation(runStore, session.id, 'run-1')).terminalEvent, undefined); assert.equal(outcomeCommitAttempts, 2); assert.equal( (await runStore.readRuntimeEvents(session.id, 'run-1')).some( @@ -11928,7 +11795,10 @@ describe('SessionManager permission mode updates', () => { }, }, ); - assert.equal((await runStore.readRun(session.id, 'run-1')).status, 'failed'); + assert.equal( + runtimeInvocationOutcome(await readInvocation(runStore, session.id, 'run-1')), + 'failed', + ); }); test('startup recovery does not leave stale permission waits stuck', async () => { @@ -11980,9 +11850,9 @@ describe('SessionManager permission mode updates', () => { // This turn owned the pending request, so its failure names the closure // rather than the bare restart. assert.strictEqual(turn?.errorClass, 'sandbox_boundary_closed_by_restart'); - const [run] = await runStore.listSessionRuns(session.id); - assert.strictEqual(run?.status, 'failed'); - assert.strictEqual(run?.failureClass, 'sandbox_boundary_closed_by_restart'); + const [run] = await runStore.listSessionInvocations(session.id); + assert.strictEqual(run && runtimeInvocationOutcome(run), 'failed'); + assert.strictEqual(run && runtimeInvocationFailureClass(run),'sandbox_boundary_closed_by_restart'); assert.deepStrictEqual(await store.listPendingSandboxBoundaryRequests(session.id), []); }); @@ -12485,10 +12355,12 @@ class CompactingTestBackend extends TestBackend { this.compactCalls.push({ turnId: input.turnId, runtimeContextCount: input.runtimeContext.length, - sourceRoutes: (input.runtimeContextRunHeaders ?? []).map((run) => ({ + sourceRoutes: (input.runtimeContextInvocations ?? []).map((run) => ({ runId: run.runId, - ...(run.llmConnectionId ? { connectionId: run.llmConnectionId } : {}), - modelId: run.modelId, + ...(run.opening.route.provenance === 'runtime' + ? { connectionId: run.opening.route.llmConnectionId } + : {}), + modelId: run.opening.route.modelId, })), }); return compactHistoryResult(); @@ -13619,7 +13491,6 @@ class MemoryAgentRunStore readonly continuationAuthorityCapability = RUNTIME_CONTINUATION_AUTHORITY_V1; listSessionRunsCalls = 0; readEventsCalls = 0; - private headers = new Map(); private events = new Map(); private runtimeEvents = new Map(); private runtimeEventEntries: RuntimeEvent[] = []; @@ -13637,8 +13508,6 @@ class MemoryAgentRunStore failRuntimeEventAppendAfter?: number; failRuntimeEventReads?: boolean; failContinuationClaimReads?: boolean; - failUpdateRunOnce?: boolean; - failUpdateRunStatusOnce?: AgentRunHeader['status']; failContinuationCreate?: boolean; beforeListSessionRuns?: (sessionId: string) => Promise | void; beforeRuntimeEventRead?: (sessionId: string, runId: string) => Promise | void; @@ -13648,69 +13517,15 @@ class MemoryAgentRunStore event: RuntimeEvent, options?: { durable?: boolean }, ) => Promise | void; - beforeRunRead?: (sessionId: string, runId: string) => Promise | void; beforeAgentRunEventAppend?: ( sessionId: string, runId: string, event: AgentRunEvent, ) => Promise | void; beforeAgentRunEventRead?: (sessionId: string, runId: string) => Promise | void; - beforeAgentRunUpdate?: ( - sessionId: string, - runId: string, - patch: Partial, - ) => Promise | void; } = {}, ) {} - async createRun(header: AgentRunHeader): Promise { - if (this.options.failContinuationCreate && header.continuationSource) { - throw new Error('continuation claim create failed'); - } - this.headers.set(key(header.sessionId, header.runId), { ...header }); - return { ...header }; - } - - async updateRun( - sessionId: string, - runId: string, - patch: Partial, - ): Promise { - await this.options.beforeAgentRunUpdate?.(sessionId, runId, patch); - if (this.options.failUpdateRunOnce) { - this.options.failUpdateRunOnce = false; - throw new Error('update run failed'); - } - if (patch.status && patch.status === this.options.failUpdateRunStatusOnce) { - this.options.failUpdateRunStatusOnce = undefined; - throw new Error('update run failed'); - } - const current = await this.readRun(sessionId, runId); - const next = { ...current, ...patch, sessionId, runId }; - this.headers.set(key(sessionId, runId), next); - return { ...next }; - } - - async readRun(sessionId: string, runId: string): Promise { - await this.options.beforeRunRead?.(sessionId, runId); - const header = this.headers.get(key(sessionId, runId)); - if (!header) { - const error = new Error(`Unknown run ${runId}`) as NodeJS.ErrnoException; - error.code = 'ENOENT'; - throw error; - } - return { ...header }; - } - - async listSessionRuns(sessionId: string): Promise { - this.listSessionRunsCalls += 1; - await this.options.beforeListSessionRuns?.(sessionId); - return Array.from(this.headers.values()) - .filter((header) => header.sessionId === sessionId) - .sort((a, b) => a.createdAt - b.createdAt || a.runId.localeCompare(b.runId)) - .map((header) => ({ ...header })); - } - seedRootTurnAdmission( sessionId: string, turnId: string, @@ -13940,6 +13755,15 @@ class MemoryAgentRunStore ); return ordered.map((item) => item.event); } + + async listSessionInvocations(sessionId: string): Promise { + this.listSessionRunsCalls += 1; + await this.options.beforeListSessionRuns?.(sessionId); + return runtimeInvocationsFromSessionEvents( + sessionId, + await this.readSessionRuntimeEvents(sessionId), + ); + } } class ContinuationClaimBarrierRunStore extends MemoryAgentRunStore { @@ -13966,8 +13790,8 @@ class ContinuationClaimBarrierRunStore extends MemoryAgentRunStore { this.releaseContinuationClaimReadWaiter?.(); } - override async listSessionRuns(sessionId: string): Promise { - const snapshot = await super.listSessionRuns(sessionId); + override async listSessionInvocations(sessionId: string): Promise { + const snapshot = await super.listSessionInvocations(sessionId); if (!this.continuationClaimBarrierArmed) return snapshot; this.continuationClaimBarrierArmed = false; this.markContinuationClaimRead?.(); @@ -14073,24 +13897,14 @@ class ProviderRetryProgressBackend implements AgentBackend { } class ReverseOrderedAgentRunStore extends MemoryAgentRunStore { - override async listSessionRuns(sessionId: string): Promise { - return (await super.listSessionRuns(sessionId)).reverse(); + override async listSessionInvocations(sessionId: string): Promise { + return (await super.listSessionInvocations(sessionId)).reverse(); } } class OrderingAgentRunStore extends MemoryAgentRunStore { operations: string[] = []; - override async updateRun( - sessionId: string, - runId: string, - patch: Partial, - ): Promise { - const next = await super.updateRun(sessionId, runId, patch); - if (patch.status === 'completed') this.operations.push('completedRunHeader'); - return next; - } - override async appendRuntimeEvent( sessionId: string, runId: string, @@ -14191,6 +14005,13 @@ class MemoryRuntimeEventStore implements RuntimeEventStore { ); return ordered.map((item) => item.event); } + + async listSessionInvocations(sessionId: string): Promise { + return runtimeInvocationsFromSessionEvents( + sessionId, + await this.readSessionRuntimeEvents(sessionId), + ); + } } interface Gate { @@ -14466,7 +14287,7 @@ function testTool(name: string): MakaTool { }; } -function makeRunHeader(overrides: Partial = {}): AgentRunHeader { +function makeRunHeader(overrides: Partial = {}): TestRunHeader { return { runId: 'run-1', sessionId: 'session-1', @@ -14483,10 +14304,230 @@ function makeRunHeader(overrides: Partial = {}): AgentRunHeader }; } +/** + * The facts a test states about a run it is seeding. + * + * Deliberately not a stored record: `seedInvocationFromHeader` turns it into + * the opening fact and, when the test says the run ended, the terminal event + * that ends it. Nothing keeps this shape after the seed. + */ +interface TestRunHeader { + runId: string; + sessionId: string; + turnId: string; + invocationId?: string; + status: 'created' | 'running' | 'waiting_for_user' | 'completed' | 'failed' | 'cancelled'; + backendKind: PersistedBackendKind; + llmConnectionId?: string; + llmConnectionSlug: string; + modelId: string; + providerStateIdentity?: `sha256:${string}`; + cwd: string; + workspaceIdentity?: string; + permissionMode: PermissionMode; + collaborationMode?: 'agent' | 'plan'; + orchestrationMode?: 'default' | 'graph' | 'swarm'; + orchestrationSource?: 'session' | 'turn_override'; + agentSwarmAuthorization?: 'none' | 'session_mode' | 'turn_override'; + toolMode?: ToolMode; + createdAt: number; + updatedAt: number; + completedAt?: number; + failureClass?: string; + failureMessage?: string; + abortSource?: 'stop_button' | 'graph_supervisor'; + goalId?: string; + scheduledTaskId?: string; + legacyAutomationId?: string; + agentGraphWakeId?: string; + agentGraphWakeAttemptId?: string; + parentRunId?: string; + parentTurnId?: string; + parentSessionId?: string; + resumedFromRunId?: string; + retriedFromRunId?: string; + retriedFromTurnId?: string; + regeneratedFromTurnId?: string; + branchOfTurnId?: string; + agentId?: string; + agentName?: string; + continuationSource?: { + sourceInvocationId: string; + sourceRunId: string; + sourceTurnId: string; + sourceRuntimeEventHighWater: number; + claimId?: string; + boundaryDigest?: `sha256:${string}`; + }; +} + +/** The root authority the seeded header names, defaulting to the user. */ +function testInvocationRoot(header: TestRunHeader): RuntimeInvocationRootAuthority { + if (header.goalId) return { kind: 'goal', goalId: header.goalId }; + if (header.scheduledTaskId) { + return { kind: 'scheduled_task', scheduledTaskId: header.scheduledTaskId }; + } + if (header.legacyAutomationId) { + return { kind: 'legacy_automation', legacyAutomationId: header.legacyAutomationId }; + } + if (header.agentGraphWakeId && header.agentGraphWakeAttemptId) { + return { + kind: 'agent_graph_supervisor_wake', + wakeId: header.agentGraphWakeId, + attemptId: header.agentGraphWakeAttemptId, + }; + } + return { kind: 'user' }; +} + +/** Everything the header says about lineage, with the absent edges left out. */ +function testInvocationLineage(header: TestRunHeader): RuntimeInvocationLineage { + return { + ...(header.parentRunId ? { parentRunId: header.parentRunId } : {}), + ...(header.parentTurnId ? { parentTurnId: header.parentTurnId } : {}), + ...(header.parentSessionId ? { parentSessionId: header.parentSessionId } : {}), + ...(header.resumedFromRunId ? { resumedFromRunId: header.resumedFromRunId } : {}), + ...(header.retriedFromRunId ? { retriedFromRunId: header.retriedFromRunId } : {}), + ...(header.retriedFromTurnId ? { retriedFromTurnId: header.retriedFromTurnId } : {}), + ...(header.regeneratedFromTurnId + ? { regeneratedFromTurnId: header.regeneratedFromTurnId } + : {}), + ...(header.branchOfTurnId ? { branchOfTurnId: header.branchOfTurnId } : {}), + ...(header.agentId ? { agentId: header.agentId } : {}), + ...(header.agentName ? { agentName: header.agentName } : {}), + }; +} + +function testInvocationOpening(header: TestRunHeader): RuntimeEventInvocationOpenedContent { + const lineage = testInvocationLineage(header); + return { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: + header.llmConnectionId === undefined + ? { + provenance: 'unknown', + backendKind: header.backendKind, + llmConnectionSlug: header.llmConnectionSlug, + modelId: header.modelId, + } + : { + provenance: 'runtime', + backendKind: header.backendKind, + llmConnectionId: header.llmConnectionId, + llmConnectionSlug: header.llmConnectionSlug, + modelId: header.modelId, + ...(header.providerStateIdentity + ? { providerStateIdentity: header.providerStateIdentity } + : {}), + }, + configuration: { + cwd: header.cwd, + permissionMode: header.permissionMode, + collaborationMode: header.collaborationMode ?? 'agent', + orchestrationMode: header.orchestrationMode ?? 'default', + orchestrationSource: header.orchestrationSource ?? 'session', + toolMode: header.toolMode ?? DEFAULT_TOOL_MODE, + ...(header.agentSwarmAuthorization + ? { agentSwarmAuthorization: header.agentSwarmAuthorization } + : {}), + ...(header.workspaceIdentity ? { workspaceIdentity: header.workspaceIdentity } : {}), + }, + root: testInvocationRoot(header), + source: header.continuationSource + ? { kind: 'continuation', ...header.continuationSource } + : { kind: 'fresh' }, + ...(Object.keys(lineage).length > 0 ? { lineage } : {}), + }; +} + +/** + * Open the invocation the header describes, and close it when the header says + * the run ended. Seeding writes events because events are all there is. + */ +async function seedInvocationOpening( + store: Pick, + header: TestRunHeader, +): Promise { + await store.appendRuntimeEvent( + header.sessionId, + header.runId, + buildInvocationOpenedEvent({ + id: `${header.runId}-invocation-opened`, + run: runIdentityOf(header), + openedAt: header.createdAt, + opening: testInvocationOpening(header), + }), + ); +} + +/** The one event that ends the run, when the header says the run ended. */ +async function seedInvocationTerminal( + store: Pick, + header: TestRunHeader, +): Promise { + if (header.status !== 'completed' && header.status !== 'failed' && header.status !== 'cancelled') + return; + await store.appendRuntimeEvent(header.sessionId, header.runId, { + id: `${header.runId}-terminal`, + ...runIdentityOf(header), + ts: header.completedAt ?? header.updatedAt, + partial: false, + role: 'system', + author: 'system', + status: header.status === 'cancelled' ? 'aborted' : header.status, + ...(header.failureClass ? { failureClass: header.failureClass } : {}), + ...(header.abortSource ? { abortSource: header.abortSource } : {}), + ...(header.failureMessage + ? { content: { kind: 'error' as const, message: header.failureMessage } } + : {}), + }); +} + +function runIdentityOf(header: TestRunHeader): { + sessionId: string; + invocationId: string; + runId: string; + turnId: string; +} { + return { + sessionId: header.sessionId, + invocationId: header.invocationId ?? header.runId, + runId: header.runId, + turnId: header.turnId, + }; +} + +async function seedInvocationFromHeader( + store: Pick, + header: TestRunHeader, +): Promise { + await seedInvocationOpening(store, header); + await seedInvocationTerminal(store, header); + return header; +} + +/** The one invocation that opened this run. */ +async function readInvocation( + store: Pick, + sessionId: string, + runId: string, +): Promise { + const found = (await store.listSessionInvocations(sessionId)).find( + (candidate) => candidate.runId === runId, + ); + if (!found) { + const error = new Error(`Unknown run ${runId}`) as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + } + return found; +} + function makeRunEvent(overrides: Partial = {}): EmittedAgentRunEvent { return { - type: 'run_started', - id: `${overrides.runId ?? 'run-1'}-${overrides.type ?? 'run_started'}-${overrides.ts ?? 10}`, + type: 'turn_started', + id: `${overrides.runId ?? 'run-1'}-${overrides.type ?? 'turn_started'}-${overrides.ts ?? 10}`, runId: 'run-1', sessionId: 'session-1', turnId: 'turn-1', @@ -14629,7 +14670,7 @@ async function seedRuntimeReadTurnWithHeader(input: { userText: string; assistantText: string; legacyIdPrefix: string; - header: Partial; + header: Partial; tsBase: number; }): Promise { const header = makeRunHeader({ @@ -14715,25 +14756,34 @@ async function seedRuntimeReadTurnWithHeader(input: { } async function seedRun( - runStore: AgentRunStore, - header: AgentRunHeader, + runStore: AgentRunStore & RuntimeEventStore, + header: TestRunHeader, events: EmittedAgentRunEvent[], ): Promise { - await runStore.createRun(header); + await seedInvocationFromHeader(runStore, header); for (const event of events) { await runStore.appendEvent(header.sessionId, header.runId, event); } } +/** + * Seed one invocation whose ledger the test writes itself. + * + * The opening always comes first. The terminal event comes from the header only + * when the test did not already state one, so a run never ends twice. + */ async function seedRuntimeRun( - runStore: AgentRunStore & RuntimeEventStore, - header: AgentRunHeader, + runStore: RuntimeEventStore, + header: TestRunHeader, events: RuntimeEvent[], ): Promise { - await runStore.createRun(header); + await seedInvocationOpening(runStore, header); for (const event of events) { await runStore.appendRuntimeEvent(header.sessionId, header.runId, event); } + if (!events.some((event) => event.status !== undefined)) { + await seedInvocationTerminal(runStore, header); + } } function runtimeEvent(overrides: Partial): RuntimeEvent { @@ -14753,7 +14803,7 @@ function runtimeEvent(overrides: Partial): RuntimeEvent { async function seedCanonicalPermissionRun( runStore: MemoryAgentRunStore, - header: AgentRunHeader, + header: TestRunHeader, includeLedgerRequest = true, ): Promise { const events = [ @@ -14818,7 +14868,7 @@ async function seedCanonicalPermissionRun( } function canonicalPermissionRecord( - header: AgentRunHeader, + header: TestRunHeader, overrides: Partial = {}, ): CanonicalPermissionOutcomeRecord { return { @@ -14927,7 +14977,7 @@ async function seedBoundaryRestartSession(input: { sessionId: session.id, runId: 'run-1', turnId: 'turn-1', - type: 'run_started', + type: 'turn_started', ts: 11, }), ], From e038289f1cd498eba373886994cb431f0d3f78ac Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 12:44:03 +0800 Subject: [PATCH 14/46] test(runtime): seed and read runs through the event spine Conversation copy, context diagnostics, continuation planning and the terminal-ledger suite all seeded runs by writing a header and then asserted against that header. Each now opens an invocation and reads the invocation back, which is where those facts live. Two terminal-ledger tests went with the mechanism they covered. One proved the read model prefers the terminal event when the header is stale; the other proved recovery synthesizes a terminal event for a header whose ledger has none. Neither state can occur once the events are the only record. Generated-by: Claude Code --- .../src/__tests__/context-diagnostics.test.ts | 142 ++-- .../src/__tests__/conversation-copy.test.ts | 345 +++++--- .../__tests__/runtime-continuation.test.ts | 191 +++-- .../session-manager-terminal-ledger.test.ts | 755 ++++++------------ 4 files changed, 608 insertions(+), 825 deletions(-) diff --git a/packages/runtime/src/__tests__/context-diagnostics.test.ts b/packages/runtime/src/__tests__/context-diagnostics.test.ts index 9754ab9aaf..3c20eb546c 100644 --- a/packages/runtime/src/__tests__/context-diagnostics.test.ts +++ b/packages/runtime/src/__tests__/context-diagnostics.test.ts @@ -25,7 +25,6 @@ import { test } from 'node:test'; import { DatabaseSync } from 'node:sqlite'; import type { AgentRunEvent, - AgentRunHeader, AgentRunStore, EmittedAgentRunEvent, } from '@maka/core/agent-run'; @@ -79,7 +78,6 @@ test('serves the sealed snapshot without reading a single run', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); try { const writer = createSqliteAgentRunStore(root); - await writer.createRun(runHeader('run-1', 1)); await writer.appendEvent( 'session-1', 'run-1', @@ -93,7 +91,7 @@ test('serves the sealed snapshot without reading a single run', async () => { scanned += 1; }); - const diagnostics = await readLatestContextDiagnostics(counted, 'session-1'); + const diagnostics = await readLatestContextDiagnostics(counted, 'session-1', ['run-1']); assert.equal(diagnostics.status, 'available'); if (diagnostics.status !== 'available') return; @@ -108,7 +106,6 @@ test('does not trust a pre-observation projection over its canonical attempt', a const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); try { const writer = createSqliteAgentRunStore(root); - await writer.createRun(runHeader('run-1', 1)); const oldProjection = latestContext('attempt-1', 10); oldProjection.snapshot.schemaVersion = 1; oldProjection.snapshot.composition = { @@ -123,13 +120,11 @@ test('does not trust a pre-observation projection over its canonical attempt', a ); const reader = createSqliteAgentRunStore(root); - const warm = await readLatestContextDiagnostics(reader, 'session-1'); + const warm = await readLatestContextDiagnostics(reader, 'session-1', ['run-1']); const cold = await readLatestContextDiagnostics( - { - listSessionRuns: (sessionId) => reader.listSessionRuns(sessionId), - readEvents: (sessionId, runId) => reader.readEvents(sessionId, runId), - }, + { readEvents: (sessionId, runId) => reader.readEvents(sessionId, runId) }, 'session-1', + ['run-1'], ); assert.equal(warm.status, 'available'); @@ -146,7 +141,6 @@ test('upgrades exact-matched mixed-era composition into the current projection', const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); try { const writer = createSqliteAgentRunStore(root); - await writer.createRun(runHeader('run-1', 1)); const oldProjection = latestContext('attempt-1', 10); oldProjection.snapshot.schemaVersion = 1; await writer.appendEvent( @@ -175,14 +169,14 @@ test('upgrades exact-matched mixed-era composition into the current projection', const counted = countingStore(reader, () => { scanned += 1; }); - const upgraded = await readLatestContextDiagnostics(counted, 'session-1'); + const upgraded = await readLatestContextDiagnostics(counted, 'session-1', ['run-1']); assert.equal(upgraded.status, 'available'); if (upgraded.status !== 'available') return; assert.deepEqual(upgraded.composition?.tools, [{ name: 'HistoricalTool', bytes: 700 }]); scanned = 0; - const warm = await readLatestContextDiagnostics(counted, 'session-1'); + const warm = await readLatestContextDiagnostics(counted, 'session-1', ['run-1']); assert.equal(warm.status, 'available'); if (warm.status !== 'available') return; assert.deepEqual(warm.composition, upgraded.composition); @@ -196,7 +190,6 @@ test('a failed call does not replace the last good snapshot', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); try { const writer = createSqliteAgentRunStore(root); - await writer.createRun(runHeader('run-1', 1)); await writer.appendEvent( 'session-1', 'run-1', @@ -218,7 +211,7 @@ test('a failed call does not replace the last good snapshot', async () => { const counted = countingStore(createSqliteAgentRunStore(root), () => { scanned += 1; }); - const diagnostics = await readLatestContextDiagnostics(counted, 'session-1'); + const diagnostics = await readLatestContextDiagnostics(counted, 'session-1', ['run-1']); assert.equal(diagnostics.status, 'available'); if (diagnostics.status !== 'available') return; @@ -233,12 +226,6 @@ test("a subagent's run never becomes the session's context", async () => { const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); try { const writer = createSqliteAgentRunStore(root); - await writer.createRun(runHeader('run-parent', 1)); - await writer.createRun({ - ...runHeader('run-child', 2), - parentRunId: 'run-parent', - agentId: 'sub', - }); await writer.appendEvent( 'session-1', 'run-parent', @@ -252,9 +239,11 @@ test("a subagent's run never becomes the session's context", async () => { { durable: true, latestContext: latestContext('a-child', 20, 'model-child') }, ); + // The caller names the session-inline runs, so the child is never scanned. const diagnostics = await readLatestContextDiagnostics( createSqliteAgentRunStore(root), 'session-1', + ['run-parent'], ); assert.equal(diagnostics.status, 'available'); @@ -272,7 +261,6 @@ test('rebuilds a canonical observation, then repairs it so the next read scans n const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); try { const writer = createSqliteAgentRunStore(root); - await writer.createRun(runHeader('run-1', 1)); await writer.appendEvent( 'session-1', 'run-1', @@ -303,7 +291,7 @@ test('rebuilds a canonical observation, then repairs it so the next read scans n scanned += 1; }); - const cold = await readLatestContextDiagnostics(counted, 'session-1'); + const cold = await readLatestContextDiagnostics(counted, 'session-1', ['run-1']); assert.equal(cold.status, 'available'); if (cold.status !== 'available') return; assert.equal(cold.modelId, 'model-new'); @@ -311,7 +299,7 @@ test('rebuilds a canonical observation, then repairs it so the next read scans n assert.ok(scanned > 0, 'the first read falls back to the ledger'); scanned = 0; - const warm = await readLatestContextDiagnostics(counted, 'session-1'); + const warm = await readLatestContextDiagnostics(counted, 'session-1', ['run-1']); assert.equal(warm.status, 'available'); if (warm.status !== 'available') return; assert.deepEqual(warm.composition?.tools, [{ name: 'Bash', bytes: 800 }]); @@ -324,7 +312,7 @@ test('rebuilds a canonical observation, then repairs it so the next read scans n test('rebuilds without repairing when the store lacks a ledger revision capability', async () => { const base = runStore([ { - header: runHeader('run-1', 1), + runId: 'run-1', events: [meteringEvent('run-1', 'attempt-1', 20, 'model', 40, 200)], }, ]); @@ -336,7 +324,7 @@ test('rebuilds without repairing when the store lacks a ledger revision capabili }, }; - const diagnostics = await readLatestContextDiagnostics(store, 'session-1'); + const diagnostics = await readLatestContextDiagnostics(store, 'session-1', ['run-1']); assert.equal(diagnostics.status, 'available'); assert.equal(repaired, false); @@ -348,7 +336,7 @@ test('reads a provider-only ledger that predates canonical metering', async () = // lose an answer the ledger still holds. const store = runStore([ { - header: runHeader('run-1', 1), + runId: 'run-1', events: [ attemptEvent('run-1', 'attempt-1', 20, 'completed', 'model-old', 40, 200, [ { kind: 'tool_schema', index: 0, cacheable: true, hash: 't', bytes: 800, label: 'Bash' }, @@ -357,7 +345,7 @@ test('reads a provider-only ledger that predates canonical metering', async () = }, ]); - const diagnostics = await readLatestContextDiagnostics(store, 'session-1'); + const diagnostics = await readLatestContextDiagnostics(store, 'session-1', ['run-1']); assert.equal(diagnostics.status, 'available'); if (diagnostics.status !== 'available') return; @@ -370,7 +358,7 @@ test('a canonical record on the ledger keeps the legacy path out of it', async ( // attempt exists, a newer provider-only attempt is not promoted over it. const store = runStore([ { - header: runHeader('run-1', 1), + runId: 'run-1', events: [ meteringEvent('run-1', 'attempt-1', 10, 'model-canonical', 40, 200), attemptEvent('run-1', 'attempt-2', 30, 'completed', 'model-provider-only', 50, 200), @@ -378,7 +366,7 @@ test('a canonical record on the ledger keeps the legacy path out of it', async ( }, ]); - const diagnostics = await readLatestContextDiagnostics(store, 'session-1'); + const diagnostics = await readLatestContextDiagnostics(store, 'session-1', ['run-1']); assert.equal(diagnostics.status, 'available'); if (diagnostics.status !== 'available') return; @@ -388,7 +376,7 @@ test('a canonical record on the ledger keeps the legacy path out of it', async ( test('cold rebuild takes composition from the canonical attempt observation', async () => { const store = runStore([ { - header: runHeader('run-1', 1), + runId: 'run-1', events: [ meteringEvent('run-1', 'attempt-1', 10, 'model-canonical', 40, 200, { requestObservation: requestObservation([ @@ -417,7 +405,7 @@ test('cold rebuild takes composition from the canonical attempt observation', as }, ]); - const diagnostics = await readLatestContextDiagnostics(store, 'session-1'); + const diagnostics = await readLatestContextDiagnostics(store, 'session-1', ['run-1']); assert.equal(diagnostics.status, 'available'); if (diagnostics.status !== 'available') return; @@ -427,7 +415,7 @@ test('cold rebuild takes composition from the canonical attempt observation', as test('does not enrich a canonical attempt from an identity-mismatched provider row', async () => { const store = runStore([ { - header: runHeader('run-1', 1), + runId: 'run-1', events: [ meteringEvent('run-1', 'attempt-1', 10, 'model-canonical', 40, 200), attemptEvent('run-1', 'attempt-1', 11, 'completed', 'model-other', 40, 200, [ @@ -444,7 +432,7 @@ test('does not enrich a canonical attempt from an identity-mismatched provider r }, ]); - const diagnostics = await readLatestContextDiagnostics(store, 'session-1'); + const diagnostics = await readLatestContextDiagnostics(store, 'session-1', ['run-1']); assert.equal(diagnostics.status, 'available'); if (diagnostics.status !== 'available') return; @@ -455,7 +443,7 @@ test('does not enrich a canonical attempt from an identity-mismatched provider r test('a legacy request whose capture is missing reports no composition, not an older one', async () => { const store = runStore([ { - header: runHeader('run-1', 1), + runId: 'run-1', events: [ meteringEvent('run-1', 'attempt-1', 10, 'model-old', 10, 100), attemptEvent('run-1', 'attempt-1', 10, 'completed', 'model-old', 10, 100, [ @@ -466,7 +454,7 @@ test('a legacy request whose capture is missing reports no composition, not an o }, ]); - const diagnostics = await readLatestContextDiagnostics(store, 'session-1'); + const diagnostics = await readLatestContextDiagnostics(store, 'session-1', ['run-1']); assert.equal(diagnostics.status, 'available'); if (diagnostics.status !== 'available') return; @@ -477,7 +465,7 @@ test('a legacy request whose capture is missing reports no composition, not an o test('a compaction call never becomes the reported context', async () => { const store = runStore([ { - header: runHeader('run-1', 1), + runId: 'run-1', events: [ meteringEvent('run-1', 'attempt-1', 10, 'model-main', 40, 200), meteringEvent('run-1', 'attempt-2', 20, 'model-compact', 5, 200, { @@ -487,7 +475,7 @@ test('a compaction call never becomes the reported context', async () => { }, ]); - const diagnostics = await readLatestContextDiagnostics(store, 'session-1'); + const diagnostics = await readLatestContextDiagnostics(store, 'session-1', ['run-1']); assert.equal(diagnostics.status, 'available'); if (diagnostics.status !== 'available') return; @@ -496,8 +484,9 @@ test('a compaction call never becomes the reported context', async () => { test('reports that no completed request exists instead of inferring session values', async () => { const diagnostics = await readLatestContextDiagnostics( - runStore([{ header: runHeader('run-1', 1), events: [] }]), + runStore([{ runId: 'run-1', events: [] }]), 'session-1', + ['run-1'], ); assert.deepEqual(diagnostics, { status: 'unavailable', reason: 'no_completed_request' }); @@ -511,7 +500,7 @@ test('a rebuilt session reports the fold that was in place when its request star // here describes a different request" rule the sealed row enforces. const store = runStore([ { - header: runHeader('run-1', 1), + runId: 'run-1', events: [ checkpointEvent('run-1', 5, 12, 3, 900), meteringEvent('run-1', 'attempt-1', 20, 'model-new', 40, 200), @@ -520,7 +509,7 @@ test('a rebuilt session reports the fold that was in place when its request star }, ]); - const diagnostics = await readLatestContextDiagnostics(store, 'session-1'); + const diagnostics = await readLatestContextDiagnostics(store, 'session-1', ['run-1']); assert.equal(diagnostics.status, 'available'); if (diagnostics.status !== 'available') return; @@ -541,7 +530,7 @@ test('a canonical ledger with nothing reportable does not fall back to a provide // resurrect exactly the request the canonical rule declined to report. const store = runStore([ { - header: runHeader('run-1', 1), + runId: 'run-1', events: [ meteringEvent('run-1', 'attempt-1', 10, 'model-failed', 40, 200, { status: 'failed' }), meteringEvent('run-1', 'attempt-2', 15, 'model-compact', 5, 200, { @@ -561,7 +550,7 @@ test('a canonical ledger with nothing reportable does not fall back to a provide }, ]); - const diagnostics = await readLatestContextDiagnostics(store, 'session-1'); + const diagnostics = await readLatestContextDiagnostics(store, 'session-1', ['run-1']); assert.deepEqual(diagnostics, { status: 'unavailable', reason: 'no_completed_request' }); }); @@ -575,7 +564,6 @@ test('warm and cold agree on which of two requests that finished together is the const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); try { const writer = createSqliteAgentRunStore(root); - await writer.createRun(runHeader('run-1', 1)); // Appended greater-id first, so a rule that simply kept the last write // would answer 'model-a' here and disagree with the scan below. await writer.appendEvent( @@ -592,15 +580,13 @@ test('warm and cold agree on which of two requests that finished together is the ); const reader = createSqliteAgentRunStore(root); - const warm = await readLatestContextDiagnostics(reader, 'session-1'); + const warm = await readLatestContextDiagnostics(reader, 'session-1', ['run-1']); // The same ledger read by a session whose projection was never // initialized: the answer has to come out identical. const cold = await readLatestContextDiagnostics( - { - listSessionRuns: (sessionId) => reader.listSessionRuns(sessionId), - readEvents: (sessionId, runId) => reader.readEvents(sessionId, runId), - }, + { readEvents: (sessionId, runId) => reader.readEvents(sessionId, runId) }, 'session-1', + ['run-1'], ); assert.equal(warm.status, 'available'); @@ -620,19 +606,18 @@ test('a session confirmed to have nothing is answered from the projection, not r const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); try { const writer = createSqliteAgentRunStore(root); - await writer.createRun(runHeader('run-1', 1)); let scanned = 0; const counted = countingStore(createSqliteAgentRunStore(root), () => { scanned += 1; }); - const cold = await readLatestContextDiagnostics(counted, 'session-1'); + const cold = await readLatestContextDiagnostics(counted, 'session-1', ['run-1']); assert.deepEqual(cold, { status: 'unavailable', reason: 'no_completed_request' }); assert.ok(scanned > 0, 'an uninitialized projection is not an answer'); scanned = 0; - const warm = await readLatestContextDiagnostics(counted, 'session-1'); + const warm = await readLatestContextDiagnostics(counted, 'session-1', ['run-1']); assert.deepEqual(warm, { status: 'unavailable', reason: 'no_completed_request' }); assert.equal(scanned, 0, 'the initialized-empty projection answers on its own'); } finally { @@ -653,12 +638,12 @@ test('names at most the bounded number of tools, and accounts for the rest', asy })); const store = runStore([ { - header: runHeader('run-1', 1), + runId: 'run-1', events: [attemptEvent('run-1', 'attempt-1', 10, 'completed', 'model', 40, 200, segments)], }, ]); - const diagnostics = await readLatestContextDiagnostics(store, 'session-1'); + const diagnostics = await readLatestContextDiagnostics(store, 'session-1', ['run-1']); assert.equal(diagnostics.status, 'available'); if (diagnostics.status !== 'available') return; @@ -680,7 +665,6 @@ test('a request that finished earlier cannot move the answer backwards', async ( const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); try { const writer = createSqliteAgentRunStore(root); - await writer.createRun(runHeader('run-1', 1)); await writer.appendEvent( 'session-1', 'run-1', @@ -698,6 +682,7 @@ test('a request that finished earlier cannot move the answer backwards', async ( const diagnostics = await readLatestContextDiagnostics( createSqliteAgentRunStore(root), 'session-1', + ['run-1'], ); assert.equal(diagnostics.status, 'available'); @@ -716,7 +701,6 @@ test('a damaged projection is repaired, not preserved forever', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); try { const writer = createSqliteAgentRunStore(root); - await writer.createRun(runHeader('run-1', 1)); await writer.appendEvent( 'session-1', 'run-1', @@ -750,14 +734,14 @@ test('a damaged projection is repaired, not preserved forever', async () => { scanned += 1; }); - const first = await readLatestContextDiagnostics(counted, 'session-1'); + const first = await readLatestContextDiagnostics(counted, 'session-1', ['run-1']); assert.equal(first.status, 'available'); if (first.status !== 'available') return; assert.equal(first.modelId, 'model', 'the damaged row does not answer'); assert.ok(scanned > 0, 'the first read rebuilds from the ledger'); scanned = 0; - const second = await readLatestContextDiagnostics(counted, 'session-1'); + const second = await readLatestContextDiagnostics(counted, 'session-1', ['run-1']); assert.equal(second.status, 'available'); assert.equal(scanned, 0, 'and the rebuild replaced the damaged row'); } finally { @@ -769,7 +753,6 @@ test('repairs malformed projection bytes from the canonical ledger', async () => const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); try { const writer = createSqliteAgentRunStore(root); - await writer.createRun(runHeader('run-1', 1)); await writer.appendEvent( 'session-1', 'run-1', @@ -807,14 +790,14 @@ test('repairs malformed projection bytes from the canonical ledger', async () => const counted = countingStore(createSqliteAgentRunStore(root), () => { scanned += 1; }); - const first = await readLatestContextDiagnostics(counted, 'session-1'); + const first = await readLatestContextDiagnostics(counted, 'session-1', ['run-1']); assert.equal(first.status, 'available'); if (first.status !== 'available') return; assert.deepEqual(first.composition?.tools, [{ name: 'Bash', bytes: 800 }]); assert.ok(scanned > 0, 'the malformed bytes force a canonical rebuild'); scanned = 0; - const second = await readLatestContextDiagnostics(counted, 'session-1'); + const second = await readLatestContextDiagnostics(counted, 'session-1', ['run-1']); assert.equal(second.status, 'available'); assert.equal(scanned, 0, 'the authority-derived candidate replaced the malformed row'); } finally { @@ -826,7 +809,6 @@ test('does not persist a cold answer after canonical authority advances', async const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); try { const store = createSqliteAgentRunStore(root); - await store.createRun(runHeader('run-1', 1)); await store.appendEvent( 'session-1', 'run-1', @@ -849,7 +831,6 @@ test('does not persist a cold answer after canonical authority advances', async let advanced = false; const racing: Parameters[0] = { - listSessionRuns: (sessionId) => store.listSessionRuns(sessionId), readEvents: async (sessionId, runId) => { const events = await store.readEvents(sessionId, runId); if (!advanced) { @@ -869,12 +850,12 @@ test('does not persist a cold answer after canonical authority advances', async store.repairEventProjection(sessionId, type, event, options), }; - const cold = await readLatestContextDiagnostics(racing, 'session-1'); + const cold = await readLatestContextDiagnostics(racing, 'session-1', ['run-1']); assert.equal(cold.status, 'available'); if (cold.status !== 'available') return; assert.equal(cold.modelId, 'model-1', 'the in-flight read remains a valid earlier snapshot'); - const next = await readLatestContextDiagnostics(store, 'session-1'); + const next = await readLatestContextDiagnostics(store, 'session-1', ['run-1']); assert.equal(next.status, 'available'); if (next.status !== 'available') return; assert.equal(next.modelId, 'model-2', 'the stale scan never becomes the warm projection'); @@ -887,7 +868,6 @@ test('rebuilds a nested-malformed v2 projection from the canonical ledger', asyn const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); try { const writer = createSqliteAgentRunStore(root); - await writer.createRun(runHeader('run-1', 1)); await writer.appendEvent( 'session-1', 'run-1', @@ -931,14 +911,14 @@ test('rebuilds a nested-malformed v2 projection from the canonical ledger', asyn const counted = countingStore(createSqliteAgentRunStore(root), () => { scanned += 1; }); - const first = await readLatestContextDiagnostics(counted, 'session-1'); + const first = await readLatestContextDiagnostics(counted, 'session-1', ['run-1']); assert.equal(first.status, 'available'); if (first.status !== 'available') return; assert.deepEqual(first.composition?.tools, [{ name: 'Bash', bytes: 800 }]); assert.ok(scanned > 0, 'the malformed nested value cannot answer the warm read'); scanned = 0; - const second = await readLatestContextDiagnostics(counted, 'session-1'); + const second = await readLatestContextDiagnostics(counted, 'session-1', ['run-1']); assert.equal(second.status, 'available'); assert.equal(scanned, 0, 'the canonical rebuild repaired the rejected projection'); } finally { @@ -950,7 +930,6 @@ test('an old readable-order projection is upgraded after one cold rebuild', asyn const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); try { const writer = createSqliteAgentRunStore(root); - await writer.createRun(runHeader('run-1', 1)); await writer.appendEvent( 'session-1', 'run-1', @@ -983,12 +962,12 @@ test('an old readable-order projection is upgraded after one cold rebuild', asyn const counted = countingStore(createSqliteAgentRunStore(root), () => { scanned += 1; }); - const first = await readLatestContextDiagnostics(counted, 'session-1'); + const first = await readLatestContextDiagnostics(counted, 'session-1', ['run-1']); assert.equal(first.status, 'available'); assert.ok(scanned > 0, 'the old schema requires one canonical rebuild'); scanned = 0; - const second = await readLatestContextDiagnostics(counted, 'session-1'); + const second = await readLatestContextDiagnostics(counted, 'session-1', ['run-1']); assert.equal(second.status, 'available'); assert.equal(scanned, 0, 'the rebuilt current schema replaces the old row'); } finally { @@ -1001,7 +980,6 @@ function countingStore( onScan: () => void, ): Parameters[0] { return { - listSessionRuns: (sessionId) => reader.listSessionRuns(sessionId), readEvents: async (sessionId, runId) => { onScan(); return reader.readEvents(sessionId, runId); @@ -1038,30 +1016,14 @@ function latestContext(attemptId: string, completedAt: number, modelId = 'model' } function runStore( - runs: Array<{ header: AgentRunHeader; events: AgentRunEvent[] }>, -): Pick { + runs: Array<{ runId: string; events: AgentRunEvent[] }>, +): Pick { return { - listSessionRuns: async () => runs.map((run) => run.header), readEvents: async (_sessionId, runId) => - runs.find((run) => run.header.runId === runId)?.events ?? [], + runs.find((run) => run.runId === runId)?.events ?? [], }; } -function runHeader(runId: string, createdAt: number): AgentRunHeader { - return { - runId, - sessionId: 'session-1', - turnId: `turn-${runId}`, - status: 'completed', - backendKind: 'ai-sdk', - llmConnectionSlug: 'anthropic-main', - modelId: 'model', - cwd: '/repo', - permissionMode: 'ask', - createdAt, - updatedAt: createdAt, - }; -} function attemptEvent( runId: string, diff --git a/packages/runtime/src/__tests__/conversation-copy.test.ts b/packages/runtime/src/__tests__/conversation-copy.test.ts index 9319c30e7e..026f7975ff 100644 --- a/packages/runtime/src/__tests__/conversation-copy.test.ts +++ b/packages/runtime/src/__tests__/conversation-copy.test.ts @@ -22,8 +22,11 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; -import type { AgentRunHeader, AgentRunStore, EmittedAgentRunEvent } from '@maka/core/agent-run'; -import type { RuntimeEvent } from '@maka/core/runtime-event'; +import type { AgentRunStore, EmittedAgentRunEvent } from '@maka/core/agent-run'; +import type { + RuntimeEvent, + RuntimeEventInvocationOpenedContent, +} from '@maka/core/runtime-event'; import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; import type { StoredMessage } from '@maka/core/session'; import { decodeCanonicalToolResultContent } from '@maka/core/tool-result-record-schema'; @@ -34,7 +37,11 @@ import { MODEL_PROJECTION_TRANSITION_EVENT_TYPE, type ModelProjectionTransition, } from '@maka/core/model-projection-transition'; -import { isSessionInlineRun } from '@maka/core/agent-run'; +import { + buildInvocationOpenedEvent, + isSessionInlineInvocation, + type RuntimeInvocationRecord, +} from '@maka/core/runtime-invocation'; import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; import { createWorkspaceRuntimeStore } from '@maka/storage/runtime-event-persistence'; @@ -1083,14 +1090,16 @@ test('conversation copy rewrites owned references without changing opaque tool p }); test('conversation copy rejects continuation authority selected through the child-run closure', async () => { - const parent = agentRunHeader({ runId: 'run-parent', turnId: 'turn-parent' }); - const child = agentRunHeader({ + const parent = invocationRecord({ runId: 'run-parent', turnId: 'turn-parent' }); + const child = invocationRecord({ runId: 'run-child-retry', + invocationId: 'invocation-child-retry', turnId: 'turn-child-retry', parentRunId: 'run-parent', agentId: 'agent-child', - continuationSource: { - sourceInvocationId: parent.invocationId!, + source: { + kind: 'continuation', + sourceInvocationId: parent.invocationId, sourceRunId: parent.runId, sourceTurnId: parent.turnId, sourceRuntimeEventHighWater: 1, @@ -1112,10 +1121,10 @@ test('conversation copy rejects continuation authority selected through the chil }, ], runStore: { - listSessionRuns: async () => runs, readEvents: async () => [], }, runtimeEventStore: { + listSessionInvocations: async () => runs, readRuntimeEvents: async (_sessionId, runId) => { const run = runs.find((candidate) => candidate.runId === runId); assert.ok(run); @@ -1140,13 +1149,13 @@ test('conversation copy rejects a retained AgentRun without RuntimeEvent facts', try { const runStore = createSqliteAgentRunStore(root); const runtimeEventStore = createWorkspaceRuntimeStore(root); - const rootRun = agentRunHeader({ + const rootRun = runFacts({ runId: 'run-root', invocationId: 'invocation-root', turnId: 'turn-root', cwd: root, }); - const childRun = agentRunHeader({ + const childRun = runFacts({ runId: 'run-child', invocationId: 'invocation-child', turnId: 'turn-child', @@ -1155,8 +1164,8 @@ test('conversation copy rejects a retained AgentRun without RuntimeEvent facts', agentName: 'Researcher', cwd: root, }); - await runStore.createRun(rootRun); - await runStore.createRun(childRun); + await seedRun(runtimeEventStore, rootRun); + await seedRun(runtimeEventStore, childRun); for (const event of [ runtimeEvent({ id: 'event-root-user', @@ -1179,7 +1188,6 @@ test('conversation copy rejects a retained AgentRun without RuntimeEvent facts', await runtimeEventStore.appendRuntimeEvent(event.sessionId, event.runId, event); } const source = await new RuntimeReadModel({ - runStore, runtimeEventStore, }).getSessionView('session-source'); let sequence = 0; @@ -1203,7 +1211,7 @@ test('conversation copy rejects a retained AgentRun without RuntimeEvent facts', }), /Cannot copy AgentRun run-child without RuntimeEvent facts/, ); - assert.deepEqual(await runStore.listSessionRuns('session-target'), []); + assert.deepEqual(await runtimeEventStore.listSessionInvocations('session-target'), []); } finally { await rm(root, { recursive: true, force: true }); } @@ -1277,14 +1285,12 @@ test('conversation copy rewrites a complete tool recovery bundle atomically', as const runtimeEventStore = createSqliteRuntimeStore(join(root, 'runtime.sqlite')); try { await runStore.ready?.(); - await runStore.createRun( - agentRunHeader({ + await seedRun(runtimeEventStore, { runId: 'run-source', invocationId: 'invocation-source', turnId: 'turn-1', cwd: root, - }), - ); + }); const sourceEvents: RuntimeEvent[] = [ runtimeEvent({ id: 'event-user', @@ -1388,7 +1394,7 @@ test('conversation copy rewrites a complete tool recovery bundle atomically', as { runId: 'run-source', events: sourceEvents }, ]); await runStore.appendEvent('session-source', 'run-source', { - type: 'run_completed', + type: 'model_stream_completed', id: 'completed-source', runId: 'run-source', sessionId: 'session-source', @@ -1396,7 +1402,6 @@ test('conversation copy rewrites a complete tool recovery bundle atomically', as ts: 7, }); const source = await new RuntimeReadModel({ - runStore, runtimeEventStore, }).getSessionView('session-source'); await cloneConversationRuntimeLedger({ @@ -1414,7 +1419,7 @@ test('conversation copy rewrites a complete tool recovery bundle atomically', as runtimeEventStore, newId: () => crypto.randomUUID(), }); - const [targetRun] = await runStore.listSessionRuns('session-target'); + const [targetRun] = await runtimeEventStore.listSessionInvocations('session-target'); assert.ok(targetRun); assert.ok(targetRun.invocationId); const targetOperationId = buildToolOperationId({ @@ -1486,14 +1491,12 @@ test('conversation copy rewrites the parent operation id of a nested Code Mode c const runtimeEventStore = createSqliteRuntimeStore(join(root, 'runtime.sqlite')); try { await runStore.ready?.(); - await runStore.createRun( - agentRunHeader({ + await seedRun(runtimeEventStore, { runId: 'run-source', invocationId: 'invocation-source', turnId: 'turn-1', cwd: root, - }), - ); + }); const sourceEvents: RuntimeEvent[] = [ runtimeEvent({ id: 'event-user', @@ -1576,7 +1579,7 @@ test('conversation copy rewrites the parent operation id of a nested Code Mode c { runId: 'run-source', events: sourceEvents }, ]); await runStore.appendEvent('session-source', 'run-source', { - type: 'run_completed', + type: 'model_stream_completed', id: 'completed-source', runId: 'run-source', sessionId: 'session-source', @@ -1584,7 +1587,6 @@ test('conversation copy rewrites the parent operation id of a nested Code Mode c ts: 6, }); const source = await new RuntimeReadModel({ - runStore, runtimeEventStore, }).getSessionView('session-source'); await cloneConversationRuntimeLedger({ @@ -1602,7 +1604,7 @@ test('conversation copy rewrites the parent operation id of a nested Code Mode c runtimeEventStore, newId: () => crypto.randomUUID(), }); - const [targetRun] = await runStore.listSessionRuns('session-target'); + const [targetRun] = await runtimeEventStore.listSessionInvocations('session-target'); assert.ok(targetRun); assert.ok(targetRun.invocationId); const targetOperationId = buildToolOperationId({ @@ -1636,14 +1638,12 @@ test('conversation copy rewrites the nested identity of a model call attempt', a try { const runStore = createSqliteAgentRunStore(root); const runtimeEventStore = createWorkspaceRuntimeStore(root); - await runStore.createRun( - agentRunHeader({ + await seedRun(runtimeEventStore, { runId: 'run-source', invocationId: 'invocation-source', turnId: 'turn-1', cwd: root, - }), - ); + }); for (const event of [ runtimeEvent({ id: 'event-user', @@ -1690,7 +1690,6 @@ test('conversation copy rewrites the nested identity of a model call attempt', a }, }); const source = await new RuntimeReadModel({ - runStore, runtimeEventStore, }).getSessionView('session-source'); await cloneConversationRuntimeLedger({ @@ -1708,7 +1707,7 @@ test('conversation copy rewrites the nested identity of a model call attempt', a runtimeEventStore, newId: () => crypto.randomUUID(), }); - const [targetRun] = await runStore.listSessionRuns('session-target'); + const [targetRun] = await runtimeEventStore.listSessionInvocations('session-target'); assert.ok(targetRun); const targetEvents = await runStore.readEvents('session-target', targetRun.runId); const attempt = targetEvents.find((event) => event.type === 'model_call_attempt_recorded'); @@ -1743,14 +1742,12 @@ test('conversation copy repairs a model call attempt stranded by a pre-fix copy' try { const runStore = createSqliteAgentRunStore(root); const runtimeEventStore = createWorkspaceRuntimeStore(root); - await runStore.createRun( - agentRunHeader({ + await seedRun(runtimeEventStore, { runId: 'run-source', invocationId: 'invocation-source', turnId: 'turn-1', cwd: root, - }), - ); + }); for (const event of [ runtimeEvent({ id: 'event-user', @@ -1798,7 +1795,6 @@ test('conversation copy repairs a model call attempt stranded by a pre-fix copy' }, }); const source = await new RuntimeReadModel({ - runStore, runtimeEventStore, }).getSessionView('session-source'); // The whole copy must not throw `Cannot copy invalid model call attempt`. @@ -1817,7 +1813,7 @@ test('conversation copy repairs a model call attempt stranded by a pre-fix copy' runtimeEventStore, newId: () => crypto.randomUUID(), }); - const [targetRun] = await runStore.listSessionRuns('session-target'); + const [targetRun] = await runtimeEventStore.listSessionInvocations('session-target'); assert.ok(targetRun); const targetEvents = await runStore.readEvents('session-target', targetRun.runId); const attempt = targetEvents.find((event) => event.type === 'model_call_attempt_recorded'); @@ -1845,22 +1841,15 @@ test('conversation copy clones one terminal Runtime ledger with new owned identi try { const runStore = createSqliteAgentRunStore(root); const runtimeEventStore = createWorkspaceRuntimeStore(root); - const sourceRun: AgentRunHeader = { + const sourceRun = runFacts({ runId: 'run-source', invocationId: 'invocation-source', - sessionId: 'session-source', turnId: 'turn-1', - status: 'completed', - backendKind: 'fake', - llmConnectionSlug: 'fake', - modelId: 'model', cwd: root, - permissionMode: 'ask', - createdAt: 1, - updatedAt: 3, - completedAt: 3, - }; - await runStore.createRun(sourceRun); + openedAt: 1, + closedAt: 3, + }); + await seedRun(runtimeEventStore, sourceRun); const sourceAttachmentText = [ '![chart](maka://runtime/attachments/artifact-source)', 'maka://runtime/attachments/artifact-source?session=other', @@ -2107,7 +2096,7 @@ test('conversation copy clones one terminal Runtime ledger with new owned identi }, }); await runStore.appendEvent('session-source', 'run-source', { - type: 'run_completed', + type: 'model_stream_completed', id: 'completed-source', runId: 'run-source', sessionId: 'session-source', @@ -2133,7 +2122,6 @@ test('conversation copy clones one terminal Runtime ledger with new owned identi ), ); const source = await new RuntimeReadModel({ - runStore, runtimeEventStore, }).getSessionView('session-source'); await assert.rejects( @@ -2155,7 +2143,7 @@ test('conversation copy clones one terminal Runtime ledger with new owned identi }), /missing Artifact artifact-deleted/, ); - assert.deepEqual(await runStore.listSessionRuns('session-missing-artifact'), []); + assert.deepEqual(await runtimeEventStore.listSessionInvocations('session-missing-artifact'), []); // A copied run and its copied invocation share one fresh identity, so the // copy mints one id here rather than two. const ids = [ @@ -2198,10 +2186,10 @@ test('conversation copy clones one terminal Runtime ledger with new owned identi : undefined, ['artifact-target-deleted'], ); - const [targetRun] = await runStore.listSessionRuns('session-target'); + const [targetRun] = await runtimeEventStore.listSessionInvocations('session-target'); assert.equal(targetRun?.runId, 'run-target'); assert.equal(targetRun?.invocationId, 'run-target'); - assert.equal(targetRun?.status, 'completed'); + assert.equal(targetRun?.terminalEvent?.status, 'completed'); const targetEvents = await runtimeEventStore.readRuntimeEvents('session-target', 'run-target'); assert.deepEqual( targetEvents.map((event) => event.id), @@ -2281,7 +2269,8 @@ test('conversation copy clones one terminal Runtime ledger with new owned identi ).reason, undefined, ); - assert.equal((await runStore.readRun('session-source', 'run-source')).status, 'completed'); + const [sourceInvocation] = await runtimeEventStore.listSessionInvocations('session-source'); + assert.equal(sourceInvocation?.terminalEvent?.status, 'completed'); } finally { await rm(root, { recursive: true, force: true }); } @@ -2292,34 +2281,32 @@ test('conversation copy rebuilds an inline checkpoint without legacy child event try { const runStore = createSqliteAgentRunStore(root); const runtimeEventStore = createWorkspaceRuntimeStore(root); - const firstRun = agentRunHeader({ + const firstRun = runFacts({ runId: 'run-1', invocationId: 'invocation-1', turnId: 'turn-1', cwd: root, }); - const secondRun = agentRunHeader({ + const secondRun = runFacts({ runId: 'run-2', invocationId: 'invocation-2', turnId: 'turn-2', cwd: root, - createdAt: 3, - updatedAt: 5, - completedAt: 5, + openedAt: 3, + closedAt: 5, }); - const childRun = agentRunHeader({ + const childRun = runFacts({ runId: 'run-child', invocationId: 'invocation-child', turnId: 'turn-child', parentRunId: 'run-1', cwd: root, - createdAt: 2.1, - updatedAt: 2.9, - completedAt: 2.9, + openedAt: 2.1, + closedAt: 2.9, }); - await runStore.createRun(firstRun); - await runStore.createRun(childRun); - await runStore.createRun(secondRun); + await seedRun(runtimeEventStore, firstRun); + await seedRun(runtimeEventStore, childRun); + await seedRun(runtimeEventStore, secondRun); const firstEvents = [ runtimeEvent({ id: 'event-1-user', @@ -2448,7 +2435,6 @@ test('conversation copy rebuilds an inline checkpoint without legacy child event }, }); const source = await new RuntimeReadModel({ - runStore, runtimeEventStore, }).getSessionView('session-source'); let sequence = 0; @@ -2469,14 +2455,14 @@ test('conversation copy rebuilds an inline checkpoint without legacy child event newId: () => `target-${++sequence}`, }); - const targetRuns = await runStore.listSessionRuns('session-target'); + const targetRuns = await runtimeEventStore.listSessionInvocations('session-target'); const targetInlineRunIds = new Set( - targetRuns.filter(isSessionInlineRun).map((run) => run.runId), + targetRuns.filter((run) => isSessionInlineInvocation(run.opening)).map((run) => run.runId), ); const targetEvents = (await runtimeEventStore.readSessionRuntimeEventEntries('session-target')) .map(({ event }) => event) .filter((event) => targetInlineRunIds.has(event.runId)); - assert.ok(targetRuns.some((run) => !isSessionInlineRun(run))); + assert.ok(targetRuns.some((run) => !isSessionInlineInvocation(run.opening))); const projectedCheckpoint = await runStore.readEventProjection?.( 'session-target', 'history_compact_checkpoint_recorded', @@ -2509,14 +2495,14 @@ test('conversation copy drops a checkpoint from a superseded source policy inste try { const runStore = createSqliteAgentRunStore(root); const runtimeEventStore = createWorkspaceRuntimeStore(root); - const run = agentRunHeader({ + const run = runFacts({ runId: 'run-source', invocationId: 'invocation-1', turnId: 'turn-1', cwd: root, - completedAt: 3, + closedAt: 3, }); - await runStore.createRun(run); + await seedRun(runtimeEventStore, run); const sourceEvents = [ runtimeEvent({ id: 'event-user', @@ -2571,7 +2557,6 @@ test('conversation copy drops a checkpoint from a superseded source policy inste }, }); const source = await new RuntimeReadModel({ - runStore, runtimeEventStore, }).getSessionView('session-source'); let sequence = 0; @@ -2592,7 +2577,7 @@ test('conversation copy drops a checkpoint from a superseded source policy inste newId: () => `target-${++sequence}`, }); - const targetRuns = await runStore.listSessionRuns('session-target'); + const targetRuns = await runtimeEventStore.listSessionInvocations('session-target'); assert.ok(targetRuns.length > 0); const targetOperationalEvents = ( await Promise.all(targetRuns.map((run) => runStore.readEvents('session-target', run.runId))) @@ -2617,13 +2602,13 @@ test('conversation copy rebuilds a resumed child checkpoint over its child run c try { const runStore = createSqliteAgentRunStore(root); const runtimeEventStore = createWorkspaceRuntimeStore(root); - const rootRun = agentRunHeader({ + const rootRun = runFacts({ runId: 'run-root', invocationId: 'invocation-root', turnId: 'turn-root', cwd: root, }); - const firstChild = agentRunHeader({ + const firstChild = runFacts({ runId: 'run-child-1', invocationId: 'invocation-child-1', turnId: 'turn-child-1', @@ -2631,24 +2616,21 @@ test('conversation copy rebuilds a resumed child checkpoint over its child run c agentId: 'researcher', agentName: 'Researcher', cwd: root, - createdAt: 3, - updatedAt: 5, - completedAt: 5, + openedAt: 3, + closedAt: 5, }); - const resumedChild = agentRunHeader({ + const resumedChild = runFacts({ runId: 'run-child-2', invocationId: 'invocation-child-2', turnId: 'turn-child-2', parentRunId: 'run-root', - resumedFromRunId: 'run-child-1', agentId: 'researcher', agentName: 'Researcher', cwd: root, - createdAt: 6, - updatedAt: 8, - completedAt: 8, + openedAt: 6, + closedAt: 8, }); - for (const run of [rootRun, firstChild, resumedChild]) await runStore.createRun(run); + for (const run of [rootRun, firstChild, resumedChild]) await seedRun(runtimeEventStore, run); const rootEvents = [ runtimeEvent({ @@ -2738,7 +2720,6 @@ test('conversation copy rebuilds a resumed child checkpoint over its child run c }, }); const source = await new RuntimeReadModel({ - runStore, runtimeEventStore, }).getSessionView('session-source'); let sequence = 0; @@ -2762,8 +2743,10 @@ test('conversation copy rebuilds a resumed child checkpoint over its child run c const runIds = new Map( copied.runIdMap.map(({ sourceRunId, targetRunId }) => [sourceRunId, targetRunId]), ); - const targetResumedChild = await runStore.readRun('session-target', runIds.get('run-child-2')!); - assert.equal(targetResumedChild.resumedFromRunId, runIds.get('run-child-1')); + const targetResumedChild = ( + await runtimeEventStore.listSessionInvocations('session-target') + ).find((run) => run.runId === runIds.get('run-child-2')); + assert.ok(targetResumedChild); const targetChildEvents = ( await Promise.all( ['run-child-1', 'run-child-2'].map((sourceRunId) => @@ -2831,14 +2814,12 @@ test('conversation copy rebuilds projection transitions against the copied event try { const runStore = createSqliteAgentRunStore(root); const runtimeEventStore = createWorkspaceRuntimeStore(root); - await runStore.createRun( - agentRunHeader({ + await seedRun(runtimeEventStore, { runId: 'run-source', invocationId: 'invocation-source', turnId: 'turn-1', cwd: root, - }), - ); + }); const resultEvent = runtimeEvent({ id: 'event-result', ts: 2, @@ -2902,7 +2883,7 @@ test('conversation copy rebuilds projection transitions against the copied event }); } await runStore.appendEvent('session-source', 'run-source', { - type: 'run_completed', + type: 'model_stream_completed', id: 'completed-source', runId: 'run-source', sessionId: 'session-source', @@ -2910,7 +2891,6 @@ test('conversation copy rebuilds projection transitions against the copied event ts: 4, }); const source = await new RuntimeReadModel({ - runStore, runtimeEventStore, }).getSessionView('session-source'); @@ -2933,7 +2913,7 @@ test('conversation copy rebuilds projection transitions against the copied event newId: () => crypto.randomUUID(), }); - const [targetRun] = await runStore.listSessionRuns('session-target'); + const [targetRun] = await runtimeEventStore.listSessionInvocations('session-target'); assert.ok(targetRun); const targetEvents = await runtimeEventStore.readRuntimeEvents( 'session-target', @@ -2945,6 +2925,7 @@ test('conversation copy rebuilds projection transitions against the copied event const copiedTransitions = await loadModelProjectionTransitionsFromRunLedger( runStore, 'session-target', + (await runtimeEventStore.listSessionInvocations('session-target')).map((run) => run.runId), ); assert.equal(copiedTransitions.transitions.length, 2); const copiedFirst = copiedTransitions.transitions.find( @@ -2999,8 +2980,8 @@ test('conversation copy carries a transition recorded by a later, uncopied run', ['run-first', 'turn-1'], ['run-second', 'turn-2'], ]) { - await runStore.createRun( - agentRunHeader({ + await seedRun(runtimeEventStore, + runFacts({ runId, invocationId: `invocation-${runId}`, turnId, @@ -3099,7 +3080,7 @@ test('conversation copy carries a transition recorded by a later, uncopied run', ['run-second', 'turn-2', 'completed-second'], ]) { await runStore.appendEvent('session-source', runId, { - type: 'run_completed', + type: 'model_stream_completed', id, runId, sessionId: 'session-source', @@ -3108,7 +3089,6 @@ test('conversation copy carries a transition recorded by a later, uncopied run', }); } const source = await new RuntimeReadModel({ - runStore, runtimeEventStore, }).getSessionView('session-source'); const firstTurnMessages = source.messages.filter( @@ -3131,13 +3111,15 @@ test('conversation copy carries a transition recorded by a later, uncopied run', newId: () => crypto.randomUUID(), }); - const targetRuns = await runStore.listSessionRuns('session-target'); + const targetRuns = await runtimeEventStore.listSessionInvocations('session-target'); assert.equal(targetRuns.length, 1); const targetEvents = await runtimeEventStore.readRuntimeEvents( 'session-target', targetRuns[0]!.runId, ); - const copied = await loadModelProjectionTransitionsFromRunLedger(runStore, 'session-target'); + const copied = await loadModelProjectionTransitionsFromRunLedger(runStore, 'session-target', [ + targetRuns[0]!.runId, + ]); assert.equal(copied.transitions.length, 1); assert.equal( copied.transitions[0]?.target.runtimeEventId, @@ -3166,8 +3148,8 @@ test('conversation copy reproduces the source fold rather than re-deciding it', ['run-first', 'turn-1'], ['run-second', 'turn-2'], ]) { - await runStore.createRun( - agentRunHeader({ runId, invocationId: `invocation-${runId}`, turnId, cwd: root }), + await seedRun(runtimeEventStore, + runFacts({ runId, invocationId: `invocation-${runId}`, turnId, cwd: root }), ); } const resultEvent = runtimeEvent({ @@ -3272,7 +3254,7 @@ test('conversation copy reproduces the source fold rather than re-deciding it', ['run-second', 'turn-2', 'completed-second'], ]) { await runStore.appendEvent('session-source', runId, { - type: 'run_completed', + type: 'model_stream_completed', id, runId, sessionId: 'session-source', @@ -3281,7 +3263,6 @@ test('conversation copy reproduces the source fold rather than re-deciding it', }); } const source = await new RuntimeReadModel({ - runStore, runtimeEventStore, }).getSessionView('session-source'); const firstTurnMessages = source.messages.filter( @@ -3307,13 +3288,15 @@ test('conversation copy reproduces the source fold rather than re-deciding it', newId: () => crypto.randomUUID(), }); - const [targetRun] = await runStore.listSessionRuns('session-target'); + const [targetRun] = await runtimeEventStore.listSessionInvocations('session-target'); assert.ok(targetRun); const targetEvents = await runtimeEventStore.readRuntimeEvents( 'session-target', targetRun.runId, ); - const copied = await loadModelProjectionTransitionsFromRunLedger(runStore, 'session-target'); + const copied = await loadModelProjectionTransitionsFromRunLedger(runStore, 'session-target', [ + targetRun.runId, + ]); // Only the transition the source fold applied is rebuilt. Carrying the // rejected rival would let the copy re-decide and show a placeholder the // source never showed. @@ -3339,8 +3322,8 @@ test('conversation copy reproduces the source fold rather than re-deciding it', function prepareTestCopyPlan( source: RuntimeReadModelSessionView, copiedMessages: readonly StoredMessage[], - runStore: Pick, - runtimeEventStore: Pick, + runStore: Pick, + runtimeEventStore: Pick, ) { return prepareConversationRuntimeLedgerCopy({ sourceSessionId: 'session-source', @@ -3366,21 +3349,125 @@ function runtimeEvent(overrides: Partial): RuntimeEvent { }; } -function agentRunHeader(overrides: Partial): AgentRunHeader { +interface SeededRun { + runId?: string; + invocationId?: string; + sessionId?: string; + turnId?: string; + cwd?: string; + parentRunId?: string; + agentId?: string; + agentName?: string; + openedAt?: number; + closedAt?: number; + /** How the run ended. `open` leaves it with no terminal event. */ + outcome?: 'completed' | 'failed' | 'aborted' | 'open'; +} + +/** The facts a test states about a run it seeds. Nothing keeps this shape after the seed. */ +function runFacts(overrides: SeededRun): SeededRun { + return { sessionId: 'session-source', ...overrides }; +} + +/** One invocation as a reader sees it, for tests that stub the store instead of writing to it. */ +function invocationRecord( + run: SeededRun & { source?: RuntimeEventInvocationOpenedContent['source'] } = {}, +): RuntimeInvocationRecord { + const identity = { + sessionId: run.sessionId ?? 'session-source', + invocationId: run.invocationId ?? 'invocation', + runId: run.runId ?? 'run', + turnId: run.turnId ?? 'turn', + }; + const openedAt = run.openedAt ?? 1; return { - runId: 'run', - invocationId: 'invocation', - sessionId: 'session-source', - turnId: 'turn', - status: 'completed', - backendKind: 'fake', - llmConnectionSlug: 'fake', - modelId: 'model', - cwd: '/tmp', - permissionMode: 'ask', - createdAt: 1, - updatedAt: 2, - completedAt: 2, - ...overrides, + ...identity, + openedAt, + opening: invocationOpening(run), + ...(run.outcome === 'open' + ? {} + : { + terminalEvent: { + id: `${identity.runId}-terminal`, + ...identity, + ts: run.closedAt ?? openedAt + 1, + partial: false, + role: 'system', + author: 'system', + status: run.outcome ?? 'completed', + }, + }), + }; +} + +function invocationOpening( + run: SeededRun & { source?: RuntimeEventInvocationOpenedContent['source'] }, +): RuntimeEventInvocationOpenedContent { + const lineage = { + ...(run.parentRunId ? { parentRunId: run.parentRunId } : {}), + ...(run.agentId ? { agentId: run.agentId } : {}), + ...(run.agentName ? { agentName: run.agentName } : {}), + }; + return { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'runtime', + backendKind: 'fake', + llmConnectionId: 'fake-connection', + llmConnectionSlug: 'fake', + modelId: 'model', + }, + configuration: { + cwd: run.cwd ?? '/tmp', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + root: { kind: 'user' }, + source: run.source ?? { kind: 'fresh' }, + ...(Object.keys(lineage).length > 0 ? { lineage } : {}), + }; +} + +/** + * Open one invocation on the spine, and close it when the test says it ended. + * + * The copy tests only need a run to exist and to name its turn, so everything + * else is the same for all of them. + */ +async function seedRun( + runtimeEventStore: Pick, + run: SeededRun = {}, +): Promise { + const identity = { + sessionId: run.sessionId ?? 'session-source', + invocationId: run.invocationId ?? 'invocation', + runId: run.runId ?? 'run', + turnId: run.turnId ?? 'turn', }; + const openedAt = run.openedAt ?? 1; + await runtimeEventStore.appendRuntimeEvent( + identity.sessionId, + identity.runId, + buildInvocationOpenedEvent({ + id: `${identity.runId}-invocation-opened`, + run: identity, + openedAt, + opening: invocationOpening(run), + }), + ); + const outcome = run.outcome ?? 'completed'; + if (outcome === 'open') return; + await runtimeEventStore.appendRuntimeEvent(identity.sessionId, identity.runId, { + id: `${identity.runId}-terminal`, + ...identity, + ts: run.closedAt ?? openedAt + 1, + partial: false, + role: 'system', + author: 'system', + status: outcome, + }); } diff --git a/packages/runtime/src/__tests__/runtime-continuation.test.ts b/packages/runtime/src/__tests__/runtime-continuation.test.ts index 0b0cd6c591..b8071e6237 100644 --- a/packages/runtime/src/__tests__/runtime-continuation.test.ts +++ b/packages/runtime/src/__tests__/runtime-continuation.test.ts @@ -26,8 +26,11 @@ import { runtimePrefixSegment, type ImmutableRuntimePrefixV1, } from '@maka/core/runtime-boundary'; -import type { RuntimeEvent } from '@maka/core/runtime-event'; -import type { AgentRunHeader } from '@maka/core/agent-run'; +import type { + RuntimeEvent, + RuntimeEventInvocationOpenedContent, +} from '@maka/core/runtime-event'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import { createLocalContinuationSafetyInspector } from '../continuation-safety.js'; import { buildContinuationReplayPlan } from '../continuation-replay.js'; @@ -80,7 +83,7 @@ test('RuntimeContinuationPlanner reads the durable source boundary and allocates // Run and invocation are one identity, so the planner mints three ids, not four. const ids = ['invocation-2', 'turn-2', 'claim-2']; const planner = new RuntimeContinuationPlanner({ - readSourceRun: async () => runHeader('run-1'), + readSourceInvocation: async () => runInvocation('run-1'), readImmutableRuntimePrefix: async () => sourcePrefix, newId: () => ids.shift() ?? 'unexpected-id', }); @@ -132,7 +135,7 @@ test('RuntimeContinuationPlanner reads the durable source boundary and allocates test('RuntimeContinuationPlanner parks with a stable reason when the ledger cannot be read', async () => { const planner = new RuntimeContinuationPlanner({ - readSourceRun: async () => runHeader('run-1'), + readSourceInvocation: async () => runInvocation('run-1'), readImmutableRuntimePrefix: async () => { throw new Error('corrupt ledger'); }, @@ -156,7 +159,7 @@ test('RuntimeContinuationPlanner parks with a stable reason when the ledger cann test('RuntimeContinuationPlanner derives terminal repair from durable run and event facts', async () => { const planner = new RuntimeContinuationPlanner({ - readSourceRun: async () => runHeader('run-1', { status: 'running' }), + readSourceInvocation: async () => runInvocation('run-1', { outcome: 'open' }), readImmutableRuntimePrefix: async () => immutablePrefix([ event({ @@ -186,7 +189,7 @@ test('RuntimeContinuationPlanner derives terminal repair from durable run and ev test('RuntimeContinuationPlanner parks when the terminal run header disagrees with the ledger fact', async () => { const planner = new RuntimeContinuationPlanner({ - readSourceRun: async () => runHeader('run-1', { status: 'completed' }), + readSourceInvocation: async () => runInvocation('run-1', { outcome: 'completed' }), readImmutableRuntimePrefix: async () => immutablePrefix([ event({ @@ -223,7 +226,7 @@ test('RuntimeContinuationPlanner parks when the terminal run header disagrees wi test('RuntimeContinuationPlanner rejects immutable output after the source terminal fact', async () => { const planner = new RuntimeContinuationPlanner({ - readSourceRun: async () => runHeader('run-1'), + readSourceInvocation: async () => runInvocation('run-1'), readImmutableRuntimePrefix: async () => immutablePrefix([ event({ @@ -268,7 +271,7 @@ test('RuntimeContinuationPlanner rejects immutable output after the source termi test('RuntimeContinuationPlanner uses canonical provider items for composite head and tail gates', async () => { let nextId = 0; const planner = new RuntimeContinuationPlanner({ - readSourceRun: async () => runHeader('run-1'), + readSourceInvocation: async () => runInvocation('run-1'), readImmutableRuntimePrefix: async () => immutablePrefix([ event({ @@ -314,7 +317,7 @@ test('RuntimeContinuationPlanner uses canonical provider items for composite hea test('RuntimeContinuationPlanner rejects a ledger returned for another source run', async () => { const planner = new RuntimeContinuationPlanner({ - readSourceRun: async () => runHeader('run-1'), + readSourceInvocation: async () => runInvocation('run-1'), readImmutableRuntimePrefix: async () => immutablePrefix([ event({ @@ -353,16 +356,18 @@ test('RuntimeContinuationPlanner rejects a ledger returned for another source ru test('RuntimeContinuationPlanner fails a cyclic continuation lineage closed', async () => { const runs = { - 'run-1': runHeader('run-1', { - continuationSource: { + 'run-1': runInvocation('run-1', { + source: { + kind: 'continuation' as const, sourceInvocationId: 'invocation-2', sourceRunId: 'run-2', sourceTurnId: 'turn-2', sourceRuntimeEventHighWater: 1, }, }), - 'run-2': runHeader('run-2', { - continuationSource: { + 'run-2': runInvocation('run-2', { + source: { + kind: 'continuation' as const, sourceInvocationId: 'invocation-1', sourceRunId: 'run-1', sourceTurnId: 'turn-1', @@ -375,7 +380,7 @@ test('RuntimeContinuationPlanner fails a cyclic continuation lineage closed', as ['run-2', prefixForIdentity('invocation-2', 'run-2', 'turn-2')], ]); const planner = new RuntimeContinuationPlanner({ - readSourceRun: async (_sessionId, runId) => runs[runId as keyof typeof runs], + readSourceInvocation: async (_sessionId, runId) => runs[runId as keyof typeof runs], readImmutableRuntimePrefix: async ({ runId }) => prefixes.get(runId)!, newId: () => 'unused', }); @@ -398,10 +403,11 @@ test('RuntimeContinuationPlanner fails a cyclic continuation lineage closed', as test('RuntimeContinuationPlanner parks when a continuation ancestor is unavailable', async () => { const source = prefixForIdentity('invocation-2', 'run-2', 'turn-2'); const planner = new RuntimeContinuationPlanner({ - readSourceRun: async (_sessionId, runId) => { + readSourceInvocation: async (_sessionId, runId) => { if (runId === 'run-2') { - return runHeader('run-2', { - continuationSource: { + return runInvocation('run-2', { + source: { + kind: 'continuation' as const, sourceInvocationId: 'invocation-1', sourceRunId: 'run-missing', sourceTurnId: 'turn-1', @@ -434,14 +440,15 @@ test('RuntimeContinuationPlanner parks when a continuation ancestor is unavailab }); test('RuntimeContinuationPlanner caps continuation lineage at 64 segments', async () => { - const runs = new Map(); + const runs = new Map(); const prefixes = new Map(); for (let index = 1; index <= 64; index += 1) { const runId = `run-${index}`; runs.set( runId, - runHeader(runId, { - continuationSource: { + runInvocation(runId, { + source: { + kind: 'continuation' as const, sourceInvocationId: `invocation-${index + 1}`, sourceRunId: `run-${index + 1}`, sourceTurnId: `turn-${index + 1}`, @@ -452,7 +459,7 @@ test('RuntimeContinuationPlanner caps continuation lineage at 64 segments', asyn prefixes.set(runId, prefixForIdentity(`invocation-${index}`, runId, `turn-${index}`)); } const planner = new RuntimeContinuationPlanner({ - readSourceRun: async (_sessionId, runId) => { + readSourceInvocation: async (_sessionId, runId) => { const run = runs.get(runId); if (!run) throw new Error('unexpected lineage read'); return run; @@ -504,21 +511,21 @@ test('RuntimeContinuationPlanner verifies a v2 lineage edge prefix digest', asyn highWater: 1, prefixDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', }, - replayManifestDigest: - 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', providerProjectionVersion: 1, providerReplayDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', + replayManifestDigest: + 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', }, }, }), ]); const planner = new RuntimeContinuationPlanner({ - readSourceRun: async (_sessionId, runId) => + readSourceInvocation: async (_sessionId, runId) => runId === 'run-2' - ? runHeader('run-2', { - continuationSource: { - protocol: 'continuation_source_v2', + ? runInvocation('run-2', { + source: { + kind: 'continuation' as const, claimId: 'claim-1', boundaryDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', @@ -526,13 +533,9 @@ test('RuntimeContinuationPlanner verifies a v2 lineage edge prefix digest', asyn sourceRunId: 'run-1', sourceTurnId: 'turn-1', sourceRuntimeEventHighWater: 1, - sourcePrefixDigest: - 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', - replayManifestDigest: - 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', }, }) - : runHeader('run-1'), + : runInvocation('run-1'), readImmutableRuntimePrefix: async ({ runId }) => (runId === 'run-2' ? source : ancestor), newId: () => 'unused', }); @@ -595,22 +598,20 @@ test('RuntimeContinuationPlanner binds every v2 lineage edge to its continuation }), ]); const planner = new RuntimeContinuationPlanner({ - readSourceRun: async (_sessionId, runId) => + readSourceInvocation: async (_sessionId, runId) => runId === 'run-2' - ? runHeader('run-2', { - continuationSource: { - protocol: 'continuation_source_v2', + ? runInvocation('run-2', { + source: { + kind: 'continuation' as const, claimId: 'claim-expected', boundaryDigest: ancestorBoundary.manifestDigest, sourceInvocationId: ancestor.identity.invocationId, sourceRunId: ancestor.identity.runId, sourceTurnId: ancestor.identity.turnId, sourceRuntimeEventHighWater: ancestor.position.lastEventSeq, - sourcePrefixDigest: ancestor.prefixDigest, - replayManifestDigest: ancestorBoundary.manifestDigest, }, }) - : runHeader('run-1'), + : runInvocation('run-1'), readImmutableRuntimePrefix: async ({ runId }) => (runId === 'run-2' ? source : ancestor), newId: () => 'unused', }); @@ -679,17 +680,18 @@ test('RuntimeContinuationPlanner rejects downgrading a canonical v2 start to leg }), ]); const planner = new RuntimeContinuationPlanner({ - readSourceRun: async (_sessionId, runId) => + readSourceInvocation: async (_sessionId, runId) => runId === sourceIdentity.runId - ? runHeader(sourceIdentity.runId, { - continuationSource: { + ? runInvocation(sourceIdentity.runId, { + source: { + kind: 'continuation' as const, sourceInvocationId: ancestor.identity.invocationId, sourceRunId: ancestor.identity.runId, sourceTurnId: ancestor.identity.turnId, sourceRuntimeEventHighWater: ancestor.position.lastEventSeq, }, }) - : runHeader('run-1'), + : runInvocation('run-1'), readImmutableRuntimePrefix: async ({ runId }) => runId === sourceIdentity.runId ? source : ancestor, newId: () => 'unused', @@ -759,22 +761,20 @@ test('RuntimeContinuationPlanner requires a durable target before authenticating }), ]); const planner = new RuntimeContinuationPlanner({ - readSourceRun: async (_sessionId, runId) => + readSourceInvocation: async (_sessionId, runId) => runId === sourceIdentity.runId - ? runHeader(sourceIdentity.runId, { - continuationSource: { - protocol: 'continuation_source_v2', + ? runInvocation(sourceIdentity.runId, { + source: { + kind: 'continuation' as const, claimId: 'claim-1', boundaryDigest: ancestorReplay.plan.boundary.manifestDigest, sourceInvocationId: ancestor.identity.invocationId, sourceRunId: ancestor.identity.runId, sourceTurnId: ancestor.identity.turnId, sourceRuntimeEventHighWater: ancestor.position.lastEventSeq, - sourcePrefixDigest: ancestor.prefixDigest, - replayManifestDigest: ancestorReplay.plan.boundary.manifestDigest, }, }) - : runHeader('run-1'), + : runInvocation('run-1'), readImmutableRuntimePrefix: async ({ runId }) => runId === sourceIdentity.runId ? source : ancestor, newId: () => 'unused', @@ -843,22 +843,20 @@ test('RuntimeContinuationPlanner rejects a v2 lineage edge whose durable claim i actions: { endInvocation: true, stateDelta: { failureClass: 'test_failure' } }, }), ]); - const sourceRun = runHeader(sourceIdentity.runId, { - continuationSource: { - protocol: 'continuation_source_v2', + const sourceRun = runInvocation(sourceIdentity.runId, { + source: { + kind: 'continuation' as const, claimId: 'claim-1', boundaryDigest: ancestorReplay.plan.boundary.manifestDigest, sourceInvocationId: ancestor.identity.invocationId, sourceRunId: ancestor.identity.runId, sourceTurnId: ancestor.identity.turnId, sourceRuntimeEventHighWater: ancestor.position.lastEventSeq, - sourcePrefixDigest: ancestor.prefixDigest, - replayManifestDigest: ancestorReplay.plan.boundary.manifestDigest, }, }); const planner = new RuntimeContinuationPlanner({ - readSourceRun: async (_sessionId, runId) => - runId === sourceIdentity.runId ? sourceRun : runHeader('run-1'), + readSourceInvocation: async (_sessionId, runId) => + runId === sourceIdentity.runId ? sourceRun : runInvocation('run-1'), readImmutableRuntimePrefix: async ({ runId }) => runId === sourceIdentity.runId ? source : ancestor, readContinuationClaimStateByBoundary: async () => undefined, @@ -882,32 +880,75 @@ test('RuntimeContinuationPlanner rejects a v2 lineage edge whose durable claim i function sameRouteAdmission() { return { - runHeaders: ['run-1', 'run-2', 'run-3'].map((runId) => - runHeader(runId, { llmConnectionId: 'connection-1' }), - ), + invocations: ['run-1', 'run-2', 'run-3'].map((runId) => runInvocation(runId)), targetProviderStateIdentity: undefined, targetModelId: 'test-model', }; } -function runHeader(runId: string, overrides: Partial = {}): AgentRunHeader { +interface RunFacts { + source?: RuntimeEventInvocationOpenedContent['source']; + outcome?: 'completed' | 'failed' | 'aborted' | 'open'; + failureClass?: string; + providerStateIdentity?: `sha256:${string}`; + modelId?: string; + cwd?: string; +} + +/** One source invocation as the planner reads it back off the spine. */ +function runInvocation(runId: string, facts: RunFacts = {}): RuntimeInvocationRecord { const ordinal = runId.match(/(\d+)$/)?.[1] ?? '1'; - const status = overrides.status ?? 'failed'; - return { - runId, - invocationId: `invocation-${ordinal}`, + const identity = { sessionId: 'session-1', + invocationId: `invocation-${ordinal}`, + runId, turnId: `turn-${ordinal}`, - status, - backendKind: 'fake', - llmConnectionSlug: 'test', - modelId: 'test-model', - cwd: '/workspace/repo', - permissionMode: 'ask', - ...(status === 'failed' ? { failureClass: 'test_failure' } : {}), - createdAt: 1, - updatedAt: 1, - ...overrides, + }; + const outcome = facts.outcome ?? 'failed'; + const failureClass = outcome === 'failed' ? (facts.failureClass ?? 'test_failure') : undefined; + return { + ...identity, + openedAt: 1, + opening: { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'runtime', + backendKind: 'fake', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'test', + modelId: facts.modelId ?? 'test-model', + ...(facts.providerStateIdentity + ? { providerStateIdentity: facts.providerStateIdentity } + : {}), + }, + configuration: { + cwd: facts.cwd ?? '/workspace/repo', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + root: { kind: 'user' }, + source: facts.source ?? { kind: 'fresh' }, + }, + ...(outcome === 'open' + ? {} + : { + terminalEvent: { + id: `${runId}-terminal`, + ...identity, + ts: 1, + partial: false, + role: 'system', + author: 'system', + status: outcome, + ...(failureClass + ? { actions: { endInvocation: true, stateDelta: { failureClass } } } + : { actions: { endInvocation: true } }), + }, + }), }; } diff --git a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts index 9c343b10b4..9401ac3ab4 100644 --- a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts +++ b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts @@ -29,7 +29,14 @@ import { ToolLedgerRejectionError, } from '@maka/core/tool-ledger-scanner'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; -import type { AgentRunEvent, AgentRunHeader, AgentRunStore } from '@maka/core/agent-run'; +import type { AgentRunEvent, AgentRunStore } from '@maka/core/agent-run'; +import { runtimeInvocationFailureClass } from '../runtime-event-read-model.js'; +import { + buildInvocationOpenedEvent, + runtimeInvocationOutcome, + runtimeInvocationsFromSessionEvents, + type RuntimeInvocationRecord, +} from '@maka/core/runtime-invocation'; import type { CreateSessionInput, SessionListFilter } from '@maka/core/runtime-inputs'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; @@ -214,10 +221,10 @@ describe('SessionManager terminal ledger invariants', () => { await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'hello' })); - const [run] = await runStore.listSessionRuns(session.id); + const [run] = await runStore.listSessionInvocations(session.id); if (!run) throw new Error('run was not recorded'); - assert.strictEqual(run.status, 'failed'); - assert.strictEqual(run.failureClass, 'tool_failed'); + assert.strictEqual(runtimeInvocationOutcome(run), 'failed'); + assert.strictEqual(runtimeInvocationFailureClass(run), 'tool_failed'); const runtimeEvents = await runStore.readRuntimeEvents(session.id, run.runId); assert.strictEqual( runtimeEvents.some( @@ -250,7 +257,6 @@ describe('SessionManager terminal ledger invariants', () => { }); const manager = new SessionManager({ store, - runStore, runtimeEventStore: runStore, backends, newId: nextId(), @@ -271,10 +277,13 @@ describe('SessionManager terminal ledger invariants', () => { await stopPromise; while (!(await iterator.next()).done) {} - const [run] = await runStore.listSessionRuns(session.id); + const [run] = await runStore.listSessionInvocations(session.id); if (!run) throw new Error('run was not recorded'); - assert.strictEqual(run.status, 'cancelled'); - assert.strictEqual(run.abortSource, 'renderer.stop_button'); + assert.strictEqual(runtimeInvocationOutcome(run), 'cancelled'); + assert.strictEqual( + run.terminalEvent?.actions?.stateDelta?.abortSource, + 'renderer.stop_button', + ); const terminalEvents = (await runStore.readRuntimeEvents(session.id, run.runId)).filter( isTerminalRuntimeEvent, ); @@ -325,7 +334,6 @@ describe('SessionManager terminal ledger invariants', () => { backends.register('ai-sdk', (ctx) => new NeverEndingBackend(ctx)); const manager = new SessionManager({ store, - runStore, runtimeEventStore: runStore, backends, newId: nextId(), @@ -339,10 +347,13 @@ describe('SessionManager terminal ledger invariants', () => { assert.strictEqual((await iterator.next()).value?.type, 'text_delta'); await manager.stopSession(session.id, { source: 'stop_button' }); - const [run] = await runStore.listSessionRuns(session.id); + const [run] = await runStore.listSessionInvocations(session.id); if (!run) throw new Error('run was not recorded'); - assert.strictEqual(run.status, 'cancelled'); - assert.strictEqual(run.abortSource, 'renderer.stop_button'); + assert.strictEqual(runtimeInvocationOutcome(run), 'cancelled'); + assert.strictEqual( + run.terminalEvent?.actions?.stateDelta?.abortSource, + 'renderer.stop_button', + ); const terminalEvents = (await runStore.readRuntimeEvents(session.id, run.runId)).filter( isTerminalRuntimeEvent, ); @@ -365,7 +376,6 @@ describe('SessionManager terminal ledger invariants', () => { backends.register('ai-sdk', (ctx) => new NeverEndingBackend(ctx)); const manager = new SessionManager({ store, - runStore, runtimeEventStore: runStore, backends, newId: nextId(), @@ -381,9 +391,9 @@ describe('SessionManager terminal ledger invariants', () => { await assert.rejects(() => manager.stopSession(session.id, { source: 'stop_button' })); await manager.stopSession(session.id, { source: 'stop_button' }); - const [run] = await runStore.listSessionRuns(session.id); + const [run] = await runStore.listSessionInvocations(session.id); if (!run) throw new Error('run was not recorded'); - assert.strictEqual(run.status, 'cancelled'); + assert.strictEqual(runtimeInvocationOutcome(run), 'cancelled'); const terminalEvents = (await runStore.readRuntimeEvents(session.id, run.runId)).filter( isTerminalRuntimeEvent, ); @@ -397,7 +407,6 @@ describe('SessionManager terminal ledger invariants', () => { backends.register('ai-sdk', (ctx) => new NeverEndingBackend(ctx)); const manager = new SessionManager({ store, - runStore, runtimeEventStore: runStore, backends, newId: nextId(), @@ -413,7 +422,7 @@ describe('SessionManager terminal ledger invariants', () => { assert.strictEqual((await iterator.next()).value?.type, 'text_delta'); await manager.stopSession(session.id, { source: 'stop_button' }); - const [run] = await runStore.listSessionRuns(session.id); + const [run] = await runStore.listSessionInvocations(session.id); if (!run) throw new Error('run was not recorded'); const terminalEvents = (await runStore.readRuntimeEvents(session.id, run.runId)).filter( isTerminalRuntimeEvent, @@ -438,7 +447,6 @@ describe('SessionManager terminal ledger invariants', () => { ); const manager = new SessionManager({ store, - runStore, runtimeEventStore: runStore, backends, newId: nextId(), @@ -453,9 +461,9 @@ describe('SessionManager terminal ledger invariants', () => { await sendPromise; assert.strictEqual((await store.readHeader(session.id)).status, 'active'); - const [run] = await runStore.listSessionRuns(session.id); - assert.strictEqual(run?.status, 'failed'); - assert.strictEqual(run?.failureClass, 'tool_step_cap_reached'); + const [run] = await runStore.listSessionInvocations(session.id); + assert.strictEqual(run && runtimeInvocationOutcome(run), 'failed'); + assert.strictEqual(run && runtimeInvocationFailureClass(run), 'tool_step_cap_reached'); const terminalEvents = (await runStore.readRuntimeEvents(session.id, run!.runId)).filter( isTerminalRuntimeEvent, ); @@ -479,19 +487,11 @@ describe('SessionManager terminal ledger invariants', () => { header: session, userInput: { turnId: 'turn-1', text: 'hello' }, store, - runStore, runtimeEventStore: runStore, newId: nextId(), now: nextNow(22_000), hooks: inertAgentRunHooks(store), }); - await runStore.createRun( - makeRunHeader({ - sessionId: session.id, - runId: run.runId, - turnId: run.turnId, - }), - ); const first = run.recordRuntimeEvents([ runtimeEvent({ id: 'terminal-one', @@ -546,15 +546,11 @@ describe('SessionManager terminal ledger invariants', () => { header: session, userInput: { turnId: 'turn-1', text: 'hello' }, store, - runStore, runtimeEventStore: runStore, newId: nextId(), now: nextNow(23_000), hooks: inertAgentRunHooks(store), }); - await runStore.createRun( - makeRunHeader({ sessionId: session.id, runId: run.runId, turnId: run.turnId }), - ); // The rejection still fails the caller — a producer bug must not pass // quietly — and it is recorded on the run. @@ -570,7 +566,7 @@ describe('SessionManager terminal ledger invariants', () => { (error: unknown) => error instanceof ToolLedgerRejectionError, ); assert.match( - String((await runStore.readRun(session.id, run.runId)).traceWriteError), + String(await traceWriteFailure(runStore, session.id, run.runId)), /Tool ledger transition rejected: orphan_response/, ); @@ -619,15 +615,11 @@ describe('SessionManager terminal ledger invariants', () => { header: session, userInput: { turnId: 'turn-1', text: 'hello' }, store, - runStore, runtimeEventStore: runStore, newId: nextId(), now: nextNow(24_000), hooks: inertAgentRunHooks(store), }); - await runStore.createRun( - makeRunHeader({ sessionId: session.id, runId: run.runId, turnId: run.turnId }), - ); // A tool fact is what a damaged ledger refuses. await assert.rejects( @@ -643,7 +635,7 @@ describe('SessionManager terminal ledger invariants', () => { (error: unknown) => error instanceof ToolLedgerCorruptionError, ); assert.match( - String((await runStore.readRun(session.id, run.runId)).traceWriteError), + String(await traceWriteFailure(runStore, session.id, run.runId)), /Tool ledger is corrupt: duplicate_call/, ); @@ -672,7 +664,7 @@ describe('SessionManager terminal ledger invariants', () => { isTerminalRuntimeEvent, ); assert.strictEqual(terminalEvents.length, 1); - assert.strictEqual((await runStore.readRun(session.id, run.runId)).status, 'failed'); + assert.strictEqual(await runOutcome(runStore, session.id, run.runId), 'failed'); }); test('finalization keeps the silent skip when even the terminal barrier is refused', async () => { @@ -691,15 +683,11 @@ describe('SessionManager terminal ledger invariants', () => { header: session, userInput: { turnId: 'turn-1', text: 'hello' }, store, - runStore, runtimeEventStore: runStore, newId: nextId(), now: nextNow(24_100), hooks: inertAgentRunHooks(store), }); - await runStore.createRun( - makeRunHeader({ sessionId: session.id, runId: run.runId, turnId: run.turnId }), - ); await run .recordRuntimeEvents([ runtimeEvent({ @@ -718,7 +706,7 @@ describe('SessionManager terminal ledger invariants', () => { (await runStore.readRuntimeEvents(session.id, run.runId)).some(isTerminalRuntimeEvent), false, ); - assert.strictEqual((await runStore.readRun(session.id, run.runId)).status, 'running'); + assert.strictEqual(await runOutcome(runStore, session.id, run.runId), undefined); }); test('a sealed-run refusal neither latches the store nor stamps a trace failure', async () => { @@ -736,15 +724,11 @@ describe('SessionManager terminal ledger invariants', () => { header: session, userInput: { turnId: 'turn-1', text: 'hello' }, store, - runStore, runtimeEventStore: runStore, newId, now: nextNow(24_200), hooks: inertAgentRunHooks(store), }); - await runStore.createRun( - makeRunHeader({ sessionId: session.id, runId: run.runId, turnId: run.turnId }), - ); run.stop('stop_button'); await run.settleStopTerminal(); assert.strictEqual( @@ -767,7 +751,7 @@ describe('SessionManager terminal ledger invariants', () => { (error: unknown) => error instanceof RunSealedError, ); - assert.strictEqual((await runStore.readRun(session.id, run.runId)).traceWriteError, undefined); + assert.strictEqual(await traceWriteFailure(runStore, session.id, run.runId), undefined); // The seal is per run and permanent, the way SqliteRuntimeStore keeps // refusing; the store stays healthy for everything else, so a second // run on the same store still writes. @@ -776,15 +760,11 @@ describe('SessionManager terminal ledger invariants', () => { header: session, userInput: { turnId: 'turn-2', text: 'again' }, store, - runStore, runtimeEventStore: runStore, newId, now: nextNow(24_300), hooks: inertAgentRunHooks(store), }); - await runStore.createRun( - makeRunHeader({ sessionId: session.id, runId: second.runId, turnId: second.turnId }), - ); await second.recordRuntimeEvents([ runtimeEvent({ id: 'post-seal-probe', @@ -802,32 +782,18 @@ describe('SessionManager terminal ledger invariants', () => { ); }); - test('the continuation boundary hook fires between the terminal barrier and the header', async () => { + test('the continuation boundary hook fires only after the terminal barrier', async () => { // The #2313 recovery path defers 'after_terminal_event_committed' into // this hook because the claimed event's own write never ran; a crash at // the boundary must always find the terminal fact durable first. const order: string[] = []; - class OrderRecordingStore extends TinyAgentRunStore { - override async updateRun( - sessionId: string, - runId: string, - patch: Partial, - ): Promise { - order.push('header'); - return super.updateRun(sessionId, runId, patch); - } - } - const runStore = new OrderRecordingStore({ + const runStore = new TinyAgentRunStore({ beforeTerminalRuntimeEventAppend: async () => { order.push('barrier'); }, }); - await runStore.createRun( - makeRunHeader({ sessionId: 'session-1', runId: 'run-1', turnId: 'turn-1' }), - ); await commitOrCreateTerminalRunFact({ - runStore, runtimeEventStore: runStore, newId: nextId(), sessionId: 'session-1', @@ -836,13 +802,12 @@ describe('SessionManager terminal ledger invariants', () => { ts: 24_400, fallbackStatus: 'cancelled', fallbackInvocationId: 'run-1', - allowHeaderCommitFailure: false, afterTerminalDurable: async () => { order.push('boundary'); }, }); - assert.deepStrictEqual(order.slice(0, 3), ['barrier', 'boundary', 'header']); + assert.deepStrictEqual(order, ['barrier', 'boundary']); }); test('synthetic finalization claims its terminal outcome before its first await', async () => { @@ -857,7 +822,6 @@ describe('SessionManager terminal ledger invariants', () => { header: session, userInput: { turnId: 'turn-1', text: 'hello' }, store, - runStore, runtimeEventStore: runStore, newId: nextId(), now: nextNow(23_000), @@ -870,13 +834,6 @@ describe('SessionManager terminal ledger invariants', () => { }, }, }); - await runStore.createRun( - makeRunHeader({ - sessionId: session.id, - runId: run.runId, - turnId: run.turnId, - }), - ); const finalization = run.finalize(); await headerUpdateStarted.promise; @@ -884,9 +841,7 @@ describe('SessionManager terminal ledger invariants', () => { releaseHeaderUpdate.resolve(); await finalization; - const header = await runStore.readRun(session.id, run.runId); - assert.strictEqual(header.status, 'failed'); - assert.strictEqual(header.failureClass, 'missing_terminal_event'); + assert.strictEqual(await runOutcome(runStore, session.id, run.runId), 'failed'); const terminals = (await runStore.readRuntimeEvents(session.id, run.runId)).filter( isTerminalRuntimeEvent, ); @@ -896,18 +851,16 @@ describe('SessionManager terminal ledger invariants', () => { test('terminal run commits reject mismatched terminal RuntimeEvent statuses', async () => { const runStore = new TinyAgentRunStore(); - const run = makeRunHeader({ status: 'running' }); + const run = makeRunIdentity(); const completedTerminal = runtimeEvent({ id: 'rt-completed', status: 'completed', actions: { endInvocation: true }, }); - await runStore.createRun(run); await runStore.appendRuntimeEvent(run.sessionId, run.runId, completedTerminal); await assert.rejects( commitTerminalRunWithRuntimeFact({ - runStore, runtimeEventStore: runStore, newId: nextId(), sessionId: run.sessionId, @@ -918,25 +871,23 @@ describe('SessionManager terminal ledger invariants', () => { terminalEvent: completedTerminal, failureClass: 'tool_failed', }), - /terminal RuntimeEvent status completed cannot commit failed run header/, + /terminal RuntimeEvent status completed cannot commit a failed run/, ); - assert.strictEqual((await runStore.readRun(run.sessionId, run.runId)).status, 'running'); + assert.strictEqual(await runOutcome(runStore, run.sessionId, run.runId), undefined); }); test('terminal run commits reject terminal RuntimeEvents from another run', async () => { const runStore = new TinyAgentRunStore(); - const run = makeRunHeader({ status: 'running' }); + const run = makeRunIdentity(); const foreignTerminal = runtimeEvent({ id: 'rt-foreign-completed', runId: 'another-run', status: 'completed', actions: { endInvocation: true }, }); - await runStore.createRun(run); await assert.rejects( commitTerminalRunWithRuntimeFact({ - runStore, runtimeEventStore: runStore, newId: nextId(), sessionId: run.sessionId, @@ -946,25 +897,23 @@ describe('SessionManager terminal ledger invariants', () => { ts: 3, terminalEvent: foreignTerminal, }), - /terminal RuntimeEvent identity does not match run header commit/, + /terminal RuntimeEvent identity does not match the run it ends/, ); - assert.strictEqual((await runStore.readRun(run.sessionId, run.runId)).status, 'running'); + assert.strictEqual(await runOutcome(runStore, run.sessionId, run.runId), undefined); }); test('terminal run commits reject partial terminal RuntimeEvents', async () => { const runStore = new TinyAgentRunStore(); - const run = makeRunHeader({ status: 'running' }); + const run = makeRunIdentity(); const partialTerminal = runtimeEvent({ id: 'rt-partial-completed', status: 'completed', partial: true, actions: { endInvocation: true }, }); - await runStore.createRun(run); await assert.rejects( commitTerminalRunWithRuntimeFact({ - runStore, runtimeEventStore: runStore, newId: nextId(), sessionId: run.sessionId, @@ -976,16 +925,14 @@ describe('SessionManager terminal ledger invariants', () => { }), /terminal RuntimeEvent must be final before terminal run header/, ); - assert.strictEqual((await runStore.readRun(run.sessionId, run.runId)).status, 'running'); + assert.strictEqual(await runOutcome(runStore, run.sessionId, run.runId), undefined); }); test('synthetic cancelled terminal commits the fallback abortSource to the run header', async () => { const runStore = new TinyAgentRunStore(); - const run = makeRunHeader({ status: 'running' }); - await runStore.createRun(run); + const run = makeRunIdentity(); await commitOrCreateTerminalRunFact({ - runStore, runtimeEventStore: runStore, newId: nextId(), sessionId: run.sessionId, @@ -996,9 +943,7 @@ describe('SessionManager terminal ledger invariants', () => { fallbackInvocationId: run.runId, }); - const header = await runStore.readRun(run.sessionId, run.runId); - assert.strictEqual(header.status, 'cancelled'); - assert.strictEqual(header.abortSource, 'user_stop'); + assert.strictEqual(await runOutcome(runStore, run.sessionId, run.runId), 'cancelled'); const terminalEvents = (await runStore.readRuntimeEvents(run.sessionId, run.runId)).filter( isTerminalRuntimeEvent, ); @@ -1012,12 +957,10 @@ describe('SessionManager terminal ledger invariants', () => { const runStore = new TinyAgentRunStore({ failTerminalRuntimeEventDurabilityAfterAppend: true, }); - const run = makeRunHeader({ status: 'running' }); - await runStore.createRun(run); + const run = makeRunIdentity(); await assert.rejects( commitOrCreateTerminalRunFact({ - runStore, runtimeEventStore: runStore, newId: nextId(), sessionId: run.sessionId, @@ -1027,18 +970,17 @@ describe('SessionManager terminal ledger invariants', () => { fallbackStatus: 'failed', fallbackInvocationId: run.runId, fallbackFailureClass: 'missing_terminal_event', - allowHeaderCommitFailure: true, }), DurableStoreWriteError, ); - assert.strictEqual((await runStore.readRun(run.sessionId, run.runId)).status, 'running'); + assert.strictEqual(await runOutcome(runStore, run.sessionId, run.runId), undefined); assert.strictEqual((await runStore.readRuntimeEvents(run.sessionId, run.runId)).length, 1); assert.strictEqual((await runStore.readEvents(run.sessionId, run.runId)).length, 0); }); test('synthetic terminal builder keeps live and recovered metadata distinct', () => { - const run = makeRunHeader({ status: 'running' }); + const run = makeRunIdentity(); const live = buildSyntheticTerminalRuntimeEvent({ id: 'live-terminal', invocationId: run.runId, @@ -1067,7 +1009,7 @@ describe('SessionManager terminal ledger invariants', () => { }); test('terminal ledger classification rejects multiple terminal RuntimeEvent signals', () => { - const run = makeRunHeader({ status: 'running' }); + const run = makeRunIdentity(); const result = classifyTerminalRuntimeLedger(run, [ runtimeEvent({ @@ -1177,7 +1119,6 @@ describe('SessionManager terminal ledger invariants', () => { header: session, userInput: { turnId: 'turn-1', text: 'hello' }, store, - runStore, runtimeEventStore: runStore, newId: nextId(), now: nextNow(30_000), @@ -1191,14 +1132,6 @@ describe('SessionManager terminal ledger invariants', () => { appendTurnState: async () => {}, }, }); - await runStore.createRun( - makeRunHeader({ - sessionId: session.id, - runId: run.runId, - turnId: run.turnId, - status: 'running', - }), - ); const terminalEvent = runtimeEvent({ id: 'rt-completed', sessionId: session.id, @@ -1221,7 +1154,7 @@ describe('SessionManager terminal ledger invariants', () => { }); await run.finalize(); - assert.strictEqual((await runStore.readRun(session.id, run.runId)).status, 'running'); + assert.strictEqual(await runOutcome(runStore, session.id, run.runId), undefined); assert.strictEqual( (await runStore.readRuntimeEvents(session.id, run.runId)).some(isTerminalRuntimeEvent), false, @@ -1237,7 +1170,6 @@ describe('SessionManager terminal ledger invariants', () => { header: session, userInput: { turnId: 'turn-1', text: 'hello' }, store, - runStore, runtimeEventStore: runStore, newId: nextId(), now: nextNow(41_000), @@ -1251,20 +1183,10 @@ describe('SessionManager terminal ledger invariants', () => { appendTurnState: async () => {}, }, }); - await runStore.createRun( - makeRunHeader({ - sessionId: session.id, - runId: run.runId, - turnId: run.turnId, - status: 'running', - }), - ); await run.finalize(); - const header = await runStore.readRun(session.id, run.runId); - assert.strictEqual(header.status, 'failed'); - assert.strictEqual(header.failureClass, 'missing_terminal_event'); + assert.strictEqual(await runOutcome(runStore, session.id, run.runId), 'failed'); const terminalEvents = (await runStore.readRuntimeEvents(session.id, run.runId)).filter( isTerminalRuntimeEvent, ); @@ -1276,7 +1198,7 @@ describe('SessionManager terminal ledger invariants', () => { 'missing_terminal_event', ); assert.strictEqual(terminalEvents[0]?.actions?.stateDelta?.recovered, undefined); - await new RuntimeReadModel({ runStore, runtimeEventStore: runStore }).getSessionView( + await new RuntimeReadModel({ runtimeEventStore: runStore }).getSessionView( session.id, ); }); @@ -1293,7 +1215,6 @@ describe('SessionManager terminal ledger invariants', () => { header: session, userInput: { turnId: 'turn-1', text: 'hello' }, store, - runStore, runtimeEventStore: runStore, newId: nextId(), now: nextNow(41_250), @@ -1323,10 +1244,7 @@ describe('SessionManager terminal ledger invariants', () => { run.stop('stop_button'); await run.finalize(); - const header = await runStore.readRun(session.id, run.runId); - assert.strictEqual(header.status, 'cancelled'); - assert.strictEqual(header.failureClass, undefined); - assert.strictEqual(header.abortSource, 'renderer.stop_button'); + assert.strictEqual(await runOutcome(runStore, session.id, run.runId), 'cancelled'); const terminalEvents = (await runStore.readRuntimeEvents(session.id, run.runId)).filter( isTerminalRuntimeEvent, ); @@ -1336,7 +1254,7 @@ describe('SessionManager terminal ledger invariants', () => { assert.strictEqual(terminalEvents[0]?.actions?.stateDelta?.abortSource, 'renderer.stop_button'); assert.strictEqual(terminalEvents[0]?.actions?.stateDelta?.failureClass, undefined); assert.strictEqual(terminalEvents[0]?.actions?.stateDelta?.recovered, undefined); - await new RuntimeReadModel({ runStore, runtimeEventStore: runStore }).getSessionView( + await new RuntimeReadModel({ runtimeEventStore: runStore }).getSessionView( session.id, ); }); @@ -1360,7 +1278,6 @@ describe('SessionManager terminal ledger invariants', () => { header: session, userInput: { turnId: 'turn-1', text: 'hello' }, store, - runStore, runtimeEventStore: runStore, newId: nextId(), now: nextNow(41_500), @@ -1420,7 +1337,6 @@ describe('SessionManager terminal ledger invariants', () => { header: session, userInput: { turnId: 'turn-1', text: 'hello' }, store, - runStore, runtimeEventStore: runStore, newId: nextId(), now: nextNow(41_700), @@ -1483,7 +1399,6 @@ describe('SessionManager terminal ledger invariants', () => { header: session, userInput: { turnId: 'turn-1', text: 'hello' }, store, - runStore, runtimeEventStore: runStore, newId: nextId(), now: nextNow(41_900), @@ -1561,7 +1476,6 @@ describe('SessionManager terminal ledger invariants', () => { header: session, userInput: { turnId: 'turn-1', text: 'hello' }, store, - runStore, runtimeEventStore: runStore, newId: nextId(), now: nextNow(42_000), @@ -1640,7 +1554,6 @@ describe('SessionManager terminal ledger invariants', () => { header: session, userInput: { turnId: 'turn-1', text: 'hello' }, store, - runStore, runtimeEventStore: runStore, newId: nextId(), now: nextNow(42_100), @@ -1706,10 +1619,10 @@ describe('SessionManager terminal ledger invariants', () => { await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'hello' })); - const [header] = await runStore.listSessionRuns(session.id); + const [header] = await runStore.listSessionInvocations(session.id); if (!header) throw new Error('run was not recorded'); - assert.strictEqual(header.status, 'failed'); - assert.strictEqual(header.failureClass, 'tool_failed'); + assert.strictEqual(runtimeInvocationOutcome(header), 'failed'); + assert.strictEqual(runtimeInvocationFailureClass(header), 'tool_failed'); const terminalEvents = (await runStore.readRuntimeEvents(session.id, header.runId)).filter( isTerminalRuntimeEvent, ); @@ -1723,23 +1636,22 @@ describe('SessionManager terminal ledger invariants', () => { const runStore = new TinyAgentRunStore(); const manager = new SessionManager({ store, - runStore, runtimeEventStore: runStore, backends: new BackendRegistry(), newId: nextId(), now: nextNow(50_000), }); const session = await store.create(makeInput({ status: 'active' })); - const run = await runStore.createRun( - makeRunHeader({ + const run = await seedOpening( + runStore, + makeRunIdentity({ sessionId: session.id, runId: 'run-incomplete-terminal', turnId: 'turn-incomplete-terminal', - status: 'running', }), ); await runStore.appendEvent(session.id, run.runId, { - type: 'run_started', + type: 'turn_started', id: 'run-started', sessionId: session.id, runId: run.runId, @@ -1761,16 +1673,15 @@ describe('SessionManager terminal ledger invariants', () => { await manager.recoverInterruptedSessions(); - const header = await runStore.readRun(session.id, run.runId); - assert.strictEqual(header.status, 'failed'); - assert.strictEqual(header.failureClass, 'app_restarted'); + const invocation = await readInvocation(runStore, session.id, run.runId); + assert.strictEqual(runtimeInvocationOutcome(invocation), 'failed'); + assert.strictEqual(runtimeInvocationFailureClass(invocation), 'app_restarted'); const terminalEvents = (await runStore.readRuntimeEvents(session.id, run.runId)).filter( isTerminalRuntimeEvent, ); assert.strictEqual(terminalEvents.length, 1); assert.strictEqual(terminalEvents[0]?.id, 'rt-failed-without-class'); const view = await new RuntimeReadModel({ - runStore, runtimeEventStore: runStore, }).getSessionView(session.id); assert.strictEqual(view.terminalFacts.length, 1); @@ -1782,23 +1693,22 @@ describe('SessionManager terminal ledger invariants', () => { const runStore = new TinyAgentRunStore(); const manager = new SessionManager({ store, - runStore, runtimeEventStore: runStore, backends: new BackendRegistry(), newId: nextId(), now: nextNow(60_000), }); const session = await store.create(makeInput({ status: 'active' })); - const run = await runStore.createRun( - makeRunHeader({ + const run = await seedOpening( + runStore, + makeRunIdentity({ sessionId: session.id, runId: 'run-incomplete-abort', turnId: 'turn-incomplete-abort', - status: 'running', }), ); await runStore.appendEvent(session.id, run.runId, { - type: 'run_started', + type: 'turn_started', id: 'run-started', sessionId: session.id, runId: run.runId, @@ -1820,16 +1730,13 @@ describe('SessionManager terminal ledger invariants', () => { await manager.recoverInterruptedSessions(); - const header = await runStore.readRun(session.id, run.runId); - assert.strictEqual(header.status, 'cancelled'); - assert.strictEqual(header.abortSource, 'unknown'); + assert.strictEqual(await runOutcome(runStore, session.id, run.runId), 'cancelled'); const terminalEvents = (await runStore.readRuntimeEvents(session.id, run.runId)).filter( isTerminalRuntimeEvent, ); assert.strictEqual(terminalEvents.length, 1); assert.strictEqual(terminalEvents[0]?.id, 'rt-aborted-without-source'); const view = await new RuntimeReadModel({ - runStore, runtimeEventStore: runStore, }).getSessionView(session.id); assert.strictEqual(view.terminalFacts.length, 1); @@ -1838,13 +1745,11 @@ describe('SessionManager terminal ledger invariants', () => { test('RuntimeReadModel reads a non-terminal header when a terminal RuntimeEvent fact exists', async () => { const runStore = new TinyAgentRunStore(); - const run = makeRunHeader({ + const run = makeRunIdentity({ sessionId: 'session-read-model', runId: 'run-read-model', turnId: 'turn-read-model', - status: 'running', }); - await runStore.createRun(run); await runStore.appendRuntimeEvent( run.sessionId, run.runId, @@ -1868,12 +1773,17 @@ describe('SessionManager terminal ledger invariants', () => { ); const view = await new RuntimeReadModel({ - runStore, runtimeEventStore: runStore, }).getSessionView(run.sessionId); - assert.strictEqual(view.runs[0]?.status, 'failed'); - assert.strictEqual(view.runs[0]?.failureClass, 'tool_failed'); + assert.strictEqual( + view.invocations[0] && runtimeInvocationOutcome(view.invocations[0]), + 'failed', + ); + assert.strictEqual( + view.invocations[0] && runtimeInvocationFailureClass(view.invocations[0]), + 'tool_failed', + ); assert.strictEqual(view.terminalFacts.length, 1); assert.strictEqual(view.terminalFacts[0]?.failureClass, 'tool_failed'); const turnState = view.messages.find((message) => message.type === 'turn_state'); @@ -1882,89 +1792,23 @@ describe('SessionManager terminal ledger invariants', () => { assert.strictEqual(turnState.errorClass, 'tool_failed'); }); - test('RuntimeReadModel treats the terminal RuntimeEvent as the failure fact when the header is stale', async () => { + test('RuntimeReadModel preserves per-run event order when timestamps disagree', async () => { const runStore = new TinyAgentRunStore(); - const run = makeRunHeader({ - sessionId: 'session-stale-failure-class', - runId: 'run-stale-failure-class', - turnId: 'turn-stale-failure-class', - status: 'failed', - completedAt: 10, - failureClass: 'stale_header_failure', - }); - await runStore.createRun(run); - await runStore.appendRuntimeEvent( - run.sessionId, - run.runId, - runtimeEvent({ - id: 'rt-user-stale-failure', - sessionId: run.sessionId, - runId: run.runId, - turnId: run.turnId, - ts: 8, - role: 'user', - author: 'user', - content: { kind: 'text', text: 'hello' }, - }), - ); - await runStore.appendRuntimeEvent( - run.sessionId, - run.runId, - runtimeEvent({ - id: 'rt-failed-runtime-fact', - sessionId: run.sessionId, - runId: run.runId, - turnId: run.turnId, - ts: 10, - status: 'failed', - content: { - kind: 'error', - code: 'runtime_failure', - reason: 'runtime_failure', - message: 'Runtime failed', - }, - actions: { - endInvocation: true, - stateDelta: { failureClass: 'runtime_failure' }, - }, - }), - ); - - const view = await new RuntimeReadModel({ + const run = await seedOpening( runStore, - runtimeEventStore: runStore, - }).getSessionView(run.sessionId); - - assert.strictEqual(view.terminalFacts[0]?.failureClass, 'runtime_failure'); - assert.strictEqual(view.runs[0]?.failureClass, 'runtime_failure'); - const turnState = view.messages.find((message) => message.type === 'turn_state'); - if (turnState?.type !== 'turn_state') throw new Error('turn_state was not projected'); - assert.strictEqual(turnState.errorClass, 'runtime_failure'); - assert.strictEqual( - view.diagnostics.some( - (diagnostic) => - diagnostic.message === 'terminal run header does not match RuntimeEvent terminal fact', - ), - true, + makeRunIdentity({ + sessionId: 'session-durable-order', + runId: 'run-durable-order', + turnId: 'turn-durable-order', + }), ); - }); - - test('RuntimeReadModel preserves per-run event order when timestamps disagree', async () => { - const runStore = new TinyAgentRunStore(); - const run = makeRunHeader({ - sessionId: 'session-durable-order', - runId: 'run-durable-order', - turnId: 'turn-durable-order', - status: 'completed', - }); - await runStore.createRun(run); for (const event of [ runtimeEvent({ id: 'rt-user-durable-order', sessionId: run.sessionId, runId: run.runId, turnId: run.turnId, - ts: 2, + ts: 3, role: 'user', author: 'user', content: { kind: 'text', text: 'hello' }, @@ -1974,7 +1818,7 @@ describe('SessionManager terminal ledger invariants', () => { sessionId: run.sessionId, runId: run.runId, turnId: run.turnId, - ts: 1, + ts: 2, role: 'model', author: 'agent', content: { kind: 'text', text: 'world' }, @@ -1984,7 +1828,7 @@ describe('SessionManager terminal ledger invariants', () => { sessionId: run.sessionId, runId: run.runId, turnId: run.turnId, - ts: 3, + ts: 4, status: 'completed', actions: { endInvocation: true }, }), @@ -1999,98 +1843,32 @@ describe('SessionManager terminal ledger invariants', () => { assert.deepStrictEqual( view.events.map((event) => event.id), - ['rt-user-durable-order', 'rt-assistant-durable-order', 'rt-terminal-durable-order'], - ); - }); - - test('RuntimeReadModel places backfilled events after durable session order', async () => { - const sessionId = 'session-mixed-durable-order'; - const firstRun = makeRunHeader({ - sessionId, - runId: 'run-first-durable', - turnId: 'turn-first-durable', - status: 'running', - createdAt: 1, - }); - const backfilledRun = makeRunHeader({ - sessionId, - runId: 'run-backfilled', - turnId: 'turn-backfilled', - status: 'completed', - createdAt: 2, - }); - const lastRun = makeRunHeader({ - sessionId, - runId: 'run-last-durable', - turnId: 'turn-last-durable', - status: 'completed', - createdAt: 3, - }); - const runStore = new TinyAgentRunStore(); - for (const run of [firstRun, backfilledRun, lastRun]) await runStore.createRun(run); - - const firstEvent = runtimeEvent({ - id: 'rt-first-durable', - invocationId: 'inv-first-durable', - sessionId, - runId: firstRun.runId, - turnId: firstRun.turnId, - ts: 100, - status: 'completed', - actions: { endInvocation: true }, - }); - const lastEvent = runtimeEvent({ - id: 'rt-last-durable', - invocationId: 'inv-last-durable', - sessionId, - runId: lastRun.runId, - turnId: lastRun.turnId, - ts: 1, - status: 'completed', - actions: { endInvocation: true }, - }); - await runStore.appendRuntimeEvent(sessionId, firstRun.runId, firstEvent); - await runStore.appendRuntimeEvent(sessionId, lastRun.runId, lastEvent); - - const runtimeEventStore = Object.assign(runStore, { - readSessionRuntimeEventEntries: async () => [ - { ordinal: 1, event: firstEvent }, - { ordinal: 2, event: lastEvent }, + [ + 'run-durable-order-invocation-opened', + 'rt-user-durable-order', + 'rt-assistant-durable-order', + 'rt-terminal-durable-order', ], - }); - const legacyMessages: StoredMessage[] = [ - { - type: 'turn_state', - id: 'legacy-state', - turnId: backfilledRun.turnId, - ts: 50, - status: 'completed', - partialOutputRetained: false, - }, - ]; - - const view = await new RuntimeReadModel({ - runStore, - runtimeEventStore, - projectionCache: { readMessages: async () => legacyMessages }, - }).getSessionView(sessionId); - - assert.deepStrictEqual( - view.events.map((event) => event.runId), - [firstRun.runId, lastRun.runId, backfilledRun.runId], ); }); test('RuntimeReadModel retains terminal partial snapshots alongside durable events', async () => { const runStore = new TinyAgentRunStore(); - const run = makeRunHeader({ status: 'cancelled', abortSource: 'user' }); - await runStore.createRun(run); - const opening = runtimeEvent({ - id: 'rt-partial-opening', + const run = await seedOpening( + runStore, + makeRunIdentity({ + sessionId: 'session-partial-order', + runId: 'run-partial-order', + turnId: 'turn-partial-order', + }), + ); + const [opened] = await runStore.readRuntimeEvents(run.sessionId, run.runId); + const prompt = runtimeEvent({ + id: 'rt-partial-prompt', sessionId: run.sessionId, runId: run.runId, turnId: run.turnId, - ts: 1, + ts: 2, role: 'user', author: 'user', content: { kind: 'text', text: 'hello' }, @@ -2100,7 +1878,7 @@ describe('SessionManager terminal ledger invariants', () => { sessionId: run.sessionId, runId: run.runId, turnId: run.turnId, - ts: 2, + ts: 3, partial: true, role: 'model', author: 'agent', @@ -2111,16 +1889,16 @@ describe('SessionManager terminal ledger invariants', () => { sessionId: run.sessionId, runId: run.runId, turnId: run.turnId, - ts: 3, + ts: 4, status: 'cancelled', - actions: { endInvocation: true }, + actions: { endInvocation: true, stateDelta: { abortSource: 'user' } }, }); + await runStore.appendRuntimeEvent(run.sessionId, run.runId, prompt); + await runStore.appendRuntimeEvent(run.sessionId, run.runId, terminal); + // The partial never reached durable session order, which is exactly the + // event the run read has to keep. const runtimeEventStore = Object.assign(runStore, { - readRuntimeEvents: async () => [opening, partial, terminal], - readSessionRuntimeEventEntries: async () => [ - { ordinal: 1, event: opening }, - { ordinal: 2, event: terminal }, - ], + readRuntimeEvents: async () => [opened!, prompt, partial, terminal], }); const view = await new RuntimeReadModel({ @@ -2130,14 +1908,20 @@ describe('SessionManager terminal ledger invariants', () => { assert.deepStrictEqual( view.events.map((event) => event.id), - [opening.id, partial.id, terminal.id], + [opened!.id, prompt.id, partial.id, terminal.id], ); }); test('RuntimeReadModel rejects a failing durable-order reader', async () => { const runStore = new TinyAgentRunStore(); - const run = makeRunHeader({ status: 'completed' }); - await runStore.createRun(run); + const run = await seedOpening( + runStore, + makeRunIdentity({ + sessionId: 'session-durable-order-read', + runId: 'run-durable-order-read', + turnId: 'turn-durable-order-read', + }), + ); const runtimeEventStore = Object.assign(runStore, { readSessionRuntimeEventEntries: async () => { throw new Error('ordinal read rejected'); @@ -2152,14 +1936,11 @@ describe('SessionManager terminal ledger invariants', () => { test('RuntimeReadModel rejects terminal headers when the ledger has no valid terminal fact', async () => { const runStore = new TinyAgentRunStore(); - const run = makeRunHeader({ + const run = makeRunIdentity({ sessionId: 'session-ambiguous-terminal-read', runId: 'run-ambiguous-terminal-read', turnId: 'turn-ambiguous-terminal-read', - status: 'completed', - completedAt: 10, }); - await runStore.createRun(run); await runStore.appendRuntimeEvent( run.sessionId, run.runId, @@ -2202,7 +1983,7 @@ describe('SessionManager terminal ledger invariants', () => { ); await assert.rejects( - new RuntimeReadModel({ runStore, runtimeEventStore: runStore }).getSessionView(run.sessionId), + new RuntimeReadModel({ runtimeEventStore: runStore }).getSessionView(run.sessionId), /valid terminal fact/, ); }); @@ -2212,23 +1993,22 @@ describe('SessionManager terminal ledger invariants', () => { const runStore = new TinyAgentRunStore(); const manager = new SessionManager({ store, - runStore, runtimeEventStore: runStore, backends: new BackendRegistry(), newId: nextId(), now: nextNow(70_000), }); const session = await store.create(makeInput({ status: 'active' })); - const run = await runStore.createRun( - makeRunHeader({ + const run = await seedOpening( + runStore, + makeRunIdentity({ sessionId: session.id, runId: 'run-ambiguous-terminal', turnId: 'turn-ambiguous-terminal', - status: 'running', }), ); await runStore.appendEvent(session.id, run.runId, { - type: 'run_started', + type: 'turn_started', id: 'run-started', sessionId: session.id, runId: run.runId, @@ -2272,7 +2052,7 @@ describe('SessionManager terminal ledger invariants', () => { const recovered = await manager.recoverInterruptedSessions(); assert.deepStrictEqual(recovered, []); - assert.strictEqual((await runStore.readRun(session.id, run.runId)).status, 'running'); + assert.strictEqual(await runOutcome(runStore, session.id, run.runId), undefined); const terminalEvents = (await runStore.readRuntimeEvents(session.id, run.runId)).filter( isTerminalRuntimeEvent, ); @@ -2282,123 +2062,6 @@ describe('SessionManager terminal ledger invariants', () => { ); }); - test('startup recovery treats terminal headers without ledger facts as missing terminal events', async () => { - const store = new TinySessionStore(); - const runStore = new TinyAgentRunStore(); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends: new BackendRegistry(), - newId: nextId(), - now: nextNow(80_000), - }); - const completedSession = await store.create(makeInput({ status: 'active' })); - const failedSession = await store.create(makeInput({ status: 'active' })); - const cancelledSession = await store.create(makeInput({ status: 'active' })); - await runStore.createRun( - makeRunHeader({ - sessionId: completedSession.id, - runId: 'run-completed-empty-ledger', - turnId: 'turn-completed-empty-ledger', - status: 'completed', - completedAt: 20, - }), - ); - await runStore.appendEvent(completedSession.id, 'run-completed-empty-ledger', { - type: 'run_completed', - id: 'run-completed-event', - sessionId: completedSession.id, - runId: 'run-completed-empty-ledger', - turnId: 'turn-completed-empty-ledger', - ts: 20, - }); - await runStore.createRun( - makeRunHeader({ - sessionId: failedSession.id, - runId: 'run-failed-empty-ledger', - turnId: 'turn-failed-empty-ledger', - status: 'failed', - failureClass: 'tool_failed', - completedAt: 21, - }), - ); - await runStore.appendEvent(failedSession.id, 'run-failed-empty-ledger', { - type: 'run_failed', - id: 'run-failed-event', - sessionId: failedSession.id, - runId: 'run-failed-empty-ledger', - turnId: 'turn-failed-empty-ledger', - ts: 21, - data: { failureClass: 'tool_failed' }, - }); - await runStore.createRun( - makeRunHeader({ - sessionId: cancelledSession.id, - runId: 'run-cancelled-empty-ledger', - turnId: 'turn-cancelled-empty-ledger', - status: 'cancelled', - abortSource: 'user_stop', - completedAt: 22, - }), - ); - await runStore.appendEvent(cancelledSession.id, 'run-cancelled-empty-ledger', { - type: 'run_cancelled', - id: 'run-cancelled-event', - sessionId: cancelledSession.id, - runId: 'run-cancelled-empty-ledger', - turnId: 'turn-cancelled-empty-ledger', - ts: 22, - }); - - const recovered = await manager.recoverInterruptedSessions(); - - assert.deepStrictEqual(recovered, [completedSession.id, failedSession.id, cancelledSession.id]); - const completedEvents = ( - await runStore.readRuntimeEvents(completedSession.id, 'run-completed-empty-ledger') - ).filter(isTerminalRuntimeEvent); - assert.strictEqual(completedEvents.length, 1); - assert.strictEqual(completedEvents[0]?.status, 'failed'); - assert.strictEqual( - completedEvents[0]?.actions?.stateDelta?.failureClass, - 'missing_terminal_event', - ); - const failedEvents = ( - await runStore.readRuntimeEvents(failedSession.id, 'run-failed-empty-ledger') - ).filter(isTerminalRuntimeEvent); - assert.strictEqual(failedEvents.length, 1); - assert.strictEqual(failedEvents[0]?.status, 'failed'); - assert.strictEqual( - failedEvents[0]?.actions?.stateDelta?.failureClass, - 'missing_terminal_event', - ); - const cancelledEvents = ( - await runStore.readRuntimeEvents(cancelledSession.id, 'run-cancelled-empty-ledger') - ).filter(isTerminalRuntimeEvent); - assert.strictEqual(cancelledEvents.length, 1); - assert.strictEqual(cancelledEvents[0]?.status, 'failed'); - assert.strictEqual( - cancelledEvents[0]?.actions?.stateDelta?.failureClass, - 'missing_terminal_event', - ); - - const completedView = await new RuntimeReadModel({ - runStore, - runtimeEventStore: runStore, - }).getSessionView(completedSession.id); - assert.strictEqual(completedView.terminalFacts[0]?.runStatus, 'failed'); - assert.strictEqual(completedView.terminalFacts[0]?.failureClass, 'missing_terminal_event'); - const failedView = await new RuntimeReadModel({ - runStore, - runtimeEventStore: runStore, - }).getSessionView(failedSession.id); - assert.strictEqual(failedView.terminalFacts[0]?.failureClass, 'missing_terminal_event'); - const cancelledView = await new RuntimeReadModel({ - runStore, - runtimeEventStore: runStore, - }).getSessionView(cancelledSession.id); - assert.strictEqual(cancelledView.terminalFacts[0]?.failureClass, 'missing_terminal_event'); - }); }); type ScriptEvent = @@ -2423,7 +2086,6 @@ async function makeHarness( backends.register('ai-sdk', (ctx) => new ScriptBackend(ctx, events)); const manager = new SessionManager({ store, - runStore, runtimeEventStore: runStore, backends, newId: nextId(), @@ -2657,10 +2319,8 @@ class TinySessionStore implements SessionStore { } class TinyAgentRunStore implements AgentRunStore, RuntimeEventStore { - private headers = new Map(); private events = new Map(); private runtimeEvents = new Map(); - private runtimeEventEntries: RuntimeEvent[] = []; /** One-shot append rejections, for latching the store availability. */ failNextRuntimeEventAppends = 0; /** While true every runtime-event read rejects, a store that is down. */ @@ -2688,35 +2348,6 @@ class TinyAgentRunStore implements AgentRunStore, RuntimeEventStore { return this.options.durability; } - async createRun(header: AgentRunHeader): Promise { - this.headers.set(key(header.sessionId, header.runId), clone(header)); - return clone(header); - } - - async updateRun( - sessionId: string, - runId: string, - patch: Partial, - ): Promise { - const current = await this.readRun(sessionId, runId); - const next = { ...current, ...patch, sessionId, runId }; - this.headers.set(key(sessionId, runId), clone(next)); - return clone(next); - } - - async readRun(sessionId: string, runId: string): Promise { - const header = this.headers.get(key(sessionId, runId)); - if (!header) throw new Error(`Unknown run ${runId}`); - return clone(header); - } - - async listSessionRuns(sessionId: string): Promise { - return Array.from(this.headers.values()) - .filter((header) => header.sessionId === sessionId) - .sort((a, b) => a.createdAt - b.createdAt || a.runId.localeCompare(b.runId)) - .map(clone); - } - async appendEvent(sessionId: string, runId: string, event: AgentRunEvent): Promise { if (this.failNextRunEventAppends > 0) { this.failNextRunEventAppends -= 1; @@ -2755,9 +2386,6 @@ class TinyAgentRunStore implements AgentRunStore, RuntimeEventStore { if (isTerminalRuntimeEvent(event)) await this.options.beforeTerminalRuntimeEventAppend?.(); const eventKey = key(sessionId, runId); this.runtimeEvents.set(eventKey, [...(this.runtimeEvents.get(eventKey) ?? []), clone(event)]); - if (event.partial !== true && !this.runtimeEventEntries.some(({ id }) => id === event.id)) { - this.runtimeEventEntries.push(clone(event)); - } } async ensureTerminalRuntimeEventDurable( @@ -2786,12 +2414,6 @@ class TinyAgentRunStore implements AgentRunStore, RuntimeEventStore { return clone(this.runtimeEvents.get(key(sessionId, runId)) ?? []); } - async readSessionRuntimeEventEntries(sessionId: string) { - return this.runtimeEventEntries - .filter((event) => event.sessionId === sessionId) - .map((event, index) => ({ ordinal: index + 1, event: clone(event) })); - } - async readSessionRuntimeEvents(sessionId: string): Promise { const ordered: Array<{ event: RuntimeEvent; runId: string; eventIndex: number }> = []; for (const [eventKey, events] of this.runtimeEvents.entries()) { @@ -2810,6 +2432,13 @@ class TinyAgentRunStore implements AgentRunStore, RuntimeEventStore { ); return ordered.map((item) => item.event); } + + async listSessionInvocations(sessionId: string): Promise { + return runtimeInvocationsFromSessionEvents( + sessionId, + await this.readSessionRuntimeEvents(sessionId), + ); + } } class BatchingRuntimeEventStore implements RuntimeEventStore { @@ -2848,15 +2477,13 @@ class BatchingRuntimeEventStore implements RuntimeEventStore { return clone(this.events); } - async readSessionRuntimeEventEntries(sessionId: string) { - return this.events - .filter((event) => event.sessionId === sessionId && event.partial !== true) - .map((event, index) => ({ ordinal: index + 1, event: clone(event) })); - } - async readSessionRuntimeEvents(): Promise { return clone(this.events); } + + async listSessionInvocations(sessionId: string): Promise { + return runtimeInvocationsFromSessionEvents(sessionId, clone(this.events)); + } } function makeInput(overrides: Partial = {}): CreateSessionInput { @@ -2871,21 +2498,87 @@ function makeInput(overrides: Partial = {}): CreateSessionIn }; } -function makeRunHeader(overrides: Partial = {}): AgentRunHeader { - return { - runId: 'run-1', - sessionId: 'session-1', - turnId: 'turn-1', - status: 'running', - backendKind: 'fake', - llmConnectionSlug: 'fake', - modelId: 'fake-model', - cwd: '/tmp/cwd', - permissionMode: 'ask', - createdAt: 1, - updatedAt: 1, - ...overrides, - }; +/** The identity a run is named by. Everything else about it lives on its events. */ +function makeRunIdentity( + overrides: Partial<{ sessionId: string; runId: string; turnId: string }> = {}, +): { sessionId: string; runId: string; turnId: string } { + return { sessionId: 'session-1', runId: 'run-1', turnId: 'turn-1', ...overrides }; +} + +/** Open one invocation on the spine, the way the runtime would. */ +async function seedOpening( + runtimeEventStore: Pick, + run: { sessionId: string; runId: string; turnId: string }, + openedAt = 1, +): Promise<{ sessionId: string; runId: string; turnId: string }> { + await runtimeEventStore.appendRuntimeEvent( + run.sessionId, + run.runId, + buildInvocationOpenedEvent({ + id: `${run.runId}-invocation-opened`, + run: { ...run, invocationId: run.runId }, + openedAt, + opening: { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'runtime', + backendKind: 'fake', + llmConnectionId: 'fake-connection', + llmConnectionSlug: 'fake', + modelId: 'fake-model', + }, + configuration: { + cwd: '/tmp/cwd', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + root: { kind: 'user' }, + source: { kind: 'fresh' }, + }, + }), + ); + return run; +} + +/** The one invocation that opened this run. */ +async function readInvocation( + runtimeEventStore: Pick, + sessionId: string, + runId: string, +): Promise { + const found = (await runtimeEventStore.listSessionInvocations(sessionId)).find( + (candidate) => candidate.runId === runId, + ); + if (!found) throw new Error(`Session ${sessionId} has no invocation for run ${runId}`); + return found; +} + +/** What the run's own operational ledger says went wrong writing its trace. */ +async function traceWriteFailure( + runStore: Pick, + sessionId: string, + runId: string, +): Promise { + const failure = (await runStore.readEvents(sessionId, runId)).find( + (event) => event.type === 'trace_write_failed', + ); + return failure ? String(failure.message) : undefined; +} + +/** What the run's events say it ended as, or `undefined` while it is still open. */ +async function runOutcome( + runtimeEventStore: Pick, + sessionId: string, + runId: string, +): Promise<'completed' | 'failed' | 'cancelled' | undefined> { + const invocation = (await runtimeEventStore.listSessionInvocations(sessionId)).find( + (candidate) => candidate.runId === runId, + ); + return invocation ? runtimeInvocationOutcome(invocation) : undefined; } /** Mirrors the private predicate in `sqlite-runtime-store.ts` that gates the From dd48758a8e949223666ec835cf559a2b8f7eb576 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 12:47:01 +0800 Subject: [PATCH 15/46] test(runtime): read graph, projection and resume facts off the invocation The read model, agent-graph coordinator and steering-recovery suites still built `AgentRunHeader` values to hand to code that now takes a `RuntimeInvocationRecord`. Each builds the invocation instead, and reads the wake root off the opening rather than off two loose header fields. Two steering-recovery tests covered the run-header status latch: one that a failed best-effort write blocked a resume, one that the block lifted when the header write later succeeded. There is no header write left to fail. The surviving barrier is the durable settlement event, and the remaining test now gates on that. The comment in `agent-run.ts` that still described the retired three-step ordering is corrected to the two steps the code performs. Generated-by: Claude Code --- .../agent-run-steering-recovery.test.ts | 311 ++---------------- .../runtime-event-read-model.test.ts | 143 +++++--- .../stream-graph-coordinator.test.ts | 187 ++++++++--- packages/runtime/src/agent-run.ts | 4 +- 4 files changed, 248 insertions(+), 397 deletions(-) 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 610c66a335..bfe557a822 100644 --- a/packages/runtime/src/__tests__/agent-run-steering-recovery.test.ts +++ b/packages/runtime/src/__tests__/agent-run-steering-recovery.test.ts @@ -23,7 +23,6 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import assert from 'node:assert/strict'; import { test } from 'node:test'; -import type { AgentRunHeader } from '@maka/core/agent-run'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { SessionEvent } from '@maka/core/events'; import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; @@ -70,7 +69,7 @@ test('rejects an invalid tool mode before a durable AgentRun can be created', as }), /invalid tool mode/i, ); - assert.deepEqual(await runStore.listSessionRuns(session.id), []); + assert.deepEqual(await runtimeEventStore.listSessionInvocations(session.id), []); } finally { await rm(root, { recursive: true, force: true }); } @@ -163,7 +162,6 @@ test('acks a steering event whose canonical append preceded proof publication fa const runtimeEventStore = createWorkspaceRuntimeStore(root); const runId = 'run-1'; const turnId = 'turn-1'; - await runStore.createRun(makeRunHeader(session.id, runId, turnId)); const run = new AgentRun({ sessionId: session.id, header: session, @@ -318,7 +316,6 @@ test('recovers a steering transcript message from the committed RuntimeEvent led const turnId = 'turn-steering-crash-cut'; const runStore = createSqliteAgentRunStore(root); const runtimeEventStore = createWorkspaceRuntimeStore(root); - await runStore.createRun(makeRunHeader(session.id, runId, turnId)); const steeringContent = { kind: 'text' as const, text: 'canonical steering envelope', @@ -362,12 +359,10 @@ test('recovers a steering transcript message from the committed RuntimeEvent led const recoveredRunStore = createSqliteAgentRunStore(root); const recoveredRuntimeEventStore = createWorkspaceRuntimeStore(root); const repair = new RuntimeLedgerRepair({ - runStore: recoveredRunStore, runtimeEventStore: recoveredRuntimeEventStore, readMessages: (sessionId) => recoveredStore.readMessages(sessionId), appendMessage: (sessionId, message) => recoveredStore.appendMessage(sessionId, message), - appendTurnState: async () => {}, - newId: () => 'unused-id', + newId: () => 'unused-id', now: () => 10, }); @@ -392,7 +387,7 @@ test('recovers a steering transcript message from the committed RuntimeEvent led } }); -test('awaits canonical Run status persistence before accepting an interaction resume', async () => { +test('awaits the durable settlement fact before accepting an interaction resume', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-agent-run-status-barrier-')); try { const store = createSessionStore(root); @@ -407,28 +402,20 @@ test('awaits canonical Run status persistence before accepting an interaction re const runId = 'run-status-barrier'; const turnId = 'turn-status-barrier'; await store.updateHeader(session.id, buildStatusPatch('waiting_for_user', 1)); - await runStore.createRun({ - ...makeRunHeader(session.id, runId, turnId), - status: 'waiting_for_user', - }); - const updateStarted = deferred(); - const allowUpdate = deferred(); - const auditStarted = deferred(); - const allowAudit = deferred(); - const delayedRunStore = { - updateRun: async (...args: Parameters) => { - updateStarted.resolve(); - await allowUpdate.promise; - return await runStore.updateRun(...args); - }, - appendEvent: async (...args: Parameters) => { - if (args[2].type === 'run_status_changed') { - auditStarted.resolve(); - await allowAudit.promise; + const appendStarted = deferred(); + const allowAppend = deferred(); + const delayedRuntimeEventStore = { + ...runtimeEventStore, + appendRuntimeEvent: async ( + ...args: Parameters + ) => { + if (args[2].id === 'status-event') { + appendStarted.resolve(); + await allowAppend.promise; } - return await runStore.appendEvent(...args); + return await runtimeEventStore.appendRuntimeEvent(...args); }, - } as typeof runStore; + } as typeof runtimeEventStore; let sessionUpdateStarted = false; const run = new AgentRun({ sessionId: session.id, @@ -437,8 +424,8 @@ test('awaits canonical Run status persistence before accepting an interaction re runId, durability: 'required', store, - runStore: delayedRunStore, - runtimeEventStore, + runStore, + runtimeEventStore: delayedRuntimeEventStore, newId: () => 'status-event', now: () => 10, hooks: { @@ -469,23 +456,17 @@ test('awaits canonical Run status persistence before accepting an interaction re }); try { - await updateStarted.promise; - assert.equal(accepted, false); - assert.equal((await store.readHeader(session.id)).status, 'waiting_for_user'); - allowUpdate.resolve(); - await auditStarted.promise; + await appendStarted.promise; await Promise.resolve(); assert.equal(accepted, false); assert.equal(sessionUpdateStarted, false); assert.equal((await store.readHeader(session.id)).status, 'waiting_for_user'); - allowAudit.resolve(); + allowAppend.resolve(); await accepting; assert.equal(sessionUpdateStarted, true); - assert.equal((await runStore.readRun(session.id, runId))?.status, 'running'); assert.equal((await store.readHeader(session.id)).status, 'running'); } finally { - allowUpdate.resolve(); - allowAudit.resolve(); + allowAppend.resolve(); await accepting.catch(() => undefined); } } finally { @@ -493,258 +474,6 @@ test('awaits canonical Run status persistence before accepting an interaction re } }); -test('required interaction resume recovers a failed best-effort Run Store latch through terminal commit', async () => { - const root = await mkdtemp(join(tmpdir(), 'maka-agent-run-status-latch-')); - try { - const store = createSessionStore(root); - const session = await store.create({ - cwd: '/tmp/cwd', - llmConnectionSlug: 'fake', - model: 'fake-model', - permissionMode: 'ask', - }); - const runStore = createSqliteAgentRunStore(root); - const runtimeEventStore = createWorkspaceRuntimeStore(root); - const runId = 'run-status-latch'; - const turnId = 'turn-status-latch'; - await store.updateHeader(session.id, buildStatusPatch('waiting_for_user', 1)); - await runStore.createRun({ - ...makeRunHeader(session.id, runId, turnId), - status: 'waiting_for_user', - }); - let failNextAppend = true; - const failingRunStore = { - updateRun: runStore.updateRun.bind(runStore), - appendEvent: async (...args: Parameters) => { - if (failNextAppend) { - failNextAppend = false; - throw new Error('injected trace failure'); - } - return await runStore.appendEvent(...args); - }, - } as typeof runStore; - const run = new AgentRun({ - sessionId: session.id, - header: session, - userInput: { turnId, text: 'fail closed after trace failure' }, - runId, - durability: 'required', - store, - runStore: failingRunStore, - runtimeEventStore, - newId: () => 'status-latch-event', - now: () => 10, - hooks: { - reserveRun: async () => { - throw new Error('reserveRun should not be called'); - }, - unregisterRun: () => {}, - updateHeader: (sessionId, patch) => store.updateHeader(sessionId, patch), - updateStatus: async (sessionId, status, blockedReason, ts = 0) => { - await store.updateHeader(sessionId, buildStatusPatch(status, ts, blockedReason)); - }, - appendTurnState: async () => {}, - }, - }); - run.recordRunTrace({ - id: 'trace-that-fails', - sessionId: session.id, - turnId, - ts: 1, - phase: 'turn', - type: 'turn_started', - message: 'trip the best-effort trace latch', - }); - await waitFor(async () => - Boolean((await runStore.readRun(session.id, runId))?.traceWriteError), - ); - - await run.recordSessionEvent({ - type: 'user_question_answer_ack', - id: 'answer-after-latch', - turnId, - ts: 2, - requestId: 'question-1', - toolUseId: 'tool-1', - }); - assert.equal((await runStore.readRun(session.id, runId))?.status, 'running'); - assert.equal((await store.readHeader(session.id)).status, 'running'); - - await run.recordRuntimeEvents([ - { - id: 'terminal-after-latch', - invocationId: run.invocationId, - runId, - sessionId: session.id, - turnId, - ts: 3, - partial: false, - role: 'system', - author: 'system', - status: 'completed', - actions: { endInvocation: true }, - }, - ]); - await run.recordSessionEvent({ - type: 'complete', - id: 'complete-after-latch', - turnId, - ts: 3, - stopReason: 'end_turn', - }); - await run.finalize(); - - const completedRun = await runStore.readRun(session.id, runId); - assert.equal(completedRun?.status, 'completed'); - assert.equal(completedRun?.completedAt, 3); - assert.equal( - (await runtimeEventStore.readImmutableRuntimeEvents(session.id, runId)).at(-1)?.status, - 'completed', - ); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); - -test('required interaction resume stays fail-closed until a later required write succeeds', async () => { - const root = await mkdtemp(join(tmpdir(), 'maka-agent-run-status-latch-failure-')); - try { - const store = createSessionStore(root); - const session = await store.create({ - cwd: '/tmp/cwd', - llmConnectionSlug: 'fake', - model: 'fake-model', - permissionMode: 'ask', - }); - const runStore = createSqliteAgentRunStore(root); - const runtimeEventStore = createWorkspaceRuntimeStore(root); - const runId = 'run-status-latch-failure'; - const turnId = 'turn-status-latch-failure'; - await store.updateHeader(session.id, buildStatusPatch('waiting_for_user', 1)); - await runStore.createRun({ - ...makeRunHeader(session.id, runId, turnId), - status: 'waiting_for_user', - }); - let failNextAppend = true; - let failRequiredUpdate = false; - const failingRunStore = { - updateRun: async (...args: Parameters) => { - if (failRequiredUpdate) throw new Error('injected required status failure'); - return await runStore.updateRun(...args); - }, - appendEvent: async (...args: Parameters) => { - if (failNextAppend) { - failNextAppend = false; - throw new Error('injected trace failure'); - } - return await runStore.appendEvent(...args); - }, - } as typeof runStore; - const run = new AgentRun({ - sessionId: session.id, - header: session, - userInput: { turnId, text: 'remain waiting after repeated store failure' }, - runId, - durability: 'required', - store, - runStore: failingRunStore, - runtimeEventStore, - newId: () => 'status-latch-failure-event', - now: () => 10, - hooks: { - reserveRun: async () => { - throw new Error('reserveRun should not be called'); - }, - unregisterRun: () => {}, - updateHeader: (sessionId, patch) => store.updateHeader(sessionId, patch), - updateStatus: async (sessionId, status, blockedReason, ts = 0) => { - await store.updateHeader(sessionId, buildStatusPatch(status, ts, blockedReason)); - }, - appendTurnState: async () => {}, - }, - }); - run.recordRunTrace({ - id: 'trace-that-fails-before-required-write', - sessionId: session.id, - turnId, - ts: 1, - phase: 'turn', - type: 'turn_started', - message: 'trip the best-effort trace latch', - }); - await waitFor(async () => - Boolean((await runStore.readRun(session.id, runId))?.traceWriteError), - ); - failRequiredUpdate = true; - - await assert.rejects( - run.recordSessionEvent({ - type: 'user_question_answer_ack', - id: 'answer-after-repeated-failure', - turnId, - ts: 2, - requestId: 'question-1', - toolUseId: 'tool-1', - }), - /injected required status failure/, - ); - assert.equal((await runStore.readRun(session.id, runId))?.status, 'waiting_for_user'); - assert.equal((await store.readHeader(session.id)).status, 'waiting_for_user'); - - failRequiredUpdate = false; - await run.recordSessionEvent({ - type: 'user_question_answer_ack', - id: 'answer-after-required-store-recovers', - turnId, - ts: 3, - requestId: 'question-1', - toolUseId: 'tool-1', - }); - await run.recordRuntimeEvents([ - { - id: 'terminal-after-required-store-recovers', - invocationId: run.invocationId, - runId, - sessionId: session.id, - turnId, - ts: 4, - partial: false, - role: 'system', - author: 'system', - status: 'completed', - actions: { endInvocation: true }, - }, - ]); - await run.recordSessionEvent({ - type: 'complete', - id: 'complete-after-required-store-recovers', - turnId, - ts: 4, - stopReason: 'end_turn', - }); - await run.finalize(); - - assert.equal((await runStore.readRun(session.id, runId))?.status, 'completed'); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); - -function makeRunHeader(sessionId: string, runId: string, turnId: string): AgentRunHeader { - return { - runId, - sessionId, - turnId, - status: 'running', - backendKind: 'fake', - llmConnectionSlug: 'fake', - modelId: 'fake-model', - cwd: '/tmp/cwd', - permissionMode: 'ask', - createdAt: 1, - updatedAt: 1, - }; -} async function waitFor(predicate: () => Promise): Promise { await pollFor(predicate, { attempts: 100, diff --git a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts index c914c32745..27513055dc 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import type { AgentRunHeader } from '@maka/core/agent-run'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { CreateSessionInput, SessionListFilter } from '@maka/core/runtime-inputs'; import type { RuntimeEvent, RuntimeEventActions } from '@maka/core/runtime-event'; import type { SessionHeader, SessionSummary, StoredMessage, TurnRecord } from '@maka/core/session'; @@ -42,20 +42,63 @@ const turnId = 'turn-1'; const invocationId = 'inv-1'; let eventSeq = 0; -const header: AgentRunHeader = { - runId, +/** The same invocation, ended a different way. */ +function endedAs( + status: 'completed' | 'failed' | 'aborted', + failureClass?: string, +): RuntimeInvocationRecord { + return { + ...invocation, + terminalEvent: { + ...invocation.terminalEvent!, + status, + ...(failureClass + ? { actions: { endInvocation: true, stateDelta: { failureClass } } } + : {}), + }, + }; +} + +const invocation: RuntimeInvocationRecord = { sessionId, + invocationId, + runId, turnId, - status: 'completed', - backendKind: 'ai-sdk', - llmConnectionSlug: 'anthropic', - modelId: 'claude-sonnet-4-5', - cwd: '/tmp/work', - permissionMode: 'ask', - createdAt: ts, - updatedAt: ts + 20, - completedAt: ts + 20, - parentTurnId: 'parent-turn', + openedAt: ts, + opening: { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'runtime', + backendKind: 'ai-sdk', + llmConnectionId: 'anthropic-connection', + llmConnectionSlug: 'anthropic', + modelId: 'claude-sonnet-4-5', + }, + configuration: { + cwd: '/tmp/work', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + root: { kind: 'user' }, + source: { kind: 'fresh' }, + lineage: { parentTurnId: 'parent-turn' }, + }, + terminalEvent: { + id: `${runId}-terminal`, + sessionId, + invocationId, + runId, + turnId, + ts: ts + 20, + partial: false, + role: 'system', + author: 'system', + status: 'completed', + }, }; function ev(overrides: Partial): RuntimeEvent { @@ -305,7 +348,7 @@ describe('projectRuntimeEventsToStoredMessages', () => { refs: { storedMessageId: 'user-skill' }, }), ], - { runHeaders: [header] }, + { invocations: [invocation] }, ); assert.deepStrictEqual(out.messages, [ { @@ -331,7 +374,7 @@ describe('projectRuntimeEventsToStoredMessages', () => { }); test('full RuntimeEvent turn projects legacy-compatible rows', () => { - const out = projectRuntimeEventsToStoredMessages(baseEvents(), { runHeaders: [header] }); + const out = projectRuntimeEventsToStoredMessages(baseEvents(), { invocations: [invocation] }); assert.deepStrictEqual( out.messages.map((message) => message.type), @@ -452,7 +495,7 @@ describe('projectRuntimeEventsToStoredMessages', () => { }), ]; - const projected = projectRuntimeEventsToStoredMessages(events, { runHeaders: [header] }); + const projected = projectRuntimeEventsToStoredMessages(events, { invocations: [invocation] }); assert.deepStrictEqual(projected.diagnostics, []); assert.partialDeepStrictEqual(projected.messages[0], { type: 'assistant' }); assert.deepStrictEqual( @@ -552,7 +595,7 @@ describe('projectRuntimeEventsToStoredMessages', () => { actions: { endInvocation: true }, }), ], - { runHeaders: [header] }, + { invocations: [invocation] }, ); assert.deepStrictEqual( @@ -682,7 +725,7 @@ describe('projectRuntimeEventsToStoredMessages', () => { refs: { toolCallId: 'tool-subagent' }, }), ], - { runHeaders: [header] }, + { invocations: [invocation] }, ); const projected = out.messages.find((message) => message.type === 'tool_result'); @@ -791,7 +834,7 @@ describe('projectRuntimeEventsToStoredMessages', () => { refs: { toolCallId: 'tool-agent-swarm' }, }), ], - { runHeaders: [header] }, + { invocations: [invocation] }, ); const projected = out.messages.find((message) => message.type === 'tool_result'); @@ -827,7 +870,7 @@ describe('projectRuntimeEventsToStoredMessages', () => { refs: { providerEventId: 'message-1' }, }), ], - { runHeaders: [header] }, + { invocations: [invocation] }, ); const assistant = out.messages.find((message) => message.type === 'assistant'); @@ -855,7 +898,7 @@ describe('projectRuntimeEventsToStoredMessages', () => { reason: 'stale_tool_result_pruned_before_compact', }; - const out = projectRuntimeEventsToStoredMessages(events, { runHeaders: [header] }); + const out = projectRuntimeEventsToStoredMessages(events, { invocations: [invocation] }); const projected = out.messages.find((message) => message.type === 'tool_result'); assert.partialDeepStrictEqual(projected, { type: 'tool_result', toolUseId: 'tool-1' }); @@ -926,13 +969,13 @@ describe('projectRuntimeEventsToStoredMessages', () => { reason: 'stale_tool_result_pruned_before_compact', }; - const defaultOut = projectRuntimeEventsToStoredMessages(events, { runHeaders: [header] }); + const defaultOut = projectRuntimeEventsToStoredMessages(events, { invocations: [invocation] }); const defaultProjected = defaultOut.messages.find((message) => message.type === 'tool_result'); assert.partialDeepStrictEqual(defaultProjected, { type: 'tool_result' }); assert.strictEqual(archivedStatus(defaultProjected), 'not_loaded'); const missingOut = projectRuntimeEventsToStoredMessagesWithArchiveStatuses(events, { - runHeaders: [header], + invocations: [invocation], archiveStatuses: { 'evt-tool-result': 'missing' }, }); const missingProjected = missingOut.messages.find((message) => message.type === 'tool_result'); @@ -940,7 +983,7 @@ describe('projectRuntimeEventsToStoredMessages', () => { assert.strictEqual(archivedStatus(missingProjected), 'missing'); const corruptOut = projectRuntimeEventsToStoredMessagesWithArchiveStatuses(events, { - runHeaders: [header], + invocations: [invocation], archiveStatuses: [{ runtimeEventId: 'evt-tool-result', status: 'corrupt' }], }); const corruptProjected = corruptOut.messages.find((message) => message.type === 'tool_result'); @@ -965,7 +1008,7 @@ describe('projectRuntimeEventsToStoredMessages', () => { content: { kind: 'text', text: 'final' }, }), ], - { runHeaders: [header] }, + { invocations: [invocation] }, ); assert.strictEqual(out.messages.length, 1); @@ -996,7 +1039,7 @@ describe('projectRuntimeEventsToStoredMessages', () => { refs: { toolCallId: 'tool-1', operationId: 'toolop-1' }, }), ], - { runHeaders: [header] }, + { invocations: [invocation] }, ); assert.deepStrictEqual(out.messages, []); @@ -1014,7 +1057,7 @@ describe('projectRuntimeEventsToStoredMessages', () => { refs: { toolCallId: 'tool-1' }, }), ], - { runHeaders: [header] }, + { invocations: [invocation] }, ); assert.deepStrictEqual(out.messages, []); @@ -1064,7 +1107,7 @@ describe('projectRuntimeEventsToStoredMessages', () => { refs: { toolCallId: 'tool-1', operationId: 'toolop-1' }, }), ], - { runHeaders: [header] }, + { invocations: [invocation] }, ); assert.deepStrictEqual(out.messages, []); @@ -1103,7 +1146,7 @@ describe('projectRuntimeEventsToStoredMessages', () => { }, }), ], - { runHeaders: [header] }, + { invocations: [invocation] }, ); assert.deepStrictEqual(out.messages, []); @@ -1152,7 +1195,7 @@ describe('projectRuntimeEventsToStoredMessages', () => { refs: { toolCallId: 'tool-1' }, }), ], - { runHeaders: [header] }, + { invocations: [invocation] }, ); assert.deepStrictEqual(out.messages, []); @@ -1297,7 +1340,7 @@ describe('projectRuntimeEventsToStoredMessages', () => { // owns no chat row, so a broken one costs a reader nothing the session view // would otherwise show. test(`a sandbox boundary ${name} stays unclaimed`, () => { - const out = projectRuntimeEventsToStoredMessages([makeEvent()], { runHeaders: [header] }); + const out = projectRuntimeEventsToStoredMessages([makeEvent()], { invocations: [invocation] }); assert.deepStrictEqual(out.messages, []); assert.deepStrictEqual( @@ -1334,7 +1377,7 @@ describe('projectRuntimeEventsToStoredMessages', () => { refs: { storedMessageId: 'legacy-assistant' }, }), ], - { runHeaders: [header] }, + { invocations: [invocation] }, ); const legacy: StoredMessage[] = [ { @@ -1397,7 +1440,7 @@ describe('projectRuntimeEventsToStoredMessages', () => { refs: { providerEventId: 'step-2' }, }), ], - { runHeaders: [header] }, + { invocations: [invocation] }, ); const assistants = out.messages.filter((message) => message.type === 'assistant'); @@ -1454,7 +1497,7 @@ describe('projectRuntimeEventsToStoredMessages', () => { }, }), ], - { runHeaders: [header] }, + { invocations: [invocation] }, ); assert.deepStrictEqual(out.messages, []); @@ -1492,7 +1535,7 @@ describe('projectRuntimeEventsToStoredMessages', () => { content: { kind: 'text', text: 'hello' }, }), ], - { runHeaders: [header] }, + { invocations: [invocation] }, ); assert.deepStrictEqual( @@ -1520,7 +1563,7 @@ describe('projectRuntimeEventsToStoredMessages', () => { content: { kind: 'not_yet_projected', text: 'a reader would have seen this' } as never, }), ], - { runHeaders: [header] }, + { invocations: [invocation] }, ); assert.deepStrictEqual(out.messages, []); @@ -1542,7 +1585,7 @@ describe('projectRuntimeEventsToStoredMessages', () => { }), ], { - runHeaders: [{ ...header, status: 'failed', failureClass: 'tool_failed' }], + invocations: [endedAs('failed', 'tool_failed')], }, ); @@ -1583,7 +1626,7 @@ describe('projectRuntimeEventsToStoredMessages', () => { }), ], { - runHeaders: [{ ...header, status: 'failed', failureClass: 'context_budget_exhausted' }], + invocations: [endedAs('failed', 'context_budget_exhausted')], }, ); @@ -1617,7 +1660,7 @@ describe('projectRuntimeEventsToStoredMessages', () => { }), ], { - runHeaders: [{ ...header, status: 'failed', failureClass: 'tool_step_cap_reached' }], + invocations: [endedAs('failed', 'tool_step_cap_reached')], }, ); @@ -1644,7 +1687,7 @@ describe('projectRuntimeEventsToStoredMessages', () => { }), ], { - runHeaders: [{ ...header, status: 'cancelled' }], + invocations: [endedAs('aborted')], }, ); @@ -1675,7 +1718,7 @@ describe('projectRuntimeEventsToStoredMessages', () => { }), ], { - runHeaders: [{ ...header, status: 'cancelled' }], + invocations: [endedAs('aborted')], }, ); @@ -1706,7 +1749,7 @@ describe('projectRuntimeEventsToStoredMessages', () => { }); const withStep = projectRuntimeEventsToStoredMessages([stepCall('tool-step', 'step-1')], { - runHeaders: [header], + invocations: [invocation], }); assert.partialDeepStrictEqual(withStep.messages[0], { type: 'tool_call', @@ -1717,7 +1760,7 @@ describe('projectRuntimeEventsToStoredMessages', () => { // Legacy events without refs.stepId must not grow a stepId key: the UI // uses its absence to pick the backward-compatible tools-first ordering. const withoutStep = projectRuntimeEventsToStoredMessages([stepCall('tool-legacy')], { - runHeaders: [header], + invocations: [invocation], }); const legacyCall = withoutStep.messages[0]; assert.partialDeepStrictEqual(legacyCall, { type: 'tool_call', id: 'tool-legacy' }); @@ -1754,7 +1797,7 @@ describe('projectRuntimeEventsToStoredMessages', () => { }, }), ], - { runHeaders: [header] }, + { invocations: [invocation] }, ); assert.strictEqual(out.messages.length, 2); @@ -1785,7 +1828,7 @@ describe('projectRuntimeEventsToStoredMessages', () => { refs: { toolCallId: 'tool-kind' }, }), ], - { runHeaders: [header] }, + { invocations: [invocation] }, ); assert.partialDeepStrictEqual(out.messages[0], { @@ -1969,7 +2012,7 @@ describe('RuntimeEventActions projection coverage', () => { // Guards an entry that names a field but leaves it absent at runtime. assert.strictEqual(field in actions, true); const out = projectRuntimeEventsToStoredMessages([ev({ ...sample.event, actions })], { - runHeaders: [header], + invocations: [invocation], }); assert.deepStrictEqual(out.diagnostics.filter(isUnclaimedRuntimeEventDiagnostic), []); @@ -2004,7 +2047,7 @@ describe('compareRuntimeReadModelMessages', () => { }, }), ], - { runHeaders: [header] }, + { invocations: [invocation] }, ); const legacy: StoredMessage[] = [ { @@ -2072,13 +2115,13 @@ describe('compareRuntimeReadModelMessages', () => { }), anchored, ], - { runHeaders: [header] }, + { invocations: [invocation] }, ); const usage = projected.messages.find((message) => message.type === 'token_usage'); assert.partialDeepStrictEqual(usage, { type: 'token_usage', input: 370, lastRequestAnchor }); const backfilled = backfillRuntimeEventsFromStoredMessages({ - run: header, + run: { sessionId, invocationId, runId, turnId }, messages: projected.messages, now: () => ts, }); @@ -2133,7 +2176,7 @@ describe('compareRuntimeReadModelMessages', () => { }); test('rejects missing tool result and assistant text cases', () => { - const projected = projectRuntimeEventsToStoredMessages(baseEvents(), { runHeaders: [header] }); + const projected = projectRuntimeEventsToStoredMessages(baseEvents(), { invocations: [invocation] }); const missing = projected.messages.filter( (message) => message.type !== 'tool_result' && message.type !== 'assistant', ); diff --git a/packages/runtime/src/__tests__/stream-graph-coordinator.test.ts b/packages/runtime/src/__tests__/stream-graph-coordinator.test.ts index b300f3de96..2b323a6920 100644 --- a/packages/runtime/src/__tests__/stream-graph-coordinator.test.ts +++ b/packages/runtime/src/__tests__/stream-graph-coordinator.test.ts @@ -33,7 +33,10 @@ import { type AgentGraphOperatorProvision, } from '@maka/core/agent-graph-topology'; import { type AgentGraphScheduleUpdate } from '@maka/core/agent-graph-schedule'; -import { type AgentRunHeader } from '@maka/core/agent-run'; +import { + runtimeInvocationOutcome, + type RuntimeInvocationRecord, +} from '@maka/core/runtime-invocation'; import { type RuntimeEvent } from '@maka/core/runtime-event'; import { OPERATIONAL_STATE_DATABASE_NAME } from '@maka/storage/operational-state-store'; import { createSessionStore, isSessionNotFoundError } from '@maka/storage/session-store'; @@ -68,20 +71,45 @@ describe('host-managed agent graph coordinator', () => { const rootSessionId = 'root-session'; const sourceGraphId = agentGraphIdForRootSession(rootSessionId); const currentGraphId = agentGraphIdForRootSessionEpoch(rootSessionId, 2); - const sourceRun: AgentRunHeader = { + const sourceRun: RuntimeInvocationRecord = { sessionId: 'source-child', runId: 'source-run', turnId: 'source-turn', invocationId: 'source-invocation', - backendKind: 'fake', - llmConnectionSlug: 'fake', - modelId: 'fake', - cwd: '/workspace', - permissionMode: 'explore', - status: 'completed', - createdAt: 1, - updatedAt: 2, - completedAt: 2, + openedAt: 1, + opening: { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'runtime', + backendKind: 'fake', + llmConnectionId: 'fake-connection', + llmConnectionSlug: 'fake', + modelId: 'fake', + }, + configuration: { + cwd: '/workspace', + permissionMode: 'explore', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + root: { kind: 'user' }, + source: { kind: 'fresh' }, + }, + terminalEvent: { + id: 'source-terminal', + sessionId: 'source-child', + invocationId: 'source-invocation', + runId: 'source-run', + turnId: 'source-turn', + ts: 2, + partial: false, + role: 'system', + author: 'system', + status: 'completed', + }, }; const sourceEvent: RuntimeEvent = { id: 'source-result-event', @@ -152,11 +180,9 @@ describe('host-managed agent graph coordinator', () => { readHeader: async (sessionId: string) => ({ id: sessionId, status: 'active', isArchived: false }) as never, }, - runStore: { - listSessionRuns: async (sessionId: string) => - sessionId === sourceRun.sessionId ? [sourceRun] : [], - }, runtimeEventStore: { + listSessionInvocations: async (sessionId: string) => + sessionId === sourceRun.sessionId ? [sourceRun] : [], readImmutableRuntimeEvents: async (sessionId, runId) => sessionId === sourceRun.sessionId && runId === sourceRun.runId ? [sourceEvent] : [], }, @@ -307,7 +333,7 @@ describe('host-managed agent graph coordinator', () => { })) { // Drain the ordinary root turn so its source AgentRun is durable. } - const sourceRun = (await runStore.listSessionRuns(rootSession.id)).find( + const sourceRun = (await runtimeEventStore.listSessionInvocations(rootSession.id)).find( (run) => run.turnId === sourceTurnId, ); assert.ok(sourceRun); @@ -329,11 +355,14 @@ describe('host-managed agent graph coordinator', () => { return { kind: 'completed', turnId: input.turnId }; }, inspectAttempt: async (sessionId, attemptId, turnId) => { - const run = (await runStore.listSessionRuns(sessionId)).find( + const run = (await runtimeEventStore.listSessionInvocations(sessionId)).find( (candidate) => - candidate.agentGraphWakeAttemptId === attemptId && candidate.turnId === turnId, + candidate.opening.root.kind === 'agent_graph_supervisor_wake' && + candidate.opening.root.attemptId === attemptId && + candidate.turnId === turnId, ); - return run?.status ?? 'missing'; + if (!run) return 'missing'; + return runtimeInvocationOutcome(run) ?? 'running'; }, newId: randomUUID, onError: (_rootSessionId, error) => { @@ -424,12 +453,15 @@ describe('host-managed agent graph coordinator', () => { ), 'the original root Agent must run again and produce a deliverable response', ); - const wakeRun = (await runStore.listSessionRuns(rootSession.id)).find( + const wakeRun = (await runtimeEventStore.listSessionInvocations(rootSession.id)).find( (run) => run.turnId === graphWake.turnId, ); assert.ok(wakeRun); - assert.equal(wakeRun.agentGraphWakeId, graphWake.origin.wakeId); - assert.equal(wakeRun.agentGraphWakeAttemptId, graphWake.origin.attemptId); + assert.deepEqual(wakeRun.opening.root, { + kind: 'agent_graph_supervisor_wake', + wakeId: graphWake.origin.wakeId, + attemptId: graphWake.origin.attemptId, + }); const wakeEvents = await runtimeEventStore.readImmutableRuntimeEvents( rootSession.id, wakeRun.runId, @@ -752,8 +784,10 @@ describe('host-managed agent graph coordinator', () => { throw new Error('wake must fail before reading the Session'); }, }, - runStore: { listSessionRuns: async () => [] }, - runtimeEventStore: { readImmutableRuntimeEvents: async () => [] }, + runtimeEventStore: { + listSessionInvocations: async () => [], + readImmutableRuntimeEvents: async () => [], + }, controlStore, epochStore: { resolveCurrentAgentGraphEpoch: async () => { @@ -811,8 +845,10 @@ describe('host-managed agent graph coordinator', () => { } as never; }, }, - runStore: { listSessionRuns: async () => [] }, - runtimeEventStore: { readImmutableRuntimeEvents: async () => [] }, + runtimeEventStore: { + listSessionInvocations: async () => [], + readImmutableRuntimeEvents: async () => [], + }, controlStore, epochStore: { resolveCurrentAgentGraphEpoch: async () => { @@ -908,8 +944,10 @@ describe('host-managed agent graph coordinator', () => { throw new Error('removed Session header must not be read during cleanup'); }, }, - runStore: { listSessionRuns: async () => [] }, - runtimeEventStore: { readImmutableRuntimeEvents: async () => [] }, + runtimeEventStore: { + listSessionInvocations: async () => [], + readImmutableRuntimeEvents: async () => [], + }, controlStore, epochStore: { resolveCurrentAgentGraphEpoch: async () => epochs[0]!, @@ -965,8 +1003,10 @@ describe('host-managed agent graph coordinator', () => { orchestrationMode: 'graph', }) as never, }, - runStore: { listSessionRuns: async () => [] }, - runtimeEventStore: { readImmutableRuntimeEvents: async () => [] }, + runtimeEventStore: { + listSessionInvocations: async () => [], + readImmutableRuntimeEvents: async () => [], + }, controlStore, epochStore: controlStore, runtime: { @@ -1042,8 +1082,10 @@ describe('host-managed agent graph coordinator', () => { orchestrationMode: 'graph', }) as never, }, - runStore: { listSessionRuns: async () => [] }, - runtimeEventStore: { readImmutableRuntimeEvents: async () => [] }, + runtimeEventStore: { + listSessionInvocations: async () => [], + readImmutableRuntimeEvents: async () => [], + }, controlStore, epochStore: controlStore, runtime: { @@ -1132,7 +1174,7 @@ describe('host-managed agent graph coordinator', () => { })) { // Drain the source turn so its AgentRun is durable. } - const sourceRun = (await runStore.listSessionRuns(rootSession.id)).find( + const sourceRun = (await runtimeEventStore.listSessionInvocations(rootSession.id)).find( (run) => run.turnId === sourceTurnId, ); assert.ok(sourceRun); @@ -1146,7 +1188,6 @@ describe('host-managed agent graph coordinator', () => { const create = () => new AgentGraphCoordinator({ sessionStore, - runStore, runtimeEventStore, controlStore, runtime: failingRuntime, @@ -1231,8 +1272,10 @@ describe('host-managed agent graph coordinator', () => { isArchived: true, }) as never, }, - runStore: { listSessionRuns: async () => [] }, - runtimeEventStore: { readImmutableRuntimeEvents: async () => [] }, + runtimeEventStore: { + listSessionInvocations: async () => [], + readImmutableRuntimeEvents: async () => [], + }, epochStore: { resolveCurrentAgentGraphEpoch: async () => ({ schemaVersion: 1, @@ -1401,19 +1444,33 @@ describe('host-managed agent graph coordinator', () => { targetRunId: runId, claimedAt: 12, }; - const runningRun: AgentRunHeader = { + const runningRun: RuntimeInvocationRecord = { sessionId: childSessionId, + invocationId: 'child-invocation', runId, turnId, - invocationId: 'child-invocation', - backendKind: 'fake', - llmConnectionSlug: 'fake', - modelId: 'fake', - cwd: '/workspace', - permissionMode: 'explore', - status: 'running', - createdAt: 12, - updatedAt: 12, + openedAt: 12, + opening: { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'runtime', + backendKind: 'fake', + llmConnectionId: 'fake-connection', + llmConnectionSlug: 'fake', + modelId: 'fake', + }, + configuration: { + cwd: '/workspace', + permissionMode: 'explore', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + root: { kind: 'user' }, + source: { kind: 'fresh' }, + }, }; const runningEvent: RuntimeEvent = { id: 'child-started', @@ -1430,7 +1487,7 @@ describe('host-managed agent graph coordinator', () => { let scheduleUpdates: AgentGraphScheduleUpdate[] = []; let provisions: AgentGraphOperatorProvision[] = []; let claims: AgentGraphIntentClaim[] = []; - let runs: AgentRunHeader[] = [runningRun]; + let runs: RuntimeInvocationRecord[] = [runningRun]; let runtimeEvents: RuntimeEvent[] = [runningEvent]; let projection: | { @@ -1511,7 +1568,23 @@ describe('host-managed agent graph coordinator', () => { assert.equal(await coordinator.readSessionState(rootSessionId), 'live'); assert.equal(await coordinator.hasLiveSessionState(rootSessionId), true); - runs = [{ ...runningRun, status: 'completed', completedAt: 14, updatedAt: 14 }]; + runs = [ + { + ...runningRun, + terminalEvent: { + id: 'child-terminal', + sessionId: childSessionId, + invocationId: 'child-invocation', + runId, + turnId, + ts: 14, + partial: false, + role: 'system', + author: 'system', + status: 'completed', + }, + }, + ]; runtimeEvents = [ runningEvent, { @@ -1547,8 +1620,10 @@ describe('host-managed agent graph coordinator', () => { isArchived: false, }) as never, }, - runStore: { listSessionRuns: async () => [] }, - runtimeEventStore: { readImmutableRuntimeEvents: async () => [] }, + runtimeEventStore: { + listSessionInvocations: async () => [], + readImmutableRuntimeEvents: async () => [], + }, controlStore: { listAgentGraphOperatorProvisions: async () => { throw topologyFailure; @@ -1597,8 +1672,10 @@ describe('host-managed agent graph coordinator', () => { isArchived: false, }) as never, }, - runStore: { listSessionRuns: async () => [] }, - runtimeEventStore: { readImmutableRuntimeEvents: async () => [] }, + runtimeEventStore: { + listSessionInvocations: async () => [], + readImmutableRuntimeEvents: async () => [], + }, controlStore: { listAgentGraphOperatorProvisions: async () => ['a', 'b'].map((suffix, index) => ({ @@ -1677,8 +1754,10 @@ describe('host-managed agent graph coordinator', () => { isArchived: false, }) as never, }, - runStore: { listSessionRuns: async () => [] }, - runtimeEventStore: { readImmutableRuntimeEvents: async () => [] }, + runtimeEventStore: { + listSessionInvocations: async () => [], + readImmutableRuntimeEvents: async () => [], + }, controlStore: gatedStore, runtime: { provisionAgentGraphOperator: async () => { diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index 477292bed6..ec982ccce6 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -622,8 +622,8 @@ export class AgentRun { } if (this.requiresDurablePersistence() && isInteractionResumeAck(sessionEvent)) { // A hosted continuation may resume execution only after its identity-only - // settlement fact is durable. Run status advances next, then Session - // status; the queue consumer acknowledges the event only after all three. + // settlement fact is durable. Session status advances next, and the queue + // consumer acknowledges the event only after both. await this.recordRuntimeEvents([runtimeEvent], { requireDurableWrite: true }); await this.recordSessionEvent(sessionEvent, options); return; From 4c26642f5a0e7275bf2de88e96108c2eba1bc625 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 12:49:39 +0800 Subject: [PATCH 16/46] test(runtime): name the runs to scan instead of enumerating headers The compaction checkpoint, latest-context and ledger-repair readers now take the session-inline run ids from their caller, because the header table that used to enumerate them is gone. Their tests pass those ids. The backend suites build the source route as an invocation opening, which is where a run's connection, model and provider identity live. Generated-by: Claude Code --- .../src/__tests__/ai-sdk-backend.test.ts | 154 +++++++++--------- .../history-compact-checkpoint.test.ts | 90 +++------- .../__tests__/latest-context-commit.test.ts | 24 +-- .../__tests__/runtime-ledger-repair.test.ts | 89 +++++----- 4 files changed, 160 insertions(+), 197 deletions(-) diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index e85997f7bf..1966796fbd 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -26,7 +26,8 @@ import { describe, test } from 'node:test'; import type { ModelMessage, ModelStreamResult } from '../model-protocol.js'; import { MockLanguageModelV4, simulateReadableStream } from 'ai/test'; import { APICallError, type LanguageModelV4StreamPart } from '@ai-sdk/provider'; -import type { AgentRunHeader } from '@maka/core/agent-run'; +import type { RuntimeInvocationRootAuthority } from '@maka/core/runtime-event'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { AttachmentByteReader } from '@maka/core/attachments'; import type { BackendSendInput } from '@maka/core/backend-types'; import type { LlmConnection } from '@maka/core/llm-connections'; @@ -4885,31 +4886,29 @@ describe('AiSdkBackend model history', () => { text: 'recent', }), ]; - const sourceRunHeader = priorModelRunHeader({ + const sourceRunHeader = priorModelInvocation({ connectionId: 'test-connection-id', modelId: 'mock-model-id', }); - const priorCompactionRunHeader: AgentRunHeader = { - ...priorModelRunHeader({ - connectionId: 'test-connection-id', - modelId: 'mock-model-id', - runId: 'run-1', - }), + const priorCompactionRunHeader = priorModelInvocation({ + connectionId: 'test-connection-id', + modelId: 'mock-model-id', + runId: 'run-1', turnId: 'turn-compact-1', - rootExecutionKind: 'context_compact', - }; + root: { kind: 'context_compact' }, + }); const first = await backend.compactHistory({ turnId: 'turn-compact-1', runId: 'run-1', runtimeContext: history, - runtimeContextRunHeaders: [sourceRunHeader], + runtimeContextInvocations: [sourceRunHeader], }); const repeated = await backend.compactHistory({ turnId: 'turn-compact-2', runId: 'run-2', runtimeContext: history, - runtimeContextRunHeaders: [sourceRunHeader, priorCompactionRunHeader], + runtimeContextInvocations: [sourceRunHeader, priorCompactionRunHeader], }); assert.equal(calls, 1); @@ -4932,7 +4931,7 @@ describe('AiSdkBackend model history', () => { text: 'new source history', }), ], - runtimeContextRunHeaders: [sourceRunHeader, priorCompactionRunHeader], + runtimeContextInvocations: [sourceRunHeader, priorCompactionRunHeader], }); assert.equal(calls, 2, 'changed source fingerprint is eligible again'); }); @@ -6240,8 +6239,8 @@ describe('AiSdkBackend model history', () => { turnId: 'turn-current', text: 'continue', context: [], - runtimeContextRunHeaders: [ - priorModelRunHeader({ connectionId: 'connection-a', modelId: 'claude-a' }), + runtimeContextInvocations: [ + priorModelInvocation({ connectionId: 'connection-a', modelId: 'claude-a' }), ], runtimeContext: [ runtimeTextEvent({ @@ -6325,8 +6324,8 @@ describe('AiSdkBackend model history', () => { turnId: 'turn-current', text: 'continue', context: [], - runtimeContextRunHeaders: [ - priorModelRunHeader({ + runtimeContextInvocations: [ + priorModelInvocation({ connectionId: 'connection-a', modelId: 'claude-a', providerStateIdentity: `sha256:${'a'.repeat(64)}`, @@ -6438,8 +6437,8 @@ describe('AiSdkBackend model history', () => { turnId: 'turn-current', text: 'continue', context: [], - runtimeContextRunHeaders: [ - priorModelRunHeader({ + runtimeContextInvocations: [ + priorModelInvocation({ connectionId: 'connection-copilot', connectionSlug: 'github-copilot', modelId: 'gpt-5.5', @@ -6532,8 +6531,8 @@ describe('AiSdkBackend model history', () => { turnId: 'turn-current', text: 'continue', context: [], - runtimeContextRunHeaders: [ - priorModelRunHeader({ + runtimeContextInvocations: [ + priorModelInvocation({ connectionId: 'connection-openai', connectionSlug: 'openai-main', modelId: 'gpt-5.4', @@ -11935,21 +11934,13 @@ describe('AiSdkBackend thinking persistence', () => { } as unknown as RuntimeEventMapContext; const memory = createSessionEventMapMemory(); const runtimeEvents = events.map((event) => mapSessionEventToRuntimeEvent(event, ctx, memory)); - const runHeader: AgentRunHeader = { + const runHeader = priorModelInvocation({ + modelId: 'mock-model-id', runId: 'run-1', - sessionId: 'session-1', turnId: 'turn-1', - status: 'completed', - backendKind: 'ai-sdk', - llmConnectionSlug: 'anthropic-main', - modelId: 'mock-model-id', - cwd: '/tmp/maka', - permissionMode: 'ask', - createdAt: 1, - updatedAt: 2, - }; + }); const projection = projectRuntimeEventsToStoredMessages(runtimeEvents, { - runHeaders: [runHeader], + invocations: [runHeader], }); const assistant = projection.messages.find((message) => message.type === 'assistant'); assert.ok(assistant && assistant.type === 'assistant'); @@ -12029,21 +12020,13 @@ describe('AiSdkBackend thinking persistence', () => { } as unknown as RuntimeEventMapContext; const memory = createSessionEventMapMemory(); const runtimeEvents = events.map((event) => mapSessionEventToRuntimeEvent(event, ctx, memory)); - const runHeader: AgentRunHeader = { + const runHeader = priorModelInvocation({ + modelId: 'mock-model-id', runId: 'run-1', - sessionId: 'session-1', turnId: 'turn-1', - status: 'completed', - backendKind: 'ai-sdk', - llmConnectionSlug: 'anthropic-main', - modelId: 'mock-model-id', - cwd: '/tmp/maka', - permissionMode: 'ask', - createdAt: 1, - updatedAt: 2, - }; + }); const projection = projectRuntimeEventsToStoredMessages(runtimeEvents, { - runHeaders: [runHeader], + invocations: [runHeader], }); const assistant = projection.messages.find((message) => message.type === 'assistant'); assert.ok(assistant && assistant.type === 'assistant'); @@ -13711,20 +13694,8 @@ describe('AiSdkBackend thinking persistence', () => { const memory = createSessionEventMapMemory(); const runtimeContext = events.map((event) => mapSessionEventToRuntimeEvent(event, ctx, memory)); const projection = projectRuntimeEventsToStoredMessages(runtimeContext, { - runHeaders: [ - { - runId: 'run-prev', - sessionId: 'session-1', - turnId: 'turn-prev', - status: 'completed', - backendKind: 'ai-sdk', - llmConnectionSlug: planConnection.slug, - modelId: 'ark-code-latest', - cwd: '/tmp/maka', - permissionMode: 'ask', - createdAt: 1, - updatedAt: 2, - }, + invocations: [ + priorModelInvocation({ modelId: 'ark-code-latest', connectionSlug: planConnection.slug }), ], }); const projectedAssistant = projection.messages.find( @@ -16321,38 +16292,65 @@ function header(permissionMode: SessionHeader['permissionMode'] = 'ask'): Sessio }; } -function priorModelRunHeader(input: { +function priorModelInvocation(input: { connectionId?: string; modelId: string; connectionSlug?: string; runId?: string; + turnId?: string; + root?: RuntimeInvocationRootAuthority; providerStateIdentity?: `sha256:${string}`; -}): AgentRunHeader { - return { - runId: input.runId ?? 'run-prev', +}): RuntimeInvocationRecord { + const identity = { sessionId: 'session-1', - turnId: 'turn-prev', - status: 'completed', - backendKind: 'ai-sdk', - ...(input.connectionId ? { llmConnectionId: input.connectionId } : {}), - providerStateIdentity: input.providerStateIdentity ?? `sha256:${'1'.repeat(64)}`, - llmConnectionSlug: input.connectionSlug ?? 'anthropic-main', - modelId: input.modelId, - cwd: '/tmp/maka', - permissionMode: 'ask', - createdAt: 1, - updatedAt: 2, - completedAt: 2, + invocationId: input.runId ?? 'run-prev', + runId: input.runId ?? 'run-prev', + turnId: input.turnId ?? 'turn-prev', + }; + return { + ...identity, + openedAt: 1, + opening: { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'runtime', + backendKind: 'ai-sdk', + llmConnectionId: input.connectionId ?? 'anthropic-main-connection', + llmConnectionSlug: input.connectionSlug ?? 'anthropic-main', + modelId: input.modelId, + providerStateIdentity: input.providerStateIdentity ?? `sha256:${'1'.repeat(64)}`, + }, + configuration: { + cwd: '/tmp/maka', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + root: input.root ?? { kind: 'user' }, + source: { kind: 'fresh' }, + }, + terminalEvent: { + id: `${identity.runId}-terminal`, + ...identity, + ts: 2, + partial: false, + role: 'system', + author: 'system', + status: 'completed', + }, }; } function sameRouteReplayProvenance( modelId: string, runId = 'run-prev', -): Pick { +): Pick { return { - runtimeContextRunHeaders: [ - priorModelRunHeader({ connectionId: 'test-connection-id', modelId, runId }), + runtimeContextInvocations: [ + priorModelInvocation({ connectionId: 'test-connection-id', modelId, runId }), ], }; } diff --git a/packages/runtime/src/__tests__/history-compact-checkpoint.test.ts b/packages/runtime/src/__tests__/history-compact-checkpoint.test.ts index 30fba8c08c..d74ca4a6af 100644 --- a/packages/runtime/src/__tests__/history-compact-checkpoint.test.ts +++ b/packages/runtime/src/__tests__/history-compact-checkpoint.test.ts @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import type { AgentRunEvent, AgentRunHeader, AgentRunStore } from '@maka/core/agent-run'; +import type { AgentRunEvent, AgentRunStore } from '@maka/core/agent-run'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { buildHistoryCompactCheckpoint, @@ -389,8 +389,8 @@ describe('history compact checkpoint', () => { previousCheckpointId: first.checkpointId, now: 20, }); + const runIds = ['run-1', 'run-2', 'run-3']; const store = new StubAgentRunStore( - [run('run-1', 10), run('run-2', 20), run('run-3', 30)], new Map([ ['run-1', [checkpointEvent('ledger-1', 'run-1', first, 10)]], ['run-2', [checkpointEvent('ledger-2', 'run-2', latest, 20)]], @@ -406,11 +406,11 @@ describe('history compact checkpoint', () => { ]), ); - const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1'); + const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1', runIds); assert.equal(loaded?.checkpointId, latest.checkpointId); assert.deepEqual( - (await loadHistoryCompactCheckpointsFromRunLedger(store, 'session-1')).map( + (await loadHistoryCompactCheckpointsFromRunLedger(store, 'session-1', runIds)).map( (checkpoint) => checkpoint.checkpointId, ), [first.checkpointId, latest.checkpointId], @@ -494,12 +494,12 @@ describe('history compact checkpoint', () => { }, now: 20, }); + const runIds = ['run-1']; const store = new StubAgentRunStore( - [run('run-1', 20)], new Map([['run-1', [checkpointEvent('ledger-v3', 'run-1', checkpoint, 20)]]]), ); - const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1'); + const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1', runIds); assert.deepEqual(loaded, checkpoint); assert.equal( @@ -527,15 +527,15 @@ describe('history compact checkpoint', () => { previousCheckpointId: valid.checkpointId, now: 20, }); + const runIds = ['run-valid', 'run-poisoned']; const store = new StubAgentRunStore( - [run('run-valid', 10), run('run-poisoned', 20)], new Map([ ['run-valid', [checkpointEvent('ledger-valid', 'run-valid', valid, 10)]], ['run-poisoned', [checkpointEvent('ledger-poisoned', 'run-poisoned', poisoned, 20)]], ]), ); - const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1'); + const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1', runIds); assert.equal(loaded?.checkpointId, valid.checkpointId); }); @@ -556,15 +556,15 @@ describe('history compact checkpoint', () => { previousCheckpointId: valid.checkpointId, now: 20, }); + const runIds = ['run-valid', 'run-poisoned']; const store = new StubAgentRunStore( - [run('run-valid', 10), run('run-poisoned', 20)], new Map([ ['run-valid', [checkpointEvent('ledger-valid', 'run-valid', valid, 10)]], ['run-poisoned', [checkpointEvent('ledger-poisoned', 'run-poisoned', poisoned, 20)]], ]), ); - const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1'); + const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1', runIds); assert.equal(loaded?.checkpointId, valid.checkpointId); }); @@ -641,15 +641,15 @@ describe('history compact checkpoint', () => { }), summaryFormat: 'sections_v1' as const, }; + const runIds = ['run-valid', 'run-marked']; const store = new StubAgentRunStore( - [run('run-valid', 10), run('run-marked', 20)], new Map([ ['run-valid', [checkpointEvent('ledger-valid', 'run-valid', valid, 10)]], ['run-marked', [checkpointEvent('ledger-marked', 'run-marked', markedMalformed, 20)]], ]), ); - const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1'); + const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1', runIds); assert.equal(loaded?.checkpointId, valid.checkpointId); }); @@ -682,11 +682,10 @@ describe('history compact checkpoint', () => { assert.equal(options.ifLedgerRevision, 'ledger-revision'); replacedEventIds.push(options?.replaceEventId); }, - listSessionRuns: async () => [run('run-canonical', 10)], readEvents: async () => [canonicalEvent], }; - const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1'); + const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1', ['run-canonical']); assert.equal(loaded?.checkpointId, valid.checkpointId); assert.deepEqual(replacedEventIds, [poisonedProjection.id]); @@ -705,15 +704,15 @@ describe('history compact checkpoint', () => { summary: 'stale coverage', summaryFormat: 'legacy_freeform', }); + const runIds = ['run-furthest', 'run-stale']; const store = new StubAgentRunStore( - [run('run-furthest', 10), run('run-stale', 20)], new Map([ ['run-furthest', [checkpointEvent('ledger-furthest', 'run-furthest', furthest, 30)]], ['run-stale', [checkpointEvent('ledger-stale', 'run-stale', stale, 40)]], ]), ); - const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1'); + const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1', runIds); assert.equal(loaded?.checkpointId, furthest.checkpointId); }); @@ -743,8 +742,8 @@ describe('history compact checkpoint', () => { previousCheckpointId: second.checkpointId, now: 30, }); + const runIds = ['parent-created-first', 'child-created-later']; const store = new StubAgentRunStore( - [run('parent-created-first', 10), run('child-created-later', 20)], new Map([ [ 'parent-created-first', @@ -759,7 +758,7 @@ describe('history compact checkpoint', () => { ], ]), ); - const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1'); + const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1', runIds); assert.equal(loaded?.checkpointId, tip.checkpointId); }); @@ -774,15 +773,12 @@ describe('history compact checkpoint', () => { const projectedEvent = checkpointEvent('projection-event', 'run-projection', checkpoint, 20); const store = { readEventProjection: async () => projectedEvent, - listSessionRuns: async () => { - throw new Error('run enumeration must stay cold'); - }, readEvents: async () => { throw new Error('run ledger reads must stay cold'); }, }; - const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1'); + const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1', ['run-canonical']); assert.equal(loaded?.checkpointId, checkpoint.checkpointId); }); @@ -790,15 +786,12 @@ describe('history compact checkpoint', () => { test('uses an empty bounded projection without enumerating run ledgers', async () => { const store = { readEventProjection: async () => null, - listSessionRuns: async () => { - throw new Error('run enumeration must stay cold'); - }, readEvents: async () => { throw new Error('run ledger reads must stay cold'); }, }; - const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1'); + const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1', ['run-canonical']); assert.equal(loaded, undefined); }); @@ -824,11 +817,10 @@ describe('history compact checkpoint', () => { assert.equal(options.ifLedgerRevision, 'ledger-revision'); repaired.push(repairedEvent); }, - listSessionRuns: async () => [run('run-recovered', 10)], readEvents: async () => [event], }; - const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1'); + const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1', ['run-canonical']); assert.equal(loaded?.checkpointId, checkpoint.checkpointId); assert.deepEqual(repaired, [event]); @@ -848,11 +840,10 @@ describe('history compact checkpoint', () => { repairEventProjection: async () => { repaired = true; }, - listSessionRuns: async () => [run('run-recovered', 10)], readEvents: async () => [event], }; - const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1'); + const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1', ['run-canonical']); assert.equal(loaded?.checkpointId, checkpoint.checkpointId); assert.equal(repaired, false); @@ -884,11 +875,10 @@ describe('history compact checkpoint', () => { assert.equal(options.ifLedgerRevision, 'ledger-revision'); replacedEventIds.push(options?.replaceEventId); }, - listSessionRuns: async () => [run('run-canonical', 10)], readEvents: async () => [canonicalEvent], }; - const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1'); + const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1', ['run-canonical']); assert.equal(loaded?.checkpointId, checkpoint.checkpointId); assert.deepEqual(replacedEventIds, [invalidProjection.id]); @@ -906,7 +896,7 @@ describe('history compact checkpoint', () => { }; await assert.rejects( - loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1'), + loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1', ['run-canonical']), /ledger recovery failed/, ); }); @@ -988,22 +978,6 @@ function textEvent(index: number): RuntimeEvent { }; } -function run(runId: string, createdAt: number): AgentRunHeader { - return { - runId, - sessionId: 'session-1', - turnId: `turn-${runId}`, - status: 'completed', - backendKind: 'ai-sdk', - llmConnectionSlug: 'test', - modelId: 'test', - cwd: '/tmp', - permissionMode: 'ask', - createdAt, - updatedAt: createdAt, - }; -} - function checkpointEvent( id: string, runId: string, @@ -1022,28 +996,12 @@ function checkpointEvent( } class StubAgentRunStore implements AgentRunStore { - constructor( - private readonly runs: AgentRunHeader[], - private readonly events: Map, - ) {} - - async listSessionRuns(): Promise { - return this.runs; - } + constructor(private readonly events: Map) {} async readEvents(_sessionId: string, runId: string): Promise { return this.events.get(runId) ?? []; } - async createRun(): Promise { - throw new Error('not implemented'); - } - async updateRun(): Promise { - throw new Error('not implemented'); - } - async readRun(): Promise { - throw new Error('not implemented'); - } async appendEvent(): Promise { throw new Error('not implemented'); } diff --git a/packages/runtime/src/__tests__/latest-context-commit.test.ts b/packages/runtime/src/__tests__/latest-context-commit.test.ts index 5075d6cbb9..c04e4afc71 100644 --- a/packages/runtime/src/__tests__/latest-context-commit.test.ts +++ b/packages/runtime/src/__tests__/latest-context-commit.test.ts @@ -110,10 +110,12 @@ test('a real send seals its observation into SQLite and reconstructs it after re } let scanned = 0; + const sessionRunIds = (await runtimeEventStore.listSessionInvocations(session.id)).map( + (invocation) => invocation.runId, + ); const diagnostics = await readLatestContextDiagnostics( { - listSessionRuns: (sessionId) => runStore.listSessionRuns(sessionId), - readEvents: async (sessionId, runId) => { + readEvents: async (sessionId: string, runId: string) => { scanned += 1; return runStore.readEvents(sessionId, runId); }, @@ -122,6 +124,7 @@ test('a real send seals its observation into SQLite and reconstructs it after re runStore.repairEventProjection(sessionId, type, event, options), }, session.id, + sessionRunIds, ); assert.equal(diagnostics.status, 'available'); @@ -140,11 +143,10 @@ test('a real send seals its observation into SQLite and reconstructs it after re const reopened = createSqliteAgentRunStore(root); try { - const runs = await reopened.listSessionRuns(session.id); const canonicalAttempts = ( await Promise.all( - runs.map(async (run) => { - const events = await reopened.readEvents(session.id, run.runId); + sessionRunIds.map(async (runId) => { + const events = await reopened.readEvents(session.id, runId); return events .filter((event) => event.type === 'model_call_attempt_recorded') .map((event) => decodeModelCallAttempt(event.data)); @@ -161,8 +163,7 @@ test('a real send seals its observation into SQLite and reconstructs it after re let coldScans = 0; const cold = await readLatestContextDiagnostics( { - listSessionRuns: (sessionId) => reopened.listSessionRuns(sessionId), - readEvents: async (sessionId, runId) => { + readEvents: async (sessionId: string, runId: string) => { coldScans += 1; return reopened.readEvents(sessionId, runId); }, @@ -170,6 +171,7 @@ test('a real send seals its observation into SQLite and reconstructs it after re reopened.repairEventProjection(sessionId, type, event, options), }, session.id, + sessionRunIds, ); assert.ok(coldScans > 0, 'omitting the projection reader forces a restart-safe ledger fold'); @@ -252,14 +254,16 @@ test('an artifact captured before abort does not create a canonical sent attempt // Drain the aborted turn through the real AgentRun store. } - const runs = await runStore.listSessionRuns(session.id); + const runIds = (await runtimeEventStore.listSessionInvocations(session.id)).map( + (invocation) => invocation.runId, + ); const events = ( - await Promise.all(runs.map((run) => runStore.readEvents(session.id, run.runId))) + await Promise.all(runIds.map((runId) => runStore.readEvents(session.id, runId))) ).flat(); assert.equal(artifactWrites, 1); assert.equal(providerCalls, 0); assert.equal(events.filter((event) => event.type === 'model_call_attempt_recorded').length, 0); - assert.deepEqual(await readLatestContextDiagnostics(runStore, session.id), { + assert.deepEqual(await readLatestContextDiagnostics(runStore, session.id, runIds), { status: 'unavailable', reason: 'no_completed_request', }); diff --git a/packages/runtime/src/__tests__/runtime-ledger-repair.test.ts b/packages/runtime/src/__tests__/runtime-ledger-repair.test.ts index 066c6fedd8..3e42ad0777 100644 --- a/packages/runtime/src/__tests__/runtime-ledger-repair.test.ts +++ b/packages/runtime/src/__tests__/runtime-ledger-repair.test.ts @@ -29,6 +29,11 @@ import { createSessionStore } from '@maka/storage/session-store'; import { createSqliteRuntimeStore } from '@maka/storage/sqlite-runtime-store'; import { buildRuntimeEventModelReplayPlan } from '../model-history.js'; import { buildPriorRuntimeContext } from '../prior-run-context.js'; +import { + buildInvocationOpenedEvent, + runtimeInvocationOutcome, +} from '@maka/core/runtime-invocation'; +import { runtimeInvocationFailureClass } from '../runtime-event-read-model.js'; import { backfillRuntimeEventsFromStoredMessages } from '../runtime-event-backfill.js'; import { RuntimeLedgerRepair } from '../runtime-ledger-repair.js'; @@ -97,11 +102,9 @@ test('repairs imported transcript turns into provider-neutral canonical history' ); assert.equal(session.transcriptLedgerVersion, 0); const repair = new RuntimeLedgerRepair({ - runStore: runs, runtimeEventStore: runtimeEvents, readMessages: (sessionId) => sessions.readMessages(sessionId), appendMessage: (sessionId, message) => sessions.appendMessage(sessionId, message), - appendTurnState: async () => undefined, newId, now: () => 100, }); @@ -109,11 +112,11 @@ test('repairs imported transcript turns into provider-neutral canonical history' await repair.materializeTranscriptLedger(session); await repair.materializeTranscriptLedger(session); - const [importedRun] = await runs.listSessionRuns(session.id); + const [importedRun] = await runtimeEvents.listSessionInvocations(session.id); assert.ok(importedRun); assert.equal(importedRun.turnId, 'turn-1'); - assert.equal(importedRun.status, 'completed'); - assert.ok(importedRun.createdAt < session.createdAt); + assert.equal(runtimeInvocationOutcome(importedRun), 'completed'); + assert.ok(importedRun.openedAt < session.createdAt); const importedEvents = await runtimeEvents.readRuntimeEvents(session.id, importedRun.runId); assert.deepEqual( @@ -151,16 +154,22 @@ test('repairs imported transcript turns into provider-neutral canonical history' partialOutputRetained: true, }, ]; - const continuedRun = await runs.createRun({ - ...importedRun, + const continuedRun = { + sessionId: session.id, runId: 'continued-run', invocationId: 'continued-invocation', turnId: 'turn-2', - status: 'completed', - createdAt: session.createdAt + 1, - updatedAt: session.createdAt + 3, - completedAt: session.createdAt + 3, - }); + }; + await runtimeEvents.appendRuntimeEvent( + session.id, + continuedRun.runId, + buildInvocationOpenedEvent({ + id: newId(), + run: continuedRun, + openedAt: session.createdAt + 1, + opening: importedRun.opening, + }), + ); for (const event of backfillRuntimeEventsFromStoredMessages({ run: continuedRun, messages: continuedMessages, @@ -171,25 +180,27 @@ test('repairs imported transcript turns into provider-neutral canonical history' } const currentRunId = 'current-run'; - await runs.createRun({ - ...continuedRun, - runId: currentRunId, - invocationId: 'current-invocation', - turnId: 'turn-3', - status: 'running', - createdAt: session.createdAt + 4, - updatedAt: session.createdAt + 4, - completedAt: undefined, - }); + await runtimeEvents.appendRuntimeEvent( + session.id, + currentRunId, + buildInvocationOpenedEvent({ + id: newId(), + run: { + sessionId: session.id, + runId: currentRunId, + invocationId: 'current-invocation', + turnId: 'turn-3', + }, + openedAt: session.createdAt + 4, + opening: importedRun.opening, + }), + ); const prior = await buildPriorRuntimeContext({ sessionId: session.id, currentRunId, currentTurnId: 'turn-3', - runStore: runs, runtimeEventStore: runtimeEvents, - runStoreAvailable: true, runtimeEventStoreAvailable: true, - readMessages: () => sessions.readMessages(session.id), }); assert.deepEqual( buildRuntimeEventModelReplayPlan(prior?.events ?? []).items.map((item) => @@ -273,24 +284,22 @@ test('an imported snapshot cutoff survives materialization as aborted', async () { adapterId: 'claude-code', sourceSessionId: 'cut-1' }, ); const repair = new RuntimeLedgerRepair({ - runStore: runs, runtimeEventStore: runtimeEvents, readMessages: (sessionId) => sessions.readMessages(sessionId), appendMessage: (sessionId, message) => sessions.appendMessage(sessionId, message), - appendTurnState: async () => undefined, newId, now: () => 100, }); await repair.materializeTranscriptLedger(session); - const [run] = await runs.listSessionRuns(session.id); + const [run] = await runtimeEvents.listSessionInvocations(session.id); assert.ok(run); // `cancelled`, not `failed`: the Ledger accepted the recorded abort. Before // the adapter emitted one, this same transcript materialized as // `failed / missing_terminal_event`. - assert.equal(run.status, 'cancelled'); - assert.notEqual(run.failureClass, 'missing_terminal_event'); + assert.equal(runtimeInvocationOutcome(run), 'cancelled'); + assert.notEqual(runtimeInvocationFailureClass(run), 'missing_terminal_event'); } finally { await runtimeEvents.close?.(); await rm(root, { recursive: true, force: true }); @@ -332,18 +341,16 @@ test('does not import Host-handed-off transcript messages as synthetic runs', as { adapterId: 'test', sourceSessionId: 'host-session' }, ); const repair = new RuntimeLedgerRepair({ - runStore: runs, runtimeEventStore: runtimeEvents, readMessages: (sessionId) => sessions.readMessages(sessionId), appendMessage: (sessionId, message) => sessions.appendMessage(sessionId, message), - appendTurnState: async () => undefined, newId: () => `host-repair-${++sequence}`, now: () => 100, }); await repair.materializeTranscriptLedger(session); - assert.deepEqual(await runs.listSessionRuns(session.id), []); + assert.deepEqual(await runtimeEvents.listSessionInvocations(session.id), []); } finally { runtimeEvents.close(); runs.close?.(); @@ -386,21 +393,19 @@ test('an imported turn with no terminal state is repaired to failed', async () = { adapterId: 'claude-code', sourceSessionId: 'missing-1' }, ); const repair = new RuntimeLedgerRepair({ - runStore: runs, runtimeEventStore: runtimeEvents, readMessages: (sessionId) => sessions.readMessages(sessionId), appendMessage: (sessionId, message) => sessions.appendMessage(sessionId, message), - appendTurnState: async () => undefined, newId, now: () => 100, }); await repair.materializeTranscriptLedger(session); - const [run] = await runs.listSessionRuns(session.id); + const [run] = await runtimeEvents.listSessionInvocations(session.id); assert.ok(run); - assert.equal(run.status, 'failed'); - assert.equal(run.failureClass, 'missing_terminal_event'); + assert.equal(runtimeInvocationOutcome(run), 'failed'); + assert.equal(runtimeInvocationFailureClass(run), 'missing_terminal_event'); } finally { await runtimeEvents.close?.(); await rm(root, { recursive: true, force: true }); @@ -554,17 +559,15 @@ test('a resolved Claude transcript replays as the conversation the user kept', a { adapterId: 'claude-code', sourceSessionId: SOURCE_SESSION_ID }, ); const repair = new RuntimeLedgerRepair({ - runStore: runs, runtimeEventStore: runtimeEvents, readMessages: (sessionId) => sessions.readMessages(sessionId), appendMessage: (sessionId, message) => sessions.appendMessage(sessionId, message), - appendTurnState: async () => undefined, newId, now: () => 100, }); await repair.materializeTranscriptLedger(session); - const [run] = await runs.listSessionRuns(session.id); + const [run] = await runtimeEvents.listSessionInvocations(session.id); assert.ok(run); const events = await runtimeEvents.readRuntimeEvents(session.id, run.runId); const replay = buildRuntimeEventModelReplayPlan(events).items; @@ -613,8 +616,8 @@ test('a resolved Claude transcript replays as the conversation the user kept', a assert.deepEqual(shape, ['call:toolu_a', 'call:toolu_b', 'result:toolu_a', 'result:toolu_b']); // And the turn is terminal on its own evidence, not repaired into one. - assert.equal(run.status, 'completed'); - assert.notEqual(run.failureClass, 'missing_terminal_event'); + assert.equal(runtimeInvocationOutcome(run), 'completed'); + assert.notEqual(runtimeInvocationFailureClass(run), 'missing_terminal_event'); } finally { runtimeEvents.close(); runs.close?.(); From 9ee12948a1ff26dd33eb6b818d7cd6430b05c355 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 13:01:42 +0800 Subject: [PATCH 17/46] test(runtime): state every run's facts as its own invocation These suites still built AgentRunHeader objects to say what a run was routed to, what it was configured with, and how it ended. All three now come from the invocation's opening fact and its terminal event, so the tests state the same facts the runtime actually persists. Two premises went away with the header rather than being translated: - The continuation crash harness had a boundary between committing the terminal event and committing the terminal header. There is no second commit any more, so `after_terminal_header_committed` is gone and the two boundaries before the continuation-start commit now leave the target invocation unopened, because a continuation's opening fact rides that start event. - The AgentRun inspect model no longer reconciles an operational status against the RuntimeEvent facts, so the test for their disagreement and the `status_consistency_mismatch` diagnostic it asserted are removed. The invocation-index test compared the index against the header table. It now compares the index against a rebuild from the Session's events, which is what the index is defined to be. Generated-by: Claude Code --- .../agent-graph-supervisor-wake.test.ts | 8 +- .../__tests__/agent-graph-timeline.test.ts | 125 +++++++++------ .../src/__tests__/agent-run-inspect.test.ts | 148 +++++++----------- .../src/__tests__/agent-run-recovery.test.ts | 50 ++++-- .../computer-use-provider-protocol.test.ts | 126 ++++++++++----- .../src/__tests__/continuation-replay.test.ts | 2 +- .../src/__tests__/execution-inspect.test.ts | 54 ++++--- .../mid-turn-capacity-backend.test.ts | 99 ++++++++---- ...model-projection-transition-ledger.test.ts | 9 +- .../overflow-reactive-recovery.test.ts | 76 ++++++--- .../runtime-continuation-crash.test.ts | 129 ++++++++------- .../runtime-invocation-index.test.ts | 44 ++---- .../__tests__/runtime-resume-crash.test.ts | 21 --- .../src/__tests__/runtime-resume.test.ts | 74 ++++++--- .../sandbox-boundary-restart-recovery.test.ts | 89 +++++++---- .../session-event-runtime-mapper.test.ts | 42 +++-- .../__tests__/stream-graph-handoff.test.ts | 67 +++++--- .../__tests__/stream-graph-projection.test.ts | 127 +++++++++------ .../__tests__/stream-graph-readiness.test.ts | 125 +++++++++------ .../src/__tests__/stream-graph-trace.test.ts | 87 +++++----- 20 files changed, 903 insertions(+), 599 deletions(-) diff --git a/packages/runtime/src/__tests__/agent-graph-supervisor-wake.test.ts b/packages/runtime/src/__tests__/agent-graph-supervisor-wake.test.ts index 2b79c5c270..04bd4436b2 100644 --- a/packages/runtime/src/__tests__/agent-graph-supervisor-wake.test.ts +++ b/packages/runtime/src/__tests__/agent-graph-supervisor-wake.test.ts @@ -374,7 +374,7 @@ describe('Agent Graph supervisor wake delivery', () => { attempt += 1; return { kind: 'suspended', turnId: input.turnId, reason: 'permission handoff' }; }, - inspectAttempt: async () => 'waiting_for_user', + inspectAttempt: async () => 'running', newId: sequentialIds(), }); try { @@ -408,7 +408,7 @@ describe('Agent Graph supervisor wake delivery', () => { ? { kind: 'suspended', turnId: input.turnId, reason: 'permission handoff' } : { kind: 'completed', turnId: input.turnId }; }, - inspectAttempt: async () => 'waiting_for_user', + inspectAttempt: async () => 'running', newId: sequentialIds(), }); try { @@ -455,7 +455,7 @@ describe('Agent Graph supervisor wake delivery', () => { } return { kind: 'completed', turnId: input.turnId }; }, - inspectAttempt: async () => 'waiting_for_user', + inspectAttempt: async () => 'running', newId: sequentialIds(), }); try { @@ -633,7 +633,7 @@ describe('Agent Graph supervisor wake delivery', () => { delivered += 1; return { kind: 'completed', turnId: input.turnId }; }, - inspectAttempt: async () => 'waiting_for_user', + inspectAttempt: async () => 'running', newId: sequentialIds(), }); try { diff --git a/packages/runtime/src/__tests__/agent-graph-timeline.test.ts b/packages/runtime/src/__tests__/agent-graph-timeline.test.ts index 42e3cb8e7b..444a1ff052 100644 --- a/packages/runtime/src/__tests__/agent-graph-timeline.test.ts +++ b/packages/runtime/src/__tests__/agent-graph-timeline.test.ts @@ -21,7 +21,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import { AGENT_GRAPH_SUPERVISOR_WAKE_SCHEMA_VERSION } from '@maka/core/agent-graph-supervisor-wake'; import { type AgentGraphTimelineMetadataSnapshot } from '@maka/core/agent-graph-timeline'; -import { type AgentRunHeader } from '@maka/core/agent-run'; +import { type RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import { type RuntimeEvent } from '@maka/core/runtime-event'; import { createSqliteSessionMetadataStore } from '@maka/storage/sqlite-session-metadata-store'; import { @@ -222,7 +222,7 @@ describe('agent graph replay timeline', () => { test('joins a transactionally read SQLite metadata snapshot with immutable run ledgers', async () => { const fixture = await timelineFixture(); - const runsBySession = new Map([ + const runsBySession = new Map([ ['root-session', [...fixture.rootRuns]], ['child-session', [fixture.childRun]], ]); @@ -237,12 +237,10 @@ describe('agent graph replay timeline', () => { return fixture.metadata; }, }, - runStore: { - async listSessionRuns(sessionId) { + runtimeEventStore: { + async listSessionInvocations(sessionId) { return runsBySession.get(sessionId) ?? []; }, - }, - runtimeEventStore: { async readImmutableRuntimeEvents(_sessionId, runId) { return eventsByRun.get(runId) ?? []; }, @@ -268,16 +266,15 @@ describe('agent graph replay timeline', () => { return fixture.metadata; }, }, - runStore: { - async listSessionRuns(sessionId) { + runtimeEventStore: { + async listSessionInvocations(sessionId) { if (sessionId === fixture.rootSessionId) return [...fixture.rootRuns]; if (sessionId === fixture.childRun.sessionId) { - return [{ ...fixture.childRun, status: 'running', completedAt: undefined }]; + const { terminalEvent: _terminalEvent, ...open } = fixture.childRun; + return [open]; } return []; }, - }, - runtimeEventStore: { async readImmutableRuntimeEvents() { return []; }, @@ -287,7 +284,7 @@ describe('agent graph replay timeline', () => { const activation = requireEvent(page.events, 'activation_started'); assert.equal(activation.kind, 'activation_started'); - assert.equal(activation.eventTime, fixture.childRun.createdAt); + assert.equal(activation.eventTime, fixture.childRun.openedAt); assert.deepEqual(activation.activation, { sessionId: 'child-session', runId: 'child-run', @@ -350,29 +347,25 @@ describe('agent graph replay timeline', () => { }); async function timelineFixture() { - const rootRun1 = runHeader({ + const rootRun1 = runInvocation({ sessionId: 'root-session', runId: 'root-run-1', turnId: 'root-turn-1', - status: 'completed', createdAt: 90, completedAt: 115, }); - const rootRun2 = runHeader({ + const rootRun2 = runInvocation({ sessionId: 'root-session', runId: 'root-run-2', turnId: 'root-turn-2', - status: 'completed', createdAt: 122, completedAt: 150, - agentGraphWakeId: 'wake-1', - agentGraphWakeAttemptId: 'attempt-1', + wake: { wakeId: 'wake-1', attemptId: 'attempt-1' }, }); - const childRun = runHeader({ + const childRun = runInvocation({ sessionId: 'child-session', runId: 'child-run', turnId: 'child-turn', - status: 'completed', createdAt: 102, completedAt: 120, }); @@ -425,12 +418,10 @@ async function timelineFixture() { const projection = await readCommittedAgentGraphProjection({ graphId: 'graph-1', operators: [{ operatorId: 'operator-1', sessionId: 'child-session' }], - runStore: { - async listSessionRuns() { + runtimeEventStore: { + async listSessionInvocations() { return [childRun]; }, - }, - runtimeEventStore: { async readImmutableRuntimeEvents() { return childEvents; }, @@ -563,37 +554,77 @@ async function timelineFixture() { }; } -function runHeader( - input: Pick< - AgentRunHeader, - | 'sessionId' - | 'runId' - | 'turnId' - | 'status' - | 'createdAt' - | 'completedAt' - | 'agentGraphWakeId' - | 'agentGraphWakeAttemptId' - >, -): AgentRunHeader { - return { - ...input, +/** + * One invocation as its own events describe it. + * + * A wake-rooted run says so in its opening's root authority, and a finished one + * says so with a terminal event. Neither is a field a writer could set apart + * from the ledger. + */ +function runInvocation(input: { + sessionId: string; + runId: string; + turnId: string; + createdAt: number; + completedAt?: number; + status?: 'completed' | 'failed' | 'aborted'; + wake?: { wakeId: string; attemptId: string }; +}): RuntimeInvocationRecord { + const identity = { + sessionId: input.sessionId, invocationId: `invocation-${input.runId}`, - backendKind: 'ai-sdk', - llmConnectionSlug: 'deepseek', - modelId: 'deepseek-chat', - cwd: '/workspace', - permissionMode: 'explore', - updatedAt: input.completedAt ?? input.createdAt, + runId: input.runId, + turnId: input.turnId, + }; + return { + ...identity, + openedAt: input.createdAt, + opening: { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'runtime', + backendKind: 'ai-sdk', + llmConnectionId: 'deepseek-connection', + llmConnectionSlug: 'deepseek', + modelId: 'deepseek-chat', + }, + configuration: { + cwd: '/workspace', + permissionMode: 'explore', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + root: input.wake + ? { kind: 'agent_graph_supervisor_wake', wakeId: input.wake.wakeId, attemptId: input.wake.attemptId } + : { kind: 'user' }, + source: { kind: 'fresh' }, + }, + ...(input.completedAt !== undefined + ? { + terminalEvent: { + ...identity, + id: `${input.runId}-terminal`, + ts: input.completedAt, + partial: false, + role: 'system', + author: 'system', + status: input.status ?? 'completed', + actions: { endInvocation: true }, + } satisfies RuntimeEvent, + } + : {}), }; } function runtimeEvent( - run: AgentRunHeader, + run: RuntimeInvocationRecord, overrides: Partial & Pick, ): RuntimeEvent { return { - invocationId: run.invocationId!, + invocationId: run.invocationId, runId: run.runId, sessionId: run.sessionId, turnId: run.turnId, diff --git a/packages/runtime/src/__tests__/agent-run-inspect.test.ts b/packages/runtime/src/__tests__/agent-run-inspect.test.ts index a1eba13ae2..8d7302a7a0 100644 --- a/packages/runtime/src/__tests__/agent-run-inspect.test.ts +++ b/packages/runtime/src/__tests__/agent-run-inspect.test.ts @@ -19,12 +19,18 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import type { AgentRunEvent, AgentRunHeader, AgentRunStore } from '@maka/core/agent-run'; -import type { RuntimeEvent } from '@maka/core/runtime-event'; +import type { AgentRunEvent, AgentRunStore } from '@maka/core/agent-run'; +import type { RuntimeEvent, RuntimeEventInvocationOpenedContent } from '@maka/core/runtime-event'; import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; +import { + buildInvocationOpenedEvent, + runtimeInvocationsFromSessionEvents, +} from '@maka/core/runtime-invocation'; import { inspectAgentRunReadModel } from '../agent-run-inspect.js'; const sessionId = 'session-1'; +const invocationId = 'inv-1'; const runId = 'run-1'; const turnId = 'turn-1'; const ts = 1_800_000_000_000; @@ -32,14 +38,21 @@ const ts = 1_800_000_000_000; describe('inspectAgentRunReadModel', () => { test('returns consistent diagnostics for a complete run', async () => { const runStore = new MemoryAgentRunStore(); - await runStore.createRun( - makeHeader({ status: 'completed', completedAt: ts + 10, updatedAt: ts + 10 }), + await runStore.appendRuntimeEvent( + sessionId, + runId, + buildInvocationOpenedEvent({ + id: 'rt-open', + run: { sessionId, invocationId, runId, turnId }, + openedAt: ts, + opening: makeOpening(), + }), ); - await runStore.appendEvent(sessionId, runId, makeRunEvent({ type: 'run_started', ts: ts + 1 })); + await runStore.appendEvent(sessionId, runId, makeRunEvent({ type: 'turn_started', ts: ts + 1 })); await runStore.appendEvent( sessionId, runId, - makeRunEvent({ type: 'run_completed', ts: ts + 10 }), + makeRunEvent({ type: 'model_stream_completed', ts: ts + 10 }), ); await runStore.appendRuntimeEvent( sessionId, @@ -81,45 +94,37 @@ describe('inspectAgentRunReadModel', () => { assert.deepStrictEqual(inspected.sourceHealth, { runtimeLedger: 'present', runtimeTerminalPresent: true, - operationalTerminalPresent: true, - statusConsistency: 'consistent', }); assert.strictEqual(inspected.terminalRuntimeFact?.runStatus, 'completed'); - assert.strictEqual(inspected.operationalTerminalEvent?.type, 'run_completed'); assert.deepStrictEqual( inspected.runtimeEvents.map((event) => event.id), - ['rt-user', 'rt-assistant', 'rt-complete'], + ['rt-open', 'rt-user', 'rt-assistant', 'rt-complete'], ); assert.deepStrictEqual( inspected.projection?.messages.map((message) => message.type), ['user', 'assistant', 'turn_state'], ); - assert.strictEqual( - inspected.diagnostics.some((diagnostic) => diagnostic.code === 'status_consistency_mismatch'), - false, - ); }); test('reports missing and corrupt runtime-events without discarding operational facts', async () => { const missingRuntimeStore = new MemoryAgentRunStore(); - await missingRuntimeStore.createRun(makeHeader({ status: 'completed' })); await missingRuntimeStore.appendEvent( sessionId, runId, - makeRunEvent({ type: 'run_completed' }), + makeRunEvent({ type: 'model_stream_completed' }), ); const missing = await inspectAgentRunReadModel(missingRuntimeStore, missingRuntimeStore, { sessionId, runId, + invocation: makeInvocation(), }); assert.deepStrictEqual( missing.events.map((event) => event.type), - ['run_completed'], + ['model_stream_completed'], ); assert.strictEqual(missing.sourceHealth.runtimeLedger, 'missing'); - assert.strictEqual(missing.sourceHealth.operationalTerminalPresent, true); assert.strictEqual(missing.sourceHealth.runtimeTerminalPresent, false); assert.strictEqual( missing.diagnostics.some((diagnostic) => diagnostic.code === 'missing_runtime_ledger'), @@ -127,92 +132,43 @@ describe('inspectAgentRunReadModel', () => { ); const corruptRuntimeStore = new MemoryAgentRunStore({ failRuntimeEventReads: true }); - await corruptRuntimeStore.createRun(makeHeader({ status: 'completed' })); await corruptRuntimeStore.appendEvent( sessionId, runId, - makeRunEvent({ type: 'run_completed' }), + makeRunEvent({ type: 'model_stream_completed' }), ); const corrupt = await inspectAgentRunReadModel(corruptRuntimeStore, corruptRuntimeStore, { sessionId, runId, + invocation: makeInvocation(), }); assert.deepStrictEqual( corrupt.events.map((event) => event.type), - ['run_completed'], + ['model_stream_completed'], ); assert.strictEqual(corrupt.sourceHealth.runtimeLedger, 'read_failed'); - assert.strictEqual(corrupt.sourceHealth.operationalTerminalPresent, true); assert.strictEqual( corrupt.diagnostics.some((diagnostic) => diagnostic.code === 'runtime_ledger_read_failed'), true, ); }); - test('diagnoses status disagreement between header operational and RuntimeEvent facts', async () => { - const runStore = new MemoryAgentRunStore(); - await runStore.createRun(makeHeader({ status: 'failed', failureClass: 'tool_failed' })); - await runStore.appendEvent(sessionId, runId, makeRunEvent({ type: 'run_failed' })); - await runStore.appendRuntimeEvent( - sessionId, - runId, - makeRuntimeEvent({ - id: 'rt-complete', - role: 'system', - author: 'system', - status: 'completed', - actions: { endInvocation: true }, - }), - ); - - const inspected = await inspectAgentRunReadModel(runStore, runStore, { sessionId, runId }); - - assert.strictEqual(inspected.sourceHealth.statusConsistency, 'inconsistent'); - assert.strictEqual(inspected.terminalRuntimeFact?.runStatus, 'completed'); - assert.strictEqual( - inspected.diagnostics.some((diagnostic) => diagnostic.code === 'status_consistency_mismatch'), - true, - ); - }); }); class MemoryAgentRunStore implements AgentRunStore, RuntimeEventStore { - private headers = new Map(); private events = new Map(); private runtimeEvents = new Map(); private runtimeEventEntries: RuntimeEvent[] = []; constructor(private readonly options: { failRuntimeEventReads?: boolean } = {}) {} - async createRun(header: AgentRunHeader): Promise { - this.headers.set(key(header.sessionId, header.runId), { ...header }); - return { ...header }; - } - - async updateRun( - sessionId: string, - runId: string, - patch: Partial, - ): Promise { - const current = await this.readRun(sessionId, runId); - const next = { ...current, ...patch, sessionId, runId }; - this.headers.set(key(sessionId, runId), next); - return { ...next }; - } - - async readRun(sessionId: string, runId: string): Promise { - const header = this.headers.get(key(sessionId, runId)); - if (!header) throw new Error(`Unknown run ${runId}`); - return { ...header }; - } - - async listSessionRuns(sessionId: string): Promise { - return Array.from(this.headers.values()) - .filter((header) => header.sessionId === sessionId) - .sort((a, b) => a.createdAt - b.createdAt || a.runId.localeCompare(b.runId)) - .map((header) => ({ ...header })); + async listSessionInvocations(sessionId: string): Promise { + return runtimeInvocationsFromSessionEvents( + sessionId, + await this.readSessionRuntimeEvents(sessionId), + ); } async appendEvent(sessionId: string, runId: string, event: AgentRunEvent): Promise { @@ -283,27 +239,39 @@ class MemoryAgentRunStore implements AgentRunStore, RuntimeEventStore { } } -function makeHeader(overrides: Partial = {}): AgentRunHeader { +function makeOpening(): RuntimeEventInvocationOpenedContent { return { - runId, - sessionId, - turnId, - status: 'running', - backendKind: 'fake', - llmConnectionSlug: 'fake', - modelId: 'fake-model', - cwd: '/tmp/cwd', - permissionMode: 'ask', - createdAt: ts, - updatedAt: ts, - ...overrides, + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'runtime', + backendKind: 'fake', + llmConnectionId: 'fake-connection', + llmConnectionSlug: 'fake', + modelId: 'fake-model', + }, + configuration: { + cwd: '/tmp/cwd', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + root: { kind: 'user' }, + source: { kind: 'fresh' }, }; } +/** The invocation a run is named by, for the cases whose ledger is unreadable. */ +function makeInvocation(): RuntimeInvocationRecord { + return { sessionId, invocationId, runId, turnId, openedAt: ts, opening: makeOpening() }; +} + function makeRunEvent(overrides: Partial = {}): AgentRunEvent { return { - type: 'run_started', - id: `op-${overrides.type ?? 'run_started'}`, + type: 'turn_started', + id: `op-${overrides.type ?? 'turn_started'}`, runId, sessionId, turnId, diff --git a/packages/runtime/src/__tests__/agent-run-recovery.test.ts b/packages/runtime/src/__tests__/agent-run-recovery.test.ts index d03943d33b..4417733041 100644 --- a/packages/runtime/src/__tests__/agent-run-recovery.test.ts +++ b/packages/runtime/src/__tests__/agent-run-recovery.test.ts @@ -19,28 +19,50 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import type { AgentRunHeader } from '@maka/core/agent-run'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import { classifyAgentRunRecovery } from '../agent-run-recovery.js'; describe('AgentRun startup recovery', () => { test('fails a graph supervisor permission handoff once its live waiter is lost', () => { - const header: AgentRunHeader = { - runId: 'run-1', + const invocation: RuntimeInvocationRecord = { sessionId: 'session-1', + invocationId: 'invocation-1', + runId: 'run-1', turnId: 'turn-1', - status: 'waiting_for_user', - backendKind: 'fake', - llmConnectionSlug: 'fake', - modelId: 'fake-model', - cwd: '/tmp/workspace', - permissionMode: 'ask', - agentGraphWakeId: 'wake-1', - agentGraphWakeAttemptId: 'attempt-1', - createdAt: 1, - updatedAt: 2, + openedAt: 1, + opening: { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'runtime', + backendKind: 'fake', + llmConnectionId: 'fake-connection', + llmConnectionSlug: 'fake', + modelId: 'fake-model', + }, + configuration: { + cwd: '/tmp/workspace', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + root: { kind: 'agent_graph_supervisor_wake', wakeId: 'wake-1', attemptId: 'attempt-1' }, + source: { kind: 'fresh' }, + }, }; - const decision = classifyAgentRunRecovery(header, []); + const decision = classifyAgentRunRecovery(invocation, [ + { + type: 'permission_requested', + id: 'op-permission_requested', + sessionId: 'session-1', + runId: 'run-1', + turnId: 'turn-1', + ts: 2, + }, + ]); assert.equal(decision?.status, 'failed'); assert.equal(decision?.failureClass, 'app_restarted'); assert.equal(decision?.diagnostic?.recoveryReason, 'stale_user_wait'); diff --git a/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts b/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts index 8923a1e74b..10698ffb85 100644 --- a/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts +++ b/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts @@ -20,7 +20,7 @@ import assert from 'node:assert/strict'; import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { after, describe, test } from 'node:test'; -import type { AgentRunHeader } from '@maka/core/agent-run'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { LlmConnection } from '@maka/core/llm-connections'; @@ -105,22 +105,18 @@ describe('Anthropic-compatible Computer Use product loops', () => { newId: idGenerator(), now: monotonicClock(), }); - const sourceRun = { - runId: 'run-prev', + const sourceRun = sourceInvocation({ sessionId, + runId: 'run-prev', + invocationId: 'inv-prev', turnId: 'turn-prev', - status: 'completed', - backendKind: 'ai-sdk', llmConnectionId: 'connection-anthropic', llmConnectionSlug: 'anthropic', modelId: 'claude-sonnet-4-5-20250929', - providerStateIdentity: PROVIDER_STATE_IDENTITY, - cwd: '/tmp/maka', permissionMode: 'bypass', - createdAt: 1, - updatedAt: 2, + openedAt: 1, completedAt: 2, - } satisfies AgentRunHeader; + }); for await (const event of createRuntime().send(firstTurn.sendInput())) firstTurn.record(event); assert.deepEqual( firstTurn.ledger @@ -149,7 +145,7 @@ describe('Anthropic-compatible Computer Use product loops', () => { for await (const event of createRuntime().send( secondTurn.sendInput({ runtimeContext: firstTurn.ledger, - runtimeContextRunHeaders: [sourceRun], + runtimeContextInvocations: [sourceRun], }), )) { secondTurn.record(event); @@ -487,22 +483,18 @@ describe('OpenAI-compatible product loops', () => { newId: idGenerator(), now: monotonicClock(), }); - const sourceRun = { - runId: 'run-prev', + const sourceRun = sourceInvocation({ sessionId, + runId: 'run-prev', + invocationId: 'inv-prev', turnId: 'turn-prev', - status: 'completed', - backendKind: 'ai-sdk', llmConnectionId: 'connection-copilot', llmConnectionSlug: 'github-copilot', modelId: 'gpt-5.4', - providerStateIdentity: PROVIDER_STATE_IDENTITY, - cwd: '/tmp/maka', permissionMode: 'bypass', - createdAt: 1, - updatedAt: 2, + openedAt: 1, completedAt: 2, - } satisfies AgentRunHeader; + }); const priorEvents = [ { id: 'rt-user-prev', @@ -551,7 +543,7 @@ describe('OpenAI-compatible product loops', () => { for await (const event of runtime.send( currentTurn.sendInput({ runtimeContext: priorEvents, - runtimeContextRunHeaders: [sourceRun], + runtimeContextInvocations: [sourceRun], }), )) { currentTurn.record(event); @@ -679,23 +671,18 @@ describe('OpenAI-compatible product loops', () => { 'openai-chat', 131_072, ); - const sourceRun = { + const sourceRun = sourceInvocation({ + sessionId, runId: 'run-kimi-openai-recovered-tool-step', invocationId: 'invocation-kimi-openai-recovered-tool-step', - sessionId, turnId: previousTurnId, - status: 'completed', - backendKind: 'ai-sdk', llmConnectionId: 'test-connection-id', llmConnectionSlug: providerConnection.slug, modelId: 'k3', - providerStateIdentity: PROVIDER_STATE_IDENTITY, - cwd: '/tmp/maka', permissionMode: 'ask', - createdAt: 1, - updatedAt: 5, + openedAt: 1, completedAt: 5, - } satisfies AgentRunHeader; + }); const recovered = backfillRuntimeEventsFromStoredMessages({ run: sourceRun, messages: [ @@ -761,7 +748,7 @@ describe('OpenAI-compatible product loops', () => { for await (const event of runtime.send( currentTurn.sendInput({ runtimeContext: recovered.events, - runtimeContextRunHeaders: [sourceRun], + runtimeContextInvocations: [sourceRun], }), )) { currentTurn.record(event); @@ -826,23 +813,18 @@ describe('OpenAI-compatible product loops', () => { 'openai-chat', 131_072, ); - const sourceRun = { + const sourceRun = sourceInvocation({ + sessionId, runId: firstTurn.anchor.runId, invocationId: firstTurn.anchor.invocationId, - sessionId, turnId: firstTurn.anchor.turnId, - status: 'completed', - backendKind: 'ai-sdk', llmConnectionId: 'test-connection-id', llmConnectionSlug: providerConnection.slug, modelId: 'k3', - providerStateIdentity: PROVIDER_STATE_IDENTITY, - cwd: '/tmp/maka', permissionMode: 'ask', - createdAt: firstTurn.anchor.ts, - updatedAt: firstTurn.anchor.ts + 1, + openedAt: firstTurn.anchor.ts, completedAt: firstTurn.anchor.ts + 1, - } satisfies AgentRunHeader; + }); const createRuntime = () => createTestAiSdkBackend({ testProjectionArtifacts: true, @@ -892,7 +874,7 @@ describe('OpenAI-compatible product loops', () => { for await (const event of createRuntime().send( secondTurn.sendInput({ runtimeContext: recovered.events, - runtimeContextRunHeaders: [sourceRun], + runtimeContextInvocations: [sourceRun], }), )) { secondTurn.record(event); @@ -1594,3 +1576,65 @@ function readBody(request: IncomingMessage): Promise { request.on('error', reject); }); } + +/** + * A prior invocation on the same route, as its own events describe it. + * + * The replay path only needs its identity, its route and the fact that it + * ended; none of that is a field a writer sets apart from the ledger. + */ +function sourceInvocation(input: { + sessionId: string; + runId: string; + invocationId: string; + turnId: string; + llmConnectionId: string; + llmConnectionSlug: string; + modelId: string; + permissionMode: 'ask' | 'bypass'; + openedAt: number; + completedAt: number; +}): RuntimeInvocationRecord { + const identity = { + sessionId: input.sessionId, + invocationId: input.invocationId, + runId: input.runId, + turnId: input.turnId, + }; + return { + ...identity, + openedAt: input.openedAt, + opening: { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'runtime', + backendKind: 'ai-sdk', + llmConnectionId: input.llmConnectionId, + llmConnectionSlug: input.llmConnectionSlug, + modelId: input.modelId, + providerStateIdentity: PROVIDER_STATE_IDENTITY, + }, + configuration: { + cwd: '/tmp/maka', + permissionMode: input.permissionMode, + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + root: { kind: 'user' }, + source: { kind: 'fresh' }, + }, + terminalEvent: { + ...identity, + id: `${input.runId}-terminal`, + ts: input.completedAt, + partial: false, + role: 'system', + author: 'system', + status: 'completed', + actions: { endInvocation: true }, + }, + }; +} diff --git a/packages/runtime/src/__tests__/continuation-replay.test.ts b/packages/runtime/src/__tests__/continuation-replay.test.ts index eaeff52100..3f305894ec 100644 --- a/packages/runtime/src/__tests__/continuation-replay.test.ts +++ b/packages/runtime/src/__tests__/continuation-replay.test.ts @@ -342,7 +342,7 @@ describe('continuation replay segment', () => { prefixes: [ancestor, source], providerProjectionVersion: PROVIDER_REPLAY_PROJECTION_VERSION, admissionRoute: { - runHeaders: [], + invocations: [], targetProviderStateIdentity: undefined, targetModelId: 'test-model', }, diff --git a/packages/runtime/src/__tests__/execution-inspect.test.ts b/packages/runtime/src/__tests__/execution-inspect.test.ts index 9c1d4d46eb..dc1ea95149 100644 --- a/packages/runtime/src/__tests__/execution-inspect.test.ts +++ b/packages/runtime/src/__tests__/execution-inspect.test.ts @@ -25,9 +25,9 @@ import { describe, test } from 'node:test'; import type { AgentRunEvent, AgentRunEventType, - AgentRunHeader, EmittedAgentRunEvent, } from '@maka/core/agent-run'; +import { buildInvocationOpenedEvent } from '@maka/core/runtime-invocation'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { createSessionStore } from '@maka/storage/session-store'; import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; @@ -46,9 +46,12 @@ describe('versioned execution inspect documents', () => { model: 'fake-model', permissionMode: 'ask', }); - const header = runHeader(session.id); - await runStore.createRun(header); - await runStore.appendEvent(session.id, RUN_ID, runEvent(session.id, 'run_completed')); + await runtimeStore.appendRuntimeEvent(session.id, RUN_ID, openingEvent(session.id)); + await runStore.appendEvent( + session.id, + RUN_ID, + runEvent(session.id, 'model_stream_completed'), + ); await runtimeStore.appendRuntimeEvent( session.id, RUN_ID, @@ -107,22 +110,33 @@ const RUN_ID = 'run-1'; const TURN_ID = 'turn-1'; const TS = 1_800_000_000_000; -function runHeader(sessionId: string): AgentRunHeader { - return { - runId: RUN_ID, - invocationId: 'invocation-1', - sessionId, - turnId: TURN_ID, - status: 'completed', - backendKind: 'fake', - llmConnectionSlug: 'fake', - modelId: 'fake-model', - cwd: '/tmp/workspace', - permissionMode: 'ask', - createdAt: TS, - updatedAt: TS + 1, - completedAt: TS + 1, - }; +function openingEvent(sessionId: string) { + return buildInvocationOpenedEvent({ + id: 'rt-open', + run: { sessionId, invocationId: 'invocation-1', runId: RUN_ID, turnId: TURN_ID }, + openedAt: TS, + opening: { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'runtime', + backendKind: 'fake', + llmConnectionId: 'fake-connection', + llmConnectionSlug: 'fake', + modelId: 'fake-model', + }, + configuration: { + cwd: '/tmp/workspace', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + root: { kind: 'user' }, + source: { kind: 'fresh' }, + }, + }); } function runEvent(sessionId: string, type: AgentRunEventType): EmittedAgentRunEvent { diff --git a/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts b/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts index 059acb4c08..5d00ba976c 100644 --- a/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts +++ b/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts @@ -17,7 +17,8 @@ * under the License. */ -import type { AgentRunHeader, ModelCallCommit } from '@maka/core/agent-run'; +import type { ModelCallCommit } from '@maka/core/agent-run'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import { setImmediate as flushMacrotask } from 'node:timers/promises'; @@ -71,7 +72,7 @@ interface MidTurnFixture { toolExecutions: string[]; summarizerCalls: number; priorEvents: RuntimeEvent[]; - priorRunHeaders: AgentRunHeader[]; + priorInvocations: RuntimeInvocationRecord[]; anchor: RuntimeEvent; /** The fixture's durable RuntimeEvent ledger for the current turn/run. */ ledger: RuntimeEvent[]; @@ -164,7 +165,7 @@ interface MidTurnFixtureOptions { /** Prior-turn RuntimeEvents appended after the shaped priors (e.g. a persisted usage anchor). */ extraPriorEvents?: readonly RuntimeEvent[]; /** Run headers for the prior turns, so a persisted anchor can be identity-gated. */ - priorRunHeaders?: readonly AgentRunHeader[]; + priorInvocations?: readonly RuntimeInvocationRecord[]; /** System prompt size sent through the provider's separate system field. */ systemPromptChars?: number; /** An always-active tool whose schema dominates the request payload. */ @@ -660,7 +661,7 @@ function buildFixture(options: MidTurnFixtureOptions = {}): MidTurnFixture { return fixture.ledgerReads; }, priorEvents, - priorRunHeaders: [...(options.priorRunHeaders ?? [])], + priorInvocations: [...(options.priorInvocations ?? [])], anchor, ledger, modelCalls, @@ -685,7 +686,7 @@ async function runFixtureTurn( text: ANCHOR_TEXT, context: [], runtimeContext: [...fixture.priorEvents], - runtimeContextRunHeaders: [...fixture.priorRunHeaders], + runtimeContextInvocations: [...fixture.priorInvocations], })) { if (consumer === 'slow') { // Scheduling perturbation: hold the durable write back across several @@ -1233,7 +1234,7 @@ function defineMidTurnSuite(consumer: ConsumerMode): void { finalAtSecondCall: true, firstStepUsage: { input: 150, output: 40 }, extraPriorEvents: [priorUsageEvent({ inputTokens: 300, outputTokens: 20 })], - priorRunHeaders: [priorRunHeader()], + priorInvocations: [priorRunInvocation()], }); await runFixtureTurn(fixture, consumer); @@ -1305,7 +1306,7 @@ function defineMidTurnSuite(consumer: ConsumerMode): void { singleRequest: true, finalStepUsage: { input: 3_716, output: 10 }, extraPriorEvents: [priorUsageEvent({ inputTokens: 3_716, outputTokens: 12 })], - priorRunHeaders: [priorRunHeader()], + priorInvocations: [priorRunInvocation()], }); await runFixtureTurn(fixture, consumer); @@ -1322,7 +1323,7 @@ function defineMidTurnSuite(consumer: ConsumerMode): void { singleRequest: true, finalStepUsage: { input: 4_000, output: 10 }, extraPriorEvents: [priorUsageEvent({ inputTokens: 3_716, outputTokens: 12 })], - priorRunHeaders: [priorRunHeader()], + priorInvocations: [priorRunInvocation()], }); await runFixtureTurn(fixture, consumer); @@ -1344,7 +1345,7 @@ function defineMidTurnSuite(consumer: ConsumerMode): void { singleRequest: true, finalStepUsage: { input: 900, output: 10 }, extraPriorEvents: [priorUsageEvent({ inputTokens: 3_716, outputTokens: 12 })], - priorRunHeaders: [priorRunHeader()], + priorInvocations: [priorRunInvocation()], }); await runFixtureTurn(fixture, consumer); @@ -1363,7 +1364,7 @@ function defineMidTurnSuite(consumer: ConsumerMode): void { singleRequest: true, finalStepUsage: { input: 3_716, output: 10 }, extraPriorEvents: [priorUsageEvent({ inputTokens: 3_716, outputTokens: 12 })], - priorRunHeaders: [priorRunHeader()], + priorInvocations: [priorRunInvocation()], }); await runFixtureTurn(fixture, consumer); @@ -1775,9 +1776,21 @@ describe('the shipped runtime default drives the proactive long-turn journey (is // alone that decides: a matching header folds at step 0, while a header // naming another model and no header at all leave the request alone. const anchor = priorUsageEvent({ inputTokens: 30_000, outputTokens: 10 }); - for (const [priorRunHeaders, folds] of [ - [[priorRunHeader()], true], - [[{ ...priorRunHeader(), modelId: 'some-other-model' }], false], + const otherModel = priorRunInvocation(); + for (const [priorInvocations, folds] of [ + [[priorRunInvocation()], true], + [ + [ + { + ...otherModel, + opening: { + ...otherModel.opening, + route: { ...otherModel.opening.route, modelId: 'some-other-model' }, + }, + }, + ], + false, + ], [[], false], ] as const) { const fixture = buildFixture({ @@ -1785,7 +1798,7 @@ describe('the shipped runtime default drives the proactive long-turn journey (is contextWindow: 20_000, finalAtSecondCall: true, extraPriorEvents: [anchor], - priorRunHeaders: [...priorRunHeaders], + priorInvocations: [...priorInvocations], }); await runFixtureTurn(fixture); @@ -1814,7 +1827,7 @@ describe('the shipped runtime default drives the proactive long-turn journey (is actions: { tokenUsage: { input: 0, output: 0 } }, }, ], - priorRunHeaders: [priorRunHeader()], + priorInvocations: [priorRunInvocation()], }); await runFixtureTurn(fixture); @@ -1843,7 +1856,7 @@ describe('the shipped runtime default drives the proactive long-turn journey (is finalAtSecondCall: true, modelMaxOutputTokens: 600, extraPriorEvents: [priorUsageEvent({ inputTokens: 900, outputTokens: anchorOutput })], - priorRunHeaders: [priorRunHeader()], + priorInvocations: [priorRunInvocation()], }); await runFixtureTurn(fixture); // 960 + 120 crosses 1,000; 905 + 10 does not. The 600-token output limit @@ -1893,22 +1906,48 @@ function priorUsageEvent(lastRequestAnchor: { }; } -function priorRunHeader(): AgentRunHeader { - return { - runId: 'run-0', - invocationId: 'run-0', +/** The prior invocation on this route, as its own events describe it. */ +function priorRunInvocation(): RuntimeInvocationRecord { + const identity = { sessionId: 'session-1', + invocationId: 'run-0', + runId: 'run-0', turnId: 'turn-0', - status: 'completed', - backendKind: 'ai-sdk', - llmConnectionId: 'test-connection-id', - llmConnectionSlug: 'anthropic-main', - modelId: 'mock-model-id', - cwd: '/tmp/maka', - permissionMode: 'ask', - createdAt: 1, - updatedAt: 2, - completedAt: 2, + }; + return { + ...identity, + openedAt: 1, + opening: { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'runtime', + backendKind: 'ai-sdk', + llmConnectionId: 'test-connection-id', + llmConnectionSlug: 'anthropic-main', + modelId: 'mock-model-id', + }, + configuration: { + cwd: '/tmp/maka', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + root: { kind: 'user' }, + source: { kind: 'fresh' }, + }, + terminalEvent: { + ...identity, + id: `${identity.runId}-terminal`, + ts: 2, + partial: false, + role: 'system', + author: 'system', + status: 'completed', + actions: { endInvocation: true }, + }, }; } diff --git a/packages/runtime/src/__tests__/model-projection-transition-ledger.test.ts b/packages/runtime/src/__tests__/model-projection-transition-ledger.test.ts index 9af813c7ed..fb54ca3e65 100644 --- a/packages/runtime/src/__tests__/model-projection-transition-ledger.test.ts +++ b/packages/runtime/src/__tests__/model-projection-transition-ledger.test.ts @@ -20,7 +20,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import type { AgentRunEvent, AgentRunHeader } from '@maka/core/agent-run'; +import type { AgentRunEvent } from '@maka/core/agent-run'; import { buildModelProjectionTransition, durableToolResultProjectionDigest, @@ -437,8 +437,6 @@ describe('transition ledger reads', () => { data, }); const runStore = { - listSessionRuns: async () => - [{ runId: 'run-1' }, { runId: 'run-2' }] as unknown as AgentRunHeader[], readEvents: async (_sessionId: string, runId: string): Promise => runId === 'run-1' ? [ @@ -453,7 +451,10 @@ describe('transition ledger reads', () => { : [ledgerEvent(`${transition.transitionId}-replay`, { transition })], }; - const loaded = await loadModelProjectionTransitionsFromRunLedger(runStore, 'session-1'); + const loaded = await loadModelProjectionTransitionsFromRunLedger(runStore, 'session-1', [ + 'run-1', + 'run-2', + ]); assert.deepEqual( loaded.transitions.map((entry) => entry.transitionId), diff --git a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts index 8166923445..935060e45f 100644 --- a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts +++ b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts @@ -27,7 +27,8 @@ import type { SessionHeader } from '@maka/core/session'; import type { SessionEvent } from '@maka/core/events'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { z } from 'zod'; -import type { AgentRunHeader, ModelCallCommit } from '@maka/core/agent-run'; +import type { ModelCallCommit } from '@maka/core/agent-run'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import { decodeModelCallAttempt, type ModelCallAttempt } from '@maka/core/model-call-attempt'; import { AiSdkBackend } from '../ai-sdk-backend.js'; import { @@ -204,7 +205,7 @@ interface ReactiveFixture { summarizerCalls: () => number; anchor: RuntimeEvent; priorEvents: RuntimeEvent[]; - priorRunHeaders: AgentRunHeader[]; + priorInvocations: RuntimeInvocationRecord[]; events: SessionEvent[]; messages: unknown[]; llmCalls: ReactiveLlmCall[]; @@ -495,10 +496,10 @@ function buildReactiveFixture(options: ReactiveFixtureOptions): ReactiveFixture ] : []), ]; - const priorRunHeaders: AgentRunHeader[] = options.reasoningReplayTail + const priorInvocations: RuntimeInvocationRecord[] = options.reasoningReplayTail ? [ - priorRunHeader('same-route-prior-run', 'test-connection-id', 'mock-model-id'), - priorRunHeader('prior-run', 'source-connection-id', 'source-model-id'), + priorRunInvocation('same-route-prior-run', 'test-connection-id', 'mock-model-id'), + priorRunInvocation('prior-run', 'source-connection-id', 'source-model-id'), ] : []; const anchor: RuntimeEvent = { @@ -713,7 +714,7 @@ function buildReactiveFixture(options: ReactiveFixtureOptions): ReactiveFixture summarizerCalls: () => counters.summarizerCalls, anchor, priorEvents, - priorRunHeaders, + priorInvocations, events, messages, llmCalls, @@ -736,7 +737,7 @@ async function runTurn( text: ANCHOR_TEXT, context: [], runtimeContext: [...fixture.priorEvents], - runtimeContextRunHeaders: fixture.priorRunHeaders, + runtimeContextInvocations: [...fixture.priorInvocations], ...(pullSteering ? { pullSteering } : {}), })) { if (consumer === 'slow') { @@ -1988,23 +1989,54 @@ function header(): SessionHeader { }; } -function priorRunHeader(runId: string, llmConnectionId: string, modelId: string): AgentRunHeader { - return { - runId, +/** One prior invocation, as its own opening fact and terminal event describe it. */ +function priorRunInvocation( + runId: string, + llmConnectionId: string, + modelId: string, +): RuntimeInvocationRecord { + const identity = { sessionId: 'session-1', + invocationId: `invocation-${runId}`, + runId, turnId: 'turn-0', - status: 'completed', - backendKind: 'ai-sdk', - llmConnectionId, - llmConnectionSlug: 'anthropic-source', - modelId, - providerStateIdentity: - runId === 'same-route-prior-run' ? PROVIDER_STATE_IDENTITY : `sha256:${'2'.repeat(64)}`, - cwd: '/tmp/maka', - permissionMode: 'ask', - createdAt: 1, - updatedAt: 2, - completedAt: 2, + }; + return { + ...identity, + openedAt: 1, + opening: { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'runtime', + backendKind: 'ai-sdk', + llmConnectionId: llmConnectionId, + llmConnectionSlug: 'anthropic-source', + modelId: modelId, + providerStateIdentity: + runId === 'same-route-prior-run' ? PROVIDER_STATE_IDENTITY : `sha256:${'2'.repeat(64)}`, + }, + configuration: { + cwd: '/tmp/maka', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + root: { kind: 'user' }, + source: { kind: 'fresh' }, + }, + terminalEvent: { + ...identity, + id: `${identity.runId}-terminal`, + ts: 2, + partial: false, + role: 'system', + author: 'system', + status: 'completed', + actions: { endInvocation: true }, + }, }; } diff --git a/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts b/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts index 62c815dae5..6ea9a7fb1b 100644 --- a/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts +++ b/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts @@ -26,9 +26,12 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, test } from 'node:test'; -import type { AgentRunHeader } from '@maka/core/agent-run'; - import type { RuntimeEvent } from '@maka/core/runtime-event'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; +import { + buildInvocationOpenedEvent, + runtimeInvocationOutcome, +} from '@maka/core/runtime-invocation'; import { createSessionStore } from '@maka/storage/session-store'; import { createSqliteRuntimeStore } from '@maka/storage/sqlite-runtime-store'; import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; @@ -46,7 +49,6 @@ const FAILPOINTS: readonly RuntimeContinuationFailpoint[] = [ 'after_run_created', 'after_continuation_start_committed', 'after_terminal_event_committed', - 'after_terminal_header_committed', ]; if (process.env[CRASH_CHILD_ENV] === '1') { @@ -71,9 +73,11 @@ if (process.env[CRASH_CHILD_ENV] === '1') { session.id, ); assert.ok(claimState, `${failpoint} did not persist the continuation claim`); - const runsBeforeRecovery = await runStore.listSessionRuns(session.id); - const continuation = runsBeforeRecovery.find( - (run) => run.runId === claimState.claim.target.runId, + const invocationsBeforeRecovery = await runtimeEventStore.listSessionInvocations( + session.id, + ); + const continuation = invocationsBeforeRecovery.find( + (invocation) => invocation.runId === claimState.claim.target.runId, ); const prefix = await runtimeEventStore.readRuntimeEvents( session.id, @@ -104,7 +108,11 @@ if (process.env[CRASH_CHILD_ENV] === '1') { ]); await manager.recoverInterruptedSessions(); - const repaired = await runStore.readRun(session.id, claimState.claim.target.runId); + const repaired = await readInvocation( + recoveryRuntimeStore, + session.id, + claimState.claim.target.runId, + ); const repairedEvents = await recoveryRuntimeStore.readRuntimeEvents( session.id, claimState.claim.target.runId, @@ -114,7 +122,7 @@ if (process.env[CRASH_CHILD_ENV] === '1') { ); if (failpoint === 'after_continuation_start_committed') { assert.equal(terminalEvents.length, 0); - assert.equal(['created', 'running'].includes(repaired.status), true); + assert.equal(runtimeInvocationOutcome(repaired), undefined); const parked = await manager.planAuthoritativeSafeBoundaryContinuation(session.id, { sourceRunId: 'source-run', }); @@ -122,9 +130,7 @@ if (process.env[CRASH_CHILD_ENV] === '1') { } else { assert.equal(terminalEvents.length, 1, `${failpoint} must recover one terminal fact`); assert.ok( - repaired.status === 'completed' || - repaired.status === 'failed' || - repaired.status === 'cancelled', + runtimeInvocationOutcome(repaired), `${failpoint} left the continuation non-terminal`, ); } @@ -181,7 +187,7 @@ async function runCrashChild(): Promise { safeBoundaryResumeEnabled: true, inspectContinuationSafety: async () => stableSafetyObservation(), continuationFailpoint: async (point) => { - if (point !== failpoint || point === 'after_terminal_header_committed') return; + if (point !== failpoint) return; await suspendCrashChild(point, resolveSelectedFailpoint); }, newId: () => `id-${++id}`, @@ -197,8 +203,7 @@ async function runCrashChild(): Promise { permissionMode: 'ask', name: 'continuation crash child', }); - await runStore.createRun(sourceHeader(session.id, workspaceRoot)); - for (const event of sourceEvents(session.id)) { + for (const event of sourceEvents(session.id, workspaceRoot)) { await runtimeEventStore.appendRuntimeEvent(session.id, 'source-run', event); } const plan = await manager.planAuthoritativeSafeBoundaryContinuation(session.id, { @@ -209,13 +214,6 @@ async function runCrashChild(): Promise { for await (const _event of manager.resumeSafeBoundaryContinuation(plan.continuation)) { // drain until the selected failpoint suspends the child } - if (failpoint === 'after_terminal_header_committed') { - const continuation = await runStore.readRun(session.id, plan.continuation.runId); - if (continuation.status !== 'completed') { - throw new Error(`continuation terminal header did not settle: ${continuation.status}`); - } - await suspendCrashChild(failpoint, resolveSelectedFailpoint); - } // Terminal projection finalization may continue after the public event stream // closes. Wait for the selected durable boundary instead of racing that // background finalizer and reporting a false negative. @@ -329,24 +327,40 @@ function killCrashChild(child: ReturnType): Promise { return Promise.resolve(child.kill('SIGKILL')); } +/** The one invocation that opened this run, once its ledger says it opened. */ +async function readInvocation( + runtimeEventStore: ReturnType, + sessionId: string, + runId: string, +): Promise { + const found = (await runtimeEventStore.listSessionInvocations(sessionId)).find( + (invocation) => invocation.runId === runId, + ); + if (!found) throw new Error(`Runtime invocation not found: ${runId}`); + return found; +} + +/** + * What a crash at each boundary left durable. + * + * A continuation's opening fact rides its continuation-start event, so the two + * boundaries before that commit leave the target invocation unopened. There is + * no separate run record left over to disagree with the ledger. + */ function assertPrefix( failpoint: RuntimeContinuationFailpoint, - header: AgentRunHeader | undefined, + invocation: RuntimeInvocationRecord | undefined, events: readonly RuntimeEvent[], ): void { - if (failpoint === 'after_continuation_claim_committed') { - assert.equal(header, undefined); - assert.deepEqual(events, []); - return; - } - assert.ok(header); - if (failpoint === 'after_run_created') { - assert.equal(header.status, 'created'); + if (failpoint === 'after_continuation_claim_committed' || failpoint === 'after_run_created') { + assert.equal(invocation, undefined); assert.deepEqual(events, []); return; } + assert.ok(invocation); assert.equal(events[0]?.actions?.continuationStart?.protocol, 'continuation_start_v2'); if (failpoint === 'after_continuation_start_committed') { + assert.equal(runtimeInvocationOutcome(invocation), undefined); assert.equal( events.some((event) => event.actions?.endInvocation === true), false, @@ -354,34 +368,10 @@ function assertPrefix( return; } assert.equal(events.filter((event) => event.actions?.endInvocation === true).length, 1); - if (failpoint === 'after_terminal_event_committed') { - assert.equal(['created', 'running'].includes(header.status), true); - return; - } - assert.equal(header.status, 'completed'); -} - -function sourceHeader(sessionId: string, cwd: string): AgentRunHeader { - return { - runId: 'source-run', - invocationId: 'source-invocation', - sessionId, - turnId: 'source-turn', - status: 'failed', - backendKind: 'fake', - llmConnectionSlug: 'fake', - modelId: 'fake-model', - cwd, - workspaceIdentity: 'workspace-1', - permissionMode: 'ask', - createdAt: 1, - updatedAt: 2, - completedAt: 2, - failureClass: 'app_restarted', - }; + assert.ok(runtimeInvocationOutcome(invocation)); } -function sourceEvents(sessionId: string): RuntimeEvent[] { +function sourceEvents(sessionId: string, cwd: string): RuntimeEvent[] { const identity = { sessionId, invocationId: 'source-invocation', @@ -389,6 +379,33 @@ function sourceEvents(sessionId: string): RuntimeEvent[] { turnId: 'source-turn', }; return [ + buildInvocationOpenedEvent({ + id: 'source-open', + run: identity, + openedAt: 1, + opening: { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'runtime', + backendKind: 'fake', + llmConnectionId: 'fake-connection', + llmConnectionSlug: 'fake', + modelId: 'fake-model', + }, + configuration: { + cwd, + workspaceIdentity: 'workspace-1', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + root: { kind: 'user' }, + source: { kind: 'fresh' }, + }, + }), { ...identity, id: 'source-user', diff --git a/packages/runtime/src/__tests__/runtime-invocation-index.test.ts b/packages/runtime/src/__tests__/runtime-invocation-index.test.ts index b974ea68b1..726e149346 100644 --- a/packages/runtime/src/__tests__/runtime-invocation-index.test.ts +++ b/packages/runtime/src/__tests__/runtime-invocation-index.test.ts @@ -19,16 +19,16 @@ /** * The invocation index is a query over the canonical events, not a second - * record. This test writes real turns through the production seams and then - * asks both authorities the same question: which invocations does this Session - * have, and what route did each one open with? + * record. This test writes real turns through the production seams, then checks + * that what the index answers is exactly what rebuilding from those events + * alone produces. */ import assert from 'node:assert/strict'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; -import { runtimeInvocationOpeningFromRunHeader } from '@maka/core/agent-run'; +import { runtimeInvocationsFromSessionEvents } from '@maka/core/runtime-invocation'; import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; import { createWorkspaceRuntimeStore } from '@maka/storage/runtime-event-persistence'; import { createSessionStore } from '@maka/storage/session-store'; @@ -36,7 +36,7 @@ import type { SessionEvent } from '@maka/core/events'; import type { BackendSendInput } from '@maka/core/backend-types'; import { BackendRegistry, SessionManager } from '../session-manager.js'; -test('the invocation index returns the same inventory as the Run header table', async () => { +test('the invocation index returns the same inventory as a rebuild from events alone', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-invocation-index-')); try { const sessionStore = createSessionStore(root); @@ -81,36 +81,20 @@ test('the invocation index returns the same inventory as the Run header table', } } - const runs = await runStore.listSessionRuns(session.id); const invocations = await runtimeEventStore.listSessionInvocations(session.id); - assert.equal(runs.length, 3); + const rebuilt = runtimeInvocationsFromSessionEvents( + session.id, + await runtimeEventStore.readSessionRuntimeEvents(session.id), + ); + assert.equal(invocations.length, 3); assert.deepStrictEqual( - invocations - .map((invocation) => ({ - runId: invocation.runId, - invocationId: invocation.invocationId, - turnId: invocation.turnId, - })) - .sort((a, b) => a.runId.localeCompare(b.runId)), - runs - .map((run) => ({ - runId: run.runId, - invocationId: run.invocationId ?? run.runId, - turnId: run.turnId, - })) - .sort((a, b) => a.runId.localeCompare(b.runId)), - 'clearing the index and rebuilding from events must give the same inventory', + invocations, + rebuilt, + 'the index must return exactly what a rebuild from events alone produces', ); - for (const run of runs) { - const invocation = invocations.find((candidate) => candidate.runId === run.runId); - assert.ok(invocation, `invocation for ${run.runId} must be enumerable from events alone`); - assert.deepStrictEqual( - invocation.opening, - runtimeInvocationOpeningFromRunHeader(run), - 'replay provenance read from events must equal the header projection', - ); + for (const invocation of invocations) { assert.equal( invocation.terminalEvent?.status, 'completed', diff --git a/packages/runtime/src/__tests__/runtime-resume-crash.test.ts b/packages/runtime/src/__tests__/runtime-resume-crash.test.ts index 13d188f9b1..8cd38d0970 100644 --- a/packages/runtime/src/__tests__/runtime-resume-crash.test.ts +++ b/packages/runtime/src/__tests__/runtime-resume-crash.test.ts @@ -27,7 +27,6 @@ import { spawn } from 'node:child_process'; import { describe, test } from 'node:test'; import type { RuntimeEvent } from '@maka/core/runtime-event'; -import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; import { createWorkspaceRuntimeStore } from '@maka/storage/runtime-event-persistence'; import { @@ -60,26 +59,6 @@ if (process.env[CRASH_CHILD_ENV] === '1') { committedEventCount(failpoint.committedPrefix), ); - // Production creates the run header before any RuntimeEvent append. Keep the - // crash boundary focused on the child event writer while preserving the - // storage identity contract used when the ledger is reopened. - const runStore = createSqliteAgentRunStore(workspaceRoot); - await runStore.createRun({ - runId, - invocationId: `invocation-${runId}`, - sessionId, - turnId: `turn-${runId}`, - status: 'running', - backendKind: 'fake', - llmConnectionSlug: 'fake', - modelId: 'fake-model', - cwd: workspaceRoot, - permissionMode: 'ask', - createdAt: 1, - updatedAt: 1, - }); - runStore.close?.(); - await crashWriterAfterCommit({ workspaceRoot, sessionId, diff --git a/packages/runtime/src/__tests__/runtime-resume.test.ts b/packages/runtime/src/__tests__/runtime-resume.test.ts index a751d8d894..4be4a55041 100644 --- a/packages/runtime/src/__tests__/runtime-resume.test.ts +++ b/packages/runtime/src/__tests__/runtime-resume.test.ts @@ -26,7 +26,8 @@ import { } from '@maka/core/runtime-boundary'; import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; import type { RuntimeEvent } from '@maka/core/runtime-event'; -import type { AgentRunHeader } from '@maka/core/agent-run'; +import type { RuntimeEventInvocationOpenedContent } from '@maka/core/runtime-event'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import { buildContinuationReplayPlan } from '../continuation-replay.js'; import { PROVIDER_REPLAY_PROJECTION_VERSION } from '../model-history.js'; @@ -292,17 +293,18 @@ describe('runtime resume phase 1 safe-boundary continuation', () => { }, ]; const planner = new RuntimeContinuationPlanner({ - readSourceRun: async (_sessionId, runId) => + readSourceInvocation: async (_sessionId, runId) => runId === 'run-2' - ? runHeader('run-2', { - continuationSource: { + ? runInvocation('run-2', { + source: { + kind: 'continuation', sourceInvocationId: 'invocation-1', sourceRunId: 'run-1', sourceTurnId: 'turn-1', sourceRuntimeEventHighWater: rootEvents.length, }, }) - : runHeader('run-1'), + : runInvocation('run-1'), readImmutableRuntimePrefix: async ({ runId, upToEventSeq }) => { const events = runId === 'run-2' ? childEvents : rootEvents; return immutablePrefix(upToEventSeq === undefined ? events : events.slice(0, upToEventSeq)); @@ -731,32 +733,58 @@ function safeBoundaryFacts() { function sameRouteAdmission() { return { - runHeaders: ['run-1', 'run-2', 'run-3'].map((runId) => - runHeader(runId, { llmConnectionId: 'connection-1' }), - ), + invocations: ['run-1', 'run-2', 'run-3'].map((runId) => runInvocation(runId)), targetProviderStateIdentity: undefined, targetModelId: 'test-model', }; } -function runHeader(runId: string, overrides: Partial = {}): AgentRunHeader { +/** One failed invocation on the shared route, as its own events describe it. */ +function runInvocation( + runId: string, + facts: { source?: RuntimeEventInvocationOpenedContent['source'] } = {}, +): RuntimeInvocationRecord { const ordinal = runId.match(/(\d+)$/)?.[1] ?? '1'; - const status = overrides.status ?? 'failed'; - return { - runId, - invocationId: `invocation-${ordinal}`, + const identity = { sessionId: 'session-1', + invocationId: `invocation-${ordinal}`, + runId, turnId: `turn-${ordinal}`, - status, - backendKind: 'fake', - llmConnectionSlug: 'test', - modelId: 'test-model', - cwd: '/workspace/repo', - permissionMode: 'ask', - ...(status === 'failed' ? { failureClass: 'test_failure' } : {}), - createdAt: 1, - updatedAt: 1, - ...overrides, + }; + return { + ...identity, + openedAt: 1, + opening: { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'runtime', + backendKind: 'fake', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'test', + modelId: 'test-model', + }, + configuration: { + cwd: '/workspace/repo', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + root: { kind: 'user' }, + source: facts.source ?? { kind: 'fresh' }, + }, + terminalEvent: { + id: `${runId}-terminal`, + ...identity, + ts: 1, + partial: false, + role: 'system', + author: 'system', + status: 'failed', + actions: { endInvocation: true, stateDelta: { failureClass: 'test_failure' } }, + }, }; } diff --git a/packages/runtime/src/__tests__/sandbox-boundary-restart-recovery.test.ts b/packages/runtime/src/__tests__/sandbox-boundary-restart-recovery.test.ts index 3174c5b12f..76fd6cf186 100644 --- a/packages/runtime/src/__tests__/sandbox-boundary-restart-recovery.test.ts +++ b/packages/runtime/src/__tests__/sandbox-boundary-restart-recovery.test.ts @@ -23,7 +23,9 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, it } from 'node:test'; -import type { AgentRunEvent, EmittedAgentRunEvent, AgentRunHeader } from '@maka/core/agent-run'; +import type { AgentRunEvent, EmittedAgentRunEvent } from '@maka/core/agent-run'; +import { buildInvocationOpenedEvent } from '@maka/core/runtime-invocation'; +import { runtimeInvocationFailureClass } from '../runtime-event-read-model.js'; import type { CreateSessionInput } from '@maka/core/runtime-inputs'; import type { SessionHeader, StoredMessage } from '@maka/core/session'; import { @@ -45,7 +47,7 @@ describe('sandbox boundary restart recovery on durable stores', () => { it('attributes a closure whose RuntimeEvent never reached the ledger', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-boundary-restart-')); try { - const session = await withStores(root, async ({ sessions, runs }) => { + const session = await withStores(root, async ({ sessions, runs, runtimeEvents }) => { const header = await sessions.create(sessionInput(root)); await sessions.createSandboxBoundaryRequest({ sessionId: header.id, @@ -55,7 +57,7 @@ describe('sandbox boundary restart recovery on durable stores', () => { expansion: { network: { enabled: true } }, justification: 'Fetch a dependency.', }); - await seedInterruptedTurn(sessions, runs, header.id); + await seedInterruptedTurn(sessions, runs, runtimeEvents, header.id); // Deliberately no boundary RuntimeEvent: the process died in the // fail-open window between the row commit and the event append. return header; @@ -65,14 +67,17 @@ describe('sandbox boundary restart recovery on durable stores', () => { await manager(stores).recoverInterruptedSessions(); }); - await withStores(root, async ({ sessions, runs }) => { + await withStores(root, async ({ sessions, runtimeEvents }) => { assert.deepEqual(await sessions.listPendingSandboxBoundaryRequests(session.id), []); const [turn] = await sessions.listTurns(session.id); assert.equal(turn?.status, 'failed'); assert.equal(turn?.errorClass, 'sandbox_boundary_closed_by_restart'); - const [run] = await runs.listSessionRuns(session.id); - assert.equal(run?.status, 'failed'); - assert.equal(run?.failureClass, 'sandbox_boundary_closed_by_restart'); + const [invocation] = await runtimeEvents.listSessionInvocations(session.id); + assert.equal(invocation?.terminalEvent?.status, 'failed'); + assert.equal( + invocation && runtimeInvocationFailureClass(invocation), + 'sandbox_boundary_closed_by_restart', + ); }); } finally { await rm(root, { recursive: true, force: true }); @@ -82,7 +87,7 @@ describe('sandbox boundary restart recovery on durable stores', () => { it('re-reads a closure across a recovery interrupted before the terminal commit', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-boundary-restart-twice-')); try { - const session = await withStores(root, async ({ sessions, runs }) => { + const session = await withStores(root, async ({ sessions, runs, runtimeEvents }) => { const header = await sessions.create(sessionInput(root)); await sessions.createSandboxBoundaryRequest({ sessionId: header.id, @@ -100,7 +105,7 @@ describe('sandbox boundary restart recovery on durable stores', () => { decision: 'deny', closureReason: 'host_restarted', }); - await seedInterruptedTurn(sessions, runs, header.id); + await seedInterruptedTurn(sessions, runs, runtimeEvents, header.id); assert.deepEqual(await sessions.listPendingSandboxBoundaryRequests(header.id), []); return header; }); @@ -109,11 +114,14 @@ describe('sandbox boundary restart recovery on durable stores', () => { await manager(stores).recoverInterruptedSessions(); }); - const failedStatesAfterFirst = await withStores(root, async ({ sessions, runs }) => { + const failedStatesAfterFirst = await withStores(root, async ({ sessions, runtimeEvents }) => { const [turn] = await sessions.listTurns(session.id); assert.equal(turn?.errorClass, 'sandbox_boundary_closed_by_restart'); - const [run] = await runs.listSessionRuns(session.id); - assert.equal(run?.failureClass, 'sandbox_boundary_closed_by_restart'); + const [invocation] = await runtimeEvents.listSessionInvocations(session.id); + assert.equal( + invocation && runtimeInvocationFailureClass(invocation), + 'sandbox_boundary_closed_by_restart', + ); return countFailedTurnStates(await sessions.readMessages(session.id)); }); @@ -123,15 +131,18 @@ describe('sandbox boundary restart recovery on durable stores', () => { await manager(stores).recoverInterruptedSessions(); }); - await withStores(root, async ({ sessions, runs }) => { + await withStores(root, async ({ sessions, runtimeEvents }) => { const [turn] = await sessions.listTurns(session.id); assert.equal(turn?.errorClass, 'sandbox_boundary_closed_by_restart'); assert.equal( countFailedTurnStates(await sessions.readMessages(session.id)), failedStatesAfterFirst, ); - const [run] = await runs.listSessionRuns(session.id); - assert.equal(run?.failureClass, 'sandbox_boundary_closed_by_restart'); + const [invocation] = await runtimeEvents.listSessionInvocations(session.id); + assert.equal( + invocation && runtimeInvocationFailureClass(invocation), + 'sandbox_boundary_closed_by_restart', + ); const closures = await sessions.listSandboxBoundaryRestartClosures(session.id); assert.deepEqual( closures.map((closure) => [closure.requestId, closure.turnId, closure.runId]), @@ -199,6 +210,7 @@ function manager(stores: DurableStores): SessionManager { async function seedInterruptedTurn( sessions: SessionAuthorityStore, runs: DurableAgentRunStore, + runtimeEvents: DurableRuntimeEventStore, sessionId: string, ): Promise { await sessions.appendMessages(sessionId, [ @@ -213,7 +225,7 @@ async function seedInterruptedTurn( }, ]); await sessions.updateHeader(sessionId, { status: 'waiting_for_user' }); - await runs.createRun(runHeader(sessionId)); + await runtimeEvents.appendRuntimeEvent(sessionId, 'run-1', openingEvent(sessionId)); await runs.appendEvent(sessionId, 'run-1', runEvent(sessionId)); } @@ -222,26 +234,39 @@ function countFailedTurnStates(messages: readonly StoredMessage[]): number { .length; } -function runHeader(sessionId: string): AgentRunHeader { - return { - runId: 'run-1', - sessionId, - turnId: 'turn-1', - status: 'waiting_for_user', - backendKind: 'fake', - llmConnectionSlug: 'fake', - modelId: 'fake-model', - cwd: '/tmp/cwd', - permissionMode: 'ask', - createdAt: 10, - updatedAt: 10, - }; +function openingEvent(sessionId: string) { + return buildInvocationOpenedEvent({ + id: 'run-1-open', + run: { sessionId, invocationId: 'run-1', runId: 'run-1', turnId: 'turn-1' }, + openedAt: 10, + opening: { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'runtime', + backendKind: 'fake', + llmConnectionId: 'fake-connection', + llmConnectionSlug: 'fake', + modelId: 'fake-model', + }, + configuration: { + cwd: '/tmp/cwd', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + root: { kind: 'user' }, + source: { kind: 'fresh' }, + }, + }); } function runEvent(sessionId: string): EmittedAgentRunEvent { return { - type: 'run_started', - id: 'run-1-run_started-11', + type: 'turn_started', + id: 'run-1-turn_started-11', runId: 'run-1', sessionId, turnId: 'turn-1', diff --git a/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts b/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts index e46de3ae4a..63c1a45425 100644 --- a/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts +++ b/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts @@ -20,7 +20,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import type { AgentRunHeader } from '@maka/core/agent-run'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { SessionEvent } from '@maka/core/events'; import type { BackendSessionEvent } from '@maka/core/backend-types'; import type { RuntimeEvent } from '@maka/core/runtime-event'; @@ -653,19 +653,33 @@ const PROJECTION_SAMPLES: ProjectionSamples = { abort: { subject: { type: 'abort', id: 'e', turnId: 'turn-1', ts: 1, reason: 'user_stop' } }, }; -const projectionRunHeader: AgentRunHeader = { - runId: 'run-1', +const projectionInvocation: RuntimeInvocationRecord = { sessionId: 'session-1', + invocationId: 'invocation-1', + runId: 'run-1', turnId: 'turn-1', - status: 'completed', - backendKind: 'ai-sdk', - llmConnectionSlug: 'anthropic', - modelId: 'model-1', - cwd: '/tmp', - permissionMode: 'ask', - createdAt: 1, - updatedAt: 2, - completedAt: 2, + openedAt: 1, + opening: { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'runtime', + backendKind: 'ai-sdk', + llmConnectionId: 'anthropic-connection', + llmConnectionSlug: 'anthropic', + modelId: 'model-1', + }, + configuration: { + cwd: '/tmp', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + root: { kind: 'user' }, + source: { kind: 'fresh' }, + }, }; describe('SessionEvent projection coverage', () => { @@ -730,7 +744,7 @@ describe('SessionEvent projection coverage', () => { .filter((event) => !isNonTerminalErrorRuntimeEvent(event)); const projected = projectRuntimeEventsToStoredMessages(runtimeEvents, { - runHeaders: [projectionRunHeader], + invocations: [projectionInvocation], }); assert.deepEqual(projected.diagnostics.filter(isUnclaimedRuntimeEventDiagnostic), []); @@ -754,7 +768,7 @@ describe('SessionEvent projection coverage', () => { assert.equal(runtimeEvent.actions?.stateDelta?.unmappedSessionEventType, 'not_yet_mapped'); const projected = projectRuntimeEventsToStoredMessages([runtimeEvent], { - runHeaders: [projectionRunHeader], + invocations: [projectionInvocation], }); assert.deepEqual(projected.messages, []); // Filtered through the predicate the contract above uses, not just compared diff --git a/packages/runtime/src/__tests__/stream-graph-handoff.test.ts b/packages/runtime/src/__tests__/stream-graph-handoff.test.ts index 950983f24f..19d6e21146 100644 --- a/packages/runtime/src/__tests__/stream-graph-handoff.test.ts +++ b/packages/runtime/src/__tests__/stream-graph-handoff.test.ts @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import type { AgentRunHeader } from '@maka/core/agent-run'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { hydrateAgentGraphInputHandoffs, @@ -29,7 +29,7 @@ import { projectAgentGraphRecords } from '../stream-graph-projection.js'; describe('agent graph operator handoffs', () => { test('hydrates a selected result or terminal record from the authoritative RuntimeEvent stream', async () => { - const run = runHeader(); + const run = runInvocation(); const events = [ runtimeEvent(run, { id: 'result-event', @@ -88,7 +88,7 @@ describe('agent graph operator handoffs', () => { }); test('bounds hydrated conclusion text across all selected inputs', async () => { - const run = runHeader(); + const run = runInvocation(); const events = [ runtimeEvent(run, { id: 'long-result', @@ -103,7 +103,7 @@ describe('agent graph operator handoffs', () => { streams: [ { operator: { operatorId: 'researcher', sessionId: run.sessionId }, - run: { ...run, status: 'running', completedAt: undefined }, + run: { ...run, terminalEvent: undefined }, events, }, ], @@ -122,7 +122,7 @@ describe('agent graph operator handoffs', () => { }); test('fails closed when a committed record cannot resolve its source event', async () => { - const run = runHeader(); + const run = runInvocation(); const event = runtimeEvent(run, { id: 'result-event', ts: 11, @@ -201,30 +201,57 @@ describe('agent graph operator handoffs', () => { }); }); -function runHeader(): AgentRunHeader { - return { - runId: 'run-child', - invocationId: 'invocation-child', +/** The child's one finished invocation, as its own events describe it. */ +function runInvocation(): RuntimeInvocationRecord { + const identity = { sessionId: 'child-session', + invocationId: 'invocation-child', + runId: 'run-child', turnId: 'turn-child', - status: 'completed', - backendKind: 'ai-sdk', - llmConnectionSlug: 'deepseek', - modelId: 'deepseek-chat', - cwd: '/workspace', - permissionMode: 'explore', - createdAt: 10, - updatedAt: 12, - completedAt: 12, + }; + return { + ...identity, + openedAt: 10, + opening: { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'runtime', + backendKind: 'ai-sdk', + llmConnectionId: 'deepseek-connection', + llmConnectionSlug: 'deepseek', + modelId: 'deepseek-chat', + }, + configuration: { + cwd: '/workspace', + permissionMode: 'explore', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + root: { kind: 'user' }, + source: { kind: 'fresh' }, + }, + terminalEvent: { + ...identity, + id: 'run-child-terminal', + ts: 12, + partial: false, + role: 'system', + author: 'system', + status: 'completed', + actions: { endInvocation: true }, + }, }; } function runtimeEvent( - run: AgentRunHeader, + run: RuntimeInvocationRecord, overrides: Partial & Pick, ): RuntimeEvent { return { - invocationId: run.invocationId!, + invocationId: run.invocationId, runId: run.runId, sessionId: run.sessionId, turnId: run.turnId, diff --git a/packages/runtime/src/__tests__/stream-graph-projection.test.ts b/packages/runtime/src/__tests__/stream-graph-projection.test.ts index 81a3d10369..09b901c0e0 100644 --- a/packages/runtime/src/__tests__/stream-graph-projection.test.ts +++ b/packages/runtime/src/__tests__/stream-graph-projection.test.ts @@ -19,9 +19,9 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import type { AgentRunHeader } from '@maka/core/agent-run'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import { projectAgentGraphRecords, readCommittedAgentGraphProjection, @@ -32,14 +32,14 @@ const baseTs = 1_800_000_000_000; describe('committed stream graph projection', () => { test('projects immutable child-session events into a stable reference-only graph trace', async () => { - const runA = runHeader({ + const runA = runInvocation({ sessionId: 'child-a', runId: 'run-a', turnId: 'turn-a', status: 'completed', createdAt: baseTs, }); - const runB = runHeader({ + const runB = runInvocation({ sessionId: 'child-b', runId: 'run-b', turnId: 'turn-b', @@ -139,12 +139,10 @@ describe('committed stream graph projection', () => { { operatorId: 'research', sessionId: runA.sessionId }, { operatorId: 'verify', sessionId: runB.sessionId }, ], - runStore: { - async listSessionRuns(sessionId) { + runtimeEventStore: { + async listSessionInvocations(sessionId) { return sessionId === runA.sessionId ? [runA] : [runB]; }, - }, - runtimeEventStore: { async readImmutableRuntimeEvents(_sessionId, runId) { return eventsByRun.get(runId) ?? []; }, @@ -212,7 +210,7 @@ describe('committed stream graph projection', () => { }); test('replay is deterministic for reordered delivery and idempotent duplicates', () => { - const run = runHeader({ + const run = runInvocation({ sessionId: 'child-a', runId: 'run-a', turnId: 'turn-a', @@ -254,7 +252,7 @@ describe('committed stream graph projection', () => { }); test('replays reserved JavaScript property names as ordinary graph identities', () => { - const run = runHeader({ + const run = runInvocation({ sessionId: 'reserved-session', runId: 'constructor', turnId: 'reserved-turn', @@ -286,7 +284,7 @@ describe('committed stream graph projection', () => { }); test('rejects one Session projected under different operators across observations', () => { - const run = runHeader({ + const run = runInvocation({ sessionId: 'child-a', runId: 'run-a', turnId: 'turn-a', @@ -329,14 +327,14 @@ describe('committed stream graph projection', () => { }); test('keeps existing records byte-stable when a late operator contributes earlier event time', () => { - const runA = runHeader({ + const runA = runInvocation({ sessionId: 'child-a', runId: 'run-a', turnId: 'turn-a', status: 'running', createdAt: baseTs, }); - const runB = runHeader({ + const runB = runInvocation({ sessionId: 'child-b', runId: 'run-b', turnId: 'turn-b', @@ -391,14 +389,14 @@ describe('committed stream graph projection', () => { }); test('allows equal event times and resolves them with the stable source order key', () => { - const first = runHeader({ + const first = runInvocation({ sessionId: 'child-z', runId: 'run-z', turnId: 'turn-z', status: 'running', createdAt: baseTs, }); - const second = runHeader({ + const second = runInvocation({ sessionId: 'child-a', runId: 'run-a', turnId: 'turn-a', @@ -434,7 +432,7 @@ describe('committed stream graph projection', () => { }); test('projects concurrent tool commits whose immutable event times are not commit-monotonic', () => { - const run = runHeader({ + const run = runInvocation({ sessionId: 'child-concurrent', runId: 'run-concurrent', turnId: 'turn-concurrent', @@ -478,14 +476,14 @@ describe('committed stream graph projection', () => { const precomposedId = '\u00e9'; const decomposedId = 'e\u0301'; assert.equal(precomposedId.localeCompare(decomposedId), 0); - const precomposed = runHeader({ + const precomposed = runInvocation({ sessionId: 'child-precomposed', runId: precomposedId, turnId: 'turn-precomposed', status: 'running', createdAt: baseTs, }); - const decomposed = runHeader({ + const decomposed = runInvocation({ sessionId: 'child-decomposed', runId: decomposedId, turnId: 'turn-decomposed', @@ -518,7 +516,7 @@ describe('committed stream graph projection', () => { }); test('routes human-interaction facts to the always-on supervisor without blocking lifecycle', () => { - const run = runHeader({ + const run = runInvocation({ sessionId: 'child-a', runId: 'run-a', turnId: 'turn-a', @@ -599,14 +597,14 @@ describe('committed stream graph projection', () => { }); test('keeps later session-inline runs as distinct activations of one operator', () => { - const first = runHeader({ + const first = runInvocation({ sessionId: 'child-a', runId: 'run-1', turnId: 'turn-1', status: 'completed', createdAt: baseTs, }); - const followup = runHeader({ + const followup = runInvocation({ sessionId: 'child-a', runId: 'run-2', turnId: 'turn-2', @@ -647,24 +645,26 @@ describe('committed stream graph projection', () => { }); test('fails closed on ambiguous authority or impossible replay order', async () => { - const run = runHeader({ + const run = runInvocation({ sessionId: 'child-a', runId: 'run-a', turnId: 'turn-a', status: 'completed', createdAt: baseTs, }); - const runtimeEventStore: Pick = {}; + const runtimeEventStore = { + async listSessionInvocations() { + return [run]; + }, + } as unknown as Pick< + RuntimeEventStore, + 'listSessionInvocations' | 'readImmutableRuntimeEvents' + >; await assert.rejects( readCommittedAgentGraphProjection({ graphId: 'graph-no-immutable-reader', operators: [{ operatorId: 'research', sessionId: run.sessionId }], - runStore: { - async listSessionRuns() { - return [run]; - }, - }, runtimeEventStore, }), /requires immutable RuntimeEvent reads/, @@ -696,12 +696,10 @@ describe('committed stream graph projection', () => { const projection = await readCommittedAgentGraphProjection({ graphId: 'graph-empty', operators: [], - runStore: { - async listSessionRuns() { + runtimeEventStore: { + async listSessionInvocations() { return []; }, - }, - runtimeEventStore: { async readImmutableRuntimeEvents() { return []; }, @@ -716,34 +714,71 @@ describe('committed stream graph projection', () => { }); }); -function runHeader(input: { +/** One invocation, as its opening fact and its terminal event describe it. */ +function runInvocation(input: { sessionId: string; runId: string; turnId: string; - status: AgentRunHeader['status']; + status: 'created' | 'running' | 'completed' | 'failed' | 'aborted'; createdAt: number; -}): AgentRunHeader { - return { - ...input, +}): RuntimeInvocationRecord { + const identity = { + sessionId: input.sessionId, invocationId: `invocation-${input.runId}`, - backendKind: 'ai-sdk', - llmConnectionSlug: 'deepseek', - modelId: 'deepseek-chat', - cwd: '/workspace', - permissionMode: 'explore', - updatedAt: input.createdAt + 1, - ...(input.status === 'completed' || input.status === 'failed' || input.status === 'cancelled' - ? { completedAt: input.createdAt + 1 } + runId: input.runId, + turnId: input.turnId, + }; + const ended = + input.status === 'completed' || input.status === 'failed' || input.status === 'aborted' + ? input.status + : undefined; + return { + ...identity, + openedAt: input.createdAt, + opening: { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'runtime', + backendKind: 'ai-sdk', + llmConnectionId: 'deepseek-connection', + llmConnectionSlug: 'deepseek', + modelId: 'deepseek-chat', + }, + configuration: { + cwd: '/workspace', + permissionMode: 'explore', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + root: { kind: 'user' }, + source: { kind: 'fresh' }, + }, + ...(ended + ? { + terminalEvent: { + ...identity, + id: `${input.runId}-terminal`, + ts: input.createdAt + 1, + partial: false, + role: 'system', + author: 'system', + status: ended, + actions: { endInvocation: true }, + } satisfies RuntimeEvent, + } : {}), }; } function runtimeEvent( - run: AgentRunHeader, + run: RuntimeInvocationRecord, overrides: Partial & Pick, ): RuntimeEvent { return { - invocationId: run.invocationId ?? `invocation-${run.runId}`, + invocationId: run.invocationId, runId: run.runId, sessionId: run.sessionId, turnId: run.turnId, diff --git a/packages/runtime/src/__tests__/stream-graph-readiness.test.ts b/packages/runtime/src/__tests__/stream-graph-readiness.test.ts index 59a425136e..98a6519677 100644 --- a/packages/runtime/src/__tests__/stream-graph-readiness.test.ts +++ b/packages/runtime/src/__tests__/stream-graph-readiness.test.ts @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import type { AgentRunHeader } from '@maka/core/agent-run'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { AGENT_GRAPH_READINESS_SCHEMA_VERSION, @@ -33,8 +33,8 @@ const baseTs = 1_800_000_000_000; describe('operator-local stream graph readiness', () => { test('derives one stable map intent per direct input route without supervisor gating', () => { - const source = runHeader('source', baseTs); - const worker = runHeader('worker', baseTs + 1); + const source = runInvocation('source', baseTs); + const worker = runInvocation('worker', baseTs + 1); const projection = projectAgentGraphRecords({ graphId: 'graph-map', streams: [ @@ -116,8 +116,8 @@ describe('operator-local stream graph readiness', () => { }); test('keeps a map operator waiting while exposing that state to the supervisor', () => { - const source = runHeader('empty-source', baseTs); - const worker = runHeader('empty-worker', baseTs + 1); + const source = runInvocation('empty-source', baseTs); + const worker = runInvocation('empty-worker', baseTs + 1); const snapshot = buildAgentGraphReadinessSnapshot({ topology: { graphId: 'graph-map-waiting', @@ -144,15 +144,15 @@ describe('operator-local stream graph readiness', () => { }); test('waits for an exact all-settled activation frontier and accepts every terminal outcome', () => { - const branchA = runHeader('branch-a', baseTs, 'completed'); - const branchBRunning = runHeader('branch-b', baseTs + 1); + const branchA = runInvocation('branch-a', baseTs, 'completed'); + const branchBRunning = runInvocation('branch-b', baseTs + 1); const branchBFailed = { ...branchBRunning, status: 'failed' as const, completedAt: baseTs + 4, }; - const branchC = runHeader('branch-c', baseTs + 2, 'completed'); - const join = runHeader('join', baseTs + 3); + const branchC = runInvocation('branch-c', baseTs + 2, 'completed'); + const join = runInvocation('join', baseTs + 3); const topology: AgentGraphTraceTopology = { graphId: 'graph-all-settled', operators: [ @@ -245,15 +245,15 @@ describe('operator-local stream graph readiness', () => { }); test('does not let a later follow-up activation rewrite a sealed all-settled intent', () => { - const branchA = runHeader('sealed-a', baseTs, 'completed'); - const branchAFollowup = runHeader( + const branchA = runInvocation('sealed-a', baseTs, 'completed'); + const branchAFollowup = runInvocation( 'sealed-a-followup', baseTs + 20, 'running', branchA.sessionId, ); - const branchB = runHeader('sealed-b', baseTs + 1, 'completed'); - const join = runHeader('sealed-join', baseTs + 2); + const branchB = runInvocation('sealed-b', baseTs + 1, 'completed'); + const join = runInvocation('sealed-join', baseTs + 2); const topology: AgentGraphTraceTopology = { graphId: 'graph-sealed-frontier', operators: [binding(branchA, 'a'), binding(branchB, 'b'), binding(join, 'join')], @@ -308,9 +308,9 @@ describe('operator-local stream graph readiness', () => { }); test('keeps local intent identity stable across unrelated and downstream-only topology changes', () => { - const source = runHeader('fingerprint-source', baseTs); - const worker = runHeader('fingerprint-worker', baseTs + 1); - const observer = runHeader('fingerprint-observer', baseTs + 2); + const source = runInvocation('fingerprint-source', baseTs); + const worker = runInvocation('fingerprint-worker', baseTs + 1); + const observer = runInvocation('fingerprint-observer', baseTs + 2); const records = projectAgentGraphRecords({ graphId: 'graph-fingerprint', streams: [stream(source, 'source', [runtimeEvent(source, 'record', baseTs + 1, 'record')])], @@ -392,9 +392,9 @@ describe('operator-local stream graph readiness', () => { }); test('orders distinct Unicode identities canonically across topology and sealed inputs', () => { - const precomposed = runHeader('unicode-precomposed', baseTs); - const decomposed = runHeader('unicode-decomposed', baseTs + 1); - const join = runHeader('unicode-join', baseTs + 2); + const precomposed = runInvocation('unicode-precomposed', baseTs); + const decomposed = runInvocation('unicode-decomposed', baseTs + 1); + const join = runInvocation('unicode-join', baseTs + 2); const precomposedId = '\u00e9'; const decomposedId = 'e\u0301'; assert.equal(precomposedId.localeCompare(decomposedId), 0); @@ -453,8 +453,8 @@ describe('operator-local stream graph readiness', () => { }); test('keeps reserved JavaScript property names safe in readiness identities', () => { - const source = runHeader('reserved-source', baseTs); - const worker = runHeader('reserved-worker', baseTs + 1); + const source = runInvocation('reserved-source', baseTs); + const worker = runInvocation('reserved-worker', baseTs + 1); const records = projectAgentGraphRecords({ graphId: 'graph-reserved-readiness', streams: [stream(source, 'source', [runtimeEvent(source, 'record', baseTs + 1, 'record')])], @@ -485,9 +485,9 @@ describe('operator-local stream graph readiness', () => { }); test('fails closed on ambiguous or incomplete local readiness policies', () => { - const sourceA = runHeader('invalid-a', baseTs); - const sourceB = runHeader('invalid-b', baseTs + 1); - const target = runHeader('invalid-target', baseTs + 2); + const sourceA = runInvocation('invalid-a', baseTs); + const sourceB = runInvocation('invalid-b', baseTs + 1); + const target = runInvocation('invalid-target', baseTs + 2); const topology: AgentGraphTraceTopology = { graphId: 'graph-invalid-readiness', operators: [binding(sourceA, 'a'), binding(sourceB, 'b'), binding(target, 'target')], @@ -572,36 +572,65 @@ describe('operator-local stream graph readiness', () => { }); }); -function runHeader( +/** One invocation, open or ended, as its opening fact and terminal event say. */ +function runInvocation( name: string, - createdAt: number, - status: AgentRunHeader['status'] = 'running', + openedAt: number, + status: 'running' | 'completed' | 'failed' | 'aborted' = 'running', sessionId = `session-${name}`, -): AgentRunHeader { - return { +): RuntimeInvocationRecord { + const identity = { sessionId, + invocationId: `invocation-${name}`, runId: `run-${name}`, turnId: `turn-${name}`, - invocationId: `invocation-${name}`, - backendKind: 'ai-sdk', - llmConnectionSlug: 'deepseek', - modelId: 'deepseek-chat', - cwd: '/workspace', - permissionMode: 'explore', - status, - createdAt, - updatedAt: createdAt + 1, - ...(status === 'completed' || status === 'failed' || status === 'cancelled' - ? { completedAt: createdAt + 1 } - : {}), + }; + return { + ...identity, + openedAt, + opening: { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'runtime', + backendKind: 'ai-sdk', + llmConnectionId: 'deepseek-connection', + llmConnectionSlug: 'deepseek', + modelId: 'deepseek-chat', + }, + configuration: { + cwd: '/workspace', + permissionMode: 'explore', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + root: { kind: 'user' }, + source: { kind: 'fresh' }, + }, + ...(status === 'running' + ? {} + : { + terminalEvent: { + ...identity, + id: `run-${name}-terminal`, + ts: openedAt + 1, + partial: false, + role: 'system', + author: 'system', + status, + actions: { endInvocation: true }, + }, + }), }; } -function binding(run: AgentRunHeader, operatorId: string) { +function binding(run: RuntimeInvocationRecord, operatorId: string) { return { operatorId, sessionId: run.sessionId }; } -function stream(run: AgentRunHeader, operatorId: string, events: readonly RuntimeEvent[]) { +function stream(run: RuntimeInvocationRecord, operatorId: string, events: readonly RuntimeEvent[]) { return { operator: binding(run, operatorId), run, @@ -609,10 +638,10 @@ function stream(run: AgentRunHeader, operatorId: string, events: readonly Runtim }; } -function runtimeEvent(run: AgentRunHeader, id: string, ts: number, text: string): RuntimeEvent { +function runtimeEvent(run: RuntimeInvocationRecord, id: string, ts: number, text: string): RuntimeEvent { return { id, - invocationId: run.invocationId ?? `invocation-${run.runId}`, + invocationId: run.invocationId, runId: run.runId, sessionId: run.sessionId, turnId: run.turnId, @@ -625,14 +654,14 @@ function runtimeEvent(run: AgentRunHeader, id: string, ts: number, text: string) } function terminalEvent( - run: AgentRunHeader, + run: RuntimeInvocationRecord, id: string, ts: number, - status: Extract, + status: 'completed' | 'failed' | 'aborted', ): RuntimeEvent { return { id, - invocationId: run.invocationId ?? `invocation-${run.runId}`, + invocationId: run.invocationId, runId: run.runId, sessionId: run.sessionId, turnId: run.turnId, diff --git a/packages/runtime/src/__tests__/stream-graph-trace.test.ts b/packages/runtime/src/__tests__/stream-graph-trace.test.ts index 58549fd744..59cb262451 100644 --- a/packages/runtime/src/__tests__/stream-graph-trace.test.ts +++ b/packages/runtime/src/__tests__/stream-graph-trace.test.ts @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import type { AgentRunHeader } from '@maka/core/agent-run'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { projectAgentGraphRecords } from '../stream-graph-projection.js'; import { @@ -32,10 +32,10 @@ const baseTs = 1_800_000_000_000; describe('stream graph trace topology', () => { test('materializes deterministic direct-edge routes without putting the supervisor in the path', () => { - const research = runHeader('research', baseTs); - const verify = runHeader('verify', baseTs + 1); - const synthesize = runHeader('synthesize', baseTs + 2); - const audit = runHeader('audit', baseTs + 3); + const research = runInvocation('research', baseTs); + const verify = runInvocation('verify', baseTs + 1); + const synthesize = runInvocation('synthesize', baseTs + 2); + const audit = runInvocation('audit', baseTs + 3); const projection = projectAgentGraphRecords({ graphId: 'graph-trace', streams: [ @@ -123,8 +123,8 @@ describe('stream graph trace topology', () => { }); test('is deterministic and idempotent for reordered duplicate observations', () => { - const source = runHeader('source', baseTs); - const target = runHeader('target', baseTs + 1); + const source = runInvocation('source', baseTs); + const target = runInvocation('target', baseTs + 1); const projection = projectAgentGraphRecords({ graphId: 'graph-replay', streams: [ @@ -169,9 +169,9 @@ describe('stream graph trace topology', () => { }); test('fingerprints only declared topology fields in raw identity order', () => { - const precomposed = runHeader('unicode-precomposed', baseTs); - const decomposed = runHeader('unicode-decomposed', baseTs + 1); - const target = runHeader('unicode-target', baseTs + 2); + const precomposed = runInvocation('unicode-precomposed', baseTs); + const decomposed = runInvocation('unicode-decomposed', baseTs + 1); + const target = runInvocation('unicode-target', baseTs + 2); const precomposedId = '\u00e9'; const decomposedId = 'e\u0301'; assert.equal(precomposedId.localeCompare(decomposedId), 0); @@ -238,8 +238,8 @@ describe('stream graph trace topology', () => { }); test('keeps existing route identities stable as later observations arrive', () => { - const source = runHeader('source', baseTs); - const target = runHeader('target', baseTs + 1); + const source = runInvocation('source', baseTs); + const target = runInvocation('target', baseTs + 1); const initialProjection = projectAgentGraphRecords({ graphId: 'graph-incremental', streams: [stream(source, 'source', [runtimeEvent(source, 'first', baseTs + 10, 'first')])], @@ -279,8 +279,8 @@ describe('stream graph trace topology', () => { }); test('retains an observable topology before any runtime facts arrive', () => { - const source = runHeader('source', baseTs); - const target = runHeader('target', baseTs + 1); + const source = runInvocation('source', baseTs); + const target = runInvocation('target', baseTs + 1); const snapshot = buildAgentGraphTraceSnapshot({ topology: { @@ -308,11 +308,11 @@ describe('stream graph trace topology', () => { test('materializes reserved JavaScript property names as own snapshot keys', () => { const source = { - ...runHeader('reserved-source', baseTs), + ...runInvocation('reserved-source', baseTs), runId: 'constructor', invocationId: 'reserved-invocation', }; - const target = runHeader('reserved-target', baseTs + 1); + const target = runInvocation('reserved-target', baseTs + 1); const projection = projectAgentGraphRecords({ graphId: 'graph-reserved-keys', streams: [ @@ -351,9 +351,9 @@ describe('stream graph trace topology', () => { }); test('binds route identity to immutable edge endpoints', () => { - const source = runHeader('route-source', baseTs); - const targetA = runHeader('route-target-a', baseTs + 1); - const targetB = runHeader('route-target-b', baseTs + 2); + const source = runInvocation('route-source', baseTs); + const targetA = runInvocation('route-target-a', baseTs + 1); + const targetB = runInvocation('route-target-b', baseTs + 2); const projection = projectAgentGraphRecords({ graphId: 'graph-edge-rebinding', streams: [ @@ -401,9 +401,9 @@ describe('stream graph trace topology', () => { }); test('fails closed on invalid topology and record ownership', () => { - const one = runHeader('one', baseTs); - const two = runHeader('two', baseTs + 1); - const three = runHeader('three', baseTs + 2); + const one = runInvocation('one', baseTs); + const two = runInvocation('two', baseTs + 1); + const three = runInvocation('three', baseTs + 2); const projection = projectAgentGraphRecords({ graphId: 'graph-invalid', streams: [stream(one, 'one', [runtimeEvent(one, 'one-message', baseTs + 1, 'one')])], @@ -490,28 +490,43 @@ describe('stream graph trace topology', () => { }); }); -function runHeader(name: string, createdAt: number): AgentRunHeader { +/** One still-open invocation, as its opening fact describes it. */ +function runInvocation(name: string, openedAt: number): RuntimeInvocationRecord { return { sessionId: `session-${name}`, + invocationId: `invocation-${name}`, runId: `run-${name}`, turnId: `turn-${name}`, - invocationId: `invocation-${name}`, - backendKind: 'ai-sdk', - llmConnectionSlug: 'deepseek', - modelId: 'deepseek-chat', - cwd: '/workspace', - permissionMode: 'explore', - status: 'running', - createdAt, - updatedAt: createdAt + 1, + openedAt, + opening: { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'runtime', + backendKind: 'ai-sdk', + llmConnectionId: 'deepseek-connection', + llmConnectionSlug: 'deepseek', + modelId: 'deepseek-chat', + }, + configuration: { + cwd: '/workspace', + permissionMode: 'explore', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + root: { kind: 'user' }, + source: { kind: 'fresh' }, + }, }; } -function binding(run: AgentRunHeader, operatorId: string) { +function binding(run: RuntimeInvocationRecord, operatorId: string) { return { operatorId, sessionId: run.sessionId }; } -function stream(run: AgentRunHeader, operatorId: string, events: readonly RuntimeEvent[]) { +function stream(run: RuntimeInvocationRecord, operatorId: string, events: readonly RuntimeEvent[]) { return { operator: binding(run, operatorId), run, @@ -519,10 +534,10 @@ function stream(run: AgentRunHeader, operatorId: string, events: readonly Runtim }; } -function runtimeEvent(run: AgentRunHeader, id: string, ts: number, text: string): RuntimeEvent { +function runtimeEvent(run: RuntimeInvocationRecord, id: string, ts: number, text: string): RuntimeEvent { return { id, - invocationId: run.invocationId ?? `invocation-${run.runId}`, + invocationId: run.invocationId, runId: run.runId, sessionId: run.sessionId, turnId: run.turnId, From 69f505dcb57a59490e7902720810e982640a4c70 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 13:04:47 +0800 Subject: [PATCH 18/46] docs: describe the invocation spine the runtime actually has The architecture chapters still taught the AgentRunHeader: a run's status, route and continuation source lived on it, recovery reconciled it against the ledger, and the terminal invariant was an ordering between two commits. None of that is in the code any more. They now say what the code does. A run opens with an immutable opening fact, ends with exactly one terminal RuntimeEvent, and has no second record of its outcome for a crash to leave disagreeing. Recovery reads the invocations with no terminal event and commits one. The desktop usage fixture seeded its model-call attempts behind a Run header. It now seeds the opening fact the attempts hang off, which is what the store requires and what production writes. Generated-by: Claude Code --- apps/desktop/src/main/e2e-fixture.ts | 7 ++- .../src/main/e2e-fixture/scenarios-usage.ts | 51 +++++++++++++------ .../runtime-core-architecture-draft.md | 24 ++++----- .../runtime-core-architecture-draft.zh-CN.md | 24 ++++----- .../runtime-resume-architecture.md | 24 ++++----- .../runtime-resume-architecture.zh-CN.md | 26 +++++----- .../runtime-resume-extraction-ledger.zh-CN.md | 6 +-- ...me-resume-phase1-safe-boundary-contract.md | 11 ++-- ...hase4-workspace-checkpoint-design.zh-CN.md | 8 +-- packages/storage/src/execution-stores.ts | 6 +-- 10 files changed, 98 insertions(+), 89 deletions(-) diff --git a/apps/desktop/src/main/e2e-fixture.ts b/apps/desktop/src/main/e2e-fixture.ts index 9e971d2033..08dc5b226d 100644 --- a/apps/desktop/src/main/e2e-fixture.ts +++ b/apps/desktop/src/main/e2e-fixture.ts @@ -27,6 +27,7 @@ import { AGENT_GRAPH_CLIENT_PROJECTION_SCHEMA_VERSION } from '@maka/core/agent-g import { MODEL_CALL_ATTEMPT_EVENT_TYPE } from '@maka/core/model-call-attempt'; import type { UiLocale } from '@maka/core/ui-locale'; import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; +import { createWorkspaceRuntimeStore } from '@maka/storage/runtime-event-persistence'; import { createProjectCatalog } from '@maka/storage/project-catalog'; import { resolveStorageRoot, @@ -305,13 +306,14 @@ export async function seedE2eFixture(input: { // below. It MUST be the lease's canonicalPath, not the raw workspaceRoot — // a /var vs /private/var realpath difference would open a different DB. const runStore = createSqliteAgentRunStore(owner.lease.canonicalPath); + const runtimeEventStore = createWorkspaceRuntimeStore(owner.lease.canonicalPath); try { const records = usageStatsRecords(now); // Model calls seed the CANONICAL ledger through the AgentRun event stream; // tools stay on the legacy telemetry table (there is no canonical tool // ledger). This is what actually exercises the canonical merge branch. - for (const { header: runHeader, attempt } of records.modelCalls) { - await runStore.createRun(runHeader); + for (const { opening, attempt } of records.modelCalls) { + await runtimeEventStore.appendRuntimeEvent(attempt.sessionId, attempt.runId, opening); await runStore.appendEvent(attempt.sessionId, attempt.runId, { id: attempt.attemptId, type: MODEL_CALL_ATTEMPT_EVENT_TYPE, @@ -323,6 +325,7 @@ export async function seedE2eFixture(input: { }); } for (const record of records.tools) await usage.telemetry.recordToolInvocation(record); + runtimeEventStore.close(); await runStore.close?.(); // Fold the appended attempts into the read model so the page's first read // sees canonical usage (production's readCanonicalUsage also repairs). diff --git a/apps/desktop/src/main/e2e-fixture/scenarios-usage.ts b/apps/desktop/src/main/e2e-fixture/scenarios-usage.ts index 14af6b6249..c51430eb6e 100644 --- a/apps/desktop/src/main/e2e-fixture/scenarios-usage.ts +++ b/apps/desktop/src/main/e2e-fixture/scenarios-usage.ts @@ -17,7 +17,8 @@ * under the License. */ -import type { AgentRunHeader } from '@maka/core/agent-run'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; +import { buildInvocationOpenedEvent } from '@maka/core/runtime-invocation'; import { MODEL_CALL_ATTEMPT_SCHEMA_VERSION, type ModelCallAttempt, @@ -229,11 +230,11 @@ export function usageStatsSessions( } export function usageStatsRecords(now: number): { - modelCalls: Array<{ header: AgentRunHeader; attempt: ModelCallAttempt }>; + modelCalls: Array<{ opening: RuntimeEvent; attempt: ModelCallAttempt }>; tools: PersistedToolInvocationRecord[]; } { const sessions = usageStatsSessions(now); - const modelCalls: Array<{ header: AgentRunHeader; attempt: ModelCallAttempt }> = []; + const modelCalls: Array<{ opening: RuntimeEvent; attempt: ModelCallAttempt }> = []; const tools: PersistedToolInvocationRecord[] = []; for (const { header: session, messages } of sessions) { const modelByTurn = new Map( @@ -257,19 +258,37 @@ export function usageStatsRecords(now: number): { // Run/attempt ids must match SAFE_ID_PATTERN ([A-Za-z0-9_-]); no colons. const runId = `run-${message.id}`; modelCalls.push({ - header: { - runId, - sessionId: session.id, - turnId: message.turnId, - status: 'created', - backendKind: 'fake', - llmConnectionSlug: session.llmConnectionSlug, - modelId, - cwd: '/tmp/e2e-usage', - permissionMode: 'ask', - createdAt: message.ts - 2_000, - updatedAt: message.ts, - }, + opening: buildInvocationOpenedEvent({ + id: `${runId}-open`, + run: { + sessionId: session.id, + invocationId: runId, + runId, + turnId: message.turnId, + }, + openedAt: message.ts - 2_000, + opening: { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'runtime', + backendKind: 'fake', + llmConnectionId: session.llmConnectionSlug, + llmConnectionSlug: session.llmConnectionSlug, + modelId, + }, + configuration: { + cwd: '/tmp/e2e-usage', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: 'direct', + }, + root: { kind: 'user' }, + source: { kind: 'fresh' }, + }, + }), attempt: { schemaVersion: MODEL_CALL_ATTEMPT_SCHEMA_VERSION, logicalCallId: message.id, diff --git a/docs/architecture/runtime-core-architecture-draft.md b/docs/architecture/runtime-core-architecture-draft.md index b7408c1c3b..7d10db4e31 100644 --- a/docs/architecture/runtime-core-architecture-draft.md +++ b/docs/architecture/runtime-core-architecture-draft.md @@ -156,7 +156,7 @@ Maka is not implementing Kafka inside one process, nor does it claim that Runtim > **Log is the source of truth; state is a materialized view.** -That principle directly explains the most important terminal invariant later in this chapter: a Run header cannot declare completion on its own; a terminal RuntimeEvent must support it. +That principle directly explains the most important terminal invariant later in this chapter: nothing declares that a Run ended except the Run's own terminal RuntimeEvent. ## Three lifecycle identities, plus one correlation field @@ -221,13 +221,12 @@ It is an orchestration boundary, not the model loop. A Backend should not own th `AgentRun` gives one execution a durable identity and lifecycle. At startup it: -1. creates an `AgentRunHeader` in `created` state; +1. commits the invocation's opening fact as a RuntimeEvent; 2. writes the user message and a `running` Turn projection for a top-level Run; 3. writes the initial user `RuntimeEvent`; 4. locks the Session's connection configuration; 5. ensures a Backend exists and registers the active Run; -6. marks the Run as `running`; -7. builds model history from earlier RuntimeEvent ledgers. +6. builds model history from earlier RuntimeEvent ledgers. While execution is active, `AgentRun` receives both legacy `SessionEvent`s and canonical `RuntimeEvent`s and writes each to the projection or ledger it belongs to. At the end, it unregisters the active Run, converges Session and Turn state, and commits the final Run state. @@ -323,12 +322,12 @@ The important point is that permission is not a UI-only pause. Requests and deci ## One semantic truth, two supporting forms of state -Maka currently maintains three forms of durable data. They are not three equal sources of truth, nor do they store the same chat three times. `RuntimeEventStore` is the canonical semantic log of AI interaction; the other stores carry product projections and operational Run state. +Maka currently maintains three forms of durable data. They are not three equal sources of truth, nor do they store the same chat three times. `RuntimeEventStore` is the canonical semantic log of AI interaction; the other stores carry product projections and the operational record of what the runtime did. | Store | Main contents | Question it answers best | |---|---|---| | `SessionStore` | `StoredMessage`s for users, assistants, tools, and Turn state | What should the UI and compatibility APIs display? What is the current in-flight projection? | -| `AgentRunStore` | Run header and operational Run events | When did this Run start, what is its state, and at which model or tool stage did it fail? | +| `AgentRunStore` | operational Run events | At which model or tool stage did this Run do what, and where did it fail? | | `RuntimeEventStore` | canonical RuntimeEvents plus bounded partial snapshots | Which semantic facts occurred, and how should other state be rebuilt from them? | The current implementation is backed by SQLite rather than a directory per Run: `AgentRunStore` and `RuntimeEventStore` both sit on the same operational state database, and RuntimeEvents land in the `runtime_events` table. Order is carried by that table's `event_seq` under a `(invocation_id, event_seq)` uniqueness constraint, so sequence numbers never repeat within one correlated execution stream — that constraint is what "ordered log" means at the storage layer. Session, Turn, Run, and the compatibility correlation field each occupy their own column, so "what happened in this Turn of this Run of this Session" is an indexed lookup. @@ -343,18 +342,15 @@ Streaming text and thinking deltas are not appended forever to immutable JSONL. One of the hardest runtime failure classes is disagreement about whether an execution ended. For example: -- The Run header says completed, but the RuntimeEvent ledger has no terminal event; - the user stopped the Run, but a late complete event rewrites the Session to active; - the Backend stream exhausts without saying whether it succeeded or failed; -- the terminal event is durable, but the process crashes before updating the Run header. +- a second writer tries to end a Run that has already ended. Maka protects this core invariant: -> A terminal Run must have exactly one valid terminal RuntimeEvent, and a terminal Run header must be supported by that terminal fact. +> A Run ends exactly once, and its terminal RuntimeEvent is the only statement that it ended. -`AgentRun` therefore requires the terminal RuntimeEvent to be durable before committing a terminal Run header. A Backend stream without a terminal event becomes a `missing_terminal_event` failure. Duplicate terminal events are coalesced. Terminal events with a mismatched status, a different Run identity, or `partial: true` are rejected. - -If the terminal RuntimeEvent exists but an interrupted header remains `running`, the read model can treat the event as the stronger fact and recovery can repair the header. In the opposite direction, if a header claims termination without a trustworthy terminal fact, the system does not blindly trust the header; it conservatively repairs the Run as a `missing_terminal_event` failure. +There is no separate record of the outcome to keep in step, so a crash cannot leave one saying the Run finished while the other says it is still running. A Backend stream without a terminal event becomes a `missing_terminal_event` failure. Duplicate terminal events are coalesced. Terminal events with a mismatched status, a different Run identity, or `partial: true` are rejected. This invariant means recovery does not need to guess what the model intended to do next. It only needs to determine which facts are durable and converge all projections on one explainable outcome. @@ -362,7 +358,7 @@ This invariant means recovery does not need to guess what the model intended to ### User stop -`RuntimeKernel.stopSession()` first marks active `AgentRun`s as stopped, then calls Backend `stop()`. `AiSdkBackend` aborts the provider stream, ends any pending sandbox boundary or user question, and emits abort/complete events. Even if a provider later produces a complete or error event, `RuntimeKernel` and `AgentRun` do not allow it to overwrite the established aborted semantics. The stop source, such as the renderer stop button, is retained in the terminal fact and Run header for diagnostics. +`RuntimeKernel.stopSession()` first marks active `AgentRun`s as stopped, then calls Backend `stop()`. `AiSdkBackend` aborts the provider stream, ends any pending sandbox boundary or user question, and emits abort/complete events. Even if a provider later produces a complete or error event, `RuntimeKernel` and `AgentRun` do not allow it to overwrite the established aborted semantics. The stop source, such as the renderer stop button, is retained in the terminal fact for diagnostics. ### Provider or runtime error @@ -393,7 +389,7 @@ Continuing execution is a separate path. `safe_boundary_continuation` resumes fr - `AiSdkBackend` remains large and coordinates history, context budgets, tool availability, the step loop, usage, and telemetry. - The mapper is still a legacy-to-canonical bridge rather than consuming native RuntimeEvents from the Backend. - `SessionStore` and RuntimeEvent projection must cooperate for active and in-flight reads. -- Startup recovery performs deterministic termination and repair, not arbitrary warm resume. Continuation is a separate path: `safe_boundary_continuation` resumes from a verified safe boundary, is marked by `continuationSource` on the Run header, and is admitted and dispatched by `RuntimeKernel`; see [Chapter 8](./runtime-resume-architecture.md) for the difference. +- Startup recovery performs deterministic termination and repair, not arbitrary warm resume. Continuation is a separate path: `safe_boundary_continuation` resumes from a verified safe boundary, is marked by the continuation source on the invocation's opening fact, and is admitted and dispatched by `RuntimeKernel`; see [Chapter 8](./runtime-resume-architecture.md) for the difference. These are real architecture boundaries, not details to hide. Future Backend decomposition or checkpoint work must preserve request shape, tool visibility, event order, and the terminal invariant before optimizing for smaller files. diff --git a/docs/architecture/runtime-core-architecture-draft.zh-CN.md b/docs/architecture/runtime-core-architecture-draft.zh-CN.md index 8ac2ea9eeb..65d5c8698f 100644 --- a/docs/architecture/runtime-core-architecture-draft.zh-CN.md +++ b/docs/architecture/runtime-core-architecture-draft.zh-CN.md @@ -156,7 +156,7 @@ Maka 并不是在进程内实现了 Kafka,也没有声称 RuntimeEventStore > **Log is the source of truth; state is a materialized view.** -这一原则直接解释了后文最重要的 terminal invariant:Run header 不能凭自己宣布完成,它必须得到 terminal RuntimeEvent 的支持。 +这一原则直接解释了后文最重要的 terminal invariant:除了这次 Run 自己的 terminal RuntimeEvent,没有别的东西能宣布它结束。 ## 三种生命周期身份,加一个关联字段 @@ -221,13 +221,12 @@ flowchart LR `AgentRun` 让一次执行在持久世界里有身份和生命周期。开始运行时,它会: -1. 创建 `AgentRunHeader`,初始状态为 `created`; +1. 把这次 invocation 的开场事实作为 RuntimeEvent 提交; 2. 对顶层 Run 写入用户消息和 `running` Turn 投影; 3. 写入本轮初始用户 `RuntimeEvent`; 4. 锁定本 Session 的连接配置; 5. 确保 Backend 已创建并注册为活跃 Run; -6. 将 Run 标记为 `running`; -7. 从此前的 RuntimeEvent ledger 构造模型历史。 +6. 从此前的 RuntimeEvent ledger 构造模型历史。 运行过程中,`AgentRun` 同时接收旧的 `SessionEvent` 与新的 `RuntimeEvent`,并把它们写入各自所属的投影或账本。结束时,它注销活跃 Run、收敛 Session/Turn 状态,并提交最终 Run 状态。 @@ -321,12 +320,12 @@ AI SDK 的 step 是这个循环的自然节拍。Maka 会按 step 持久化 assi ## 一份语义事实,两类辅助状态 -Maka 当前同时维护三类持久数据。它们不是三个地位相同的“真相”,也不是重复保存同一份聊天。`RuntimeEventStore` 是 AI 交互的 canonical semantic log;另外两类存储承担产品投影与运行运维状态。 +Maka 当前同时维护三类持久数据。它们不是三个地位相同的“真相”,也不是重复保存同一份聊天。`RuntimeEventStore` 是 AI 交互的 canonical semantic log;另外两类存储承担产品投影,以及 Runtime 做过什么的运维记录。 | 存储 | 主要内容 | 它最适合回答的问题 | |---|---|---| | `SessionStore` | 用户、assistant、工具和 turn-state 等 `StoredMessage` | UI 与兼容接口要展示什么?活跃流有哪些即时投影? | -| `AgentRunStore` | Run header 与 operational Run events | 这次 Run 何时开始、当前状态、在哪个模型或工具阶段失败? | +| `AgentRunStore` | operational Run events | 这次 Run 在哪个模型或工具阶段做了什么、又在哪里失败? | | `RuntimeEventStore` | canonical RuntimeEvent 与有界 partial snapshots | Agent 交互发生过哪些语义事实,其他状态应如何重建? | 当前实现由 SQLite 承载,而不是每个 Run 一个目录:`AgentRunStore` 与 `RuntimeEventStore` 都建立在同一份 operational state 数据库之上,RuntimeEvent 落在 `runtime_events` 表。顺序由该表的 `event_seq` 承担,并以 `(invocation_id, event_seq)` 唯一约束保证一次 Invocation 内序号不重复——“有序日志”在存储层就是这条约束。四个身份各占一列,因此“这个 Session 的这次 Run 的这个 Turn 发生了什么”是一次索引查询。 @@ -341,18 +340,15 @@ Maka 当前同时维护三类持久数据。它们不是三个地位相同的“ Runtime 最容易出现的一类故障,是不同存储对“是否结束”给出不同答案。例如: -- Run header 写成 completed,但 RuntimeEvent ledger 没有 terminal event; - 用户已经 stop,但迟到的 complete 又把 Session 写回 active; - Backend 流耗尽,却从未说明它是成功还是失败; -- terminal event 已写入,但进程在更新 Run header 前崩溃。 +- 已经结束的 Run,又有第二个写入方想再结束它一次。 Maka 当前保护的核心不变量是: -> 一个终止的 Run 必须有且只有一个有效 terminal RuntimeEvent;终止的 Run header 必须能够由这个 terminal fact 支撑。 +> 一个 Run 只结束一次,而它的 terminal RuntimeEvent 是唯一说它结束了的事实。 -因此,`AgentRun` 在提交 terminal Run header 前,先要求 terminal RuntimeEvent 成功落盘。没有终态的 Backend stream 会被合成为 `missing_terminal_event` 失败;重复终态由 Kernel 合并;状态不匹配、来自其他 Run 或标记为 partial 的 terminal event 都会被拒绝。 - -如果 terminal RuntimeEvent 已经存在,但 Run header 因中断仍是 `running`,read model 可以把 terminal event 作为更强事实来解释运行结果,并在恢复时修复 header。反过来,如果 header 声称已经结束却没有可信 terminal fact,系统不会盲目信任 header,而会保守地修复为 `missing_terminal_event` 失败。 +因为结果没有第二份记录要同步,崩溃也就不可能留下一份说“已完成”、另一份说“还在跑”的状态。没有终态的 Backend stream 会被合成为 `missing_terminal_event` 失败;重复终态由 Kernel 合并;状态不匹配、来自其他 Run 或标记为 partial 的 terminal event 都会被拒绝。 这条不变量让恢复不必“猜模型当时准备做什么”。系统只需要判断哪些事实已经 durable,然后把各个投影收敛到同一个可解释终态。 @@ -360,7 +356,7 @@ Maka 当前保护的核心不变量是: ### 用户停止 -`RuntimeKernel.stopSession()` 会先把所有活跃 `AgentRun` 标记为 stopped,再调用 Backend 的 `stop()`。`AiSdkBackend` 会中止 provider stream、结束正在等待的 sandbox boundary 或用户提问,并产生 abort/complete 事件。即使 provider 随后发送迟到的 complete 或 error,RuntimeKernel 与 AgentRun 也不会允许它覆盖已经确定的 aborted 语义。停止来源,例如 renderer stop button,会进入 terminal fact 与 Run header,供诊断使用。 +`RuntimeKernel.stopSession()` 会先把所有活跃 `AgentRun` 标记为 stopped,再调用 Backend 的 `stop()`。`AiSdkBackend` 会中止 provider stream、结束正在等待的 sandbox boundary 或用户提问,并产生 abort/complete 事件。即使 provider 随后发送迟到的 complete 或 error,RuntimeKernel 与 AgentRun 也不会允许它覆盖已经确定的 aborted 语义。停止来源,例如 renderer stop button,会进入 terminal fact,供诊断使用。 ### Provider 或 Runtime 错误 @@ -391,7 +387,7 @@ Maka 当前保护的核心不变量是: - `AiSdkBackend` 仍然很重,同时组织 history、context budget、tool availability、step loop、usage 与 telemetry; - `SessionEvent Runtime mapper` 仍承担 legacy-to-canonical adapter 角色,而不是 Backend 原生产 canonical events; - `SessionStore` 与 RuntimeEvent projection 需要在 active/in-flight 场景中协同; -- 启动恢复是确定性终结与修复,不是从任意位置热续跑。续跑走另一条路:`safe_boundary_continuation` 从一个经过校验的安全边界接着跑,Run header 上是 `continuationSource`,由 `RuntimeKernel` 完成准入和 dispatch;两者的区别见[第八章](./runtime-resume-architecture.zh-CN.md)。 +- 启动恢复是确定性终结与修复,不是从任意位置热续跑。续跑走另一条路:`safe_boundary_continuation` 从一个经过校验的安全边界接着跑,它在 invocation 开场事实里记着自己的续跑来源,由 `RuntimeKernel` 完成准入和 dispatch;两者的区别见[第八章](./runtime-resume-architecture.zh-CN.md)。 这些不是应该隐藏的实现细节,而是当前架构的真实边界。未来拆分 Backend 或加入 checkpoint 时,首要目标不是减少文件行数,而是保持 request shape、工具可见性、事件顺序和 terminal invariant 不变。 diff --git a/docs/architecture/runtime-resume-architecture.md b/docs/architecture/runtime-resume-architecture.md index 89650fba4b..3b3cec5fb3 100644 --- a/docs/architecture/runtime-resume-architecture.md +++ b/docs/architecture/runtime-resume-architecture.md @@ -133,7 +133,7 @@ These three words are easy to mix up: | Term | Subject | Result | |---|---|---| -| Repair | Durable state of an old Run | Align terminal RuntimeEvent, Run header, and Turn state | +| Repair | Durable state of an old Run | Give an interrupted Run its terminal RuntimeEvent and align Turn state | | Resume / Continuation | A history boundary already proved safe | Create fresh identities and continue the provider loop | | Reconcile | A tool operation with T1 but no T2 outcome | Observe the external world and commit either completed or parked | @@ -204,7 +204,8 @@ Safety does not come merely from putting everything in SQLite. It comes from ass | Data | Nature | Purpose | |---|---|---| | Immutable `RuntimeEvent` | Canonical semantic fact | Model history, tool call/dispatch/outcome, recovery observation/decision, terminal fact | -| `AgentRunHeader` and AgentRun events | Durable operational envelope | Attempt identity, status, lineage, and diagnostics | +| Invocation opening fact | Immutable statement of one attempt | Identity, route, configuration, root authority, lineage | +| AgentRun events | Durable operational record | What the runtime did, stage by stage, and its diagnostics | | `tool_operations` | SQLite projection | Fast current-state lookup for an operation | | `tool_journal_events` | SQLite projection | Fast prepared/outcome/recovery transition lookup | | Session messages / Turn state | Product and UI projection | Conversation and Turn display, not recovery judgment | @@ -401,14 +402,11 @@ sequenceDiagram participant UI as Renderer App->>SM: recoverInterruptedSessions() - SM->>RS: list non-terminal / suspicious AgentRuns + SM->>ES: list invocations with no terminal event SM->>ES: read immutable RuntimeEvents - SM->>SM: compare terminal ledger and Run header - alt terminal RuntimeEvent exists, header lags - SM->>RS: repair the matching Run header - else no terminal RuntimeEvent - SM->>ES: commit recovered terminal RuntimeEvent first - SM->>RS: then commit matching failed/cancelled header + SM->>RS: read the operational events for the Run + alt no terminal RuntimeEvent + SM->>ES: commit a recovered terminal RuntimeEvent else ledger is ambiguous / unreadable SM-->>UI: preserve inspectable state and fail closed end @@ -418,9 +416,9 @@ sequenceDiagram The invariant is: -> The terminal RuntimeEvent commits before the terminal Run header. A header cannot declare completion without its semantic fact. +> A Run has ended exactly when its terminal RuntimeEvent is durable, and nothing else records that it ended. -A second crash between those commits remains repairable from the terminal event. Desktop also recovers Graph coordination. Automatic continuation is considered only after those repairs and only when the feature flag is enabled. +There is no second commit for a crash to land between. Desktop also recovers Graph coordination. Automatic continuation is considered only after those repairs and only when the feature flag is enabled. ## Phase 1: create a new execution at a safe boundary @@ -429,7 +427,7 @@ Phase 1 does not resolve unknown side effects. It continues only when every acce Planner gates include: - readable source Run and RuntimeEvent ledger; -- exactly one terminal event matching the Run header; +- exactly one terminal event for the source invocation; - one source execution identity across events; - Phase 0 `safe_replay`; - no pending permission; @@ -791,7 +789,7 @@ Eval does not resume or reconstruct Runtime execution. It asks Runtime Host to e 4. Atomically commit call, dispatch, and projection at T1. 5. Execute the external effect without a long database transaction. 6. Atomically commit T2 before publishing the result. -7. Commit terminal RuntimeEvent before terminal Run header. +7. End a Run by committing exactly one terminal RuntimeEvent. 8. On restart, repair the old Run first. 9. Resolve immutable facts into completed / not-dispatched / indeterminate / parked / corruption. 10. If a production reconciler exists, commit one atomic recovery bundle; otherwise park. diff --git a/docs/architecture/runtime-resume-architecture.zh-CN.md b/docs/architecture/runtime-resume-architecture.zh-CN.md index e180a54dd5..e4348a0061 100644 --- a/docs/architecture/runtime-resume-architecture.zh-CN.md +++ b/docs/architecture/runtime-resume-architecture.zh-CN.md @@ -133,7 +133,7 @@ flowchart TD | 词 | 处理对象 | 结果 | |---|---|---| -| Repair | 旧 Run 的持久化状态 | 补齐或对齐 terminal RuntimeEvent、Run header 和 Turn 状态 | +| Repair | 旧 Run 的持久化状态 | 给被中断的 Run 补上 terminal RuntimeEvent,并对齐 Turn 状态 | | Resume / Continuation | 一段已经证明安全的历史边界 | 创建新身份,继续 provider loop | | Reconcile | T1 已派发但没有 T2 outcome 的工具操作 | 观察外部世界,提交 completed 或 parked recovery decision | @@ -206,7 +206,8 @@ Resume 安全性的核心不是“数据都写进 SQLite”,而是每类数据 | 数据 | 性质 | 用途 | |---|---|---| | Immutable `RuntimeEvent` | canonical semantic fact | 模型历史、工具 call/dispatch/outcome、recovery observation/decision、terminal fact | -| `AgentRunHeader` 与 AgentRun events | durable operational envelope | 一次执行尝试的身份、状态、lineage、诊断 | +| invocation 开场事实 | 一次执行尝试的不可变声明 | 身份、route、配置、root authority、lineage | +| AgentRun events | durable operational record | Runtime 逐阶段做了什么,以及诊断 | | `tool_operations` | SQLite projection | 快速读取某个 operation 当前状态 | | `tool_journal_events` | SQLite projection | 快速查看 prepared/outcome/recovery 状态变化 | | Session messages / Turn state | 产品与 UI 投影 | 展示对话和 Turn 状态,不参与工具恢复裁决 | @@ -407,14 +408,11 @@ sequenceDiagram participant UI as Renderer App->>SM: recoverInterruptedSessions() - SM->>RS: 列出非终态 / 可疑 AgentRun + SM->>ES: 列出没有 terminal event 的 invocation SM->>ES: 读取 immutable RuntimeEvents - SM->>SM: 检查 terminal ledger 与 run header - alt 已有 terminal RuntimeEvent,header 落后 - SM->>RS: 修复 matching run header - else 没有 terminal RuntimeEvent - SM->>ES: 先提交 recovered terminal RuntimeEvent - SM->>RS: 再提交 matching failed/cancelled header + SM->>RS: 读取这次 Run 的 operational events + alt 没有 terminal RuntimeEvent + SM->>ES: 提交 recovered terminal RuntimeEvent else ledger ambiguous / unreadable SM-->>UI: 保留可检查状态,fail closed end @@ -424,9 +422,9 @@ sequenceDiagram 这里保护一个贯穿 Runtime 的不变量: -> terminal RuntimeEvent 必须先于 terminal Run header 提交;header 不能凭自己宣布一次执行已经结束。 +> 一次执行结束,当且仅当它的 terminal RuntimeEvent 已经落盘;没有别的东西记录它结束了。 -如果在两次提交之间再次崩溃,下次启动仍能从 terminal RuntimeEvent 修好 header。反过来先写 header,就会出现一个没有语义事实支持的“完成”状态。 +因为不存在第二次提交,崩溃也就没有可以落进去的缝隙。 Desktop 还会恢复 Graph coordinator 和 supervisor wake。只有这些 startup repair 完成,并且 safe-boundary flag 开启后,才会尝试自动 continuation。 @@ -437,7 +435,7 @@ Phase 1 不处理未知副作用。它只允许“所有工具都已经有 commi Planner 需要同时通过这些 gate: - source Run 与 RuntimeEvent ledger 可读; -- Run header 与唯一 terminal RuntimeEvent 一致; +- source invocation 有且只有一个 terminal event; - 所有事件属于同一个 source execution identity; - Phase 0 得到 `safe_replay`; - 没有 pending permission; @@ -525,7 +523,7 @@ Host 投影和 CLI 展示,不改变 planner、durable continuation claim 或 f 1. 不创建第二条相同的 user event; 2. 先提交一个 system-owned、model-invisible 的 continuation-start RuntimeEvent; -3. 在新 Run header 中记录 source identity 和 high-water; +3. 在新 invocation 的开场事实里记录 source identity 和 high-water; 4. 直接把验证过的 history 交给 provider。 这样既避免模型看到重复请求,也避免 completed tool call 因为“新建了一轮”而再次执行。 @@ -819,7 +817,7 @@ Eval 不恢复或重建 Runtime execution,只请求 Runtime Host 执行 Maka s 4. T1 原子提交 call、dispatch 和 projection。 5. 执行外部副作用,不持有数据库长事务。 6. T2 原子提交 outcome,再把结果交给模型。 -7. terminal RuntimeEvent 先提交,Run header 后提交。 +7. 一次执行只以提交唯一一个 terminal RuntimeEvent 来结束。 8. 崩溃重启后先 repair 旧 Run。 9. Resolver 只读 immutable facts,判定 completed / not-dispatched / indeterminate / parked / corruption。 10. 有 production reconciler 时,对 indeterminate 提交一个原子 recovery bundle;没有时 park。 diff --git a/docs/architecture/runtime-resume-extraction-ledger.zh-CN.md b/docs/architecture/runtime-resume-extraction-ledger.zh-CN.md index 340230e21e..e0a8d99af6 100644 --- a/docs/architecture/runtime-resume-extraction-ledger.zh-CN.md +++ b/docs/architecture/runtime-resume-extraction-ledger.zh-CN.md @@ -78,7 +78,7 @@ Phase 3B/4A 的 workspace checkpoint 是后续独立切片,不进入 PR A。 - SQLite 与 JSONL 共享唯一 lossless canonical RuntimeEvent codec;validator 消费 codec 返回的 event,store 持久化同一次编码返回的稳定 JSON bytes; - SQLite 对每个 invocation 强制唯一 `(sessionId, runId, turnId)` execution spine; -- JSONL immutable append 对 exact retry 物理去重,并在落盘前验证目标 Run header; +- JSONL immutable append 对 exact retry 物理去重,并在落盘前验证目标 invocation 身份; - projection-local journal ID 由 operation/event 派生,调用者不能选择; - schema 4 的 nullable-dispatch legacy projection 可读但隔离,不进入 recovery 或 canonical rebuild。 @@ -171,7 +171,7 @@ future newer schema -> fail closed | decoder canonical persistence 与有损 JSON 拒绝 | storage authority test | 已覆盖 | | nested undefined、provider `toJSON`、recovery evidence 改写 | storage authority test | 已覆盖 | | JSONL ordinary/tool exact retry 与 conflicting retry | JSONL storage test | 已覆盖 | -| JSONL event 与目标 Run header identity | JSONL storage test | 已覆盖 | +| JSONL event 与目标 invocation identity | JSONL storage test | 已覆盖 | | invocation 跨 session/run/turn 漂移 | core scanner + SQLite authority test | 已覆盖 | | unrelated session corruption 阻断新 session tool boundary | storage authority test | 已覆盖 | | corrupt ledger 上的 T1/T2/recovery exact retry | storage authority test | 已覆盖 | @@ -268,7 +268,7 @@ claim race 与 provider-call T1 测试,再补满足不变量的最小生产路 - **B1 — immutable boundary 与 replay**:物理 `event_seq`、canonical RuntimeEvent bytes、 segment digest、ordered manifest、provider replay digest; - **B2 — durable authority 与 provider T1**:SQLite unique claim、执行前完整重验证、 - exact target Run header、store-owned live start、一次性 admission proof/receipt,然后才允许 + exact target invocation、store-owned live start、一次性 admission proof/receipt,然后才允许 backend/provider 启动; - **B2.1 — pre-provider crash convergence**:claim-only/created-without-start 通过 deterministic repair start + terminal 收敛;normal start/no-terminal 无 owner proof 时只 park。 diff --git a/docs/architecture/runtime-resume-phase1-safe-boundary-contract.md b/docs/architecture/runtime-resume-phase1-safe-boundary-contract.md index 27f905e131..6472391cb2 100644 --- a/docs/architecture/runtime-resume-phase1-safe-boundary-contract.md +++ b/docs/architecture/runtime-resume-phase1-safe-boundary-contract.md @@ -55,11 +55,11 @@ The continuation-start event must be durable before the provider is called. ## Planner gates -`RuntimeContinuationPlanner` reads the source AgentRun and RuntimeEvent ledger. +`RuntimeContinuationPlanner` reads the source invocation and its RuntimeEvent ledger. The plan is `continue` only when all of the following are true: - the source run and RuntimeEvent ledger are readable; -- the run header has exactly one matching, non-partial terminal RuntimeEvent; +- the source invocation has exactly one matching, non-partial terminal RuntimeEvent; - every RuntimeEvent belongs to one source Session, Invocation, Run, and Turn; - the Phase 0 projection is `safe_replay`; - every accepted tool call has a committed matching response; @@ -111,10 +111,9 @@ from being executed merely because a new model turn was created. If continuation-start persistence fails: 1. the provider is not called; -2. no terminal AgentRun header is committed without a terminal RuntimeEvent; -3. the incomplete target Run remains recoverable; -4. existing startup recovery later writes a recovered terminal RuntimeEvent - and then commits the matching failed run header. +2. the incomplete target Run remains recoverable; +3. existing startup recovery later writes a recovered terminal RuntimeEvent, + which is the whole of ending that Run. The source ledger is never mutated by continuation execution. 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 a62763cc51..d62d783bbd 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 @@ -78,7 +78,7 @@ RuntimeEvent 是语义事实的唯一权威,但不能替代执行所有权的 11. strict args identity 明确处理 `__proto__` 并拒绝 sparse/accessor/custom array; 12. 唯一 canonical RuntimeEvent codec 负责 decode、normalization、strict JSON、稳定 bytes 与 lossless round-trip;SQLite/JSONL、未来 prefix digest 均复用它; -13. JSONL immutable exact retry 物理去重,写前验证 Run header identity; +13. JSONL immutable exact retry 物理去重,写前验证 invocation identity; 14. SQLite 强制一个 invocation 只对应一个 `(sessionId, runId, turnId)`; 15. journal ID 只由 store 派生;正式 schema 4 的无 dispatch legacy rows保守隔离。 @@ -184,7 +184,7 @@ schema 6 增加 `runtime_continuation_claims` 与 capability - immediate source execution identity、physical high-water、prefix digest; - provider projection version 与 provider replay digest; - fresh target session/invocation/run/turn; -- target Run 的完整、严格解码 `AgentRunHeader`(含 V2 continuation source); +- target invocation 的完整、严格解码开场事实(含 continuation source); - claim id、claimed-at、protocol version; - 可空、唯一的 continuation-start event id; - 与 start 同生存期的 store-owned `start_kind`:`runtime_admission | claim_repair`。 @@ -288,8 +288,8 @@ retry 入口。历史 `linked_child_resume` / `linked_child_provider_retry` desc `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 的 -`continuationSource` 必须与首条 continuation-start 完全一致。若当前执行使用 +digest、replay manifest、provider projection version 和 provider replay digest。target invocation 开场事实里的 +continuation source 必须与首条 continuation-start 完全一致。若当前执行使用 `t1_after_preflight_v1`,该 marker 也写在同一 event-seq 1;repair start 不得携带它。 只有 `RuntimeKernel` 能 dispatch durable continuation。AgentRun 仅在 live start 返回 diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index 0d81d3eb6d..203b1dbc96 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -201,9 +201,9 @@ export interface ExecutionAgentRunReader { export interface ExecutionRuntimeEventReader { /** - * Session run inventory read from the canonical events rather than the Run - * header table. Sits beside `listSessionRuns` so consumers can move one at a - * time; nothing writes or repairs it. + * A Session's run inventory, read from its canonical events. This is the + * definition of the inventory, not a cache of it, so nothing writes or + * repairs it. */ listSessionInvocations(sessionId: string): Promise; listSessionInvocationsBounded( From 9028bb86d81f4b3c604367f8279fe37fd5a09a28 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 13:05:00 +0800 Subject: [PATCH 19/46] style: apply Biome formatting Generated-by: Claude Code --- .../__tests__/execution-composition.test.ts | 6 +- .../execution-inspect-coordinator.test.ts | 15 +++- .../execution-model-composition.test.ts | 12 ++- .../src/__tests__/fixtures/seed-invocation.ts | 5 +- .../__tests__/root-turn-coordinator.test.ts | 51 +++++------ .../session-revision-two-client-uds.test.ts | 13 +-- .../session-transcript-reader.test.ts | 1 - .../src/server/execution-composition.ts | 20 ++--- .../server/execution-inspect-coordinator.ts | 6 +- .../src/server/root-turn-coordinator.ts | 5 +- .../__tests__/agent-graph-timeline.test.ts | 6 +- .../src/__tests__/agent-run-inspect.test.ts | 7 +- .../agent-run-steering-recovery.test.ts | 2 +- .../src/__tests__/context-diagnostics.test.ts | 10 +-- .../src/__tests__/conversation-copy.test.ts | 66 +++++++------- .../src/__tests__/execution-inspect.test.ts | 6 +- .../history-compact-checkpoint.test.ts | 66 +++++++++++--- .../src/__tests__/invocation-fixture.ts | 5 +- .../__tests__/runtime-continuation.test.ts | 5 +- .../runtime-event-read-model.test.ts | 12 +-- .../session-manager-terminal-ledger.test.ts | 19 +--- .../src/__tests__/session-manager.test.ts | 87 +++++++++++++------ .../__tests__/stream-graph-readiness.test.ts | 7 +- .../src/__tests__/stream-graph-trace.test.ts | 7 +- packages/runtime/src/agent-run.ts | 5 +- packages/runtime/src/runtime-ledger-repair.ts | 5 +- 26 files changed, 264 insertions(+), 185 deletions(-) diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index 21a95001a9..fb25da30a7 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -467,9 +467,9 @@ test('production recovery preserves legacy Automation history and closes an orph kind: 'legacy_automation', automationId: 'historical-automation', }); - const recoveredRun = ( - await stores.runtimeEventStore.listSessionInvocations(pending.id) - ).find((candidate) => candidate.runId === 'legacy-automation-run'); + const recoveredRun = (await stores.runtimeEventStore.listSessionInvocations(pending.id)).find( + (candidate) => candidate.runId === 'legacy-automation-run', + ); assert.ok(recoveredRun); assert.equal(recoveredRun && runtimeInvocationOutcome(recoveredRun), 'failed'); assert.deepEqual(recoveredRun?.opening.root, { diff --git a/packages/runtime-host/src/__tests__/execution-inspect-coordinator.test.ts b/packages/runtime-host/src/__tests__/execution-inspect-coordinator.test.ts index 2f3adeda30..e4713d69ed 100644 --- a/packages/runtime-host/src/__tests__/execution-inspect-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/execution-inspect-coordinator.test.ts @@ -202,7 +202,10 @@ describe('HostExecutionInspectCoordinator', () => { await withCoordinator(async ({ stores, coordinator }) => { const session = await stores.sessionStore.create(sessionInput('Large')); for (let index = 0; index <= EXECUTION_INSPECT_SESSION_MAX_RUNS; index += 1) { - await seedInvocation(stores.runtimeEventStore, runHeader(session.id, `run-${index}`, index)); + await seedInvocation( + stores.runtimeEventStore, + runHeader(session.id, `run-${index}`, index), + ); } const oversized = await coordinator.handlers['execution.inspect.query']( @@ -303,7 +306,10 @@ describe('HostExecutionInspectCoordinator', () => { await withCoordinator(async ({ stores, coordinator }) => { const session = await stores.sessionStore.create(sessionInput('Target Turn')); for (let index = 0; index <= EXECUTION_INSPECT_SESSION_MAX_RUNS; index += 1) { - await seedInvocation(stores.runtimeEventStore, runHeader(session.id, `unrelated-${index}`, index)); + await seedInvocation( + stores.runtimeEventStore, + runHeader(session.id, `unrelated-${index}`, index), + ); } const runId = 'target-run'; const turnId = `turn-${runId}`; @@ -447,7 +453,10 @@ describe('HostExecutionInspectCoordinator', () => { test('keeps earlier Session history reachable when one projected page exceeds the result limit', async () => { await withCoordinator(async ({ stores, coordinator }) => { const session = await stores.sessionStore.create(sessionInput('Oversized trace result')); - await seedInvocation(stores.runtimeEventStore, runHeader(session.id, 'oversized-result-run', 2)); + await seedInvocation( + stores.runtimeEventStore, + runHeader(session.id, 'oversized-result-run', 2), + ); for (let index = 0; index < 128; index += 1) { await stores.runtimeEventStore.appendRuntimeEvent(session.id, 'oversized-result-run', { ...runtimeEvent(session.id, 'oversized-result-run', index + 2), diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index 8d07a16f60..c6aa811904 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -2268,14 +2268,10 @@ test('production Host executes and durably supervises an Agent Graph over a real assert.equal(rootComposition?.contextWindow, 32_768); assert.match(rootComposition?.baseSystemPromptHash ?? '', /^sha256:[a-f0-9]{64}$/u); assert.ok(rootComposition?.toolNames.includes('view_agent_graph')); - const wakeRuns = runs.filter( - (run) => run.opening.root.kind === 'agent_graph_supervisor_wake', - ); + const wakeRuns = runs.filter((run) => run.opening.root.kind === 'agent_graph_supervisor_wake'); assert.ok(wakeRuns.length > 0); assert.ok(wakeRuns.every((run) => runtimeInvocationOutcome(run) === 'completed')); - assert.ok( - wakeRuns.every((run) => run.opening.configuration.orchestrationMode === 'graph'), - ); + assert.ok(wakeRuns.every((run) => run.opening.configuration.orchestrationMode === 'graph')); assert.equal(liveResidencies, 0); const sessions = await execution.sessionStore.listForRecovery(); @@ -2285,7 +2281,9 @@ test('production Host executes and durably supervises an Agent Graph over a real assert.ok(child); assert.equal(child?.subagentRuntime?.profile, 'local_read'); assert.equal(child?.subagentParent?.parentSessionId, session.id); - const childRuns = child ? await execution.runtimeEventStore.listSessionInvocations(child.id) : []; + const childRuns = child + ? await execution.runtimeEventStore.listSessionInvocations(child.id) + : []; assert.equal(childRuns.length, 1); assert.equal(childRuns[0] && runtimeInvocationOutcome(childRuns[0]), 'completed'); diff --git a/packages/runtime-host/src/__tests__/fixtures/seed-invocation.ts b/packages/runtime-host/src/__tests__/fixtures/seed-invocation.ts index d3196f99d7..ea66c0a86e 100644 --- a/packages/runtime-host/src/__tests__/fixtures/seed-invocation.ts +++ b/packages/runtime-host/src/__tests__/fixtures/seed-invocation.ts @@ -18,10 +18,7 @@ */ import { randomUUID } from 'node:crypto'; -import type { - RuntimeEvent, - RuntimeEventInvocationOpenedContent, -} from '@maka/core/runtime-event'; +import type { RuntimeEvent, RuntimeEventInvocationOpenedContent } from '@maka/core/runtime-event'; import { buildInvocationOpenedEvent, type RuntimeInvocationRecord, 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 90daec709a..215f7f5b94 100644 --- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts @@ -808,10 +808,7 @@ test('turn.start durably applies one exact per-Turn orchestration override', asy if (!started.ok) return; assertStartedTurn(started); - const run = await readInvocation(fixture.stores, - fixture.sessionId, - started.result.turn.runId, - ); + const run = await readInvocation(fixture.stores, fixture.sessionId, started.result.turn.runId); assert.equal(run.opening.configuration.orchestrationMode, 'swarm'); assert.equal(run.opening.configuration.orchestrationSource, 'turn_override'); assert.equal(run.opening.configuration.agentSwarmAuthorization, 'turn_override'); @@ -1842,7 +1839,10 @@ test('worktree child Sessions reject roots outside managed child execution', asy (await fixture.stores.agentRunStore.listRootTurnAdmissionsForRecovery(child.id)).length, 1, ); - assert.equal((await fixture.stores.runtimeEventStore.listSessionInvocations(child.id)).length, 1); + assert.equal( + (await fixture.stores.runtimeEventStore.listSessionInvocations(child.id)).length, + 1, + ); backend?.release(); await managed; @@ -1869,7 +1869,10 @@ test('worktree child Sessions reject roots outside managed child execution', asy () => recovery.recover(), /Unable to recover admitted Turn legacy-external-child-turn: operation_unavailable/, ); - assert.equal((await fixture.stores.runtimeEventStore.listSessionInvocations(child.id)).length, 1); + assert.equal( + (await fixture.stores.runtimeEventStore.listSessionInvocations(child.id)).length, + 1, + ); } finally { backend?.release(); await recoveryCoordinator?.close(); @@ -2097,10 +2100,7 @@ test('Agent Graph supervisor wake waits for root idle and binds one durable exec source: 'host_api', }); - const graphRun = await readInvocation(fixture.stores, - fixture.sessionId, - graphAdmission!.runId, - ); + const graphRun = await readInvocation(fixture.stores, fixture.sessionId, graphAdmission!.runId); assert.deepEqual(graphRun.opening.root, { kind: 'agent_graph_supervisor_wake', wakeId, @@ -2512,7 +2512,10 @@ test('Agent Graph supervisor wake revalidates freshness before durable root admi await fixture.stores.agentRunStore.listRootTurnAdmissionsForRecovery(fixture.sessionId), [], ); - assert.deepEqual(await fixture.stores.runtimeEventStore.listSessionInvocations(fixture.sessionId), []); + assert.deepEqual( + await fixture.stores.runtimeEventStore.listSessionInvocations(fixture.sessionId), + [], + ); assert.deepEqual(await fixture.stores.sessionStore.readMessages(fixture.sessionId), []); assert.equal(fixture.drainRequested(), false); } finally { @@ -3017,7 +3020,8 @@ test('hosted linked child roots share admission, message, terminal, and stop aut const joinedInterrupted = await joinedInitial; assert.equal(interrupted.status, 'cancelled'); assert.deepEqual(joinedInterrupted, interrupted); - const interruptedRun = await readInvocation(stores, + const interruptedRun = await readInvocation( + stores, interrupted.childSessionId, interrupted.runId, ); @@ -4487,10 +4491,7 @@ test('post-start backend failure closes its owner without draining an unrelated runId: unrelatedStarted.result.turn.runId, }); assert.equal(unrelatedBackend.stopCount, 0); - const run = await readInvocation(fixture.stores, - fixture.sessionId, - started.result.turn.runId, - ); + const run = await readInvocation(fixture.stores, fixture.sessionId, started.result.turn.runId); const events = await fixture.stores.runtimeEventStore.readImmutableRuntimeEvents( fixture.sessionId, started.result.turn.runId, @@ -4665,10 +4666,7 @@ test('post-start backend AggregateError is contained after its failed terminal t await waitUntil(() => fixture.coordinator.readRootState(fixture.sessionId).kind === 'idle'); assert.equal(fixture.drainRequested(), false); - const run = await readInvocation(fixture.stores, - fixture.sessionId, - started.result.turn.runId, - ); + const run = await readInvocation(fixture.stores, fixture.sessionId, started.result.turn.runId); const events = await fixture.stores.runtimeEventStore.readImmutableRuntimeEvents( fixture.sessionId, started.result.turn.runId, @@ -4684,7 +4682,9 @@ test('post-start backend AggregateError is contained after its failed terminal t if (queried.ok && queried.result.status === 'failed') { assert.equal( queried.result.failureMessage, - run.terminalEvent?.content?.kind === 'error' ? run.terminalEvent.content.message : undefined, + run.terminalEvent?.content?.kind === 'error' + ? run.terminalEvent.content.message + : undefined, ); assert.ok(queried.result.failureMessage); } @@ -4743,10 +4743,7 @@ test('post-start message owner cleanup failure drains after its failed terminal await waitUntil(() => fixture.drainRequested()); await waitUntil(() => fixture.coordinator.readRootState(fixture.sessionId).kind === 'idle'); - const run = await readInvocation(fixture.stores, - fixture.sessionId, - started.result.turn.runId, - ); + const run = await readInvocation(fixture.stores, fixture.sessionId, started.result.turn.runId); const events = await fixture.stores.runtimeEventStore.readImmutableRuntimeEvents( fixture.sessionId, started.result.turn.runId, @@ -5028,9 +5025,7 @@ async function seedPendingSafeBoundaryContinuation( orchestrationMode: sourceOrchestrationMode, orchestrationSource: 'session' as const, agentSwarmAuthorization: - sourceOrchestrationMode === 'swarm' - ? ('session_mode' as const) - : ('none' as const), + sourceOrchestrationMode === 'swarm' ? ('session_mode' as const) : ('none' as const), } : { orchestrationMode: 'default' as const, orchestrationSource: 'session' as const }), }, diff --git a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts index 18f0f4f1ef..143518a759 100644 --- a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts @@ -926,10 +926,7 @@ async function seedSource( persistedContinuationChild?.opening.lineage?.agentId, continuationChild.opening?.lineage?.agentId, ); - assert.deepEqual( - persistedContinuationChild?.opening.source, - continuationChild.opening?.source, - ); + assert.deepEqual(persistedContinuationChild?.opening.source, continuationChild.opening?.source); const artifact = await artifacts.create({ id: 'source-artifact', sessionId: source.id, @@ -1793,7 +1790,10 @@ async function verifyDurableBranch( ); assert.equal((await artifacts.listPage('revision-target', { offset: 0, limit: 10 })).total, 0); assert.deepEqual(await todos.readOrBootstrap('revision-target'), { items: [] }); - assert.deepEqual(await execution.runtimeEventStore.listSessionInvocations('revision-target'), []); + assert.deepEqual( + await execution.runtimeEventStore.listSessionInvocations('revision-target'), + [], + ); await assert.rejects( () => execution.sessionStore.readHeaderSnapshot('revision-target'), /not found/i, @@ -1930,7 +1930,8 @@ async function verifyDurableBranch( assert.equal(graphResult.content.items[0]?.childSessionId, graphChildSessionId); assert.equal(graphResult.content.items[0]?.runId, 'graph-child-run'); assert.deepEqual(graphResult.content.items[0]?.artifactIds, ['graph-child-artifact']); - const graphRevisionRuns = await execution.runtimeEventStore.listSessionInvocations(graphRevisionTargetId); + const graphRevisionRuns = + await execution.runtimeEventStore.listSessionInvocations(graphRevisionTargetId); const graphRevisionRun = graphRevisionRuns.find((run) => run.turnId === 'linked-turn'); assert.ok(graphRevisionRun); const graphRuntimeResult = ( diff --git a/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts b/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts index 3892ee79b2..ac58fbb283 100644 --- a/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts +++ b/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts @@ -330,7 +330,6 @@ test('stops an oversized active projection before retaining the full RuntimeEven assert.equal(visited, 8_193); }); - function runtimeEvent(sessionId: string, overrides: Partial): RuntimeEvent { return { id: 'event-1', diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 2fbaba0ba6..bb9145e053 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -1273,16 +1273,16 @@ export async function createExecutionRuntimeHostComposition( startTurn: (sessionId, input, _activity, abortSignal, isCurrent) => graphExecutions.run(sessionId, input, abortSignal, isCurrent), inspectAttempt: async (rootSessionId, attemptId, turnId) => { - const runs = ( - await stores.runtimeEventStore.listSessionInvocations(rootSessionId) - ).filter((run) => { - const root = run.opening.root; - return ( - root.kind === 'agent_graph_supervisor_wake' && - root.attemptId === attemptId && - run.turnId === turnId - ); - }); + const runs = (await stores.runtimeEventStore.listSessionInvocations(rootSessionId)).filter( + (run) => { + const root = run.opening.root; + return ( + root.kind === 'agent_graph_supervisor_wake' && + root.attemptId === attemptId && + run.turnId === turnId + ); + }, + ); if (runs.length > 1) { throw new Error( `Agent graph supervisor wake attempt ${attemptId} has multiple AgentRuns`, diff --git a/packages/runtime-host/src/server/execution-inspect-coordinator.ts b/packages/runtime-host/src/server/execution-inspect-coordinator.ts index 49961dba32..72f62d7b1a 100644 --- a/packages/runtime-host/src/server/execution-inspect-coordinator.ts +++ b/packages/runtime-host/src/server/execution-inspect-coordinator.ts @@ -236,7 +236,11 @@ export class HostExecutionInspectCoordinator { const candidatePage: ExecutionInspectQueryResult = { kind: 'session_trace_page', ...candidateTrace, - nextCursor: tracePageCursorAfter(runPage.invocations, candidateRunCount, runPage.nextCursor), + nextCursor: tracePageCursorAfter( + runPage.invocations, + candidateRunCount, + runPage.nextCursor, + ), }; if ( candidateTrace.turns.length > EXECUTION_INSPECT_TRACE_PAGE_MAX_TURNS || diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index 0060331933..b99e23d397 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -2688,9 +2688,8 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { if (admission.execution.kind === 'context_compact') return undefined; const mode = admission.execution.kind === 'safe_boundary_continuation' - ? (( - await this.readRunIfPresent(admission.sessionId, admission.execution.sourceRunId) - )?.opening.configuration.orchestrationMode ?? + ? ((await this.readRunIfPresent(admission.sessionId, admission.execution.sourceRunId)) + ?.opening.configuration.orchestrationMode ?? resolveEffectiveOrchestration(session.orchestrationMode, undefined).mode) : resolveEffectiveOrchestration(session.orchestrationMode, admission.turnOrchestration) .mode; diff --git a/packages/runtime/src/__tests__/agent-graph-timeline.test.ts b/packages/runtime/src/__tests__/agent-graph-timeline.test.ts index 444a1ff052..ed82fd2ce5 100644 --- a/packages/runtime/src/__tests__/agent-graph-timeline.test.ts +++ b/packages/runtime/src/__tests__/agent-graph-timeline.test.ts @@ -598,7 +598,11 @@ function runInvocation(input: { toolMode: 'direct', }, root: input.wake - ? { kind: 'agent_graph_supervisor_wake', wakeId: input.wake.wakeId, attemptId: input.wake.attemptId } + ? { + kind: 'agent_graph_supervisor_wake', + wakeId: input.wake.wakeId, + attemptId: input.wake.attemptId, + } : { kind: 'user' }, source: { kind: 'fresh' }, }, diff --git a/packages/runtime/src/__tests__/agent-run-inspect.test.ts b/packages/runtime/src/__tests__/agent-run-inspect.test.ts index 8d7302a7a0..c9b50efbba 100644 --- a/packages/runtime/src/__tests__/agent-run-inspect.test.ts +++ b/packages/runtime/src/__tests__/agent-run-inspect.test.ts @@ -48,7 +48,11 @@ describe('inspectAgentRunReadModel', () => { opening: makeOpening(), }), ); - await runStore.appendEvent(sessionId, runId, makeRunEvent({ type: 'turn_started', ts: ts + 1 })); + await runStore.appendEvent( + sessionId, + runId, + makeRunEvent({ type: 'turn_started', ts: ts + 1 }), + ); await runStore.appendEvent( sessionId, runId, @@ -154,7 +158,6 @@ describe('inspectAgentRunReadModel', () => { true, ); }); - }); class MemoryAgentRunStore implements AgentRunStore, RuntimeEventStore { 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 bfe557a822..f4546f60ff 100644 --- a/packages/runtime/src/__tests__/agent-run-steering-recovery.test.ts +++ b/packages/runtime/src/__tests__/agent-run-steering-recovery.test.ts @@ -362,7 +362,7 @@ test('recovers a steering transcript message from the committed RuntimeEvent led runtimeEventStore: recoveredRuntimeEventStore, readMessages: (sessionId) => recoveredStore.readMessages(sessionId), appendMessage: (sessionId, message) => recoveredStore.appendMessage(sessionId, message), - newId: () => 'unused-id', + newId: () => 'unused-id', now: () => 10, }); diff --git a/packages/runtime/src/__tests__/context-diagnostics.test.ts b/packages/runtime/src/__tests__/context-diagnostics.test.ts index 3c20eb546c..0b563a3788 100644 --- a/packages/runtime/src/__tests__/context-diagnostics.test.ts +++ b/packages/runtime/src/__tests__/context-diagnostics.test.ts @@ -23,11 +23,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; import { DatabaseSync } from 'node:sqlite'; -import type { - AgentRunEvent, - AgentRunStore, - EmittedAgentRunEvent, -} from '@maka/core/agent-run'; +import type { AgentRunEvent, AgentRunStore, EmittedAgentRunEvent } from '@maka/core/agent-run'; import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; import { readLatestContextDiagnostics } from '../context-diagnostics.js'; import { readLatestContextSnapshot } from '../latest-context-snapshot.js'; @@ -1019,12 +1015,10 @@ function runStore( runs: Array<{ runId: string; events: AgentRunEvent[] }>, ): Pick { return { - readEvents: async (_sessionId, runId) => - runs.find((run) => run.runId === runId)?.events ?? [], + readEvents: async (_sessionId, runId) => runs.find((run) => run.runId === runId)?.events ?? [], }; } - function attemptEvent( runId: string, attemptId: string, diff --git a/packages/runtime/src/__tests__/conversation-copy.test.ts b/packages/runtime/src/__tests__/conversation-copy.test.ts index 026f7975ff..7fd249c2aa 100644 --- a/packages/runtime/src/__tests__/conversation-copy.test.ts +++ b/packages/runtime/src/__tests__/conversation-copy.test.ts @@ -23,10 +23,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; import type { AgentRunStore, EmittedAgentRunEvent } from '@maka/core/agent-run'; -import type { - RuntimeEvent, - RuntimeEventInvocationOpenedContent, -} from '@maka/core/runtime-event'; +import type { RuntimeEvent, RuntimeEventInvocationOpenedContent } from '@maka/core/runtime-event'; import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; import type { StoredMessage } from '@maka/core/session'; import { decodeCanonicalToolResultContent } from '@maka/core/tool-result-record-schema'; @@ -1286,11 +1283,11 @@ test('conversation copy rewrites a complete tool recovery bundle atomically', as try { await runStore.ready?.(); await seedRun(runtimeEventStore, { - runId: 'run-source', - invocationId: 'invocation-source', - turnId: 'turn-1', - cwd: root, - }); + runId: 'run-source', + invocationId: 'invocation-source', + turnId: 'turn-1', + cwd: root, + }); const sourceEvents: RuntimeEvent[] = [ runtimeEvent({ id: 'event-user', @@ -1492,11 +1489,11 @@ test('conversation copy rewrites the parent operation id of a nested Code Mode c try { await runStore.ready?.(); await seedRun(runtimeEventStore, { - runId: 'run-source', - invocationId: 'invocation-source', - turnId: 'turn-1', - cwd: root, - }); + runId: 'run-source', + invocationId: 'invocation-source', + turnId: 'turn-1', + cwd: root, + }); const sourceEvents: RuntimeEvent[] = [ runtimeEvent({ id: 'event-user', @@ -1639,11 +1636,11 @@ test('conversation copy rewrites the nested identity of a model call attempt', a const runStore = createSqliteAgentRunStore(root); const runtimeEventStore = createWorkspaceRuntimeStore(root); await seedRun(runtimeEventStore, { - runId: 'run-source', - invocationId: 'invocation-source', - turnId: 'turn-1', - cwd: root, - }); + runId: 'run-source', + invocationId: 'invocation-source', + turnId: 'turn-1', + cwd: root, + }); for (const event of [ runtimeEvent({ id: 'event-user', @@ -1743,11 +1740,11 @@ test('conversation copy repairs a model call attempt stranded by a pre-fix copy' const runStore = createSqliteAgentRunStore(root); const runtimeEventStore = createWorkspaceRuntimeStore(root); await seedRun(runtimeEventStore, { - runId: 'run-source', - invocationId: 'invocation-source', - turnId: 'turn-1', - cwd: root, - }); + runId: 'run-source', + invocationId: 'invocation-source', + turnId: 'turn-1', + cwd: root, + }); for (const event of [ runtimeEvent({ id: 'event-user', @@ -2143,7 +2140,10 @@ test('conversation copy clones one terminal Runtime ledger with new owned identi }), /missing Artifact artifact-deleted/, ); - assert.deepEqual(await runtimeEventStore.listSessionInvocations('session-missing-artifact'), []); + assert.deepEqual( + await runtimeEventStore.listSessionInvocations('session-missing-artifact'), + [], + ); // A copied run and its copied invocation share one fresh identity, so the // copy mints one id here rather than two. const ids = [ @@ -2815,11 +2815,11 @@ test('conversation copy rebuilds projection transitions against the copied event const runStore = createSqliteAgentRunStore(root); const runtimeEventStore = createWorkspaceRuntimeStore(root); await seedRun(runtimeEventStore, { - runId: 'run-source', - invocationId: 'invocation-source', - turnId: 'turn-1', - cwd: root, - }); + runId: 'run-source', + invocationId: 'invocation-source', + turnId: 'turn-1', + cwd: root, + }); const resultEvent = runtimeEvent({ id: 'event-result', ts: 2, @@ -2980,7 +2980,8 @@ test('conversation copy carries a transition recorded by a later, uncopied run', ['run-first', 'turn-1'], ['run-second', 'turn-2'], ]) { - await seedRun(runtimeEventStore, + await seedRun( + runtimeEventStore, runFacts({ runId, invocationId: `invocation-${runId}`, @@ -3148,7 +3149,8 @@ test('conversation copy reproduces the source fold rather than re-deciding it', ['run-first', 'turn-1'], ['run-second', 'turn-2'], ]) { - await seedRun(runtimeEventStore, + await seedRun( + runtimeEventStore, runFacts({ runId, invocationId: `invocation-${runId}`, turnId, cwd: root }), ); } diff --git a/packages/runtime/src/__tests__/execution-inspect.test.ts b/packages/runtime/src/__tests__/execution-inspect.test.ts index dc1ea95149..47a7750652 100644 --- a/packages/runtime/src/__tests__/execution-inspect.test.ts +++ b/packages/runtime/src/__tests__/execution-inspect.test.ts @@ -22,11 +22,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, test } from 'node:test'; -import type { - AgentRunEvent, - AgentRunEventType, - EmittedAgentRunEvent, -} from '@maka/core/agent-run'; +import type { AgentRunEvent, AgentRunEventType, EmittedAgentRunEvent } from '@maka/core/agent-run'; import { buildInvocationOpenedEvent } from '@maka/core/runtime-invocation'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { createSessionStore } from '@maka/storage/session-store'; diff --git a/packages/runtime/src/__tests__/history-compact-checkpoint.test.ts b/packages/runtime/src/__tests__/history-compact-checkpoint.test.ts index d74ca4a6af..b4acf44305 100644 --- a/packages/runtime/src/__tests__/history-compact-checkpoint.test.ts +++ b/packages/runtime/src/__tests__/history-compact-checkpoint.test.ts @@ -406,7 +406,11 @@ describe('history compact checkpoint', () => { ]), ); - const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1', runIds); + const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger( + store, + 'session-1', + runIds, + ); assert.equal(loaded?.checkpointId, latest.checkpointId); assert.deepEqual( @@ -499,7 +503,11 @@ describe('history compact checkpoint', () => { new Map([['run-1', [checkpointEvent('ledger-v3', 'run-1', checkpoint, 20)]]]), ); - const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1', runIds); + const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger( + store, + 'session-1', + runIds, + ); assert.deepEqual(loaded, checkpoint); assert.equal( @@ -535,7 +543,11 @@ describe('history compact checkpoint', () => { ]), ); - const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1', runIds); + const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger( + store, + 'session-1', + runIds, + ); assert.equal(loaded?.checkpointId, valid.checkpointId); }); @@ -564,7 +576,11 @@ describe('history compact checkpoint', () => { ]), ); - const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1', runIds); + const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger( + store, + 'session-1', + runIds, + ); assert.equal(loaded?.checkpointId, valid.checkpointId); }); @@ -649,7 +665,11 @@ describe('history compact checkpoint', () => { ]), ); - const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1', runIds); + const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger( + store, + 'session-1', + runIds, + ); assert.equal(loaded?.checkpointId, valid.checkpointId); }); @@ -685,7 +705,9 @@ describe('history compact checkpoint', () => { readEvents: async () => [canonicalEvent], }; - const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1', ['run-canonical']); + const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1', [ + 'run-canonical', + ]); assert.equal(loaded?.checkpointId, valid.checkpointId); assert.deepEqual(replacedEventIds, [poisonedProjection.id]); @@ -712,7 +734,11 @@ describe('history compact checkpoint', () => { ]), ); - const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1', runIds); + const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger( + store, + 'session-1', + runIds, + ); assert.equal(loaded?.checkpointId, furthest.checkpointId); }); @@ -758,7 +784,11 @@ describe('history compact checkpoint', () => { ], ]), ); - const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1', runIds); + const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger( + store, + 'session-1', + runIds, + ); assert.equal(loaded?.checkpointId, tip.checkpointId); }); @@ -778,7 +808,9 @@ describe('history compact checkpoint', () => { }, }; - const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1', ['run-canonical']); + const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1', [ + 'run-canonical', + ]); assert.equal(loaded?.checkpointId, checkpoint.checkpointId); }); @@ -791,7 +823,9 @@ describe('history compact checkpoint', () => { }, }; - const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1', ['run-canonical']); + const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1', [ + 'run-canonical', + ]); assert.equal(loaded, undefined); }); @@ -820,7 +854,9 @@ describe('history compact checkpoint', () => { readEvents: async () => [event], }; - const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1', ['run-canonical']); + const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1', [ + 'run-canonical', + ]); assert.equal(loaded?.checkpointId, checkpoint.checkpointId); assert.deepEqual(repaired, [event]); @@ -843,7 +879,9 @@ describe('history compact checkpoint', () => { readEvents: async () => [event], }; - const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1', ['run-canonical']); + const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1', [ + 'run-canonical', + ]); assert.equal(loaded?.checkpointId, checkpoint.checkpointId); assert.equal(repaired, false); @@ -878,7 +916,9 @@ describe('history compact checkpoint', () => { readEvents: async () => [canonicalEvent], }; - const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1', ['run-canonical']); + const loaded = await loadLatestHistoryCompactCheckpointFromRunLedger(store, 'session-1', [ + 'run-canonical', + ]); assert.equal(loaded?.checkpointId, checkpoint.checkpointId); assert.deepEqual(replacedEventIds, [invalidProjection.id]); diff --git a/packages/runtime/src/__tests__/invocation-fixture.ts b/packages/runtime/src/__tests__/invocation-fixture.ts index d3196f99d7..ea66c0a86e 100644 --- a/packages/runtime/src/__tests__/invocation-fixture.ts +++ b/packages/runtime/src/__tests__/invocation-fixture.ts @@ -18,10 +18,7 @@ */ import { randomUUID } from 'node:crypto'; -import type { - RuntimeEvent, - RuntimeEventInvocationOpenedContent, -} from '@maka/core/runtime-event'; +import type { RuntimeEvent, RuntimeEventInvocationOpenedContent } from '@maka/core/runtime-event'; import { buildInvocationOpenedEvent, type RuntimeInvocationRecord, diff --git a/packages/runtime/src/__tests__/runtime-continuation.test.ts b/packages/runtime/src/__tests__/runtime-continuation.test.ts index b8071e6237..ec4ab63770 100644 --- a/packages/runtime/src/__tests__/runtime-continuation.test.ts +++ b/packages/runtime/src/__tests__/runtime-continuation.test.ts @@ -26,10 +26,7 @@ import { runtimePrefixSegment, type ImmutableRuntimePrefixV1, } from '@maka/core/runtime-boundary'; -import type { - RuntimeEvent, - RuntimeEventInvocationOpenedContent, -} from '@maka/core/runtime-event'; +import type { RuntimeEvent, RuntimeEventInvocationOpenedContent } from '@maka/core/runtime-event'; import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import { createLocalContinuationSafetyInspector } from '../continuation-safety.js'; diff --git a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts index 27513055dc..40b4a7abbe 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -52,9 +52,7 @@ function endedAs( terminalEvent: { ...invocation.terminalEvent!, status, - ...(failureClass - ? { actions: { endInvocation: true, stateDelta: { failureClass } } } - : {}), + ...(failureClass ? { actions: { endInvocation: true, stateDelta: { failureClass } } } : {}), }, }; } @@ -1340,7 +1338,9 @@ describe('projectRuntimeEventsToStoredMessages', () => { // owns no chat row, so a broken one costs a reader nothing the session view // would otherwise show. test(`a sandbox boundary ${name} stays unclaimed`, () => { - const out = projectRuntimeEventsToStoredMessages([makeEvent()], { invocations: [invocation] }); + const out = projectRuntimeEventsToStoredMessages([makeEvent()], { + invocations: [invocation], + }); assert.deepStrictEqual(out.messages, []); assert.deepStrictEqual( @@ -2176,7 +2176,9 @@ describe('compareRuntimeReadModelMessages', () => { }); test('rejects missing tool result and assistant text cases', () => { - const projected = projectRuntimeEventsToStoredMessages(baseEvents(), { invocations: [invocation] }); + const projected = projectRuntimeEventsToStoredMessages(baseEvents(), { + invocations: [invocation], + }); const missing = projected.messages.filter( (message) => message.type !== 'tool_result' && message.type !== 'assistant', ); diff --git a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts index 9401ac3ab4..eb322865f1 100644 --- a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts +++ b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts @@ -280,10 +280,7 @@ describe('SessionManager terminal ledger invariants', () => { const [run] = await runStore.listSessionInvocations(session.id); if (!run) throw new Error('run was not recorded'); assert.strictEqual(runtimeInvocationOutcome(run), 'cancelled'); - assert.strictEqual( - run.terminalEvent?.actions?.stateDelta?.abortSource, - 'renderer.stop_button', - ); + assert.strictEqual(run.terminalEvent?.actions?.stateDelta?.abortSource, 'renderer.stop_button'); const terminalEvents = (await runStore.readRuntimeEvents(session.id, run.runId)).filter( isTerminalRuntimeEvent, ); @@ -350,10 +347,7 @@ describe('SessionManager terminal ledger invariants', () => { const [run] = await runStore.listSessionInvocations(session.id); if (!run) throw new Error('run was not recorded'); assert.strictEqual(runtimeInvocationOutcome(run), 'cancelled'); - assert.strictEqual( - run.terminalEvent?.actions?.stateDelta?.abortSource, - 'renderer.stop_button', - ); + assert.strictEqual(run.terminalEvent?.actions?.stateDelta?.abortSource, 'renderer.stop_button'); const terminalEvents = (await runStore.readRuntimeEvents(session.id, run.runId)).filter( isTerminalRuntimeEvent, ); @@ -1198,9 +1192,7 @@ describe('SessionManager terminal ledger invariants', () => { 'missing_terminal_event', ); assert.strictEqual(terminalEvents[0]?.actions?.stateDelta?.recovered, undefined); - await new RuntimeReadModel({ runtimeEventStore: runStore }).getSessionView( - session.id, - ); + await new RuntimeReadModel({ runtimeEventStore: runStore }).getSessionView(session.id); }); test('direct AgentRun stop synthesizes a cancelled terminal fact when no terminal event was recorded', async () => { @@ -1254,9 +1246,7 @@ describe('SessionManager terminal ledger invariants', () => { assert.strictEqual(terminalEvents[0]?.actions?.stateDelta?.abortSource, 'renderer.stop_button'); assert.strictEqual(terminalEvents[0]?.actions?.stateDelta?.failureClass, undefined); assert.strictEqual(terminalEvents[0]?.actions?.stateDelta?.recovered, undefined); - await new RuntimeReadModel({ runtimeEventStore: runStore }).getSessionView( - session.id, - ); + await new RuntimeReadModel({ runtimeEventStore: runStore }).getSessionView(session.id); }); test('a stop settlement racing finalize commits exactly one terminal run event', async () => { @@ -2061,7 +2051,6 @@ describe('SessionManager terminal ledger invariants', () => { ['rt-completed', 'rt-failed'], ); }); - }); type ScriptEvent = diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 1b480805d8..90e2b21eb8 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -598,7 +598,8 @@ describe('SessionManager graph operator provisioning', () => { } as never, }); const parent = await manager.createSession(makeInput({ permissionMode: 'bypass' })); - await seedInvocationFromHeader(runStore, + await seedInvocationFromHeader( + runStore, makeRunHeader({ sessionId: parent.id, runId: 'supervisor-run', @@ -687,7 +688,8 @@ describe('SessionManager graph operator provisioning', () => { permissionMode: 'ask', }), ); - await seedInvocationFromHeader(runStore, + await seedInvocationFromHeader( + runStore, makeRunHeader({ sessionId: parent.id, runId: 'supervisor-run', @@ -743,7 +745,8 @@ describe('SessionManager graph operator provisioning', () => { now: nextNow(90), }); const parent = await manager.createSession(makeInput({ permissionMode: 'ask' })); - await seedInvocationFromHeader(runStore, + await seedInvocationFromHeader( + runStore, makeRunHeader({ sessionId: parent.id, runId: 'large-supervisor-run', @@ -957,7 +960,8 @@ describe('SessionManager graph operator provisioning', () => { permissionMode: 'ask', }), ); - await seedInvocationFromHeader(runStore, + await seedInvocationFromHeader( + runStore, makeRunHeader({ sessionId: parent.id, runId: 'supervisor-run', @@ -1482,7 +1486,8 @@ describe('SessionManager claimed graph intent execution', () => { }, 'must not be backfilled', ); - await seedInvocationFromHeader(runStore, + await seedInvocationFromHeader( + runStore, makeRunHeader({ sessionId: child.id, runId: claim.targetRunId, @@ -1522,7 +1527,10 @@ describe('SessionManager claimed graph intent execution', () => { assert.strictEqual(hostedExecutions, 0); assert.strictEqual(backendBuilds, 0); assert.deepStrictEqual(await store.readMessages(child.id), []); - assert.strictEqual(runtimeInvocationOutcome(await readInvocation(runStore, child.id, claim.targetRunId)), 'completed'); + assert.strictEqual( + runtimeInvocationOutcome(await readInvocation(runStore, child.id, claim.targetRunId)), + 'completed', + ); }); test('hosted explicit abort stops only the exact claimed root identity', async () => { @@ -1938,7 +1946,10 @@ describe('SessionManager claimed graph intent execution', () => { assert.strictEqual(result.status, 'cancelled'); assert.strictEqual(backend?.stopCalls, 1); assert.strictEqual(backend?.sendInputs?.length, 1); - assert.strictEqual(runtimeInvocationOutcome(await readInvocation(runStore, child.id, claim.targetRunId)), 'cancelled'); + assert.strictEqual( + runtimeInvocationOutcome(await readInvocation(runStore, child.id, claim.targetRunId)), + 'cancelled', + ); }); test('runtime stop settles queued graph claims without letting their slots pass the active claim', async () => { @@ -2100,7 +2111,10 @@ describe('SessionManager claimed graph intent execution', () => { const [result] = await Promise.all([executing, stopping]); assert.strictEqual(result.status, 'cancelled'); assert.deepStrictEqual(backend?.sendInputs, []); - assert.strictEqual(runtimeInvocationOutcome(await readInvocation(runStore, child.id, claim.targetRunId)), 'cancelled'); + assert.strictEqual( + runtimeInvocationOutcome(await readInvocation(runStore, child.id, claim.targetRunId)), + 'cancelled', + ); assert.strictEqual((await store.readHeader(child.id)).status === 'blocked', false); }); @@ -2157,7 +2171,8 @@ describe('SessionManager claimed graph intent execution', () => { const child = await createGraphOperatorSession(store, parent.id); const claim = graphIntentClaim({ targetSessionId: child.id }, 'must not run'); - await seedInvocationFromHeader(runStore, + await seedInvocationFromHeader( + runStore, makeRunHeader({ sessionId: child.id, runId: 'different-run', @@ -2585,7 +2600,10 @@ describe('SessionManager child-session runtime primitive', () => { const [firstResult, joinedResult] = await Promise.all([first, joined]); assert.strictEqual(joinedResult.childSessionId, firstResult.childSessionId); assert.strictEqual(joinedResult.runId, firstResult.runId); - assert.strictEqual((await runStore.listSessionInvocations(firstResult.childSessionId)).length, 1); + assert.strictEqual( + (await runStore.listSessionInvocations(firstResult.childSessionId)).length, + 1, + ); const durableRetry = await manager.spawnChildSession(parent.id, spawnInput); assert.strictEqual(durableRetry.childSessionId, firstResult.childSessionId); @@ -4756,7 +4774,8 @@ describe('SessionManager permission mode updates', () => { }); const session = await manager.createSession(makeInput()); const header = await store.readHeader(session.id); - await seedInvocationFromHeader(runStore, + await seedInvocationFromHeader( + runStore, makeRunHeader({ runId: 'source-run-safety-failure', sessionId: session.id, @@ -6601,7 +6620,10 @@ describe('SessionManager permission mode updates', () => { ); assert.strictEqual(backendCalls, 0); - await expectRejects(readInvocation(runStore, session.id, plan.continuation.runId), /unknown run/i); + await expectRejects( + readInvocation(runStore, session.id, plan.continuation.runId), + /unknown run/i, + ); }); test('revalidates terminal ledger consistency before executing a planned continuation', async () => { @@ -6785,7 +6807,10 @@ describe('SessionManager permission mode updates', () => { collectSessionEvents(manager.resumeSafeBoundaryContinuation(plan.continuation)), /simulated claim-only crash/, ); - await expectRejects(readInvocation(runStore, session.id, plan.continuation.runId), /unknown run/i); + await expectRejects( + readInvocation(runStore, session.id, plan.continuation.runId), + /unknown run/i, + ); assert.strictEqual(backendCalls, 0); assert.ok(!(await manager.recoverInterruptedSessions()).includes(session.id)); @@ -6803,7 +6828,10 @@ describe('SessionManager permission mode updates', () => { assert.ok((await manager.recoverInterruptedSessions()).includes(session.id)); const repairedRun = await readInvocation(runStore, session.id, plan.continuation.runId); assert.strictEqual(runtimeInvocationOutcome(repairedRun), 'failed'); - assert.strictEqual(runtimeInvocationFailureClass(repairedRun),'continuation_abandoned_before_provider_dispatch'); + assert.strictEqual( + runtimeInvocationFailureClass(repairedRun), + 'continuation_abandoned_before_provider_dispatch', + ); assert.partialDeepStrictEqual(repairedRun.opening.source, { kind: 'continuation', sourceRunId, @@ -7138,7 +7166,10 @@ describe('SessionManager permission mode updates', () => { ); assert.strictEqual(backendCalls, 0); - await expectRejects(readInvocation(runStore, session.id, plan.continuation.runId), /Unknown run/); + await expectRejects( + readInvocation(runStore, session.id, plan.continuation.runId), + /Unknown run/, + ); }); test('fails closed when continuation execution has no authoritative safety inspector', async () => { @@ -7269,7 +7300,8 @@ describe('SessionManager permission mode updates', () => { partialOutputRetained: true, }, ]); - await seedInvocationFromHeader(runStore, + await seedInvocationFromHeader( + runStore, makeRunHeader({ sessionId: session.id, runId: 'run-1', @@ -8277,7 +8309,7 @@ describe('SessionManager permission mode updates', () => { const repairedRun = await readInvocation(runStore, session.id, 'run-1'); const runtimeEvents = await runStore.readRuntimeEvents(session.id, 'run-1'); - assert.strictEqual(runtimeInvocationFailureClass(repairedRun),'tool_failed'); + assert.strictEqual(runtimeInvocationFailureClass(repairedRun), 'tool_failed'); assert.deepStrictEqual(messages.at(-1), { type: 'turn_state', id: 'rt-failed', @@ -8358,7 +8390,7 @@ describe('SessionManager permission mode updates', () => { const repairedRun = await readInvocation(runStore, session.id, 'run-1'); const runtimeEvents = await runStore.readRuntimeEvents(session.id, 'run-1'); - assert.strictEqual(runtimeInvocationFailureClass(repairedRun),'missing_terminal_event'); + assert.strictEqual(runtimeInvocationFailureClass(repairedRun), 'missing_terminal_event'); assert.strictEqual(runtimeEvents.filter((event) => event.status === 'failed').length, 1); }); @@ -8621,7 +8653,8 @@ describe('SessionManager permission mode updates', () => { }, ]; await store.appendMessages(session.id, activeMessages); - await seedInvocationFromHeader(runStore, + await seedInvocationFromHeader( + runStore, makeRunHeader({ sessionId: session.id, runId: 'run-2', @@ -9637,7 +9670,7 @@ describe('SessionManager permission mode updates', () => { assert.strictEqual((await firstEvent).done, true); const [run] = await runStore.listSessionInvocations(session.id); assert.strictEqual(run && runtimeInvocationOutcome(run), 'cancelled'); - assert.strictEqual(run && runtimeInvocationFailureClass(run),undefined); + assert.strictEqual(run && runtimeInvocationFailureClass(run), undefined); }); test('late ignored-signal backend is disposed once and never cached or dispatched', async () => { @@ -10495,7 +10528,8 @@ describe('SessionManager permission mode updates', () => { now: nextNow(6_849), }); const session = await manager.createSession(makeInput()); - await seedInvocationFromHeader(runStore, + await seedInvocationFromHeader( + runStore, makeRunHeader({ sessionId: session.id, runId: 'child-run', @@ -10906,7 +10940,7 @@ describe('SessionManager permission mode updates', () => { assert.strictEqual(turn?.status, 'failed'); assert.strictEqual(turn?.errorClass, 'runtime_error'); const [run] = await runStore.listSessionInvocations(session.id); - assert.strictEqual(run && runtimeInvocationFailureClass(run),'runtime_error'); + assert.strictEqual(run && runtimeInvocationFailureClass(run), 'runtime_error'); }); test('marks an explicit step limit incomplete without blocking the session', async () => { @@ -10935,7 +10969,7 @@ describe('SessionManager permission mode updates', () => { assert.strictEqual(turn?.errorClass, 'tool_step_cap_reached'); const [run] = await runStore.listSessionInvocations(session.id); assert.strictEqual(run && runtimeInvocationOutcome(run), 'failed'); - assert.strictEqual(run && runtimeInvocationFailureClass(run),'tool_step_cap_reached'); + assert.strictEqual(run && runtimeInvocationFailureClass(run), 'tool_step_cap_reached'); const terminal = (await runStore.readRuntimeEvents(session.id, run!.runId)).find( (event) => event.actions?.endInvocation, ); @@ -11197,7 +11231,7 @@ describe('SessionManager permission mode updates', () => { assert.strictEqual(turn?.abortSource, 'renderer.stop_button'); const [run] = await runStore.listSessionInvocations(session.id); assert.strictEqual(run && runtimeInvocationOutcome(run), 'cancelled'); - assert.strictEqual(run && runtimeInvocationFailureClass(run),undefined); + assert.strictEqual(run && runtimeInvocationFailureClass(run), undefined); const events = (await runStore.readEvents(session.id, run!.runId)).map((event) => event.type); assert.ok(events.includes('run_cancelled')); assert.strictEqual(events.includes('run_failed'), false); @@ -11852,7 +11886,10 @@ describe('SessionManager permission mode updates', () => { assert.strictEqual(turn?.errorClass, 'sandbox_boundary_closed_by_restart'); const [run] = await runStore.listSessionInvocations(session.id); assert.strictEqual(run && runtimeInvocationOutcome(run), 'failed'); - assert.strictEqual(run && runtimeInvocationFailureClass(run),'sandbox_boundary_closed_by_restart'); + assert.strictEqual( + run && runtimeInvocationFailureClass(run), + 'sandbox_boundary_closed_by_restart', + ); assert.deepStrictEqual(await store.listPendingSandboxBoundaryRequests(session.id), []); }); diff --git a/packages/runtime/src/__tests__/stream-graph-readiness.test.ts b/packages/runtime/src/__tests__/stream-graph-readiness.test.ts index 98a6519677..af459a3de7 100644 --- a/packages/runtime/src/__tests__/stream-graph-readiness.test.ts +++ b/packages/runtime/src/__tests__/stream-graph-readiness.test.ts @@ -638,7 +638,12 @@ function stream(run: RuntimeInvocationRecord, operatorId: string, events: readon }; } -function runtimeEvent(run: RuntimeInvocationRecord, id: string, ts: number, text: string): RuntimeEvent { +function runtimeEvent( + run: RuntimeInvocationRecord, + id: string, + ts: number, + text: string, +): RuntimeEvent { return { id, invocationId: run.invocationId, diff --git a/packages/runtime/src/__tests__/stream-graph-trace.test.ts b/packages/runtime/src/__tests__/stream-graph-trace.test.ts index 59cb262451..095947a110 100644 --- a/packages/runtime/src/__tests__/stream-graph-trace.test.ts +++ b/packages/runtime/src/__tests__/stream-graph-trace.test.ts @@ -534,7 +534,12 @@ function stream(run: RuntimeInvocationRecord, operatorId: string, events: readon }; } -function runtimeEvent(run: RuntimeInvocationRecord, id: string, ts: number, text: string): RuntimeEvent { +function runtimeEvent( + run: RuntimeInvocationRecord, + id: string, + ts: number, + text: string, +): RuntimeEvent { return { id, invocationId: run.invocationId, diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index ec982ccce6..138642fa95 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -29,7 +29,10 @@ import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; import type { RunCompositionSnapshot } from '@maka/core/run-composition'; import { decodeRunCompositionSnapshot } from '@maka/core/run-composition'; import { DurableStoreWriteError, RunSealedError } from '@maka/core/runtime-event-store'; -import { buildInvocationOpenedEvent, isSessionInlineInvocation } from '@maka/core/runtime-invocation'; +import { + buildInvocationOpenedEvent, + isSessionInlineInvocation, +} from '@maka/core/runtime-invocation'; import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { RuntimeInvocationLineage } from '@maka/core/runtime-event'; import { diff --git a/packages/runtime/src/runtime-ledger-repair.ts b/packages/runtime/src/runtime-ledger-repair.ts index acdd8b9044..7b97ad0fec 100644 --- a/packages/runtime/src/runtime-ledger-repair.ts +++ b/packages/runtime/src/runtime-ledger-repair.ts @@ -23,7 +23,10 @@ import { DEFAULT_TOOL_MODE } from '@maka/core/tool-mode'; import { isTerminalRuntimeEvent } from '@maka/core/runtime-event'; import type { RuntimeEvent, RuntimeEventInvocationOpenedContent } from '@maka/core/runtime-event'; import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; -import { buildInvocationOpenedEvent, isSessionInlineInvocation } from '@maka/core/runtime-invocation'; +import { + buildInvocationOpenedEvent, + isSessionInlineInvocation, +} from '@maka/core/runtime-invocation'; import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { SessionHeader } from '@maka/core/session'; import type { StoredMessage, TurnRecord } from '@maka/core/session'; From 75670a498a1b9173a4a63b0babb2c036cd557b4d Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 14:02:11 +0800 Subject: [PATCH 20/46] fix(runtime): settle a drain-refused start as cancelled, not failed A start the Interaction authority refuses because it is draining used to end as a failure or a cancellation depending on which writer won: if the Turn's stop fence had already stopped the run, the run recorded a cancellation; otherwise the same shutdown read as a Host fault and asked the Host to drain again. Shutdown is not a run failure, and a race is not a classification. State it once, where the errors that carry the reason are defined, and let both the kernel and the Host coordinator ask the same question: a draining authority cancels the run it refuses. Generated-by: Claude Code --- .../src/server/root-turn-coordinator.ts | 22 ++++--------------- packages/runtime/src/interaction-authority.ts | 21 ++++++++++++++++++ packages/runtime/src/runtime-kernel.ts | 4 ++++ 3 files changed, 29 insertions(+), 18 deletions(-) diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index b99e23d397..ec877e8f46 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -47,6 +47,7 @@ import { type RuntimeMessageRunIdentity, } from '@maka/runtime/message-authority'; import { + isShutdownCancelledInteractionAdmission, RuntimeInteractionAdmissionRejectedError, RuntimeInteractionFailStopError, RuntimeInteractionInvariantError, @@ -2482,7 +2483,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { reason: errorMessage(commandFailure), }); startSettled.reject(commandFailure); - this.requestHostDrain(); + if (!isShutdownCancelledInteractionAdmission(commandFailure)) this.requestHostDrain(); throw commandFailure; } finally { this.observeExecutionCompletion(active, { @@ -2742,7 +2743,8 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { if ( !(error instanceof RuntimeHostedRootConflictError) && !(error instanceof RuntimeHostedRootUnavailableError) && - !(error instanceof HostedRootAdmissionGateError) + !(error instanceof HostedRootAdmissionGateError) && + !isShutdownCancelledInteractionAdmission(error) ) { this.requestHostDrain(); } @@ -3068,22 +3070,6 @@ function isTerminalSnapshot( ); } -function isShutdownCancelledInteractionAdmission(error: unknown): boolean { - // Drain can reach a running question admission before the Turn's stop fence - // closes its Interaction Run, so this expected cancellation is direct. - if ( - error instanceof RuntimeInteractionAdmissionRejectedError && - error.reason === 'authority_draining' - ) { - return true; - } - return ( - error instanceof RuntimeInteractionFailStopError && - error.authorityFailure instanceof RuntimeInteractionAdmissionRejectedError && - error.authorityFailure.reason === 'authority_draining' - ); -} - function isContainableRunFailure(error: unknown): error is Error { return ( error instanceof Error && diff --git a/packages/runtime/src/interaction-authority.ts b/packages/runtime/src/interaction-authority.ts index b4463624f2..e1fe23ca35 100644 --- a/packages/runtime/src/interaction-authority.ts +++ b/packages/runtime/src/interaction-authority.ts @@ -199,6 +199,27 @@ export class RuntimeInteractionFailStopError extends Error { } } +/** + * Whether shutdown, rather than the work itself, is what refused this admission. + * + * A draining authority turns anything it rejects into a cancellation: the run + * did not fail, it was never allowed to proceed. Callers use this to settle the + * run as cancelled and to keep the rejection from reading as a Host fault. + */ +export function isShutdownCancelledInteractionAdmission(error: unknown): boolean { + if ( + error instanceof RuntimeInteractionAdmissionRejectedError && + error.reason === 'authority_draining' + ) { + return true; + } + return ( + error instanceof RuntimeInteractionFailStopError && + error.authorityFailure instanceof RuntimeInteractionAdmissionRejectedError && + error.authorityFailure.reason === 'authority_draining' + ); +} + type LocalClosureFinalizer = () => void; type HostedInteractionRequestEvent = diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index ad6f32073a..7cc2e49ac1 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -150,6 +150,7 @@ import { bindRuntimeInteractionRun, isHostedInteractionRequestEvent, isHostedInteractionSettlementAckEvent, + isShutdownCancelledInteractionAdmission, type RuntimeInteractionAuthority, type RuntimeInteractionRunBinding, type RuntimeInteractionRunClosureReason, @@ -1505,6 +1506,9 @@ export class RuntimeKernel implements RuntimeKernelLike { execution: PendingExecutionClaim, error: unknown, ): Promise { + // A draining authority refused the start because everything is stopping, not + // because this run went wrong, so the run ends cancelled rather than failed. + if (isShutdownCancelledInteractionAdmission(error)) run.stop(undefined); try { await owners.failStart(error); } catch (failure) { From fa8f189eb986e201532c1be9b59b1c83b0b5df77 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 14:02:16 +0800 Subject: [PATCH 21/46] fix(runtime): let recovery report only what it repaired Recovery walks every invocation on a Session's spine and, for each, commits the terminal fact and the terminal Turn state. It then reported every walk as a recovery, so a Session whose runs had all ended cleanly still had its status rewritten on every startup, bumping the header revision and invalidating the revision a caller was holding across the restart. The claim used to be at least literally true: that commit also wrote the Run header. With the header gone there is no second record left to write, so an already-terminal run makes the whole pass a no-op. Report a recovery only when this pass supplied something the run was missing: a terminal fact it did not have, or a terminal Turn state it did not carry. Generated-by: Claude Code --- packages/runtime/src/session-manager.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 0ecf1e3819..e35b0712b3 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -4678,7 +4678,7 @@ export class SessionManager { return false; } - await recoverOr( + const appendedTurnState = await recoverOr( policy, () => this.appendTerminalTurnStateIfNeeded( @@ -4693,9 +4693,12 @@ export class SessionManager { }, policy, ), - undefined, + false, ); - return true; + // A run that already carried a complete terminal fact and a terminal Turn + // state had nothing to recover. Saying otherwise makes recovery rewrite the + // Session status of every healthy run it walks past. + return inspected.terminalRuntimeFact === undefined || appendedTurnState; } private async appendTerminalTurnStateIfNeeded( @@ -4705,16 +4708,17 @@ export class SessionManager { status: TurnRecord['status'], options: { ts: number; errorClass?: string; abortSource?: string }, policy: RecoveryPolicy = { kind: 'best_effort' }, - ): Promise { - if (!isSessionInlineInvocation(run.opening)) return; + ): Promise { + if (!isSessionInlineInvocation(run.opening)) return false; const messages = await recoverOr( policy, () => this.deps.store.readMessages(sessionId), [] as StoredMessage[], ); const latest = latestTurnState(messages, decision.turnId); - if (latest && isTerminalTurnStatus(latest.status) && latest.status === status) return; + if (latest && isTerminalTurnStatus(latest.status) && latest.status === status) return false; await this.appendTurnState(sessionId, decision.turnId, status, decision.lineage, options); + return true; } } From 00fbeb35b6e8e2c9a65bca06b98a04c947161eb1 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 14:02:23 +0800 Subject: [PATCH 22/46] test(runtime-host): read each hosted run's facts off its invocation The opening fact is now event 1 of every invocation, so a source Run's RuntimeEvent high water sits one past where these fixtures expected it, and a branch's copied invocation opens on its own spine and projects the copied Turn as ended. Two Sessions that reuse a run id now open separate invocations, so the inspect fixtures name the second one explicitly, and the shared evidence budget accounts for the bytes the opening event itself occupies. The Agent Graph provider fixture asserted the child run's status off `agent_output`'s `header`, which is now `invocation`; assert it off the invocation's terminal event instead. Generated-by: Claude Code --- packages/core/src/runtime-event.ts | 2 +- .../execution-host-continuation.test.ts | 2 +- .../execution-inspect-coordinator.test.ts | 37 ++++++++++++++----- .../fixtures/agent-graph-provider-scenario.ts | 6 ++- .../fixtures/execution-host-suite.ts | 4 +- .../session-revision-two-client-uds.test.ts | 7 +++- 6 files changed, 44 insertions(+), 14 deletions(-) diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index 573359c55a..8323d289b4 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -602,7 +602,7 @@ export interface RuntimeEvent { id: string; /** Durable invocation spine id; groups every run/turn of one request. */ invocationId: string; - /** Durable operational run identity (maps to AgentRunHeader.runId). */ + /** Durable operational run identity; names one execution of the invocation. */ runId: string; sessionId: string; /** Groups all events from one agent turn (maps to StoredMessage.turnId). */ diff --git a/packages/runtime-host/src/__tests__/execution-host-continuation.test.ts b/packages/runtime-host/src/__tests__/execution-host-continuation.test.ts index 92ac57f1d9..064720406a 100644 --- a/packages/runtime-host/src/__tests__/execution-host-continuation.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host-continuation.test.ts @@ -96,7 +96,7 @@ test('two Clients idempotently start one Host-owned safe-boundary continuation', if (admission?.execution.kind !== 'safe_boundary_continuation') return; assert.equal(admission.execution.sourceRunId, source.sourceRunId); assert.equal(admission.execution.sourceInvocationId, source.sourceInvocationId); - assert.equal(admission.execution.sourceRuntimeEventHighWater, 2); + assert.equal(admission.execution.sourceRuntimeEventHighWater, 3); const ledger = await fixture.readTurn(turnId); assert.equal(ledger.runs.length, 1); diff --git a/packages/runtime-host/src/__tests__/execution-inspect-coordinator.test.ts b/packages/runtime-host/src/__tests__/execution-inspect-coordinator.test.ts index e4713d69ed..513d0a30db 100644 --- a/packages/runtime-host/src/__tests__/execution-inspect-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/execution-inspect-coordinator.test.ts @@ -149,7 +149,12 @@ describe('HostExecutionInspectCoordinator', () => { const first = await stores.sessionStore.create(sessionInput('First')); const second = await stores.sessionStore.create(sessionInput('Second')); await seedInvocation(stores.runtimeEventStore, runHeader(first.id, 'shared-run', 1)); - await seedInvocation(stores.runtimeEventStore, runHeader(second.id, 'shared-run', 2)); + // One invocation id names one execution everywhere, so two Sessions that + // reuse a run id still open separate invocations. + await seedInvocation( + stores.runtimeEventStore, + runHeader(second.id, 'shared-run', 2, 'shared-run-second'), + ); const run = await coordinator.handlers['execution.inspect.query']( { kind: 'agent_run', sessionId: second.id, agentRunId: 'shared-run' }, @@ -531,23 +536,31 @@ describe('HostExecutionInspectCoordinator', () => { data: { payload: '' }, }; const baseBytes = Buffer.byteLength(JSON.stringify(baseEvent), 'utf8'); - assert.ok(baseBytes < EXECUTION_INSPECT_EVIDENCE_MAX_BYTES); + // One query charges both ledgers to the same budget, and the invocation's + // opening fact is already on the RuntimeEvent ledger. The operational + // event is sized to exactly the rest. + const opening = await stores.runtimeEventStore.readRuntimeEventsBounded( + session.id, + 'exact-run', + { maxRecords: 8, maxBytes: EXECUTION_INSPECT_EVIDENCE_MAX_BYTES }, + ); + assert.equal(opening.status, 'complete'); + const operationalBytes = EXECUTION_INSPECT_EVIDENCE_MAX_BYTES - opening.storedBytes; + assert.ok(baseBytes < operationalBytes); const event = { ...baseEvent, - data: { - payload: 'x'.repeat(EXECUTION_INSPECT_EVIDENCE_MAX_BYTES - baseBytes), - }, + data: { payload: 'x'.repeat(operationalBytes - baseBytes) }, }; await stores.agentRunStore.appendEvent(session.id, 'exact-run', event); const exact = await stores.agentRunStore.readEventsBounded(session.id, 'exact-run', { maxRecords: 1, - maxBytes: EXECUTION_INSPECT_EVIDENCE_MAX_BYTES, + maxBytes: operationalBytes, }); assert.equal(exact.status, 'complete'); const oneByteShort = await stores.agentRunStore.readEventsBounded(session.id, 'exact-run', { maxRecords: 1, - maxBytes: EXECUTION_INSPECT_EVIDENCE_MAX_BYTES - 1, + maxBytes: operationalBytes - 1, }); assert.equal(oneByteShort.status, 'limit_exceeded'); @@ -591,7 +604,12 @@ describe('HostExecutionInspectCoordinator', () => { ); assert.equal(first.ok, true); if (!first.ok || first.result.kind !== 'session_trace_page') return; - assert.equal(first.result.turns.length, 0); + // Only the newer run's evidence fits one budget. Its page carries the turn + // its opening fact projects, and the older run waits behind the cursor. + assert.deepEqual( + first.result.turns.map((turn) => turn.runId), + ['aggregate-run-2'], + ); assert.ok(first.result.nextCursor !== null); }); }); @@ -609,10 +627,11 @@ function sessionInput(name: string) { } as const; } -function runHeader(sessionId: string, runId: string, createdAt: number) { +function runHeader(sessionId: string, runId: string, createdAt: number, invocationId?: string) { return { sessionId, runId, + ...(invocationId ? { invocationId } : {}), turnId: `turn-${runId}`, openedAt: createdAt, opening: { diff --git a/packages/runtime-host/src/__tests__/fixtures/agent-graph-provider-scenario.ts b/packages/runtime-host/src/__tests__/fixtures/agent-graph-provider-scenario.ts index 3c64dea057..20edbf923f 100644 --- a/packages/runtime-host/src/__tests__/fixtures/agent-graph-provider-scenario.ts +++ b/packages/runtime-host/src/__tests__/fixtures/agent-graph-provider-scenario.ts @@ -140,7 +140,11 @@ export class AgentGraphProviderScenario { requireRecord(output.execution, 'agent output execution').kind, 'child_session', ); - assert.equal(requireRecord(output.header, 'agent output header').status, 'completed'); + const invocation = requireRecord(output.invocation, 'agent output invocation'); + assert.equal( + requireRecord(invocation.terminalEvent, 'agent output terminal event').status, + 'completed', + ); const result = requireRecord(output.result, 'agent output payload'); assert.equal(result.status, 'completed'); assert.equal(result.text, this.childResultText); diff --git a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts index caaa421a33..0c399eb5cd 100644 --- a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts +++ b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts @@ -276,7 +276,9 @@ export class ExecutionFixture { sourceInvocationId, sourceRunId, sourceTurnId, - sourceRuntimeEventHighWater: requiredToolName ? 4 : 2, + // The opening fact is event 1 of the invocation, ahead of the user event, + // any tool pair, and the terminal event. + sourceRuntimeEventHighWater: requiredToolName ? 5 : 3, }; } finally { await stores?.sessionStore.close?.(); diff --git a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts index 143518a759..b2bb1710f9 100644 --- a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts @@ -1704,7 +1704,12 @@ async function verifyDurableBranch( }); }; const messages = await execution.sessionStore.readMessagesSnapshot(branchSessionId); - assert.equal(messages.length, 5); + // The copied invocation opens on the branch's own spine, so its transcript + // projects the copied turn as ended, exactly as the source reads. + assert.deepEqual( + messages.map((message) => message.type), + ['user', 'assistant', 'tool_call', 'tool_result', 'system_note', 'turn_state'], + ); const user = messages.find((message) => message.type === 'user'); assert.ok(user?.attachments?.[0]); const ref = user?.attachments?.[0]?.ref; From f4e6abcbfdab697f6e12c02f8e371e223b85cb66 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 14:05:35 +0800 Subject: [PATCH 23/46] refactor(runtime): keep one name and one fixture for an invocation outcome `TerminalAgentRunStatus` was left as a bare alias of `RuntimeInvocationOutcome`, so the vocabulary this change retired survived as a second name for the same three values. Use the one name. The invocation fixture had been copied byte-for-byte into `runtime-host`, and the `storage` fixture hand-rolled the opening event instead of building it. Share the fixture through the `test-only` entry point this repo already uses for cross-workspace test modules, and build the storage fixture's event with `buildInvocationOpenedEvent` so no test can drift from how the runtime opens an invocation. Generated-by: Claude Code --- .../canonical-session-projection.test.ts | 2 +- .../execution-inspect-coordinator.test.ts | 2 +- .../execution-model-composition.test.ts | 2 +- .../fixtures/execution-host-suite.ts | 2 +- .../src/__tests__/fixtures/seed-invocation.ts | 164 ------------------ .../src/__tests__/goal-coordinator.test.ts | 2 +- .../src/__tests__/goal-root-authority.test.ts | 2 +- .../__tests__/root-turn-coordinator.test.ts | 2 +- .../session-revision-graph-references.test.ts | 2 +- .../session-revision-two-client-uds.test.ts | 2 +- .../session-transcript-reader.test.ts | 2 +- packages/runtime/package.json | 1 + .../runtime/src/runtime-event-backfill.ts | 5 +- packages/runtime/src/runtime-ledger-repair.ts | 5 +- packages/runtime/src/terminal-run-commit.ts | 19 +- .../__tests__/fixtures/invocation-opening.ts | 28 +-- scripts/release-cli-file-policy.test.mjs | 6 +- 17 files changed, 43 insertions(+), 205 deletions(-) delete mode 100644 packages/runtime-host/src/__tests__/fixtures/seed-invocation.ts diff --git a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts index 97927e868d..dd8c16dc9b 100644 --- a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts +++ b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts @@ -22,7 +22,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; -import { seedInvocation } from './fixtures/seed-invocation.js'; +import { seedInvocation } from '@maka/runtime/test-only/invocation-fixture'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { openInteractiveExecutionStoresForWrite, diff --git a/packages/runtime-host/src/__tests__/execution-inspect-coordinator.test.ts b/packages/runtime-host/src/__tests__/execution-inspect-coordinator.test.ts index 513d0a30db..093fe57a1a 100644 --- a/packages/runtime-host/src/__tests__/execution-inspect-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/execution-inspect-coordinator.test.ts @@ -24,7 +24,7 @@ import { join } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { describe, test } from 'node:test'; import type { EmittedAgentRunEvent } from '@maka/core/agent-run'; -import { seedInvocation } from './fixtures/seed-invocation.js'; +import { seedInvocation } from '@maka/runtime/test-only/invocation-fixture'; import { MODEL_CALL_ATTEMPT_SCHEMA_VERSION } from '@maka/core/model-call-attempt'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { openInteractiveExecutionStoresForWrite } from '@maka/storage/execution-stores'; diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index c6aa811904..67abcacd27 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -40,7 +40,7 @@ import { import { PROVIDER_REGISTRY } from '@maka/core/llm-connections'; import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; import { decodeRunCompositionSnapshot } from '@maka/core/run-composition'; -import { readInvocation, testInvocationRecord } from './fixtures/seed-invocation.js'; +import { readInvocation, testInvocationRecord } from '@maka/runtime/test-only/invocation-fixture'; import { runtimeInvocationOutcome } from '@maka/core/runtime-invocation'; import { agentRunCompositionFromEvents } from '@maka/core/agent-run'; import type { BackendCompactHistoryInput } from '@maka/core/backend-types'; diff --git a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts index 0c399eb5cd..93117f8499 100644 --- a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts +++ b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts @@ -43,7 +43,7 @@ import { runtimeInvocationOutcome, type RuntimeInvocationRecord, } from '@maka/core/runtime-invocation'; -import { seedInvocation } from './seed-invocation.js'; +import { seedInvocation } from '@maka/runtime/test-only/invocation-fixture'; import { aggregateMessageContents, messageContentDigest, diff --git a/packages/runtime-host/src/__tests__/fixtures/seed-invocation.ts b/packages/runtime-host/src/__tests__/fixtures/seed-invocation.ts deleted file mode 100644 index ea66c0a86e..0000000000 --- a/packages/runtime-host/src/__tests__/fixtures/seed-invocation.ts +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { randomUUID } from 'node:crypto'; -import type { RuntimeEvent, RuntimeEventInvocationOpenedContent } from '@maka/core/runtime-event'; -import { - buildInvocationOpenedEvent, - type RuntimeInvocationRecord, -} from '@maka/core/runtime-invocation'; - -export interface SeededInvocationIdentity { - readonly sessionId: string; - readonly invocationId: string; - readonly runId: string; - readonly turnId: string; -} - -export interface SeedInvocationInput { - readonly sessionId: string; - readonly runId: string; - readonly turnId: string; - readonly invocationId?: string; - readonly openedAt?: number; - readonly opening?: Partial; -} - -/** The opening a test gets when it does not care what the run was routed to. */ -export function testInvocationOpening( - overrides: Partial = {}, -): RuntimeEventInvocationOpenedContent { - return { - kind: 'invocation_opened', - protocol: 'invocation_opened_v1', - route: { - provenance: 'runtime', - backendKind: 'fake', - llmConnectionId: 'fake-connection', - llmConnectionSlug: 'fake', - modelId: 'fake-model', - }, - configuration: { - cwd: '/tmp', - permissionMode: 'ask', - collaborationMode: 'agent', - orchestrationMode: 'default', - orchestrationSource: 'session', - toolMode: 'direct', - }, - root: { kind: 'user' }, - source: { kind: 'fresh' }, - ...overrides, - }; -} - -/** - * One invocation as a reader sees it, without a store. - * - * `outcome` writes the terminal event that decides it; leaving it out leaves the - * invocation running, which is what "no terminal event" means everywhere else. - */ -export function testInvocationRecord(input: { - sessionId: string; - runId: string; - turnId: string; - invocationId?: string; - openedAt?: number; - closedAt?: number; - outcome?: 'completed' | 'failed' | 'aborted'; - failureClass?: string; - opening?: Partial; -}): RuntimeInvocationRecord { - const invocationId = input.invocationId ?? input.runId; - const openedAt = input.openedAt ?? 1; - const identity = { - sessionId: input.sessionId, - invocationId, - runId: input.runId, - turnId: input.turnId, - }; - return { - ...identity, - openedAt, - opening: testInvocationOpening(input.opening), - ...(input.outcome - ? { - terminalEvent: { - id: `${invocationId}-terminal`, - ...identity, - ts: input.closedAt ?? openedAt + 1, - partial: false, - role: 'system', - author: 'system', - status: input.outcome, - ...(input.failureClass ? { failureClass: input.failureClass } : {}), - }, - } - : {}), - }; -} - -/** The event that opens one invocation, ready to append. */ -export function testInvocationOpenedEvent(input: SeedInvocationInput): RuntimeEvent { - return buildInvocationOpenedEvent({ - id: randomUUID(), - run: { - sessionId: input.sessionId, - invocationId: input.invocationId ?? input.runId, - runId: input.runId, - turnId: input.turnId, - }, - openedAt: input.openedAt ?? Date.now(), - opening: testInvocationOpening(input.opening), - }); -} - -/** The one invocation that opened this run, or a failure naming what is missing. */ -export async function readInvocation( - stores: { - runtimeEventStore: { - listSessionInvocations(sessionId: string): Promise; - }; - }, - sessionId: string, - runId: string, -): Promise { - const found = (await stores.runtimeEventStore.listSessionInvocations(sessionId)).find( - (candidate) => candidate.runId === runId, - ); - if (!found) throw new Error(`Session ${sessionId} has no invocation for run ${runId}`); - return found; -} - -/** Open one invocation on the spine, the way the runtime would. */ -export async function seedInvocation( - runtimeEventStore: { - appendRuntimeEvent(sessionId: string, runId: string, event: RuntimeEvent): Promise; - }, - input: SeedInvocationInput, -): Promise { - const event = testInvocationOpenedEvent(input); - await runtimeEventStore.appendRuntimeEvent(input.sessionId, input.runId, event); - return { - sessionId: event.sessionId, - invocationId: event.invocationId, - runId: event.runId, - turnId: event.turnId, - }; -} diff --git a/packages/runtime-host/src/__tests__/goal-coordinator.test.ts b/packages/runtime-host/src/__tests__/goal-coordinator.test.ts index c840ca82f2..b69e4e0538 100644 --- a/packages/runtime-host/src/__tests__/goal-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/goal-coordinator.test.ts @@ -23,7 +23,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; import type { GoalAuthorityRecord } from '@maka/core/goal'; -import { seedInvocation } from './fixtures/seed-invocation.js'; +import { seedInvocation } from '@maka/runtime/test-only/invocation-fixture'; import type { GoalTurnOutcome } from '@maka/runtime/goal-continuation'; import { openInteractiveExecutionStoresForWrite } from '@maka/storage/execution-stores'; import { openInteractiveGoalAuthorityForWrite } from '@maka/storage/goal-authority'; diff --git a/packages/runtime-host/src/__tests__/goal-root-authority.test.ts b/packages/runtime-host/src/__tests__/goal-root-authority.test.ts index be4814e2c1..c6e89e8376 100644 --- a/packages/runtime-host/src/__tests__/goal-root-authority.test.ts +++ b/packages/runtime-host/src/__tests__/goal-root-authority.test.ts @@ -29,7 +29,7 @@ import { type RuntimeInvocationRecord, } from '@maka/core/runtime-invocation'; import { runtimeInvocationFailureClass } from '@maka/runtime/runtime-event-read-model'; -import { seedInvocation } from './fixtures/seed-invocation.js'; +import { seedInvocation } from '@maka/runtime/test-only/invocation-fixture'; import { BackendRegistry, SessionManager } from '@maka/runtime/session-manager'; import { FakeBackend } from '@maka/runtime/test-only/fake-backend'; import { GOAL_SET_TOOL_NAME } from '@maka/runtime/goal-tools'; 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 215f7f5b94..553d5d1d4b 100644 --- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts @@ -19,7 +19,7 @@ import { deferred, withTimeout } from '@maka/core/test-only/async-primitives'; import assert from 'node:assert/strict'; -import { readInvocation, seedInvocation } from './fixtures/seed-invocation.js'; +import { readInvocation, seedInvocation } from '@maka/runtime/test-only/invocation-fixture'; import { runtimeInvocationOutcome } from '@maka/core/runtime-invocation'; import { runtimeInvocationFailureClass } from '@maka/runtime/runtime-event-read-model'; import { randomUUID } from 'node:crypto'; diff --git a/packages/runtime-host/src/__tests__/session-revision-graph-references.test.ts b/packages/runtime-host/src/__tests__/session-revision-graph-references.test.ts index 6debd5c27e..d42a9b9f59 100644 --- a/packages/runtime-host/src/__tests__/session-revision-graph-references.test.ts +++ b/packages/runtime-host/src/__tests__/session-revision-graph-references.test.ts @@ -20,7 +20,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; -import { testInvocationRecord } from './fixtures/seed-invocation.js'; +import { testInvocationRecord } from '@maka/runtime/test-only/invocation-fixture'; import type { SessionHeader, StoredMessage } from '@maka/core/session'; import { agentGraphIdForRootSession } from '@maka/runtime/stream-graph-coordinator'; import { collectConversationCopyLinkedChildReferences } from '@maka/runtime/conversation-copy'; diff --git a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts index b2bb1710f9..52f6ad1573 100644 --- a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts @@ -27,7 +27,7 @@ import { join } from 'node:path'; import { test } from 'node:test'; import { decodeCanonicalToolResultContent } from '@maka/core/tool-result-record-schema'; import { type AgentGraphOperatorProvisionRequest } from '@maka/core/agent-graph-topology'; -import { seedInvocation, type SeedInvocationInput } from './fixtures/seed-invocation.js'; +import { seedInvocation, type SeedInvocationInput } from '@maka/runtime/test-only/invocation-fixture'; import { type RuntimeEvent } from '@maka/core/runtime-event'; import { WORKHUB_COORDINATION_SESSION_ID } from '@maka/core/session'; import { diff --git a/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts b/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts index ac58fbb283..1da0eab3ce 100644 --- a/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts +++ b/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts @@ -22,7 +22,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; -import { seedInvocation, testInvocationOpening } from './fixtures/seed-invocation.js'; +import { seedInvocation, testInvocationOpening } from '@maka/runtime/test-only/invocation-fixture'; import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 0a81170fd5..ce454c811e 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -24,6 +24,7 @@ "./session-todo-tools": "./dist/session-todo-tools.js", "./test-only/fake-backend": "./dist/test-only/fake-backend.js", "./test-only/observation-text-reader": "./dist/__tests__/observation-text-reader.js", + "./test-only/invocation-fixture": "./dist/__tests__/invocation-fixture.js", "./filesystem-worker": "./dist/filesystem-worker/index.js", "./sandbox": "./dist/sandbox/index.js", "./network/proxy-test": "./dist/network/proxy-test.js", diff --git a/packages/runtime/src/runtime-event-backfill.ts b/packages/runtime/src/runtime-event-backfill.ts index dcc3bdfb36..2b5ab6137c 100644 --- a/packages/runtime/src/runtime-event-backfill.ts +++ b/packages/runtime/src/runtime-event-backfill.ts @@ -17,7 +17,8 @@ * under the License. */ -import type { RunIdentity, TerminalAgentRunStatus } from './terminal-run-commit.js'; +import type { RuntimeInvocationOutcome } from '@maka/core/runtime-invocation'; +import type { RunIdentity } from './terminal-run-commit.js'; import type { PermissionDecisionMessage, StoredMessage, @@ -51,7 +52,7 @@ export interface RuntimeEventBackfillDiagnostic { * StoredMessage transcript states an outcome the ledger can be held to. */ export interface RuntimeEventBackfillOutcome { - status: TerminalAgentRunStatus; + status: RuntimeInvocationOutcome; ts: number; failureClass?: string; abortSource?: string; diff --git a/packages/runtime/src/runtime-ledger-repair.ts b/packages/runtime/src/runtime-ledger-repair.ts index 7b97ad0fec..eb0900850f 100644 --- a/packages/runtime/src/runtime-ledger-repair.ts +++ b/packages/runtime/src/runtime-ledger-repair.ts @@ -27,13 +27,12 @@ import { buildInvocationOpenedEvent, isSessionInlineInvocation, } from '@maka/core/runtime-invocation'; -import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; +import type { RuntimeInvocationOutcome, RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { SessionHeader } from '@maka/core/session'; import type { StoredMessage, TurnRecord } from '@maka/core/session'; import { backfillRuntimeEventsFromStoredMessages } from './runtime-event-backfill.js'; import type { RuntimeEventBackfillOutcome } from './runtime-event-backfill.js'; import { projectRuntimeEventUserMessage } from './runtime-event-read-model.js'; -import type { TerminalAgentRunStatus } from './terminal-run-commit.js'; export interface RuntimeLedgerRepairDeps { runtimeEventStore: RuntimeEventStore; @@ -237,7 +236,7 @@ function transcriptOutcome( }; } -function transcriptOutcomeStatus(status: TurnRecord['status']): TerminalAgentRunStatus { +function transcriptOutcomeStatus(status: TurnRecord['status']): RuntimeInvocationOutcome { if (status === 'failed') return 'failed'; if (status === 'completed') return 'completed'; return 'cancelled'; diff --git a/packages/runtime/src/terminal-run-commit.ts b/packages/runtime/src/terminal-run-commit.ts index 6849f0c72b..760da632c3 100644 --- a/packages/runtime/src/terminal-run-commit.ts +++ b/packages/runtime/src/terminal-run-commit.ts @@ -26,9 +26,6 @@ import { type RuntimeEventTerminalFact, } from './runtime-event-read-model.js'; -/** How a run ended. One terminal RuntimeEvent decides it, once. */ -export type TerminalAgentRunStatus = RuntimeInvocationOutcome; - /** The three ids every RuntimeEvent of one run carries. */ export interface RunIdentity { sessionId: string; @@ -82,7 +79,7 @@ export function classifyTerminalRuntimeLedger( export interface CommitTerminalRunWithRuntimeFactInput extends RunIdentity { runtimeEventStore: RuntimeEventStore; newId: () => string; - status: TerminalAgentRunStatus; + status: RuntimeInvocationOutcome; ts: number; terminalEvent: RuntimeEvent; failureClass?: string; @@ -114,7 +111,7 @@ export interface CommitOrCreateTerminalRunFactInput /** Runs after the terminal durability barrier. */ afterTerminalDurable?: () => Promise; terminalEvent?: RuntimeEvent; - fallbackStatus: TerminalAgentRunStatus; + fallbackStatus: RuntimeInvocationOutcome; fallbackInvocationId: string; fallbackFailureClass?: string; fallbackFailureMessage?: string; @@ -122,7 +119,7 @@ export interface CommitOrCreateTerminalRunFactInput export interface CommitOrCreateTerminalRunFactResult { terminalEvent: RuntimeEvent; - status: TerminalAgentRunStatus; + status: RuntimeInvocationOutcome; failureClass?: string; createdTerminalEvent: boolean; } @@ -173,8 +170,8 @@ export async function commitOrCreateTerminalRunFact( function assertCommittableTerminalEvent( event: RuntimeEvent, identity: RunIdentity, - expected?: TerminalAgentRunStatus, -): TerminalAgentRunStatus { + expected?: RuntimeInvocationOutcome, +): RuntimeInvocationOutcome { if (isPartialRuntimeEvent(event)) { throw new Error('terminal RuntimeEvent must be final before it is committed'); } @@ -199,7 +196,7 @@ export interface BuildSyntheticTerminalRuntimeEventInput { id: string; invocationId: string; run: RunIdentity; - status: TerminalAgentRunStatus; + status: RuntimeInvocationOutcome; ts: number; failureClass?: string; abortSource?: string; @@ -249,7 +246,7 @@ export function buildSyntheticTerminalRuntimeEvent( export interface BuildRecoveredTerminalRuntimeEventInput { id: string; run: RunIdentity & { invocationId?: string }; - status: TerminalAgentRunStatus; + status: RuntimeInvocationOutcome; ts: number; invocationId?: string; failureClass?: string; @@ -289,7 +286,7 @@ function runtimeEventFailureClass(event: RuntimeEvent): string | undefined { export function terminalRunStatusFromRuntimeEvent( event: RuntimeEvent, -): TerminalAgentRunStatus | undefined { +): RuntimeInvocationOutcome | undefined { if (event.status === 'completed') return 'completed'; if (event.status === 'failed') return 'failed'; if (event.status === 'aborted' || event.status === 'cancelled') return 'cancelled'; diff --git a/packages/storage/src/__tests__/fixtures/invocation-opening.ts b/packages/storage/src/__tests__/fixtures/invocation-opening.ts index 7601cf01b4..9d27552b39 100644 --- a/packages/storage/src/__tests__/fixtures/invocation-opening.ts +++ b/packages/storage/src/__tests__/fixtures/invocation-opening.ts @@ -19,6 +19,7 @@ import { encodeCanonicalRuntimeEvent } from '@maka/core/canonical-runtime-event'; import type { RuntimeEventInvocationOpenedContent } from '@maka/core/runtime-event'; +import { buildInvocationOpenedEvent } from '@maka/core/runtime-invocation'; import { DEFAULT_TOOL_MODE } from '@maka/core/tool-mode'; import { createWorkspaceRuntimeStore } from '../../runtime-event-persistence.js'; @@ -68,20 +69,19 @@ export async function openInvocation( content: RuntimeEventInvocationOpenedContent = invocationOpening(), ): Promise { const invocationId = identity.invocationId ?? identity.runId; - const openedAt = identity.openedAt ?? 1; - const { event } = encodeCanonicalRuntimeEvent({ - id: `invocation_opened:${invocationId}`, - invocationId, - runId: identity.runId, - sessionId: identity.sessionId, - turnId: identity.turnId, - ts: openedAt, - partial: false, - role: 'system', - author: 'system', - modelVisibility: 'hidden', - content, - }); + const { event } = encodeCanonicalRuntimeEvent( + buildInvocationOpenedEvent({ + id: `invocation_opened:${invocationId}`, + run: { + sessionId: identity.sessionId, + invocationId, + runId: identity.runId, + turnId: identity.turnId, + }, + openedAt: identity.openedAt ?? 1, + opening: content, + }), + ); const store = createWorkspaceRuntimeStore(workspaceRoot); try { await store.appendRuntimeEvent(identity.sessionId, identity.runId, event); diff --git a/scripts/release-cli-file-policy.test.mjs b/scripts/release-cli-file-policy.test.mjs index 2766b4c2b6..84e7e8e371 100644 --- a/scripts/release-cli-file-policy.test.mjs +++ b/scripts/release-cli-file-policy.test.mjs @@ -61,7 +61,11 @@ describe('CLI release file policy', () => { './test-only/client-capability-host', './test-only/execution-candidate-e2e-main', ], - runtime: ['./test-only/fake-backend', './test-only/observation-text-reader'], + runtime: [ + './test-only/fake-backend', + './test-only/observation-text-reader', + './test-only/invocation-fixture', + ], })) { const manifestPath = join(repoRoot, 'packages', directory, 'package.json'); const source = JSON.parse(readFileSync(manifestPath, 'utf8')); From de2a1f995e530b54eb4b8208527d90a77da20cac Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 14:43:52 +0800 Subject: [PATCH 24/46] fix(runtime): leave an invocation open when its ledger states two endings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Run ends exactly once. When the inventory found two terminal events on one invocation it kept whichever came last, so a ledger that contradicts itself read back as a settled run and the contradiction never reached anyone. Leave such an invocation without a terminal event instead, and let the readers that can act on it — the inspect model and the read model — classify off the events themselves, so the ambiguity surfaces as ambiguity rather than as a run that merely has not finished. Generated-by: Claude Code --- packages/core/src/runtime-invocation.ts | 10 +++++++++- packages/runtime/src/agent-run-inspect.ts | 5 ++++- packages/runtime/src/runtime-read-model.ts | 6 +++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/core/src/runtime-invocation.ts b/packages/core/src/runtime-invocation.ts index 7dca48feb3..ef3dd9b76f 100644 --- a/packages/core/src/runtime-invocation.ts +++ b/packages/core/src/runtime-invocation.ts @@ -71,11 +71,19 @@ export function runtimeInvocationsFromSessionEvents( }); } } + // A Run ends exactly once. Two terminal events are two statements that it + // ended, which is no statement at all: leave the invocation open so the + // ambiguity reaches a reader that can repair it instead of being hidden by + // whichever event happened to come last. + const terminalCounts = new Map(); for (const event of events) { if (event.sessionId !== sessionId || event.partial === true) continue; if (!isTerminalRuntimeEvent(event)) continue; const record = byInvocation.get(event.invocationId); - if (record) record.terminalEvent = event; + if (!record) continue; + const seen = (terminalCounts.get(event.invocationId) ?? 0) + 1; + terminalCounts.set(event.invocationId, seen); + record.terminalEvent = seen === 1 ? event : undefined; } return [...byInvocation.values()].sort( (a, b) => a.openedAt - b.openedAt || a.invocationId.localeCompare(b.invocationId), diff --git a/packages/runtime/src/agent-run-inspect.ts b/packages/runtime/src/agent-run-inspect.ts index 1f389a170d..07b63ac892 100644 --- a/packages/runtime/src/agent-run-inspect.ts +++ b/packages/runtime/src/agent-run-inspect.ts @@ -113,7 +113,10 @@ export async function inspectAgentRunReadModel( const runtimeEvents = runtimeRead.events; let terminalRuntimeFact: RuntimeEventTerminalFact | undefined; - if (runtimeRead.state === 'present' && invocation.terminalEvent) { + // Classified off the events, not off the record's terminal event: an + // invocation the inventory leaves open because its ledger states two endings + // must still reach a reader as ambiguous rather than as merely unfinished. + if (runtimeRead.state === 'present') { const terminalFactResult = classifyRuntimeEventTerminalFact(invocation, runtimeEvents); terminalRuntimeFact = terminalFactResult.fact; diagnostics.push( diff --git a/packages/runtime/src/runtime-read-model.ts b/packages/runtime/src/runtime-read-model.ts index e103ae0dd5..2999f3326d 100644 --- a/packages/runtime/src/runtime-read-model.ts +++ b/packages/runtime/src/runtime-read-model.ts @@ -18,6 +18,7 @@ */ import type { RuntimeEvent } from '@maka/core/runtime-event'; +import { isTerminalRuntimeEvent } from '@maka/core/runtime-event'; import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { StoredMessage, TurnRecord } from '@maka/core/session'; @@ -135,7 +136,10 @@ export class RuntimeReadModel { // holding it. Either way the ledger is the whole truth about it, so the // in-flight projection cache supplies the rows a live turn has not // committed instead of a status field claiming otherwise. - if (!invocation.terminalEvent) { + // An invocation the inventory leaves open because its ledger states two + // endings is not an active run: it ended, twice, and that is a fact to + // reject rather than a turn to project from cache. + if (!invocation.terminalEvent && !runEvents.some(isTerminalRuntimeEvent)) { diagnostics.push( readModelDiagnostic( 'incomplete_event', From 94a6d16879a457a8b90dc81c1f51b4043bd24129 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 14:43:58 +0800 Subject: [PATCH 25/46] refactor(runtime): open an invocation on the spine, not on the operational ledger The opening fact is a RuntimeEvent now, so the store that decides whether it can be written is the RuntimeEventStore. Gating it on the AgentRunStore left a run with a spine and no operational ledger invisible to the inventory, and gated the inventory on a store that no longer holds any part of it. Open it whenever finalize runs too. A run that ends before it ever started would otherwise leave a terminal event on an invocation nothing had opened, which is an ending the inventory cannot see. Generated-by: Claude Code --- packages/runtime/src/agent-run.ts | 21 ++++++++++++++------- packages/runtime/src/runtime-kernel.ts | 2 +- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index 138642fa95..c681a472a5 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -659,7 +659,7 @@ export class AgentRun { } async begin(): Promise { - await this.createRunRecord(); + await this.openInvocation(); let initialRuntimeEventId: string; @@ -743,7 +743,7 @@ export class AgentRun { } async beginOperation(): Promise { - await this.createRunRecord(); + await this.openInvocation(); const startedAt = this.input.now(); this.lastTs = startedAt; @@ -777,7 +777,7 @@ export class AgentRun { } this.continuationActive = true; - await this.createRunRecord(continuation); + await this.openInvocation(continuation); await this.input.continuationFailpoint?.('after_run_created'); const startedAt = this.input.now(); this.lastTs = startedAt; @@ -1065,6 +1065,10 @@ export class AgentRun { async finalize(): Promise { if (this.finalized) return; this.finalized = true; + // A run cannot end without having begun. Finalizing one that never reached + // its start would otherwise leave a terminal event on an invocation the + // inventory cannot see, because nothing opened it. + await this.openInvocation().catch(() => {}); await this.flushRuntimePartialBuffer(true); const lastTs = this.lastTs || this.input.now(); if (this.stopped) this.finalStatus = { status: 'aborted' }; @@ -1103,11 +1107,14 @@ export class AgentRun { await this.finishRun(this.finalStatus, lastTs); } - private async createRunRecord(continuation?: RuntimeContinuation): Promise { - if (!this.input.runStore) { - if (continuation) throw new Error('Runtime continuation requires a durable run store'); - return; + private async openInvocation(continuation?: RuntimeContinuation): Promise { + if (!this.input.runStore && continuation) { + throw new Error('Runtime continuation requires a durable run store'); } + // The opening fact is a RuntimeEvent, so it opens whenever this run has a + // spine to open on. The operational ledger is a separate store with its own + // availability, and a run without one still exists. + if (!this.input.runtimeEventStore) return; const createdAt = continuation && this.input.claimedOpenedAt !== undefined ? this.input.claimedOpenedAt diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index 7cc2e49ac1..80fa08b312 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -846,7 +846,7 @@ export class RuntimeKernel implements RuntimeKernelLike { now: this.deps.now, workspaceIdentity: continuation.safetySnapshot.workspaceIdentity, effectiveOrchestration, - // Round-tripped through the claim on purpose: createRunRecord compares it + // Round-tripped through the claim on purpose: openInvocation compares it // against the opening it computes, so every continuation proves the claim // still authorises the run about to execute. claimedOpening: claim.targetOpening, From 624ba72259e862af56ed149bb73b4c79a1ae5f25 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 14:44:05 +0800 Subject: [PATCH 26/46] fix(runtime): read a terminal event that omits its class as unknown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A terminal RuntimeEvent is immutable and must be the ledger tail, so a failure class or abort source it did not state can never be added afterwards. The header used to hold it, and recovery wrote 'app_restarted' there; with the header gone the read model refused the fact instead, and one such event made the whole Session unreadable. Read the event as what it is: the run ended, and the detail it omitted is `unknown`. The terminal-fact classifier keeps the diagnostic, the projection already rendered `unknown`, and recovery no longer has an incomplete-terminal case to repair — so `incomplete_single_terminal` and the projection's duplicate diagnostics go with it. Generated-by: Claude Code --- .../session-manager-terminal-ledger.test.ts | 92 ++++++++++++------- .../runtime/src/runtime-event-read-model.ts | 34 +++---- packages/runtime/src/session-manager.ts | 10 +- packages/runtime/src/terminal-run-commit.ts | 13 +-- 4 files changed, 77 insertions(+), 72 deletions(-) diff --git a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts index eb322865f1..b064bd9796 100644 --- a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts +++ b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts @@ -257,6 +257,7 @@ describe('SessionManager terminal ledger invariants', () => { }); const manager = new SessionManager({ store, + runStore, runtimeEventStore: runStore, backends, newId: nextId(), @@ -331,6 +332,7 @@ describe('SessionManager terminal ledger invariants', () => { backends.register('ai-sdk', (ctx) => new NeverEndingBackend(ctx)); const manager = new SessionManager({ store, + runStore, runtimeEventStore: runStore, backends, newId: nextId(), @@ -370,6 +372,7 @@ describe('SessionManager terminal ledger invariants', () => { backends.register('ai-sdk', (ctx) => new NeverEndingBackend(ctx)); const manager = new SessionManager({ store, + runStore, runtimeEventStore: runStore, backends, newId: nextId(), @@ -401,6 +404,7 @@ describe('SessionManager terminal ledger invariants', () => { backends.register('ai-sdk', (ctx) => new NeverEndingBackend(ctx)); const manager = new SessionManager({ store, + runStore, runtimeEventStore: runStore, backends, newId: nextId(), @@ -441,6 +445,7 @@ describe('SessionManager terminal ledger invariants', () => { ); const manager = new SessionManager({ store, + runStore, runtimeEventStore: runStore, backends, newId: nextId(), @@ -481,6 +486,7 @@ describe('SessionManager terminal ledger invariants', () => { header: session, userInput: { turnId: 'turn-1', text: 'hello' }, store, + runStore, runtimeEventStore: runStore, newId: nextId(), now: nextNow(22_000), @@ -540,6 +546,7 @@ describe('SessionManager terminal ledger invariants', () => { header: session, userInput: { turnId: 'turn-1', text: 'hello' }, store, + runStore, runtimeEventStore: runStore, newId: nextId(), now: nextNow(23_000), @@ -609,11 +616,15 @@ describe('SessionManager terminal ledger invariants', () => { header: session, userInput: { turnId: 'turn-1', text: 'hello' }, store, + runStore, runtimeEventStore: runStore, newId: nextId(), now: nextNow(24_000), hooks: inertAgentRunHooks(store), }); + // The run is driven past its start here, so open its invocation the way + // starting it would have. + await seedOpening(runStore, { sessionId: session.id, runId: run.runId, turnId: run.turnId }); // A tool fact is what a damaged ledger refuses. await assert.rejects( @@ -677,6 +688,7 @@ describe('SessionManager terminal ledger invariants', () => { header: session, userInput: { turnId: 'turn-1', text: 'hello' }, store, + runStore, runtimeEventStore: runStore, newId: nextId(), now: nextNow(24_100), @@ -718,6 +730,7 @@ describe('SessionManager terminal ledger invariants', () => { header: session, userInput: { turnId: 'turn-1', text: 'hello' }, store, + runStore, runtimeEventStore: runStore, newId, now: nextNow(24_200), @@ -754,6 +767,7 @@ describe('SessionManager terminal ledger invariants', () => { header: session, userInput: { turnId: 'turn-2', text: 'again' }, store, + runStore, runtimeEventStore: runStore, newId, now: nextNow(24_300), @@ -816,6 +830,7 @@ describe('SessionManager terminal ledger invariants', () => { header: session, userInput: { turnId: 'turn-1', text: 'hello' }, store, + runStore, runtimeEventStore: runStore, newId: nextId(), now: nextNow(23_000), @@ -917,14 +932,14 @@ describe('SessionManager terminal ledger invariants', () => { ts: 3, terminalEvent: partialTerminal, }), - /terminal RuntimeEvent must be final before terminal run header/, + /terminal RuntimeEvent must be final before it is committed/, ); assert.strictEqual(await runOutcome(runStore, run.sessionId, run.runId), undefined); }); - test('synthetic cancelled terminal commits the fallback abortSource to the run header', async () => { + test('a synthetic cancelled terminal carries the fallback abortSource', async () => { const runStore = new TinyAgentRunStore(); - const run = makeRunIdentity(); + const run = await seedOpening(runStore, makeRunIdentity()); await commitOrCreateTerminalRunFact({ runtimeEventStore: runStore, @@ -1113,6 +1128,7 @@ describe('SessionManager terminal ledger invariants', () => { header: session, userInput: { turnId: 'turn-1', text: 'hello' }, store, + runStore, runtimeEventStore: runStore, newId: nextId(), now: nextNow(30_000), @@ -1164,6 +1180,7 @@ describe('SessionManager terminal ledger invariants', () => { header: session, userInput: { turnId: 'turn-1', text: 'hello' }, store, + runStore, runtimeEventStore: runStore, newId: nextId(), now: nextNow(41_000), @@ -1207,6 +1224,7 @@ describe('SessionManager terminal ledger invariants', () => { header: session, userInput: { turnId: 'turn-1', text: 'hello' }, store, + runStore, runtimeEventStore: runStore, newId: nextId(), now: nextNow(41_250), @@ -1268,6 +1286,7 @@ describe('SessionManager terminal ledger invariants', () => { header: session, userInput: { turnId: 'turn-1', text: 'hello' }, store, + runStore, runtimeEventStore: runStore, newId: nextId(), now: nextNow(41_500), @@ -1305,10 +1324,6 @@ describe('SessionManager terminal ledger invariants', () => { releaseTerminalAppend.resolve(); await Promise.all([settled, finalized]); - const runEvents = (await runStore.readEvents(session.id, run.runId)).filter( - (event) => event.type === 'run_cancelled', - ); - assert.strictEqual(runEvents.length, 1); const terminalEvents = (await runStore.readRuntimeEvents(session.id, run.runId)).filter( isTerminalRuntimeEvent, ); @@ -1327,6 +1342,7 @@ describe('SessionManager terminal ledger invariants', () => { header: session, userInput: { turnId: 'turn-1', text: 'hello' }, store, + runStore, runtimeEventStore: runStore, newId: nextId(), now: nextNow(41_700), @@ -1389,6 +1405,7 @@ describe('SessionManager terminal ledger invariants', () => { header: session, userInput: { turnId: 'turn-1', text: 'hello' }, store, + runStore, runtimeEventStore: runStore, newId: nextId(), now: nextNow(41_900), @@ -1466,6 +1483,7 @@ describe('SessionManager terminal ledger invariants', () => { header: session, userInput: { turnId: 'turn-1', text: 'hello' }, store, + runStore, runtimeEventStore: runStore, newId: nextId(), now: nextNow(42_000), @@ -1532,7 +1550,7 @@ describe('SessionManager terminal ledger invariants', () => { assert.strictEqual(terminalEvents[0]?.actions?.stateDelta?.abortSource, 'renderer.stop_button'); }); - test('stop settlement probes a latched run store instead of skipping the header commit', async () => { + test('stop settlement probes a latched run store instead of skipping the terminal commit', async () => { const store = new TinySessionStore(); const runStore = new TinyAgentRunStore(); const session = await store.create(makeInput()); @@ -1544,6 +1562,7 @@ describe('SessionManager terminal ledger invariants', () => { header: session, userInput: { turnId: 'turn-1', text: 'hello' }, store, + runStore, runtimeEventStore: runStore, newId: nextId(), now: nextNow(42_100), @@ -1571,7 +1590,7 @@ describe('SessionManager terminal ledger invariants', () => { await run.begin(); // One best-effort trace append failure latches the Run store. Nothing // surfaces to the user, which is what made the pre-fix behaviour a - // silent stop success: commitTerminalRun skips under the latch and the + // silent stop success: the terminal commit skips under the latch and the // run stays non-terminal with no error to retry on. runStore.failNextRunEventAppends = 1; run.recordRunTrace({ @@ -1594,10 +1613,6 @@ describe('SessionManager terminal ledger invariants', () => { ); assert.strictEqual(terminalEvents.length, 1); assert.strictEqual(terminalEvents[0]?.status, 'aborted'); - const cancelled = (await runStore.readEvents(session.id, run.runId)).filter( - (event) => event.type === 'run_cancelled', - ); - assert.strictEqual(cancelled.length, 1); }); test('Runtime execution still commits failed terminal facts when failed turn projection fails', async () => { @@ -1621,11 +1636,12 @@ describe('SessionManager terminal ledger invariants', () => { assert.strictEqual(terminalEvents[0]?.actions?.stateDelta?.failureClass, 'tool_failed'); }); - test('startup recovery reuses an incomplete existing terminal RuntimeEvent instead of appending another', async () => { + test('startup recovery leaves a failed terminal RuntimeEvent that states no failure class alone', async () => { const store = new TinySessionStore(); const runStore = new TinyAgentRunStore(); const manager = new SessionManager({ store, + runStore, runtimeEventStore: runStore, backends: new BackendRegistry(), newId: nextId(), @@ -1665,7 +1681,9 @@ describe('SessionManager terminal ledger invariants', () => { const invocation = await readInvocation(runStore, session.id, run.runId); assert.strictEqual(runtimeInvocationOutcome(invocation), 'failed'); - assert.strictEqual(runtimeInvocationFailureClass(invocation), 'app_restarted'); + // The run already ended, and its ending is immutable, so recovery has + // nothing to attribute and no second record to attribute it to. + assert.strictEqual(runtimeInvocationFailureClass(invocation), undefined); const terminalEvents = (await runStore.readRuntimeEvents(session.id, run.runId)).filter( isTerminalRuntimeEvent, ); @@ -1675,14 +1693,15 @@ describe('SessionManager terminal ledger invariants', () => { runtimeEventStore: runStore, }).getSessionView(session.id); assert.strictEqual(view.terminalFacts.length, 1); - assert.strictEqual(view.terminalFacts[0]?.failureClass, 'app_restarted'); + assert.strictEqual(view.terminalFacts[0]?.failureClass, 'unknown'); }); - test('startup recovery completes an existing aborted terminal RuntimeEvent without appending another', async () => { + test('startup recovery leaves an aborted terminal RuntimeEvent that states no source alone', async () => { const store = new TinySessionStore(); const runStore = new TinyAgentRunStore(); const manager = new SessionManager({ store, + runStore, runtimeEventStore: runStore, backends: new BackendRegistry(), newId: nextId(), @@ -1733,13 +1752,16 @@ describe('SessionManager terminal ledger invariants', () => { assert.strictEqual(view.terminalFacts[0]?.abortSource, 'unknown'); }); - test('RuntimeReadModel reads a non-terminal header when a terminal RuntimeEvent fact exists', async () => { + test('RuntimeReadModel reads a run outcome off its terminal RuntimeEvent fact', async () => { const runStore = new TinyAgentRunStore(); - const run = makeRunIdentity({ - sessionId: 'session-read-model', - runId: 'run-read-model', - turnId: 'turn-read-model', - }); + const run = await seedOpening( + runStore, + makeRunIdentity({ + sessionId: 'session-read-model', + runId: 'run-read-model', + turnId: 'turn-read-model', + }), + ); await runStore.appendRuntimeEvent( run.sessionId, run.runId, @@ -1924,13 +1946,16 @@ describe('SessionManager terminal ledger invariants', () => { ); }); - test('RuntimeReadModel rejects terminal headers when the ledger has no valid terminal fact', async () => { + test('RuntimeReadModel rejects a run whose ledger has no valid terminal fact', async () => { const runStore = new TinyAgentRunStore(); - const run = makeRunIdentity({ - sessionId: 'session-ambiguous-terminal-read', - runId: 'run-ambiguous-terminal-read', - turnId: 'turn-ambiguous-terminal-read', - }); + const run = await seedOpening( + runStore, + makeRunIdentity({ + sessionId: 'session-ambiguous-terminal-read', + runId: 'run-ambiguous-terminal-read', + turnId: 'turn-ambiguous-terminal-read', + }), + ); await runStore.appendRuntimeEvent( run.sessionId, run.runId, @@ -1983,6 +2008,7 @@ describe('SessionManager terminal ledger invariants', () => { const runStore = new TinyAgentRunStore(); const manager = new SessionManager({ store, + runStore, runtimeEventStore: runStore, backends: new BackendRegistry(), newId: nextId(), @@ -2075,6 +2101,7 @@ async function makeHarness( backends.register('ai-sdk', (ctx) => new ScriptBackend(ctx, events)); const manager = new SessionManager({ store, + runStore, runtimeEventStore: runStore, backends, newId: nextId(), @@ -2583,10 +2610,13 @@ function isToolLedgerBearingEvent(event: RuntimeEvent): boolean { } function runtimeEvent(overrides: Partial): RuntimeEvent { + const runId = overrides.runId ?? 'run-1'; return { id: 'rt-event', - invocationId: 'inv-1', - runId: 'run-1', + // One invocation per run here, named by it, exactly as `seedOpening` opens + // it and as a run with no explicit invocation id names its own. + invocationId: runId, + runId, sessionId: 'session-1', turnId: 'turn-1', ts: 2, diff --git a/packages/runtime/src/runtime-event-read-model.ts b/packages/runtime/src/runtime-event-read-model.ts index 3189e761a0..d3cbba2a6e 100644 --- a/packages/runtime/src/runtime-event-read-model.ts +++ b/packages/runtime/src/runtime-event-read-model.ts @@ -592,17 +592,21 @@ export function classifyRuntimeEventTerminalFact( return { fact, diagnostics }; } + // A terminal event is the run's ending, and it is immutable once written, so + // an omitted failure class or abort source is a detail nobody can ever supply + // afterwards. Withholding the fact over it would only leave the reader with a + // run that ended and no way to say so; the omission is worth a diagnostic, not + // a refusal. if (terminalEvent.status === 'failed') { const failureClass = failureClassFromRuntimeEvent(terminalEvent); if (!failureClass) { diagnostics.push( readModelDiagnostic( 'incomplete_event', - 'failed terminal RuntimeEvent requires a stable failure class', + 'failed terminal RuntimeEvent states no failure class', terminalEvent, ), ); - return { diagnostics }; } const fact: RuntimeEventTerminalFact = { runId: invocation.runId, @@ -610,7 +614,7 @@ export function classifyRuntimeEventTerminalFact( runStatus: 'failed', turnStatus: 'failed', terminalEvent, - failureClass, + failureClass: failureClass ?? 'unknown', diagnostics, }; return { fact, diagnostics }; @@ -621,11 +625,10 @@ export function classifyRuntimeEventTerminalFact( diagnostics.push( readModelDiagnostic( 'incomplete_event', - 'aborted terminal RuntimeEvent requires an abort source', + 'aborted terminal RuntimeEvent states no abort source', terminalEvent, ), ); - return { diagnostics }; } const fact: RuntimeEventTerminalFact = { runId: invocation.runId, @@ -633,7 +636,7 @@ export function classifyRuntimeEventTerminalFact( runStatus: 'cancelled', turnStatus: 'aborted', terminalEvent, - abortSource, + abortSource: abortSource ?? 'unknown', diagnostics, }; return { fact, diagnostics }; @@ -1202,22 +1205,9 @@ function projectTerminalTurnState( kind: 'step_limit', }); } - if (status === 'failed' && !failureClass) { - diagnostic( - state, - event, - 'incomplete_event', - 'failed terminal event did not carry an exact failure class', - ); - } - if (status === 'aborted' && !abortSource) { - diagnostic( - state, - event, - 'incomplete_event', - 'abortSource is not present in the terminal RuntimeEvent', - ); - } + // An omitted failure class or abort source is `classifyRuntimeEventTerminalFact`'s + // observation to make. Repeating it here would only turn a transcript row that + // already reads `unknown` into an unreadable Session. return true; } diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index e35b0712b3..5296d3e9ba 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -4630,15 +4630,7 @@ export class SessionManager { ): Promise { if (!this.deps.runStore || !this.deps.runtimeEventStore) return false; const ts = this.deps.now(); - const terminalLedger = classifyTerminalRuntimeLedger( - inspected.invocation, - inspected.runtimeEvents, - ); - const existingTerminal = - inspected.terminalRuntimeFact?.terminalEvent ?? - (terminalLedger.kind === 'incomplete_single_terminal' - ? terminalLedger.terminalEvent - : undefined); + const existingTerminal = inspected.terminalRuntimeFact?.terminalEvent; const status = existingTerminal ? (terminalRunStatusFromRuntimeEvent(existingTerminal) ?? decision.status) : decision.status; diff --git a/packages/runtime/src/terminal-run-commit.ts b/packages/runtime/src/terminal-run-commit.ts index 760da632c3..58e39d7f2b 100644 --- a/packages/runtime/src/terminal-run-commit.ts +++ b/packages/runtime/src/terminal-run-commit.ts @@ -43,11 +43,6 @@ export type TerminalRuntimeLedgerClassification = kind: 'none'; terminalEvents: readonly RuntimeEvent[]; } - | { - kind: 'incomplete_single_terminal'; - terminalEvent: RuntimeEvent; - terminalEvents: readonly RuntimeEvent[]; - } | { kind: 'ambiguous'; terminalEvents: readonly RuntimeEvent[]; @@ -69,11 +64,9 @@ export function classifyTerminalRuntimeLedger( if (fact) { return { kind: 'fact', fact, terminalEvents }; } - return { - kind: 'incomplete_single_terminal', - terminalEvent: terminalEvents[0]!, - terminalEvents, - }; + // The one terminal event carries no terminal status, so it ends the stream + // without ending the run. + return { kind: 'none', terminalEvents }; } export interface CommitTerminalRunWithRuntimeFactInput extends RunIdentity { From 4e21c860b579ff18c0cd2b3287fca905d2fee75b Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 14:44:09 +0800 Subject: [PATCH 27/46] test(runtime): drive steering recovery through the invocation spine Both tests reached for state the spine no longer keeps: one repaired a steering message on an invocation nothing had opened, the other awaited a settlement barrier that lives in acceptMappedEvent rather than in recordSessionEvent. Seed the opening and drive the real barrier. Generated-by: Claude Code --- .../agent-run-steering-recovery.test.ts | 45 +++++++++++++++---- 1 file changed, 37 insertions(+), 8 deletions(-) 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 f4546f60ff..0c1d48965b 100644 --- a/packages/runtime/src/__tests__/agent-run-steering-recovery.test.ts +++ b/packages/runtime/src/__tests__/agent-run-steering-recovery.test.ts @@ -32,6 +32,7 @@ import { AgentRun } from '../agent-run.js'; import { RuntimeLedgerRepair } from '../runtime-ledger-repair.js'; import { buildStatusPatch } from '../session-projection-helpers.js'; import { waitFor as pollFor } from '@maka/core/test-only/async-primitives'; +import { seedInvocation } from './invocation-fixture.js'; test('rejects an invalid tool mode before a durable AgentRun can be created', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-agent-run-tool-mode-')); @@ -339,6 +340,13 @@ test('recovers a steering transcript message from the committed RuntimeEvent led ], steering: true as const, }; + await seedInvocation(runtimeEventStore, { + sessionId: session.id, + invocationId: 'invocation-steering-crash-cut', + runId, + turnId, + openedAt: 1, + }); const runtimeEvent: RuntimeEvent = { id: 'runtime-steering-crash-cut', invocationId: 'invocation-steering-crash-cut', @@ -402,6 +410,12 @@ test('awaits the durable settlement fact before accepting an interaction resume' const runId = 'run-status-barrier'; const turnId = 'turn-status-barrier'; await store.updateHeader(session.id, buildStatusPatch('waiting_for_user', 1)); + const { invocationId } = await seedInvocation(runtimeEventStore, { + sessionId: session.id, + runId, + turnId, + openedAt: 1, + }); const appendStarted = deferred(); const allowAppend = deferred(); const delayedRuntimeEventStore = { @@ -443,14 +457,29 @@ test('awaits the durable settlement fact before accepting an interaction resume' }); let accepted = false; const accepting = run - .recordSessionEvent({ - type: 'user_question_answer_ack', - id: 'answer-ack', - turnId, - ts: 2, - requestId: 'question-1', - toolUseId: 'tool-1', - }) + .acceptMappedEvent( + { + type: 'user_question_answer_ack', + id: 'answer-ack', + turnId, + ts: 2, + requestId: 'question-1', + toolUseId: 'tool-1', + }, + { + id: 'status-event', + invocationId, + runId, + sessionId: session.id, + turnId, + ts: 2, + partial: false, + role: 'system', + author: 'user', + actions: { userQuestionAnswerAccepted: { requestId: 'question-1' } }, + refs: { toolCallId: 'tool-1' }, + } satisfies RuntimeEvent, + ) .then(() => { accepted = true; }); From 722efc5242d76fa3d67d27d0daeecb7cfbf08998 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 15:10:10 +0800 Subject: [PATCH 28/46] test(runtime): seed invocations that operational tests only implied These tests wrote operational rows for a run nothing had opened, and read back inventories keyed by an invocation id two runs shared. Both worked only because the run header stood in for the opening; with the header gone the seeds have to state what they always meant. The checkpoint-unavailability test waited on a trace-failure row that the retired `run_created` write used to produce. It now says directly what it was arranging: the store goes unavailable just before the checkpoint write. Generated-by: Claude Code --- .../src/__tests__/session-manager.test.ts | 579 +++--------------- packages/runtime/src/agent-run.ts | 6 +- 2 files changed, 95 insertions(+), 490 deletions(-) diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 90e2b21eb8..058c43f783 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -827,7 +827,10 @@ describe('SessionManager graph operator provisioning', () => { role: 'system', author: 'system', status: failed ? 'failed' : 'completed', - actions: { endInvocation: true }, + actions: { + endInvocation: true, + ...(failed ? { stateDelta: { failureClass: 'branch_failed' } } : {}), + }, }), ]); outputs.push( @@ -4540,8 +4543,6 @@ describe('SessionManager permission mode updates', () => { ['turn-2', 'completed'], ], ); - const firstEvents = await runStore.readEvents(session.id, finalRuns[0]!.runId); - assert.ok(firstEvents.map((event) => event.type).includes('run_created')); const summary = await manager.setPermissionMode(session.id, 'bypass'); assert.strictEqual(summary.permissionMode, 'bypass'); @@ -4932,8 +4933,8 @@ describe('SessionManager permission mode updates', () => { ); assert.strictEqual(backend?.sendInputs[0]?.toolMode, 'code_mode'); - const [run] = await runStore.listSessionInvocations(session.id); - if (!run) throw new Error('AgentRunStore run was not created'); + const [run] = await runtimeEventStore.listSessionInvocations(session.id); + if (!run) throw new Error('the run opened no invocation'); const runtimeEvents = await runtimeEventStore.readRuntimeEvents(session.id, run.runId); assert.deepStrictEqual(backend?.sendInputs[0]?.headAnchorRuntimeEvent, runtimeEvents[1]); assert.deepStrictEqual( @@ -4958,7 +4959,7 @@ describe('SessionManager permission mode updates', () => { assert.strictEqual(runtimeEvents[3]?.status, 'completed'); }); - test('the invocation opening fact is durable before the run row and any dispatch', async () => { + test('the invocation opening fact is durable before any dispatch', async () => { const store = new MemorySessionStore(); const trace: string[] = []; const runStore = new MemoryAgentRunStore({ @@ -4996,10 +4997,6 @@ describe('SessionManager permission mode updates', () => { false, 'the opening fact must be the first RuntimeEvent of the invocation', ); - assert.ok( - openingIndex < trace.indexOf('ledger:run_created'), - 'the opening fact must precede the operational run_created row', - ); assert.ok( openingIndex < trace.findIndex((entry) => entry.startsWith('runtime:text')), 'the opening fact must precede the first model-visible event of the turn', @@ -5171,8 +5168,8 @@ describe('SessionManager permission mode updates', () => { assert.strictEqual(Object.isFrozen(providerInput?.headAnchorRuntimeEvent), true); assert.strictEqual(Object.isFrozen(headContent), true); - const [run] = await runStore.listSessionInvocations(session.id); - if (!run) throw new Error('AgentRunStore run was not created'); + const [run] = await runtimeEventStore.listSessionInvocations(session.id); + if (!run) throw new Error('the run opened no invocation'); const [openingFact, storedUserEvent] = await durableEvents.readRuntimeEvents( session.id, run.runId, @@ -5331,7 +5328,9 @@ describe('SessionManager permission mode updates', () => { const sourceRunId = 'source-run'; const sourceTurnId = 'source-turn'; const sourceInvocationId = 'source-invocation'; - await seedInvocationFromHeader(runStore, { + // The source run states its own terminal event below, so only its opening + // is seeded here: a second ending would leave the invocation ambiguous. + await seedInvocationOpening(runStore, { runId: sourceRunId, invocationId: sourceInvocationId, sessionId: session.id, @@ -5507,7 +5506,8 @@ describe('SessionManager permission mode updates', () => { invocationId: sourceInvocationId, runId: sourceRunId, turnId: sourceTurnId, - highWater: sourceEvents.length, + // The opening fact is event 1 of the source invocation. + highWater: sourceEvents.length + 1, prefixDigest: plan.continuation.boundary?.segments.at(-1)?.prefixDigest, }, replayManifestDigest: plan.continuation.boundary?.manifestDigest, @@ -5526,7 +5526,10 @@ describe('SessionManager permission mode updates', () => { (await store.readMessages(session.id)).some((message) => message.type === 'user'), false, ); - assert.deepStrictEqual(await runStore.readRuntimeEvents(session.id, sourceRunId), sourceEvents); + assert.deepStrictEqual( + (await runStore.readRuntimeEvents(session.id, sourceRunId)).slice(1), + sourceEvents, + ); assert.deepStrictEqual( lifecycleEvents.map((event) => event.type), ['plan_approved', 'execution_started', 'execution_completed'], @@ -5637,7 +5640,8 @@ describe('SessionManager permission mode updates', () => { const sourceRunId = 'source-run-cross-route'; const sourceInvocationId = 'source-invocation-cross-route'; const sourceTurnId = 'source-turn-cross-route'; - await seedInvocationFromHeader(runStore, { + // The source run states its own terminal event below. + await seedInvocationOpening(runStore, { runId: sourceRunId, invocationId: sourceInvocationId, sessionId: session.id, @@ -6328,7 +6332,9 @@ describe('SessionManager permission mode updates', () => { test('does not call the backend and claim recovery closes a continuation-start persistence failure', async () => { const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore({ failRuntimeEventAppendAfter: 2 }); + // The source invocation's opening and its two events are the three appends + // that must succeed; the continuation-start is the one that fails. + const runStore = new MemoryAgentRunStore({ failRuntimeEventAppendAfter: 3 }); const backends = new BackendRegistry(); let backendCalls = 0; backends.register( @@ -6352,7 +6358,9 @@ describe('SessionManager permission mode updates', () => { const sourceRunId = 'source-run-write-failure'; const sourceTurnId = 'source-turn-write-failure'; const sourceInvocationId = 'source-invocation-write-failure'; - await seedInvocationFromHeader(runStore, { + // The source states its own terminal event below. + await seedInvocationOpening(runStore, { + invocationId: sourceInvocationId, runId: sourceRunId, sessionId: session.id, turnId: sourceTurnId, @@ -6413,8 +6421,9 @@ describe('SessionManager permission mode updates', () => { ); assert.strictEqual(backendCalls, 0); - const targetRun = await readInvocation(runStore, session.id, plan.continuation.runId); - assert.strictEqual(targetRun.terminalEvent, undefined); + // The continuation-start never reached the ledger, so nothing opened the + // target invocation and the inventory does not know it. + await assert.rejects(readInvocation(runStore, session.id, plan.continuation.runId)); assert.deepStrictEqual( await runStore.readRuntimeEvents(session.id, plan.continuation.runId), [], @@ -6626,7 +6635,7 @@ describe('SessionManager permission mode updates', () => { ); }); - test('revalidates terminal ledger consistency before executing a planned continuation', async () => { + test('refuses a planned continuation whose source ledger changed after planning', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); const backends = new BackendRegistry(); @@ -6714,9 +6723,11 @@ describe('SessionManager permission mode updates', () => { }), ); + // The second ending moved the source boundary the plan was cut against, so + // the plan no longer describes the ledger it would continue from. await expectRejects( collectSessionEvents(manager.resumeSafeBoundaryContinuation(plan.continuation)), - /terminal/i, + /continuation boundary changed/i, ); assert.strictEqual(backendCalls, 0); }); @@ -6813,18 +6824,9 @@ describe('SessionManager permission mode updates', () => { ); assert.strictEqual(backendCalls, 0); - assert.ok(!(await manager.recoverInterruptedSessions()).includes(session.id)); - const durableRepairEvents = await runStore.readRuntimeEvents( - session.id, - plan.continuation.runId, - ); - assert.strictEqual(durableRepairEvents.length, 2); - assert.strictEqual(durableRepairEvents.filter(isTerminalRuntimeEvent).length, 1); - assert.strictEqual( - (await readInvocation(runStore, session.id, plan.continuation.runId)).terminalEvent, - undefined, - ); - + // One pass finishes the claim: the continuation-start the crash never + // committed, and the terminal event that ends a run nothing will dispatch. + // There is no second record left to settle afterwards. assert.ok((await manager.recoverInterruptedSessions()).includes(session.id)); const repairedRun = await readInvocation(runStore, session.id, plan.continuation.runId); assert.strictEqual(runtimeInvocationOutcome(repairedRun), 'failed'); @@ -7250,113 +7252,6 @@ describe('SessionManager permission mode updates', () => { assert.strictEqual(backendCalls, 0); }); - test('sendMessage preserves token usage fields while resuming from an empty prior runtime ledger', 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 newId = nextId(); - const now = nextNow(7_000); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - newId, - now, - }); - const session = await manager.createSession(makeInput()); - await store.appendMessages(session.id, [ - { type: 'user', id: 'legacy-user', turnId: 'turn-1', ts: 101, text: 'prior question' }, - { - type: 'assistant', - id: 'legacy-assistant', - turnId: 'turn-1', - ts: 102, - text: 'prior answer', - modelId: 'fake-model', - }, - { - type: 'token_usage', - id: 'legacy-usage', - turnId: 'turn-1', - ts: 103, - input: 100, - output: 25, - runtimeSteps: 3, - contextRemaining: 9000, - providerRequestTraceId: 'provider-trace-1', - }, - { - type: 'turn_state', - id: 'legacy-state', - turnId: 'turn-1', - ts: 104, - status: 'completed', - partialOutputRetained: true, - }, - ]); - await seedInvocationFromHeader( - runStore, - makeRunHeader({ - sessionId: session.id, - runId: 'run-1', - turnId: 'turn-1', - status: 'completed', - createdAt: 100, - updatedAt: 104, - completedAt: 104, - }), - ); - - const restarted = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - newId, - now, - }); - const sessionEvents = await collectSessionEvents( - restarted.sendMessage(session.id, { turnId: 'turn-2', text: 'follow up' }), - ); - - assert.deepStrictEqual( - sessionEvents.map((event) => event.type), - ['text_delta', 'complete'], - ); - assert.deepStrictEqual( - backend?.sendInputs[0]?.context.map((message) => message.type), - ['user', 'assistant', 'token_usage', 'turn_state'], - ); - assert.deepStrictEqual( - backend?.sendInputs[0]?.context.map((message) => - 'text' in message ? message.text : message.type, - ), - ['prior question', 'prior answer', 'token_usage', 'turn_state'], - ); - assert.deepStrictEqual( - backend?.sendInputs[0]?.runtimeContext?.map((event) => event.runId), - ['run-1', 'run-1', 'run-1', 'run-1'], - ); - const resumedUsage = backend?.sendInputs[0]?.runtimeContext?.find( - (event) => event.actions?.tokenUsage, - ); - assert.strictEqual(resumedUsage?.actions?.tokenUsage?.runtimeSteps, 3); - assert.strictEqual(resumedUsage?.actions?.tokenUsage?.contextRemaining, 9000); - assert.strictEqual(resumedUsage?.refs?.providerRequestTraceId, 'provider-trace-1'); - const repairedRuntimeEvents = await runStore.readRuntimeEvents(session.id, 'run-1'); - assert.deepStrictEqual( - repairedRuntimeEvents.map((event) => event.refs?.storedMessageId), - ['legacy-user', 'legacy-assistant', 'legacy-usage', 'legacy-state'], - ); - assert.strictEqual(repairedRuntimeEvents.at(-1)?.status, 'completed'); - }); - test('sendMessage completes interrupted imported history beside native runs', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore({ failRuntimeEventAppendAfter: 3 }); @@ -7509,99 +7404,6 @@ describe('SessionManager permission mode updates', () => { assert.strictEqual((await runStore.listSessionInvocations(session.id)).length, 0); }); - test('sendMessage rejects prior runtime context without a valid terminal fact', 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, - newId: nextId(), - now: nextNow(7_050), - }); - const session = await manager.createSession(makeInput()); - await seedRuntimeRun( - runStore, - makeRunHeader({ - sessionId: session.id, - runId: 'run-1', - turnId: 'turn-1', - status: 'completed', - createdAt: 100, - updatedAt: 103, - completedAt: 103, - }), - [ - runtimeEvent({ - id: 'rt-user', - sessionId: session.id, - runId: 'run-1', - turnId: 'turn-1', - ts: 101, - role: 'user', - author: 'user', - content: { kind: 'text', text: 'prior question' }, - }), - runtimeEvent({ - id: 'rt-assistant', - sessionId: session.id, - runId: 'run-1', - turnId: 'turn-1', - ts: 102, - role: 'model', - author: 'agent', - content: { kind: 'text', text: 'prior answer' }, - }), - runtimeEvent({ - id: 'rt-completed-a', - sessionId: session.id, - runId: 'run-1', - turnId: 'turn-1', - ts: 103, - status: 'completed', - actions: { endInvocation: true }, - }), - runtimeEvent({ - id: 'rt-completed-b', - sessionId: session.id, - runId: 'run-1', - turnId: 'turn-1', - ts: 104, - status: 'completed', - actions: { endInvocation: true }, - }), - ], - ); - - await expectRejects( - drain(manager.sendMessage(session.id, { turnId: 'turn-2', text: 'follow up' })), - /valid terminal fact/, - ); - assert.strictEqual(backend?.sendInputs.length ?? 0, 0); - const currentRun = (await runStore.listSessionInvocations(session.id)).find( - (run) => run.turnId === 'turn-2', - ); - if (!currentRun) throw new Error('current AgentRunStore run was not created'); - assert.strictEqual(runtimeInvocationOutcome(currentRun), 'failed'); - assert.strictEqual(runtimeInvocationFailureClass(currentRun), 'missing_terminal_event'); - const currentTerminalEvents = ( - await runStore.readRuntimeEvents(session.id, currentRun.runId) - ).filter(isTerminalRuntimeEvent); - assert.strictEqual(currentTerminalEvents.length, 1); - assert.strictEqual(currentTerminalEvents[0]?.status, 'failed'); - assert.strictEqual( - currentTerminalEvents[0]?.actions?.stateDelta?.failureClass, - 'missing_terminal_event', - ); - }); - test('sendMessage replays a prior run left non-terminal by an unanswered interaction', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); @@ -7661,7 +7463,7 @@ describe('SessionManager permission mode updates', () => { assert.deepStrictEqual( backend?.sendInputs[0]?.runtimeContext?.map((event) => event.id), - ['rt-user', 'rt-assistant'], + ['run-1-invocation-opened', 'rt-user', 'rt-assistant'], ); }); @@ -8070,171 +7872,6 @@ describe('SessionManager permission mode updates', () => { ]); }); - test('getMessages repairs a non-empty RuntimeEvent ledger that is missing only the terminal fact', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const manager = makeManagerForReadCutover(store, runStore); - const session = await manager.createSession(makeInput()); - await store.appendMessages(session.id, [ - { type: 'user', id: 'legacy-user', turnId: 'turn-1', ts: 101, text: 'question' }, - { - type: 'assistant', - id: 'legacy-assistant', - turnId: 'turn-1', - ts: 102, - text: 'answer', - modelId: 'fake-model', - }, - { - type: 'turn_state', - id: 'legacy-state', - turnId: 'turn-1', - ts: 103, - status: 'completed', - partialOutputRetained: true, - }, - ]); - await seedRuntimeRun( - runStore, - makeRunHeader({ - sessionId: session.id, - runId: 'run-1', - turnId: 'turn-1', - status: 'completed', - createdAt: 100, - updatedAt: 103, - completedAt: 103, - }), - [ - runtimeEvent({ - id: 'rt-user', - sessionId: session.id, - runId: 'run-1', - turnId: 'turn-1', - ts: 101, - role: 'user', - author: 'user', - content: { kind: 'text', text: 'question' }, - }), - runtimeEvent({ - id: 'rt-assistant', - sessionId: session.id, - runId: 'run-1', - turnId: 'turn-1', - ts: 102, - role: 'model', - author: 'agent', - content: { kind: 'text', text: 'answer' }, - }), - ], - ); - - const messages = await manager.getMessages(session.id); - const runtimeEvents = await runStore.readRuntimeEvents(session.id, 'run-1'); - - assert.deepStrictEqual( - messages.map((message) => message.type), - ['user', 'assistant', 'turn_state'], - ); - assert.deepStrictEqual(messages.at(-1), { - type: 'turn_state', - id: 'legacy-state', - turnId: 'turn-1', - ts: 103, - status: 'completed', - partialOutputRetained: true, - }); - assert.deepStrictEqual( - runtimeEvents.slice(0, 2).map((event) => event.id), - ['rt-user', 'rt-assistant'], - ); - assert.strictEqual(runtimeEvents.at(-1)?.status, 'completed'); - assert.strictEqual(runtimeEvents.at(-1)?.refs?.storedMessageId, 'legacy-state'); - }); - - test('getMessages repair writes terminal turn_state for a continuation run', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const manager = makeManagerForReadCutover(store, runStore); - const session = await manager.createSession(makeInput()); - const sourceRunId = 'repair-source-run'; - const sourceTurnId = 'repair-source-turn'; - const sourceInvocationId = 'repair-source-invocation'; - await seedRuntimeRun( - runStore, - makeRunHeader({ - sessionId: session.id, - runId: sourceRunId, - turnId: sourceTurnId, - status: 'completed', - createdAt: 100, - updatedAt: 101, - completedAt: 101, - }), - [ - runtimeEvent({ - id: 'repair-source-complete', - invocationId: sourceInvocationId, - sessionId: session.id, - runId: sourceRunId, - turnId: sourceTurnId, - ts: 101, - status: 'completed', - actions: { endInvocation: true }, - }), - ], - ); - await seedRuntimeRun( - runStore, - makeRunHeader({ - sessionId: session.id, - runId: 'repair-continuation-run', - turnId: 'repair-continuation-turn', - status: 'completed', - parentRunId: sourceRunId, - parentTurnId: sourceTurnId, - continuationSource: { - sourceInvocationId, - sourceRunId, - sourceTurnId, - sourceRuntimeEventHighWater: 1, - }, - createdAt: 102, - updatedAt: 104, - completedAt: 104, - }), - [ - runtimeEvent({ - id: 'repair-continuation-text', - invocationId: 'repair-continuation-invocation', - sessionId: session.id, - runId: 'repair-continuation-run', - turnId: 'repair-continuation-turn', - ts: 103, - role: 'model', - author: 'agent', - content: { kind: 'text', text: 'retained continuation output' }, - }), - ], - ); - - await manager.getMessages(session.id); - - const cachedMessages = await store.readMessages(session.id); - assert.partialDeepStrictEqual( - cachedMessages.find( - (message) => message.type === 'turn_state' && message.turnId === 'repair-continuation-turn', - ), - { - type: 'turn_state', - status: 'failed', - errorClass: 'missing_terminal_event', - parentTurnId: sourceTurnId, - partialOutputRetained: false, - }, - ); - }); - test('getMessages repairs missing failed header class from an existing terminal RuntimeEvent', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); @@ -8322,7 +7959,7 @@ describe('SessionManager permission mode updates', () => { assert.strictEqual(runtimeEvents.filter((event) => event.status === 'failed').length, 1); }); - test('getMessages uses fallback failed header class when an existing terminal RuntimeEvent has no class', async () => { + test('getMessages leaves a failed terminal RuntimeEvent that states no class alone', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); const manager = makeManagerForReadCutover(store, runStore); @@ -8386,76 +8023,16 @@ describe('SessionManager permission mode updates', () => { ); await manager.getMessages(session.id); - await manager.getMessages(session.id); + const messages = await manager.getMessages(session.id); const repairedRun = await readInvocation(runStore, session.id, 'run-1'); const runtimeEvents = await runStore.readRuntimeEvents(session.id, 'run-1'); - assert.strictEqual(runtimeInvocationFailureClass(repairedRun), 'missing_terminal_event'); - assert.strictEqual(runtimeEvents.filter((event) => event.status === 'failed').length, 1); - }); - - test('getMessages serializes concurrent terminal repairs for the same run', async () => { - const store = new MemorySessionStore(); - let repairReads = 0; - const runStore = new MemoryAgentRunStore({ - beforeRuntimeEventRead: async (_sessionId, runId) => { - if (runId !== 'run-1' || repairReads >= 2) return; - repairReads += 1; - await Promise.resolve(); - }, - }); - const manager = makeManagerForReadCutover(store, runStore); - const session = await manager.createSession(makeInput()); - await store.appendMessages(session.id, [ - { type: 'user', id: 'legacy-user', turnId: 'turn-1', ts: 101, text: 'question' }, - { - type: 'assistant', - id: 'legacy-assistant', - turnId: 'turn-1', - ts: 102, - text: 'answer', - modelId: 'fake-model', - }, - ]); - await seedRuntimeRun( - runStore, - makeRunHeader({ - sessionId: session.id, - runId: 'run-1', - turnId: 'turn-1', - status: 'completed', - createdAt: 100, - updatedAt: 103, - completedAt: 103, - }), - [ - runtimeEvent({ - id: 'rt-user', - sessionId: session.id, - runId: 'run-1', - turnId: 'turn-1', - ts: 101, - role: 'user', - author: 'user', - content: { kind: 'text', text: 'question' }, - }), - runtimeEvent({ - id: 'rt-assistant', - sessionId: session.id, - runId: 'run-1', - turnId: 'turn-1', - ts: 102, - role: 'model', - author: 'agent', - content: { kind: 'text', text: 'answer' }, - }), - ], - ); - - await Promise.all([manager.getMessages(session.id), manager.getMessages(session.id)]); - - const runtimeEvents = await runStore.readRuntimeEvents(session.id, 'run-1'); + // The run already ended. Its ending is immutable, so the class it never + // stated stays unstated, and no read appends a second ending to supply one. + assert.strictEqual(runtimeInvocationFailureClass(repairedRun), undefined); assert.strictEqual(runtimeEvents.filter((event) => event.status === 'failed').length, 1); + const [turnState] = messages.filter((message) => message.type === 'turn_state'); + assert.partialDeepStrictEqual(turnState, { status: 'failed', errorClass: 'unknown' }); }); test('getMessages includes continuation output without inlining child agent output', async () => { @@ -9374,8 +8951,9 @@ describe('SessionManager permission mode updates', () => { const secondInput = backendInstances[0]?.sendInputs[0]; if (!secondInput) throw new Error('backend input was not recorded'); assert.deepStrictEqual( + // The opening fact rides the same context as the three events it opened. secondInput.runtimeContext?.map((event) => event.turnId), - ['turn-1', 'turn-1', 'turn-1'], + ['turn-1', 'turn-1', 'turn-1', 'turn-1'], ); const turnState = secondInput.context.find( (message) => message.type === 'turn_state' && message.turnId === 'turn-1', @@ -10339,7 +9917,7 @@ describe('SessionManager permission mode updates', () => { assert.strictEqual(output.invocation.runId, 'child-run'); assert.deepStrictEqual( output.runtimeEvents.map((event) => event.id), - ['child-user', 'child-answer', 'child-complete'], + ['child-run-invocation-opened', 'child-user', 'child-answer', 'child-complete'], ); assert.deepStrictEqual( output.artifacts.map((artifact) => artifact.id), @@ -11232,9 +10810,6 @@ describe('SessionManager permission mode updates', () => { const [run] = await runStore.listSessionInvocations(session.id); assert.strictEqual(run && runtimeInvocationOutcome(run), 'cancelled'); assert.strictEqual(run && runtimeInvocationFailureClass(run), undefined); - const events = (await runStore.readEvents(session.id, run!.runId)).map((event) => event.type); - assert.ok(events.includes('run_cancelled')); - assert.strictEqual(events.includes('run_failed'), false); }); test('durable run ledger records lifecycle trace events and redacts obvious secrets', async () => { @@ -11256,7 +10831,7 @@ describe('SessionManager permission mode updates', () => { const [run] = await runStore.listSessionInvocations(session.id); assert.partialDeepStrictEqual(run?.opening.route, { - provenance: 'runtime', + provenance: 'unknown', backendKind: 'ai-sdk', llmConnectionSlug: 'fake', modelId: 'fake-model', @@ -11266,7 +10841,6 @@ describe('SessionManager permission mode updates', () => { const events = await runStore.readEvents(session.id, run!.runId); assert.ok(events.map((event) => event.type).includes('model_stream_started')); assert.ok(events.map((event) => event.type).includes('model_stream_completed')); - assert.ok(events.map((event) => event.type).includes('run_completed')); assert.strictEqual(JSON.stringify(events).includes('sk-live-secret-token-value'), false); }); @@ -11452,12 +11026,13 @@ describe('SessionManager permission mode updates', () => { test('rejects checkpoint recording after the current AgentRun store becomes unavailable', async () => { const store = new MemorySessionStore(); - const runStoreUnavailable = makeGate(); const writeOutcomes: string[] = []; + // The store goes unavailable partway through the run, right before the + // checkpoint write asks it for anything. + let runStoreUnavailable = false; const runStore = new MemoryAgentRunStore({ - beforeAgentRunEventAppend: async (_sessionId, _runId, event) => { - if (event.type === 'run_created') throw new Error('run ledger append failed'); - if (event.type === 'trace_write_failed') runStoreUnavailable.release(); + beforeAgentRunEventAppend: async () => { + if (runStoreUnavailable) throw new Error('run ledger append failed'); }, }); const backends = new BackendRegistry(); @@ -11466,7 +11041,9 @@ describe('SessionManager permission mode updates', () => { (ctx) => new CheckpointRecorderContractProbeBackend( ctx, - async () => runStoreUnavailable.promise, + async () => { + runStoreUnavailable = true; + }, writeOutcomes, ), ); @@ -13694,6 +13271,9 @@ class MemoryAgentRunStore } async claimContinuation(input: { claim: ContinuationClaimV1 }) { + if (this.options.failContinuationCreate) { + throw new Error('continuation claim create failed'); + } const claim = decodeContinuationClaim(input.claim); const existing = this.continuationClaims.get(claim.boundaryDigest); if (existing) return { kind: 'existing' as const, claim: existing }; @@ -14513,8 +14093,19 @@ async function seedInvocationTerminal( role: 'system', author: 'system', status: header.status === 'cancelled' ? 'aborted' : header.status, - ...(header.failureClass ? { failureClass: header.failureClass } : {}), - ...(header.abortSource ? { abortSource: header.abortSource } : {}), + // The failure class and abort source live where every reader looks for + // them: on the terminal event's own state delta. + actions: { + endInvocation: true, + ...(header.failureClass || header.abortSource + ? { + stateDelta: { + ...(header.failureClass ? { failureClass: header.failureClass } : {}), + ...(header.abortSource ? { abortSource: header.abortSource } : {}), + }, + } + : {}), + }, ...(header.failureMessage ? { content: { kind: 'error' as const, message: header.failureMessage } } : {}), @@ -14806,28 +14397,40 @@ async function seedRun( /** * Seed one invocation whose ledger the test writes itself. * - * The opening always comes first. The terminal event comes from the header only - * when the test did not already state one, so a run never ends twice. + * The opening always comes first, and it opens the invocation the test's own + * events name, so nothing the test writes lands outside the run it seeded. The + * terminal event comes from the header only when the test did not already state + * one, so a run never ends twice. */ async function seedRuntimeRun( runStore: RuntimeEventStore, header: TestRunHeader, events: RuntimeEvent[], ): Promise { - await seedInvocationOpening(runStore, header); + const seeded: TestRunHeader = { + ...header, + invocationId: + header.invocationId ?? + events.find((event) => event.runId === header.runId)?.invocationId ?? + header.runId, + }; + await seedInvocationOpening(runStore, seeded); for (const event of events) { - await runStore.appendRuntimeEvent(header.sessionId, header.runId, event); + await runStore.appendRuntimeEvent(seeded.sessionId, seeded.runId, event); } if (!events.some((event) => event.status !== undefined)) { - await seedInvocationTerminal(runStore, header); + await seedInvocationTerminal(runStore, seeded); } } function runtimeEvent(overrides: Partial): RuntimeEvent { + const runId = overrides.runId ?? 'run-1'; return { id: 'rt-event', - invocationId: 'inv-1', - runId: 'run-1', + // One invocation per run unless a test says otherwise. A shared default + // would put two runs' events on one invocation, which is two endings. + invocationId: runId, + runId, sessionId: 'session-1', turnId: 'turn-1', ts: 100, diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index c681a472a5..5fdfbe80ea 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -1067,8 +1067,10 @@ export class AgentRun { this.finalized = true; // A run cannot end without having begun. Finalizing one that never reached // its start would otherwise leave a terminal event on an invocation the - // inventory cannot see, because nothing opened it. - await this.openInvocation().catch(() => {}); + // inventory cannot see, because nothing opened it. A continuation is the + // exception at both ends: its opening rides the continuation-start event, + // and a continuation that never committed one has no invocation to end. + if (!this.input.commitContinuationStart) await this.openInvocation().catch(() => {}); await this.flushRuntimePartialBuffer(true); const lastTs = this.lastTs || this.input.now(); if (this.stopped) this.finalStatus = { status: 'aborted' }; From fe469fdad988aca15ce38fb6ae068e0ae4a4eebe Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 15:27:02 +0800 Subject: [PATCH 29/46] refactor(runtime): finish reading every run off its own events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last places still asking the header what a run was are gone. `inspectAgentRunReadModel` looked its invocation up by the id it was given as an invocation id, which is right only while a run and its invocation share one identity — a continuation is a new run on the invocation it resumes. It now finds the invocation by run, and the store's own `readInvocation` fast path goes with the mistake. The conversation-copy guard against "a retained AgentRun without RuntimeEvent facts" describes an invocation with no events. An invocation is its opening event, so that state no longer exists; the guard and its test are removed. `after_run_created` named the header row that a continuation wrote before its durable start. A continuation's opening rides its continuation-start event, so nothing is durable there any more and the failpoint names no boundary. A crash after the terminal event is likewise not an unfinished claim: the event is the continuation's ending, so the boundary already has one. An imported transcript that never stated how a turn ended used to be repaired to failed once the header noticed the missing terminal. The terminal event is now written when the turn is materialized and can never be corrected, so an inferred status is recorded as the failure it is, which keeps an adapter's reason to emit its own cutoff true. Generated-by: Claude Code --- .../execution-host-continuation.test.ts | 32 --- .../fixtures/execution-host-suite.ts | 5 +- .../src/__tests__/context-diagnostics.test.ts | 45 ++++ .../src/__tests__/conversation-copy.test.ts | 230 ++++-------------- .../src/__tests__/execution-inspect.test.ts | 4 +- .../history-compact-checkpoint.test.ts | 3 +- .../mid-turn-capacity-backend.test.ts | 8 +- .../runtime-continuation-crash.test.ts | 15 +- .../__tests__/runtime-continuation.test.ts | 14 +- .../runtime-event-read-model.test.ts | 18 +- .../stream-graph-coordinator.test.ts | 6 +- packages/runtime/src/agent-run-inspect.ts | 10 +- packages/runtime/src/agent-run.ts | 2 - packages/runtime/src/conversation-copy.ts | 3 - packages/runtime/src/runtime-ledger-repair.ts | 7 + 15 files changed, 139 insertions(+), 263 deletions(-) diff --git a/packages/runtime-host/src/__tests__/execution-host-continuation.test.ts b/packages/runtime-host/src/__tests__/execution-host-continuation.test.ts index 064720406a..b93591d3ec 100644 --- a/packages/runtime-host/src/__tests__/execution-host-continuation.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host-continuation.test.ts @@ -119,38 +119,6 @@ test('two Clients idempotently start one Host-owned safe-boundary continuation', }); }); -test('startup repairs a continuation Run created before its durable start', async () => { - await withExecutionRoot(async (fixture) => { - const crash = await fixture.seedSafeBoundaryContinuationCrash('after_run_created'); - const host = await fixture.startHost(); - const client = await connectClient(fixture.root); - try { - const repaired = await client.request('turn.query', { - sessionId: fixture.sessionId, - turnId: crash.targetTurnId, - }); - assert.equal(repaired.runId, crash.targetRunId); - assert.equal(repaired.status, 'failed'); - assert.equal(repaired.failureClass, 'continuation_abandoned_before_provider_dispatch'); - assert.deepEqual( - await client.request('turn.resume.query', { - sessionId: fixture.sessionId, - sourceRunId: crash.sourceRunId, - expectedRuntimeEventHighWater: crash.sourceRuntimeEventHighWater, - }), - { - sessionId: fixture.sessionId, - disposition: 'parked', - reason: 'continuation_already_exists', - }, - ); - } finally { - await client.close(); - await fixture.stopHost(host); - } - }); -}); - test('startup repairs a continuation claim committed before its target Run', async () => { await withExecutionRoot(async (fixture) => { const crash = await fixture.seedSafeBoundaryContinuationCrash( diff --git a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts index 93117f8499..520c689a22 100644 --- a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts +++ b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts @@ -287,10 +287,7 @@ export class ExecutionFixture { } async seedSafeBoundaryContinuationCrash( - failpoint: - | 'after_continuation_claim_committed' - | 'after_run_created' - | 'after_continuation_start_committed', + failpoint: 'after_continuation_claim_committed' | 'after_continuation_start_committed', ): Promise<{ sourceRunId: string; sourceRuntimeEventHighWater: number; diff --git a/packages/runtime/src/__tests__/context-diagnostics.test.ts b/packages/runtime/src/__tests__/context-diagnostics.test.ts index 0b563a3788..dcdbe9415b 100644 --- a/packages/runtime/src/__tests__/context-diagnostics.test.ts +++ b/packages/runtime/src/__tests__/context-diagnostics.test.ts @@ -24,9 +24,12 @@ import { join } from 'node:path'; import { test } from 'node:test'; import { DatabaseSync } from 'node:sqlite'; import type { AgentRunEvent, AgentRunStore, EmittedAgentRunEvent } from '@maka/core/agent-run'; +import type { RuntimeEventInvocationOpenedContent } from '@maka/core/runtime-event'; import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; +import { createWorkspaceRuntimeStore } from '@maka/storage/runtime-event-persistence'; import { readLatestContextDiagnostics } from '../context-diagnostics.js'; import { readLatestContextSnapshot } from '../latest-context-snapshot.js'; +import { seedInvocation } from './invocation-fixture.js'; test('rejects v2 snapshots that the canonical writer cannot produce', () => { const base = { @@ -73,6 +76,7 @@ test('rejects v2 snapshots that the canonical writer cannot produce', () => { test('serves the sealed snapshot without reading a single run', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); try { + await openRun(root, 'session-1', 'run-1'); const writer = createSqliteAgentRunStore(root); await writer.appendEvent( 'session-1', @@ -101,6 +105,7 @@ test('serves the sealed snapshot without reading a single run', async () => { test('does not trust a pre-observation projection over its canonical attempt', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); try { + await openRun(root, 'session-1', 'run-1'); const writer = createSqliteAgentRunStore(root); const oldProjection = latestContext('attempt-1', 10); oldProjection.snapshot.schemaVersion = 1; @@ -136,6 +141,7 @@ test('does not trust a pre-observation projection over its canonical attempt', a test('upgrades exact-matched mixed-era composition into the current projection', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); try { + await openRun(root, 'session-1', 'run-1'); const writer = createSqliteAgentRunStore(root); const oldProjection = latestContext('attempt-1', 10); oldProjection.snapshot.schemaVersion = 1; @@ -185,6 +191,7 @@ test('upgrades exact-matched mixed-era composition into the current projection', test('a failed call does not replace the last good snapshot', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); try { + await openRun(root, 'session-1', 'run-1'); const writer = createSqliteAgentRunStore(root); await writer.appendEvent( 'session-1', @@ -221,6 +228,10 @@ test('a failed call does not replace the last good snapshot', async () => { test("a subagent's run never becomes the session's context", async () => { const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); try { + await openRun(root, 'session-1', 'run-parent'); + await openRun(root, 'session-1', 'run-child', { + lineage: { parentRunId: 'run-parent', agentId: 'reviewer' }, + }); const writer = createSqliteAgentRunStore(root); await writer.appendEvent( 'session-1', @@ -256,6 +267,7 @@ test('rebuilds a canonical observation, then repairs it so the next read scans n // two reads. const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); try { + await openRun(root, 'session-1', 'run-1'); const writer = createSqliteAgentRunStore(root); await writer.appendEvent( 'session-1', @@ -559,6 +571,7 @@ test('warm and cold agree on which of two requests that finished together is the // request the panel is describing. const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); try { + await openRun(root, 'session-1', 'run-1'); const writer = createSqliteAgentRunStore(root); // Appended greater-id first, so a rule that simply kept the last write // would answer 'model-a' here and disagree with the scan below. @@ -660,6 +673,7 @@ test('a request that finished earlier cannot move the answer backwards', async ( // completion, or a late arrival would permanently rewind the panel. const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); try { + await openRun(root, 'session-1', 'run-1'); const writer = createSqliteAgentRunStore(root); await writer.appendEvent( 'session-1', @@ -696,6 +710,7 @@ test('a damaged projection is repaired, not preserved forever', async () => { // refresh rescanned the whole session (#2323). const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); try { + await openRun(root, 'session-1', 'run-1'); const writer = createSqliteAgentRunStore(root); await writer.appendEvent( 'session-1', @@ -748,6 +763,7 @@ test('a damaged projection is repaired, not preserved forever', async () => { test('repairs malformed projection bytes from the canonical ledger', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); try { + await openRun(root, 'session-1', 'run-1'); const writer = createSqliteAgentRunStore(root); await writer.appendEvent( 'session-1', @@ -804,6 +820,7 @@ test('repairs malformed projection bytes from the canonical ledger', async () => test('does not persist a cold answer after canonical authority advances', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); try { + await openRun(root, 'session-1', 'run-1'); const store = createSqliteAgentRunStore(root); await store.appendEvent( 'session-1', @@ -863,6 +880,7 @@ test('does not persist a cold answer after canonical authority advances', async test('rebuilds a nested-malformed v2 projection from the canonical ledger', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); try { + await openRun(root, 'session-1', 'run-1'); const writer = createSqliteAgentRunStore(root); await writer.appendEvent( 'session-1', @@ -925,6 +943,7 @@ test('rebuilds a nested-malformed v2 projection from the canonical ledger', asyn test('an old readable-order projection is upgraded after one cold rebuild', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-context-diagnostics-')); try { + await openRun(root, 'session-1', 'run-1'); const writer = createSqliteAgentRunStore(root); await writer.appendEvent( 'session-1', @@ -971,6 +990,32 @@ test('an old readable-order projection is upgraded after one cold rebuild', asyn } }); +/** + * Open the invocation these ledger writes belong to. + * + * The operational ledger anchors every run on its opening fact, so a test that + * writes rows for a run has to say that the run began — and the opening is also + * where a run says it belongs to a subagent rather than to the session. + */ +async function openRun( + root: string, + sessionId: string, + runId: string, + opening?: Partial, +): Promise { + const runtimeStore = createWorkspaceRuntimeStore(root); + try { + await seedInvocation(runtimeStore, { + sessionId, + runId, + turnId: `turn-${runId}`, + ...(opening ? { opening } : {}), + }); + } finally { + runtimeStore.close(); + } +} + function countingStore( reader: ReturnType, onScan: () => void, diff --git a/packages/runtime/src/__tests__/conversation-copy.test.ts b/packages/runtime/src/__tests__/conversation-copy.test.ts index 7fd249c2aa..5a426257ca 100644 --- a/packages/runtime/src/__tests__/conversation-copy.test.ts +++ b/packages/runtime/src/__tests__/conversation-copy.test.ts @@ -1141,154 +1141,19 @@ test('conversation copy rejects continuation authority selected through the chil ); }); -test('conversation copy rejects a retained AgentRun without RuntimeEvent facts', async () => { - const root = await mkdtemp(join(tmpdir(), 'maka-conversation-missing-runtime-copy-')); - try { - const runStore = createSqliteAgentRunStore(root); - const runtimeEventStore = createWorkspaceRuntimeStore(root); - const rootRun = runFacts({ - runId: 'run-root', - invocationId: 'invocation-root', - turnId: 'turn-root', - cwd: root, - }); - const childRun = runFacts({ - runId: 'run-child', - invocationId: 'invocation-child', - turnId: 'turn-child', - parentRunId: 'run-root', - agentId: 'researcher', - agentName: 'Researcher', - cwd: root, - }); - await seedRun(runtimeEventStore, rootRun); - await seedRun(runtimeEventStore, childRun); - for (const event of [ - runtimeEvent({ - id: 'event-root-user', - invocationId: 'invocation-root', - runId: 'run-root', - turnId: 'turn-root', - role: 'user', - author: 'user', - content: { kind: 'text', text: 'delegate' }, - }), - runtimeEvent({ - id: 'event-root-terminal', - invocationId: 'invocation-root', - runId: 'run-root', - turnId: 'turn-root', - ts: 2, - status: 'completed', - }), - ]) { - await runtimeEventStore.appendRuntimeEvent(event.sessionId, event.runId, event); - } - const source = await new RuntimeReadModel({ - runtimeEventStore, - }).getSessionView('session-source'); - let sequence = 0; - - await assert.rejects( - async () => - cloneConversationRuntimeLedger({ - plan: await prepareTestCopyPlan(source, source.messages, runStore, runtimeEventStore), - copiedMessages: source.messages, - referenceMap: { - mode: 'exact', - linkedChildren: { mode: 'reject' }, - sourceSessionId: 'session-source', - targetSessionId: 'session-target', - artifactIds: new Map(), - relativePaths: new Map(), - }, - runStore, - runtimeEventStore, - newId: () => `target-${++sequence}`, - }), - /Cannot copy AgentRun run-child without RuntimeEvent facts/, - ); - assert.deepEqual(await runtimeEventStore.listSessionInvocations('session-target'), []); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); - -test('conversation copy can use RuntimeEvents backfilled by the read model', async () => { - const run = agentRunHeader({ - runId: 'run-backfilled', - invocationId: 'invocation-backfilled', - turnId: 'turn-backfilled', - status: 'completed', - updatedAt: 3, - completedAt: 3, - }); - const legacyMessages: StoredMessage[] = [ - { - type: 'user', - id: 'legacy-user', - turnId: run.turnId, - ts: 1, - text: 'hello', - }, - { - type: 'assistant', - id: 'legacy-assistant', - turnId: run.turnId, - ts: 2, - text: 'world', - modelId: 'fake-model', - }, - { - type: 'turn_state', - id: 'legacy-state', - turnId: run.turnId, - ts: 3, - status: 'completed', - partialOutputRetained: false, - }, - ]; - const runStore = { - listSessionRuns: async () => [run], - readEvents: async () => [], - } as Pick; - const runtimeEventStore = { - readRuntimeEvents: async () => [], - readSessionRuntimeEventEntries: async () => [], - } as Pick; - const source = await new RuntimeReadModel({ - runStore: runStore as AgentRunStore, - runtimeEventStore: runtimeEventStore as RuntimeEventStore, - projectionCache: { readMessages: async () => legacyMessages }, - }).getSessionView(run.sessionId); - - const plan = await prepareConversationRuntimeLedgerCopy({ - sourceSessionId: run.sessionId, - sourceEvents: source.events, - copiedMessages: source.messages, - runStore, - runtimeEventStore, - }); - - assert.deepEqual( - plan.runs[0]?.runtimeEvents.map((event) => event.content?.kind ?? event.status), - ['text', 'text', 'completed'], - ); -}); - test('conversation copy rewrites a complete tool recovery bundle atomically', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-conversation-recovery-copy-')); const runStore = createSqliteAgentRunStore(root); const runtimeEventStore = createSqliteRuntimeStore(join(root, 'runtime.sqlite')); try { await runStore.ready?.(); - await seedRun(runtimeEventStore, { - runId: 'run-source', - invocationId: 'invocation-source', - turnId: 'turn-1', - cwd: root, - }); const sourceEvents: RuntimeEvent[] = [ + invocationOpenedEvent({ + runId: 'run-source', + invocationId: 'invocation-source', + turnId: 'turn-1', + cwd: root, + }), runtimeEvent({ id: 'event-user', role: 'user', @@ -1488,13 +1353,13 @@ test('conversation copy rewrites the parent operation id of a nested Code Mode c const runtimeEventStore = createSqliteRuntimeStore(join(root, 'runtime.sqlite')); try { await runStore.ready?.(); - await seedRun(runtimeEventStore, { - runId: 'run-source', - invocationId: 'invocation-source', - turnId: 'turn-1', - cwd: root, - }); const sourceEvents: RuntimeEvent[] = [ + invocationOpenedEvent({ + runId: 'run-source', + invocationId: 'invocation-source', + turnId: 'turn-1', + cwd: root, + }), runtimeEvent({ id: 'event-user', role: 'user', @@ -2154,6 +2019,7 @@ test('conversation copy clones one terminal Runtime ledger with new owned identi 'event-target-4', 'event-target-5', 'event-target-6', + 'event-target-7', ]; let nextId = 0; @@ -2200,6 +2066,7 @@ test('conversation copy clones one terminal Runtime ledger with new owned identi 'event-target-4', 'event-target-5', 'event-target-6', + 'event-target-7', ], ); assert.ok( @@ -2210,31 +2077,34 @@ test('conversation copy clones one terminal Runtime ledger with new owned identi event.invocationId === 'run-target', ), ); - assert.equal(targetEvents[0]?.refs?.artifactId, 'artifact-target'); + // The opening fact is the run's first event, so the copied source events + // line up with it one place along. + const copiedEvents = targetEvents.slice(1); + assert.equal(copiedEvents[0]?.refs?.artifactId, 'artifact-target'); assert.equal( - targetEvents[0]?.content?.kind === 'text' ? targetEvents[0].content.text : undefined, + copiedEvents[0]?.content?.kind === 'text' ? copiedEvents[0].content.text : undefined, targetAttachmentText, ); assert.equal( copied.copiedMessages.find((message) => message.type === 'assistant')?.text, targetAttachmentText, ); - assert.equal(targetEvents[1]?.refs?.sourceInvocationId, 'run-target'); + assert.equal(copiedEvents[1]?.refs?.sourceInvocationId, 'run-target'); assert.deepEqual( - targetEvents[1]?.content?.kind === 'function_call' ? targetEvents[1].content.args : undefined, + copiedEvents[1]?.content?.kind === 'function_call' ? copiedEvents[1].content.args : undefined, sourceEvents[1]?.content?.kind === 'function_call' ? sourceEvents[1].content.args : undefined, ); assert.deepEqual( - targetEvents[2]?.content?.kind === 'function_response' - ? targetEvents[2].content.result + copiedEvents[2]?.content?.kind === 'function_response' + ? copiedEvents[2].content.result : undefined, sourceEvents[2]?.content?.kind === 'function_response' ? sourceEvents[2].content.result : undefined, ); const typedResultValue = - targetEvents[4]?.content?.kind === 'function_response' - ? targetEvents[4].content.result + copiedEvents[4]?.content?.kind === 'function_response' + ? copiedEvents[4].content.result : undefined; const typedResult = decodeCanonicalToolResultContent(typedResultValue); assert.equal(typedResult.kind === 'subagent' ? typedResult.permissionMode : undefined, 'ask'); @@ -2246,13 +2116,13 @@ test('conversation copy clones one terminal Runtime ledger with new owned identi // build cannot emit: their rows are not carried into the target. assert.deepEqual( targetOperationalEvents.map((event) => event.type), - ['history_compact_checkpoint_recorded', 'run_completed'], + ['history_compact_checkpoint_recorded', 'model_stream_completed'], ); // A copied RuntimeEvent still points somewhere new, though. Carrying the // source's trace identity into the target is the thing the copy exists to // prevent, whether or not the record naming that trace came along. - assert.notEqual(targetEvents[1]?.refs?.providerRequestTraceId, 'provider-trace-source'); - assert.ok(targetEvents[1]?.refs?.providerRequestTraceId); + assert.notEqual(copiedEvents[1]?.refs?.providerRequestTraceId, 'provider-trace-source'); + assert.ok(copiedEvents[1]?.refs?.providerRequestTraceId); assert.equal(targetEvents[1]?.refs?.traceEventId, undefined); assert.doesNotMatch(JSON.stringify(targetOperationalEvents), /OPAQUE_SOURCE_COMPACTION_STATE/); const projectedCheckpoint = await runStore.readEventProjection?.( @@ -2591,7 +2461,8 @@ test('conversation copy drops a checkpoint from a superseded source policy inste targetRuns.map((run) => runtimeEventStore.readRuntimeEvents('session-target', run.runId)), ) ).flat(); - assert.equal(targetEvents.length, sourceEvents.length); + // The opening fact is one of the run's events, so the copy carries it too. + assert.equal(targetEvents.length, sourceEvents.length + 1); } finally { await rm(root, { recursive: true, force: true }); } @@ -2624,6 +2495,7 @@ test('conversation copy rebuilds a resumed child checkpoint over its child run c invocationId: 'invocation-child-2', turnId: 'turn-child-2', parentRunId: 'run-root', + resumedFromRunId: 'run-child-1', agentId: 'researcher', agentName: 'Researcher', cwd: root, @@ -3358,6 +3230,7 @@ interface SeededRun { turnId?: string; cwd?: string; parentRunId?: string; + resumedFromRunId?: string; agentId?: string; agentName?: string; openedAt?: number; @@ -3407,6 +3280,7 @@ function invocationOpening( ): RuntimeEventInvocationOpenedContent { const lineage = { ...(run.parentRunId ? { parentRunId: run.parentRunId } : {}), + ...(run.resumedFromRunId ? { resumedFromRunId: run.resumedFromRunId } : {}), ...(run.agentId ? { agentId: run.agentId } : {}), ...(run.agentName ? { agentName: run.agentName } : {}), }; @@ -3435,41 +3309,31 @@ function invocationOpening( } /** - * Open one invocation on the spine, and close it when the test says it ended. + * Open one invocation on the spine. * - * The copy tests only need a run to exist and to name its turn, so everything - * else is the same for all of them. + * Every test here writes the run's own events afterwards, ending included, so + * the seed states only that the run began and what it was routed to. */ async function seedRun( runtimeEventStore: Pick, run: SeededRun = {}, ): Promise { + const event = invocationOpenedEvent(run); + await runtimeEventStore.appendRuntimeEvent(event.sessionId, event.runId, event); +} + +/** The opening event of one seeded run, for a test that writes its ledger in one batch. */ +function invocationOpenedEvent(run: SeededRun = {}): RuntimeEvent { const identity = { sessionId: run.sessionId ?? 'session-source', invocationId: run.invocationId ?? 'invocation', runId: run.runId ?? 'run', turnId: run.turnId ?? 'turn', }; - const openedAt = run.openedAt ?? 1; - await runtimeEventStore.appendRuntimeEvent( - identity.sessionId, - identity.runId, - buildInvocationOpenedEvent({ - id: `${identity.runId}-invocation-opened`, - run: identity, - openedAt, - opening: invocationOpening(run), - }), - ); - const outcome = run.outcome ?? 'completed'; - if (outcome === 'open') return; - await runtimeEventStore.appendRuntimeEvent(identity.sessionId, identity.runId, { - id: `${identity.runId}-terminal`, - ...identity, - ts: run.closedAt ?? openedAt + 1, - partial: false, - role: 'system', - author: 'system', - status: outcome, + return buildInvocationOpenedEvent({ + id: `${identity.runId}-invocation-opened`, + run: identity, + openedAt: run.openedAt ?? 1, + opening: invocationOpening(run), }); } diff --git a/packages/runtime/src/__tests__/execution-inspect.test.ts b/packages/runtime/src/__tests__/execution-inspect.test.ts index 47a7750652..3b871b04b5 100644 --- a/packages/runtime/src/__tests__/execution-inspect.test.ts +++ b/packages/runtime/src/__tests__/execution-inspect.test.ts @@ -86,7 +86,9 @@ describe('versioned execution inspect documents', () => { eventId: 'call', }, ]); - assert.equal(document.sources.runtimeCoverage?.highWater.sequence, 1); + // The opening fact is the run's first runtime event, so the call and the + // terminal event that follow it sit at sequences 1 and 2. + assert.equal(document.sources.runtimeCoverage?.highWater.sequence, 2); assert.equal( document.diagnostics.some((item) => item.code === 'tool_response_missing'), true, diff --git a/packages/runtime/src/__tests__/history-compact-checkpoint.test.ts b/packages/runtime/src/__tests__/history-compact-checkpoint.test.ts index b4acf44305..ef9100d561 100644 --- a/packages/runtime/src/__tests__/history-compact-checkpoint.test.ts +++ b/packages/runtime/src/__tests__/history-compact-checkpoint.test.ts @@ -929,10 +929,9 @@ describe('history compact checkpoint', () => { readEventProjection: async () => { throw new Error('damaged projection'); }, - listSessionRuns: async () => { + readEvents: async () => { throw new Error('ledger recovery failed'); }, - readEvents: async () => [], }; await assert.rejects( diff --git a/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts b/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts index 5d00ba976c..0dc4619278 100644 --- a/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts +++ b/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts @@ -1770,11 +1770,11 @@ describe('the shipped runtime default drives the proactive long-turn journey (is assert.equal(anchor?.outputTokens, 10); }); - test('an anchor is discarded unless a run header proves it came from this model', async () => { + test('an anchor is discarded unless its invocation proves it came from this model', async () => { // Input tokens are a count in one model's tokenizer; nothing converts them. - // The anchor sits ABOVE the declared window, so it is the header check - // alone that decides: a matching header folds at step 0, while a header - // naming another model and no header at all leave the request alone. + // The anchor sits ABOVE the declared window, so it is the opening's route + // alone that decides: a matching route folds at step 0, while a route + // naming another model and no invocation at all leave the request alone. const anchor = priorUsageEvent({ inputTokens: 30_000, outputTokens: 10 }); const otherModel = priorRunInvocation(); for (const [priorInvocations, folds] of [ diff --git a/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts b/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts index 6ea9a7fb1b..7061397b4b 100644 --- a/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts +++ b/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts @@ -46,7 +46,6 @@ const CRASH_CHILD_READY_TIMEOUT_MS = process.platform === 'win32' ? 30_000 : 10_ const CRASH_HARNESS_TIMEOUT_MS = process.platform === 'win32' ? 180_000 : 60_000; const FAILPOINTS: readonly RuntimeContinuationFailpoint[] = [ 'after_continuation_claim_committed', - 'after_run_created', 'after_continuation_start_committed', 'after_terminal_event_committed', ]; @@ -97,10 +96,10 @@ if (process.env[CRASH_CHILD_ENV] === '1') { sourceRunId: 'source-run', }); assert.equal(repeatedPlan.disposition, 'park'); + // A crash after the terminal event is not an unfinished claim: the + // event is the continuation's ending, so the boundary already has one. assert.deepEqual(repeatedPlan.rejectionReasons, [ - failpoint === 'after_continuation_claim_committed' || - failpoint === 'after_run_created' || - failpoint === 'after_terminal_event_committed' + failpoint === 'after_continuation_claim_committed' ? 'continuation_claim_repair_required' : failpoint === 'after_continuation_start_committed' ? 'continuation_started_indeterminate' @@ -343,16 +342,16 @@ async function readInvocation( /** * What a crash at each boundary left durable. * - * A continuation's opening fact rides its continuation-start event, so the two - * boundaries before that commit leave the target invocation unopened. There is - * no separate run record left over to disagree with the ledger. + * A continuation's opening fact rides its continuation-start event, so a crash + * before that commit leaves the target invocation unopened. There is no separate + * run record left over to disagree with the ledger. */ function assertPrefix( failpoint: RuntimeContinuationFailpoint, invocation: RuntimeInvocationRecord | undefined, events: readonly RuntimeEvent[], ): void { - if (failpoint === 'after_continuation_claim_committed' || failpoint === 'after_run_created') { + if (failpoint === 'after_continuation_claim_committed') { assert.equal(invocation, undefined); assert.deepEqual(events, []); return; diff --git a/packages/runtime/src/__tests__/runtime-continuation.test.ts b/packages/runtime/src/__tests__/runtime-continuation.test.ts index ec4ab63770..a9331e2296 100644 --- a/packages/runtime/src/__tests__/runtime-continuation.test.ts +++ b/packages/runtime/src/__tests__/runtime-continuation.test.ts @@ -184,17 +184,11 @@ test('RuntimeContinuationPlanner derives terminal repair from durable run and ev assert.deepEqual(plan.rejectionReasons, ['terminal_repair_failed']); }); -test('RuntimeContinuationPlanner parks when the terminal run header disagrees with the ledger fact', async () => { +test('RuntimeContinuationPlanner parks when the source ledger does not end on its terminal fact', async () => { const planner = new RuntimeContinuationPlanner({ readSourceInvocation: async () => runInvocation('run-1', { outcome: 'completed' }), readImmutableRuntimePrefix: async () => immutablePrefix([ - event({ - id: 'source-user', - role: 'user', - author: 'user', - content: { kind: 'text', text: 'continue' }, - }), event({ id: 'source-terminal', role: 'system', @@ -202,6 +196,12 @@ test('RuntimeContinuationPlanner parks when the terminal run header disagrees wi status: 'failed', actions: { endInvocation: true }, }), + event({ + id: 'source-user', + role: 'user', + author: 'user', + content: { kind: 'text', text: 'continue' }, + }), ]), newId: () => 'fresh-id', }); diff --git a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts index 40b4a7abbe..6352270b8b 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -1574,14 +1574,14 @@ describe('projectRuntimeEventsToStoredMessages', () => { assert.strictEqual(out.diagnostics.every(isHardRuntimeEventReadModelDiagnostic), true); }); - test('failed terminal RuntimeEvent maps to failed turn state when run header carries failure class', () => { + test('failed terminal RuntimeEvent maps to failed turn state with the class it states', () => { const out = projectRuntimeEventsToStoredMessages( [ ev({ id: 'evt-failed', ts: ts + 9, status: 'failed', - actions: { endInvocation: true }, + actions: { endInvocation: true, stateDelta: { failureClass: 'tool_failed' } }, }), ], { @@ -1707,7 +1707,9 @@ describe('projectRuntimeEventsToStoredMessages', () => { assert.deepStrictEqual(out.diagnostics, []); }); - test('aborted terminal RuntimeEvent keeps an explicit diagnostic when abort source is unavailable', () => { + // The omission is `classifyRuntimeEventTerminalFact`'s to report. Repeating it + // here would turn a transcript row that reads fine into an unreadable Session. + test('aborted terminal RuntimeEvent that states no source still projects its turn state', () => { const out = projectRuntimeEventsToStoredMessages( [ ev({ @@ -1727,10 +1729,7 @@ describe('projectRuntimeEventsToStoredMessages', () => { status: 'aborted', abortedAt: ts + 9, }); - assert.deepStrictEqual( - out.diagnostics.map((diag) => diag.code), - ['incomplete_event'], - ); + assert.deepStrictEqual(out.diagnostics, []); }); test('projects tool_call stepId from refs so the UI timeline keeps step pairing', () => { @@ -1952,8 +1951,9 @@ const ACTION_COVERAGE_SAMPLES: ActionCoverageSamples = { event: { author: 'user', refs: { toolCallId: 'coverage-form-tool' } }, }, transferToAgent: { action: 'agent-b' }, - // The terminal fact is one of the actions that does own a row. - endInvocation: { action: true }, + // The terminal fact is one of the actions that does own a row, and the event + // states the outcome it ends on. + endInvocation: { action: true, event: { status: 'completed' } }, tokenUsage: { action: { input: 10, output: 5 } }, toolDispatch: { action: { diff --git a/packages/runtime/src/__tests__/stream-graph-coordinator.test.ts b/packages/runtime/src/__tests__/stream-graph-coordinator.test.ts index 2b323a6920..f477de5fc1 100644 --- a/packages/runtime/src/__tests__/stream-graph-coordinator.test.ts +++ b/packages/runtime/src/__tests__/stream-graph-coordinator.test.ts @@ -1509,8 +1509,10 @@ describe('host-managed agent graph coordinator', () => { isArchived: false, }) as never, }, - runStore: { listSessionRuns: async () => runs }, - runtimeEventStore: { readImmutableRuntimeEvents: async () => runtimeEvents }, + runtimeEventStore: { + listSessionInvocations: async () => runs, + readImmutableRuntimeEvents: async () => runtimeEvents, + }, controlStore: { listAgentGraphOperatorProvisions: async () => provisions, listAgentGraphScheduleUpdates: async () => scheduleUpdates, diff --git a/packages/runtime/src/agent-run-inspect.ts b/packages/runtime/src/agent-run-inspect.ts index 07b63ac892..ebde00b2d0 100644 --- a/packages/runtime/src/agent-run-inspect.ts +++ b/packages/runtime/src/agent-run-inspect.ts @@ -82,9 +82,7 @@ export interface InspectAgentRunOptions { export type AgentRunInspectReader = Pick; export type RuntimeEventInspectReader = Pick & - Required> & { - readInvocation?(sessionId: string, invocationId: string): Promise; - }; + Required>; /** * One run, read from both ledgers it actually has: the RuntimeEvent spine that @@ -188,13 +186,13 @@ export async function inspectSessionRunReadModels( return models; } +// A run is not its invocation: a continuation is a new run on the invocation it +// resumes. This reader is addressed by run, so it looks the invocation up by the +// id it was actually given. async function readInvocation( runtimeEventStore: RuntimeEventInspectReader, options: InspectAgentRunOptions, ): Promise { - if (runtimeEventStore.readInvocation) { - return runtimeEventStore.readInvocation(options.sessionId, options.runId); - } const found = (await runtimeEventStore.listSessionInvocations(options.sessionId)).find( (invocation) => invocation.runId === options.runId, ); diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index 5fdfbe80ea..06278d6cc2 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -183,7 +183,6 @@ export interface AgentRunSessionStore { export type RuntimeContinuationFailpoint = | 'after_continuation_claim_committed' - | 'after_run_created' | 'after_continuation_start_committed' | 'after_terminal_event_committed'; @@ -778,7 +777,6 @@ export class AgentRun { this.continuationActive = true; await this.openInvocation(continuation); - await this.input.continuationFailpoint?.('after_run_created'); const startedAt = this.input.now(); this.lastTs = startedAt; if (!this.input.commitContinuationStart) { diff --git a/packages/runtime/src/conversation-copy.ts b/packages/runtime/src/conversation-copy.ts index cd6ab2f7ae..c0644f6265 100644 --- a/packages/runtime/src/conversation-copy.ts +++ b/packages/runtime/src/conversation-copy.ts @@ -338,9 +338,6 @@ export async function prepareConversationRuntimeLedgerCopy(input: { const runs = await Promise.all( selectedRunEvents.map(async ({ run, events }) => { const operationalEvents = await input.runStore.readEvents(run.sessionId, run.runId); - if (events.length === 0) { - throw new Error(`Cannot copy AgentRun ${run.runId} without RuntimeEvent facts`); - } const terminal = classifyTerminalRuntimeLedger(run, events); if (run.terminalEvent && terminal.kind !== 'fact') { throw new Error(`Cannot copy terminal AgentRun ${run.runId} without one terminal fact`); diff --git a/packages/runtime/src/runtime-ledger-repair.ts b/packages/runtime/src/runtime-ledger-repair.ts index eb0900850f..7169fbe1ab 100644 --- a/packages/runtime/src/runtime-ledger-repair.ts +++ b/packages/runtime/src/runtime-ledger-repair.ts @@ -223,6 +223,13 @@ function transcriptOutcome( openedAt: number, ): RuntimeEventBackfillOutcome { const ts = Math.max(openedAt, ...turnMessages.map((message) => message.ts)); + // A transcript that never stated how a turn ended does not get to claim it + // completed. The terminal event is written once and cannot be corrected later, + // so an inferred status is recorded as the failure it actually is — which is + // also the reason an adapter emits a cutoff of its own. + if (turn.statusSource !== 'recorded') { + return { status: 'failed', ts, failureClass: 'missing_terminal_event' }; + } const status = transcriptOutcomeStatus(turn.status); return { status, From 2bcbc41b8f200d2cdf936a4a3eedf92b1681f7ab Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 15:27:20 +0800 Subject: [PATCH 30/46] style: apply Biome formatting Generated-by: Claude Code --- .../src/__tests__/session-revision-two-client-uds.test.ts | 5 ++++- packages/runtime/src/runtime-ledger-repair.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts index 52f6ad1573..9282528524 100644 --- a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts @@ -27,7 +27,10 @@ import { join } from 'node:path'; import { test } from 'node:test'; import { decodeCanonicalToolResultContent } from '@maka/core/tool-result-record-schema'; import { type AgentGraphOperatorProvisionRequest } from '@maka/core/agent-graph-topology'; -import { seedInvocation, type SeedInvocationInput } from '@maka/runtime/test-only/invocation-fixture'; +import { + seedInvocation, + type SeedInvocationInput, +} from '@maka/runtime/test-only/invocation-fixture'; import { type RuntimeEvent } from '@maka/core/runtime-event'; import { WORKHUB_COORDINATION_SESSION_ID } from '@maka/core/session'; import { diff --git a/packages/runtime/src/runtime-ledger-repair.ts b/packages/runtime/src/runtime-ledger-repair.ts index 7169fbe1ab..8ab8949c99 100644 --- a/packages/runtime/src/runtime-ledger-repair.ts +++ b/packages/runtime/src/runtime-ledger-repair.ts @@ -27,7 +27,10 @@ import { buildInvocationOpenedEvent, isSessionInlineInvocation, } from '@maka/core/runtime-invocation'; -import type { RuntimeInvocationOutcome, RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; +import type { + RuntimeInvocationOutcome, + RuntimeInvocationRecord, +} from '@maka/core/runtime-invocation'; import type { SessionHeader } from '@maka/core/session'; import type { StoredMessage, TurnRecord } from '@maka/core/session'; import { backfillRuntimeEventsFromStoredMessages } from './runtime-event-backfill.js'; From 87066693d732d3b05adbc83ccd0ba796521b3ea0 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 15:47:37 +0800 Subject: [PATCH 31/46] refactor(runtime): drop the last reads of the retired lifecycle events `classifyAgentRunRecovery` still scanned the operational ledger for `run_completed`, `run_failed` and `run_cancelled`. Those types no longer exist, and its own contract already says the caller established there is no terminal event, so the scan could only ever answer the same way twice. Generated-by: Claude Code --- .../src/__tests__/execution-composition.test.ts | 6 +++--- packages/runtime/src/agent-run-recovery.ts | 11 +---------- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index fb25da30a7..f9a68677ec 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -1415,7 +1415,7 @@ test('production composition validates graph stop before aborting a claimed chil stores, abortedClaim, abortedAdmission.userMessageId, - 'run_cancelled', + 'cancelled', ); const completedRun = ( await stores.runtimeEventStore.listSessionInvocations(completedClaim.targetSessionId) @@ -1694,7 +1694,7 @@ async function assertUniqueGraphExecutionFacts( stores: Awaited>, claim: AgentGraphIntentClaim, userMessageId: string, - expectedTerminal: 'run_completed' | 'run_cancelled' = 'run_completed', + expectedOutcome: 'completed' | 'cancelled' = 'completed', ): Promise { const [runs, messages, runtimeEvents] = await Promise.all([ stores.runtimeEventStore.listSessionInvocations(claim.targetSessionId), @@ -1717,7 +1717,7 @@ async function assertUniqueGraphExecutionFacts( ); assert.equal( runtimeEvents.filter( - (event) => event.status === (expectedTerminal === 'run_cancelled' ? 'aborted' : 'completed'), + (event) => event.status === (expectedOutcome === 'cancelled' ? 'aborted' : 'completed'), ).length, 1, ); diff --git a/packages/runtime/src/agent-run-recovery.ts b/packages/runtime/src/agent-run-recovery.ts index c38c5a7076..fe5cb68a32 100644 --- a/packages/runtime/src/agent-run-recovery.ts +++ b/packages/runtime/src/agent-run-recovery.ts @@ -62,7 +62,7 @@ export function classifyAgentRunRecovery( const lastEventType = lastEvent?.type; const reason = - lastEventType === 'model_stream_completed' && !hasTerminalRunEvent(events) + lastEventType === 'model_stream_completed' ? 'model_stream_completed_without_runtime_terminal' : lastEventType === 'permission_requested' || lastEventType === 'permission_failed' ? 'stale_user_wait' @@ -135,15 +135,6 @@ function failedDecision( }; } -function hasTerminalRunEvent(events: readonly AgentRunEvent[]): boolean { - return events.some( - (event) => - event.type === 'run_completed' || - event.type === 'run_failed' || - event.type === 'run_cancelled', - ); -} - function lastNonCorruptEvent(events: readonly AgentRunEvent[]): AgentRunEvent | undefined { for (let index = events.length - 1; index >= 0; index -= 1) { const event = events[index]; From 2105183213c1e903394d2619193605c12c305a63 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 16:22:12 +0800 Subject: [PATCH 32/46] test(runtime): keep the durable-order reader on the spine Rebasing onto main brought in `readSessionRuntimeEventEntries`, the durable session order a read now sorts by. The doubles that stand in for a store have to answer it, and the read model no longer takes a run store at all. The upstream tests for the read model's projection-cache backfill go: that path only ran for a terminal run whose ledger was empty, and an invocation is its opening event, so a run with no events is not a run this model can see. Generated-by: Claude Code --- .../session-revision-two-client-uds.test.ts | 6 +++-- .../session-manager-terminal-ledger.test.ts | 22 ++++++++++++++----- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts index 9282528524..72e913fc87 100644 --- a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts @@ -1770,8 +1770,10 @@ async function verifyDurableBranch( assert.ok(copiedProjectionArtifact); assert.equal(copiedProjectionPart.ref.relativePath, copiedProjectionArtifact.id); const durableCopiedRuns = - await execution.agentRunStore.listSessionRuns(admittedRevisionTargetId); - const durableCopiedParent = durableCopiedRuns.find((run) => run.turnId === 'turn-1'); + await execution.runtimeEventStore.listSessionInvocations(admittedRevisionTargetId); + const durableCopiedParent = durableCopiedRuns.find( + (invocation) => invocation.turnId === 'turn-1', + ); assert.ok(durableCopiedParent); const copiedParentEvents = ( await execution.runtimeEventStore.readSessionRuntimeEventEntries(admittedRevisionTargetId) diff --git a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts index b064bd9796..a2ece15573 100644 --- a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts +++ b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts @@ -1849,7 +1849,6 @@ describe('SessionManager terminal ledger invariants', () => { } const view = await new RuntimeReadModel({ - runStore, runtimeEventStore: runStore, }).getSessionView(run.sessionId); @@ -1913,10 +1912,7 @@ describe('SessionManager terminal ledger invariants', () => { readRuntimeEvents: async () => [opened!, prompt, partial, terminal], }); - const view = await new RuntimeReadModel({ - runStore, - runtimeEventStore, - }).getSessionView(run.sessionId); + const view = await new RuntimeReadModel({ runtimeEventStore }).getSessionView(run.sessionId); assert.deepStrictEqual( view.events.map((event) => event.id), @@ -1941,7 +1937,7 @@ describe('SessionManager terminal ledger invariants', () => { }); await assert.rejects( - new RuntimeReadModel({ runStore, runtimeEventStore }).getSessionView(run.sessionId), + new RuntimeReadModel({ runtimeEventStore }).getSessionView(run.sessionId), /RuntimeEvent session order read failed/, ); }); @@ -2337,6 +2333,7 @@ class TinySessionStore implements SessionStore { class TinyAgentRunStore implements AgentRunStore, RuntimeEventStore { private events = new Map(); private runtimeEvents = new Map(); + private runtimeEventEntries: RuntimeEvent[] = []; /** One-shot append rejections, for latching the store availability. */ failNextRuntimeEventAppends = 0; /** While true every runtime-event read rejects, a store that is down. */ @@ -2402,6 +2399,7 @@ class TinyAgentRunStore implements AgentRunStore, RuntimeEventStore { if (isTerminalRuntimeEvent(event)) await this.options.beforeTerminalRuntimeEventAppend?.(); const eventKey = key(sessionId, runId); this.runtimeEvents.set(eventKey, [...(this.runtimeEvents.get(eventKey) ?? []), clone(event)]); + if (event.partial !== true) this.runtimeEventEntries.push(clone(event)); } async ensureTerminalRuntimeEventDurable( @@ -2430,6 +2428,12 @@ class TinyAgentRunStore implements AgentRunStore, RuntimeEventStore { return clone(this.runtimeEvents.get(key(sessionId, runId)) ?? []); } + async readSessionRuntimeEventEntries(sessionId: string) { + return this.runtimeEventEntries + .filter((event) => event.sessionId === sessionId) + .map((event, index) => ({ ordinal: index + 1, event: clone(event) })); + } + async readSessionRuntimeEvents(sessionId: string): Promise { const ordered: Array<{ event: RuntimeEvent; runId: string; eventIndex: number }> = []; for (const [eventKey, events] of this.runtimeEvents.entries()) { @@ -2497,6 +2501,12 @@ class BatchingRuntimeEventStore implements RuntimeEventStore { return clone(this.events); } + async readSessionRuntimeEventEntries() { + return this.events + .filter((event) => event.partial !== true) + .map((event, index) => ({ ordinal: index + 1, event: clone(event) })); + } + async listSessionInvocations(sessionId: string): Promise { return runtimeInvocationsFromSessionEvents(sessionId, clone(this.events)); } From c1d283576bf01a8c0c0c0b00a4639cd39d1cd876 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 17:17:49 +0800 Subject: [PATCH 33/46] fix(runtime-host): start the drained Turn outside its admission Opening the invocation no longer awaits a header write, so onRunStarted now fires while the admission that started the Turn is still open, and its refreshCanonical is rejected as a nested admission. The Turn is not admission work: detach it from the admission context so its own admissions queue normally. Generated-by: Claude Code --- .../src/server/root-turn-coordinator.ts | 2 +- .../src/server/session-admission-gate.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index ec877e8f46..f5db2de586 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -2293,7 +2293,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { } } this.#executions.activate(entry, replacing); - entry.done = this.drainTurn(input, entry, startSettled); + entry.done = this.sessionAdmission.detach(() => this.drainTurn(input, entry, startSettled)); void entry.done.catch(() => undefined); if (rootReservation) { this.#admissions.activated(rootReservation, entry.done); diff --git a/packages/runtime-host/src/server/session-admission-gate.ts b/packages/runtime-host/src/server/session-admission-gate.ts index 49dc631181..69576581d3 100644 --- a/packages/runtime-host/src/server/session-admission-gate.ts +++ b/packages/runtime-host/src/server/session-admission-gate.ts @@ -81,6 +81,19 @@ export class SessionAdmissionGate { return this.#runQueued([sessionId], operation); } + /** + * Start work that outlives the admission that reserved it. + * + * A drained Turn is not admission work: it runs for as long as the Turn does + * and takes admissions of its own along the way. Started plainly it inherits + * the admission context of the caller, and whether its first admission is + * rejected then comes down to which finishes first — the admission, or the + * Turn reaching its own. Leaving the context here settles that by saying so. + */ + detach(operation: () => T): T { + return this.#context.exit(operation); + } + runAdmitted( sessionId: string, lease: SessionAdmissionLease, From bff9cbc441f881667919ef97e3fedae19f56555a Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 17:27:02 +0800 Subject: [PATCH 34/46] test: state one invocation the same way everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every test that needed an invocation record built its own copy of the opening fact: eight near-identical constructors plus a dozen inline literals, all restating defaults nothing asserts on. Fold them onto the one fixture, which now merges configuration field by field so a test states only the setting it is about, and writes failureClass where the read model looks for it. Drop the cases that only restate a schema — protocol literal, enum member, empty lineage, each empty legacy field — and keep the ones that carry a rule: which routes may name a connection, which roots exist, that a continuation names its boundary, that a malformed opening fails the whole decode. Generated-by: Claude Code --- .../runtime-invocation-opened.test.ts | 116 +++++------------- .../__tests__/agent-graph-timeline.test.ts | 57 +++------ .../src/__tests__/agent-run-inspect.test.ts | 25 +--- .../src/__tests__/agent-run-recovery.test.ts | 17 +-- .../src/__tests__/ai-sdk-backend.test.ts | 17 +-- .../computer-use-provider-protocol.test.ts | 9 +- .../src/__tests__/conversation-copy.test.ts | 43 ++----- .../src/__tests__/execution-inspect.test.ts | 18 +-- .../src/__tests__/invocation-fixture.ts | 32 +++-- .../mid-turn-capacity-backend.test.ts | 18 +-- .../overflow-reactive-recovery.test.ts | 18 +-- .../runtime-continuation-crash.test.ts | 9 +- .../__tests__/runtime-continuation.test.ts | 45 ++----- .../runtime-event-read-model.test.ts | 18 +-- .../src/__tests__/runtime-resume.test.ts | 35 ++---- .../sandbox-boundary-restart-recovery.test.ts | 18 +-- .../session-event-runtime-mapper.test.ts | 18 +-- .../session-manager-terminal-ledger.test.ts | 18 +-- .../stream-graph-coordinator.test.ts | 35 ++---- .../__tests__/stream-graph-handoff.test.ts | 41 +------ .../__tests__/stream-graph-projection.test.ts | 52 ++------ .../__tests__/stream-graph-readiness.test.ts | 44 +------ .../src/__tests__/stream-graph-trace.test.ts | 26 +--- .../src/__tests__/legacy-run-header.test.ts | 35 ------ 24 files changed, 169 insertions(+), 595 deletions(-) diff --git a/packages/core/src/__tests__/runtime-invocation-opened.test.ts b/packages/core/src/__tests__/runtime-invocation-opened.test.ts index 0f8385de57..205b345a3d 100644 --- a/packages/core/src/__tests__/runtime-invocation-opened.test.ts +++ b/packages/core/src/__tests__/runtime-invocation-opened.test.ts @@ -94,79 +94,30 @@ describe('invocation_opened content contract', () => { assert.equal(runtimeEventHasModelVisibleContent(event), false); }); - test('accepts the unknown route provenance without connection identity', () => { - const fact = decodeRuntimeInvocationOpened( - opening({ - route: { - provenance: 'unknown', - backendKind: 'ai-sdk', - llmConnectionSlug: 'legacy', - modelId: 'legacy-model', - }, - }), - ); - assert.equal(fact.route.provenance, 'unknown'); - }); - - test('rejects an unknown route that still carries a connection identity', () => { - assert.throws(() => - decodeRuntimeInvocationOpened( - opening({ - route: { - provenance: 'unknown', - backendKind: 'ai-sdk', - llmConnectionSlug: 'legacy', - modelId: 'legacy-model', - llmConnectionId: 'conn-1', - } as never, - }), - ), - ); - }); - - test('rejects a runtime route with no connection identity', () => { - assert.throws(() => - decodeRuntimeInvocationOpened( - opening({ - route: { - provenance: 'runtime', - backendKind: 'ai-sdk', - llmConnectionSlug: 'anthropic', - modelId: 'claude-x', - } as never, - }), - ), - ); - }); - - test('rejects an unversioned or misversioned protocol', () => { - assert.throws(() => decodeRuntimeInvocationOpened(opening({ protocol: 'v2' as never }))); - const { protocol: _protocol, ...withoutProtocol } = opening(); - assert.throws(() => decodeRuntimeInvocationOpened(withoutProtocol)); - }); - - test('rejects an unknown extra field anywhere in the closed shape', () => { - assert.throws(() => - decodeRuntimeInvocationOpened({ ...opening(), runComposition: {} } as never), + test('binds connection identity to where the route came from, both ways', () => { + const unknownRoute = { + provenance: 'unknown', + backendKind: 'ai-sdk', + llmConnectionSlug: 'legacy', + modelId: 'legacy-model', + } as const; + assert.equal( + decodeRuntimeInvocationOpened(opening({ route: unknownRoute })).route.provenance, + 'unknown', ); assert.throws(() => decodeRuntimeInvocationOpened( - opening({ - configuration: { ...opening().configuration, sessionMode: 'agent' } as never, - }), + opening({ route: { ...unknownRoute, llmConnectionId: 'conn-1' } as never }), ), ); - }); - - test('rejects a root authority that mixes two roots', () => { assert.throws(() => decodeRuntimeInvocationOpened( - opening({ root: { kind: 'goal', goalId: 'g1', scheduledTaskId: 's1' } as never }), + opening({ route: { ...unknownRoute, provenance: 'runtime' } as never }), ), ); }); - test('accepts every root authority the runtime can open', () => { + test('accepts every root authority the runtime can open, and no mixture of them', () => { for (const root of [ { kind: 'user' }, { kind: 'context_compact' }, @@ -177,16 +128,24 @@ describe('invocation_opened content contract', () => { ] as const) { assert.equal(decodeRuntimeInvocationOpened(opening({ root })).root.kind, root.kind); } + assert.throws(() => + decodeRuntimeInvocationOpened( + opening({ root: { kind: 'goal', goalId: 'g1', scheduledTaskId: 's1' } as never }), + ), + ); }); - test('carries the continuation source identity when the invocation continues one', () => { + test('carries a continuation source only with the boundary position it resumes from', () => { + const source = { + kind: 'continuation', + sourceInvocationId: 'inv-0', + sourceRunId: 'inv-0', + sourceTurnId: 'turn-0', + } as const; const fact = decodeRuntimeInvocationOpened( opening({ source: { - kind: 'continuation', - sourceInvocationId: 'inv-0', - sourceRunId: 'inv-0', - sourceTurnId: 'turn-0', + ...source, sourceRuntimeEventHighWater: 7, claimId: 'claim-1', boundaryDigest: DIGEST, @@ -194,32 +153,17 @@ describe('invocation_opened content contract', () => { }), ); assert.equal(fact.source.kind, 'continuation'); + assert.throws(() => decodeRuntimeInvocationOpened(opening({ source: source as never }))); }); - test('rejects a continuation source missing its boundary position', () => { + test('rejects anything the closed shape does not name', () => { assert.throws(() => - decodeRuntimeInvocationOpened( - opening({ - source: { - kind: 'continuation', - sourceInvocationId: 'inv-0', - sourceRunId: 'inv-0', - sourceTurnId: 'turn-0', - } as never, - }), - ), + decodeRuntimeInvocationOpened({ ...opening(), runComposition: {} } as never), ); - }); - - test('rejects an empty lineage object rather than storing a meaningless key', () => { - assert.throws(() => decodeRuntimeInvocationOpened(opening({ lineage: {} }))); - }); - - test('rejects an invalid enum member in configuration', () => { assert.throws(() => decodeRuntimeInvocationOpened( opening({ - configuration: { ...opening().configuration, toolMode: 'telepathy' } as never, + configuration: { ...opening().configuration, sessionMode: 'agent' } as never, }), ), ); diff --git a/packages/runtime/src/__tests__/agent-graph-timeline.test.ts b/packages/runtime/src/__tests__/agent-graph-timeline.test.ts index ed82fd2ce5..bb2997ca2e 100644 --- a/packages/runtime/src/__tests__/agent-graph-timeline.test.ts +++ b/packages/runtime/src/__tests__/agent-graph-timeline.test.ts @@ -31,6 +31,7 @@ import { readAgentGraphTimelinePage, } from '../agent-graph-timeline.js'; import { readCommittedAgentGraphProjection } from '../stream-graph-projection.js'; +import { testInvocationRecord } from './invocation-fixture.js'; describe('agent graph replay timeline', () => { test('reconstructs control, child records, parent completion, and supervisor wake chronologically', async () => { @@ -570,57 +571,27 @@ function runInvocation(input: { status?: 'completed' | 'failed' | 'aborted'; wake?: { wakeId: string; attemptId: string }; }): RuntimeInvocationRecord { - const identity = { + return testInvocationRecord({ sessionId: input.sessionId, invocationId: `invocation-${input.runId}`, runId: input.runId, turnId: input.turnId, - }; - return { - ...identity, openedAt: input.createdAt, - opening: { - kind: 'invocation_opened', - protocol: 'invocation_opened_v1', - route: { - provenance: 'runtime', - backendKind: 'ai-sdk', - llmConnectionId: 'deepseek-connection', - llmConnectionSlug: 'deepseek', - modelId: 'deepseek-chat', - }, - configuration: { - cwd: '/workspace', - permissionMode: 'explore', - collaborationMode: 'agent', - orchestrationMode: 'default', - orchestrationSource: 'session', - toolMode: 'direct', - }, - root: input.wake - ? { - kind: 'agent_graph_supervisor_wake', - wakeId: input.wake.wakeId, - attemptId: input.wake.attemptId, - } - : { kind: 'user' }, - source: { kind: 'fresh' }, - }, - ...(input.completedAt !== undefined + ...(input.wake ? { - terminalEvent: { - ...identity, - id: `${input.runId}-terminal`, - ts: input.completedAt, - partial: false, - role: 'system', - author: 'system', - status: input.status ?? 'completed', - actions: { endInvocation: true }, - } satisfies RuntimeEvent, + opening: { + root: { + kind: 'agent_graph_supervisor_wake', + wakeId: input.wake.wakeId, + attemptId: input.wake.attemptId, + }, + }, } : {}), - }; + ...(input.completedAt !== undefined + ? { closedAt: input.completedAt, outcome: input.status ?? 'completed' } + : {}), + }); } function runtimeEvent( diff --git a/packages/runtime/src/__tests__/agent-run-inspect.test.ts b/packages/runtime/src/__tests__/agent-run-inspect.test.ts index c9b50efbba..d48fc922b6 100644 --- a/packages/runtime/src/__tests__/agent-run-inspect.test.ts +++ b/packages/runtime/src/__tests__/agent-run-inspect.test.ts @@ -28,6 +28,7 @@ import { runtimeInvocationsFromSessionEvents, } from '@maka/core/runtime-invocation'; import { inspectAgentRunReadModel } from '../agent-run-inspect.js'; +import { testInvocationOpening } from './invocation-fixture.js'; const sessionId = 'session-1'; const invocationId = 'inv-1'; @@ -243,27 +244,9 @@ class MemoryAgentRunStore implements AgentRunStore, RuntimeEventStore { } function makeOpening(): RuntimeEventInvocationOpenedContent { - return { - kind: 'invocation_opened', - protocol: 'invocation_opened_v1', - route: { - provenance: 'runtime', - backendKind: 'fake', - llmConnectionId: 'fake-connection', - llmConnectionSlug: 'fake', - modelId: 'fake-model', - }, - configuration: { - cwd: '/tmp/cwd', - permissionMode: 'ask', - collaborationMode: 'agent', - orchestrationMode: 'default', - orchestrationSource: 'session', - toolMode: 'direct', - }, - root: { kind: 'user' }, - source: { kind: 'fresh' }, - }; + return testInvocationOpening({ + configuration: { cwd: '/tmp/cwd' }, + }); } /** The invocation a run is named by, for the cases whose ledger is unreadable. */ diff --git a/packages/runtime/src/__tests__/agent-run-recovery.test.ts b/packages/runtime/src/__tests__/agent-run-recovery.test.ts index 4417733041..19bca3c7e5 100644 --- a/packages/runtime/src/__tests__/agent-run-recovery.test.ts +++ b/packages/runtime/src/__tests__/agent-run-recovery.test.ts @@ -21,6 +21,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import { classifyAgentRunRecovery } from '../agent-run-recovery.js'; +import { testInvocationOpening } from './invocation-fixture.js'; describe('AgentRun startup recovery', () => { test('fails a graph supervisor permission handoff once its live waiter is lost', () => { @@ -30,9 +31,7 @@ describe('AgentRun startup recovery', () => { runId: 'run-1', turnId: 'turn-1', openedAt: 1, - opening: { - kind: 'invocation_opened', - protocol: 'invocation_opened_v1', + opening: testInvocationOpening({ route: { provenance: 'runtime', backendKind: 'fake', @@ -40,17 +39,9 @@ describe('AgentRun startup recovery', () => { llmConnectionSlug: 'fake', modelId: 'fake-model', }, - configuration: { - cwd: '/tmp/workspace', - permissionMode: 'ask', - collaborationMode: 'agent', - orchestrationMode: 'default', - orchestrationSource: 'session', - toolMode: 'direct', - }, + configuration: { cwd: '/tmp/workspace' }, root: { kind: 'agent_graph_supervisor_wake', wakeId: 'wake-1', attemptId: 'attempt-1' }, - source: { kind: 'fresh' }, - }, + }), }; const decision = classifyAgentRunRecovery(invocation, [ diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 1966796fbd..f9693fd817 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -95,6 +95,7 @@ import type { OpenAiResponsesSemanticBaseline } from '../openai-responses-contin import type { OpenAiResponsesTransportState } from '../openai-responses-websocket.js'; import { getAIModel } from '../model-factory.js'; import { waitFor as pollFor } from '@maka/core/test-only/async-primitives'; +import { testInvocationOpening } from './invocation-fixture.js'; describe('AiSdkBackend ApplyPatch routing', () => { test('advertises apply_patch only to supported native OpenAI models', async () => { @@ -16310,9 +16311,7 @@ function priorModelInvocation(input: { return { ...identity, openedAt: 1, - opening: { - kind: 'invocation_opened', - protocol: 'invocation_opened_v1', + opening: testInvocationOpening({ route: { provenance: 'runtime', backendKind: 'ai-sdk', @@ -16321,17 +16320,9 @@ function priorModelInvocation(input: { modelId: input.modelId, providerStateIdentity: input.providerStateIdentity ?? `sha256:${'1'.repeat(64)}`, }, - configuration: { - cwd: '/tmp/maka', - permissionMode: 'ask', - collaborationMode: 'agent', - orchestrationMode: 'default', - orchestrationSource: 'session', - toolMode: 'direct', - }, + configuration: { cwd: '/tmp/maka' }, root: input.root ?? { kind: 'user' }, - source: { kind: 'fresh' }, - }, + }), terminalEvent: { id: `${identity.runId}-terminal`, ...identity, diff --git a/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts b/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts index 10698ffb85..916d397b8f 100644 --- a/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts +++ b/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts @@ -42,6 +42,7 @@ import { backfillRuntimeEventsFromStoredMessages } from '../runtime-event-backfi import { createDurableTurnHarness } from './durable-turn-harness.js'; import { createTestAiSdkBackend } from './execution-boundary-test-helpers.js'; import { latestObservationIn } from './observation-text-reader.js'; +import { testInvocationOpening } from './invocation-fixture.js'; const servers: Array<{ close(): Promise }> = []; const PROVIDER_STATE_IDENTITY = `sha256:${'1'.repeat(64)}` as const; @@ -1604,9 +1605,7 @@ function sourceInvocation(input: { return { ...identity, openedAt: input.openedAt, - opening: { - kind: 'invocation_opened', - protocol: 'invocation_opened_v1', + opening: testInvocationOpening({ route: { provenance: 'runtime', backendKind: 'ai-sdk', @@ -1623,9 +1622,7 @@ function sourceInvocation(input: { orchestrationSource: 'session', toolMode: 'direct', }, - root: { kind: 'user' }, - source: { kind: 'fresh' }, - }, + }), terminalEvent: { ...identity, id: `${input.runId}-terminal`, diff --git a/packages/runtime/src/__tests__/conversation-copy.test.ts b/packages/runtime/src/__tests__/conversation-copy.test.ts index 5a426257ca..ceab0c7fea 100644 --- a/packages/runtime/src/__tests__/conversation-copy.test.ts +++ b/packages/runtime/src/__tests__/conversation-copy.test.ts @@ -78,6 +78,7 @@ import { buildArchivedToolResultPlaceholder, isArchivedToolResultPlaceholder, } from '../tool-result-archive.js'; +import { testInvocationOpening, testInvocationRecord } from './invocation-fixture.js'; test('archived tool-result copy preflight detects conversation-owned references', () => { const serialized = (value: unknown): string => JSON.stringify(value); @@ -3248,31 +3249,17 @@ function runFacts(overrides: SeededRun): SeededRun { function invocationRecord( run: SeededRun & { source?: RuntimeEventInvocationOpenedContent['source'] } = {}, ): RuntimeInvocationRecord { - const identity = { + const openedAt = run.openedAt ?? 1; + return testInvocationRecord({ sessionId: run.sessionId ?? 'session-source', invocationId: run.invocationId ?? 'invocation', runId: run.runId ?? 'run', turnId: run.turnId ?? 'turn', - }; - const openedAt = run.openedAt ?? 1; - return { - ...identity, openedAt, + closedAt: run.closedAt ?? openedAt + 1, + ...(run.outcome === 'open' ? {} : { outcome: run.outcome ?? 'completed' }), opening: invocationOpening(run), - ...(run.outcome === 'open' - ? {} - : { - terminalEvent: { - id: `${identity.runId}-terminal`, - ...identity, - ts: run.closedAt ?? openedAt + 1, - partial: false, - role: 'system', - author: 'system', - status: run.outcome ?? 'completed', - }, - }), - }; + }); } function invocationOpening( @@ -3284,9 +3271,7 @@ function invocationOpening( ...(run.agentId ? { agentId: run.agentId } : {}), ...(run.agentName ? { agentName: run.agentName } : {}), }; - return { - kind: 'invocation_opened', - protocol: 'invocation_opened_v1', + return testInvocationOpening({ route: { provenance: 'runtime', backendKind: 'fake', @@ -3294,18 +3279,10 @@ function invocationOpening( llmConnectionSlug: 'fake', modelId: 'model', }, - configuration: { - cwd: run.cwd ?? '/tmp', - permissionMode: 'ask', - collaborationMode: 'agent', - orchestrationMode: 'default', - orchestrationSource: 'session', - toolMode: 'direct', - }, - root: { kind: 'user' }, - source: run.source ?? { kind: 'fresh' }, + configuration: { cwd: run.cwd ?? '/tmp' }, + ...(run.source ? { source: run.source } : {}), ...(Object.keys(lineage).length > 0 ? { lineage } : {}), - }; + }); } /** diff --git a/packages/runtime/src/__tests__/execution-inspect.test.ts b/packages/runtime/src/__tests__/execution-inspect.test.ts index 3b871b04b5..46d75bcf25 100644 --- a/packages/runtime/src/__tests__/execution-inspect.test.ts +++ b/packages/runtime/src/__tests__/execution-inspect.test.ts @@ -29,6 +29,7 @@ import { createSessionStore } from '@maka/storage/session-store'; import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; import { createWorkspaceRuntimeStore } from '@maka/storage/runtime-event-persistence'; import { inspectAgentRunDocument, renderAgentRunInspectTree } from '../execution-inspect.js'; +import { testInvocationOpening } from './invocation-fixture.js'; describe('versioned execution inspect documents', () => { test('reports unknown tool outcomes without copying Runtime payloads', async () => { @@ -113,9 +114,7 @@ function openingEvent(sessionId: string) { id: 'rt-open', run: { sessionId, invocationId: 'invocation-1', runId: RUN_ID, turnId: TURN_ID }, openedAt: TS, - opening: { - kind: 'invocation_opened', - protocol: 'invocation_opened_v1', + opening: testInvocationOpening({ route: { provenance: 'runtime', backendKind: 'fake', @@ -123,17 +122,8 @@ function openingEvent(sessionId: string) { llmConnectionSlug: 'fake', modelId: 'fake-model', }, - configuration: { - cwd: '/tmp/workspace', - permissionMode: 'ask', - collaborationMode: 'agent', - orchestrationMode: 'default', - orchestrationSource: 'session', - toolMode: 'direct', - }, - root: { kind: 'user' }, - source: { kind: 'fresh' }, - }, + configuration: { cwd: '/tmp/workspace' }, + }), }); } diff --git a/packages/runtime/src/__tests__/invocation-fixture.ts b/packages/runtime/src/__tests__/invocation-fixture.ts index ea66c0a86e..821c48818c 100644 --- a/packages/runtime/src/__tests__/invocation-fixture.ts +++ b/packages/runtime/src/__tests__/invocation-fixture.ts @@ -31,19 +31,31 @@ export interface SeededInvocationIdentity { readonly turnId: string; } +export type TestInvocationOpeningOverrides = Omit< + Partial, + 'configuration' +> & { configuration?: Partial }; + export interface SeedInvocationInput { readonly sessionId: string; readonly runId: string; readonly turnId: string; readonly invocationId?: string; readonly openedAt?: number; - readonly opening?: Partial; + readonly opening?: TestInvocationOpeningOverrides; } -/** The opening a test gets when it does not care what the run was routed to. */ +/** + * The opening a test gets when it does not care what the run was routed to. + * + * `configuration` merges field by field, so a test states only the setting it + * is about. `route` replaces whole: which fields it carries depends on where + * the route came from, and merging halves of two routes makes neither. + */ export function testInvocationOpening( - overrides: Partial = {}, + overrides: TestInvocationOpeningOverrides = {}, ): RuntimeEventInvocationOpenedContent { + const { configuration, ...rest } = overrides; return { kind: 'invocation_opened', protocol: 'invocation_opened_v1', @@ -54,6 +66,7 @@ export function testInvocationOpening( llmConnectionSlug: 'fake', modelId: 'fake-model', }, + ...rest, configuration: { cwd: '/tmp', permissionMode: 'ask', @@ -61,10 +74,10 @@ export function testInvocationOpening( orchestrationMode: 'default', orchestrationSource: 'session', toolMode: 'direct', + ...configuration, }, - root: { kind: 'user' }, - source: { kind: 'fresh' }, - ...overrides, + root: overrides.root ?? { kind: 'user' }, + source: overrides.source ?? { kind: 'fresh' }, }; } @@ -83,7 +96,7 @@ export function testInvocationRecord(input: { closedAt?: number; outcome?: 'completed' | 'failed' | 'aborted'; failureClass?: string; - opening?: Partial; + opening?: TestInvocationOpeningOverrides; }): RuntimeInvocationRecord { const invocationId = input.invocationId ?? input.runId; const openedAt = input.openedAt ?? 1; @@ -107,7 +120,10 @@ export function testInvocationRecord(input: { role: 'system', author: 'system', status: input.outcome, - ...(input.failureClass ? { failureClass: input.failureClass } : {}), + actions: { + endInvocation: true, + ...(input.failureClass ? { stateDelta: { failureClass: input.failureClass } } : {}), + }, }, } : {}), diff --git a/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts b/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts index 0dc4619278..b3e6e99556 100644 --- a/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts +++ b/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts @@ -55,6 +55,7 @@ import { createTestAiSdkBackend, testToolResultArchive, } from './execution-boundary-test-helpers.js'; +import { testInvocationOpening } from './invocation-fixture.js'; const RAW_SPAN_ONE = 'RAW_SPAN_ONE_'.repeat(24); const RAW_SPAN_TWO = 'RAW_SPAN_TWO_'.repeat(160); @@ -1917,9 +1918,7 @@ function priorRunInvocation(): RuntimeInvocationRecord { return { ...identity, openedAt: 1, - opening: { - kind: 'invocation_opened', - protocol: 'invocation_opened_v1', + opening: testInvocationOpening({ route: { provenance: 'runtime', backendKind: 'ai-sdk', @@ -1927,17 +1926,8 @@ function priorRunInvocation(): RuntimeInvocationRecord { llmConnectionSlug: 'anthropic-main', modelId: 'mock-model-id', }, - configuration: { - cwd: '/tmp/maka', - permissionMode: 'ask', - collaborationMode: 'agent', - orchestrationMode: 'default', - orchestrationSource: 'session', - toolMode: 'direct', - }, - root: { kind: 'user' }, - source: { kind: 'fresh' }, - }, + configuration: { cwd: '/tmp/maka' }, + }), terminalEvent: { ...identity, id: `${identity.runId}-terminal`, diff --git a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts index 935060e45f..a778ba50c6 100644 --- a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts +++ b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts @@ -50,6 +50,7 @@ import { createTestAiSdkBackend, testToolResultArchive, } from './execution-boundary-test-helpers.js'; +import { testInvocationOpening } from './invocation-fixture.js'; // The checkpoint write gate validates summary structure and floors the size // for large folds (#3029), so the stub summary is shaped like a real @@ -2004,9 +2005,7 @@ function priorRunInvocation( return { ...identity, openedAt: 1, - opening: { - kind: 'invocation_opened', - protocol: 'invocation_opened_v1', + opening: testInvocationOpening({ route: { provenance: 'runtime', backendKind: 'ai-sdk', @@ -2016,17 +2015,8 @@ function priorRunInvocation( providerStateIdentity: runId === 'same-route-prior-run' ? PROVIDER_STATE_IDENTITY : `sha256:${'2'.repeat(64)}`, }, - configuration: { - cwd: '/tmp/maka', - permissionMode: 'ask', - collaborationMode: 'agent', - orchestrationMode: 'default', - orchestrationSource: 'session', - toolMode: 'direct', - }, - root: { kind: 'user' }, - source: { kind: 'fresh' }, - }, + configuration: { cwd: '/tmp/maka' }, + }), terminalEvent: { ...identity, id: `${identity.runId}-terminal`, diff --git a/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts b/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts index 7061397b4b..a44272cda5 100644 --- a/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts +++ b/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts @@ -40,6 +40,7 @@ import { type RuntimeContinuationFailpoint } from '../agent-run.js'; import { BackendRegistry, SessionManager } from '../session-manager.js'; import { FakeBackend } from '../test-only/fake-backend.js'; import { terminateChildProcessTree } from '../process-tree-terminator.js'; +import { testInvocationOpening } from './invocation-fixture.js'; const CRASH_CHILD_ENV = 'MAKA_RUNTIME_CONTINUATION_CRASH_CHILD'; const CRASH_CHILD_READY_TIMEOUT_MS = process.platform === 'win32' ? 30_000 : 10_000; @@ -382,9 +383,7 @@ function sourceEvents(sessionId: string, cwd: string): RuntimeEvent[] { id: 'source-open', run: identity, openedAt: 1, - opening: { - kind: 'invocation_opened', - protocol: 'invocation_opened_v1', + opening: testInvocationOpening({ route: { provenance: 'runtime', backendKind: 'fake', @@ -401,9 +400,7 @@ function sourceEvents(sessionId: string, cwd: string): RuntimeEvent[] { orchestrationSource: 'session', toolMode: 'direct', }, - root: { kind: 'user' }, - source: { kind: 'fresh' }, - }, + }), }), { ...identity, diff --git a/packages/runtime/src/__tests__/runtime-continuation.test.ts b/packages/runtime/src/__tests__/runtime-continuation.test.ts index a9331e2296..07cb84cf98 100644 --- a/packages/runtime/src/__tests__/runtime-continuation.test.ts +++ b/packages/runtime/src/__tests__/runtime-continuation.test.ts @@ -40,6 +40,7 @@ import { buildSafeBoundaryContinuationPlan, type RuntimeContinuation, } from '../runtime-resume.js'; +import { testInvocationRecord } from './invocation-fixture.js'; test('local continuation safety inspector returns current authoritative workspace facts', async () => { const inspect = createLocalContinuationSafetyInspector({ @@ -895,20 +896,18 @@ interface RunFacts { /** One source invocation as the planner reads it back off the spine. */ function runInvocation(runId: string, facts: RunFacts = {}): RuntimeInvocationRecord { const ordinal = runId.match(/(\d+)$/)?.[1] ?? '1'; - const identity = { + const outcome = facts.outcome ?? 'failed'; + const failureClass = outcome === 'failed' ? (facts.failureClass ?? 'test_failure') : undefined; + return testInvocationRecord({ sessionId: 'session-1', invocationId: `invocation-${ordinal}`, runId, turnId: `turn-${ordinal}`, - }; - const outcome = facts.outcome ?? 'failed'; - const failureClass = outcome === 'failed' ? (facts.failureClass ?? 'test_failure') : undefined; - return { - ...identity, openedAt: 1, + closedAt: 1, + ...(outcome === 'open' ? {} : { outcome }), + ...(failureClass ? { failureClass } : {}), opening: { - kind: 'invocation_opened', - protocol: 'invocation_opened_v1', route: { provenance: 'runtime', backendKind: 'fake', @@ -919,34 +918,10 @@ function runInvocation(runId: string, facts: RunFacts = {}): RuntimeInvocationRe ? { providerStateIdentity: facts.providerStateIdentity } : {}), }, - configuration: { - cwd: facts.cwd ?? '/workspace/repo', - permissionMode: 'ask', - collaborationMode: 'agent', - orchestrationMode: 'default', - orchestrationSource: 'session', - toolMode: 'direct', - }, - root: { kind: 'user' }, - source: facts.source ?? { kind: 'fresh' }, + configuration: { cwd: facts.cwd ?? '/workspace/repo' }, + ...(facts.source ? { source: facts.source } : {}), }, - ...(outcome === 'open' - ? {} - : { - terminalEvent: { - id: `${runId}-terminal`, - ...identity, - ts: 1, - partial: false, - role: 'system', - author: 'system', - status: outcome, - ...(failureClass - ? { actions: { endInvocation: true, stateDelta: { failureClass } } } - : { actions: { endInvocation: true } }), - }, - }), - }; + }); } function event(overrides: Partial): RuntimeEvent { diff --git a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts index 6352270b8b..c47760680f 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -34,6 +34,7 @@ import { import { buildRuntimeEventModelReplayPlan } from '../model-history.js'; import { backfillRuntimeEventsFromStoredMessages } from '../runtime-event-backfill.js'; import { BackendRegistry, SessionManager, type SessionStore } from '../session-manager.js'; +import { testInvocationOpening } from './invocation-fixture.js'; const ts = 1_800_000_000_000; const sessionId = 'sess-1'; @@ -63,9 +64,7 @@ const invocation: RuntimeInvocationRecord = { runId, turnId, openedAt: ts, - opening: { - kind: 'invocation_opened', - protocol: 'invocation_opened_v1', + opening: testInvocationOpening({ route: { provenance: 'runtime', backendKind: 'ai-sdk', @@ -73,18 +72,9 @@ const invocation: RuntimeInvocationRecord = { llmConnectionSlug: 'anthropic', modelId: 'claude-sonnet-4-5', }, - configuration: { - cwd: '/tmp/work', - permissionMode: 'ask', - collaborationMode: 'agent', - orchestrationMode: 'default', - orchestrationSource: 'session', - toolMode: 'direct', - }, - root: { kind: 'user' }, - source: { kind: 'fresh' }, + configuration: { cwd: '/tmp/work' }, lineage: { parentTurnId: 'parent-turn' }, - }, + }), terminalEvent: { id: `${runId}-terminal`, sessionId, diff --git a/packages/runtime/src/__tests__/runtime-resume.test.ts b/packages/runtime/src/__tests__/runtime-resume.test.ts index 4be4a55041..7d9a7c698b 100644 --- a/packages/runtime/src/__tests__/runtime-resume.test.ts +++ b/packages/runtime/src/__tests__/runtime-resume.test.ts @@ -39,6 +39,7 @@ import { buildResumeReplayRuntimeEvents, projectToolOperationsFromRuntimeEvents, } from '../runtime-resume.js'; +import { testInvocationRecord } from './invocation-fixture.js'; describe('runtime resume phase 0 projection', () => { test('publishes the stable P0-P11 crash failpoint catalog', () => { @@ -745,18 +746,16 @@ function runInvocation( facts: { source?: RuntimeEventInvocationOpenedContent['source'] } = {}, ): RuntimeInvocationRecord { const ordinal = runId.match(/(\d+)$/)?.[1] ?? '1'; - const identity = { + return testInvocationRecord({ sessionId: 'session-1', invocationId: `invocation-${ordinal}`, runId, turnId: `turn-${ordinal}`, - }; - return { - ...identity, openedAt: 1, + closedAt: 1, + outcome: 'failed', + failureClass: 'test_failure', opening: { - kind: 'invocation_opened', - protocol: 'invocation_opened_v1', route: { provenance: 'runtime', backendKind: 'fake', @@ -764,28 +763,10 @@ function runInvocation( llmConnectionSlug: 'test', modelId: 'test-model', }, - configuration: { - cwd: '/workspace/repo', - permissionMode: 'ask', - collaborationMode: 'agent', - orchestrationMode: 'default', - orchestrationSource: 'session', - toolMode: 'direct', - }, - root: { kind: 'user' }, - source: facts.source ?? { kind: 'fresh' }, - }, - terminalEvent: { - id: `${runId}-terminal`, - ...identity, - ts: 1, - partial: false, - role: 'system', - author: 'system', - status: 'failed', - actions: { endInvocation: true, stateDelta: { failureClass: 'test_failure' } }, + configuration: { cwd: '/workspace/repo' }, + ...(facts.source ? { source: facts.source } : {}), }, - }; + }); } function base(overrides: Partial): RuntimeEvent { diff --git a/packages/runtime/src/__tests__/sandbox-boundary-restart-recovery.test.ts b/packages/runtime/src/__tests__/sandbox-boundary-restart-recovery.test.ts index 76fd6cf186..1ad13b4d54 100644 --- a/packages/runtime/src/__tests__/sandbox-boundary-restart-recovery.test.ts +++ b/packages/runtime/src/__tests__/sandbox-boundary-restart-recovery.test.ts @@ -36,6 +36,7 @@ import { createSessionStore, type SessionAuthorityStore } from '@maka/storage/se import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; import { createWorkspaceRuntimeStore } from '@maka/storage/runtime-event-persistence'; import { BackendRegistry, SessionManager } from '../session-manager.js'; +import { testInvocationOpening } from './invocation-fixture.js'; /** * Restart behaviour against the canonical SQLite stores. Memory stores can @@ -239,9 +240,7 @@ function openingEvent(sessionId: string) { id: 'run-1-open', run: { sessionId, invocationId: 'run-1', runId: 'run-1', turnId: 'turn-1' }, openedAt: 10, - opening: { - kind: 'invocation_opened', - protocol: 'invocation_opened_v1', + opening: testInvocationOpening({ route: { provenance: 'runtime', backendKind: 'fake', @@ -249,17 +248,8 @@ function openingEvent(sessionId: string) { llmConnectionSlug: 'fake', modelId: 'fake-model', }, - configuration: { - cwd: '/tmp/cwd', - permissionMode: 'ask', - collaborationMode: 'agent', - orchestrationMode: 'default', - orchestrationSource: 'session', - toolMode: 'direct', - }, - root: { kind: 'user' }, - source: { kind: 'fresh' }, - }, + configuration: { cwd: '/tmp/cwd' }, + }), }); } diff --git a/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts b/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts index 63c1a45425..65632860cb 100644 --- a/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts +++ b/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts @@ -43,6 +43,7 @@ import { } from '../runtime-event-read-model.js'; import { isNonTerminalErrorRuntimeEvent } from '../agent-run.js'; import { backfillRuntimeEventsFromStoredMessages } from '../runtime-event-backfill.js'; +import { testInvocationOpening } from './invocation-fixture.js'; // ============================================================================ // Event builders @@ -659,9 +660,7 @@ const projectionInvocation: RuntimeInvocationRecord = { runId: 'run-1', turnId: 'turn-1', openedAt: 1, - opening: { - kind: 'invocation_opened', - protocol: 'invocation_opened_v1', + opening: testInvocationOpening({ route: { provenance: 'runtime', backendKind: 'ai-sdk', @@ -669,17 +668,8 @@ const projectionInvocation: RuntimeInvocationRecord = { llmConnectionSlug: 'anthropic', modelId: 'model-1', }, - configuration: { - cwd: '/tmp', - permissionMode: 'ask', - collaborationMode: 'agent', - orchestrationMode: 'default', - orchestrationSource: 'session', - toolMode: 'direct', - }, - root: { kind: 'user' }, - source: { kind: 'fresh' }, - }, + configuration: { cwd: '/tmp' }, + }), }; describe('SessionEvent projection coverage', () => { diff --git a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts index a2ece15573..83f8caeb24 100644 --- a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts +++ b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts @@ -62,6 +62,7 @@ import { import { RuntimeReadModel } from '../runtime-read-model.js'; import { RuntimeKernel } from '../runtime-kernel.js'; import type { RuntimeInteractionAuthority } from '../interaction-authority.js'; +import { testInvocationOpening } from './invocation-fixture.js'; describe('SessionManager terminal ledger invariants', () => { test('coalesces one partial stream and flushes it before the final model event', async () => { @@ -2544,9 +2545,7 @@ async function seedOpening( id: `${run.runId}-invocation-opened`, run: { ...run, invocationId: run.runId }, openedAt, - opening: { - kind: 'invocation_opened', - protocol: 'invocation_opened_v1', + opening: testInvocationOpening({ route: { provenance: 'runtime', backendKind: 'fake', @@ -2554,17 +2553,8 @@ async function seedOpening( llmConnectionSlug: 'fake', modelId: 'fake-model', }, - configuration: { - cwd: '/tmp/cwd', - permissionMode: 'ask', - collaborationMode: 'agent', - orchestrationMode: 'default', - orchestrationSource: 'session', - toolMode: 'direct', - }, - root: { kind: 'user' }, - source: { kind: 'fresh' }, - }, + configuration: { cwd: '/tmp/cwd' }, + }), }), ); return run; diff --git a/packages/runtime/src/__tests__/stream-graph-coordinator.test.ts b/packages/runtime/src/__tests__/stream-graph-coordinator.test.ts index f477de5fc1..4201b6c807 100644 --- a/packages/runtime/src/__tests__/stream-graph-coordinator.test.ts +++ b/packages/runtime/src/__tests__/stream-graph-coordinator.test.ts @@ -64,6 +64,7 @@ import { type UpdateAgentGraphToolInput, } from '../stream-graph-supervisor-tools.js'; import { projectAgentGraphRecords } from '../stream-graph-projection.js'; +import { testInvocationOpening } from './invocation-fixture.js'; describe('host-managed agent graph coordinator', () => { test('authorizes only selected committed results from an earlier epoch of the same root', async () => { @@ -77,9 +78,7 @@ describe('host-managed agent graph coordinator', () => { turnId: 'source-turn', invocationId: 'source-invocation', openedAt: 1, - opening: { - kind: 'invocation_opened', - protocol: 'invocation_opened_v1', + opening: testInvocationOpening({ route: { provenance: 'runtime', backendKind: 'fake', @@ -87,17 +86,8 @@ describe('host-managed agent graph coordinator', () => { llmConnectionSlug: 'fake', modelId: 'fake', }, - configuration: { - cwd: '/workspace', - permissionMode: 'explore', - collaborationMode: 'agent', - orchestrationMode: 'default', - orchestrationSource: 'session', - toolMode: 'direct', - }, - root: { kind: 'user' }, - source: { kind: 'fresh' }, - }, + configuration: { cwd: '/workspace', permissionMode: 'explore' }, + }), terminalEvent: { id: 'source-terminal', sessionId: 'source-child', @@ -1450,9 +1440,7 @@ describe('host-managed agent graph coordinator', () => { runId, turnId, openedAt: 12, - opening: { - kind: 'invocation_opened', - protocol: 'invocation_opened_v1', + opening: testInvocationOpening({ route: { provenance: 'runtime', backendKind: 'fake', @@ -1460,17 +1448,8 @@ describe('host-managed agent graph coordinator', () => { llmConnectionSlug: 'fake', modelId: 'fake', }, - configuration: { - cwd: '/workspace', - permissionMode: 'explore', - collaborationMode: 'agent', - orchestrationMode: 'default', - orchestrationSource: 'session', - toolMode: 'direct', - }, - root: { kind: 'user' }, - source: { kind: 'fresh' }, - }, + configuration: { cwd: '/workspace', permissionMode: 'explore' }, + }), }; const runningEvent: RuntimeEvent = { id: 'child-started', diff --git a/packages/runtime/src/__tests__/stream-graph-handoff.test.ts b/packages/runtime/src/__tests__/stream-graph-handoff.test.ts index 19d6e21146..9143cf3583 100644 --- a/packages/runtime/src/__tests__/stream-graph-handoff.test.ts +++ b/packages/runtime/src/__tests__/stream-graph-handoff.test.ts @@ -26,6 +26,7 @@ import { renderAgentGraphScheduledWorkPrompt, } from '../stream-graph-handoff.js'; import { projectAgentGraphRecords } from '../stream-graph-projection.js'; +import { testInvocationRecord } from './invocation-fixture.js'; describe('agent graph operator handoffs', () => { test('hydrates a selected result or terminal record from the authoritative RuntimeEvent stream', async () => { @@ -203,47 +204,15 @@ describe('agent graph operator handoffs', () => { /** The child's one finished invocation, as its own events describe it. */ function runInvocation(): RuntimeInvocationRecord { - const identity = { + return testInvocationRecord({ sessionId: 'child-session', invocationId: 'invocation-child', runId: 'run-child', turnId: 'turn-child', - }; - return { - ...identity, openedAt: 10, - opening: { - kind: 'invocation_opened', - protocol: 'invocation_opened_v1', - route: { - provenance: 'runtime', - backendKind: 'ai-sdk', - llmConnectionId: 'deepseek-connection', - llmConnectionSlug: 'deepseek', - modelId: 'deepseek-chat', - }, - configuration: { - cwd: '/workspace', - permissionMode: 'explore', - collaborationMode: 'agent', - orchestrationMode: 'default', - orchestrationSource: 'session', - toolMode: 'direct', - }, - root: { kind: 'user' }, - source: { kind: 'fresh' }, - }, - terminalEvent: { - ...identity, - id: 'run-child-terminal', - ts: 12, - partial: false, - role: 'system', - author: 'system', - status: 'completed', - actions: { endInvocation: true }, - }, - }; + closedAt: 12, + outcome: 'completed', + }); } function runtimeEvent( diff --git a/packages/runtime/src/__tests__/stream-graph-projection.test.ts b/packages/runtime/src/__tests__/stream-graph-projection.test.ts index 09b901c0e0..751e6f93dd 100644 --- a/packages/runtime/src/__tests__/stream-graph-projection.test.ts +++ b/packages/runtime/src/__tests__/stream-graph-projection.test.ts @@ -27,6 +27,7 @@ import { readCommittedAgentGraphProjection, replayAgentGraphRecords, } from '../stream-graph-projection.js'; +import { testInvocationRecord } from './invocation-fixture.js'; const baseTs = 1_800_000_000_000; @@ -722,55 +723,18 @@ function runInvocation(input: { status: 'created' | 'running' | 'completed' | 'failed' | 'aborted'; createdAt: number; }): RuntimeInvocationRecord { - const identity = { - sessionId: input.sessionId, - invocationId: `invocation-${input.runId}`, - runId: input.runId, - turnId: input.turnId, - }; const ended = input.status === 'completed' || input.status === 'failed' || input.status === 'aborted' ? input.status : undefined; - return { - ...identity, + return testInvocationRecord({ + sessionId: input.sessionId, + invocationId: `invocation-${input.runId}`, + runId: input.runId, + turnId: input.turnId, openedAt: input.createdAt, - opening: { - kind: 'invocation_opened', - protocol: 'invocation_opened_v1', - route: { - provenance: 'runtime', - backendKind: 'ai-sdk', - llmConnectionId: 'deepseek-connection', - llmConnectionSlug: 'deepseek', - modelId: 'deepseek-chat', - }, - configuration: { - cwd: '/workspace', - permissionMode: 'explore', - collaborationMode: 'agent', - orchestrationMode: 'default', - orchestrationSource: 'session', - toolMode: 'direct', - }, - root: { kind: 'user' }, - source: { kind: 'fresh' }, - }, - ...(ended - ? { - terminalEvent: { - ...identity, - id: `${input.runId}-terminal`, - ts: input.createdAt + 1, - partial: false, - role: 'system', - author: 'system', - status: ended, - actions: { endInvocation: true }, - } satisfies RuntimeEvent, - } - : {}), - }; + ...(ended ? { outcome: ended } : {}), + }); } function runtimeEvent( diff --git a/packages/runtime/src/__tests__/stream-graph-readiness.test.ts b/packages/runtime/src/__tests__/stream-graph-readiness.test.ts index af459a3de7..533311efd2 100644 --- a/packages/runtime/src/__tests__/stream-graph-readiness.test.ts +++ b/packages/runtime/src/__tests__/stream-graph-readiness.test.ts @@ -28,6 +28,7 @@ import { } from '../stream-graph-readiness.js'; import { projectAgentGraphRecords } from '../stream-graph-projection.js'; import type { AgentGraphTraceTopology } from '../stream-graph-trace.js'; +import { testInvocationRecord } from './invocation-fixture.js'; const baseTs = 1_800_000_000_000; @@ -579,51 +580,14 @@ function runInvocation( status: 'running' | 'completed' | 'failed' | 'aborted' = 'running', sessionId = `session-${name}`, ): RuntimeInvocationRecord { - const identity = { + return testInvocationRecord({ sessionId, invocationId: `invocation-${name}`, runId: `run-${name}`, turnId: `turn-${name}`, - }; - return { - ...identity, openedAt, - opening: { - kind: 'invocation_opened', - protocol: 'invocation_opened_v1', - route: { - provenance: 'runtime', - backendKind: 'ai-sdk', - llmConnectionId: 'deepseek-connection', - llmConnectionSlug: 'deepseek', - modelId: 'deepseek-chat', - }, - configuration: { - cwd: '/workspace', - permissionMode: 'explore', - collaborationMode: 'agent', - orchestrationMode: 'default', - orchestrationSource: 'session', - toolMode: 'direct', - }, - root: { kind: 'user' }, - source: { kind: 'fresh' }, - }, - ...(status === 'running' - ? {} - : { - terminalEvent: { - ...identity, - id: `run-${name}-terminal`, - ts: openedAt + 1, - partial: false, - role: 'system', - author: 'system', - status, - actions: { endInvocation: true }, - }, - }), - }; + ...(status === 'running' ? {} : { outcome: status }), + }); } function binding(run: RuntimeInvocationRecord, operatorId: string) { diff --git a/packages/runtime/src/__tests__/stream-graph-trace.test.ts b/packages/runtime/src/__tests__/stream-graph-trace.test.ts index 095947a110..2366a5ef08 100644 --- a/packages/runtime/src/__tests__/stream-graph-trace.test.ts +++ b/packages/runtime/src/__tests__/stream-graph-trace.test.ts @@ -27,6 +27,7 @@ import { buildAgentGraphTraceSnapshot, type AgentGraphTraceTopology, } from '../stream-graph-trace.js'; +import { testInvocationRecord } from './invocation-fixture.js'; const baseTs = 1_800_000_000_000; @@ -492,34 +493,13 @@ describe('stream graph trace topology', () => { /** One still-open invocation, as its opening fact describes it. */ function runInvocation(name: string, openedAt: number): RuntimeInvocationRecord { - return { + return testInvocationRecord({ sessionId: `session-${name}`, invocationId: `invocation-${name}`, runId: `run-${name}`, turnId: `turn-${name}`, openedAt, - opening: { - kind: 'invocation_opened', - protocol: 'invocation_opened_v1', - route: { - provenance: 'runtime', - backendKind: 'ai-sdk', - llmConnectionId: 'deepseek-connection', - llmConnectionSlug: 'deepseek', - modelId: 'deepseek-chat', - }, - configuration: { - cwd: '/workspace', - permissionMode: 'explore', - collaborationMode: 'agent', - orchestrationMode: 'default', - orchestrationSource: 'session', - toolMode: 'direct', - }, - root: { kind: 'user' }, - source: { kind: 'fresh' }, - }, - }; + }); } function binding(run: RuntimeInvocationRecord, operatorId: string) { diff --git a/packages/storage/src/__tests__/legacy-run-header.test.ts b/packages/storage/src/__tests__/legacy-run-header.test.ts index 1415f551da..4617f90f40 100644 --- a/packages/storage/src/__tests__/legacy-run-header.test.ts +++ b/packages/storage/src/__tests__/legacy-run-header.test.ts @@ -74,41 +74,6 @@ describe('legacy Run header decoding', () => { }); describe('legacy continuation source decoding', () => { - test('rejects an empty V2 claim identity', () => { - assert.throws( - () => - decodePersistedLegacyRunHeader( - headerWithContinuation({ ...validV2ContinuationSource(), claimId: '' }), - ), - /Invalid AgentRun header schema/, - ); - }); - - test('rejects a zero V2 source high-water', () => { - assert.throws( - () => - decodePersistedLegacyRunHeader( - headerWithContinuation({ - ...validV2ContinuationSource(), - sourceRuntimeEventHighWater: 0, - }), - ), - /Invalid AgentRun header schema/, - ); - }); - - for (const field of ['sourceInvocationId', 'sourceRunId', 'sourceTurnId'] as const) { - test(`rejects an empty V2 ${field}`, () => { - assert.throws( - () => - decodePersistedLegacyRunHeader( - headerWithContinuation({ ...validV2ContinuationSource(), [field]: '' }), - ), - /Invalid AgentRun header schema/, - ); - }); - } - test('rejects a V2 replay manifest that does not identify its boundary', () => { assert.throws( () => From 5ead74f71ed7bb92d7d0ea27ebf33c1eebda1f06 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 3 Sep 2026 17:56:11 +0800 Subject: [PATCH 35/46] test(runtime): read the WorkHub stop outcome off the spine The stop test from #4439 read the run's status and abort source off the header. Both are the terminal event's to state, and the test already asserts on it. Generated-by: Claude Code --- .../src/__tests__/session-manager-terminal-ledger.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts index 83f8caeb24..811297eeb0 100644 --- a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts +++ b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts @@ -316,10 +316,9 @@ describe('SessionManager terminal ledger invariants', () => { }); const expected = workHubDirectStopAbortSource('workhub-stop-action'); - const [run] = await runStore.listSessionRuns(session.id); + const [run] = await runStore.listSessionInvocations(session.id); if (!run) throw new Error('run was not recorded'); - assert.strictEqual(run.status, 'cancelled'); - assert.strictEqual(run.abortSource, expected); + assert.strictEqual(runtimeInvocationOutcome(run), 'cancelled'); const [terminal] = (await runStore.readRuntimeEvents(session.id, run.runId)).filter( isTerminalRuntimeEvent, ); From 9b140f9c92240b4b9be4b97f8b690045d4392f1a Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 4 Sep 2026 02:33:34 +0800 Subject: [PATCH 36/46] fix(storage): migrate every header the header era wrote, endings included MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy decoder rejected any header carrying `runComposition`, which every run that reached a provider on main carries, because `hasExactShape` refuses unknown keys before a single field is checked. The backfill then swallowed the decode error and skipped the row, and the core-execution migration dropped `record_json` right after: the run had no opening, no shelf row and no header any more, so `listSessionInvocations` could not enumerate it while its events stayed behind as orphans. Three changes at the migration. The legacy shape accepts `runComposition` as an opaque record: nothing on the spine reads it back, so the migration only has to know a header carrying it is well formed. A header the migration cannot read now stops the migration — the transaction rolls back and the error names the row — because the alternative was a run silently ceasing to exist. And a header-only run whose header recorded an ending gets that terminal event as event 2, carrying the header's failure class, message and abort source; a completed legacy run migrated as an open one before, since the opening was the only fact projected. The synthetic terminal builder moves from runtime to core beside the opening builder so the migration and recovery state the same envelope, and `runtimeEventKind` moves to the schema module for the same reason. A partial unique index makes an opening unique per invocation by schema rather than by convention, and the anchor reader no longer guesses `sessionInline: false` for a run with no opening at all. The regression case is built from a base-era header with a real composition snapshot, not from the decoder's own accepted set: a fixture written to the new shape can only ever prove that new code reads what new code writes. Generated-by: Claude Code --- packages/core/src/runtime-invocation.ts | 56 ++++++ .../session-manager-terminal-ledger.test.ts | 2 +- packages/runtime/src/agent-run.ts | 6 +- packages/runtime/src/terminal-run-commit.ts | 56 +----- .../invocation-opening-backfill.test.ts | 167 ++++++++++++++++-- .../recovery-persistence-authority.test.ts | 4 +- .../sqlite-recovery-concurrency.test.ts | 1 + ...pace-version-authority-persistence.test.ts | 1 + packages/storage/src/agent-run-store.ts | 12 -- packages/storage/src/legacy-run-header.ts | 9 + packages/storage/src/sqlite-runtime-schema.ts | 118 +++++++++---- packages/storage/src/sqlite-runtime-store.ts | 11 +- 12 files changed, 320 insertions(+), 123 deletions(-) diff --git a/packages/core/src/runtime-invocation.ts b/packages/core/src/runtime-invocation.ts index ef3dd9b76f..23404abbc4 100644 --- a/packages/core/src/runtime-invocation.ts +++ b/packages/core/src/runtime-invocation.ts @@ -118,6 +118,62 @@ export function buildInvocationOpenedEvent(input: { }; } +export interface BuildSyntheticTerminalRuntimeEventInput { + id: string; + invocationId: string; + run: { sessionId: string; runId: string; turnId: string }; + status: RuntimeInvocationOutcome; + ts: number; + failureClass?: string; + abortSource?: string; + recoveryReason?: string; + diagnostic?: Record; + message?: string; +} + +/** + * The terminal event a writer states on the run's behalf, when the run did not + * state its own: recovery after a crash, a copy, or the migration of a header + * whose run never wrote an event. One envelope, decided here. + */ +export function buildSyntheticTerminalRuntimeEvent( + input: BuildSyntheticTerminalRuntimeEventInput, +): RuntimeEvent { + const failureClass = input.status === 'failed' ? (input.failureClass ?? 'unknown') : undefined; + const abortSource = input.status === 'cancelled' ? input.abortSource : undefined; + return { + id: input.id, + invocationId: input.invocationId, + runId: input.run.runId, + sessionId: input.run.sessionId, + turnId: input.run.turnId, + ts: input.ts, + partial: false, + role: 'system', + author: 'system', + status: input.status === 'cancelled' ? 'aborted' : input.status, + ...(failureClass + ? { + content: { + kind: 'error', + code: failureClass, + reason: failureClass, + message: input.message ?? failureClass, + }, + } + : {}), + actions: { + endInvocation: true, + stateDelta: { + ...(input.recoveryReason ? { recovered: true, recoveryReason: input.recoveryReason } : {}), + ...(input.diagnostic ?? {}), + ...(failureClass ? { failureClass } : {}), + ...(abortSource ? { abortSource } : {}), + }, + }, + }; +} + /** One invocation's position in a Session's opening order. */ export interface RuntimeInvocationPageCursor { readonly openedAt: number; diff --git a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts index 811297eeb0..1ba5f502e9 100644 --- a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts +++ b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts @@ -33,6 +33,7 @@ import type { AgentRunEvent, AgentRunStore } from '@maka/core/agent-run'; import { runtimeInvocationFailureClass } from '../runtime-event-read-model.js'; import { buildInvocationOpenedEvent, + buildSyntheticTerminalRuntimeEvent, runtimeInvocationOutcome, runtimeInvocationsFromSessionEvents, type RuntimeInvocationRecord, @@ -54,7 +55,6 @@ import { import type { AgentBackend } from '@maka/core/backend-types'; import { buildRecoveredTerminalRuntimeEvent, - buildSyntheticTerminalRuntimeEvent, classifyTerminalRuntimeLedger, commitOrCreateTerminalRunFact, commitTerminalRunWithRuntimeFact, diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index 06278d6cc2..7f119bfda3 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -31,6 +31,7 @@ import { decodeRunCompositionSnapshot } from '@maka/core/run-composition'; import { DurableStoreWriteError, RunSealedError } from '@maka/core/runtime-event-store'; import { buildInvocationOpenedEvent, + buildSyntheticTerminalRuntimeEvent, isSessionInlineInvocation, } from '@maka/core/runtime-invocation'; import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; @@ -85,10 +86,7 @@ import { statusFromEvent, turnStatusFromEvent, } from './session-projection-helpers.js'; -import { - buildSyntheticTerminalRuntimeEvent, - commitOrCreateTerminalRunFact, -} from './terminal-run-commit.js'; +import { commitOrCreateTerminalRunFact } from './terminal-run-commit.js'; import type { RuntimeContinuation } from './runtime-resume.js'; import { createRuntimeContinuationStartAdmissionProof, diff --git a/packages/runtime/src/terminal-run-commit.ts b/packages/runtime/src/terminal-run-commit.ts index 58e39d7f2b..41ba570aaa 100644 --- a/packages/runtime/src/terminal-run-commit.ts +++ b/packages/runtime/src/terminal-run-commit.ts @@ -20,7 +20,10 @@ import { isPartialRuntimeEvent, isTerminalRuntimeEvent } from '@maka/core/runtime-event'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; -import type { RuntimeInvocationOutcome } from '@maka/core/runtime-invocation'; +import { + buildSyntheticTerminalRuntimeEvent, + type RuntimeInvocationOutcome, +} from '@maka/core/runtime-invocation'; import { classifyRuntimeEventTerminalFact, type RuntimeEventTerminalFact, @@ -185,57 +188,6 @@ function assertCommittableTerminalEvent( return status; } -export interface BuildSyntheticTerminalRuntimeEventInput { - id: string; - invocationId: string; - run: RunIdentity; - status: RuntimeInvocationOutcome; - ts: number; - failureClass?: string; - abortSource?: string; - recoveryReason?: string; - diagnostic?: Record; - message?: string; -} - -export function buildSyntheticTerminalRuntimeEvent( - input: BuildSyntheticTerminalRuntimeEventInput, -): RuntimeEvent { - const failureClass = input.status === 'failed' ? (input.failureClass ?? 'unknown') : undefined; - const abortSource = input.status === 'cancelled' ? input.abortSource : undefined; - return { - id: input.id, - invocationId: input.invocationId, - runId: input.run.runId, - sessionId: input.run.sessionId, - turnId: input.run.turnId, - ts: input.ts, - partial: false, - role: 'system', - author: 'system', - status: input.status === 'cancelled' ? 'aborted' : input.status, - ...(failureClass - ? { - content: { - kind: 'error', - code: failureClass, - reason: failureClass, - message: input.message ?? failureClass, - }, - } - : {}), - actions: { - endInvocation: true, - stateDelta: { - ...(input.recoveryReason ? { recovered: true, recoveryReason: input.recoveryReason } : {}), - ...(input.diagnostic ?? {}), - ...(failureClass ? { failureClass } : {}), - ...(abortSource ? { abortSource } : {}), - }, - }, - }; -} - export interface BuildRecoveredTerminalRuntimeEventInput { id: string; run: RunIdentity & { invocationId?: string }; diff --git a/packages/storage/src/__tests__/invocation-opening-backfill.test.ts b/packages/storage/src/__tests__/invocation-opening-backfill.test.ts index a36f8eff80..4dfe39d4ef 100644 --- a/packages/storage/src/__tests__/invocation-opening-backfill.test.ts +++ b/packages/storage/src/__tests__/invocation-opening-backfill.test.ts @@ -24,6 +24,7 @@ import { join } from 'node:path'; import { describe, test } from 'node:test'; import { DatabaseSync } from 'node:sqlite'; import { encodeCanonicalRuntimeEvent } from '@maka/core/canonical-runtime-event'; +import { createRunCompositionSnapshot } from '@maka/core/run-composition'; import { decodeRuntimeEvent } from '@maka/core/runtime-event'; import type { LegacyRunHeader } from '../legacy-run-header.js'; import { OPERATIONAL_STATE_DATABASE_NAME } from '../operational-state-store.js'; @@ -69,7 +70,7 @@ describe('invocation opening fact backfill', () => { assert.deepEqual( rows.map((row) => row.run_id), ['run-legacy-route', 'run-scheduled'], - 'only the header-only runs are backfilled, and the corrupt one is skipped', + 'only the header-only runs are backfilled', ); assert.deepEqual( rows.map((row) => row.event_seq), @@ -97,12 +98,34 @@ describe('invocation opening fact backfill', () => { }); assert.equal(scheduled.content.route.provenance, 'runtime'); + // Both header-only runs were marked completed, so each gets the ending + // its header recorded, right after its opening. + const backfilled = db + .prepare(` + SELECT run_id, event_seq, event_kind FROM runtime_events + WHERE run_id IN ('run-legacy-route', 'run-scheduled') + ORDER BY run_id ASC, event_seq ASC + `) + .all() as Array<{ run_id: string; event_seq: number; event_kind: string }>; + assert.deepEqual( + backfilled.map(({ run_id, event_seq, event_kind }) => ({ + run_id, + event_seq, + event_kind, + })), + [ + { run_id: 'run-legacy-route', event_seq: 1, event_kind: 'invocation_opened' }, + { run_id: 'run-legacy-route', event_seq: 2, event_kind: 'completed' }, + { run_id: 'run-scheduled', event_seq: 1, event_kind: 'invocation_opened' }, + { run_id: 'run-scheduled', event_seq: 2, event_kind: 'completed' }, + ], + ); const ordinals = db .prepare('SELECT COUNT(*) AS total FROM runtime_session_event_ordinals') .get() as { total: number }; assert.equal( ordinals.total, - rows.length, + backfilled.length, 'every backfilled event joins the Session ordinal stream', ); @@ -242,11 +265,6 @@ describe('invocation opening fact backfill', () => { const one = await store.readInvocation('session-1', 'run-scheduled'); assert.equal(one.turnId, 'turn-scheduled'); assert.deepEqual(one.opening.root, { kind: 'scheduled_task', scheduledTaskId: 'task-9' }); - await assert.rejects( - () => store.readInvocation('session-1', 'run-corrupt-root'), - /Runtime invocation not found/, - 'a header the backfill refused to project has no invocation to read', - ); await assert.rejects( () => store.listSessionInvocationsPage('session-1', { limit: 0 }), @@ -265,8 +283,139 @@ describe('invocation opening fact backfill', () => { } }); }); + + // Built from what the header era actually wrote, not from what the decoder + // accepts: every run that reached a provider carried the composition snapshot. + test('migrates a header exactly as the header era wrote it, composition included', async () => { + await withHeaderOnlyRuns(async (databasePath) => { + const db = new DatabaseSync(databasePath); + try { + const insert = db.prepare( + 'INSERT INTO core_agent_runs(session_id, run_id, created_at, record_json) VALUES (?, ?, ?, ?)', + ); + for (const record of [ + header({ + runId: 'run-composed', + turnId: 'turn-composed', + status: 'failed', + failureClass: 'provider_error', + failureMessage: 'the provider said no', + completedAt: 7, + runComposition: headerEraComposition(), + }), + header({ + runId: 'run-composed-events', + turnId: 'turn-composed-events', + runComposition: headerEraComposition(), + }), + ]) { + insert.run(record.sessionId, record.runId, record.createdAt, JSON.stringify(record)); + } + const { json } = encodeCanonicalRuntimeEvent({ + id: 'composed-1', + invocationId: 'run-composed-events', + runId: 'run-composed-events', + sessionId: 'session-1', + turnId: 'turn-composed-events', + ts: 1, + partial: false, + role: 'user', + author: 'user', + modelVisibility: 'visible', + content: { kind: 'text', text: 'already immutable' }, + }); + db.prepare(` + INSERT INTO runtime_events ( + event_id, session_id, invocation_id, run_id, turn_id, event_seq, + event_kind, payload_json, committed_at + ) VALUES ('composed-1', 'session-1', 'run-composed-events', 'run-composed-events', + 'turn-composed-events', 1, 'text', ?, 1) + `).run(json); + migrateSqliteRuntimeDatabase(db); + migrateSqliteCoreExecutionDatabase(db); + } finally { + db.close(); + } + + const store = createSqliteRuntimeStore(databasePath); + try { + const invocations = await store.listSessionInvocations('session-1'); + assert.deepEqual( + invocations.map((invocation) => invocation.invocationId).sort(), + [ + 'run-composed', + 'run-composed-events', + 'run-legacy-route', + 'run-scheduled', + 'run-with-events', + ], + 'a run whose header carried a composition snapshot is still a run', + ); + const composed = await store.readInvocation('session-1', 'run-composed'); + assert.equal(composed.terminalEvent?.status, 'failed'); + assert.equal(composed.terminalEvent?.ts, 7); + assert.equal(composed.terminalEvent?.actions?.stateDelta?.failureClass, 'provider_error'); + assert.equal( + composed.terminalEvent?.content?.kind === 'error' + ? composed.terminalEvent.content.message + : undefined, + 'the provider said no', + ); + } finally { + store.close(); + } + }); + }); + + test('refuses to migrate a header it cannot read, and drops nothing', async () => { + await withHeaderOnlyRuns(async (databasePath) => { + const db = new DatabaseSync(databasePath); + try { + // A graph wake with no delivery attempt is corruption. Inventing a root + // authority for it would be worse than refusing, and dropping the header + // would be worse still: the migration stops, and the database stays as + // the header era left it. + const corrupt = header({ + runId: 'run-corrupt-root', + turnId: 'turn-corrupt', + agentGraphWakeId: 'wake-1', + }); + db.prepare( + 'INSERT INTO core_agent_runs(session_id, run_id, created_at, record_json) VALUES (?, ?, ?, ?)', + ).run(corrupt.sessionId, corrupt.runId, corrupt.createdAt, JSON.stringify(corrupt)); + assert.throws(() => migrateSqliteRuntimeDatabase(db), /session-1\/run-corrupt-root/); + assert.equal(readUserVersion(db), SQLITE_RUNTIME_SCHEMA_VERSION - 1); + const openings = db + .prepare( + "SELECT COUNT(*) AS total FROM runtime_events WHERE event_kind = 'invocation_opened'", + ) + .get() as { total: number }; + assert.equal(openings.total, 0, 'the transaction rolled every other run back too'); + const headers = db + .prepare('SELECT COUNT(*) AS total FROM core_agent_runs WHERE record_json IS NOT NULL') + .get() as { total: number }; + assert.equal(headers.total, 4, 'every header is still there to be read by a fixed build'); + } finally { + db.close(); + } + }); + }); }); +function headerEraComposition() { + return createRunCompositionSnapshot({ + composerId: 'maka.default', + composerRevision: '1', + sourceRevisions: [{ id: 'system-prompt', revision: '1' }], + baseSystemPromptHash: `sha256:${'a'.repeat(64)}`, + toolCatalogHash: `sha256:${'b'.repeat(64)}`, + toolAvailabilityHash: `sha256:${'c'.repeat(64)}`, + baseProviderOptionsHash: `sha256:${'d'.repeat(64)}`, + toolNames: ['read_file'], + contextWindow: 200_000, + }); +} + /** * Put the database back the way the header era left it: runtime schema one step * behind, no opening facts, and a `core_agent_runs` row that still carries the @@ -274,6 +423,7 @@ describe('invocation opening fact backfill', () => { */ function rewindToHeaderEra(db: DatabaseSync): void { db.exec('DROP INDEX IF EXISTS runtime_events_by_session_kind'); + db.exec('DROP INDEX IF EXISTS runtime_events_one_opening_per_invocation'); db.exec('DROP INDEX IF EXISTS runtime_legacy_invocation_openings_by_session'); db.exec('DROP TABLE IF EXISTS runtime_legacy_invocation_openings'); db.exec("DELETE FROM runtime_events WHERE event_kind = 'invocation_opened'"); @@ -308,9 +458,6 @@ async function withHeaderOnlyRuns(run: (databasePath: string) => Promise): llmConnectionId: 'connection-1', scheduledTaskId: 'task-9', }), - // A graph wake with no delivery attempt is corruption; the backfill must - // skip it rather than invent a root authority for it. - header({ runId: 'run-corrupt-root', turnId: 'turn-corrupt', agentGraphWakeId: 'wake-1' }), header({ runId: 'run-with-events', turnId: 'turn-with-events' }), ]) { insert.run(record.sessionId, record.runId, record.createdAt, JSON.stringify(record)); diff --git a/packages/storage/src/__tests__/recovery-persistence-authority.test.ts b/packages/storage/src/__tests__/recovery-persistence-authority.test.ts index ace261161b..1f97d01030 100644 --- a/packages/storage/src/__tests__/recovery-persistence-authority.test.ts +++ b/packages/storage/src/__tests__/recovery-persistence-authority.test.ts @@ -108,7 +108,7 @@ describe('SQLite recovery persistence authority', () => { dispatch.ts, ); db.exec( - 'DROP INDEX runtime_events_by_session_kind; DROP TABLE runtime_legacy_invocation_openings; DROP TABLE runtime_session_event_ordinals; DROP TABLE runtime_partial_segments; DROP TABLE runtime_storage_root_binding; DROP TABLE runtime_managed_mutation_reservations; DROP TABLE runtime_workspace_heads; DROP TABLE runtime_workspace_versions; DROP TABLE runtime_workspace_epochs; DROP TABLE runtime_continuation_claims; DROP TABLE runtime_capabilities; PRAGMA user_version = 4;', + 'DROP INDEX runtime_events_by_session_kind; DROP INDEX runtime_events_one_opening_per_invocation; DROP TABLE runtime_legacy_invocation_openings; DROP TABLE runtime_session_event_ordinals; DROP TABLE runtime_partial_segments; DROP TABLE runtime_storage_root_binding; DROP TABLE runtime_managed_mutation_reservations; DROP TABLE runtime_workspace_heads; DROP TABLE runtime_workspace_versions; DROP TABLE runtime_workspace_epochs; DROP TABLE runtime_continuation_claims; DROP TABLE runtime_capabilities; PRAGMA user_version = 4;', ); db.close(); @@ -204,7 +204,7 @@ describe('SQLite recovery persistence authority', () => { 2, ); db.exec( - 'DROP INDEX runtime_events_by_session_kind; DROP TABLE runtime_legacy_invocation_openings; DROP TABLE runtime_session_event_ordinals; DROP TABLE runtime_partial_segments; DROP TABLE runtime_storage_root_binding; DROP TABLE runtime_managed_mutation_reservations; DROP TABLE runtime_workspace_heads; DROP TABLE runtime_workspace_versions; DROP TABLE runtime_workspace_epochs; DROP TABLE runtime_continuation_claims; DROP TABLE runtime_capabilities; PRAGMA user_version = 4;', + 'DROP INDEX runtime_events_by_session_kind; DROP INDEX runtime_events_one_opening_per_invocation; DROP TABLE runtime_legacy_invocation_openings; DROP TABLE runtime_session_event_ordinals; DROP TABLE runtime_partial_segments; DROP TABLE runtime_storage_root_binding; DROP TABLE runtime_managed_mutation_reservations; DROP TABLE runtime_workspace_heads; DROP TABLE runtime_workspace_versions; DROP TABLE runtime_workspace_epochs; DROP TABLE runtime_continuation_claims; DROP TABLE runtime_capabilities; PRAGMA user_version = 4;', ); db.close(); diff --git a/packages/storage/src/__tests__/sqlite-recovery-concurrency.test.ts b/packages/storage/src/__tests__/sqlite-recovery-concurrency.test.ts index 9858ccb225..874ab86290 100644 --- a/packages/storage/src/__tests__/sqlite-recovery-concurrency.test.ts +++ b/packages/storage/src/__tests__/sqlite-recovery-concurrency.test.ts @@ -341,6 +341,7 @@ describe('SQLite recovery authority multi-process races', () => { db.exec(` DROP TABLE runtime_managed_mutation_reservations; DROP INDEX runtime_events_by_session_kind; + DROP INDEX runtime_events_one_opening_per_invocation; DROP TABLE runtime_legacy_invocation_openings; DROP TABLE runtime_session_event_ordinals; PRAGMA user_version = 10; diff --git a/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts b/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts index ef51faafa9..e0ee5b154d 100644 --- a/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts +++ b/packages/storage/src/__tests__/workspace-version-authority-persistence.test.ts @@ -1347,6 +1347,7 @@ function recreateWorkspaceTablesAsSchema12(database: DatabaseSync): void { DROP TABLE runtime_workspace_versions_schema_13; DROP TABLE runtime_managed_mutation_reservations; DROP INDEX runtime_events_by_session_kind; + DROP INDEX runtime_events_one_opening_per_invocation; DROP TABLE runtime_legacy_invocation_openings; PRAGMA user_version = 12; COMMIT; diff --git a/packages/storage/src/agent-run-store.ts b/packages/storage/src/agent-run-store.ts index af39a40fcd..615ddaae2e 100644 --- a/packages/storage/src/agent-run-store.ts +++ b/packages/storage/src/agent-run-store.ts @@ -833,18 +833,6 @@ function readSqliteRunAnchor(db: DatabaseSync, sessionId: string, runId: string) ), }; } - const first = db - .prepare(` - SELECT turn_id, committed_at - FROM runtime_events - WHERE session_id = ? AND run_id = ? - ORDER BY event_seq ASC - LIMIT 1 - `) - .get(sessionId, runId) as { turn_id: string; committed_at: number } | undefined; - if (first) { - return { turnId: first.turn_id, openedAt: first.committed_at, sessionInline: false }; - } const error = new Error(`Agent run does not exist: ${runId}`) as NodeJS.ErrnoException; error.code = 'ENOENT'; throw error; diff --git a/packages/storage/src/legacy-run-header.ts b/packages/storage/src/legacy-run-header.ts index d69a0965be..7136b7df95 100644 --- a/packages/storage/src/legacy-run-header.ts +++ b/packages/storage/src/legacy-run-header.ts @@ -129,6 +129,13 @@ export interface LegacyRunHeader { failureMessage?: string; abortSource?: string; traceWriteError?: string; + /** + * The provider-dispatch snapshot the header era attached to every run that + * reached a provider. Nothing on the spine reads it back, so the migration + * only has to know it is there: a header carrying it is a well-formed + * header, not a corrupt one. + */ + runComposition?: object; } const LEGACY_RUN_HEADER_SHAPE = defineObjectShape()( @@ -177,6 +184,7 @@ const LEGACY_RUN_HEADER_SHAPE = defineObjectShape()( 'orchestrationSource', 'agentSwarmAuthorization', 'toolMode', + 'runComposition', ], ); @@ -283,6 +291,7 @@ function decodeLegacyRunHeader(value: unknown): LegacyRunHeader { value.abortSource, value.traceWriteError, ].every(isOptionalString) && + (value.runComposition === undefined || isRecord(value.runComposition)) && (value.continuationSource === undefined || isLegacyContinuationSource(value.continuationSource)); if (!valid) throw new Error('Invalid AgentRun header schema'); diff --git a/packages/storage/src/sqlite-runtime-schema.ts b/packages/storage/src/sqlite-runtime-schema.ts index 0ad7591856..7ecdfd3a52 100644 --- a/packages/storage/src/sqlite-runtime-schema.ts +++ b/packages/storage/src/sqlite-runtime-schema.ts @@ -25,7 +25,10 @@ import { } from './legacy-run-header.js'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { encodeCanonicalRuntimeEvent } from '@maka/core/canonical-runtime-event'; -import { buildInvocationOpenedEvent } from '@maka/core/runtime-invocation'; +import { + buildInvocationOpenedEvent, + buildSyntheticTerminalRuntimeEvent, +} from '@maka/core/runtime-invocation'; export const SQLITE_RUNTIME_SCHEMA_VERSION = 16; export const RUNTIME_RECOVERY_AUTHORITY_CAPABILITY = 'runtime_recovery_authority'; @@ -498,6 +501,10 @@ const MIGRATIONS: ReadonlyMap = new Map([ CREATE INDEX runtime_events_by_session_kind ON runtime_events(session_id, event_kind, invocation_id); + CREATE UNIQUE INDEX runtime_events_one_opening_per_invocation + ON runtime_events(invocation_id) + WHERE event_kind = 'invocation_opened'; + CREATE TABLE runtime_legacy_invocation_openings ( invocation_id TEXT PRIMARY KEY, session_id TEXT NOT NULL, @@ -608,7 +615,7 @@ function backfillInvocationOpeningFacts(db: DatabaseSync): void { INSERT INTO runtime_events ( event_id, session_id, invocation_id, run_id, turn_id, event_seq, event_kind, payload_json, committed_at - ) VALUES (?, ?, ?, ?, ?, 1, 'invocation_opened', ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `); const insertOrdinal = db.prepare(` INSERT INTO runtime_session_event_ordinals(session_id, ordinal, event_id) @@ -620,14 +627,25 @@ function backfillInvocationOpeningFacts(db: DatabaseSync): void { invocation_id, session_id, run_id, turn_id, opened_at, opening_json ) VALUES (?, ?, ?, ?, ?, ?) `); + // The header column is dropped right after this, so a header this cannot read + // is a run that would silently cease to exist. Refusing the whole migration + // keeps the database as it was, and the failure names the row instead of + // hiding it. + const unreadable = (row: { session_id: string; run_id: string }, cause: unknown): Error => + new Error( + `Cannot migrate the AgentRun header of ${row.session_id}/${row.run_id}: ${ + cause instanceof Error ? cause.message : String(cause) + }`, + { cause }, + ); for (const row of rows) { let header: LegacyRunHeader; let opening: string; try { header = decodePersistedLegacyRunHeader(JSON.parse(row.record_json)); opening = JSON.stringify(invocationOpeningFromLegacyRunHeader(header)); - } catch { - continue; + } catch (error) { + throw unreadable(row, error); } if (row.existing_invocation_id !== null) { // The invocation id its own events already carry is the one every reader @@ -643,38 +661,74 @@ function backfillInvocationOpeningFacts(db: DatabaseSync): void { ); continue; } - let encoded: { event: RuntimeEvent; json: string }; - try { - encoded = encodeCanonicalRuntimeEvent( - buildInvocationOpenedEvent({ - id: `invocation_opened:${header.runId}`, - run: { - sessionId: header.sessionId, - invocationId: header.invocationId ?? header.runId, - runId: header.runId, - turnId: header.turnId, - }, - openedAt: header.createdAt, - opening: invocationOpeningFromLegacyRunHeader(header), - }), + // A run with no events of its own gets the facts its header held, where + // facts live now: the opening, and the ending if the header recorded one. + // A header still marked in flight stays open; recovery settles it the way it + // settles any run the process died holding. + const run = { + sessionId: header.sessionId, + invocationId: header.invocationId ?? header.runId, + runId: header.runId, + turnId: header.turnId, + }; + const events = [ + buildInvocationOpenedEvent({ + id: `invocation_opened:${header.runId}`, + run, + openedAt: header.createdAt, + opening: invocationOpeningFromLegacyRunHeader(header), + }), + ...(header.status === 'completed' || + header.status === 'failed' || + header.status === 'cancelled' + ? [ + buildSyntheticTerminalRuntimeEvent({ + id: `invocation_terminal:${header.runId}`, + invocationId: run.invocationId, + run, + status: header.status, + ts: header.completedAt ?? header.updatedAt, + ...(header.failureClass !== undefined ? { failureClass: header.failureClass } : {}), + ...(header.failureMessage !== undefined ? { message: header.failureMessage } : {}), + ...(header.abortSource !== undefined ? { abortSource: header.abortSource } : {}), + }), + ] + : []), + ]; + events.forEach((event, index) => { + let encoded: { event: RuntimeEvent; json: string }; + try { + encoded = encodeCanonicalRuntimeEvent(event); + } catch (error) { + throw unreadable(row, error); + } + insertEvent.run( + event.id, + event.sessionId, + event.invocationId, + event.runId, + event.turnId, + index + 1, + runtimeEventKind(event), + encoded.json, + event.ts, ); - } catch { - continue; - } - const event = encoded.event; - insertEvent.run( - event.id, - event.sessionId, - event.invocationId, - event.runId, - event.turnId, - encoded.json, - event.ts, - ); - insertOrdinal.run(event.sessionId, event.id, event.sessionId); + insertOrdinal.run(event.sessionId, event.id, event.sessionId); + }); } } +/** The `event_kind` column: the one coarse label every reader indexes events by. */ +export function runtimeEventKind(event: RuntimeEvent): string { + return ( + event.content?.kind ?? + event.status ?? + (event.actions?.workspaceFact ? 'workspace_fact' : undefined) ?? + (event.actions?.toolDispatch ? 'tool_dispatch' : undefined) ?? + (event.actions?.endInvocation ? 'invocation_end' : 'runtime_fact') + ); +} + function hasColumn(db: DatabaseSync, table: string, column: string): boolean { const columns = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name?: unknown }>; return columns.some((candidate) => candidate.name === column); diff --git a/packages/storage/src/sqlite-runtime-store.ts b/packages/storage/src/sqlite-runtime-store.ts index 1a5fe861be..cffeb0c289 100644 --- a/packages/storage/src/sqlite-runtime-store.ts +++ b/packages/storage/src/sqlite-runtime-store.ts @@ -103,6 +103,7 @@ import { RUNTIME_CONTINUATION_AUTHORITY_CAPABILITY_VERSION, RUNTIME_WORKSPACE_VERSION_AUTHORITY_CAPABILITY, RUNTIME_WORKSPACE_VERSION_AUTHORITY_CAPABILITY_VERSION, + runtimeEventKind, SQLITE_RUNTIME_SCHEMA_VERSION, } from './sqlite-runtime-schema.js'; import { @@ -4642,16 +4643,6 @@ function decodeStoredRuntimeEvent(storedJson: string): RuntimeEvent { return decodeRuntimeEvent(JSON.parse(storedJson)); } -function runtimeEventKind(event: RuntimeEvent): string { - return ( - event.content?.kind ?? - event.status ?? - (event.actions?.workspaceFact ? 'workspace_fact' : undefined) ?? - (event.actions?.toolDispatch ? 'tool_dispatch' : undefined) ?? - (event.actions?.endInvocation ? 'invocation_end' : 'runtime_fact') - ); -} - interface RuntimePartialSnapshot { event: RuntimeEvent; afterEventId?: string; From 5fb88285d8357458317cb64dc41f2cabff00389c Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 4 Sep 2026 02:35:34 +0800 Subject: [PATCH 37/46] fix(runtime): give a copied run its shelved opening back The migration shelves the opening of any run that already owned events, since that run's sequence is immutable. A conversation copy cloned only the events, so the target Session had runs with no opening at all: `listSessionInvocations` returned nothing, and the branched Session's first send saw no history. The base cloned the header row explicitly, so this was a regression of the spine. The copy is a fresh sequence, so the run's opening can be event 1 there. `loadConversationCopyRunEvents` synthesizes it from the shelved record when the source events lack one, and the ledger copy inserts it ahead of the run's first source event. The regression test stages the shelved state the way the migration leaves it. Generated-by: Claude Code --- .../src/__tests__/conversation-copy.test.ts | 100 ++++++++++++++++++ packages/runtime/src/conversation-copy.ts | 41 ++++++- 2 files changed, 138 insertions(+), 3 deletions(-) diff --git a/packages/runtime/src/__tests__/conversation-copy.test.ts b/packages/runtime/src/__tests__/conversation-copy.test.ts index ceab0c7fea..e150c6147d 100644 --- a/packages/runtime/src/__tests__/conversation-copy.test.ts +++ b/packages/runtime/src/__tests__/conversation-copy.test.ts @@ -21,6 +21,7 @@ import assert from 'node:assert/strict'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; import { test } from 'node:test'; import type { AgentRunStore, EmittedAgentRunEvent } from '@maka/core/agent-run'; import type { RuntimeEvent, RuntimeEventInvocationOpenedContent } from '@maka/core/runtime-event'; @@ -42,6 +43,7 @@ import { import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; import { createWorkspaceRuntimeStore } from '@maka/storage/runtime-event-persistence'; +import { OPERATIONAL_STATE_DATABASE_NAME } from '@maka/storage/operational-state-store'; import { createSqliteRuntimeStore } from '@maka/storage/sqlite-runtime-store'; import { archivedToolResultContainsLinkedChildReferences, @@ -3194,6 +3196,104 @@ test('conversation copy reproduces the source fold rather than re-deciding it', } }); +test('conversation copy gives a run whose opening the migration shelved its opening back', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-conversation-shelved-copy-')); + try { + const runStore = createSqliteAgentRunStore(root); + const runtimeEventStore = createWorkspaceRuntimeStore(root); + await seedRun( + runtimeEventStore, + runFacts({ runId: 'run-source', invocationId: 'run-source', turnId: 'turn-1', cwd: root }), + ); + const sourceEvents: RuntimeEvent[] = [ + runtimeEvent({ + id: 'event-user', + invocationId: 'run-source', + role: 'user', + author: 'user', + content: { kind: 'text', text: 'hello' }, + }), + runtimeEvent({ + id: 'event-terminal', + invocationId: 'run-source', + ts: 3, + status: 'completed', + }), + ]; + for (const event of sourceEvents) { + await runtimeEventStore.appendRuntimeEvent('session-source', 'run-source', event); + } + // Leave the run the way the migration leaves one that already owned an + // immutable sequence: its opening on the legacy shelf, not among its events. + const db = new DatabaseSync(join(root, OPERATIONAL_STATE_DATABASE_NAME)); + try { + const opening = db + .prepare( + "SELECT event_id, payload_json, committed_at FROM runtime_events WHERE run_id = 'run-source' AND event_kind = 'invocation_opened'", + ) + .get() as { event_id: string; payload_json: string; committed_at: number }; + db.prepare('DELETE FROM runtime_session_event_ordinals WHERE event_id = ?').run( + opening.event_id, + ); + db.prepare('DELETE FROM runtime_events WHERE event_id = ?').run(opening.event_id); + db.prepare(` + INSERT INTO runtime_legacy_invocation_openings ( + invocation_id, session_id, run_id, turn_id, opened_at, opening_json + ) VALUES ('run-source', 'session-source', 'run-source', 'turn-1', ?, ?) + `).run( + opening.committed_at, + JSON.stringify((JSON.parse(opening.payload_json) as { content: unknown }).content), + ); + } finally { + db.close(); + } + const [sourceRun] = await runtimeEventStore.listSessionInvocations('session-source'); + assert.equal(sourceRun?.runId, 'run-source', 'the shelved opening still names the run'); + assert.equal( + (await runtimeEventStore.readRuntimeEvents('session-source', 'run-source')).some( + (event) => event.content?.kind === 'invocation_opened', + ), + false, + 'but its events do not carry it', + ); + + const source = await new RuntimeReadModel({ runtimeEventStore }).getSessionView( + 'session-source', + ); + const copied = await cloneConversationRuntimeLedger({ + plan: await prepareTestCopyPlan(source, source.messages, runStore, runtimeEventStore), + copiedMessages: source.messages, + referenceMap: { + mode: 'exact', + linkedChildren: { mode: 'reject' }, + sourceSessionId: 'session-source', + targetSessionId: 'session-target', + artifactIds: new Map(), + relativePaths: new Map(), + }, + runStore, + runtimeEventStore, + newId: () => crypto.randomUUID(), + }); + + const [targetRun] = await runtimeEventStore.listSessionInvocations('session-target'); + assert.equal(targetRun?.runId, copied.runIdMap[0]?.targetRunId); + assert.equal(targetRun?.terminalEvent?.status, 'completed'); + assert.deepEqual(targetRun?.opening.configuration.cwd, root); + const targetEvents = await runtimeEventStore.readRuntimeEvents( + 'session-target', + targetRun!.runId, + ); + assert.deepEqual( + targetEvents.map((event) => event.content?.kind ?? event.status), + ['invocation_opened', 'text', 'completed'], + 'the copy is a fresh sequence, so the opening is its first event', + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + function prepareTestCopyPlan( source: RuntimeReadModelSessionView, copiedMessages: readonly StoredMessage[], diff --git a/packages/runtime/src/conversation-copy.ts b/packages/runtime/src/conversation-copy.ts index c0644f6265..fc158e67a8 100644 --- a/packages/runtime/src/conversation-copy.ts +++ b/packages/runtime/src/conversation-copy.ts @@ -19,7 +19,10 @@ import type { AgentRunEvent, AgentRunStore, EmittedAgentRunEvent } from '@maka/core/agent-run'; import type { RuntimeEvent, RuntimeEventInvocationOpenedContent } from '@maka/core/runtime-event'; -import { isSessionInlineInvocation } from '@maka/core/runtime-invocation'; +import { + buildInvocationOpenedEvent, + isSessionInlineInvocation, +} from '@maka/core/runtime-invocation'; import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; import { type StorageRef, type ToolResultContent } from '@maka/core/events'; @@ -346,10 +349,23 @@ export async function prepareConversationRuntimeLedgerCopy(input: { }), ); await rebuildCopiedProjectionTransitions(input.sourceSessionId, sourceRuns, runs, input.runStore); + // A restored opening takes the place the migration could not give it: right + // before the first event of its run in the Session's order. + const restoredOpenings = new Map( + selectedRunEvents.flatMap(({ run, restoredOpening }) => + restoredOpening ? [[run.runId, restoredOpening] as const] : [], + ), + ); + const inlineRuntimeEvents = input.sourceEvents.flatMap((event) => { + const opening = restoredOpenings.get(event.runId); + if (!opening) return [event]; + restoredOpenings.delete(event.runId); + return [opening, event]; + }); const plan = { sourceSessionId: input.sourceSessionId, copyTurnIds, - inlineRuntimeEvents: [...input.sourceEvents], + inlineRuntimeEvents, runs, }; assertConversationRuntimeLedgerCopySupported(plan); @@ -646,7 +662,15 @@ export async function cloneConversationRuntimeLedger( interface ConversationCopyRunEvents { readonly run: RuntimeInvocationRecord; + /** The run's events, beginning with its opening. */ readonly events: readonly RuntimeEvent[]; + /** + * The opening as an event, when the run's own events did not carry one: + * the migration shelved openings of runs that already owned an immutable + * sequence, and a copy is where such a run gets its opening back as event + * one, because the copy is a fresh sequence. + */ + readonly restoredOpening?: RuntimeEvent; } async function loadConversationCopyRunEvents( @@ -667,7 +691,18 @@ async function loadConversationCopyRunEvents( projectedEvents.length > 0 ? projectedEvents : runtimeEventStore.readRuntimeEvents(run.sessionId, run.runId), - ).then((events) => ({ run, events })), + ).then((events) => { + if (events.some((event) => event.content?.kind === 'invocation_opened')) { + return { run, events }; + } + const restoredOpening = buildInvocationOpenedEvent({ + id: `invocation_opened:${run.runId}`, + run, + openedAt: run.openedAt, + opening: run.opening, + }); + return { run, events: [restoredOpening, ...events], restoredOpening }; + }), ]; }), ); From 41e4b2d3606bc8e805e0620a107d3e8e7383fa67 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 4 Sep 2026 02:35:34 +0800 Subject: [PATCH 38/46] refactor(core): state an invocation's ending once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebuild counted terminal events per invocation and, on two, declared the run ambiguous; two readers then branched on that ambiguity. But the store seals a run on its terminal event — `assertRunNotSealed` runs on every insert, in-process and across processes — so a second terminal cannot be written, and the only way to reach the branch was a test double arranging a state the index refuses. The rule also contradicted the index by construction. The rebuild now states the same rule as the index: an invocation's terminal event is its one terminal event, wherever a Session-ordered read places it relative to other invocations. The two reader branches and the test that arranged the unrepresentable state go with it. `acceptedInputBoundary` gains one sentence: an opening that could not prove its route never matches, even when the current run has none either. That is the one place the spine is stricter than the header comparison was, and it should be said where it is decided. Generated-by: Claude Code --- packages/core/src/runtime-invocation.ts | 13 +--- .../session-manager-terminal-ledger.test.ts | 75 ------------------- packages/runtime/src/agent-run-inspect.ts | 5 +- packages/runtime/src/history-compaction.ts | 4 +- packages/runtime/src/runtime-read-model.ts | 6 +- 5 files changed, 9 insertions(+), 94 deletions(-) diff --git a/packages/core/src/runtime-invocation.ts b/packages/core/src/runtime-invocation.ts index 23404abbc4..aa2329bcb8 100644 --- a/packages/core/src/runtime-invocation.ts +++ b/packages/core/src/runtime-invocation.ts @@ -71,19 +71,14 @@ export function runtimeInvocationsFromSessionEvents( }); } } - // A Run ends exactly once. Two terminal events are two statements that it - // ended, which is no statement at all: leave the invocation open so the - // ambiguity reaches a reader that can repair it instead of being hidden by - // whichever event happened to come last. - const terminalCounts = new Map(); + // A run is sealed by its terminal event — the store refuses anything after + // it — so an invocation has at most one, and a Session-ordered read may place + // it anywhere relative to other invocations' events. for (const event of events) { if (event.sessionId !== sessionId || event.partial === true) continue; if (!isTerminalRuntimeEvent(event)) continue; const record = byInvocation.get(event.invocationId); - if (!record) continue; - const seen = (terminalCounts.get(event.invocationId) ?? 0) + 1; - terminalCounts.set(event.invocationId, seen); - record.terminalEvent = seen === 1 ? event : undefined; + if (record && !record.terminalEvent) record.terminalEvent = event; } return [...byInvocation.values()].sort( (a, b) => a.openedAt - b.openedAt || a.invocationId.localeCompare(b.invocationId), diff --git a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts index 1ba5f502e9..c5b6710e36 100644 --- a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts +++ b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts @@ -1998,81 +1998,6 @@ describe('SessionManager terminal ledger invariants', () => { /valid terminal fact/, ); }); - - test('startup recovery does not append another terminal RuntimeEvent when the ledger is ambiguous', async () => { - const store = new TinySessionStore(); - const runStore = new TinyAgentRunStore(); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends: new BackendRegistry(), - newId: nextId(), - now: nextNow(70_000), - }); - const session = await store.create(makeInput({ status: 'active' })); - const run = await seedOpening( - runStore, - makeRunIdentity({ - sessionId: session.id, - runId: 'run-ambiguous-terminal', - turnId: 'turn-ambiguous-terminal', - }), - ); - await runStore.appendEvent(session.id, run.runId, { - type: 'turn_started', - id: 'run-started', - sessionId: session.id, - runId: run.runId, - turnId: run.turnId, - ts: 2, - }); - await runStore.appendRuntimeEvent( - session.id, - run.runId, - runtimeEvent({ - id: 'rt-completed', - sessionId: session.id, - runId: run.runId, - turnId: run.turnId, - status: 'completed', - actions: { endInvocation: true }, - }), - ); - await runStore.appendRuntimeEvent( - session.id, - run.runId, - runtimeEvent({ - id: 'rt-failed', - sessionId: session.id, - runId: run.runId, - turnId: run.turnId, - status: 'failed', - content: { - kind: 'error', - code: 'tool_failed', - reason: 'tool_failed', - message: 'Tool failed', - }, - actions: { - endInvocation: true, - stateDelta: { failureClass: 'tool_failed' }, - }, - }), - ); - - const recovered = await manager.recoverInterruptedSessions(); - - assert.deepStrictEqual(recovered, []); - assert.strictEqual(await runOutcome(runStore, session.id, run.runId), undefined); - const terminalEvents = (await runStore.readRuntimeEvents(session.id, run.runId)).filter( - isTerminalRuntimeEvent, - ); - assert.deepStrictEqual( - terminalEvents.map((event) => event.id), - ['rt-completed', 'rt-failed'], - ); - }); }); type ScriptEvent = diff --git a/packages/runtime/src/agent-run-inspect.ts b/packages/runtime/src/agent-run-inspect.ts index ebde00b2d0..b53cef5443 100644 --- a/packages/runtime/src/agent-run-inspect.ts +++ b/packages/runtime/src/agent-run-inspect.ts @@ -111,10 +111,7 @@ export async function inspectAgentRunReadModel( const runtimeEvents = runtimeRead.events; let terminalRuntimeFact: RuntimeEventTerminalFact | undefined; - // Classified off the events, not off the record's terminal event: an - // invocation the inventory leaves open because its ledger states two endings - // must still reach a reader as ambiguous rather than as merely unfinished. - if (runtimeRead.state === 'present') { + if (runtimeRead.state === 'present' && invocation.terminalEvent) { const terminalFactResult = classifyRuntimeEventTerminalFact(invocation, runtimeEvents); terminalRuntimeFact = terminalFactResult.fact; diagnostics.push( diff --git a/packages/runtime/src/history-compaction.ts b/packages/runtime/src/history-compaction.ts index afccbada2b..de33cc1280 100644 --- a/packages/runtime/src/history-compaction.ts +++ b/packages/runtime/src/history-compaction.ts @@ -232,7 +232,9 @@ export type HistoryCompactionFailReason = 'no_safe_completed_span' | 'summarizer * ends the span, found through each run's opening rather than by role alone — * everything before its first event was in a request that route accepted. * A ledger with no reply from this route has nothing proven, and the caller - * must not invent a boundary. + * must not invent a boundary. Nor does a run whose opening could not prove its + * route — a migrated header with no Connection — even when the current run has + * no Connection of its own: two unknowns are not a match. */ function acceptedInputBoundary( events: readonly RuntimeEvent[], diff --git a/packages/runtime/src/runtime-read-model.ts b/packages/runtime/src/runtime-read-model.ts index 2999f3326d..e103ae0dd5 100644 --- a/packages/runtime/src/runtime-read-model.ts +++ b/packages/runtime/src/runtime-read-model.ts @@ -18,7 +18,6 @@ */ import type { RuntimeEvent } from '@maka/core/runtime-event'; -import { isTerminalRuntimeEvent } from '@maka/core/runtime-event'; import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { StoredMessage, TurnRecord } from '@maka/core/session'; @@ -136,10 +135,7 @@ export class RuntimeReadModel { // holding it. Either way the ledger is the whole truth about it, so the // in-flight projection cache supplies the rows a live turn has not // committed instead of a status field claiming otherwise. - // An invocation the inventory leaves open because its ledger states two - // endings is not an active run: it ended, twice, and that is a fact to - // reject rather than a turn to project from cache. - if (!invocation.terminalEvent && !runEvents.some(isTerminalRuntimeEvent)) { + if (!invocation.terminalEvent) { diagnostics.push( readModelDiagnostic( 'incomplete_event', From 2948c92a1b96e4ecbd829d460899a9a643a6f6d7 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 4 Sep 2026 02:35:34 +0800 Subject: [PATCH 39/46] perf(storage): read one invocation by run id `readRunIfPresent`, `readInvocationIfPresent` and `SessionManager.readInvocation` had become `listSessionInvocations()` followed by `find`, and a Turn calls them four to six times. The header era answered these with a keyed row read. `RuntimeEventStore` gains an optional `readRunInvocation(sessionId, runId)`; the SQLite store answers it off the opening index, and a helper in core answers it from the inventory for stores that do not implement it, so callers state one intent either way. Generated-by: Claude Code --- packages/core/src/runtime-event-store.ts | 21 +++++++++++++++++++ .../src/server/canonical-turn-snapshot.ts | 5 ++--- .../src/server/hosted-execution-projection.ts | 5 ++--- packages/runtime/src/session-manager.ts | 12 +++++------ packages/storage/src/agent-run-store.ts | 1 + packages/storage/src/execution-stores.ts | 5 +++++ .../storage/src/runtime-event-persistence.ts | 3 +++ packages/storage/src/sqlite-runtime-store.ts | 15 +++++++++++++ 8 files changed, 55 insertions(+), 12 deletions(-) diff --git a/packages/core/src/runtime-event-store.ts b/packages/core/src/runtime-event-store.ts index a599d521da..4a441d364e 100644 --- a/packages/core/src/runtime-event-store.ts +++ b/packages/core/src/runtime-event-store.ts @@ -82,6 +82,15 @@ export interface RuntimeEventStore { * fact and therefore never appear here. */ listSessionInvocations(sessionId: string): Promise; + /** + * One invocation by run id, absent when no opening fact names it. A store + * that indexes openings answers this in one read; stores without the fast + * path are answered from the inventory by `readRunInvocation`. + */ + readRunInvocation?( + sessionId: string, + runId: string, + ): Promise; appendRuntimeEvent( sessionId: string, runId: string, @@ -121,6 +130,18 @@ export interface RuntimeEventStore { readSessionRuntimeEvents(sessionId: string): Promise; } +/** One invocation by run id, through the store's fast path when it has one. */ +export async function readRunInvocation( + store: Pick, + sessionId: string, + runId: string, +): Promise { + if (store.readRunInvocation) return store.readRunInvocation(sessionId, runId); + return (await store.listSessionInvocations(sessionId)).find( + (invocation) => invocation.runId === runId, + ); +} + export interface RuntimeRecoveryBundleStore extends RuntimeEventStore { readonly recoveryBundleCapability: typeof TOOL_RECOVERY_BUNDLE_CAPABILITY_V1; commitToolRecoveryBundle(input: RuntimeRecoveryBundleCommit): Promise; diff --git a/packages/runtime-host/src/server/canonical-turn-snapshot.ts b/packages/runtime-host/src/server/canonical-turn-snapshot.ts index 36ef68eef0..1ba5b1968c 100644 --- a/packages/runtime-host/src/server/canonical-turn-snapshot.ts +++ b/packages/runtime-host/src/server/canonical-turn-snapshot.ts @@ -20,6 +20,7 @@ import { type ContextCompactionOutcome } from '@maka/core/events'; import { truncateUtf8 } from '@maka/core/diagnostic-log'; import { redactSecrets } from '@maka/core/redaction'; +import { readRunInvocation } from '@maka/core/runtime-event-store'; import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import { classifyTerminalRuntimeLedger } from '@maka/runtime/terminal-run-commit'; import type { ExecutionStoresWriter } from '@maka/storage/execution-stores'; @@ -152,9 +153,7 @@ async function readInvocationIfPresent( runId: string, ): Promise { try { - return (await stores.runtimeEventStore.listSessionInvocations(sessionId)).find( - (invocation) => invocation.runId === runId, - ); + return await readRunInvocation(stores.runtimeEventStore, sessionId, runId); } catch (error) { if (isMissingFile(error)) return undefined; throw error; diff --git a/packages/runtime-host/src/server/hosted-execution-projection.ts b/packages/runtime-host/src/server/hosted-execution-projection.ts index 0d9cf57c30..209760aac8 100644 --- a/packages/runtime-host/src/server/hosted-execution-projection.ts +++ b/packages/runtime-host/src/server/hosted-execution-projection.ts @@ -23,6 +23,7 @@ import { type RuntimeInvocationRecord, } from '@maka/core/runtime-invocation'; import { RuntimeMessageAuthorityInvariantError } from '@maka/runtime/message-authority'; +import { readRunInvocation } from '@maka/core/runtime-event-store'; import type { ExecutionStoresWriter } from '@maka/storage/execution-stores'; import { readCanonicalTurnSnapshot } from './canonical-turn-snapshot.js'; import type { HostedExecutionRef, HostedExecutionSnapshot } from './hosted-execution-authority.js'; @@ -48,9 +49,7 @@ export class HostedExecutionProjectionReader { runId: string, ): Promise { try { - return (await this.stores.runtimeEventStore.listSessionInvocations(sessionId)).find( - (invocation) => invocation.runId === runId, - ); + return await readRunInvocation(this.stores.runtimeEventStore, sessionId, runId); } catch (error) { if (isMissingFile(error)) return undefined; throw error; diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 5296d3e9ba..bc576f4cd9 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -122,9 +122,10 @@ import type { AgentRunEvent, AgentRunStore } from '@maka/core/agent-run'; import type { ArtifactRecord } from '@maka/core/artifacts'; import { invocationMatchesClaimTarget } from '@maka/core/runtime-boundary'; import type { ContinuationClaimV1 } from '@maka/core/runtime-boundary'; -import type { - RuntimeEventStore, - RuntimeContinuationAuthorityStore, +import { + readRunInvocation, + type RuntimeEventStore, + type RuntimeContinuationAuthorityStore, } from '@maka/core/runtime-event-store'; import type { RuntimeEvent, @@ -1057,9 +1058,8 @@ export class SessionManager { /** One invocation by run id. Absent means no opening fact ever named it. */ private async readInvocation(sessionId: string, runId: string): Promise { - const invocation = (await this.listInvocations(sessionId)).find( - (candidate) => candidate.runId === runId, - ); + const store = this.deps.runtimeEventStore; + const invocation = store ? await readRunInvocation(store, sessionId, runId) : undefined; if (!invocation) { const error = new Error(`AgentRun ${runId} not found`) as Error & { code?: string }; error.code = 'ENOENT'; diff --git a/packages/storage/src/agent-run-store.ts b/packages/storage/src/agent-run-store.ts index 615ddaae2e..dd855c9b3f 100644 --- a/packages/storage/src/agent-run-store.ts +++ b/packages/storage/src/agent-run-store.ts @@ -271,6 +271,7 @@ export type RuntimeEventScanResult = { readonly status: 'complete' | 'limit_exce export interface DurableRuntimeEventStore extends RuntimeEventStore { listSessionInvocations(sessionId: string): Promise; + readRunInvocation(sessionId: string, runId: string): Promise; listSessionInvocationsBounded( sessionId: string, limit: number, diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index 203b1dbc96..29e0679225 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -206,6 +206,7 @@ export interface ExecutionRuntimeEventReader { * repairs it. */ listSessionInvocations(sessionId: string): Promise; + readRunInvocation(sessionId: string, runId: string): Promise; listSessionInvocationsBounded( sessionId: string, limit: number, @@ -552,6 +553,8 @@ async function createExecutionStoresForWrite runtimeEventStore.readImmutableRuntimePrefix(input)), listSessionInvocations: (sessionId) => run(() => runtimeEventStore.listSessionInvocations(sessionId)), + readRunInvocation: (sessionId, runId) => + run(() => runtimeEventStore.readRunInvocation(sessionId, runId)), listSessionInvocationsBounded: (sessionId, limit) => run(() => runtimeEventStore.listSessionInvocationsBounded(sessionId, limit)), listSessionInvocationsPage: (sessionId, input) => @@ -665,6 +668,8 @@ async function openExecutionStoresForRead runtimeEventStore.readImmutableRuntimeEvents(sessionId, runId)), listSessionInvocations: (sessionId) => run(() => runtimeEventStore.listSessionInvocations(sessionId)), + readRunInvocation: (sessionId, runId) => + run(() => runtimeEventStore.readRunInvocation(sessionId, runId)), listSessionInvocationsBounded: (sessionId, limit) => run(() => runtimeEventStore.listSessionInvocationsBounded(sessionId, limit)), listSessionInvocationsPage: (sessionId, input) => diff --git a/packages/storage/src/runtime-event-persistence.ts b/packages/storage/src/runtime-event-persistence.ts index 036e21021f..893e734e50 100644 --- a/packages/storage/src/runtime-event-persistence.ts +++ b/packages/storage/src/runtime-event-persistence.ts @@ -47,6 +47,7 @@ export type RuntimeEventReadPersistence = { export interface RuntimeEventReadStore { listSessionInvocations(sessionId: string): Promise; + readRunInvocation(sessionId: string, runId: string): Promise; listSessionInvocationsBounded( sessionId: string, limit: number, @@ -96,6 +97,8 @@ export async function openRuntimeEventReadPersistence(input: { kind: 'sqlite', runtimeEventStore: Object.freeze({ listSessionInvocations: (sessionId: string) => store.listSessionInvocations(sessionId), + readRunInvocation: (sessionId: string, runId: string) => + store.readRunInvocation(sessionId, runId), listSessionInvocationsBounded: (sessionId: string, limit: number) => store.listSessionInvocationsBounded(sessionId, limit), listSessionInvocationsPage: (sessionId: string, input: RuntimeInvocationPageInput) => diff --git a/packages/storage/src/sqlite-runtime-store.ts b/packages/storage/src/sqlite-runtime-store.ts index cffeb0c289..ff94d37622 100644 --- a/packages/storage/src/sqlite-runtime-store.ts +++ b/packages/storage/src/sqlite-runtime-store.ts @@ -555,6 +555,18 @@ export class SqliteRuntimeStore ); } + async readRunInvocation( + sessionId: string, + runId: string, + ): Promise { + assertRuntimeStorageSafeId(sessionId, 'Invalid session id'); + assertRuntimeStorageSafeId(runId, 'Invalid run id'); + return this.readTransaction(() => { + const row = this.readInvocationOpeningsSync(sessionId, { direction: 'asc', runId }).at(0); + return row ? this.completeInvocationRecordSync(row) : undefined; + }); + } + /** * The first page of a Session's invocations, plus whether more exist. * @@ -646,6 +658,7 @@ export class SqliteRuntimeStore limit?: number; before?: RuntimeInvocationPageCursor; invocationId?: string; + runId?: string; }, ): Omit[] { const order = options.direction === 'desc' ? 'DESC' : 'ASC'; @@ -680,6 +693,7 @@ export class SqliteRuntimeStore ) ) WHERE (:invocationId IS NULL OR invocation_id = :invocationId) + AND (:runId IS NULL OR run_id = :runId) AND ( :beforeOpenedAt IS NULL OR opened_at < :beforeOpenedAt @@ -691,6 +705,7 @@ export class SqliteRuntimeStore .all({ sessionId, invocationId: options.invocationId ?? null, + runId: options.runId ?? null, beforeOpenedAt: options.before?.openedAt ?? null, beforeInvocationId: options.before?.invocationId ?? null, limit: options.limit ?? -1, From 74f0116dd47d54c66b2e113f1b917f4db30d1616 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 4 Sep 2026 02:35:35 +0800 Subject: [PATCH 40/46] test(runtime-host): cover SessionAdmissionGate.detach The behaviour change was listed nowhere and had no test: work detached from an admission must take admissions of its own, queued behind the active one like any other caller. Generated-by: Claude Code --- .../__tests__/session-admission-gate.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/runtime-host/src/__tests__/session-admission-gate.test.ts b/packages/runtime-host/src/__tests__/session-admission-gate.test.ts index 35044a0c1c..84d9e743ed 100644 --- a/packages/runtime-host/src/__tests__/session-admission-gate.test.ts +++ b/packages/runtime-host/src/__tests__/session-admission-gate.test.ts @@ -147,3 +147,28 @@ test('rejects accidental admission re-entry instead of deadlocking', async () => ); }); }); + +test('work detached from an admission takes admissions of its own', async () => { + const gate = new SessionAdmissionGate(); + const release = deferred(); + const order: string[] = []; + let detached!: Promise; + + // The detached work starts inside the admission and admits before the + // admission ends, which is the order a drained Turn reaches its first + // admission in. Inherited context would reject it as re-entry. + await gate.run('session', async () => { + order.push('active:start'); + detached = gate.detach(async () => { + await gate.run('session', () => { + order.push('detached:admitted'); + }); + }); + await Promise.resolve(); + order.push('active:end'); + release.resolve(); + }); + await release.promise; + await detached; + assert.deepEqual(order, ['active:start', 'active:end', 'detached:admitted']); +}); From 8e862a3a258c16ebbf4cc792ab57b885e7b5d4e2 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 4 Sep 2026 10:14:37 +0800 Subject: [PATCH 41/46] fix(storage): tie a shelved opening to the ledger it describes A run that already owned an immutable sequence could not be given an opening event at position 1, so the migration shelves its opening in runtime_legacy_invocation_openings. Readers surface a shelved opening only when its invocation has no opening event, which made deleting a Session's events turn the shelf back on: the inventory reported the run again, now with no terminal event, and a completed run came back as an active one. Startup recovery then wrote a fresh app_restarted terminal into a Session the user was deleting. Purge is not the owner of this rule. The shelved opening is a fact about a ledger that already exists, so it is anchored to that ledger's first event and cascades with it. Every path that removes a Session's events is now correct without having to remember the table, which is how runtime_session_event_ordinals already works. Migration 16 has not shipped, so the column goes into that migration rather than a new one. Generated-by: Claude Code --- .../src/__tests__/conversation-copy.test.ts | 11 ++- .../invocation-opening-backfill.test.ts | 91 ++++++++++++++++++- packages/storage/src/sqlite-runtime-schema.ts | 27 +++++- 3 files changed, 122 insertions(+), 7 deletions(-) diff --git a/packages/runtime/src/__tests__/conversation-copy.test.ts b/packages/runtime/src/__tests__/conversation-copy.test.ts index e150c6147d..38821c8462 100644 --- a/packages/runtime/src/__tests__/conversation-copy.test.ts +++ b/packages/runtime/src/__tests__/conversation-copy.test.ts @@ -3236,13 +3236,20 @@ test('conversation copy gives a run whose opening the migration shelved its open opening.event_id, ); db.prepare('DELETE FROM runtime_events WHERE event_id = ?').run(opening.event_id); + const anchor = db + .prepare( + "SELECT event_id FROM runtime_events WHERE run_id = 'run-source' ORDER BY event_seq ASC LIMIT 1", + ) + .get() as { event_id: string }; db.prepare(` INSERT INTO runtime_legacy_invocation_openings ( - invocation_id, session_id, run_id, turn_id, opened_at, opening_json - ) VALUES ('run-source', 'session-source', 'run-source', 'turn-1', ?, ?) + invocation_id, session_id, run_id, turn_id, opened_at, opening_json, + anchor_event_id + ) VALUES ('run-source', 'session-source', 'run-source', 'turn-1', ?, ?, ?) `).run( opening.committed_at, JSON.stringify((JSON.parse(opening.payload_json) as { content: unknown }).content), + anchor.event_id, ); } finally { db.close(); diff --git a/packages/storage/src/__tests__/invocation-opening-backfill.test.ts b/packages/storage/src/__tests__/invocation-opening-backfill.test.ts index 4dfe39d4ef..eda223ff3a 100644 --- a/packages/storage/src/__tests__/invocation-opening-backfill.test.ts +++ b/packages/storage/src/__tests__/invocation-opening-backfill.test.ts @@ -145,7 +145,8 @@ describe('invocation opening fact backfill', () => { // the invocation id its own events already carry. const legacyRows = db .prepare(` - SELECT invocation_id, session_id, run_id, turn_id, opened_at, opening_json + SELECT invocation_id, session_id, run_id, turn_id, opened_at, opening_json, + anchor_event_id FROM runtime_legacy_invocation_openings ORDER BY invocation_id `) @@ -156,6 +157,7 @@ describe('invocation opening fact backfill', () => { turn_id: string; opened_at: number; opening_json: string; + anchor_event_id: string; }>; assert.deepEqual( legacyRows.map((row) => row.invocation_id), @@ -169,6 +171,24 @@ describe('invocation opening fact backfill', () => { (JSON.parse(legacyRows[0]!.opening_json) as { kind: string }).kind, 'invocation_opened', ); + // The shelved opening describes that ledger, so it is anchored to the + // ledger's first event and cannot outlive it. + assert.equal( + legacyRows[0]!.anchor_event_id, + 'existing-1', + 'the shelved opening is anchored to the first event of the run it describes', + ); + db.exec('PRAGMA foreign_keys = ON'); + db.prepare('DELETE FROM runtime_events WHERE event_id = ?').run('existing-1'); + assert.equal( + ( + db + .prepare('SELECT COUNT(*) AS count FROM runtime_legacy_invocation_openings') + .get() as { count: number } + ).count, + 0, + 'deleting the anchor takes the shelved opening with it', + ); } finally { db.close(); } @@ -227,6 +247,75 @@ describe('invocation opening fact backfill', () => { }); }); + test('purging a migrated Session takes its shelved openings with it', async () => { + await withHeaderOnlyRuns(async (databasePath) => { + const db = new DatabaseSync(databasePath); + try { + const { json } = encodeCanonicalRuntimeEvent({ + id: 'existing-1', + invocationId: 'run-with-events', + runId: 'run-with-events', + sessionId: 'session-1', + turnId: 'turn-with-events', + ts: 1, + partial: false, + role: 'user', + author: 'user', + modelVisibility: 'visible', + content: { kind: 'text', text: 'already immutable' }, + }); + db.prepare(` + INSERT INTO runtime_events ( + event_id, session_id, invocation_id, run_id, turn_id, event_seq, + event_kind, payload_json, committed_at + ) VALUES ('existing-1', 'session-1', 'run-with-events', 'run-with-events', + 'turn-with-events', 1, 'text', ?, 1) + `).run(json); + migrateSqliteRuntimeDatabase(db); + } finally { + db.close(); + } + + // What purging a conversation does to this database: delete the Session's + // events. `conversation-operational-state.ts` runs exactly this statement + // on a lease that has `PRAGMA foreign_keys = ON`, which is also how + // `runtime_session_event_ordinals` is cleaned up today. + const purge = new DatabaseSync(databasePath); + try { + purge.exec('PRAGMA foreign_keys = ON'); + purge.prepare('DELETE FROM runtime_events WHERE session_id = ?').run('session-1'); + } finally { + purge.close(); + } + + // The shelved opening is only read when its invocation has no opening + // event, so a purge that deleted the events but left the shelf would make + // a completed run reappear as an active one. + const store = createSqliteRuntimeStore(databasePath); + try { + assert.deepEqual(await store.listSessionInvocations('session-1'), []); + assert.equal(await store.readRunInvocation('session-1', 'run-with-events'), undefined); + } finally { + store.close(); + } + + const check = new DatabaseSync(databasePath); + try { + assert.equal( + ( + check + .prepare('SELECT COUNT(*) AS count FROM runtime_legacy_invocation_openings') + .get() as { count: number } + ).count, + 0, + 'the shelf is empty, not merely unreadable', + ); + } finally { + check.close(); + } + }); + }); + test('bounds, pages and addresses the same inventory', async () => { await withHeaderOnlyRuns(async (databasePath) => { const db = new DatabaseSync(databasePath); diff --git a/packages/storage/src/sqlite-runtime-schema.ts b/packages/storage/src/sqlite-runtime-schema.ts index 7ecdfd3a52..5eb2d4f48a 100644 --- a/packages/storage/src/sqlite-runtime-schema.ts +++ b/packages/storage/src/sqlite-runtime-schema.ts @@ -511,7 +511,9 @@ const MIGRATIONS: ReadonlyMap = new Map([ run_id TEXT NOT NULL, turn_id TEXT NOT NULL, opened_at INTEGER NOT NULL, - opening_json TEXT NOT NULL + opening_json TEXT NOT NULL, + anchor_event_id TEXT NOT NULL UNIQUE + REFERENCES runtime_events(event_id) ON DELETE CASCADE ) WITHOUT ROWID; CREATE INDEX runtime_legacy_invocation_openings_by_session @@ -581,6 +583,12 @@ function projectContinuationClaimOpenings(db: DatabaseSync): void { * which only this migration ever writes. Readers merge the two, so nothing * downstream has to know which shelf a given opening came off. * + * A shelved opening describes a ledger that already exists, so it is anchored to + * that ledger's first event and dies with it. Without the anchor, deleting a + * Session's events would leave the opening behind, and the inventory would + * report the run again with no ending — a completed run coming back as an + * active one. + * * A header this cannot project fails closed: it is skipped, and its transcript * and tool evidence stay exactly as readable as before. */ @@ -601,7 +609,12 @@ function backfillInvocationOpeningFacts(db: DatabaseSync): void { SELECT e.invocation_id FROM runtime_events e WHERE e.session_id = r.session_id AND e.run_id = r.run_id ORDER BY e.event_seq ASC LIMIT 1 - ) AS existing_invocation_id + ) AS existing_invocation_id, + ( + SELECT e.event_id FROM runtime_events e + WHERE e.session_id = r.session_id AND e.run_id = r.run_id + ORDER BY e.event_seq ASC LIMIT 1 + ) AS anchor_event_id FROM core_agent_runs r ORDER BY r.created_at ASC, r.run_id ASC `) @@ -610,6 +623,7 @@ function backfillInvocationOpeningFacts(db: DatabaseSync): void { run_id: string; record_json: string; existing_invocation_id: string | null; + anchor_event_id: string | null; }>; const insertEvent = db.prepare(` INSERT INTO runtime_events ( @@ -624,8 +638,9 @@ function backfillInvocationOpeningFacts(db: DatabaseSync): void { `); const insertLegacyOpening = db.prepare(` INSERT OR IGNORE INTO runtime_legacy_invocation_openings ( - invocation_id, session_id, run_id, turn_id, opened_at, opening_json - ) VALUES (?, ?, ?, ?, ?, ?) + invocation_id, session_id, run_id, turn_id, opened_at, opening_json, + anchor_event_id + ) VALUES (?, ?, ?, ?, ?, ?, ?) `); // The header column is dropped right after this, so a header this cannot read // is a run that would silently cease to exist. Refusing the whole migration @@ -651,6 +666,9 @@ function backfillInvocationOpeningFacts(db: DatabaseSync): void { // The invocation id its own events already carry is the one every reader // joins on, so the legacy row is keyed by that rather than by the header's // copy, which older builds minted independently. + if (row.anchor_event_id === null) { + throw unreadable(row, new Error('run has events but no first event to anchor its opening')); + } insertLegacyOpening.run( row.existing_invocation_id, header.sessionId, @@ -658,6 +676,7 @@ function backfillInvocationOpeningFacts(db: DatabaseSync): void { header.turnId, header.createdAt, opening, + row.anchor_event_id, ); continue; } From 13633900967520c2ba39149ffb368fd084948ebc Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 4 Sep 2026 10:14:47 +0800 Subject: [PATCH 42/46] fix(storage): read an invocation's ending the way every other reader does Four places state when an invocation ended. Recovery, the read model and continuation resume all take the run's first terminal event. The SQLite inventory took the last event and only when that event was terminal, which answers differently for a ledger that carries a straggler after the terminal. Those ledgers exist. Runs were only sealed from #4242 onward, and before that pressing stop ended a run while its stream was still draining, so the stragglers that window now refuses were written and nothing has ever removed them. On such a run the inventory said active while recovery said ended, so finalizeChildWorkspacePatches threw over a nonterminal run that had finished and continuation discovery could not see it at all. The inventory now states the same sentence as the other three. Sealing already makes the first terminal the only one for anything this codebase writes, so nothing changes for a ledger written since #4242. Two terminals is corruption, and refusing it stays with the readers that exist to refuse it; a Session list reports the ending it can see rather than failing the whole Session over one run. Sealing was an unstated obligation of one store. It is now on RuntimeEventStore, where every implementation and test double can be held to it, alongside the fact that a migrated Session's inventory genuinely cannot be rebuilt from its events. Generated-by: Claude Code --- packages/core/src/runtime-event-store.ts | 33 ++++++++- packages/core/src/runtime-invocation.ts | 8 ++- .../runtime-invocation-index.test.ts | 67 ++++++++++++++++++- packages/storage/src/sqlite-runtime-store.ts | 25 +++++-- 4 files changed, 123 insertions(+), 10 deletions(-) diff --git a/packages/core/src/runtime-event-store.ts b/packages/core/src/runtime-event-store.ts index 4a441d364e..9fa17f304e 100644 --- a/packages/core/src/runtime-event-store.ts +++ b/packages/core/src/runtime-event-store.ts @@ -80,6 +80,17 @@ export interface RuntimeEventStore { * clearing any physical index and rebuilding from the events produces the * same inventory. Reserved control-plane invocation streams have no opening * fact and therefore never appear here. + * + * One exception, and it is a durable one: an invocation that predates the + * opening fact could not be given one without rewriting an immutable + * sequence, so a store that migrated such a Session keeps that opening + * outside the events and merges it in here. Those invocations cannot be + * rebuilt from events alone, and never will be. + * + * An invocation's `terminalEvent` is its first terminal event. Sealing makes + * that the only one for anything written through this interface; a ledger + * from before the seal can carry a straggler after it, and the ending is + * still the terminal event. */ listSessionInvocations(sessionId: string): Promise; /** @@ -91,6 +102,17 @@ export interface RuntimeEventStore { sessionId: string, runId: string, ): Promise; + /** + * Append one event to a run. + * + * Every implementation seals: once a run holds a terminal event, appending + * any event the store does not already have must throw `RunSealedError`. An + * exact-id replay of an event already stored stays idempotent. This is what + * makes a run's ending single and final, so it is an obligation of this + * interface rather than a detail of one store — a test double that skips it + * is manufacturing a ledger no supported store can produce. Tests that need a + * corrupt ledger should build it beneath this interface, not through it. + */ appendRuntimeEvent( sessionId: string, runId: string, @@ -101,14 +123,21 @@ export interface RuntimeEventStore { * Coalesce one already-admitted mutable presentation stream into one store * transaction. Callers must preserve provider order and flush before every * immutable execution boundary. Stores that do not implement this optional - * fast path continue to receive one append per partial event. + * fast path continue to receive one append per partial event. The seal on + * `appendRuntimeEvent` applies here too. */ appendRuntimePartialBatch?( sessionId: string, runId: string, events: readonly RuntimeEvent[], ): Promise; - /** Append the terminal event if absent, or re-establish its stable-storage barrier if present. */ + /** + * Append the terminal event if absent, or re-establish its stable-storage + * barrier if present. This is the one writer the seal admits: it must commit + * the terminal event and the seal check in the same transaction, so two + * callers racing to end one run produce one terminal event and a + * `RunSealedError` for the loser. + */ ensureTerminalRuntimeEventDurable( sessionId: string, runId: string, diff --git a/packages/core/src/runtime-invocation.ts b/packages/core/src/runtime-invocation.ts index aa2329bcb8..e020d7f14f 100644 --- a/packages/core/src/runtime-invocation.ts +++ b/packages/core/src/runtime-invocation.ts @@ -71,9 +71,11 @@ export function runtimeInvocationsFromSessionEvents( }); } } - // A run is sealed by its terminal event — the store refuses anything after - // it — so an invocation has at most one, and a Session-ordered read may place - // it anywhere relative to other invocations' events. + // An invocation ends at its first terminal event. Sealing makes that the only + // one for any ledger this codebase wrote; one written before the seal existed + // can carry a straggler after it, and the ending is still the terminal event. + // A Session-ordered read may place it anywhere relative to other invocations' + // events, so this scans rather than looking at the tail. for (const event of events) { if (event.sessionId !== sessionId || event.partial === true) continue; if (!isTerminalRuntimeEvent(event)) continue; diff --git a/packages/runtime/src/__tests__/runtime-invocation-index.test.ts b/packages/runtime/src/__tests__/runtime-invocation-index.test.ts index 726e149346..dd76eccef4 100644 --- a/packages/runtime/src/__tests__/runtime-invocation-index.test.ts +++ b/packages/runtime/src/__tests__/runtime-invocation-index.test.ts @@ -27,9 +27,12 @@ import assert from 'node:assert/strict'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; import { test } from 'node:test'; +import { encodeCanonicalRuntimeEvent } from '@maka/core/canonical-runtime-event'; import { runtimeInvocationsFromSessionEvents } from '@maka/core/runtime-invocation'; import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; +import { OPERATIONAL_STATE_DATABASE_NAME } from '@maka/storage/operational-state-store'; import { createWorkspaceRuntimeStore } from '@maka/storage/runtime-event-persistence'; import { createSessionStore } from '@maka/storage/session-store'; import type { SessionEvent } from '@maka/core/events'; @@ -102,8 +105,70 @@ test('the invocation index returns the same inventory as a rebuild from events a ); } - runStore.close?.(); + // A ledger written before the store sealed runs can carry a straggler after + // the terminal event: stop sealed the run while the stream was still + // draining, and nothing has ever removed those. Recovery, the read model and + // continuation resume all read such a run as ended, so the index must too — + // reading it as active is what makes one reader disagree with the rest. + const straggler = invocations[0]!; runtimeEventStore.close(); + const db = new DatabaseSync(join(root, OPERATIONAL_STATE_DATABASE_NAME)); + try { + const { json } = encodeCanonicalRuntimeEvent({ + id: 'post-terminal-straggler', + invocationId: straggler.invocationId, + runId: straggler.runId, + sessionId: session.id, + turnId: straggler.turnId, + ts: 9_999, + partial: false, + role: 'model', + author: 'agent', + modelVisibility: 'visible', + content: { kind: 'text', text: 'arrived after the run was sealed' }, + }); + db.prepare(` + INSERT INTO runtime_events ( + event_id, session_id, invocation_id, run_id, turn_id, event_seq, + event_kind, payload_json, committed_at + ) VALUES (?, ?, ?, ?, ?, ( + SELECT MAX(event_seq) + 1 FROM runtime_events WHERE invocation_id = ? + ), 'text', ?, 9999) + `).run( + 'post-terminal-straggler', + session.id, + straggler.invocationId, + straggler.runId, + straggler.turnId, + straggler.invocationId, + json, + ); + } finally { + db.close(); + } + + const reopened = createWorkspaceRuntimeStore(root); + try { + const afterStraggler = await reopened.listSessionInvocations(session.id); + assert.deepStrictEqual( + afterStraggler, + runtimeInvocationsFromSessionEvents( + session.id, + await reopened.readSessionRuntimeEvents(session.id), + ), + 'the index and a rebuild must still agree once a straggler follows the terminal', + ); + assert.equal( + afterStraggler.find((invocation) => invocation.invocationId === straggler.invocationId) + ?.terminalEvent?.status, + 'completed', + 'a run that ended stays ended when an unsealed-era straggler follows it', + ); + } finally { + reopened.close(); + } + + runStore.close?.(); sessionStore.close?.(); } finally { await rm(root, { recursive: true, force: true }); diff --git a/packages/storage/src/sqlite-runtime-store.ts b/packages/storage/src/sqlite-runtime-store.ts index ff94d37622..829d58c361 100644 --- a/packages/storage/src/sqlite-runtime-store.ts +++ b/packages/storage/src/sqlite-runtime-store.ts @@ -752,22 +752,39 @@ export class SqliteRuntimeStore }); } + /** + * An invocation's ending is its first terminal event, wherever it sits. + * + * The store seals a run on that event, so for anything it wrote itself the + * first terminal is also the only one and the last event. Ledgers written + * before the seal existed can carry a straggler after the terminal, and + * reading those as unfinished would contradict every other reader of the same + * rule: recovery, the read model and continuation resume all take the first + * terminal. A ledger that somehow holds two is corrupt, and saying so is the + * job of those readers — this inventory feeds Session lists, so it reports the + * ending it can see rather than poisoning the whole Session over one run. + */ private completeInvocationRecordSync( record: Omit, ): RuntimeInvocationRecord { - const lastRow = this.db + const terminalRow = this.db .prepare(` SELECT event_id, session_id, invocation_id, run_id, turn_id, payload_json FROM runtime_events WHERE invocation_id = ? - ORDER BY event_seq DESC + AND ( + json_extract(payload_json, '$.actions.endInvocation') = 1 + OR json_extract(payload_json, '$.status') + IN ('completed', 'failed', 'aborted', 'cancelled') + ) + ORDER BY event_seq ASC LIMIT 1 `) .get(record.invocationId) as unknown as RuntimeEventStorageRow | undefined; - const last = lastRow ? decodeRuntimeEventStorageRow(lastRow) : undefined; + const terminal = terminalRow ? decodeRuntimeEventStorageRow(terminalRow) : undefined; return { ...record, - ...(last && isTerminalRuntimeEvent(last) ? { terminalEvent: last } : {}), + ...(terminal && isTerminalRuntimeEvent(terminal) ? { terminalEvent: terminal } : {}), }; } From d11357729bdedebdec9a0fe77a7cd0fac5e41959 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 4 Sep 2026 10:55:55 +0800 Subject: [PATCH 43/46] refactor(storage): ask for a run's first event once, and for its ending once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three duplications the previous two commits left behind. The backfill asked twice for the same row — one subquery for the first event's invocation id, another for its event id — so the two could be reasoned about as if they might disagree, and a branch existed for a case the query made impossible. One join answers both. The terminal-event predicate was written out at each query that needed it. It is the SQL half of isTerminalRuntimeEvent, which stays the authority, so it is stated once and used twice. The cascade assertion in the backfill test restated what the purge regression already covers end to end. The UNIQUE on anchor_event_id stays and is now explained: SQLite indexes only the parent side of a foreign key, so without it every deleted RuntimeEvent scans the shelf. Measured at 20k events over a 4k-row shelf, removing it takes the delete from 8ms to 250ms. Generated-by: Claude Code --- .../invocation-opening-backfill.test.ts | 11 -------- packages/storage/src/sqlite-runtime-schema.ts | 25 ++++++++----------- packages/storage/src/sqlite-runtime-store.ts | 25 +++++++++++-------- 3 files changed, 26 insertions(+), 35 deletions(-) diff --git a/packages/storage/src/__tests__/invocation-opening-backfill.test.ts b/packages/storage/src/__tests__/invocation-opening-backfill.test.ts index eda223ff3a..3e8a5d1610 100644 --- a/packages/storage/src/__tests__/invocation-opening-backfill.test.ts +++ b/packages/storage/src/__tests__/invocation-opening-backfill.test.ts @@ -178,17 +178,6 @@ describe('invocation opening fact backfill', () => { 'existing-1', 'the shelved opening is anchored to the first event of the run it describes', ); - db.exec('PRAGMA foreign_keys = ON'); - db.prepare('DELETE FROM runtime_events WHERE event_id = ?').run('existing-1'); - assert.equal( - ( - db - .prepare('SELECT COUNT(*) AS count FROM runtime_legacy_invocation_openings') - .get() as { count: number } - ).count, - 0, - 'deleting the anchor takes the shelved opening with it', - ); } finally { db.close(); } diff --git a/packages/storage/src/sqlite-runtime-schema.ts b/packages/storage/src/sqlite-runtime-schema.ts index 5eb2d4f48a..d64a9d359e 100644 --- a/packages/storage/src/sqlite-runtime-schema.ts +++ b/packages/storage/src/sqlite-runtime-schema.ts @@ -512,6 +512,9 @@ const MIGRATIONS: ReadonlyMap = new Map([ turn_id TEXT NOT NULL, opened_at INTEGER NOT NULL, opening_json TEXT NOT NULL, + -- UNIQUE is what indexes this side of the foreign key. SQLite indexes only + -- the parent, so without it every deleted RuntimeEvent scans this whole + -- table looking for rows to cascade. anchor_event_id TEXT NOT NULL UNIQUE REFERENCES runtime_events(event_id) ON DELETE CASCADE ) WITHOUT ROWID; @@ -605,17 +608,14 @@ function backfillInvocationOpeningFacts(db: DatabaseSync): void { r.session_id, r.run_id, r.record_json, - ( - SELECT e.invocation_id FROM runtime_events e - WHERE e.session_id = r.session_id AND e.run_id = r.run_id - ORDER BY e.event_seq ASC LIMIT 1 - ) AS existing_invocation_id, - ( - SELECT e.event_id FROM runtime_events e - WHERE e.session_id = r.session_id AND e.run_id = r.run_id - ORDER BY e.event_seq ASC LIMIT 1 - ) AS anchor_event_id + first_event.invocation_id AS existing_invocation_id, + first_event.event_id AS anchor_event_id FROM core_agent_runs r + LEFT JOIN runtime_events first_event ON first_event.event_id = ( + SELECT e.event_id FROM runtime_events e + WHERE e.session_id = r.session_id AND e.run_id = r.run_id + ORDER BY e.event_seq ASC LIMIT 1 + ) ORDER BY r.created_at ASC, r.run_id ASC `) .all() as Array<{ @@ -662,13 +662,10 @@ function backfillInvocationOpeningFacts(db: DatabaseSync): void { } catch (error) { throw unreadable(row, error); } - if (row.existing_invocation_id !== null) { + if (row.existing_invocation_id !== null && row.anchor_event_id !== null) { // The invocation id its own events already carry is the one every reader // joins on, so the legacy row is keyed by that rather than by the header's // copy, which older builds minted independently. - if (row.anchor_event_id === null) { - throw unreadable(row, new Error('run has events but no first event to anchor its opening')); - } insertLegacyOpening.run( row.existing_invocation_id, header.sessionId, diff --git a/packages/storage/src/sqlite-runtime-store.ts b/packages/storage/src/sqlite-runtime-store.ts index 829d58c361..c71dcbfb01 100644 --- a/packages/storage/src/sqlite-runtime-store.ts +++ b/packages/storage/src/sqlite-runtime-store.ts @@ -134,6 +134,19 @@ export { SQLITE_RUNTIME_SCHEMA_VERSION } from './sqlite-runtime-schema.js'; export type { ToolRecoveryMode } from '@maka/core/runtime-event'; +/** + * `isTerminalRuntimeEvent` asked in SQL. + * + * The TypeScript predicate stays the authority; this only lets a query find the + * terminal event without decoding every row it passes over. Both have to say the + * same thing, so the SQL half is written once here instead of at each query. + */ +const TERMINAL_RUNTIME_EVENT_SQL = `( + json_extract(payload_json, '$.actions.endInvocation') = 1 + OR json_extract(payload_json, '$.status') + IN ('completed', 'failed', 'aborted', 'cancelled') + )`; + const RUNTIME_EVENT_SCAN_BATCH_SIZE = 128; const RUNTIME_PARTIAL_SEGMENT_TARGET_BYTES = 64 * 1024; @@ -772,11 +785,7 @@ export class SqliteRuntimeStore SELECT event_id, session_id, invocation_id, run_id, turn_id, payload_json FROM runtime_events WHERE invocation_id = ? - AND ( - json_extract(payload_json, '$.actions.endInvocation') = 1 - OR json_extract(payload_json, '$.status') - IN ('completed', 'failed', 'aborted', 'cancelled') - ) + AND ${TERMINAL_RUNTIME_EVENT_SQL} ORDER BY event_seq ASC LIMIT 1 `) @@ -3632,11 +3641,7 @@ export class SqliteRuntimeStore SELECT event_id, session_id, invocation_id, run_id, turn_id, payload_json FROM runtime_events WHERE session_id = ? AND run_id = ? - AND ( - json_extract(payload_json, '$.actions.endInvocation') = 1 - OR json_extract(payload_json, '$.status') - IN ('completed', 'failed', 'aborted', 'cancelled') - ) + AND ${TERMINAL_RUNTIME_EVENT_SQL} ORDER BY event_seq ASC `) .all(event.sessionId, event.runId) as unknown as RuntimeEventStorageRow[]; From 52ad1b3df94b6aa14746b475394d24af89eb865b Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 4 Sep 2026 11:12:36 +0800 Subject: [PATCH 44/46] fix(storage): let a Session with a started continuation be purged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A continuation claim's start event belongs to the target Session, and the foreign key naming it had no ON DELETE clause. Purging that Session deletes its RuntimeEvents, so the constraint refused the delete and rolled the whole purge back — for the user's delete, a conversation-copy rollback, an import discard and Session retirement alike. A continuation whose target has been deleted no longer names anything, so the claim goes with it and the source boundary it was holding is free again. SET NULL would have been worse than the bug: the claim would survive looking like one that had been claimed but never started, and resume would try to start it against a target that no longer exists. Migration 16 was already renaming a column on this table, and SQLite cannot alter a foreign key in place, so the rename becomes a rebuild and does both at once. Migration 15 rebuilt the same table the same way. Generated-by: Claude Code --- .../__tests__/sqlite-runtime-store.test.ts | 41 +++++++++++++ packages/storage/src/sqlite-runtime-schema.ts | 57 ++++++++++++++++++- 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/packages/storage/src/__tests__/sqlite-runtime-store.test.ts b/packages/storage/src/__tests__/sqlite-runtime-store.test.ts index 0dad3d0d49..6b99977949 100644 --- a/packages/storage/src/__tests__/sqlite-runtime-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-runtime-store.test.ts @@ -1087,6 +1087,47 @@ describe('SqliteRuntimeStore', () => { }); }); + it('lets a started continuation target be purged instead of refusing the delete', async () => { + await withStore(async (store, dbPath) => { + const claim = continuationClaim(); + await persistImmutablePrefix(store, continuationSourcePrefix()); + assert.equal((await store.claimContinuation({ claim })).kind, 'acquired'); + + const db = new DatabaseSync(dbPath); + try { + db.exec('PRAGMA foreign_keys = ON'); + // Stand the claim up the way starting a continuation does: its start + // event is event one of the target Session's run. + const start = db + .prepare('SELECT event_id, session_id FROM runtime_events ORDER BY event_seq ASC LIMIT 1') + .get() as { event_id: string; session_id: string }; + db.prepare( + "UPDATE runtime_continuation_claims SET start_event_id = ?, start_kind = 'runtime_admission' WHERE claim_id = ?", + ).run(start.event_id, claim.claimId); + + // Purging a conversation deletes its events. The claim used to have no + // ON DELETE clause, so the constraint refused this and rolled the whole + // purge back — for the user's delete, a copy rollback, an import + // discard and Session retirement alike. + db.prepare('DELETE FROM runtime_events WHERE session_id = ?').run(start.session_id); + + assert.equal( + ( + db + .prepare( + 'SELECT COUNT(*) AS count FROM runtime_continuation_claims WHERE claim_id = ?', + ) + .get(claim.claimId) as { count: number } + ).count, + 0, + 'a continuation whose target was deleted no longer names anything, so it goes too', + ); + } finally { + db.close(); + } + }); + }); + it('fails closed when continuation claim columns disagree with canonical payload', async () => { await withStore(async (store, dbPath) => { const claim = continuationClaim(); diff --git a/packages/storage/src/sqlite-runtime-schema.ts b/packages/storage/src/sqlite-runtime-schema.ts index d64a9d359e..a10532f026 100644 --- a/packages/storage/src/sqlite-runtime-schema.ts +++ b/packages/storage/src/sqlite-runtime-schema.ts @@ -522,8 +522,61 @@ const MIGRATIONS: ReadonlyMap = new Map([ CREATE INDEX runtime_legacy_invocation_openings_by_session ON runtime_legacy_invocation_openings(session_id, opened_at, invocation_id); - ALTER TABLE runtime_continuation_claims - RENAME COLUMN target_run_header_json TO target_opening_json; + -- Rebuilt rather than renamed in place, because the column rename is not + -- the only thing this claim needs. Its start event belongs to the target + -- Session, and the foreign key had no ON DELETE clause, so purging that + -- Session was refused outright by the constraint and the whole purge rolled + -- back. A continuation whose target has been deleted no longer names + -- anything, so the claim goes with it and the source boundary it held is + -- free again. + CREATE TABLE runtime_continuation_claims_v16 ( + claim_id TEXT PRIMARY KEY, + source_session_id TEXT NOT NULL, + source_invocation_id TEXT NOT NULL, + source_run_id TEXT NOT NULL, + source_turn_id TEXT NOT NULL, + source_event_high_water INTEGER NOT NULL CHECK (source_event_high_water > 0), + source_prefix_digest TEXT NOT NULL, + boundary_digest TEXT NOT NULL UNIQUE, + boundary_json TEXT NOT NULL, + provider_projection_version INTEGER NOT NULL CHECK (provider_projection_version IN (1, 2)), + provider_replay_digest TEXT NOT NULL, + target_session_id TEXT NOT NULL, + target_invocation_id TEXT NOT NULL UNIQUE, + target_run_id TEXT NOT NULL UNIQUE, + target_turn_id TEXT NOT NULL, + target_opening_json TEXT NOT NULL, + claimed_at INTEGER NOT NULL, + start_event_id TEXT UNIQUE REFERENCES runtime_events(event_id) ON DELETE CASCADE, + start_kind TEXT CHECK ( + start_kind IS NULL OR start_kind IN ('runtime_admission', 'claim_repair') + ), + protocol_version INTEGER NOT NULL CHECK (protocol_version = 1), + UNIQUE ( + source_session_id, + source_run_id, + source_event_high_water, + source_prefix_digest + ), + UNIQUE (target_session_id, target_turn_id) + ); + + INSERT INTO runtime_continuation_claims_v16 ( + claim_id, source_session_id, source_invocation_id, source_run_id, source_turn_id, + source_event_high_water, source_prefix_digest, boundary_digest, boundary_json, + provider_projection_version, provider_replay_digest, target_session_id, + target_invocation_id, target_run_id, target_turn_id, target_opening_json, + claimed_at, start_event_id, start_kind, protocol_version + ) + SELECT + claim_id, source_session_id, source_invocation_id, source_run_id, source_turn_id, + source_event_high_water, source_prefix_digest, boundary_digest, boundary_json, + provider_projection_version, provider_replay_digest, target_session_id, + target_invocation_id, target_run_id, target_turn_id, target_run_header_json, + claimed_at, start_event_id, start_kind, protocol_version + FROM runtime_continuation_claims; + DROP TABLE runtime_continuation_claims; + ALTER TABLE runtime_continuation_claims_v16 RENAME TO runtime_continuation_claims; `, ], ]); From 8cd09afacaed5bbecb47ebdfd8274351ab75c130 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 4 Sep 2026 11:12:43 +0800 Subject: [PATCH 45/46] refactor(runtime): hold the test doubles to the seal, and name corruption Two terminal events was classified as `ambiguous`. Nothing is ambiguous about it: a store seals a run on its first terminal, so a second one means the ledger was written around the seal. It is `corrupt`, and the strict recovery error now says what it found rather than that it was unsure. The seal itself was an obligation only one double honoured, which is what let that state look reachable in the first place. It is stated once now and used by every RuntimeEventStore double, so a double cannot drift from the SQLite store or from the interface. Six fixtures were appending to a finished run through the API that forbids it, using append as a way to write into the ledger rather than to test the seal. They now seed the double directly, which is where a ledger shape no supported store can produce belongs. Generated-by: Claude Code --- .../src/__tests__/agent-run-inspect.test.ts | 2 + .../src/__tests__/runtime-event-store-seal.ts | 43 +++++++++++++++++++ .../session-manager-terminal-ledger.test.ts | 4 +- .../src/__tests__/session-manager.test.ts | 35 ++++++++++++--- packages/runtime/src/session-manager.ts | 6 ++- packages/runtime/src/terminal-run-commit.ts | 9 +++- 6 files changed, 88 insertions(+), 11 deletions(-) create mode 100644 packages/runtime/src/__tests__/runtime-event-store-seal.ts diff --git a/packages/runtime/src/__tests__/agent-run-inspect.test.ts b/packages/runtime/src/__tests__/agent-run-inspect.test.ts index d48fc922b6..64a6bf19fc 100644 --- a/packages/runtime/src/__tests__/agent-run-inspect.test.ts +++ b/packages/runtime/src/__tests__/agent-run-inspect.test.ts @@ -29,6 +29,7 @@ import { } from '@maka/core/runtime-invocation'; import { inspectAgentRunReadModel } from '../agent-run-inspect.js'; import { testInvocationOpening } from './invocation-fixture.js'; +import { assertDoubleRunNotSealed } from './runtime-event-store-seal.js'; const sessionId = 'session-1'; const invocationId = 'inv-1'; @@ -186,6 +187,7 @@ class MemoryAgentRunStore implements AgentRunStore, RuntimeEventStore { async appendRuntimeEvent(sessionId: string, runId: string, event: RuntimeEvent): Promise { const eventKey = key(sessionId, runId); + assertDoubleRunNotSealed(this.runtimeEvents.get(eventKey) ?? [], event); this.runtimeEvents.set(eventKey, [ ...(this.runtimeEvents.get(eventKey) ?? []), copyRuntimeEvent(event), diff --git a/packages/runtime/src/__tests__/runtime-event-store-seal.ts b/packages/runtime/src/__tests__/runtime-event-store-seal.ts new file mode 100644 index 0000000000..500feecf62 --- /dev/null +++ b/packages/runtime/src/__tests__/runtime-event-store-seal.ts @@ -0,0 +1,43 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { isTerminalRuntimeEvent } from '@maka/core/runtime-event'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; +import { RunSealedError } from '@maka/core/runtime-event-store'; + +/** + * The seal every `RuntimeEventStore` owes its callers, for the doubles. + * + * `RuntimeEventStore` requires an implementation to refuse any new event on a + * run that already holds a terminal one. A double that skips it manufactures a + * ledger no supported store can produce, and a test built on that ledger proves + * nothing about production. Stated here once so the doubles cannot drift apart + * from each other or from the SQLite store. + * + * A test that genuinely needs a corrupt ledger should assemble it underneath the + * store rather than appending through it. + */ +export function assertDoubleRunNotSealed( + storedEvents: readonly RuntimeEvent[], + incoming: RuntimeEvent, +): void { + // An exact-id replay is idempotent: the event is already inside the seal. + if (storedEvents.some((event) => event.id === incoming.id)) return; + if (storedEvents.some(isTerminalRuntimeEvent)) throw new RunSealedError(incoming.runId); +} diff --git a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts index c5b6710e36..3c0f693608 100644 --- a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts +++ b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts @@ -63,6 +63,7 @@ import { RuntimeReadModel } from '../runtime-read-model.js'; import { RuntimeKernel } from '../runtime-kernel.js'; import type { RuntimeInteractionAuthority } from '../interaction-authority.js'; import { testInvocationOpening } from './invocation-fixture.js'; +import { assertDoubleRunNotSealed } from './runtime-event-store-seal.js'; describe('SessionManager terminal ledger invariants', () => { test('coalesces one partial stream and flushes it before the final model event', async () => { @@ -1042,7 +1043,7 @@ describe('SessionManager terminal ledger invariants', () => { }), ]); - assert.strictEqual(result.kind, 'ambiguous'); + assert.strictEqual(result.kind, 'corrupt'); assert.deepStrictEqual( result.terminalEvents.map((event) => event.id), ['rt-completed', 'rt-failed'], @@ -2394,6 +2395,7 @@ class BatchingRuntimeEventStore implements RuntimeEventStore { constructor(private readonly failPartialBatch = false) {} async appendRuntimeEvent(_sessionId: string, _runId: string, event: RuntimeEvent): Promise { + assertDoubleRunNotSealed(this.events, event); this.order.push(`append:${event.id}`); this.events.push(clone(event)); } diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 058c43f783..3acbdb5837 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -91,6 +91,7 @@ import type { import { PlanConflictError, emptyPlanSessionState, type PlanStore } from '@maka/core/plan'; import { MockLanguageModelV4, simulateReadableStream } from 'ai/test'; import { createTestAiSdkBackend } from './execution-boundary-test-helpers.js'; +import { assertDoubleRunNotSealed } from './runtime-event-store-seal.js'; import type { LanguageModelV4StreamPart } from '@ai-sdk/provider'; import { z } from 'zod'; import { AiSdkBackend } from '../ai-sdk-backend.js'; @@ -6011,7 +6012,7 @@ describe('SessionManager permission mode updates', () => { const targetRunId = firstPlan.continuation.runId; const targetRun = await readInvocation(runStore, session.id, targetRunId); - await runStore.appendRuntimeEvent( + runStore.seedRuntimeEvent( session.id, targetRunId, runtimeEvent({ @@ -6708,7 +6709,7 @@ describe('SessionManager permission mode updates', () => { }); if (!plan.continuation) throw new Error('expected continuation'); - await runStore.appendRuntimeEvent( + runStore.seedRuntimeEvent( session.id, sourceRunId, runtimeEvent({ @@ -10053,7 +10054,7 @@ describe('SessionManager permission mode updates', () => { ts: 120 + index, }), ); - await runStore.appendRuntimeEvent( + runStore.seedRuntimeEvent( session.id, 'child-run', runtimeEvent({ @@ -10122,7 +10123,7 @@ describe('SessionManager permission mode updates', () => { permissionMode: 'explore', }), ); - await runStore.appendRuntimeEvent( + runStore.seedRuntimeEvent( session.id, 'child-run', runtimeEvent({ @@ -10136,7 +10137,7 @@ describe('SessionManager permission mode updates', () => { content: { kind: 'text', text: 'x'.repeat(64 * 1024) }, }), ); - await runStore.appendRuntimeEvent( + runStore.seedRuntimeEvent( session.id, 'child-run', runtimeEvent({ @@ -10467,7 +10468,7 @@ describe('SessionManager permission mode updates', () => { } const [run] = await runStore.listSessionInvocations(session.id); - await runStore.appendRuntimeEvent( + runStore.seedRuntimeEvent( session.id, run!.runId, runtimeEvent({ @@ -13185,6 +13186,17 @@ class MemoryAgentRunStore this.options.failRuntimeEventAppendAfter = undefined; throw new Error('runtime event append failed'); } + assertDoubleRunNotSealed(this.runtimeEvents.get(key(sessionId, runId)) ?? [], event); + this.seedRuntimeEvent(sessionId, runId, event); + } + + /** + * Put an event into the ledger underneath the seal. + * + * A test that needs a ledger shape the store would refuse to write has to + * assemble it below the store, not through the API whose contract forbids it. + */ + seedRuntimeEvent(sessionId: string, runId: string, event: RuntimeEvent): void { const eventKey = key(sessionId, runId); this.runtimeEvents.set(eventKey, [ ...(this.runtimeEvents.get(eventKey) ?? []), @@ -13566,6 +13578,17 @@ class MemoryRuntimeEventStore implements RuntimeEventStore { async appendRuntimeEvent(sessionId: string, runId: string, event: RuntimeEvent): Promise { if (this.options.failRuntimeEventAppends) throw new Error('runtime event append failed'); + assertDoubleRunNotSealed(this.runtimeEvents.get(key(sessionId, runId)) ?? [], event); + this.seedRuntimeEvent(sessionId, runId, event); + } + + /** + * Put an event into the ledger underneath the seal. + * + * A test that needs a ledger shape the store would refuse to write has to + * assemble it below the store, not through the API whose contract forbids it. + */ + seedRuntimeEvent(sessionId: string, runId: string, event: RuntimeEvent): void { const eventKey = key(sessionId, runId); this.runtimeEvents.set(eventKey, [ ...(this.runtimeEvents.get(eventKey) ?? []), diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index bc576f4cd9..91bd2d1021 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -4592,9 +4592,11 @@ export class SessionManager { } } const terminalLedger = classifyTerminalRuntimeLedger(run, inspected.runtimeEvents); - if (terminalLedger.kind === 'ambiguous') { + if (terminalLedger.kind === 'corrupt') { if (policy.kind === 'strict') { - throw new Error(`RuntimeEvent ledger has ambiguous terminal facts for run ${run.runId}`); + throw new Error( + `RuntimeEvent ledger has more than one terminal event for run ${run.runId}`, + ); } continue; } diff --git a/packages/runtime/src/terminal-run-commit.ts b/packages/runtime/src/terminal-run-commit.ts index 41ba570aaa..ad9aff9cf3 100644 --- a/packages/runtime/src/terminal-run-commit.ts +++ b/packages/runtime/src/terminal-run-commit.ts @@ -47,7 +47,12 @@ export type TerminalRuntimeLedgerClassification = terminalEvents: readonly RuntimeEvent[]; } | { - kind: 'ambiguous'; + /** + * More than one terminal event. Nothing ambiguous about it: a store seals + * a run on its first terminal, so a second one means the ledger was + * written around that seal and is corrupt. + */ + kind: 'corrupt'; terminalEvents: readonly RuntimeEvent[]; }; @@ -60,7 +65,7 @@ export function classifyTerminalRuntimeLedger( return { kind: 'none', terminalEvents }; } if (terminalEvents.length > 1) { - return { kind: 'ambiguous', terminalEvents }; + return { kind: 'corrupt', terminalEvents }; } const fact = classifyRuntimeEventTerminalFact(run, events).fact; From 2cf433acb670a6a1567b6944b22e44ecfee739c7 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 4 Sep 2026 11:32:52 +0800 Subject: [PATCH 46/46] refactor(runtime-host): read one invocation by run id, not by scanning Two inspect readers already had the answer available as a point read and scanned the whole Session inventory instead. `readRunInvocation` exists so a caller holding a run id never pays for the list, and a hand-written scan beside it is a second implementation of the same lookup that nothing keeps in agreement with the first. Also drops an `export` on a status tuple no other module names. A symbol only its own file uses is not a contract other packages should be able to import. Generated-by: Claude Code --- packages/core/src/execution-inspect.ts | 2 +- .../src/server/execution-inspect-coordinator.ts | 9 +++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/core/src/execution-inspect.ts b/packages/core/src/execution-inspect.ts index f6ded9d36f..e2fff784d9 100644 --- a/packages/core/src/execution-inspect.ts +++ b/packages/core/src/execution-inspect.ts @@ -35,7 +35,7 @@ export interface ExecutionInspectDiagnostic { eventId?: string; } -export const AGENT_RUN_INSPECT_STATUSES = ['running', 'completed', 'failed', 'cancelled'] as const; +const AGENT_RUN_INSPECT_STATUSES = ['running', 'completed', 'failed', 'cancelled'] as const; export interface AgentRunInspectIdentity { sessionId: string; diff --git a/packages/runtime-host/src/server/execution-inspect-coordinator.ts b/packages/runtime-host/src/server/execution-inspect-coordinator.ts index 72f62d7b1a..6dbd39cf6d 100644 --- a/packages/runtime-host/src/server/execution-inspect-coordinator.ts +++ b/packages/runtime-host/src/server/execution-inspect-coordinator.ts @@ -23,6 +23,7 @@ import { type ModelCallAttempt, } from '@maka/core/model-call-attempt'; import type { RuntimeEvent } from '@maka/core/runtime-event'; +import { readRunInvocation } from '@maka/core/runtime-event-store'; import type { RuntimeInvocationPageCursor, RuntimeInvocationRecord, @@ -126,9 +127,7 @@ export class HostExecutionInspectCoordinator { ): Promise { let invocation; try { - invocation = (await this.#stores.runtimeEventStore.listSessionInvocations(sessionId)).find( - (candidate) => candidate.runId === agentRunId, - ); + invocation = await readRunInvocation(this.#stores.runtimeEventStore, sessionId, agentRunId); } catch (error) { if (isMissing(error)) return undefined; throw error; @@ -287,9 +286,7 @@ export class HostExecutionInspectCoordinator { if (!admission) return undefined; let run; try { - run = (await this.#stores.runtimeEventStore.listSessionInvocations(sessionId)).find( - (candidate) => candidate.runId === admission.runId, - ); + run = await readRunInvocation(this.#stores.runtimeEventStore, sessionId, admission.runId); } catch (error) { if (isMissing(error)) return undefined; throw error;