diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 788c4b495f..65c5943686 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -264,6 +264,7 @@ export type { SubagentSessionRuntime, SubagentSessionRuntimeSummary, SubagentSessionSpawn, + SessionConversationCopy, TurnRecord, TurnStateMessage, TurnStatus, @@ -295,6 +296,7 @@ export { isSubagentSessionParent, isSubagentSessionRuntime, isSubagentSessionSpawn, + isSessionConversationCopy, subagentSessionRuntimeSummary, isTurnStatus, decodeStoredMessageForRead, diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index e3f5053aae..23f35efab6 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -142,6 +142,20 @@ export interface SubagentSessionSpawn { initialRunId: string; } +/** + * Internal publication state for a Host-owned cross-Session conversation copy. + * + * Preparing copies are not product Sessions yet. The Host publishes them only + * after Messages, Runtime Events, Artifacts, and Task Ledger state are durable. + */ +export interface SessionConversationCopy { + kind: 'branch' | 'revision'; + sourceSessionId: string; + sourceTurnId: string; + requestFingerprint: `sha256:${string}`; + state: 'preparing' | 'committed'; +} + export type SubagentSessionRuntimeSummary = Omit< SubagentSessionRuntime, 'systemPrompt' | 'categoryPolicy' @@ -200,6 +214,8 @@ export interface SessionHeader { subagentSpawn?: SubagentSessionSpawn; /** Immutable host-managed filesystem isolation for this child Session. */ subagentWorkspace?: SubagentWorkspaceBinding; + /** Immutable Host publication identity for a cross-Session conversation copy. */ + conversationCopy?: SessionConversationCopy; /** Stable root id for an edit-and-resend version family. */ revisionRootSessionId?: string; /** Immediate previous version in the same conversation slot. */ @@ -334,6 +350,10 @@ const SUBAGENT_SESSION_SPAWN_IDENTITY_SHAPE = defineObjectShape()( + ['kind', 'sourceSessionId', 'sourceTurnId', 'requestFingerprint', 'state'], + [], +); const SESSION_LINEAGE_ID_MAX_CHARS = 512; const SESSION_LINEAGE_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/; const SUBAGENT_RUNTIME_NAME_MAX_CHARS = 512; @@ -415,6 +435,20 @@ export function isSubagentSessionSpawn(value: unknown): value is SubagentSession ); } +/** Strict decoder guard for Host-owned conversation-copy publication state. */ +export function isSessionConversationCopy(value: unknown): value is SessionConversationCopy { + return ( + isRecord(value) && + hasExactShape(value, SESSION_CONVERSATION_COPY_SHAPE) && + (value.kind === 'branch' || value.kind === 'revision') && + isSessionLineageId(value.sourceSessionId) && + isSessionLineageId(value.sourceTurnId) && + typeof value.requestFingerprint === 'string' && + /^sha256:[0-9a-f]{64}$/.test(value.requestFingerprint) && + (value.state === 'preparing' || value.state === 'committed') + ); +} + export function subagentSessionRuntimeSummary( value: SubagentSessionRuntime, ): SubagentSessionRuntimeSummary { diff --git a/packages/runtime-host/src/__tests__/artifact-coordinator.test.ts b/packages/runtime-host/src/__tests__/artifact-coordinator.test.ts index 766012d4a7..adf55cf144 100644 --- a/packages/runtime-host/src/__tests__/artifact-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/artifact-coordinator.test.ts @@ -7,6 +7,7 @@ import { openInteractiveArtifactStoreForWrite } from '@maka/storage/artifact-sto import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority'; import { HostArtifactCoordinator } from '../server/artifact-coordinator.js'; import type { ConnectionContext } from '../server/operation-dispatcher.js'; +import { SessionAdmissionGate } from '../server/session-admission-gate.js'; const connectionContext: ConnectionContext = { hostEpoch: 'host-epoch-1', @@ -38,9 +39,13 @@ test('Artifact mutation failure requests Host drain and fails closed', async () await rm(metadataPath); await mkdir(metadataPath); let drainRequests = 0; - const coordinator = new HostArtifactCoordinator(store, () => { - drainRequests += 1; - }); + const coordinator = new HostArtifactCoordinator( + store, + () => { + drainRequests += 1; + }, + new SessionAdmissionGate(), + ); assert.deepEqual( await coordinator.handlers['artifact.delete']( diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 74ffdb7d63..58300b56ae 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -69,11 +69,13 @@ describe('Runtime Host bootstrap protocol', () => { 'queue.retract', 'runtime.policy.mutate', 'runtime.policy.query', + 'session.branch.create', 'session.catalog.query', 'session.configuration.update', 'session.create', 'session.metadata.update', 'session.read_marker.set', + 'session.revision.create', 'skill.catalog.mutate', 'skill.catalog.preview-update', 'skill.catalog.query', 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 e6e4c4c0e8..f88d12b4f2 100644 --- a/packages/runtime-host/src/__tests__/session-admission-gate.test.ts +++ b/packages/runtime-host/src/__tests__/session-admission-gate.test.ts @@ -41,6 +41,35 @@ test('does not serialize operations for different Sessions', async () => { await first; }); +test('serializes overlapping multi-Session admissions without lock-order deadlocks', async () => { + const gate = new SessionAdmissionGate(); + const entered = deferred(); + const release = deferred(); + const order: string[] = []; + + const first = gate.runMany(['second', 'first'], async (lease) => { + order.push('first:start'); + assert.equal(await gate.runAdmitted('first', lease, () => 'first-owned'), 'first-owned'); + assert.equal(await gate.runAdmitted('second', lease, () => 'second-owned'), 'second-owned'); + entered.resolve(); + await release.promise; + order.push('first:end'); + }); + await entered.promise; + const second = gate.runMany(['first', 'second'], () => { + order.push('second'); + }); + const independent = gate.run('third', () => { + order.push('third'); + }); + + await independent; + assert.deepEqual(order, ['first:start', 'third']); + release.resolve(); + await Promise.all([first, second]); + assert.deepEqual(order, ['first:start', 'third', 'first:end', 'second']); +}); + test('keeps the admission open until admitted child work settles', async () => { const gate = new SessionAdmissionGate(); const childEntered = deferred(); diff --git a/packages/runtime-host/src/__tests__/session-revision-protocol.test.ts b/packages/runtime-host/src/__tests__/session-revision-protocol.test.ts new file mode 100644 index 0000000000..f01ee95590 --- /dev/null +++ b/packages/runtime-host/src/__tests__/session-revision-protocol.test.ts @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { + decodeClientFrame, + decodeHostFrame, + HOST_OPERATION_SPECS, + RuntimeHostProtocolError, + type SessionCatalogProjection, +} from '../protocol/index.js'; + +describe('Session revision protocol', () => { + test('declares exact ready-only branch and revision commands', () => { + for (const operation of ['session.branch.create', 'session.revision.create'] as const) { + assert.equal(HOST_OPERATION_SPECS[operation].mode, 'command'); + assert.equal(HOST_OPERATION_SPECS[operation].availability, 'ready'); + assert.deepEqual( + decodeClientFrame({ + requestId: 'request-1', + operation, + input: { + sourceSessionId: 'source-session', + targetSessionId: 'target-session', + sourceTurnId: 'turn-1', + expectedSourceRevision: 3, + }, + }), + { + requestId: 'request-1', + operation, + input: { + sourceSessionId: 'source-session', + targetSessionId: 'target-session', + sourceTurnId: 'turn-1', + expectedSourceRevision: 3, + }, + }, + ); + } + }); + + test('rejects aliasing, unknown fields, and mismatched response identities', () => { + assert.throws( + () => + decodeClientFrame({ + requestId: 'request-1', + operation: 'session.branch.create', + input: { + sourceSessionId: 'same-session', + targetSessionId: 'same-session', + sourceTurnId: 'turn-1', + expectedSourceRevision: 1, + }, + }), + isInvalidFrame, + ); + assert.throws( + () => + decodeClientFrame({ + requestId: 'request-1', + operation: 'session.revision.create', + input: { + sourceSessionId: 'source-session', + targetSessionId: 'target-session', + sourceTurnId: 'turn-1', + expectedSourceRevision: 1, + ignored: true, + }, + }), + isInvalidFrame, + ); + const input = { + sourceSessionId: 'source-session', + targetSessionId: 'target-session', + sourceTurnId: 'turn-1', + expectedSourceRevision: 1, + }; + assert.throws( + () => + HOST_OPERATION_SPECS['session.branch.create'].assertOutputForInput?.(input, { + kind: 'committed', + session: sessionProjection('wrong-target'), + }), + isInvalidFrame, + ); + }); + + test('preserves optimistic source revision conflicts on the wire', () => { + assert.deepEqual( + decodeHostFrame({ + requestId: 'request-1', + operation: 'session.revision.create', + ok: true, + result: { + kind: 'source_revision_conflict', + expectedRevision: 4, + actualRevision: 5, + }, + }), + { + requestId: 'request-1', + operation: 'session.revision.create', + ok: true, + result: { + kind: 'source_revision_conflict', + expectedRevision: 4, + actualRevision: 5, + }, + }, + ); + }); +}); + +function sessionProjection(id: string): SessionCatalogProjection { + return { + id, + revision: 1, + cwd: '/workspace', + createdAt: 1, + lastUsedAt: 1, + name: 'Session', + isFlagged: false, + isArchived: false, + labels: [], + labelsTruncated: false, + hasUnread: false, + status: 'active', + backend: 'fake', + llmConnectionSlug: 'fake', + connectionLocked: false, + model: 'fake-model', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + }; +} + +function isInvalidFrame(error: unknown): boolean { + return error instanceof RuntimeHostProtocolError && error.code === 'invalid_frame'; +} 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 new file mode 100644 index 0000000000..bf1e348103 --- /dev/null +++ b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts @@ -0,0 +1,1085 @@ +import assert from 'node:assert/strict'; +import { fork, type ChildProcess } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdtemp, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import type { AgentRunHeader, RuntimeEvent } from '@maka/core'; +import { FAKE_ASK_USER_QUESTION_PROMPT } from '@maka/runtime'; +import { openInteractiveArtifactStoreForWrite } from '@maka/storage/artifact-stores'; +import { openInteractiveExecutionStoresForWrite } from '@maka/storage/execution-stores'; +import { + resolveRootControlNamespace, + resolveStorageRoot, + tryAcquireInteractiveRootOwner, + type StorageRootCapability, +} from '@maka/storage/root-authority'; +import { openInteractiveTaskLedgerStoreForWrite } from '@maka/storage/task-ledger-authority'; +import { + connectRuntimeHost, + RuntimeHostOperationError, + type RuntimeHostConnection, +} from '../client/index.js'; +import { + RUNTIME_HOST_PROTOCOL_VERSION, + type SessionCatalogItem, + type SessionCatalogProjection, +} from '../protocol/index.js'; + +const CURRENT_PROTOCOL = { + min: RUNTIME_HOST_PROTOCOL_VERSION, + max: RUNTIME_HOST_PROTOCOL_VERSION, +} as const; +const PROCESS_TIMEOUT_MS = 10_000; +const REVISION_TARGET_ID = 'revision-target'; +const ADMITTED_REVISION_TARGET_ID = 'admitted-revision-target'; +const LINEAGE_REVISION_TARGET_ID = 'lineage-revision-target'; +const LINEAGE_BRANCH_TARGET_ID = 'lineage-branch-target'; + +test('two UDS Clients share exact retryable Session branch and revision authority', { + skip: process.platform === 'win32' ? 'POSIX UDS integration' : false, + timeout: 120_000, +}, async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-session-revision-')); + const root = join(base, 'root'); + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const { + sourceSessionId, + busySessionId, + linkedChildSourceSessionId, + metadataLinkedSourceSessionId, + archivedOwnedSourceSessionId, + } = await seedSource(root, capability); + let host: ExecutionHostHandle | undefined; + try { + host = await startHost(root, capability.rootId); + await verifyConcurrentRevisionAuthority( + root, + sourceSessionId, + busySessionId, + linkedChildSourceSessionId, + metadataLinkedSourceSessionId, + archivedOwnedSourceSessionId, + ); + await stopHost(host); + host = undefined; + + host = await startHost(root, capability.rootId); + await verifyRestartRecoveryAndAdmission(root, sourceSessionId); + await stopHost(host); + host = undefined; + + host = await startHost(root, capability.rootId); + await verifySecondRestartRetention(root); + await stopHost(host); + host = undefined; + + await verifyDurableBranch( + capability, + sourceSessionId, + 'branch-target', + ADMITTED_REVISION_TARGET_ID, + LINEAGE_REVISION_TARGET_ID, + LINEAGE_BRANCH_TARGET_ID, + ); + } finally { + await terminateHost(host); + await rm(join(resolveRootControlNamespace(), capability.rootId), { + recursive: true, + force: true, + }); + await removePosixEndpointDirectories(capability.rootId); + await rm(base, { recursive: true, force: true }); + } +}); + +async function verifyConcurrentRevisionAuthority( + root: string, + sourceSessionId: string, + busySessionId: string, + linkedChildSourceSessionId: string, + metadataLinkedSourceSessionId: string, + archivedOwnedSourceSessionId: string, +): Promise { + const desktop = await connectClient(root, 'desktop'); + const tui = await connectClient(root, 'tui'); + try { + const source = await querySession(desktop, sourceSessionId); + const linkedChildSource = await querySession(desktop, linkedChildSourceSessionId); + await assert.rejects( + desktop.request('session.branch.create', { + sourceSessionId: linkedChildSourceSessionId, + targetSessionId: 'linked-child-copy-target', + sourceTurnId: 'linked-turn', + expectedSourceRevision: linkedChildSource.revision, + }), + operationError('operation_unavailable'), + ); + assert.deepEqual( + await tui.request('session.catalog.query', { + kind: 'get', + sessionId: 'linked-child-copy-target', + }), + { kind: 'session', session: null }, + ); + const metadataLinkedSource = await querySession(desktop, metadataLinkedSourceSessionId); + await assert.rejects( + desktop.request('session.branch.create', { + sourceSessionId: metadataLinkedSourceSessionId, + targetSessionId: 'metadata-linked-copy-target', + sourceTurnId: 'metadata-linked-turn', + expectedSourceRevision: metadataLinkedSource.revision, + }), + operationError('operation_unavailable'), + ); + const archivedOwnedSource = await querySession(desktop, archivedOwnedSourceSessionId); + await assert.rejects( + desktop.request('session.branch.create', { + sourceSessionId: archivedOwnedSourceSessionId, + targetSessionId: 'archived-owned-copy-target', + sourceTurnId: 'archived-owned-turn', + expectedSourceRevision: archivedOwnedSource.revision, + }), + operationError('operation_unavailable'), + ); + for (const sessionId of ['metadata-linked-copy-target', 'archived-owned-copy-target']) { + assert.deepEqual( + await tui.request('session.catalog.query', { + kind: 'get', + sessionId, + }), + { kind: 'session', session: null }, + ); + } + const branchInput = { + sourceSessionId, + targetSessionId: 'branch-target', + sourceTurnId: 'turn-1', + expectedSourceRevision: source.revision, + }; + const [desktopBranch, tuiBranch] = await Promise.all([ + desktop.request('session.branch.create', branchInput), + tui.request('session.branch.create', branchInput), + ]); + assert.deepEqual(desktopBranch, tuiBranch); + assert.equal(desktopBranch.kind, 'committed'); + if (desktopBranch.kind !== 'committed') assert.fail('Branch must commit'); + const branch = requireSessionProjection(desktopBranch.session); + assert.equal(branch.parentSessionId, sourceSessionId); + assert.equal(branch.branchOfTurnId, 'turn-1'); + assert.equal(branch.isFlagged, true); + assert.equal(branch.connectionLocked, true); + + const artifactPage = await desktop.request('artifact.query', { + kind: 'list_start', + sessionId: branch.id, + }); + assert.equal(artifactPage.kind, 'page'); + if (artifactPage.kind !== 'page') assert.fail('Branch Artifact query must return a page'); + assert.equal(artifactPage.artifacts.length, 2); + assert.notEqual(artifactPage.artifacts[0]?.id, 'source-artifact'); + const taskPage = await tui.request('task.ledger.query', { + kind: 'list_start', + sessionId: branch.id, + }); + assert.equal(taskPage.kind, 'page'); + if (taskPage.kind !== 'page') assert.fail('Branch Task Ledger query must return a page'); + assert.deepEqual(taskPage.tasks.map((task) => task.subject).sort(), [ + 'Legacy child task', + 'Retained task', + ]); + + const renamed = await desktop.request('session.metadata.update', { + sessionId: sourceSessionId, + expectedRevision: source.revision, + patch: { name: 'Renamed source' }, + }); + assert.equal(renamed.kind, 'committed'); + if (renamed.kind !== 'committed') assert.fail('Source rename must commit'); + const renamedSource = requireSessionProjection(renamed.session); + assert.deepEqual(await tui.request('session.branch.create', branchInput), desktopBranch); + + const revisionInput = { + sourceSessionId, + targetSessionId: REVISION_TARGET_ID, + sourceTurnId: 'turn-2', + expectedSourceRevision: renamedSource.revision, + }; + const revised = await desktop.request('session.revision.create', revisionInput); + assert.equal(revised.kind, 'committed'); + if (revised.kind !== 'committed') assert.fail('Revision must commit'); + const revision = requireSessionProjection(revised.session); + assert.equal(revision.revisionRootSessionId, sourceSessionId); + assert.equal(revision.revisionParentSessionId, sourceSessionId); + assert.equal(revision.revisionOfTurnId, 'turn-2'); + assert.equal(revision.revisionIndex, 2); + assert.equal(revision.revisionState, 'preparing'); + + const stale = await tui.request('session.revision.create', { + ...revisionInput, + targetSessionId: 'stale-revision-target', + expectedSourceRevision: renamedSource.revision + 1, + }); + assert.deepEqual(stale, { + kind: 'source_revision_conflict', + expectedRevision: renamedSource.revision + 1, + actualRevision: renamedSource.revision, + }); + await assert.rejects( + desktop.request('session.branch.create', { + ...branchInput, + sourceTurnId: 'turn-2', + }), + operationError('operation_conflict'), + ); + + await desktop.startTurn({ + sessionId: busySessionId, + turnId: 'busy-turn', + content: { text: FAKE_ASK_USER_QUESTION_PROMPT }, + }); + const busy = await querySession(desktop, busySessionId); + await assert.rejects( + tui.request('session.branch.create', { + sourceSessionId: busySessionId, + targetSessionId: 'busy-source-copy', + sourceTurnId: 'busy-turn', + expectedSourceRevision: busy.revision, + }), + operationError('session_busy'), + ); + } finally { + await Promise.allSettled([desktop.close(), tui.close()]); + } +} + +async function verifyRestartRecoveryAndAdmission( + root: string, + sourceSessionId: string, +): Promise { + const restarted = await connectClient(root, 'desktop'); + try { + assert.deepEqual( + await restarted.request('session.catalog.query', { + kind: 'get', + sessionId: REVISION_TARGET_ID, + }), + { kind: 'session', session: null }, + ); + const source = await querySession(restarted, sourceSessionId); + const retried = await restarted.request('session.revision.create', { + sourceSessionId, + targetSessionId: REVISION_TARGET_ID, + sourceTurnId: 'turn-2', + expectedSourceRevision: source.revision, + }); + assert.equal(retried.kind, 'committed'); + if (retried.kind !== 'committed') assert.fail('Recovered revision retry must commit'); + assert.equal(requireSessionProjection(retried.session).revisionIndex, 2); + + const admitted = await restarted.request('session.revision.create', { + sourceSessionId, + targetSessionId: ADMITTED_REVISION_TARGET_ID, + sourceTurnId: 'turn-2', + expectedSourceRevision: source.revision, + }); + assert.equal(admitted.kind, 'committed'); + if (admitted.kind !== 'committed') assert.fail('Admitted revision must commit'); + assert.equal(requireSessionProjection(admitted.session).revisionIndex, 3); + await restarted.startTurn({ + sessionId: ADMITTED_REVISION_TARGET_ID, + turnId: 'turn-3', + content: { text: 'commit this revision' }, + }); + assert.equal( + (await querySession(restarted, ADMITTED_REVISION_TARGET_ID)).revisionState, + 'committed', + ); + + const lineageRevision = await restarted.request('session.revision.create', { + sourceSessionId, + targetSessionId: LINEAGE_REVISION_TARGET_ID, + sourceTurnId: 'turn-2', + expectedSourceRevision: source.revision, + }); + assert.equal(lineageRevision.kind, 'committed'); + if (lineageRevision.kind !== 'committed') assert.fail('Lineage revision must commit'); + const lineageSource = requireSessionProjection(lineageRevision.session); + assert.equal(lineageSource.revisionState, 'preparing'); + const lineageBranch = await restarted.request('session.branch.create', { + sourceSessionId: LINEAGE_REVISION_TARGET_ID, + targetSessionId: LINEAGE_BRANCH_TARGET_ID, + sourceTurnId: 'turn-1', + expectedSourceRevision: lineageSource.revision, + }); + assert.equal(lineageBranch.kind, 'committed'); + } finally { + await restarted.close(); + } +} + +async function verifySecondRestartRetention(root: string): Promise { + const recovered = await connectClient(root, 'desktop'); + try { + assert.deepEqual( + await recovered.request('session.catalog.query', { + kind: 'get', + sessionId: REVISION_TARGET_ID, + }), + { kind: 'session', session: null }, + ); + assert.equal( + (await querySession(recovered, ADMITTED_REVISION_TARGET_ID)).revisionState, + 'committed', + ); + assert.equal( + (await querySession(recovered, LINEAGE_REVISION_TARGET_ID)).revisionState, + 'committed', + ); + assert.equal( + (await querySession(recovered, LINEAGE_BRANCH_TARGET_ID)).parentSessionId, + LINEAGE_REVISION_TARGET_ID, + ); + } finally { + await recovered.close(); + } +} + +async function seedSource( + root: string, + capability: StorageRootCapability<'interactive'>, +): Promise<{ + sourceSessionId: string; + busySessionId: string; + linkedChildSourceSessionId: string; + metadataLinkedSourceSessionId: string; + archivedOwnedSourceSessionId: string; +}> { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) throw new Error('Unable to acquire execution root for Session setup'); + try { + const execution = await openInteractiveExecutionStoresForWrite(owner.lease); + const artifacts = await openInteractiveArtifactStoreForWrite(owner.lease); + const tasks = await openInteractiveTaskLedgerStoreForWrite(owner.lease); + await artifacts.recover(); + const source = await execution.sessionStore.create({ + cwd: root, + name: 'Source Session', + backend: 'fake', + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + }); + const busy = await execution.sessionStore.create({ + cwd: root, + name: 'Busy Session', + backend: 'fake', + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + }); + const linkedChildSource = await execution.sessionStore.create({ + cwd: root, + name: 'Linked Child Source Session', + backend: 'fake', + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + }); + const metadataLinkedSource = await execution.sessionStore.create({ + cwd: root, + name: 'Metadata-linked Source Session', + backend: 'fake', + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + }); + const archivedOwnedSource = await execution.sessionStore.create({ + cwd: root, + name: 'Archived-owned Source Session', + backend: 'fake', + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + }); + const artifact = await artifacts.create({ + id: 'source-artifact', + sessionId: source.id, + turnId: 'turn-1', + name: 'source.txt', + kind: 'file', + content: 'retained bytes', + mimeType: 'text/plain', + source: 'user_upload', + now: 1, + }); + await artifacts.create({ + id: 'legacy-child-artifact', + sessionId: source.id, + turnId: 'legacy-child-turn', + name: 'legacy-child.txt', + kind: 'file', + content: 'legacy child bytes', + mimeType: 'text/plain', + source: 'tool_result', + now: 2, + }); + await execution.sessionStore.appendMessages(source.id, [ + { + type: 'user', + id: 'user-1', + turnId: 'turn-1', + ts: 1, + text: 'first', + attachments: [ + { + kind: 'code', + name: 'source.txt', + mimeType: 'text/plain', + bytes: 14, + ref: { + kind: 'session_file', + sessionId: source.id, + relativePath: artifact.relativePath, + }, + }, + ], + }, + { + type: 'assistant', + id: 'assistant-1', + turnId: 'turn-1', + ts: 2, + text: 'first response', + modelId: 'fake-model', + }, + { type: 'user', id: 'user-2', turnId: 'turn-2', ts: 3, text: 'second' }, + { + type: 'assistant', + id: 'assistant-2', + turnId: 'turn-2', + ts: 4, + text: 'second response', + modelId: 'fake-model', + }, + ]); + await execution.sessionStore.updateHeader(source.id, { + isFlagged: true, + titleIsManual: true, + }); + 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( + root, + source.id, + 'legacy-child-run', + 'legacy-child-invocation', + 'legacy-child-turn', + ), + parentRunId: 'run-turn-1', + }, + ]; + for (const run of sourceRuns) await execution.agentRunStore.createRun(run); + const sourceRuntimeEvents = [ + runtimeEvent(source.id, 'run-turn-1', 'invocation-turn-1', 'turn-1', { + id: 'user-1', + ts: 1, + role: 'user', + author: 'user', + content: { + kind: 'text', + text: 'first', + attachments: [ + { + kind: 'code', + name: 'source.txt', + mimeType: 'text/plain', + bytes: 14, + ref: { + kind: 'session_file', + sessionId: source.id, + relativePath: artifact.relativePath, + }, + }, + ], + }, + }), + runtimeEvent(source.id, 'run-turn-1', 'invocation-turn-1', 'turn-1', { + id: 'assistant-1', + ts: 2, + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'first response' }, + }), + runtimeEvent(source.id, 'run-turn-1', 'invocation-turn-1', 'turn-1', { + id: 'terminal-1', + ts: 2.5, + role: 'system', + author: 'system', + status: 'completed', + }), + runtimeEvent(source.id, 'run-turn-2', 'invocation-turn-2', 'turn-2', { + id: 'user-2', + ts: 3, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'second' }, + }), + runtimeEvent(source.id, 'run-turn-2', 'invocation-turn-2', 'turn-2', { + id: 'assistant-2', + ts: 4, + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'second response' }, + }), + runtimeEvent(source.id, 'run-turn-2', 'invocation-turn-2', 'turn-2', { + id: 'terminal-2', + ts: 4.5, + role: 'system', + author: 'system', + status: 'completed', + }), + runtimeEvent(source.id, 'legacy-child-run', 'legacy-child-invocation', 'legacy-child-turn', { + id: 'legacy-child-output', + ts: 2.1, + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'legacy child output' }, + }), + runtimeEvent(source.id, 'legacy-child-run', 'legacy-child-invocation', 'legacy-child-turn', { + id: 'legacy-child-terminal', + ts: 2.2, + role: 'system', + author: 'system', + status: 'completed', + }), + ]; + for (const event of sourceRuntimeEvents) { + await execution.runtimeEventStore.appendRuntimeEvent(event.sessionId, event.runId, event); + } + await execution.sessionStore.appendMessages(linkedChildSource.id, [ + { + type: 'user', + id: 'linked-user', + turnId: 'linked-turn', + ts: 1, + text: 'delegate this', + }, + { + type: 'tool_result', + id: 'linked-result', + turnId: 'linked-turn', + ts: 2, + toolUseId: 'linked-call', + isError: false, + content: { + kind: 'subagent', + childSessionId: 'linked-child-session', + agentName: 'worker', + turnId: 'linked-child-turn', + status: 'completed', + permissionMode: 'ask', + summary: 'done', + artifactIds: [], + }, + }, + ]); + await execution.sessionStore.appendMessage(metadataLinkedSource.id, { + type: 'user', + id: 'metadata-linked-user', + turnId: 'metadata-linked-turn', + ts: 1, + text: 'delegate without a committed result', + }); + await execution.sessionStore.createSubagent({ + cwd: root, + name: 'Metadata-linked Child Session', + backend: 'fake', + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + subagentParent: { + kind: 'subagent', + parentSessionId: metadataLinkedSource.id, + spawnedBy: { + parentRunId: 'metadata-parent-run', + parentTurnId: 'metadata-linked-turn', + toolCallId: 'metadata-tool-call', + }, + lifecycle: 'foreground', + }, + subagentRuntime: { + schemaVersion: 1, + definitionVersion: 1, + agentId: 'worker', + agentName: 'Worker', + profile: 'default', + systemPrompt: 'Complete the delegated task.', + toolNames: [], + categoryPolicy: {}, + }, + subagentSpawn: { + schemaVersion: 1, + requestFingerprint: 'a'.repeat(64), + initialTurnId: 'metadata-child-turn', + initialRunId: 'metadata-child-run', + }, + }); + const archivedBody = JSON.stringify({ + kind: 'subagent', + agentName: 'Worker', + turnId: 'archived-owned-child-turn', + runId: 'archived-owned-child-run', + status: 'completed', + permissionMode: 'ask', + summary: 'done', + artifactIds: [], + }); + const archivedBodySha256 = createHash('sha256').update(archivedBody).digest('hex'); + await artifacts.create({ + id: 'archived-owned-result', + sessionId: archivedOwnedSource.id, + turnId: 'archived-owned-child-turn', + name: 'archived-owned-result.json', + kind: 'file', + content: archivedBody, + mimeType: 'application/json', + source: 'tool_result_archive', + now: 1, + }); + await execution.sessionStore.appendMessages(archivedOwnedSource.id, [ + { + type: 'user', + id: 'archived-owned-user', + turnId: 'archived-owned-turn', + ts: 1, + text: 'reuse the archived result', + }, + ]); + const archivedOwnedRuns = [ + agentRunHeader( + root, + archivedOwnedSource.id, + 'archived-owned-parent-run', + 'archived-owned-parent-invocation', + 'archived-owned-turn', + ), + { + ...agentRunHeader( + root, + archivedOwnedSource.id, + 'archived-owned-child-run', + 'archived-owned-child-invocation', + 'archived-owned-child-turn', + ), + parentRunId: 'archived-owned-parent-run', + }, + ]; + for (const run of archivedOwnedRuns) await execution.agentRunStore.createRun(run); + const archivedOwnedRuntimeEvents = [ + runtimeEvent( + archivedOwnedSource.id, + 'archived-owned-parent-run', + 'archived-owned-parent-invocation', + 'archived-owned-turn', + { + id: 'archived-owned-parent-user', + role: 'user', + author: 'user', + content: { kind: 'text', text: 'reuse the archived result' }, + }, + ), + runtimeEvent( + archivedOwnedSource.id, + 'archived-owned-parent-run', + 'archived-owned-parent-invocation', + 'archived-owned-turn', + { + id: 'archived-owned-parent-terminal', + ts: 3, + status: 'completed', + }, + ), + runtimeEvent( + archivedOwnedSource.id, + 'archived-owned-child-run', + 'archived-owned-child-invocation', + 'archived-owned-child-turn', + { + id: 'archived-owned-child-call', + ts: 1.5, + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'archived-owned-tool-call', + name: 'subagent', + args: { task: 'summarize' }, + }, + }, + ), + runtimeEvent( + archivedOwnedSource.id, + 'archived-owned-child-run', + 'archived-owned-child-invocation', + 'archived-owned-child-turn', + { + id: 'archived-owned-child-result', + ts: 2, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'archived-owned-tool-call', + name: 'subagent', + isError: false, + result: { + kind: 'maka.archived_tool_result', + rewriteVersion: 1, + artifactId: 'archived-owned-result', + runtimeEventId: 'archived-owned-child-result', + toolCallId: 'archived-owned-tool-call', + toolName: 'subagent', + bodySha256: archivedBodySha256, + originalEstimatedTokens: 20, + originalBytes: Buffer.byteLength(archivedBody, 'utf8'), + reason: 'stale_tool_result_pruned_before_compact', + }, + }, + }, + ), + runtimeEvent( + archivedOwnedSource.id, + 'archived-owned-child-run', + 'archived-owned-child-invocation', + 'archived-owned-child-turn', + { + id: 'archived-owned-child-terminal', + ts: 2.5, + status: 'completed', + }, + ), + ]; + for (const event of archivedOwnedRuntimeEvents) { + await execution.runtimeEventStore.appendRuntimeEvent(event.sessionId, event.runId, event); + } + await tasks.create(source.id, [{ subject: 'Retained task' }], { + turnId: 'turn-1', + source: 'tool', + actor: 'main_agent', + }); + await tasks.create(source.id, [{ subject: 'Legacy child task' }], { + turnId: 'legacy-child-turn', + runId: 'legacy-child-run', + source: 'tool', + actor: 'child_agent', + }); + await tasks.update( + source.id, + (await tasks.list(source.id))[0]!.id, + { status: 'in_progress' }, + { + turnId: 'turn-2', + source: 'tool', + actor: 'main_agent', + }, + ); + return { + sourceSessionId: source.id, + busySessionId: busy.id, + linkedChildSourceSessionId: linkedChildSource.id, + metadataLinkedSourceSessionId: metadataLinkedSource.id, + archivedOwnedSourceSessionId: archivedOwnedSource.id, + }; + } finally { + await owner.close(); + } +} + +async function verifyDurableBranch( + capability: StorageRootCapability<'interactive'>, + sourceSessionId: string, + branchSessionId: string, + admittedRevisionTargetId: string, + lineageRevisionTargetId: string, + lineageBranchTargetId: string, +): Promise { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) throw new Error('Unable to reacquire execution root'); + try { + const execution = await openInteractiveExecutionStoresForWrite(owner.lease); + const artifacts = await openInteractiveArtifactStoreForWrite(owner.lease); + const tasks = await openInteractiveTaskLedgerStoreForWrite(owner.lease); + await artifacts.recover(); + const messages = await execution.sessionStore.readMessagesSnapshot(branchSessionId); + assert.equal(messages.length, 3); + const user = messages.find((message) => message.type === 'user'); + assert.ok(user?.attachments?.[0]); + const ref = user?.attachments?.[0]?.ref; + assert.equal(ref?.kind, 'session_file'); + if (ref?.kind !== 'session_file') assert.fail('Copied attachment must remain session-backed'); + assert.equal(ref.sessionId, branchSessionId); + assert.notEqual(ref.relativePath, `${sourceSessionId}/source-artifact-source.txt`); + assert.equal((await artifacts.listPage(branchSessionId, { offset: 0, limit: 10 })).total, 2); + assert.deepEqual((await tasks.list(branchSessionId)).map((task) => task.subject).sort(), [ + 'Legacy child task', + 'Retained task', + ]); + assert.ok((await tasks.list(branchSessionId)).every((task) => task.status === 'pending')); + const copiedRuns = await execution.agentRunStore.listSessionRuns(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((await artifacts.listPage('revision-target', { offset: 0, limit: 10 })).total, 0); + assert.deepEqual(await tasks.list('revision-target'), []); + assert.deepEqual(await execution.agentRunStore.listSessionRuns('revision-target'), []); + await assert.rejects( + () => execution.sessionStore.readHeaderSnapshot('revision-target'), + /not found/i, + ); + assert.equal( + (await execution.sessionStore.readHeaderSnapshot(admittedRevisionTargetId)).revisionState, + 'committed', + ); + assert.equal( + (await execution.sessionStore.readHeaderSnapshot(lineageRevisionTargetId)).revisionState, + 'committed', + ); + assert.equal( + (await execution.sessionStore.readHeaderSnapshot(lineageBranchTargetId)).parentSessionId, + lineageRevisionTargetId, + ); + } finally { + await owner.close(); + } +} + +async function querySession( + connection: RuntimeHostConnection, + sessionId: string, +): Promise { + const result = await connection.request('session.catalog.query', { + kind: 'get', + sessionId, + }); + assert.equal(result.kind, 'session'); + if (result.kind !== 'session' || !result.session) { + assert.fail('Session catalog get must return the Session'); + } + return requireSessionProjection(result.session); +} + +function requireSessionProjection(item: SessionCatalogItem): SessionCatalogProjection { + if ('kind' in item) assert.fail(`Expected a representable Session, received ${item.kind}`); + return item; +} + +interface ExecutionHostHandle { + readonly child: ChildProcess; + readonly hostEpoch: string; + readonly endpoint: string; +} + +async function startHost(root: string, rootId: string): Promise { + const child = fork( + new URL('./fixtures/execution-host.js', import.meta.url), + [root, rootId, '60000'], + { stdio: ['ignore', 'ignore', 'inherit', 'ipc'] }, + ); + try { + return { child, ...(await waitForHostReady(child)) }; + } catch (error) { + await terminateChild(child); + throw error; + } +} + +async function stopHost(host: ExecutionHostHandle): Promise { + if (host.child.exitCode === null && host.child.signalCode === null) { + host.child.kill('SIGTERM'); + } + const exit = await withTimeout( + waitForExit(host.child), + PROCESS_TIMEOUT_MS, + 'execution Host did not stop', + ); + assert.deepEqual(exit, { code: 0, signal: null }); +} + +async function terminateHost(host: ExecutionHostHandle | undefined): Promise { + if (host) await terminateChild(host.child); +} + +async function terminateChild(child: ChildProcess): Promise { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + await withTimeout(waitForExit(child), PROCESS_TIMEOUT_MS, 'execution Host did not exit').then( + () => undefined, + () => undefined, + ); +} + +async function connectClient( + rootPath: string, + surface: 'desktop' | 'tui', +): Promise { + const result = await connectRuntimeHost({ + rootPath, + surface, + protocol: CURRENT_PROTOCOL, + }); + assert.equal(result.kind, 'connected'); + if (result.kind !== 'connected') throw new Error('Runtime Host did not accept the Client'); + return result.connection; +} + +function waitForHostReady(child: ChildProcess): Promise<{ + hostEpoch: string; + endpoint: string; +}> { + return withTimeout( + new Promise((resolve, reject) => { + const cleanup = () => { + child.off('error', onError); + child.off('exit', onExit); + child.off('message', onMessage); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + cleanup(); + reject(new Error(`execution Host exited before readiness: ${code ?? signal}`)); + }; + const onMessage = (message: unknown) => { + if (!isHostReadyMessage(message)) return; + cleanup(); + resolve({ hostEpoch: message.hostEpoch, endpoint: message.endpoint }); + }; + child.once('error', onError); + child.once('exit', onExit); + child.on('message', onMessage); + }), + PROCESS_TIMEOUT_MS, + 'execution Host did not become ready', + ); +} + +function isHostReadyMessage( + value: unknown, +): value is { type: 'ready'; hostEpoch: string; endpoint: string } { + if (!value || typeof value !== 'object') return false; + const message = value as Record; + return ( + message.type === 'ready' && + typeof message.hostEpoch === 'string' && + typeof message.endpoint === 'string' + ); +} + +function waitForExit( + child: ChildProcess, +): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { + if (child.exitCode !== null || child.signalCode !== null) { + return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); + } + return new Promise((resolve, reject) => { + const cleanup = () => { + child.off('error', onError); + child.off('exit', onExit); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + cleanup(); + resolve({ code, signal }); + }; + child.once('error', onError); + child.once('exit', onExit); + }); +} + +async function removePosixEndpointDirectories(rootId: string): Promise { + if (process.platform === 'win32' || typeof process.getuid !== 'function') return; + const prefix = `m-${process.getuid()}-${Buffer.from(rootId, 'hex').toString('base64url')}-`; + const entries = await readdir('/tmp', { withFileTypes: true }); + await Promise.all( + entries.map(async (entry) => { + if (entry.isDirectory() && entry.name.startsWith(prefix)) { + await rm(join('/tmp', entry.name), { recursive: true, force: true }); + } + }), + ); +} + +function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + let timer: NodeJS.Timeout | undefined; + return Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(message)), timeoutMs); + }), + ]).finally(() => { + if (timer) clearTimeout(timer); + }); +} + +function operationError(code: RuntimeHostOperationError['code']) { + return (error: unknown): boolean => + error instanceof RuntimeHostOperationError && error.code === code; +} + +function agentRunHeader( + cwd: string, + sessionId: string, + runId: string, + invocationId: string, + turnId: string, +): AgentRunHeader { + return { + runId, + invocationId, + sessionId, + turnId, + status: 'completed', + backendKind: 'fake', + llmConnectionSlug: 'fake', + modelId: 'fake-model', + cwd, + permissionMode: 'ask', + createdAt: 1, + updatedAt: 5, + completedAt: 5, + }; +} + +function runtimeEvent( + sessionId: string, + runId: string, + invocationId: string, + turnId: string, + overrides: Partial, +): RuntimeEvent { + return { + id: 'event', + invocationId, + runId, + sessionId, + turnId, + ts: 1, + partial: false, + role: 'system', + author: 'system', + ...overrides, + }; +} diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts index d8033231b3..58be86985d 100644 --- a/packages/runtime-host/src/protocol/operations.ts +++ b/packages/runtime-host/src/protocol/operations.ts @@ -16,6 +16,7 @@ import { import { RUNTIME_POLICY_OPERATION_SPECS } from './runtime-policy.js'; import { SESSION_CATALOG_OPERATION_SPECS } from './session-catalog.js'; import { SESSION_CONTINUITY_OPERATION_SPECS } from './session-continuity.js'; +import { SESSION_REVISION_OPERATION_SPECS } from './session-revision.js'; import { SKILL_CATALOG_OPERATION_SPECS } from './skill-catalog.js'; import { TASK_LEDGER_OPERATION_SPECS } from './task-ledger.js'; import { TURN_OPERATION_SPECS } from './turn.js'; @@ -86,6 +87,7 @@ export * from './client-capability.js'; export * from './memory.js'; export * from './runtime-policy.js'; export * from './session-catalog.js'; +export * from './session-revision.js'; export * from './skill-catalog.js'; export * from './usage-pricing.js'; @@ -99,6 +101,7 @@ export const HOST_OPERATION_SPECS = composeOperationSpecMaps( INTERACTION_OPERATION_SPECS, SESSION_CONTINUITY_OPERATION_SPECS, SESSION_CATALOG_OPERATION_SPECS, + SESSION_REVISION_OPERATION_SPECS, ARTIFACT_OPERATION_SPECS, SKILL_CATALOG_OPERATION_SPECS, USAGE_PRICING_OPERATION_SPECS, diff --git a/packages/runtime-host/src/protocol/session-revision.ts b/packages/runtime-host/src/protocol/session-revision.ts new file mode 100644 index 0000000000..3732c028b3 --- /dev/null +++ b/packages/runtime-host/src/protocol/session-revision.ts @@ -0,0 +1,133 @@ +import { requireCount, requireEntityId, requireExactRecord, requireRecord } from './codec.js'; +import { invalidProtocolFrame } from './errors.js'; +import { defineOperation } from './operation-spec.js'; +import { decodeSessionCatalogItem, type SessionCatalogItem } from './session-catalog.js'; + +const SESSION_COPY_ERRORS = [ + 'host_not_ready', + 'host_draining', + 'operation_unavailable', + 'invalid_request', + 'not_found', + 'session_busy', + 'operation_conflict', + 'persistence_failed', + 'commit_outcome_unknown', + 'internal_failure', +] as const; + +export interface SessionConversationCopyInput { + readonly sourceSessionId: string; + readonly targetSessionId: string; + readonly sourceTurnId: string; + readonly expectedSourceRevision: number; +} + +export type SessionConversationCopyResult = + | { + readonly kind: 'committed'; + readonly session: SessionCatalogItem; + } + | { + readonly kind: 'source_revision_conflict'; + readonly expectedRevision: number; + readonly actualRevision: number; + }; + +export const SESSION_REVISION_OPERATION_SPECS = { + 'session.branch.create': defineOperation< + SessionConversationCopyInput, + SessionConversationCopyResult, + (typeof SESSION_COPY_ERRORS)[number] + >({ + mode: 'command', + availability: 'ready', + errors: SESSION_COPY_ERRORS, + decodeInput: decodeSessionConversationCopyInput, + decodeOutput: decodeSessionConversationCopyResult, + assertOutputForInput: assertConversationCopyOutput, + }), + 'session.revision.create': defineOperation< + SessionConversationCopyInput, + SessionConversationCopyResult, + (typeof SESSION_COPY_ERRORS)[number] + >({ + mode: 'command', + availability: 'ready', + errors: SESSION_COPY_ERRORS, + decodeInput: decodeSessionConversationCopyInput, + decodeOutput: decodeSessionConversationCopyResult, + assertOutputForInput: assertConversationCopyOutput, + }), +} as const; + +export function decodeSessionConversationCopyInput(value: unknown): SessionConversationCopyInput { + const input = requireExactRecord(value, 'Session conversation-copy input', [ + 'sourceSessionId', + 'targetSessionId', + 'sourceTurnId', + 'expectedSourceRevision', + ]); + const sourceSessionId = requireEntityId(input.sourceSessionId, 'sourceSessionId'); + const targetSessionId = requireEntityId(input.targetSessionId, 'targetSessionId'); + if (sourceSessionId === targetSessionId) { + throw invalidProtocolFrame('Session conversation copy requires distinct Sessions'); + } + return { + sourceSessionId, + targetSessionId, + sourceTurnId: requireEntityId(input.sourceTurnId, 'sourceTurnId'), + expectedSourceRevision: positiveRevision( + input.expectedSourceRevision, + 'expected source Session revision', + ), + }; +} + +export function decodeSessionConversationCopyResult(value: unknown): SessionConversationCopyResult { + const result = requireRecord(value, 'Session conversation-copy result'); + if (result.kind === 'committed') { + const exact = requireExactRecord(result, 'committed Session conversation-copy result', [ + 'kind', + 'session', + ]); + return { + kind: 'committed', + session: decodeSessionCatalogItem(exact.session), + }; + } + if (result.kind === 'source_revision_conflict') { + const exact = requireExactRecord(result, 'Session source revision conflict result', [ + 'kind', + 'expectedRevision', + 'actualRevision', + ]); + return { + kind: 'source_revision_conflict', + expectedRevision: positiveRevision(exact.expectedRevision, 'expected Session revision'), + actualRevision: positiveRevision(exact.actualRevision, 'actual Session revision'), + }; + } + throw invalidProtocolFrame('Invalid Session conversation-copy result kind'); +} + +function assertConversationCopyOutput( + input: SessionConversationCopyInput, + output: SessionConversationCopyResult, +): void { + if (output.kind === 'committed' && output.session.id !== input.targetSessionId) { + throw invalidProtocolFrame('Session conversation-copy result identity does not match request'); + } + if ( + output.kind === 'source_revision_conflict' && + output.expectedRevision !== input.expectedSourceRevision + ) { + throw invalidProtocolFrame('Session source revision conflict does not match request'); + } +} + +function positiveRevision(value: unknown, label: string): number { + const revision = requireCount(value, label); + if (revision < 1) throw invalidProtocolFrame(`Invalid ${label}`); + return revision; +} diff --git a/packages/runtime-host/src/server/artifact-coordinator.ts b/packages/runtime-host/src/server/artifact-coordinator.ts index 6495849816..d147ad4cc3 100644 --- a/packages/runtime-host/src/server/artifact-coordinator.ts +++ b/packages/runtime-host/src/server/artifact-coordinator.ts @@ -17,20 +17,28 @@ import { } from '../protocol/index.js'; import { encodeArtifactProjection } from '../protocol/artifact.js'; import type { ArtifactOperationHandlerMap } from './operation-dispatcher.js'; +import { SessionAdmissionGate } from './session-admission-gate.js'; /** Session-scoped Host projection and deletion authority for Artifacts. */ export class HostArtifactCoordinator { readonly handlers: ArtifactOperationHandlerMap = { - 'artifact.query': (input) => this.#query(input), + 'artifact.query': (input) => + this.#sessionAdmission.run(input.sessionId, () => this.#query(input)), 'artifact.delete': (input) => this.#delete(input), }; readonly #store: InteractiveArtifactStoreWriter; readonly #requestDrain: () => void; + readonly #sessionAdmission: SessionAdmissionGate; - constructor(store: InteractiveArtifactStoreWriter, requestDrain: () => void) { + constructor( + store: InteractiveArtifactStoreWriter, + requestDrain: () => void, + sessionAdmission: SessionAdmissionGate, + ) { this.#store = authenticateInteractiveArtifactStoreWriter(store); this.#requestDrain = requestDrain; + this.#sessionAdmission = sessionAdmission; } async #query(input: ArtifactQueryInput): Promise> { @@ -108,10 +116,9 @@ export class HostArtifactCoordinator { readonly artifactId: string; }): Promise> { try { - const deleted = await this.#store.deleteUserArtifactInSession( - input.sessionId, - input.artifactId, - ); + const deleteArtifact = () => + this.#store.deleteUserArtifactInSession(input.sessionId, input.artifactId); + const deleted = await this.#sessionAdmission.run(input.sessionId, deleteArtifact); if (deleted.kind === 'not_found') { return { ok: false, diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 961d578536..4b2e6783d5 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -32,6 +32,7 @@ import { RuntimePolicyActivationGate } from './runtime-policy-activation-gate.js import { HostRuntimePolicyCoordinator } from './runtime-policy-coordinator.js'; import { SessionAdmissionGate } from './session-admission-gate.js'; import { HostSessionCatalogCoordinator } from './session-catalog-coordinator.js'; +import { HostSessionRevisionCoordinator } from './session-revision-coordinator.js'; import { SessionContinuityCoordinator } from './session-continuity-coordinator.js'; import { HostSkillCatalogCoordinator } from './skill-catalog-coordinator.js'; import { SkillCatalogRepository } from './skill-catalog-repository.js'; @@ -275,10 +276,25 @@ export async function createExecutionRuntimeHostComposition( continuity: continuityCoordinator, requestDrain: context.requestDrain, }); - const artifacts = new HostArtifactCoordinator(openedArtifactStore, context.requestDrain); + const sessionRevisions = new HostSessionRevisionCoordinator({ + stores, + artifacts: openedArtifactStore, + taskLedger: taskLedgerStore, + manager, + admission: sessionAdmission, + continuity: continuityCoordinator, + isSessionActive: (sessionId) => coordinator.readRootState(sessionId).kind === 'active', + requestDrain: context.requestDrain, + }); + const artifacts = new HostArtifactCoordinator( + openedArtifactStore, + context.requestDrain, + sessionAdmission, + ); const handlers = { ...coordinator.handlers, ...sessionCatalog.handlers, + ...sessionRevisions.handlers, ...messages.handlers, ...interactions.handlers, ...runtimePolicy.handlers, @@ -295,6 +311,8 @@ export async function createExecutionRuntimeHostComposition( recoveryTask ??= (async () => { await requireMemory(memory).recover(); await skills.recover(); + await openedArtifactStore.recover(); + await sessionRevisions.recover(); const sessions = await stores.sessionStore.listForRecovery(); for (const session of sessions) { await stores.runtimeEventStore.repairImmutableSteeringMessageProofsForRecovery( @@ -306,7 +324,6 @@ export async function createExecutionRuntimeHostComposition( sessions.map((session) => session.id), ); await coordinator.prepareRecovery(); - await openedArtifactStore.recover(); await interactions.recoverPendingAfterHostRestart(); await manager.recoverInterruptedSessionsStrict(stores); await graphCoordinator.recover(); diff --git a/packages/runtime-host/src/server/operation-dispatcher.ts b/packages/runtime-host/src/server/operation-dispatcher.ts index 64b46852ce..068f4605ac 100644 --- a/packages/runtime-host/src/server/operation-dispatcher.ts +++ b/packages/runtime-host/src/server/operation-dispatcher.ts @@ -52,7 +52,14 @@ export type SessionContinuityOperationKey = Extract< OperationKey, 'subscription.open' | 'subscription.close' >; -export type SessionCatalogOperationKey = Extract; +export type SessionRevisionOperationKey = Extract< + OperationKey, + 'session.branch.create' | 'session.revision.create' +>; +export type SessionCatalogOperationKey = Exclude< + Extract, + SessionRevisionOperationKey +>; export type TaskLedgerOperationKey = Extract; export type ArtifactOperationKey = Extract; export type SkillCatalogOperationKey = Extract; @@ -76,6 +83,10 @@ export type SessionCatalogOperationHandlerMap = Pick< OperationHandlerMap, SessionCatalogOperationKey >; +export type SessionRevisionOperationHandlerMap = Pick< + OperationHandlerMap, + SessionRevisionOperationKey +>; export type TaskLedgerOperationHandlerMap = Pick; export type ArtifactOperationHandlerMap = Pick; export type SkillCatalogOperationHandlerMap = Pick; diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index a1a15387ed..2e9e73b274 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -339,6 +339,7 @@ export class RootTurnCoordinator { async readSessionHeader(sessionId: string): Promise { try { const header = await this.stores.sessionStore.readHeaderSnapshot(sessionId); + if (header.conversationCopy?.state === 'preparing') return null; return { isArchived: header.isArchived || header.status === 'archived', unavailableReason: unsupportedSessionModeReason(header), @@ -1000,6 +1001,7 @@ export class RootTurnCoordinator { runId: active.runId, userMessageId: active.userMessageId, onRunStarted: async () => { + await this.manager.commitRevisionVersion(input.sessionId); await this.continuity.refreshCanonical(input.sessionId); startSettled.resolve(); await active.execution?.onReady?.(); @@ -1016,6 +1018,7 @@ export class RootTurnCoordinator { if (startedRunId !== active.runId) { throw new Error('Runtime started a different Run than the admitted identity'); } + await this.manager.commitRevisionVersion(input.sessionId); await this.continuity.refreshCanonical(input.sessionId); startSettled.resolve(); }, diff --git a/packages/runtime-host/src/server/session-admission-gate.ts b/packages/runtime-host/src/server/session-admission-gate.ts index fbf43c6d9f..d8c3aec6cd 100644 --- a/packages/runtime-host/src/server/session-admission-gate.ts +++ b/packages/runtime-host/src/server/session-admission-gate.ts @@ -7,12 +7,12 @@ export interface SessionAdmissionLease { } interface SessionAdmissionContext { - readonly sessionId: string; + readonly sessionIds: ReadonlySet; active: boolean; } interface SessionAdmissionLeaseState { - readonly sessionId: string; + readonly sessionIds: ReadonlySet; readonly context: SessionAdmissionContext; readonly tasks: Promise[]; accepting: boolean; @@ -38,14 +38,28 @@ export class SessionAdmissionGate { ), ); } - return this.#runQueued(sessionId, operation); + return this.#runQueued([sessionId], operation); + } + + runMany( + sessionIds: readonly string[], + operation: (lease: SessionAdmissionLease) => Promise | T, + ): Promise { + if (this.#context.getStore()?.active) { + return Promise.reject( + new Error( + 'Cannot enter Session admission from an active admission; reuse its lease instead', + ), + ); + } + return this.#runQueued(sessionIds, operation); } enqueueDetached( sessionId: string, operation: (lease: SessionAdmissionLease) => Promise | void, ): Promise { - return this.#runQueued(sessionId, operation); + return this.#runQueued([sessionId], operation); } runAdmitted( @@ -58,8 +72,8 @@ export class SessionAdmissionGate { return Promise.reject(new Error('Session admission lease no longer accepts tasks')); } const inherited = this.#context.getStore(); - if (inherited?.active && inherited.sessionId !== sessionId) { - return Promise.reject(new Error('Cannot nest Session admission across Sessions')); + if (inherited?.active && inherited !== state.context) { + return Promise.reject(new Error('Cannot reuse a Session admission lease from another task')); } let task: Promise; @@ -78,24 +92,38 @@ export class SessionAdmissionGate { } async #runQueued( - sessionId: string, + requestedSessionIds: readonly string[], operation: (lease: SessionAdmissionLease) => Promise | T, ): Promise { - const previous = this.#tails.get(sessionId) ?? Promise.resolve(); + const sessionIds = [...new Set(requestedSessionIds)].sort(); + if (sessionIds.length === 0) { + throw new Error('Session admission requires at least one Session'); + } + const previous = sessionIds.map((sessionId) => this.#tails.get(sessionId) ?? Promise.resolve()); let release!: () => void; const current = new Promise((resolve) => { release = resolve; }); - const tail = previous.then(() => current); - this.#tails.set(sessionId, tail); - await previous; + const tails = new Map( + sessionIds.map((sessionId, index) => { + const tail = previous[index]!.then(() => current); + this.#tails.set(sessionId, tail); + return [sessionId, tail] as const; + }), + ); + if (previous.length === 1) { + await previous[0]; + } else { + await Promise.all(previous); + } - const context: SessionAdmissionContext = { sessionId, active: true }; + const ownedSessionIds = new Set(sessionIds); + const context: SessionAdmissionContext = { sessionIds: ownedSessionIds, active: true }; const lease: SessionAdmissionLease = Object.freeze({ [sessionAdmissionLeaseBrand]: true as const, }); const state: SessionAdmissionLeaseState = { - sessionId, + sessionIds: ownedSessionIds, context, tasks: [], accepting: true, @@ -132,14 +160,16 @@ export class SessionAdmissionGate { context.active = false; this.#leases.delete(lease); release(); - if (this.#tails.get(sessionId) === tail) this.#tails.delete(sessionId); + for (const [sessionId, tail] of tails) { + if (this.#tails.get(sessionId) === tail) this.#tails.delete(sessionId); + } } } #requireLease(sessionId: string, lease: SessionAdmissionLease): SessionAdmissionLeaseState { const state = this.#leases.get(lease); if (!state) throw new Error('Session admission lease was not issued by this gate'); - if (state.sessionId !== sessionId) { + if (!state.sessionIds.has(sessionId)) { throw new Error('Session admission lease does not match the Session'); } return state; diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts index 6f48248896..07b378cede 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -136,7 +136,7 @@ export class HostSessionCatalogCoordinator { const record = await this.#readCatalogRecordIfPresent(input.sessionId); return successQuery({ kind: 'session', - session: record ? projectSession(record) : null, + session: record ? projectSessionCatalogRecord(record) : null, }); } @@ -189,7 +189,7 @@ export class HostSessionCatalogCoordinator { ); if (probe.kind === 'existing') { return createSuccess( - projectSession(await this.#stores.readCatalogRecord(input.sessionId)), + projectSessionCatalogRecord(await this.#stores.readCatalogRecord(input.sessionId)), ); } if (probe.kind === 'conflict') { @@ -228,7 +228,9 @@ export class HostSessionCatalogCoordinator { ); } await this.#continuity.refreshCanonical(input.sessionId, lease); - return createSuccess(projectSession(await this.#stores.readCatalogRecord(input.sessionId))); + return createSuccess( + projectSessionCatalogRecord(await this.#stores.readCatalogRecord(input.sessionId)), + ); } catch (error) { if (error instanceof SessionOperationFailure) { return createFailure(error.code, error.message); @@ -318,7 +320,9 @@ export class HostSessionCatalogCoordinator { ) { return configurationSuccess({ kind: 'committed', - session: projectSession(await this.#stores.readCatalogRecord(input.sessionId)), + session: projectSessionCatalogRecord( + await this.#stores.readCatalogRecord(input.sessionId), + ), }); } commitAttempted = true; @@ -373,7 +377,9 @@ export class HostSessionCatalogCoordinator { await this.#continuity.refreshCanonical(input.sessionId, lease); return { ok: true, - result: projectSession(await this.#stores.readCatalogRecord(input.sessionId)), + result: projectSessionCatalogRecord( + await this.#stores.readCatalogRecord(input.sessionId), + ), }; } catch (error) { if (isNotFound(error)) return readMarkerFailure('not_found', 'Session does not exist'); @@ -402,7 +408,7 @@ export class HostSessionCatalogCoordinator { await this.#continuity.refreshCanonical(sessionId, lease); return { kind: 'committed', - session: projectSession(await this.#stores.readCatalogRecord(sessionId)), + session: projectSessionCatalogRecord(await this.#stores.readCatalogRecord(sessionId)), }; } @@ -632,7 +638,7 @@ async function prepareCreate(input: SessionCreateInput): Promise; +type ConversationCopyCreateInput = CreateSessionInput & { + readonly conversationCopy: SessionConversationCopy; +}; + +export interface HostSessionRevisionCoordinatorOptions { + readonly stores: ExecutionStoresWriter<'interactive'>; + readonly artifacts: InteractiveArtifactStoreWriter; + readonly taskLedger: InteractiveTaskLedgerWriter; + readonly manager: SessionManager; + readonly admission: SessionAdmissionGate; + readonly continuity: SessionContinuityCoordinator; + readonly isSessionActive: (sessionId: string) => boolean; + readonly requestDrain: () => void; +} + +/** Host authority for exact, retryable cross-Session branch and revision copies. */ +export class HostSessionRevisionCoordinator { + readonly handlers: SessionRevisionOperationHandlerMap = { + 'session.branch.create': (input) => this.#copy('branch', input), + 'session.revision.create': (input) => this.#copy('revision', input), + }; + + readonly #stores: ExecutionStoresWriter<'interactive'>; + readonly #artifacts: InteractiveArtifactStoreWriter; + readonly #taskLedger: InteractiveTaskLedgerWriter; + + constructor(private readonly options: HostSessionRevisionCoordinatorOptions) { + this.#stores = authenticateExecutionStoresWriter(options.stores, 'interactive'); + this.#artifacts = authenticateInteractiveArtifactStoreWriter(options.artifacts); + this.#taskLedger = authenticateInteractiveTaskLedgerWriter(options.taskLedger); + } + + async recover(): Promise { + const copies = (await this.#stores.sessionStore.listHeaders()).filter( + (header) => header.conversationCopy !== undefined, + ); + for (const header of copies) { + if (header.conversationCopy!.state === 'preparing') await this.#discard(header); + } + + const committed = copies.filter((header) => header.conversationCopy!.state === 'committed'); + const retained = new Set( + committed + .filter( + (header) => + header.conversationCopy!.kind === 'branch' || header.revisionState === 'committed', + ) + .map((header) => header.id), + ); + for (const header of committed) { + if ( + header.conversationCopy!.kind === 'revision' && + header.revisionState === 'preparing' && + (await this.#hasAdmittedRevisionTurn(header.id)) + ) { + retained.add(header.id); + } + } + for (let changed = true; changed; ) { + changed = false; + for (const header of committed) { + if (!retained.has(header.id)) continue; + const sourceSessionId = header.conversationCopy!.sourceSessionId; + if (!retained.has(sourceSessionId)) { + retained.add(sourceSessionId); + changed = true; + } + } + } + for (const header of committed) { + if (header.conversationCopy!.kind !== 'revision' || header.revisionState !== 'preparing') { + continue; + } + if (retained.has(header.id)) { + await this.options.manager.commitRevisionVersion(header.id); + } else { + await this.#discard(header); + } + } + } + + async #copy( + kind: ConversationCopyKind, + input: SessionConversationCopyInput, + ): Promise { + const requestFingerprint = conversationCopyFingerprint(kind, input); + const retry = await this.options.admission.run(input.targetSessionId, async () => + this.#resolveExistingTarget(kind, input, requestFingerprint, true), + ); + if (retry) return retry; + + let rootSessionId: string; + try { + const source = await this.#stores.sessionStore.readHeaderRecordSnapshot( + input.sourceSessionId, + ); + rootSessionId = source.header.revisionRootSessionId ?? input.sourceSessionId; + } catch (error) { + return isSessionNotFoundError(error) + ? copyFailure('not_found', 'Source Session does not exist') + : copyFailure('persistence_failed', 'Source Session metadata is unavailable'); + } + + return this.options.admission.runMany( + [input.sourceSessionId, input.targetSessionId, rootSessionId], + (lease) => this.#copyAdmitted(kind, input, requestFingerprint, lease), + ); + } + + async #copyAdmitted( + kind: ConversationCopyKind, + input: SessionConversationCopyInput, + requestFingerprint: `sha256:${string}`, + lease: SessionAdmissionLease, + ): Promise { + const existing = await this.#resolveExistingTarget(kind, input, requestFingerprint, false); + if (existing) return existing; + + let sourceRecord; + try { + sourceRecord = await this.#stores.sessionStore.readHeaderRecordSnapshot( + input.sourceSessionId, + ); + } catch (error) { + return isSessionNotFoundError(error) + ? copyFailure('not_found', 'Source Session does not exist') + : copyFailure('persistence_failed', 'Source Session metadata is unavailable'); + } + if (sourceRecord.revision !== input.expectedSourceRevision) { + return copySuccess({ + kind: 'source_revision_conflict', + expectedRevision: input.expectedSourceRevision, + actualRevision: sourceRecord.revision, + }); + } + const sourceHeader = sourceRecord.header; + if (sourceHeader.conversationCopy?.state === 'preparing') { + return copyFailure('not_found', 'Source Session does not exist'); + } + if (sourceHeader.subagentParent) { + return copyFailure( + 'operation_conflict', + 'Linked child Sessions cannot be copied as ordinary conversations', + ); + } + if (this.options.isSessionActive(input.sourceSessionId)) { + return copyFailure('session_busy', 'Source Session has an active Turn'); + } + + let source; + try { + source = await this.options.manager.readConversationCopySnapshot(input.sourceSessionId); + } catch { + return copyFailure('persistence_failed', 'Source conversation ledger is unavailable'); + } + const slice = createConversationCopySlice( + source.messages, + input.sourceTurnId, + kind === 'branch' ? 'through' : 'before', + ); + if (!slice) { + return copyFailure('invalid_request', 'Source turn does not exist'); + } + let plan: ConversationRuntimeLedgerCopyPlan; + let sessionHeaders: SessionHeader[]; + try { + [plan, sessionHeaders] = await Promise.all([ + prepareConversationRuntimeLedgerCopy({ + sourceSessionId: input.sourceSessionId, + sourceEvents: source.events, + copiedMessages: slice.messages, + runStore: this.#stores.agentRunStore, + runtimeEventStore: this.#stores.runtimeEventStore, + }), + this.#stores.sessionStore.listHeaders(), + ]); + } catch { + return copyFailure('persistence_failed', 'Source conversation lineage is unavailable'); + } + const copyTurnIds = plan.copyTurnIds; + if ( + hasLinkedChildSessionReference(slice.messages) || + hasLinkedChildSessionMetadata(sessionHeaders, input.sourceSessionId, copyTurnIds) + ) { + return copyFailure( + 'operation_unavailable', + 'Session conversation copy does not yet support linked child Session graphs', + ); + } + const archivePreflight = await this.#preflightArchivedToolResults( + input.sourceSessionId, + plan.runs.flatMap(({ runtimeEvents }) => runtimeEvents), + slice.messages, + copyTurnIds, + ); + if (archivePreflight) return archivePreflight; + + let createInput: ConversationCopyCreateInput; + try { + createInput = await this.#createInput(kind, input, requestFingerprint, sourceHeader); + } catch { + return copyFailure('persistence_failed', 'Session revision family is unavailable'); + } + let boundary; + try { + boundary = await this.#stores.sessionStore.readExecutionBoundary(input.sourceSessionId); + } catch { + return copyFailure('persistence_failed', 'Source execution boundary is unavailable'); + } + + const created = await this.#stores.sessionStore + .createStableSession( + { + sessionId: input.targetSessionId, + requestFingerprint, + input: createInput, + }, + boundary, + ) + .catch(() => null); + if (!created) { + return this.#unknownAfterCommitAttempt( + kind, + input, + requestFingerprint, + 'Session conversation-copy creation outcome is unknown', + ); + } + if (created.kind === 'conflict') { + return copyFailure( + 'operation_conflict', + 'Target Session identity belongs to a different request', + ); + } + if (created.kind === 'existing') { + return ( + (await this.#resolveExistingTarget(kind, input, requestFingerprint, false)) ?? + copyFailure('commit_outcome_unknown', 'Target Session publication state is unknown') + ); + } + + try { + const artifactCopy = await this.#artifacts.copyConversationArtifacts({ + sourceSessionId: input.sourceSessionId, + targetSessionId: input.targetSessionId, + turnIds: copyTurnIds, + }); + const references = { + mode: 'exact' as const, + sourceSessionId: input.sourceSessionId, + targetSessionId: input.targetSessionId, + artifactIds: artifactCopy.artifactIds, + relativePaths: artifactCopy.relativePaths, + }; + const runtimeCopy = await cloneConversationRuntimeLedger({ + plan, + copiedMessages: slice.messages, + referenceMap: references, + runStore: this.#stores.agentRunStore, + runtimeEventStore: this.#stores.runtimeEventStore, + newId: randomUUID, + }); + const copiedMessages = runtimeCopy.copiedMessages; + await this.#taskLedger.copyConversationTaskLedger({ + sourceSessionId: input.sourceSessionId, + targetSessionId: input.targetSessionId, + turnIds: copyTurnIds, + ...(slice.beforeTs === undefined ? {} : { beforeTs: slice.beforeTs }), + runIdMap: runtimeCopy.runIdMap, + }); + if (copiedMessages.length > 0) { + await this.#stores.sessionStore.appendMessages(input.targetSessionId, [...copiedMessages]); + } + await this.#stores.sessionStore.appendMessage( + input.targetSessionId, + conversationCopyStartNote(kind, input, createInput), + ); + await this.#stores.sessionStore.updateHeader(input.targetSessionId, { + conversationCopy: { + ...createInput.conversationCopy!, + state: 'committed', + }, + isFlagged: sourceHeader.isFlagged, + titleIsManual: sourceHeader.titleIsManual, + connectionLocked: + sourceHeader.connectionLocked || + copiedMessages.some((message) => message.type === 'user'), + }); + await this.options.continuity.refreshCanonical(input.targetSessionId, lease); + return copySuccess({ + kind: 'committed', + session: projectSessionCatalogRecord( + await this.#stores.sessionStore.readCatalogRecord(input.targetSessionId), + ), + }); + } catch { + return this.#rollbackIncompleteCopy( + kind, + input, + requestFingerprint, + 'Session conversation copy could not be committed', + ); + } + } + + async #preflightArchivedToolResults( + sourceSessionId: string, + sourceEvents: readonly RuntimeEvent[], + copiedMessages: readonly StoredMessage[], + copyTurnIds: readonly string[], + ): Promise { + const archives = collectArchivedToolResultPlaceholders( + sourceEvents, + copiedMessages, + copyTurnIds, + ); + if (!archives) { + return copyFailure('persistence_failed', 'Archived tool result metadata is invalid'); + } + for (const archive of archives) { + const read = await this.#artifacts + .readTextInSession(sourceSessionId, archive.artifactId, { + maxBytes: archive.originalBytes, + }) + .catch(() => null); + if ( + !read?.ok || + Buffer.byteLength(read.text, 'utf8') !== archive.originalBytes || + createHash('sha256').update(read.text).digest('hex') !== archive.bodySha256 + ) { + return copyFailure('persistence_failed', 'Archived tool result is unavailable or corrupt'); + } + if (archivedToolResultContainsConversationOwnedReferences(read.text, sourceSessionId)) { + return copyFailure( + 'operation_unavailable', + 'Session conversation copy cannot preserve owned references inside archived tool results', + ); + } + } + return null; + } + + async #createInput( + kind: ConversationCopyKind, + input: SessionConversationCopyInput, + requestFingerprint: `sha256:${string}`, + source: SessionHeader, + ): Promise { + const common: ConversationCopyCreateInput = { + cwd: source.cwd, + ...(source.projectId !== undefined ? { projectId: source.projectId } : {}), + backend: source.backend, + llmConnectionSlug: source.llmConnectionSlug, + model: source.model, + ...(source.thinkingLevel !== undefined ? { thinkingLevel: source.thinkingLevel } : {}), + permissionMode: source.permissionMode, + collaborationMode: source.collaborationMode ?? 'agent', + orchestrationMode: source.orchestrationMode ?? 'default', + name: source.name, + labels: [...source.labels], + conversationCopy: { + kind, + sourceSessionId: input.sourceSessionId, + sourceTurnId: input.sourceTurnId, + requestFingerprint, + state: 'preparing', + }, + status: 'active', + }; + if (kind === 'branch') { + return { + ...common, + parentSessionId: input.sourceSessionId, + branchOfTurnId: input.sourceTurnId, + }; + } + const revisionRootSessionId = source.revisionRootSessionId ?? input.sourceSessionId; + const family = (await this.#stores.sessionStore.listHeaders()).filter( + (candidate) => + candidate.conversationCopy?.state !== 'preparing' && + (candidate.id === revisionRootSessionId || + candidate.revisionRootSessionId === revisionRootSessionId), + ); + const revisionIndex = + Math.max(1, ...family.map((candidate) => candidate.revisionIndex ?? 1)) + 1; + return { + ...common, + ...(source.parentSessionId ? { parentSessionId: source.parentSessionId } : {}), + ...(source.branchOfTurnId ? { branchOfTurnId: source.branchOfTurnId } : {}), + revisionRootSessionId, + revisionParentSessionId: input.sourceSessionId, + revisionOfTurnId: input.sourceTurnId, + revisionIndex, + revisionState: 'preparing', + }; + } + + async #resolveExistingTarget( + kind: ConversationCopyKind, + input: SessionConversationCopyInput, + requestFingerprint: `sha256:${string}`, + discardPreparing: boolean, + ): Promise { + let probe; + try { + probe = await this.#stores.sessionStore.probeStableSessionCreate( + input.targetSessionId, + requestFingerprint, + ); + } catch { + return copyFailure('persistence_failed', 'Target Session identity is unavailable'); + } + if (probe.kind === 'absent') return null; + if (probe.kind === 'conflict') { + return copyFailure( + 'operation_conflict', + 'Target Session identity belongs to a different request', + ); + } + const copy = probe.record.header.conversationCopy; + if ( + copy?.kind !== kind || + copy.sourceSessionId !== input.sourceSessionId || + copy.sourceTurnId !== input.sourceTurnId || + copy.requestFingerprint !== requestFingerprint + ) { + return copyFailure( + 'operation_conflict', + 'Target Session identity belongs to a different conversation copy', + ); + } + if (copy.state === 'committed') { + try { + return copySuccess({ + kind: 'committed', + session: projectSessionCatalogRecord( + await this.#stores.sessionStore.readCatalogRecord(input.targetSessionId), + ), + }); + } catch { + return copyFailure( + 'commit_outcome_unknown', + 'Committed target Session projection is unavailable', + ); + } + } + if (!discardPreparing) return null; + try { + await this.#discard(probe.record.header); + return null; + } catch { + this.options.requestDrain(); + return copyFailure( + 'commit_outcome_unknown', + 'Incomplete target Session could not be recovered', + ); + } + } + + async #rollbackIncompleteCopy( + kind: ConversationCopyKind, + input: SessionConversationCopyInput, + requestFingerprint: `sha256:${string}`, + message: string, + ): Promise { + let header; + try { + header = await this.#stores.sessionStore.readHeaderSnapshot(input.targetSessionId); + if (header.conversationCopy?.state === 'committed') { + return copySuccess({ + kind: 'committed', + session: projectSessionCatalogRecord( + await this.#stores.sessionStore.readCatalogRecord(input.targetSessionId), + ), + }); + } + await this.#discard(header); + return copyFailure('persistence_failed', message); + } catch { + return this.#unknownAfterCommitAttempt(kind, input, requestFingerprint, message); + } + } + + async #unknownAfterCommitAttempt( + kind: ConversationCopyKind, + input: SessionConversationCopyInput, + requestFingerprint: `sha256:${string}`, + message: string, + ): Promise { + const resolved = await this.#resolveExistingTarget(kind, input, requestFingerprint, false); + if (resolved?.ok) return resolved; + this.options.requestDrain(); + return copyFailure('commit_outcome_unknown', message); + } + + async #discard(header: SessionHeader): Promise { + const copy = header.conversationCopy; + if (!copy) throw new Error('Session is not a conversation copy'); + const sidecars = await Promise.allSettled([ + this.#artifacts.purgeSessionArtifacts(header.id), + this.#taskLedger.purgeConversationTaskLedger(header.id), + this.#stores.purgeConversationOperationalState(header.id), + ]); + const failures = sidecars.flatMap((result) => + result.status === 'rejected' ? [result.reason] : [], + ); + if (failures.length > 0) { + throw new AggregateError(failures, `Conversation copy ${header.id} could not be purged`); + } + await this.#stores.sessionStore.discardStableConversationCopy( + header.id, + copy.requestFingerprint, + ); + } + + async #hasAdmittedRevisionTurn(sessionId: string): Promise { + if ( + (await this.#stores.agentRunStore.listRootTurnAdmissionsForRecovery(sessionId)).length > 0 + ) { + return true; + } + const messages = await this.#stores.sessionStore.readMessagesForRecovery(sessionId); + let boundary = -1; + for (let index = 0; index < messages.length; index += 1) { + const message = messages[index]!; + if ( + message.type === 'system_note' && + message.kind === 'session_start' && + isRevisionStartData(message.data) + ) { + boundary = index; + } + } + return boundary >= 0 && messages.slice(boundary + 1).some((message) => message.type === 'user'); + } +} + +function conversationCopyFingerprint( + kind: ConversationCopyKind, + input: SessionConversationCopyInput, +): `sha256:${string}` { + return `sha256:${createHash('sha256') + .update( + JSON.stringify([ + 'session.conversation-copy.v0', + kind, + input.sourceSessionId, + input.targetSessionId, + input.sourceTurnId, + input.expectedSourceRevision, + ]), + ) + .digest('hex')}`; +} + +function conversationCopyStartNote( + kind: ConversationCopyKind, + input: SessionConversationCopyInput, + createInput: ConversationCopyCreateInput, +): StoredMessage { + return { + type: 'system_note', + id: randomUUID(), + ts: Date.now(), + kind: 'session_start', + data: + kind === 'branch' + ? { + parentSessionId: input.sourceSessionId, + branchOfTurnId: input.sourceTurnId, + } + : { + revisionRootSessionId: createInput.revisionRootSessionId, + revisionParentSessionId: input.sourceSessionId, + revisionOfTurnId: input.sourceTurnId, + revisionIndex: createInput.revisionIndex, + revisionState: 'preparing', + }, + }; +} + +function isRevisionStartData(value: unknown): boolean { + return ( + !!value && + typeof value === 'object' && + !Array.isArray(value) && + 'revisionRootSessionId' in value + ); +} + +function hasLinkedChildSessionReference(messages: readonly StoredMessage[]): boolean { + return messages.some((message) => { + if (message.type !== 'tool_result') return false; + if (message.content.kind === 'subagent') { + return message.content.childSessionId !== undefined; + } + return ( + message.content.kind === 'agent_swarm' && + message.content.items.some((item) => item.childSessionId !== undefined) + ); + }); +} + +function hasLinkedChildSessionMetadata( + headers: readonly SessionHeader[], + sourceSessionId: string, + copyTurnIds: readonly string[], +): boolean { + const retainedTurnIds = new Set(copyTurnIds); + return headers.some( + (header) => + header.subagentParent?.parentSessionId === sourceSessionId && + retainedTurnIds.has(header.subagentParent.spawnedBy.parentTurnId), + ); +} + +function collectArchivedToolResultPlaceholders( + events: readonly RuntimeEvent[], + messages: readonly StoredMessage[], + copyTurnIds: readonly string[], +): ArchivedToolResultCopyDescriptor[] | null { + const retainedTurnIds = new Set(copyTurnIds); + const archives = new Map(); + const add = (value: unknown): boolean => { + if (!isRecord(value) || value.kind !== 'maka.archived_tool_result') return true; + if (!isArchivedToolResultPlaceholder(value)) return false; + addDescriptor(value); + return true; + }; + const addDescriptor = (descriptor: ArchivedToolResultCopyDescriptor): void => { + const key = `${descriptor.artifactId}:${descriptor.bodySha256}:${descriptor.originalBytes}`; + if (!archives.has(key)) archives.set(key, descriptor); + }; + + for (const event of events) { + if (retainedTurnIds.has(event.turnId) && event.content?.kind === 'function_response') { + if (!add(event.content.result)) return null; + } + } + for (const message of messages) { + if (message.type !== 'tool_result') continue; + if (message.content.kind === 'json') { + if (!add(message.content.value)) return null; + continue; + } + if (message.content.kind === 'archived_tool_result') { + if (!message.content.artifactId && !message.content.bodySha256) continue; + if (!message.content.artifactId || !message.content.bodySha256) return null; + addDescriptor({ + artifactId: message.content.artifactId, + bodySha256: message.content.bodySha256, + originalBytes: message.content.originalBytes, + }); + } + } + return [...archives.values()]; +} + +interface ArchivedToolResultCopyDescriptor { + readonly artifactId: string; + readonly bodySha256: string; + readonly originalBytes: number; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function copySuccess(result: SessionConversationCopyResult): ConversationCopyOutcome { + return { ok: true, result }; +} + +function copyFailure( + code: Extract['error']['code'], + message: string, +): ConversationCopyOutcome { + return { ok: false, error: { code, message } }; +} diff --git a/packages/runtime/src/__tests__/conversation-copy.test.ts b/packages/runtime/src/__tests__/conversation-copy.test.ts new file mode 100644 index 0000000000..06edd284f1 --- /dev/null +++ b/packages/runtime/src/__tests__/conversation-copy.test.ts @@ -0,0 +1,1530 @@ +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 type { + AgentRunHeader, + AgentRunStore, + RuntimeEvent, + RuntimeEventStore, + StoredMessage, +} from '@maka/core'; +import { + canonicalToolArgsHash, + decodeCanonicalToolResultContent, + isSessionInlineRun, +} from '@maka/core'; +import { + createAgentRunStore, + createRuntimeEventStore, + createSqliteAgentRunStore, + createSqliteRuntimeStore, +} from '@maka/storage'; +import { + archivedToolResultContainsConversationOwnedReferences, + cloneConversationRuntimeLedger, + createConversationCopySlice, + prepareConversationRuntimeLedgerCopy, + rewriteConversationCopyMessage, +} from '../conversation-copy.js'; +import { + buildHistoryCompactCheckpoint, + matchHistoryCompactCheckpointPrefix, + validateHistoryCompactCheckpointShape, +} from '../history-compact-checkpoint.js'; +import { isHistoryCompactContentEvent } from '../history-compact.js'; +import { RuntimeReadModel, type RuntimeReadModelSessionView } from '../runtime-read-model.js'; +import { buildToolOperationId } from '../runtime-commit-sink.js'; +import { buildToolResultArchiveResourceRef } from '../tool-result-archive-resource.js'; + +test('archived tool-result copy preflight detects conversation-owned references', () => { + const serialized = (value: unknown): string => JSON.stringify(value); + assert.equal( + archivedToolResultContainsConversationOwnedReferences( + serialized({ kind: 'text', text: 'safe result' }), + 'session-source', + ), + false, + ); + assert.equal( + archivedToolResultContainsConversationOwnedReferences( + serialized({ + kind: 'image', + mimeType: 'image/png', + ref: { + kind: 'session_file', + sessionId: 'session-source', + relativePath: 'session-source/image.png', + }, + }), + 'session-source', + ), + true, + ); + assert.equal( + archivedToolResultContainsConversationOwnedReferences( + serialized({ + kind: 'subagent', + agentName: 'Researcher', + turnId: 'turn-child', + runId: 'run-child', + status: 'completed', + permissionMode: 'ask', + summary: 'done', + artifactIds: [], + }), + 'session-source', + ), + true, + ); + assert.equal( + archivedToolResultContainsConversationOwnedReferences( + serialized({ + kind: 'agent_swarm', + status: 'completed', + items: [ + { + itemId: 'item-1', + index: 0, + profile: 'default', + started: true, + resumedFromRunId: 'run-source', + status: 'completed', + summary: 'done', + artifactIds: [], + }, + ], + startedAt: 1, + completedAt: 2, + durationMs: 1, + }), + 'session-source', + ), + true, + ); +}); + +test('conversation copy slices exact turns on inclusive and exclusive boundaries', () => { + const messages = [ + { type: 'user', id: 'user-1', turnId: 'turn-1', ts: 1, text: 'first' }, + { type: 'user', id: 'user-2', turnId: 'turn-2', ts: 3, text: 'second' }, + { + type: 'assistant', + id: 'assistant-1', + turnId: 'turn-1', + ts: 2, + text: 'first response', + modelId: 'model', + }, + ] as const; + + const inclusive = createConversationCopySlice(messages, 'turn-1', 'through'); + assert.deepEqual(inclusive?.turnIds, ['turn-1']); + assert.deepEqual( + inclusive?.messages.map((message) => message.id), + ['user-1', 'assistant-1'], + ); + assert.equal(inclusive?.beforeTs, 3); + + const exclusive = createConversationCopySlice(messages, 'turn-2', 'before'); + assert.deepEqual(exclusive, inclusive); + assert.equal(createConversationCopySlice(messages, 'missing', 'through'), null); +}); + +test('conversation copy rewrites owned references without changing opaque tool payloads', () => { + const resourceRef = buildToolResultArchiveResourceRef({ + artifactId: 'artifact-source', + bodySha256: 'a'.repeat(64), + originalBytes: 12, + }); + const messages: StoredMessage[] = [ + { + type: 'user', + id: 'user-1', + turnId: 'turn-1', + ts: 1, + text: 'attached', + attachments: [ + { + kind: 'code', + name: 'artifact.txt', + mimeType: 'text/plain', + bytes: 12, + ref: { + kind: 'session_file', + sessionId: 'session-source', + relativePath: 'session-source/artifact-source-file.txt', + }, + }, + ], + }, + { + type: 'tool_call', + id: 'tool-1', + turnId: 'turn-1', + ts: 2, + toolName: 'opaque', + args: { + sessionId: 'session-source', + runId: 'run-source', + artifactId: 'artifact-source', + }, + providerOptions: { + sourceInvocationId: 'invocation-source', + }, + }, + { + type: 'tool_result', + id: 'result-1', + turnId: 'turn-1', + ts: 3, + toolUseId: 'tool-1', + isError: false, + content: { + kind: 'json', + value: { + kind: 'maka.archived_tool_result', + rewriteVersion: 1, + artifactId: 'artifact-source', + resourceRef, + runtimeEventId: 'event-source', + toolCallId: 'tool-1', + toolName: 'opaque', + bodySha256: 'a'.repeat(64), + originalEstimatedTokens: 3, + originalBytes: 12, + reason: 'stale_tool_result_pruned_before_compact', + }, + }, + }, + { + type: 'tool_result', + id: 'result-2', + turnId: 'turn-1', + ts: 4, + toolUseId: 'tool-2', + isError: false, + content: { + kind: 'agent_swarm', + status: 'completed', + items: [ + { + itemId: 'item-1', + index: 0, + profile: 'default', + started: true, + runId: 'run-source', + resumedFromRunId: 'run-source', + status: 'completed', + summary: 'done', + artifactIds: ['artifact-source'], + }, + ], + startedAt: 1, + completedAt: 2, + durationMs: 1, + }, + }, + ]; + const references = { + mode: 'exact' as const, + sourceSessionId: 'session-source', + targetSessionId: 'session-target', + artifactIds: new Map([['artifact-source', 'artifact-target']]), + relativePaths: new Map([ + ['session-source/artifact-source-file.txt', 'session-target/artifact-target-file.txt'], + ]), + runIds: new Map([['run-source', 'run-target']]), + invocationIds: new Map([['invocation-source', 'invocation-target']]), + runtimeEventIds: new Map([['event-source', 'event-target']]), + providerTraceIds: new Map(), + }; + const rewritten = messages.map((message) => rewriteConversationCopyMessage(message, references)); + + assert.deepEqual(rewritten[0]?.type === 'user' ? rewritten[0].attachments?.[0]?.ref : undefined, { + kind: 'session_file', + sessionId: 'session-target', + relativePath: 'session-target/artifact-target-file.txt', + }); + assert.deepEqual( + rewritten[1]?.type === 'tool_call' ? rewritten[1].args : undefined, + messages[1]?.type === 'tool_call' ? messages[1].args : undefined, + ); + assert.deepEqual( + rewritten[1]?.type === 'tool_call' ? rewritten[1].providerOptions : undefined, + messages[1]?.type === 'tool_call' ? messages[1].providerOptions : undefined, + ); + const archived = + rewritten[2]?.type === 'tool_result' && + rewritten[2].content.kind === 'json' && + typeof rewritten[2].content.value === 'object' && + rewritten[2].content.value !== null + ? (rewritten[2].content.value as { + artifactId?: string; + resourceRef?: string; + runtimeEventId?: string; + }) + : undefined; + assert.equal(archived?.artifactId, 'artifact-target'); + assert.equal(archived?.runtimeEventId, 'event-target'); + assert.equal( + archived?.resourceRef, + buildToolResultArchiveResourceRef({ + artifactId: 'artifact-target', + bodySha256: 'a'.repeat(64), + originalBytes: 12, + }), + ); + const swarm = + rewritten[3]?.type === 'tool_result' && rewritten[3].content.kind === 'agent_swarm' + ? rewritten[3].content.items[0] + : undefined; + assert.equal(swarm?.runId, 'run-target'); + assert.equal(swarm?.resumedFromRunId, 'run-target'); + assert.deepEqual(swarm?.artifactIds, ['artifact-target']); + const unavailableArchive = rewriteConversationCopyMessage( + { + type: 'tool_result', + id: 'result-3', + turnId: 'turn-1', + ts: 5, + toolUseId: 'tool-3', + isError: false, + content: { + kind: 'archived_tool_result', + status: 'missing', + runtimeEventId: 'event-source', + toolCallId: 'tool-3', + toolName: 'opaque', + originalEstimatedTokens: 3, + originalBytes: 12, + rewriteVersion: 1, + reason: 'stale_tool_result_pruned_before_compact', + }, + }, + references, + ); + assert.equal( + unavailableArchive.type === 'tool_result' && + unavailableArchive.content.kind === 'archived_tool_result' + ? unavailableArchive.content.runtimeEventId + : undefined, + 'event-target', + ); + const preserved = rewriteConversationCopyMessage(messages[0]!, { + mode: 'preserve_external', + sourceSessionId: 'session-source', + targetSessionId: 'session-target', + runIds: new Map(), + runtimeEventIds: new Map(), + providerTraceIds: new Map(), + }); + assert.deepEqual( + preserved.type === 'user' ? preserved.attachments?.[0]?.ref : undefined, + messages[0]?.type === 'user' ? messages[0].attachments?.[0]?.ref : undefined, + ); + for (const message of [messages[2]!, messages[3]!]) { + assert.throws( + () => + rewriteConversationCopyMessage(message, { + ...references, + artifactIds: new Map(), + }), + /missing Artifact artifact-source/, + ); + } + assert.throws( + () => + rewriteConversationCopyMessage(messages[3]!, { + ...references, + runIds: new Map(), + }), + /missing AgentRun run-source/, + ); +}); + +test('conversation copy turn closure includes legacy children but excludes later continuations', async () => { + const runs = [ + agentRunHeader({ + runId: 'run-parent', + turnId: 'turn-parent', + }), + agentRunHeader({ + runId: 'run-child', + turnId: 'turn-child', + parentRunId: 'run-parent', + }), + agentRunHeader({ + runId: 'run-grandchild', + turnId: 'turn-grandchild', + parentRunId: 'run-child', + }), + agentRunHeader({ + runId: 'run-continuation', + turnId: 'turn-after-boundary', + parentRunId: 'run-parent', + continuationSource: { + sourceInvocationId: 'invocation-parent', + sourceRunId: 'run-parent', + sourceTurnId: 'turn-parent', + sourceRuntimeEventHighWater: 1, + }, + }), + ]; + + const plan = await prepareConversationRuntimeLedgerCopy({ + sourceSessionId: 'session-source', + sourceEvents: [], + copiedMessages: [ + { + type: 'user', + id: 'message-parent', + turnId: 'turn-parent', + ts: 1, + text: 'retain this turn', + }, + ], + runStore: { + listSessionRuns: async () => runs, + readEvents: async () => [], + }, + runtimeEventStore: { + readRuntimeEvents: async (_sessionId, runId) => { + const run = runs.find((candidate) => candidate.runId === runId); + assert.ok(run); + return [ + runtimeEvent({ + id: `terminal-${runId}`, + runId, + invocationId: run.invocationId, + turnId: run.turnId, + status: 'completed', + }), + ]; + }, + }, + }); + + assert.deepEqual(plan.copyTurnIds, ['turn-parent', 'turn-child', 'turn-grandchild']); +}); + +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 = createAgentRunStore(root); + const runtimeEventStore = createRuntimeEventStore(root); + const rootRun = agentRunHeader({ + runId: 'run-root', + invocationId: 'invocation-root', + turnId: 'turn-root', + cwd: root, + }); + const childRun = agentRunHeader({ + runId: 'run-child', + invocationId: 'invocation-child', + turnId: 'turn-child', + parentRunId: 'run-root', + agentId: 'researcher', + agentName: 'Researcher', + cwd: root, + }); + await runStore.createRun(rootRun); + await runStore.createRun(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({ + runStore, + 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', + 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 runStore.listSessionRuns('session-target'), []); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +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 runStore.createRun( + agentRunHeader({ + runId: 'run-source', + invocationId: 'invocation-source', + turnId: 'turn-1', + cwd: root, + }), + ); + const sourceEvents: RuntimeEvent[] = [ + runtimeEvent({ + id: 'event-user', + role: 'user', + author: 'user', + content: { kind: 'text', text: 'recover the write' }, + }), + runtimeEvent({ + id: 'event-call', + ts: 2, + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'provider-call-1', + name: 'Write', + args: { path: 'notes.txt', content: 'after' }, + }, + }), + runtimeEvent({ + id: 'event-dispatch', + ts: 3, + actions: { + toolDispatch: { + protocol: 't1_after_preflight_v1', + operationId: 'operation-1', + providerToolCallId: 'provider-call-1', + toolName: 'Write', + canonicalArgsHash: canonicalToolArgsHash('Write', { + path: 'notes.txt', + content: 'after', + }), + recoveryMode: 'reconcile', + }, + }, + refs: { operationId: 'operation-1', toolCallId: 'provider-call-1' }, + }), + runtimeEvent({ + id: 'event-reconcile', + ts: 4, + actions: { + toolRecovery: { + kind: 'maka.tool.reconcile_result', + version: 1, + payload: { + protocol: 'tool_reconcile_v1', + operationId: 'operation-1', + observation: 'matches_expected_state', + observationSchema: 'state_identity_v1', + observationDigest: `sha256:${'b'.repeat(64)}`, + }, + }, + }, + refs: { operationId: 'operation-1', toolCallId: 'provider-call-1' }, + }), + runtimeEvent({ + id: 'event-outcome', + ts: 5, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'provider-call-1', + name: 'Write', + result: { kind: 'text', text: 'ok' }, + isError: false, + }, + refs: { operationId: 'operation-1', toolCallId: 'provider-call-1' }, + }), + runtimeEvent({ + id: 'event-decision', + ts: 6, + actions: { + toolRecovery: { + kind: 'maka.tool.recovery_decision', + version: 1, + payload: { + protocol: 'tool_recovery_v1', + operationId: 'operation-1', + disposition: 'completed', + reasonCode: 'reconcile_matches_expected_state', + outcomeEventId: 'event-outcome', + evidenceEventIds: [ + 'event-call', + 'event-dispatch', + 'event-reconcile', + 'event-outcome', + ], + }, + }, + }, + refs: { operationId: 'operation-1', toolCallId: 'provider-call-1' }, + }), + runtimeEvent({ + id: 'event-terminal', + ts: 7, + status: 'completed', + }), + ]; + await runtimeEventStore.importConversationCopyRuntimeEvents('session-source', [ + { runId: 'run-source', events: sourceEvents }, + ]); + await runStore.appendEvent('session-source', 'run-source', { + type: 'run_completed', + id: 'completed-source', + runId: 'run-source', + sessionId: 'session-source', + turnId: 'turn-1', + ts: 7, + }); + const source = await new RuntimeReadModel({ + runStore, + runtimeEventStore, + }).getSessionView('session-source'); + await cloneConversationRuntimeLedger({ + plan: await prepareTestCopyPlan(source, source.messages, runStore, runtimeEventStore), + copiedMessages: source.messages, + referenceMap: { + mode: 'exact', + sourceSessionId: 'session-source', + targetSessionId: 'session-target', + artifactIds: new Map(), + relativePaths: new Map(), + }, + runStore, + runtimeEventStore, + newId: () => crypto.randomUUID(), + }); + const [targetRun] = await runStore.listSessionRuns('session-target'); + assert.ok(targetRun); + assert.ok(targetRun.invocationId); + const targetOperationId = buildToolOperationId({ + invocationId: targetRun.invocationId, + providerToolCallId: 'provider-call-1', + }); + assert.notEqual(targetOperationId, 'operation-1'); + const targetEvents = await runtimeEventStore.readRuntimeEvents( + 'session-target', + targetRun.runId, + ); + const dispatch = targetEvents.find((event) => event.actions?.toolDispatch)?.actions + ?.toolDispatch; + const reconcile = targetEvents.find( + (event) => event.actions?.toolRecovery?.kind === 'maka.tool.reconcile_result', + )?.actions?.toolRecovery; + const decisionEvent = targetEvents.find( + (event) => event.actions?.toolRecovery?.kind === 'maka.tool.recovery_decision', + ); + const decision = decisionEvent?.actions?.toolRecovery; + const callEvent = targetEvents.find((event) => event.content?.kind === 'function_call'); + const dispatchEvent = targetEvents.find((event) => event.actions?.toolDispatch); + const reconcileEvent = targetEvents.find( + (event) => event.actions?.toolRecovery?.kind === 'maka.tool.reconcile_result', + ); + const outcomeEvent = targetEvents.find((event) => event.content?.kind === 'function_response'); + assert.ok(callEvent); + assert.ok(dispatchEvent); + assert.ok(reconcileEvent); + assert.ok(outcomeEvent); + assert.equal(dispatch?.operationId, targetOperationId); + assert.equal(reconcile?.payload.operationId, targetOperationId); + assert.equal(decision?.kind, 'maka.tool.recovery_decision'); + if (decision?.kind !== 'maka.tool.recovery_decision') { + assert.fail('Copied recovery decision is missing'); + } + assert.equal(decision.payload.disposition, 'completed'); + if (decision.payload.disposition !== 'completed') { + assert.fail('Copied recovery decision must be completed'); + } + assert.equal(decision.payload.outcomeEventId, outcomeEvent.id); + assert.deepEqual(decision.payload.evidenceEventIds, [ + callEvent.id, + dispatchEvent.id, + reconcileEvent.id, + outcomeEvent.id, + ]); + assert.equal(decision.payload.operationId, targetOperationId); + assert.ok( + targetEvents + .filter((event) => event.refs?.operationId) + .every((event) => event.refs?.operationId === targetOperationId), + ); + assert.equal((await runtimeEventStore.readToolOperation('operation-1'))?.runId, 'run-source'); + assert.equal( + (await runtimeEventStore.readToolOperation(targetOperationId))?.runId, + targetRun.runId, + ); + } finally { + runtimeEventStore.close(); + runStore.close?.(); + await rm(root, { recursive: true, force: true }); + } +}); + +test('conversation copy validates operational events before persisting target ledgers', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-conversation-copy-preflight-')); + try { + const runStore = createAgentRunStore(root); + const runtimeEventStore = createRuntimeEventStore(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', + 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 clones one terminal Runtime ledger with new owned identities', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-conversation-runtime-copy-')); + try { + const runStore = createAgentRunStore(root); + const runtimeEventStore = createRuntimeEventStore(root); + const sourceRun: AgentRunHeader = { + 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); + const sourceEvents: RuntimeEvent[] = [ + runtimeEvent({ + id: 'event-user', + role: 'user', + author: 'user', + content: { kind: 'text', text: 'hello' }, + refs: { artifactId: 'artifact-source' }, + }), + runtimeEvent({ + id: 'event-model', + ts: 2, + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'tool-1', + name: 'opaque', + args: { + sessionId: 'session-source', + runId: 'run-source', + artifactId: 'artifact-source', + }, + providerOptions: { + sourceInvocationId: 'invocation-source', + }, + }, + refs: { + sourceInvocationId: 'invocation-source', + providerRequestTraceId: 'provider-trace-source', + traceEventId: 'capture-source', + }, + }), + runtimeEvent({ + id: 'event-tool', + ts: 2.5, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'tool-1', + name: 'opaque', + result: { + kind: 'json', + value: { + sessionId: 'session-source', + runId: 'run-source', + artifactId: 'artifact-source', + }, + }, + }, + }), + runtimeEvent({ + id: 'event-typed-call', + ts: 2.6, + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'tool-2', + name: 'subagent', + args: {}, + }, + }), + runtimeEvent({ + id: 'event-typed-tool', + ts: 2.75, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'tool-2', + name: 'subagent', + result: { + kind: 'subagent', + agentName: 'Researcher', + turnId: 'turn-1', + runId: 'run-source', + status: 'completed', + permissionMode: 'ask', + summary: 'done', + artifactIds: ['artifact-deleted'], + }, + }, + }), + runtimeEvent({ + id: 'event-terminal', + ts: 3, + role: 'system', + author: 'system', + status: 'completed', + }), + ]; + for (const event of sourceEvents) { + await runtimeEventStore.appendRuntimeEvent('session-source', 'run-source', event); + } + const checkpoint = buildHistoryCompactCheckpoint({ + sessionId: 'session-source', + coveredRuntimeEvents: sourceEvents.filter(isHistoryCompactContentEvent), + summary: 'The source turn called one opaque tool.', + highWaterSeq: 3, + }); + await runStore.appendEvent('session-source', 'run-source', { + type: 'provider_request_captured', + id: 'capture-source', + runId: 'run-source', + sessionId: 'session-source', + turnId: 'turn-1', + ts: 2, + data: { + schemaVersion: 2, + traceId: 'provider-trace-source', + captureId: 'capture-source', + turnId: 'turn-1', + step: 1, + providerId: 'provider', + modelId: 'model', + requestHash: 'request-hash', + requestPayloadWithoutProviderOptionsHash: 'payload-hash', + requestBytes: 12, + segments: [], + artifactId: 'artifact-source', + }, + }); + await runStore.appendEvent('session-source', 'run-source', { + type: 'provider_request_attempt_recorded', + id: 'attempt-source', + runId: 'run-source', + sessionId: 'session-source', + turnId: 'turn-1', + ts: 2.5, + data: { + traceId: 'provider-trace-source', + attemptId: 'attempt-source', + turnId: 'turn-1', + step: 1, + attempt: 1, + captureId: 'capture-source', + captureArtifactId: 'artifact-source', + providerId: 'provider', + modelId: 'model', + requestHash: 'request-hash', + requestBytes: 12, + segments: [], + startedAt: 2, + completedAt: 2.5, + status: 'completed', + latencyMs: 0.5, + }, + }); + await runStore.appendEvent('session-source', 'run-source', { + type: 'active_full_compact_block_recorded', + id: 'active-compact-source', + runId: 'run-source', + sessionId: 'session-source', + turnId: 'turn-1', + ts: 2.6, + data: { blockId: 'active-source', block: { sourceOwnedHash: true } }, + }); + await runStore.appendEvent('session-source', 'run-source', { + type: 'semantic_compact_block_recorded', + id: 'semantic-compact-source', + runId: 'run-source', + sessionId: 'session-source', + turnId: 'turn-1', + ts: 2.7, + data: { blockId: 'semantic-source', block: { sourceOwnedHash: true } }, + }); + await runStore.appendEvent('session-source', 'run-source', { + type: 'history_compact_checkpoint_recorded', + id: 'checkpoint-source', + runId: 'run-source', + sessionId: 'session-source', + turnId: 'turn-1', + ts: 2.75, + data: { + checkpointId: checkpoint.checkpointId, + highWaterName: checkpoint.highWaterName, + highWaterSeq: checkpoint.highWaterSeq, + boundaryKind: 'historyCompact', + checkpoint, + }, + }); + await runStore.appendEvent('session-source', 'run-source', { + type: 'run_completed', + id: 'completed-source', + runId: 'run-source', + sessionId: 'session-source', + turnId: 'turn-1', + ts: 3, + }); + 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', + sourceSessionId: 'session-source', + targetSessionId: 'session-missing-artifact', + artifactIds: new Map([['artifact-source', 'artifact-target']]), + relativePaths: new Map(), + }, + runStore, + runtimeEventStore, + newId: () => crypto.randomUUID(), + }), + /missing Artifact artifact-deleted/, + ); + assert.deepEqual(await runStore.listSessionRuns('session-missing-artifact'), []); + const ids = [ + 'run-target', + 'invocation-target', + 'event-target-1', + 'event-target-2', + 'event-target-3', + 'event-target-4', + 'event-target-5', + 'event-target-6', + ]; + let nextId = 0; + + const copied = await cloneConversationRuntimeLedger({ + plan: await prepareTestCopyPlan(source, source.messages, runStore, runtimeEventStore), + copiedMessages: source.messages, + referenceMap: { + mode: 'exact', + sourceSessionId: 'session-source', + targetSessionId: 'session-target', + artifactIds: new Map([ + ['artifact-source', 'artifact-target'], + ['artifact-deleted', 'artifact-target-deleted'], + ]), + relativePaths: new Map(), + }, + runStore, + runtimeEventStore, + newId: () => ids[nextId++] ?? `generated-${nextId}`, + }); + + assert.deepEqual(copied.runIdMap, [{ sourceRunId: 'run-source', targetRunId: 'run-target' }]); + const copiedTypedResult = copied.copiedMessages.find( + (message) => message.type === 'tool_result' && message.content.kind === 'subagent', + ); + assert.deepEqual( + copiedTypedResult?.type === 'tool_result' && copiedTypedResult.content.kind === 'subagent' + ? copiedTypedResult.content.artifactIds + : undefined, + ['artifact-target-deleted'], + ); + const [targetRun] = await runStore.listSessionRuns('session-target'); + assert.equal(targetRun?.runId, 'run-target'); + assert.equal(targetRun?.invocationId, 'invocation-target'); + assert.equal(targetRun?.status, 'completed'); + const targetEvents = await runtimeEventStore.readRuntimeEvents('session-target', 'run-target'); + assert.deepEqual( + targetEvents.map((event) => event.id), + [ + 'event-target-1', + 'event-target-2', + 'event-target-3', + 'event-target-4', + 'event-target-5', + 'event-target-6', + ], + ); + assert.ok( + targetEvents.every( + (event) => + event.sessionId === 'session-target' && + event.runId === 'run-target' && + event.invocationId === 'invocation-target', + ), + ); + assert.equal(targetEvents[0]?.refs?.artifactId, 'artifact-target'); + assert.equal(targetEvents[1]?.refs?.sourceInvocationId, 'invocation-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, + ); + assert.deepEqual( + targetEvents[2]?.content?.kind === 'function_response' + ? targetEvents[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 + : undefined; + const typedResult = decodeCanonicalToolResultContent(typedResultValue); + assert.deepEqual(typedResult.kind === 'subagent' ? typedResult.artifactIds : undefined, [ + 'artifact-target-deleted', + ]); + const targetOperationalEvents = await runStore.readEvents('session-target', 'run-target'); + assert.deepEqual( + targetOperationalEvents.map((event) => event.type), + [ + 'provider_request_captured', + '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', + ); + assert.ok(targetCapture); + assert.ok(targetAttempt); + 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(targetEvents[1]?.refs?.providerRequestTraceId, targetCapture.data?.traceId); + assert.equal(targetEvents[1]?.refs?.traceEventId, targetCapture.id); + const projectedCheckpoint = await runStore.readEventProjection?.( + 'session-target', + 'history_compact_checkpoint_recorded', + ); + assert.ok(projectedCheckpoint); + const targetCheckpoint = projectedCheckpoint.data?.checkpoint; + assert.ok(validateHistoryCompactCheckpointShape(targetCheckpoint, 'session-target')); + assert.equal( + matchHistoryCompactCheckpointPrefix( + targetCheckpoint, + targetEvents.filter(isHistoryCompactContentEvent), + ).reason, + undefined, + ); + assert.equal((await runStore.readRun('session-source', 'run-source')).status, 'completed'); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('conversation copy rebuilds an inline checkpoint without legacy child events in its prefix', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-conversation-checkpoint-copy-')); + try { + const runStore = createAgentRunStore(root); + const runtimeEventStore = createRuntimeEventStore(root); + const firstRun = agentRunHeader({ + runId: 'run-1', + invocationId: 'invocation-1', + turnId: 'turn-1', + cwd: root, + }); + const secondRun = agentRunHeader({ + runId: 'run-2', + invocationId: 'invocation-2', + turnId: 'turn-2', + cwd: root, + createdAt: 3, + updatedAt: 5, + completedAt: 5, + }); + const childRun = agentRunHeader({ + runId: 'run-child', + invocationId: 'invocation-child', + turnId: 'turn-child', + parentRunId: 'run-1', + cwd: root, + createdAt: 2.1, + updatedAt: 2.9, + completedAt: 2.9, + }); + await runStore.createRun(firstRun); + await runStore.createRun(childRun); + await runStore.createRun(secondRun); + const firstEvents = [ + runtimeEvent({ + id: 'event-1-user', + invocationId: 'invocation-1', + runId: 'run-1', + turnId: 'turn-1', + role: 'user', + author: 'user', + content: { kind: 'text', text: 'first' }, + }), + runtimeEvent({ + id: 'event-1-terminal', + invocationId: 'invocation-1', + runId: 'run-1', + turnId: 'turn-1', + ts: 2, + role: 'system', + author: 'system', + status: 'completed', + }), + ]; + const secondEvents = [ + runtimeEvent({ + id: 'event-2-user', + invocationId: 'invocation-2', + runId: 'run-2', + turnId: 'turn-2', + ts: 3, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'second' }, + }), + runtimeEvent({ + id: 'event-2-terminal', + invocationId: 'invocation-2', + runId: 'run-2', + turnId: 'turn-2', + ts: 5, + role: 'system', + author: 'system', + status: 'completed', + }), + ]; + const childEvents = [ + runtimeEvent({ + id: 'event-child-output', + invocationId: 'invocation-child', + runId: 'run-child', + turnId: 'turn-child', + ts: 2.5, + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'legacy child output' }, + }), + runtimeEvent({ + id: 'event-child-terminal', + invocationId: 'invocation-child', + runId: 'run-child', + turnId: 'turn-child', + ts: 2.9, + role: 'system', + author: 'system', + status: 'completed', + }), + ]; + for (const event of [...firstEvents, ...childEvents, ...secondEvents]) { + await runtimeEventStore.appendRuntimeEvent(event.sessionId, event.runId, event); + } + const sourceEvents = [...firstEvents, ...secondEvents]; + const checkpoint = buildHistoryCompactCheckpoint({ + sessionId: 'session-source', + coveredRuntimeEvents: sourceEvents.filter(isHistoryCompactContentEvent), + summary: 'Both retained turns are complete.', + highWaterSeq: 5, + }); + await runStore.appendEvent('session-source', 'run-2', { + type: 'history_compact_checkpoint_recorded', + id: 'checkpoint-cross-run', + runId: 'run-2', + sessionId: 'session-source', + turnId: 'turn-2', + ts: 4, + data: { + checkpointId: checkpoint.checkpointId, + highWaterName: checkpoint.highWaterName, + highWaterSeq: checkpoint.highWaterSeq, + boundaryKind: 'historyCompact', + checkpoint, + }, + }); + const source = await new RuntimeReadModel({ + runStore, + runtimeEventStore, + }).getSessionView('session-source'); + let sequence = 0; + + await cloneConversationRuntimeLedger({ + plan: await prepareTestCopyPlan(source, source.messages, runStore, runtimeEventStore), + copiedMessages: source.messages, + referenceMap: { + mode: 'exact', + sourceSessionId: 'session-source', + targetSessionId: 'session-target', + artifactIds: new Map(), + relativePaths: new Map(), + }, + runStore, + runtimeEventStore, + newId: () => `target-${++sequence}`, + }); + + const targetRuns = await runStore.listSessionRuns('session-target'); + const targetEvents = ( + await Promise.all( + targetRuns.map((run) => runtimeEventStore.readRuntimeEvents('session-target', run.runId)), + ) + ).flat(); + const targetInlineRunIds = new Set( + targetRuns.filter(isSessionInlineRun).map((run) => run.runId), + ); + assert.ok(targetRuns.some((run) => !isSessionInlineRun(run))); + const projectedCheckpoint = await runStore.readEventProjection?.( + 'session-target', + 'history_compact_checkpoint_recorded', + ); + assert.ok(projectedCheckpoint); + assert.ok( + validateHistoryCompactCheckpointShape(projectedCheckpoint.data?.checkpoint, 'session-target'), + ); + assert.equal( + matchHistoryCompactCheckpointPrefix( + projectedCheckpoint.data.checkpoint, + targetEvents.filter( + (event) => targetInlineRunIds.has(event.runId) && isHistoryCompactContentEvent(event), + ), + ).reason, + undefined, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('conversation copy rebuilds a resumed child checkpoint over its child run chain', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-conversation-child-checkpoint-copy-')); + try { + const runStore = createAgentRunStore(root); + const runtimeEventStore = createRuntimeEventStore(root); + const rootRun = agentRunHeader({ + runId: 'run-root', + invocationId: 'invocation-root', + turnId: 'turn-root', + cwd: root, + }); + const firstChild = agentRunHeader({ + runId: 'run-child-1', + invocationId: 'invocation-child-1', + turnId: 'turn-child-1', + parentRunId: 'run-root', + agentId: 'researcher', + agentName: 'Researcher', + cwd: root, + createdAt: 3, + updatedAt: 5, + completedAt: 5, + }); + const resumedChild = agentRunHeader({ + 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, + }); + for (const run of [rootRun, firstChild, resumedChild]) await runStore.createRun(run); + + const rootEvents = [ + 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', + }), + ]; + const firstChildEvents = [ + runtimeEvent({ + id: 'event-child-1-user', + invocationId: 'invocation-child-1', + runId: 'run-child-1', + turnId: 'turn-child-1', + ts: 3, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'first child prompt' }, + }), + runtimeEvent({ + id: 'event-child-1-terminal', + invocationId: 'invocation-child-1', + runId: 'run-child-1', + turnId: 'turn-child-1', + ts: 5, + status: 'completed', + }), + ]; + const resumedChildEvents = [ + runtimeEvent({ + id: 'event-child-2-user', + invocationId: 'invocation-child-2', + runId: 'run-child-2', + turnId: 'turn-child-2', + ts: 6, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'resume child work' }, + }), + runtimeEvent({ + id: 'event-child-2-terminal', + invocationId: 'invocation-child-2', + runId: 'run-child-2', + turnId: 'turn-child-2', + ts: 8, + status: 'completed', + }), + ]; + for (const event of [...rootEvents, ...firstChildEvents, ...resumedChildEvents]) { + await runtimeEventStore.appendRuntimeEvent(event.sessionId, event.runId, event); + } + const childSourceEvents = [...firstChildEvents, ...resumedChildEvents].filter( + isHistoryCompactContentEvent, + ); + const checkpoint = buildHistoryCompactCheckpoint({ + sessionId: 'session-source', + coveredRuntimeEvents: childSourceEvents, + summary: 'The resumed child retained both child turns.', + highWaterSeq: 8, + }); + await runStore.appendEvent('session-source', 'run-child-2', { + type: 'history_compact_checkpoint_recorded', + id: 'checkpoint-child-chain', + runId: 'run-child-2', + sessionId: 'session-source', + turnId: 'turn-child-2', + ts: 7, + data: { + checkpointId: checkpoint.checkpointId, + highWaterName: checkpoint.highWaterName, + highWaterSeq: checkpoint.highWaterSeq, + boundaryKind: 'historyCompact', + checkpoint, + }, + }); + const source = await new RuntimeReadModel({ + runStore, + runtimeEventStore, + }).getSessionView('session-source'); + let sequence = 0; + + const copied = await cloneConversationRuntimeLedger({ + plan: await prepareTestCopyPlan(source, source.messages, runStore, runtimeEventStore), + copiedMessages: source.messages, + referenceMap: { + mode: 'exact', + sourceSessionId: 'session-source', + targetSessionId: 'session-target', + artifactIds: new Map(), + relativePaths: new Map(), + }, + runStore, + runtimeEventStore, + newId: () => `target-${++sequence}`, + }); + + 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 targetChildEvents = ( + await Promise.all( + ['run-child-1', 'run-child-2'].map((sourceRunId) => + runtimeEventStore.readRuntimeEvents('session-target', runIds.get(sourceRunId)!), + ), + ) + ) + .flat() + .filter(isHistoryCompactContentEvent); + const projectedCheckpoint = await runStore.readEventProjection?.( + 'session-target', + 'history_compact_checkpoint_recorded', + ); + assert.ok(projectedCheckpoint); + assert.ok( + validateHistoryCompactCheckpointShape(projectedCheckpoint.data?.checkpoint, 'session-target'), + ); + assert.equal( + matchHistoryCompactCheckpointPrefix(projectedCheckpoint.data.checkpoint, targetChildEvents) + .reason, + undefined, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +function prepareTestCopyPlan( + source: RuntimeReadModelSessionView, + copiedMessages: readonly StoredMessage[], + runStore: Pick, + runtimeEventStore: Pick, +) { + return prepareConversationRuntimeLedgerCopy({ + sourceSessionId: 'session-source', + sourceEvents: source.events, + copiedMessages, + runStore, + runtimeEventStore, + }); +} + +function runtimeEvent(overrides: Partial): RuntimeEvent { + return { + id: 'event', + invocationId: 'invocation-source', + runId: 'run-source', + sessionId: 'session-source', + turnId: 'turn-1', + ts: 1, + partial: false, + role: 'system', + author: 'system', + ...overrides, + }; +} + +function agentRunHeader(overrides: Partial): AgentRunHeader { + 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, + }; +} diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 78f4362f06..c062e8bc3e 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -10030,6 +10030,18 @@ describe('SessionManager permission mode updates', () => { expect(regenUser?.type === 'user' ? regenUser.text : undefined).toBe('aborted turn text'); }); + test('Host conversation copy fails closed without a side-effect-free message snapshot', async () => { + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const manager = makeManagerForReadCutover(store, runStore); + const session = await manager.createSession(makeInput({ name: 'Parent' })); + + await assert.rejects( + () => manager.readConversationCopySnapshot(session.id), + /Conversation copy requires a side-effect-free message snapshot/, + ); + }); + test('branchFromTurn copies through the RuntimeEvent-primary message boundary', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); @@ -10262,7 +10274,7 @@ describe('SessionManager permission mode updates', () => { expect(childInput.runtimeContext?.[0]?.sessionId).toBe(child.id); }); - test('branchFromTurn never leaves a terminal cloned run header without a terminal RuntimeEvent fact', async () => { + test('branchFromTurn removes an incomplete target when Runtime ledger copy fails', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore({ failRuntimeEventAppendAfter: 5 }); const manager = makeManagerForReadCutover(store, runStore); @@ -10289,10 +10301,10 @@ describe('SessionManager permission mode updates', () => { ); const child = (await store.list()).find((summary) => summary.parentSessionId === session.id); - expect(child).toBeDefined(); - const childRuns = await runStore.listSessionRuns(child!.id); + expect(child).toBeUndefined(); + const childRuns = await runStore.listSessionRuns('session-2'); for (const run of childRuns) { - const runtimeEvents = await runStore.readRuntimeEvents(child!.id, run.runId); + const runtimeEvents = await runStore.readRuntimeEvents('session-2', run.runId); const hasTerminalFact = runtimeEvents.some(isTerminalRuntimeEvent); expect( run.status === 'completed' || run.status === 'failed' || run.status === 'cancelled' diff --git a/packages/runtime/src/conversation-copy.ts b/packages/runtime/src/conversation-copy.ts new file mode 100644 index 0000000000..7b3cc6986d --- /dev/null +++ b/packages/runtime/src/conversation-copy.ts @@ -0,0 +1,1161 @@ +import type { + AgentRunEvent, + AgentRunHeader, + AgentRunStore, + RuntimeEvent, + RuntimeEventStore, + StorageRef, + StoredMessage, + ToolResultContent, +} from '@maka/core'; +import { decodeCanonicalToolResultContent, isSessionInlineRun } from '@maka/core'; +import { TOOL_RECOVERY_DECISION_FACT_KIND } from '@maka/core/tool-recovery-fact'; +import { + buildHistoryCompactCheckpoint, + matchHistoryCompactCheckpointPrefix, + validateHistoryCompactCheckpointShape, +} from './history-compact-checkpoint.js'; +import { isHistoryCompactContentEvent } from './history-compact.js'; +import { + classifyTerminalRuntimeLedger, + commitTerminalRunWithRuntimeFact, +} from './terminal-run-commit.js'; +import { buildToolOperationId } from './runtime-commit-sink.js'; +import { + buildToolResultArchiveResourceRef, + parseToolResultArchiveResourceRef, +} from './tool-result-archive-resource.js'; +import { + deserializeToolResultArchive, + isArchivedToolResultPlaceholder, + type ArchivedToolResultPlaceholder, +} from './tool-result-archive.js'; + +export interface ConversationCopySlice { + readonly messages: readonly StoredMessage[]; + readonly turnIds: readonly string[]; + readonly beforeTs?: number; +} + +interface ConversationCopyIdentityMap { + readonly sourceSessionId: string; + readonly targetSessionId: string; +} + +export type ConversationCopyArtifactReferenceMap = + | (ConversationCopyIdentityMap & { + readonly mode: 'exact'; + readonly artifactIds: ReadonlyMap; + readonly relativePaths: ReadonlyMap; + }) + | (ConversationCopyIdentityMap & { + readonly mode: 'preserve_external'; + }); + +export type ConversationCopyMessageReferenceMap = ConversationCopyArtifactReferenceMap & { + readonly runIds: ReadonlyMap; + readonly runtimeEventIds: ReadonlyMap; + readonly providerTraceIds: ReadonlyMap; +}; + +export type ConversationCopyReferenceMap = ConversationCopyMessageReferenceMap & { + readonly invocationIds: ReadonlyMap; + readonly operationIds: ReadonlyMap; + readonly agentRunEventIds: ReadonlyMap; +}; + +export interface CloneConversationRuntimeLedgerInput { + readonly plan: ConversationRuntimeLedgerCopyPlan; + readonly copiedMessages: readonly StoredMessage[]; + readonly referenceMap: ConversationCopyArtifactReferenceMap; + readonly runStore: AgentRunStore; + readonly runtimeEventStore: RuntimeEventStore & { + importConversationCopyRuntimeEvents?( + sessionId: string, + batches: readonly { + readonly runId: string; + readonly events: readonly RuntimeEvent[]; + }[], + ): Promise; + }; + readonly newId: () => string; +} + +export interface ConversationRuntimeLedgerCopyPlan { + readonly sourceSessionId: string; + readonly copyTurnIds: readonly string[]; + readonly inlineRuntimeEvents: readonly RuntimeEvent[]; + readonly runs: readonly { + readonly run: AgentRunHeader; + readonly runtimeEvents: readonly RuntimeEvent[]; + readonly operationalEvents: readonly AgentRunEvent[]; + }[]; +} + +export interface CloneConversationRuntimeLedgerResult { + readonly copiedMessages: readonly StoredMessage[]; + readonly runIdMap: readonly { + readonly sourceRunId: string; + readonly targetRunId: string; + }[]; +} + +export function createConversationCopySlice( + messages: readonly StoredMessage[], + sourceTurnId: string, + boundary: 'through' | 'before', +): ConversationCopySlice | null { + const turnOrder: string[] = []; + const seen = new Set(); + for (const message of messages) { + const turnId = messageTurnId(message); + if (turnId && !seen.has(turnId)) { + seen.add(turnId); + turnOrder.push(turnId); + } + } + const sourceIndex = turnOrder.indexOf(sourceTurnId); + if (sourceIndex < 0) return null; + const retainedTurnIds = + boundary === 'through' ? turnOrder.slice(0, sourceIndex + 1) : turnOrder.slice(0, sourceIndex); + const retained = new Set(retainedTurnIds); + const firstExcludedTurnId = + boundary === 'through' ? turnOrder[sourceIndex + 1] : turnOrder[sourceIndex]; + const firstExcludedTimestamps = + firstExcludedTurnId === undefined + ? [] + : messages + .filter((message) => messageTurnId(message) === firstExcludedTurnId) + .map((message) => message.ts); + return { + messages: messages.filter((message) => { + if (message.type === 'turn_state') return false; + const turnId = messageTurnId(message); + return turnId !== undefined && retained.has(turnId); + }), + turnIds: retainedTurnIds, + ...(firstExcludedTimestamps.length > 0 + ? { beforeTs: Math.min(...firstExcludedTimestamps) } + : {}), + }; +} + +export function rewriteConversationCopyMessage( + message: StoredMessage, + references: ConversationCopyMessageReferenceMap, +): StoredMessage { + if (message.type === 'user' && message.attachments) { + return { + ...message, + attachments: message.attachments.map((attachment) => ({ + ...attachment, + ref: rewriteStorageRef(attachment.ref, references), + })), + }; + } + if (message.type === 'tool_result') { + return { + ...message, + content: rewriteToolResultContent(message.content, references), + }; + } + if (message.type === 'token_usage' && message.providerRequestTraceId) { + return { + ...message, + providerRequestTraceId: rewriteOwnedId( + message.providerRequestTraceId, + references.providerTraceIds, + 'provider trace', + ), + }; + } + return message; +} + +export async function prepareConversationRuntimeLedgerCopy(input: { + readonly sourceSessionId: string; + readonly sourceEvents: readonly RuntimeEvent[]; + readonly copiedMessages: readonly StoredMessage[]; + readonly runStore: Pick; + readonly runtimeEventStore: Pick; +}): Promise { + const sourceRuns = await input.runStore.listSessionRuns(input.sourceSessionId); + const transcriptTurnIds = [ + ...new Set( + input.copiedMessages.map(messageTurnId).filter((turnId): turnId is string => !!turnId), + ), + ]; + const copyTurnIds = conversationCopyTurnClosure(sourceRuns, transcriptTurnIds); + const selectedRunEvents = await loadConversationCopyRunEvents( + sourceRuns, + input.sourceEvents, + copyTurnIds, + input.runtimeEventStore, + ); + 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 (isTerminalRunStatus(run.status) && terminal.kind !== 'fact') { + throw new Error(`Cannot copy terminal AgentRun ${run.runId} without one terminal fact`); + } + return { run, runtimeEvents: events, operationalEvents }; + }), + ); + return { + sourceSessionId: input.sourceSessionId, + copyTurnIds, + inlineRuntimeEvents: [...input.sourceEvents], + runs, + }; +} + +export async function cloneConversationRuntimeLedger( + input: CloneConversationRuntimeLedgerInput, +): Promise { + if (input.plan.sourceSessionId !== input.referenceMap.sourceSessionId) { + throw new Error('Conversation copy plan does not belong to the source Session'); + } + const flattenedPlans = input.plan.runs.map(({ run, runtimeEvents, operationalEvents }) => ({ + run, + events: runtimeEvents, + operationalEvents, + terminal: classifyTerminalRuntimeLedger(run, runtimeEvents), + })); + const sourceCompactableEvents = sourceCompactableEventsByRunId( + flattenedPlans, + input.plan.inlineRuntimeEvents, + ); + const runIds = new Map(flattenedPlans.map(({ run }) => [run.runId, input.newId()])); + const targetInvocationIds = new Map(flattenedPlans.map(({ run }) => [run.runId, input.newId()])); + const invocationIds = new Map( + flattenedPlans.flatMap(({ run }) => + run.invocationId ? [[run.invocationId, targetInvocationIds.get(run.runId)!] as const] : [], + ), + ); + const copiedPermissionDecisions = new Map( + input.copiedMessages.flatMap((message) => + message.type === 'permission_decision' ? [[message.id, message] as const] : [], + ), + ); + const runtimeEventIds = new Map( + flattenedPlans.flatMap(({ events }) => + events.map((event) => [event.id, input.newId()] as const), + ), + ); + const operationalEventIds = new Map( + flattenedPlans.flatMap(({ operationalEvents }) => + operationalEvents.flatMap((event) => + isCopiedAgentRunEvent(event) ? [[event.id, input.newId()] as const] : [], + ), + ), + ); + const providerTraceIds = providerTraceIdMap(flattenedPlans, input.newId); + const operationIds = toolOperationIdMap(flattenedPlans, targetInvocationIds); + const references: ConversationCopyReferenceMap = { + ...input.referenceMap, + runIds, + invocationIds, + operationIds, + runtimeEventIds, + providerTraceIds, + agentRunEventIds: operationalEventIds, + }; + const clonedEventBySourceId = new Map(); + for (const plan of flattenedPlans) { + const runId = runIds.get(plan.run.runId)!; + const invocationId = targetInvocationIds.get(plan.run.runId)!; + for (const event of plan.events) { + clonedEventBySourceId.set( + event.id, + cloneRuntimeEvent( + event, + { + sessionId: input.referenceMap.targetSessionId, + runId, + eventId: runtimeEventIds.get(event.id)!, + invocationId, + }, + references, + copiedPermissionDecisions, + ), + ); + } + } + const checkpointIds = new Map(); + 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, + { + sessionId: input.referenceMap.targetSessionId, + runId, + eventId: operationalEventIds.get(event.id), + }, + references, + sourceCompactableEvents.get(plan.run.runId) ?? [], + clonedEventBySourceId, + checkpointIds, + operationalEventIds, + providerTraceIds, + ); + return clonedEvent ? [clonedEvent] : []; + }); + const terminalEvent = + plan.terminal.kind === 'fact' && isTerminalRunStatus(plan.run.status) + ? clonedEventBySourceId.get(plan.terminal.fact.terminalEvent.id) + : undefined; + if (plan.terminal.kind === 'fact' && isTerminalRunStatus(plan.run.status) && !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, + ), + clonedRuntimeEvents: plan.events.map((event) => clonedEventBySourceId.get(event.id)!), + clonedOperationalEvents, + terminalEvent, + }; + }); + const copiedMessages = input.copiedMessages.map((message) => + rewriteConversationCopyMessage(message, references), + ); + + for (const { clonedRun } of preparedPlans) { + await input.runStore.createRun(clonedRun); + } + + if (input.runtimeEventStore.importConversationCopyRuntimeEvents) { + await input.runtimeEventStore.importConversationCopyRuntimeEvents( + input.referenceMap.targetSessionId, + preparedPlans.map(({ runId, clonedRuntimeEvents }) => ({ + runId, + events: clonedRuntimeEvents, + })), + ); + } else { + for (const { runId, clonedRuntimeEvents } of preparedPlans) { + for (const clonedEvent of clonedRuntimeEvents) { + await input.runtimeEventStore.appendRuntimeEvent( + input.referenceMap.targetSessionId, + runId, + clonedEvent, + ); + } + } + } + + for (const { plan, runId, clonedOperationalEvents, terminalEvent } of preparedPlans) { + for (const clonedEvent of clonedOperationalEvents) { + await input.runStore.appendEvent(input.referenceMap.targetSessionId, runId, clonedEvent); + } + + if (plan.terminal.kind === 'fact' && isTerminalRunStatus(plan.run.status) && terminalEvent) { + await commitTerminalRunWithRuntimeFact({ + runStore: input.runStore, + runtimeEventStore: input.runtimeEventStore, + newId: input.newId, + sessionId: input.referenceMap.targetSessionId, + runId, + turnId: plan.run.turnId, + status: plan.terminal.fact.runStatus, + ts: terminalEvent.ts, + terminalEvent, + ...(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, + }, + }); + } + } + + return { + copiedMessages, + runIdMap: [...runIds].map(([sourceRunId, targetRunId]) => ({ + sourceRunId, + targetRunId, + })), + }; +} + +interface ConversationCopyRunEvents { + readonly run: AgentRunHeader; + readonly events: readonly RuntimeEvent[]; +} + +async function loadConversationCopyRunEvents( + sourceRuns: readonly AgentRunHeader[], + sourceEvents: readonly RuntimeEvent[], + copyTurnIds: readonly string[], + runtimeEventStore: Pick, +): Promise { + const copiedTurnIds = new Set(copyTurnIds); + return Promise.all( + sourceRuns.flatMap((run) => { + if (!copiedTurnIds.has(run.turnId)) return []; + const projectedEvents = sourceEvents.filter( + (event) => event.runId === run.runId && copiedTurnIds.has(event.turnId), + ); + return [ + Promise.resolve( + projectedEvents.length > 0 + ? projectedEvents + : runtimeEventStore.readRuntimeEvents(run.sessionId, run.runId), + ).then((events) => ({ run, events })), + ]; + }), + ); +} + +export function archivedToolResultContainsConversationOwnedReferences( + serializedResult: string, + sourceSessionId: string, +): boolean { + const value = deserializeToolResultArchive(serializedResult); + if (isArchivedToolResultPlaceholder(value)) return true; + + let content: ToolResultContent; + try { + content = decodeCanonicalToolResultContent(value); + } catch { + return false; + } + + if (content.kind === 'archived_tool_result') return true; + if (content.kind === 'image') { + return content.ref.kind === 'session_file' && content.ref.sessionId === sourceSessionId; + } + if (content.kind === 'subagent') { + return ( + content.childSessionId !== undefined || + content.runId !== undefined || + content.artifactIds.length > 0 + ); + } + if (content.kind === 'agent_swarm') { + return content.items.some( + (item) => + item.childSessionId !== undefined || + item.runId !== undefined || + item.resumedFromRunId !== undefined || + item.artifactIds.length > 0, + ); + } + return false; +} + +function cloneAgentRunEvent( + event: AgentRunEvent, + ids: { + readonly sessionId: string; + readonly runId: string; + readonly eventId?: string; + }, + references: ConversationCopyReferenceMap, + sourceCompactableEvents: readonly RuntimeEvent[], + clonedRuntimeEvents: ReadonlyMap, + checkpointIds: Map, + operationalEventIds: ReadonlyMap, + providerTraceIds: ReadonlyMap, +): AgentRunEvent | null { + if (event.type === 'event_corrupt') { + throw new Error(`Cannot copy corrupt AgentRun event ${event.id}`); + } + if (!isCopiedAgentRunEvent(event)) return null; + if (!ids.eventId) { + throw new Error(`Cannot copy AgentRun event ${event.id} without a target identity`); + } + + 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 === 'history_compact_checkpoint_recorded') { + const sourceCheckpoint = event.data?.checkpoint; + if (!validateHistoryCompactCheckpointShape(sourceCheckpoint, event.sessionId)) { + throw new Error(`Cannot copy invalid history compact checkpoint ${event.id}`); + } + const match = matchHistoryCompactCheckpointPrefix(sourceCheckpoint, sourceCompactableEvents); + if (match.reason) { + throw new Error(`Cannot copy unmatched history compact checkpoint ${event.id}`); + } + const coveredRuntimeEvents = match.coveredRuntimeEvents.map((sourceEvent) => { + const cloned = clonedRuntimeEvents.get(sourceEvent.id); + if (!cloned) { + throw new Error( + `History compact checkpoint ${event.id} crosses the conversation copy boundary`, + ); + } + return cloned; + }); + const headAnchor = + sourceCheckpoint.phase === 'mid_turn' + ? { + runtimeEventId: + clonedRuntimeEvents.get(sourceCheckpoint.headAnchor!.runtimeEventId)?.id ?? + sourceCheckpoint.headAnchor!.runtimeEventId, + turnId: sourceCheckpoint.headAnchor!.turnId, + } + : undefined; + const checkpoint = buildHistoryCompactCheckpoint({ + sessionId: references.targetSessionId, + coveredRuntimeEvents, + summary: sourceCheckpoint.summary, + highWaterName: sourceCheckpoint.highWaterName, + highWaterSeq: sourceCheckpoint.highWaterSeq, + now: sourceCheckpoint.createdAt, + ...(sourceCheckpoint.phase ? { phase: sourceCheckpoint.phase } : {}), + ...(headAnchor ? { headAnchor } : {}), + ...(sourceCheckpoint.previousCheckpointId && + checkpointIds.has(sourceCheckpoint.previousCheckpointId) + ? { + previousCheckpointId: checkpointIds.get(sourceCheckpoint.previousCheckpointId)!, + } + : {}), + }); + checkpointIds.set(sourceCheckpoint.checkpointId, checkpoint.checkpointId); + data = { + ...event.data, + checkpointId: checkpoint.checkpointId, + checkpoint, + }; + } + + return { + ...event, + id: ids.eventId, + sessionId: ids.sessionId, + runId: ids.runId, + ...(data ? { data } : {}), + }; +} + +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, + captureId: requiredMappedId(operationalEventIds, data.captureId, 'provider request capture'), + captureArtifactId: rewriteOwnedArtifactId(data.captureArtifactId, references), + }; +} + +function providerRequestCapture(event: AgentRunEvent): Record & { + readonly traceId: string; + readonly captureId: string; + readonly artifactId: string; +} { + const data = event.data; + if ( + !data || + data.captureId !== event.id || + typeof data.traceId !== 'string' || + typeof data.artifactId !== 'string' + ) { + throw new Error(`Cannot copy invalid provider request capture ${event.id}`); + } + return { + ...data, + traceId: data.traceId, + captureId: data.captureId, + artifactId: data.artifactId, + }; +} + +function providerRequestAttempt(event: AgentRunEvent): Record & { + readonly traceId: string; + readonly attemptId: string; + readonly captureId: string; + readonly captureArtifactId: string; +} { + const data = event.data; + if ( + !data || + data.attemptId !== event.id || + typeof data.traceId !== 'string' || + typeof data.captureId !== 'string' || + typeof data.captureArtifactId !== 'string' + ) { + throw new Error(`Cannot copy invalid provider request attempt ${event.id}`); + } + return { + ...data, + traceId: data.traceId, + attemptId: data.attemptId, + captureId: data.captureId, + captureArtifactId: data.captureArtifactId, + }; +} + +function requiredMappedId( + ids: ReadonlyMap, + sourceId: string, + kind: string, +): string { + const targetId = ids.get(sourceId); + if (!targetId) throw new Error(`Conversation copy is missing ${kind} ${sourceId}`); + return targetId; +} + +function rewriteOwnedArtifactId( + sourceArtifactId: string, + references: ConversationCopyArtifactReferenceMap, +): string { + if (references.mode === 'preserve_external') return sourceArtifactId; + return rewriteOwnedId(sourceArtifactId, references.artifactIds, 'Artifact'); +} + +function rewriteOwnedId(sourceId: string, ids: ReadonlyMap, kind: string): string { + return requiredMappedId(ids, sourceId, kind); +} + +function providerTraceIdMap( + plans: readonly { readonly operationalEvents: readonly AgentRunEvent[] }[], + newId: () => string, +): Map { + 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' + ) { + continue; + } + const traceId = event.data?.traceId; + if (typeof traceId === 'string' && !result.has(traceId)) result.set(traceId, newId()); + } + } + return result; +} + +function toolOperationIdMap( + plans: readonly { + readonly run: AgentRunHeader; + readonly events: readonly RuntimeEvent[]; + }[], + targetInvocationIds: ReadonlyMap, +): Map { + const result = new Map(); + for (const { run, events } of plans) { + const invocationId = requiredMappedId(targetInvocationIds, run.runId, 'target invocation'); + for (const event of events) { + const dispatch = event.actions?.toolDispatch; + if (!dispatch) continue; + const targetOperationId = buildToolOperationId({ + invocationId, + providerToolCallId: dispatch.providerToolCallId, + }); + const existing = result.get(dispatch.operationId); + if (existing && existing !== targetOperationId) { + throw new Error(`Tool operation ${dispatch.operationId} crosses copied AgentRuns`); + } + result.set(dispatch.operationId, targetOperationId); + } + } + return result; +} + +function isCopiedAgentRunEvent(event: AgentRunEvent): boolean { + // Active/semantic blocks hash the exact provider-visible source. Rewriting + // target-owned RuntimeEvent and Artifact references invalidates that + // evidence, so a copied Session starts without these derived diagnostics. + return ( + event.type !== 'run_completed' && + event.type !== 'run_failed' && + event.type !== 'run_cancelled' && + event.type !== 'event_corrupt' && + event.type !== 'active_full_compact_block_recorded' && + event.type !== 'semantic_compact_block_recorded' + ); +} + +function cloneRuntimeEvent( + event: RuntimeEvent, + ids: { + readonly sessionId: string; + readonly runId: string; + readonly eventId: string; + readonly invocationId: string; + }, + references: ConversationCopyReferenceMap, + copiedPermissionDecisions: ReadonlyMap< + string, + Extract + >, +): RuntimeEvent { + const rewritten = rewriteRuntimeEventReferences(event, references); + const cloned: RuntimeEvent = { + ...rewritten, + id: ids.eventId, + invocationId: ids.invocationId, + sessionId: ids.sessionId, + runId: ids.runId, + }; + const accepted = event.actions?.permissionAnswerAccepted; + const decision = accepted ? copiedPermissionDecisions.get(accepted.requestId) : undefined; + if (!decision || !cloned.actions) return cloned; + const { permissionAnswerAccepted: _accepted, ...actions } = cloned.actions; + cloned.actions = { + ...actions, + permissionDecision: { + requestId: decision.id, + toolName: decision.toolName, + decision: decision.decision, + ...(decision.rememberForTurn !== undefined + ? { rememberForTurn: decision.rememberForTurn } + : {}), + ...(decision.reviewer !== undefined ? { reviewer: decision.reviewer } : {}), + ...(decision.rationale !== undefined ? { rationale: decision.rationale } : {}), + ...(decision.riskLevel !== undefined ? { riskLevel: decision.riskLevel } : {}), + }, + }; + cloned.ts = decision.ts; + return cloned; +} + +function cloneRunHeader( + source: AgentRunHeader, + targetSessionId: string, + runId: string, + invocationId: string, + 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 + ? { + continuationSource: { + ...source.continuationSource, + sourceInvocationId: rewriteOwnedId( + source.continuationSource.sourceInvocationId, + references.invocationIds, + 'invocation', + ), + sourceRunId: rewriteOwnedId( + source.continuationSource.sourceRunId, + references.runIds, + 'AgentRun', + ), + }, + } + : {}), + }; + if (isTerminalRunStatus(source.status)) { + cloned.status = 'running'; + delete cloned.completedAt; + delete cloned.failureClass; + delete cloned.failureMessage; + delete cloned.abortSource; + } + return cloned; +} + +function rewriteRuntimeEventReferences( + event: RuntimeEvent, + references: ConversationCopyReferenceMap, +): RuntimeEvent { + const content = + event.content?.kind === 'text' && event.content.attachments + ? { + ...event.content, + attachments: event.content.attachments.map((attachment) => ({ + ...attachment, + ref: rewriteStorageRef(attachment.ref, references), + })), + } + : event.content?.kind === 'function_response' + ? { + ...event.content, + result: rewriteRuntimeToolResult(event.content.result, references), + } + : event.content; + const refs = event.refs + ? (() => { + const { operationId: _operationId, traceEventId: _traceEventId, ...preserved } = event.refs; + const traceEventId = event.refs.traceEventId + ? references.agentRunEventIds.get(event.refs.traceEventId) + : undefined; + return { + ...preserved, + ...(traceEventId ? { traceEventId } : {}), + ...(event.refs.operationId + ? { + operationId: rewriteOwnedId( + event.refs.operationId, + references.operationIds, + 'tool operation', + ), + } + : {}), + ...(event.refs.artifactId + ? { + artifactId: rewriteOwnedArtifactId(event.refs.artifactId, references), + } + : {}), + ...(event.refs.sourceInvocationId + ? { + sourceInvocationId: rewriteOwnedId( + event.refs.sourceInvocationId, + references.invocationIds, + 'invocation', + ), + } + : {}), + ...(event.refs.sourceRunId + ? { + sourceRunId: rewriteOwnedId(event.refs.sourceRunId, references.runIds, 'AgentRun'), + } + : {}), + ...(event.refs.providerRequestTraceId + ? { + providerRequestTraceId: rewriteOwnedId( + event.refs.providerRequestTraceId, + references.providerTraceIds, + 'provider trace', + ), + } + : {}), + }; + })() + : undefined; + const actions = rewriteRuntimeEventActions(event.actions, references); + return { + ...event, + ...(content ? { content } : {}), + ...(actions ? { actions } : {}), + ...(refs ? { refs } : {}), + }; +} + +function rewriteRuntimeEventActions( + actions: RuntimeEvent['actions'], + references: ConversationCopyReferenceMap, +): RuntimeEvent['actions'] { + const dispatch = actions?.toolDispatch; + const recovery = actions?.toolRecovery; + if (!dispatch && !recovery) return actions; + const operationId = dispatch?.operationId ?? recovery?.payload.operationId; + const targetOperationId = operationId + ? rewriteOwnedId(operationId, references.operationIds, 'tool operation') + : undefined; + return { + ...actions, + ...(dispatch && targetOperationId + ? { toolDispatch: { ...dispatch, operationId: targetOperationId } } + : {}), + ...(recovery && targetOperationId + ? { + toolRecovery: rewriteToolRecoveryFact(recovery, targetOperationId, references), + } + : {}), + }; +} + +function rewriteToolRecoveryFact( + recovery: NonNullable['toolRecovery'], + operationId: string, + references: ConversationCopyReferenceMap, +): NonNullable['toolRecovery'] { + if (!recovery || recovery.kind !== TOOL_RECOVERY_DECISION_FACT_KIND) { + return recovery ? { ...recovery, payload: { ...recovery.payload, operationId } } : recovery; + } + const payload = recovery.payload; + return { + ...recovery, + payload: { + ...payload, + operationId, + evidenceEventIds: payload.evidenceEventIds.map((eventId) => + requiredMappedId(references.runtimeEventIds, eventId, 'RuntimeEvent'), + ), + ...(payload.disposition === 'completed' + ? { + outcomeEventId: requiredMappedId( + references.runtimeEventIds, + payload.outcomeEventId, + 'RuntimeEvent', + ), + } + : {}), + }, + }; +} + +function rewriteToolResultContent( + content: ToolResultContent, + references: ConversationCopyMessageReferenceMap, +): ToolResultContent { + if (content.kind === 'image') { + return { ...content, ref: rewriteStorageRef(content.ref, references) }; + } + if (content.kind === 'archived_tool_result') { + return { + ...content, + runtimeEventId: rewriteOwnedId( + content.runtimeEventId, + references.runtimeEventIds, + 'RuntimeEvent', + ), + ...(content.artifactId + ? { artifactId: rewriteOwnedArtifactId(content.artifactId, references) } + : {}), + }; + } + if (content.kind === 'json' && isArchivedToolResultPlaceholder(content.value)) { + return { + ...content, + value: rewriteArchivedToolResult(content.value, references), + }; + } + if (content.kind === 'subagent') { + return { + ...content, + ...(content.runId + ? { runId: rewriteOwnedId(content.runId, references.runIds, 'AgentRun') } + : {}), + artifactIds: rewriteArtifactIds(content.artifactIds, references), + }; + } + if (content.kind === 'agent_swarm') { + return { + ...content, + items: content.items.map((item) => ({ + ...item, + ...(item.runId ? { runId: rewriteOwnedId(item.runId, references.runIds, 'AgentRun') } : {}), + ...(item.resumedFromRunId + ? { + resumedFromRunId: rewriteOwnedId( + item.resumedFromRunId, + references.runIds, + 'AgentRun', + ), + } + : {}), + artifactIds: rewriteArtifactIds(item.artifactIds, references), + })), + }; + } + return content; +} + +function rewriteRuntimeToolResult( + value: unknown, + references: ConversationCopyMessageReferenceMap, +): unknown { + if (isArchivedToolResultPlaceholder(value)) { + return rewriteArchivedToolResult(value, references); + } + let content: ToolResultContent; + try { + content = decodeCanonicalToolResultContent(value); + } catch { + return value; + } + return rewriteToolResultContent(content, references); +} + +function rewriteArtifactIds( + artifactIds: readonly string[], + references: ConversationCopyArtifactReferenceMap, +): readonly string[] { + return artifactIds.map((artifactId) => rewriteOwnedArtifactId(artifactId, references)); +} + +function rewriteStorageRef( + ref: StorageRef, + references: ConversationCopyArtifactReferenceMap, +): StorageRef { + if (ref.kind !== 'session_file' || ref.sessionId !== references.sourceSessionId) return ref; + if (references.mode === 'preserve_external') return ref; + const relativePath = references.relativePaths.get(ref.relativePath); + if (!relativePath) { + throw new Error(`Conversation copy is missing Session file ${ref.relativePath}`); + } + return { + ...ref, + sessionId: references.targetSessionId, + relativePath, + }; +} + +function rewriteArchivedToolResult( + value: ArchivedToolResultPlaceholder, + references: ConversationCopyMessageReferenceMap, +): ArchivedToolResultPlaceholder { + const artifactId = rewriteOwnedArtifactId(value.artifactId, references); + const resource = value.resourceRef + ? parseToolResultArchiveResourceRef(value.resourceRef) + : undefined; + return { + ...value, + runtimeEventId: rewriteOwnedId( + value.runtimeEventId, + references.runtimeEventIds, + 'RuntimeEvent', + ), + artifactId, + ...(resource && artifactId !== value.artifactId + ? { + resourceRef: buildToolResultArchiveResourceRef({ + ...resource, + artifactId, + }), + } + : {}), + }; +} + +function messageTurnId(message: StoredMessage): string | undefined { + return 'turnId' in message && typeof message.turnId === 'string' ? message.turnId : undefined; +} + +function conversationCopyTurnClosure( + runs: readonly AgentRunHeader[], + retainedTurnIds: readonly string[], +): string[] { + const result = [...new Set(retainedTurnIds)]; + const includedTurnIds = new Set(result); + const includedRunIds = new Set( + runs.filter((run) => includedTurnIds.has(run.turnId)).map((run) => run.runId), + ); + for (let changed = true; changed; ) { + changed = false; + for (const run of runs) { + if ( + isSessionInlineRun(run) || + !run.parentRunId || + !includedRunIds.has(run.parentRunId) || + includedRunIds.has(run.runId) + ) { + continue; + } + includedRunIds.add(run.runId); + if (!includedTurnIds.has(run.turnId)) { + includedTurnIds.add(run.turnId); + result.push(run.turnId); + } + changed = true; + } + } + return result; +} + +function sourceCompactableEventsByRunId( + plans: readonly { + readonly run: AgentRunHeader; + readonly events: readonly RuntimeEvent[]; + }[], + sessionEvents: readonly RuntimeEvent[], +): ReadonlyMap { + const plansByRunId = new Map(plans.map((plan) => [plan.run.runId, plan])); + const inlineEvents = sessionEvents.filter(isHistoryCompactContentEvent); + const result = new Map(); + + for (const plan of plans) { + if (isSessionInlineRun(plan.run)) { + result.set(plan.run.runId, inlineEvents); + continue; + } + + const reverseChain = []; + const visited = new Set(); + let cursor: (typeof plans)[number] | undefined = plan; + while (cursor) { + if (visited.has(cursor.run.runId)) { + throw new Error( + `Conversation copy child resume lineage contains a cycle at ${cursor.run.runId}`, + ); + } + visited.add(cursor.run.runId); + reverseChain.push(cursor); + const sourceRunId = cursor.run.resumedFromRunId; + if (!sourceRunId) break; + cursor = plansByRunId.get(sourceRunId); + if (!cursor) { + throw new Error( + `Conversation copy child resume source ${sourceRunId} crosses the copy boundary`, + ); + } + } + result.set( + plan.run.runId, + reverseChain + .reverse() + .flatMap((item) => item.events) + .filter(isHistoryCompactContentEvent), + ); + } + + return result; +} + +function isTerminalRunStatus(status: AgentRunHeader['status']): boolean { + return status === 'completed' || status === 'failed' || status === 'cancelled'; +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index a0030ee5d8..e377bd1945 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -45,6 +45,19 @@ export type { AgentOutputResult, StopSessionInput, } from './session-manager.js'; +export { + archivedToolResultContainsConversationOwnedReferences, + cloneConversationRuntimeLedger, + createConversationCopySlice, + prepareConversationRuntimeLedgerCopy, +} from './conversation-copy.js'; +export type { + CloneConversationRuntimeLedgerInput, + CloneConversationRuntimeLedgerResult, + ConversationCopyArtifactReferenceMap, + ConversationCopySlice, + ConversationRuntimeLedgerCopyPlan, +} from './conversation-copy.js'; export type { SubagentExecutionRef } from './subagent-execution.js'; export { AGENT_GRAPH_RECORD_FACETS, diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index adf2e9eeb4..a739db752d 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -74,6 +74,7 @@ import { decodeAgentGraphIntentClaim, executionBoundaryContains, failureClassFromCompleteStopReason, + deriveTurnRecords, isActiveShellRunStatus, isDeepResearchSession, isSessionInlineRun, @@ -102,9 +103,15 @@ import { type RuntimeEventTerminalFact } from './runtime-event-read-model.js'; import { RuntimeReadModel, RuntimeReadModelError, + type RuntimeReadModelProjectionCache, type RuntimeReadModelSessionView, } from './runtime-read-model.js'; import { inspectAgentRunReadModel, type AgentRunInspectModel } from './agent-run-inspect.js'; +import { + cloneConversationRuntimeLedger as cloneConversationLedger, + createConversationCopySlice, + prepareConversationRuntimeLedgerCopy, +} from './conversation-copy.js'; import { firstRuntimeRepairRunId, RuntimeLedgerRepair } from './runtime-ledger-repair.js'; import { buildRecoveredTerminalRuntimeEvent, @@ -560,6 +567,7 @@ export interface SessionStore { list(filter?: SessionListFilter): Promise; readHeader(sessionId: string): Promise; readMessages(sessionId: string): Promise; + readMessagesSnapshot?(sessionId: string): Promise; listTurns(sessionId: string): Promise; appendMessage(sessionId: string, m: StoredMessage): Promise; appendMessages(sessionId: string, ms: StoredMessage[]): Promise; @@ -4234,23 +4242,34 @@ export class SessionManager { async branchFromTurn(sessionId: string, input: BranchFromTurnInput): Promise { const sourceView = await this.getSessionView(sessionId); - // Inclusive: keep everything up to and including the chosen turn. A found - // turn always has at least its own messages, so an empty copy means the - // turn does not exist. - const copied = copyMessagesThroughTurnBoundary(sourceView.messages, input.sourceTurnId); - if (copied.length === 0) - throw new Error(`Cannot branch from unknown turn ${input.sourceTurnId}`); - return this.createBranchSession(sessionId, sourceView, copied, input); + const slice = createConversationCopySlice(sourceView.messages, input.sourceTurnId, 'through'); + if (!slice) throw new Error(`Cannot branch from unknown turn ${input.sourceTurnId}`); + return this.createBranchSession(sessionId, sourceView, [...slice.messages], input); + } + + /** Canonical, repaired source view for a Host-owned cross-Session copy. */ + async readConversationCopySnapshot(sessionId: string): Promise { + const readMessagesSnapshot = this.deps.store.readMessagesSnapshot; + if (!readMessagesSnapshot) { + throw new Error('Conversation copy requires a side-effect-free message snapshot'); + } + 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; + const messages = await readMessages(sessionId); + if (messages.length === 0) return view; + return { + ...view, + messages, + turns: deriveTurnRecords(messages), + }; } async branchBeforeTurn(sessionId: string, input: BranchFromTurnInput): Promise { const sourceView = await this.getSessionView(sessionId); - // Exclusive dual of branchFromTurn: keep everything strictly before the - // chosen turn, dropping it and every later turn. An empty copy is valid - // here (the turn is the first one) — it branches to a fresh, empty context. - const copied = copyMessagesBeforeTurn(sourceView.messages, input.sourceTurnId); - if (copied === null) throw new Error(`Cannot branch before unknown turn ${input.sourceTurnId}`); - return this.createBranchSession(sessionId, sourceView, copied, input); + const slice = createConversationCopySlice(sourceView.messages, input.sourceTurnId, 'before'); + if (!slice) throw new Error(`Cannot branch before unknown turn ${input.sourceTurnId}`); + return this.createBranchSession(sessionId, sourceView, [...slice.messages], input); } /** @@ -4260,9 +4279,9 @@ export class SessionManager { */ async reviseBeforeTurn(sessionId: string, input: ReviseBeforeTurnInput): Promise { const sourceView = await this.getSessionView(sessionId); - const copied = copyMessagesBeforeTurn(sourceView.messages, input.sourceTurnId); - if (copied === null) throw new Error(`Cannot revise before unknown turn ${input.sourceTurnId}`); - return this.createRevisionSession(sessionId, sourceView, copied, input); + const slice = createConversationCopySlice(sourceView.messages, input.sourceTurnId, 'before'); + if (!slice) throw new Error(`Cannot revise before unknown turn ${input.sourceTurnId}`); + return this.createRevisionSession(sessionId, sourceView, [...slice.messages], input); } private async createRevisionSession( @@ -4309,26 +4328,35 @@ export class SessionManager { }, boundary, ); - await this.cloneConversationRuntimeLedger(next.id, sourceView, copied); - if (copied.length > 0) await this.deps.store.appendMessages(next.id, copied); - await this.deps.store.appendMessage(next.id, { - type: 'system_note', - id: this.deps.newId(), - ts: this.deps.now(), - kind: 'session_start', - data: { - revisionRootSessionId, - revisionParentSessionId: sessionId, - revisionOfTurnId: input.sourceTurnId, - revisionIndex, - revisionState: 'preparing', - }, - }); - await this.deps.store.updateHeader(next.id, { - isFlagged: header.isFlagged, - titleIsManual: header.titleIsManual, - }); - return headerToSummary(await this.deps.store.readHeader(next.id)); + try { + const rewritten = await this.cloneConversationRuntimeLedger( + sessionId, + next.id, + sourceView, + copied, + ); + if (rewritten.length > 0) await this.deps.store.appendMessages(next.id, [...rewritten]); + await this.deps.store.appendMessage(next.id, { + type: 'system_note', + id: this.deps.newId(), + ts: this.deps.now(), + kind: 'session_start', + data: { + revisionRootSessionId, + revisionParentSessionId: sessionId, + revisionOfTurnId: input.sourceTurnId, + revisionIndex, + revisionState: 'preparing', + }, + }); + await this.deps.store.updateHeader(next.id, { + isFlagged: header.isFlagged, + titleIsManual: header.titleIsManual, + }); + return headerToSummary(await this.deps.store.readHeader(next.id)); + } catch (error) { + return this.rollbackLegacyConversationCopy(next.id, error); + } } private async createBranchSession( @@ -4360,16 +4388,25 @@ export class SessionManager { }, boundary, ); - await this.cloneConversationRuntimeLedger(next.id, sourceView, copied); - if (copied.length > 0) await this.deps.store.appendMessages(next.id, copied); - await this.deps.store.appendMessage(next.id, { - type: 'system_note', - id: this.deps.newId(), - ts: this.deps.now(), - kind: 'session_start', - data: { parentSessionId: sessionId, branchOfTurnId: input.sourceTurnId }, - }); - return headerToSummary(await this.deps.store.readHeader(next.id)); + try { + const rewritten = await this.cloneConversationRuntimeLedger( + sessionId, + next.id, + sourceView, + copied, + ); + if (rewritten.length > 0) await this.deps.store.appendMessages(next.id, [...rewritten]); + await this.deps.store.appendMessage(next.id, { + type: 'system_note', + id: this.deps.newId(), + ts: this.deps.now(), + kind: 'session_start', + data: { parentSessionId: sessionId, branchOfTurnId: input.sourceTurnId }, + }); + return headerToSummary(await this.deps.store.readHeader(next.id)); + } catch (error) { + return this.rollbackLegacyConversationCopy(next.id, error); + } } async respondToSandboxBoundary( @@ -4659,11 +4696,14 @@ export class SessionManager { return user; } - private async getSessionView(sessionId: string): Promise { + private async getSessionView( + 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().getSessionView(sessionId); + 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; @@ -4676,17 +4716,19 @@ export class SessionManager { repaired.add(runId); } } - return this.readModel().getSessionView(sessionId); + return this.readModel(projectionCache).getSessionView(sessionId); } - private readModel(): RuntimeReadModel { + private readModel( + projectionCache: RuntimeReadModelProjectionCache = this.deps.store, + ): RuntimeReadModel { if (!this.deps.runStore || !this.deps.runtimeEventStore) { throw new Error('RuntimeReadModel requires AgentRunStore and RuntimeEventStore'); } return new RuntimeReadModel({ runStore: this.deps.runStore, runtimeEventStore: this.deps.runtimeEventStore, - projectionCache: this.deps.store, + projectionCache, ...(this.deps.canonicalPermissionOutcomes ? { canonicalPermissionOutcomes: this.deps.canonicalPermissionOutcomes } : {}), @@ -4700,86 +4742,44 @@ export class SessionManager { } private async cloneConversationRuntimeLedger( + sourceSessionId: string, childSessionId: string, sourceView: RuntimeReadModelSessionView, copiedMessages: readonly StoredMessage[], - ): Promise { - if (!this.deps.runStore || !this.deps.runtimeEventStore) return; - const copiedTurnIds = new Set(); - for (const message of copiedMessages) { - if ('turnId' in message && typeof message.turnId === 'string') - copiedTurnIds.add(message.turnId); - } - if (copiedTurnIds.size === 0) return; - const copiedPermissionDecisions = new Map( - copiedMessages.flatMap((message) => - message.type === 'permission_decision' ? [[message.id, message] as const] : [], - ), - ); - - for (const sourceRun of sourceView.runs) { - if (!copiedTurnIds.has(sourceRun.turnId)) continue; - const sourceEvents = sourceView.events.filter( - (event) => event.runId === sourceRun.runId && copiedTurnIds.has(event.turnId), - ); - if (sourceEvents.length === 0) continue; + ): Promise { + if (!this.deps.runStore || !this.deps.runtimeEventStore) return copiedMessages; + const plan = await prepareConversationRuntimeLedgerCopy({ + sourceSessionId, + sourceEvents: sourceView.events, + copiedMessages, + runStore: this.deps.runStore, + runtimeEventStore: this.deps.runtimeEventStore, + }); + const copied = await cloneConversationLedger({ + plan, + copiedMessages, + referenceMap: { + mode: 'preserve_external', + sourceSessionId, + targetSessionId: childSessionId, + }, + runStore: this.deps.runStore, + runtimeEventStore: this.deps.runtimeEventStore, + newId: this.deps.newId, + }); + return copied.copiedMessages; + } - const runId = this.deps.newId(); - const invocationId = this.deps.newId(); - const clonedRun = cloneRunHeaderForConversationCopy( - sourceRun, - childSessionId, - runId, - invocationId, + private async rollbackLegacyConversationCopy(sessionId: string, error: unknown): Promise { + try { + await this.deps.store.remove(sessionId); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + `Conversation copy ${sessionId} failed and could not be removed`, ); - await this.deps.runStore.createRun(clonedRun); - - const sourceTerminalLedger = classifyTerminalRuntimeLedger(sourceRun, sourceEvents); - const clonedEventBySourceId = new Map(); - for (const event of sourceEvents) { - const clonedEvent = cloneRuntimeEventForConversationCopy( - event, - { - sessionId: childSessionId, - runId, - eventId: this.deps.newId(), - invocationId, - }, - copiedPermissionDecisions, - ); - await this.deps.runtimeEventStore.appendRuntimeEvent(childSessionId, runId, clonedEvent); - clonedEventBySourceId.set(event.id, clonedEvent); - } - - if (sourceTerminalLedger.kind === 'fact' && isTerminalRunStatus(sourceRun.status)) { - const terminalEvent = clonedEventBySourceId.get(sourceTerminalLedger.fact.terminalEvent.id); - if (!terminalEvent) continue; - await commitTerminalRunWithRuntimeFact({ - runStore: this.deps.runStore, - runtimeEventStore: this.deps.runtimeEventStore, - newId: this.deps.newId, - sessionId: childSessionId, - runId, - turnId: sourceRun.turnId, - status: sourceTerminalLedger.fact.runStatus, - ts: terminalEvent.ts, - terminalEvent, - ...(sourceTerminalLedger.fact.failureClass - ? { failureClass: sourceTerminalLedger.fact.failureClass } - : {}), - ...(sourceRun.failureMessage ? { failureMessage: sourceRun.failureMessage } : {}), - ...(sourceTerminalLedger.fact.abortSource - ? { abortSource: sourceTerminalLedger.fact.abortSource } - : {}), - runEventData: { - recovered: true, - recoveryReason: 'conversation_runtime_ledger_clone', - sourceSessionId: sourceRun.sessionId, - sourceRunId: sourceRun.runId, - }, - }); - } } + throw error; } private async recoverAgentRunsFromLedger( @@ -5376,110 +5376,6 @@ function turnStateLineage( }; } -function cloneRuntimeEventForConversationCopy( - event: RuntimeEvent, - ids: { sessionId: string; runId: string; eventId: string; invocationId: string }, - copiedPermissionDecisions: ReadonlyMap< - string, - Extract - >, -): RuntimeEvent { - const cloned: RuntimeEvent = { - ...event, - id: ids.eventId, - invocationId: ids.invocationId, - sessionId: ids.sessionId, - runId: ids.runId, - }; - const accepted = event.actions?.permissionAnswerAccepted; - const decision = accepted ? copiedPermissionDecisions.get(accepted.requestId) : undefined; - if (!decision || !cloned.actions) return cloned; - const { permissionAnswerAccepted: _accepted, ...actions } = cloned.actions; - cloned.actions = { - ...actions, - permissionDecision: { - requestId: decision.id, - toolName: decision.toolName, - decision: decision.decision, - ...(decision.rememberForTurn !== undefined - ? { rememberForTurn: decision.rememberForTurn } - : {}), - ...(decision.reviewer !== undefined ? { reviewer: decision.reviewer } : {}), - ...(decision.rationale !== undefined ? { rationale: decision.rationale } : {}), - ...(decision.riskLevel !== undefined ? { riskLevel: decision.riskLevel } : {}), - }, - }; - cloned.ts = decision.ts; - return cloned; -} - -function cloneRunHeaderForConversationCopy( - sourceRun: AgentRunHeader, - childSessionId: string, - runId: string, - invocationId: string, -): AgentRunHeader { - const cloned = { ...sourceRun, invocationId, sessionId: childSessionId, runId }; - if (isTerminalRunStatus(sourceRun.status)) { - cloned.status = 'running'; - delete cloned.completedAt; - delete cloned.failureClass; - delete cloned.failureMessage; - delete cloned.abortSource; - } - return cloned; -} - -function copyMessagesThroughTurnBoundary( - messages: readonly StoredMessage[], - turnId: string, -): StoredMessage[] { - let lastIndex = -1; - for (let index = 0; index < messages.length; index += 1) { - const message = messages[index]!; - if ((message as { turnId?: string }).turnId === turnId) { - lastIndex = index; - } - } - if (lastIndex < 0) return []; - // Branch v1 copies conversation context only. Turn metadata is intentionally - // not copied into the child session; lineage lives on the child session - // header (`parentSessionId` + `branchOfTurnId`) and future turns. - return messages.slice(0, lastIndex + 1).filter((message) => message.type !== 'turn_state'); -} - -// Exclusive dual of copyMessagesThroughTurnBoundary: every message belonging to -// a turn strictly before the chosen one, dropping it and every later turn. -// Returns null when the turn is absent (so the caller can reject an unknown -// turn), and an empty array when the turn is the first one (a valid branch into -// empty context). Membership, not array position, decides what to keep: the read -// model does not guarantee a turn's messages are contiguous or that a user -// prompt precedes its turn_state in array order, so a positional slice could -// drop an earlier turn's prompt. turn_state is dropped for the same reason as in -// the inclusive copy — lineage lives on the child header, not copied metadata. -function copyMessagesBeforeTurn( - messages: readonly StoredMessage[], - turnId: string, -): StoredMessage[] | null { - const turnOrder: string[] = []; - const seen = new Set(); - for (const message of messages) { - const messageTurnId = (message as { turnId?: string }).turnId; - if (messageTurnId && !seen.has(messageTurnId)) { - seen.add(messageTurnId); - turnOrder.push(messageTurnId); - } - } - const cut = turnOrder.indexOf(turnId); - if (cut < 0) return null; - const keep = new Set(turnOrder.slice(0, cut)); - return messages.filter((message) => { - if (message.type === 'turn_state') return false; - const messageTurnId = (message as { turnId?: string }).turnId; - return messageTurnId !== undefined && keep.has(messageTurnId); - }); -} - function isTerminalRunStatus(status: AgentRunHeader['status']): boolean { return status === 'completed' || status === 'failed' || status === 'cancelled'; } diff --git a/packages/storage/src/__tests__/artifact-store.test.ts b/packages/storage/src/__tests__/artifact-store.test.ts index 5724f15acf..76c4dcb8fc 100644 --- a/packages/storage/src/__tests__/artifact-store.test.ts +++ b/packages/storage/src/__tests__/artifact-store.test.ts @@ -185,6 +185,64 @@ describe('FileArtifactStore', () => { }); }); + test('copies an exact turn-scoped Artifact snapshot and purges only the target Session', async () => { + await withWorkspace(async (root) => { + const authority = createArtifactStoreWriteAuthority(root); + await authority.recover(); + const { store } = authority; + const retained = await store.create({ + ...artifactInput('retained-artifact', 'retained', 10), + turnId: 'turn-retained', + mimeType: 'text/plain', + }); + const deleted = await store.create({ + ...artifactInput('deleted-artifact', 'deleted', 11), + turnId: 'turn-retained', + }); + await store.delete(deleted.id); + await store.create({ + ...artifactInput('later-artifact', 'later', 20), + turnId: 'turn-later', + }); + + const copied = await store.copyConversationArtifacts({ + sourceSessionId: 'session-1', + targetSessionId: 'session-copy', + turnIds: ['turn-retained'], + }); + const copiedId = copied.artifactIds.get(retained.id); + const copiedDeletedId = copied.artifactIds.get(deleted.id); + assert.ok(copiedId); + assert.ok(copiedDeletedId); + assert.notEqual(copiedId, retained.id); + const target = await store.list('session-copy'); + assert.equal(target.length, 1); + assert.equal(target[0]?.id, copiedId); + assert.equal(target[0]?.turnId, retained.turnId); + assert.equal(copied.relativePaths.get(retained.relativePath), target[0]?.relativePath); + assert.deepEqual(await store.readText(copiedId!), { + ok: true, + text: 'retained', + }); + const targetWithTombstones = await store.list('session-copy', { includeDeleted: true }); + assert.equal(targetWithTombstones.find((record) => record.id === copiedId)?.status, 'live'); + const copiedDeleted = targetWithTombstones.find((record) => record.id === copiedDeletedId); + assert.equal(copiedDeleted?.status, 'deleted'); + assert.equal(copied.relativePaths.get(deleted.relativePath), copiedDeleted?.relativePath); + assert.deepEqual(await store.readText(copiedDeletedId!, { includeDeleted: true }), { + ok: true, + text: 'deleted', + }); + + await store.purgeSessionArtifacts('session-copy'); + assert.deepEqual(await store.list('session-copy'), []); + assert.deepEqual((await store.list('session-1')).map((record) => record.id).sort(), [ + 'later-artifact', + 'retained-artifact', + ]); + }); + }); + test('user delete evaluates current-generation policy before tombstone state', async () => { await withWorkspace(async (root) => { const authority = createArtifactStoreWriteAuthority(root); diff --git a/packages/storage/src/__tests__/execution-stores.test.ts b/packages/storage/src/__tests__/execution-stores.test.ts index cf178ce01e..119070c9ff 100644 --- a/packages/storage/src/__tests__/execution-stores.test.ts +++ b/packages/storage/src/__tests__/execution-stores.test.ts @@ -13,6 +13,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, test } from 'node:test'; import { + canonicalToolArgsHash, MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_COUNT, type AgentRunEvent, @@ -170,6 +171,53 @@ describe('execution stores', () => { }); }); + test('purges one incomplete conversation-copy ledger from canonical SQLite', async () => { + await withRoot(async ({ root }) => { + const capability = await resolveStorageRoot({ + path: root, + kind: 'headless', + }); + const writer = await openHeadlessExecutionStoresForWrite( + createHeadlessRootLease(capability, 'write'), + ); + try { + const retained = await writer.sessionStore.create(sessionInput(root)); + const copied = await writer.sessionStore.create(sessionInput(root)); + await writer.agentRunStore.createRun(runHeader(retained.id, 'retained-run')); + await writer.agentRunStore.createRun(runHeader(copied.id, 'copied-run')); + await writer.runtimeEventStore.importConversationCopyRuntimeEvents(retained.id, [ + { + runId: 'retained-run', + events: [runtimeEvent(retained.id, 'retained-run', 'retained-event', 1)], + }, + ]); + await writer.runtimeEventStore.importConversationCopyRuntimeEvents(copied.id, [ + { + runId: 'copied-run', + events: conversationCopyToolLedger(copied.id, 'copied-run'), + }, + ]); + + await writer.purgeConversationOperationalState(copied.id); + + assert.deepEqual( + (await writer.agentRunStore.listSessionRuns(retained.id)).map((run) => run.runId), + ['retained-run'], + ); + assert.deepEqual(await writer.agentRunStore.listSessionRuns(copied.id), []); + assert.deepEqual( + (await writer.runtimeEventStore.readSessionRuntimeEvents(retained.id)).map( + (event) => event.id, + ), + ['retained-event'], + ); + assert.deepEqual(await writer.runtimeEventStore.readSessionRuntimeEvents(copied.id), []); + } finally { + await writer.sessionStore.close?.(); + } + }); + }); + test('freezes and authenticates execution store facades', async () => { await withRoot(async ({ base, root }) => { const capability = await resolveStorageRoot({ @@ -1802,3 +1850,63 @@ function runtimeEvent(sessionId: string, runId: string, id: string, ts: number): content: { kind: 'text', text: 'hello' }, }; } + +function conversationCopyToolLedger(sessionId: string, runId: string): RuntimeEvent[] { + const operationId = `${runId}-operation`; + const providerToolCallId = `${runId}-provider-call`; + const canonicalArgsHash = canonicalToolArgsHash('Read', { path: 'README.md' }); + const identity = { + invocationId: runId, + runId, + sessionId, + turnId: 'turn-1', + partial: false as const, + }; + return [ + { + ...identity, + id: `${runId}-call`, + ts: 2, + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: providerToolCallId, + name: 'Read', + args: { path: 'README.md' }, + }, + }, + { + ...identity, + id: `${runId}-dispatch`, + ts: 3, + role: 'system', + author: 'system', + actions: { + toolDispatch: { + protocol: 't1_after_preflight_v1', + operationId, + providerToolCallId, + toolName: 'Read', + canonicalArgsHash, + recoveryMode: 'replay_safe', + }, + }, + refs: { operationId, toolCallId: providerToolCallId }, + }, + { + ...identity, + id: `${runId}-response`, + ts: 4, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: providerToolCallId, + name: 'Read', + result: 'contents', + }, + refs: { operationId, toolCallId: providerToolCallId }, + }, + ]; +} diff --git a/packages/storage/src/__tests__/sqlite-runtime-store.test.ts b/packages/storage/src/__tests__/sqlite-runtime-store.test.ts index ddef7d573b..92b8645d74 100644 --- a/packages/storage/src/__tests__/sqlite-runtime-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-runtime-store.test.ts @@ -60,6 +60,25 @@ describe('SqliteRuntimeStore', () => { }); }); + it('imports a conversation-copy tool ledger with its derived projections', async () => { + await withStore(async (store) => { + const events = [functionCallEvent(), toolDispatchEvent(), functionResponseEvent({ ts: 11 })]; + + await store.importConversationCopyRuntimeEvents('session-1', [{ runId: 'run-1', events }]); + await store.importConversationCopyRuntimeEvents('session-1', [{ runId: 'run-1', events }]); + + assert.deepEqual(await store.readImmutableRuntimeEvents('session-1', 'run-1'), events); + assert.equal( + (await store.readToolOperation('operation-1'))?.currentState, + 'outcome_committed', + ); + assert.deepEqual( + (await store.readToolJournal('operation-1')).map((event) => event.state), + ['prepared', 'outcome_committed'], + ); + }); + }); + it('commits function_call, dispatch fact, and operation projection atomically in T1', async () => { await withStore(async (store) => { const call = functionCallEvent(); diff --git a/packages/storage/src/__tests__/sqlite-session-store.test.ts b/packages/storage/src/__tests__/sqlite-session-store.test.ts index c8ad4c8ede..aa998b8754 100644 --- a/packages/storage/src/__tests__/sqlite-session-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-session-store.test.ts @@ -18,6 +18,7 @@ import { SessionReadMarkerMessageNotFoundError, SessionNotFoundError, SQLITE_SESSION_METADATA_DATABASE_NAME, + type StableSessionCreateInput, } from '../session-store.js'; import { createSqliteSessionMetadataStore, @@ -641,6 +642,130 @@ describe('default SQLite session metadata store', () => { } }); + test('hides preparing conversation copies and discards them without tombstoning stable identity', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-copy-publication-')); + const store = createSessionStore(root); + const sessionId = 'stable-copy'; + const requestFingerprint: `sha256:${string}` = `sha256:${'f'.repeat(64)}`; + const input: StableSessionCreateInput = { + ...makeInput({ + parentSessionId: 'source-session', + branchOfTurnId: 'turn-1', + }), + conversationCopy: { + kind: 'branch', + sourceSessionId: 'source-session', + sourceTurnId: 'turn-1', + requestFingerprint, + state: 'preparing', + }, + }; + try { + await assert.rejects( + () => store.create(input), + /Conversation copy metadata requires createStableSession/, + ); + assert.equal( + (await store.createStableSession({ sessionId, requestFingerprint, input })).kind, + 'created', + ); + assert.deepEqual(await store.list(), []); + const page = await store.listCatalogPage(undefined, undefined, 10); + assert.equal(page.kind, 'page'); + if (page.kind !== 'page') assert.fail('Expected a Session catalog page'); + assert.deepEqual(page.records, []); + await assert.rejects(() => store.readCatalogRecord(sessionId), isSessionNotFoundError); + + assert.equal(await store.discardStableConversationCopy(sessionId, requestFingerprint), true); + assert.equal( + (await store.createStableSession({ sessionId, requestFingerprint, input })).kind, + 'created', + ); + await store.updateHeader(sessionId, { + conversationCopy: { ...input.conversationCopy!, state: 'committed' }, + }); + assert.equal((await store.list())[0]?.id, sessionId); + assert.equal((await store.readCatalogRecord(sessionId)).header.id, sessionId); + await assert.rejects( + () => store.updateHeader(sessionId, { conversationCopy: undefined }), + /conversation-copy identity is immutable/, + ); + await assert.rejects( + () => store.discardStableConversationCopy(sessionId, requestFingerprint), + /matching incomplete conversation copy/, + ); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('recovers pending catalog publication after an incomplete copy loses its transcript', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-copy-recovery-')); + const sessionId = 'interrupted-copy'; + const requestFingerprint: `sha256:${string}` = `sha256:${'a'.repeat(64)}`; + const input: StableSessionCreateInput = { + ...makeInput({ + parentSessionId: 'source-session', + branchOfTurnId: 'turn-1', + }), + conversationCopy: { + kind: 'branch', + sourceSessionId: 'source-session', + sourceTurnId: 'turn-1', + requestFingerprint, + state: 'preparing', + }, + }; + const initial = createSessionStore(root); + try { + assert.equal( + ( + await initial.createStableSession({ + sessionId, + requestFingerprint, + input, + }) + ).kind, + 'created', + ); + } finally { + await initial.close?.(); + } + + const metadata = createSqliteSessionMetadataStore( + join(root, SQLITE_SESSION_METADATA_DATABASE_NAME), + ); + try { + await metadata.requireCatalogProjectionRecovery(); + } finally { + metadata.close(); + } + await rm(join(root, 'sessions', sessionId), { recursive: true, force: true }); + + const recovered = createSessionStore(root); + try { + assert.equal((await recovered.listHeaders())[0]?.id, sessionId); + assert.equal( + await recovered.discardStableConversationCopy(sessionId, requestFingerprint), + true, + ); + assert.equal( + ( + await recovered.createStableSession({ + sessionId, + requestFingerprint, + input, + }) + ).kind, + 'created', + ); + } finally { + await recovered.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + test('persists the stable project association in SQLite metadata and summaries', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-default-session-project-')); const store = createSessionStore(root); diff --git a/packages/storage/src/__tests__/task-ledger-store.test.ts b/packages/storage/src/__tests__/task-ledger-store.test.ts index 0c73fb9608..dfab649f25 100644 --- a/packages/storage/src/__tests__/task-ledger-store.test.ts +++ b/packages/storage/src/__tests__/task-ledger-store.test.ts @@ -104,6 +104,75 @@ describe('TaskLedgerStore', () => { assert.equal(events[2]?.refs?.toolCallId, 'call-2'); }); + it('copies Task Ledger state at a turn boundary and rewrites Session and Run ownership', async () => { + const root = await tempRoot(); + const store = createTaskLedgerStore(root); + const { + created: [task], + } = await store.create(SESSION_ID, [{ subject: 'copy boundary task' }], { + runId: 'source-run', + turnId: 'turn-retained', + source: 'tool', + actor: 'main_agent', + }); + assert.ok(task); + await store.update( + SESSION_ID, + task.id, + { status: 'in_progress' }, + { + runId: 'later-run', + turnId: 'turn-later', + source: 'tool', + actor: 'main_agent', + }, + ); + + await assert.rejects( + () => + store.copyConversationTaskLedger({ + sourceSessionId: SESSION_ID, + targetSessionId: 'session-copy-missing-run', + turnIds: ['turn-retained'], + runIdMap: [], + }), + /missing AgentRun source-run/, + ); + assert.deepEqual(await store.list('session-copy-missing-run'), []); + + await store.copyConversationTaskLedger({ + sourceSessionId: SESSION_ID, + targetSessionId: 'session-copy', + turnIds: ['turn-retained'], + runIdMap: [{ sourceRunId: 'source-run', targetRunId: 'target-run' }], + }); + + const copied = await store.list('session-copy'); + assert.equal(copied.length, 1); + assert.equal(copied[0]?.status, 'pending'); + assert.equal(copied[0]?.owner?.runId, 'target-run'); + const [event] = ( + await readFile(join(root, 'sessions', 'session-copy', 'task-events.jsonl'), 'utf8') + ) + .trim() + .split('\n') + .map( + (line) => + JSON.parse(line) as { + sessionId: string; + refs?: { runId?: string; turnId?: string }; + }, + ); + assert.equal(event?.sessionId, 'session-copy'); + assert.deepEqual(event?.refs, { + runId: 'target-run', + turnId: 'turn-retained', + }); + await store.purgeConversationTaskLedger('session-copy'); + assert.deepEqual(await store.list('session-copy'), []); + assert.equal((await store.list(SESSION_ID))[0]?.status, 'in_progress'); + }); + it('clears stale evidence when tasks leave evidence-bearing statuses', async () => { const root = await tempRoot(); const store = createTaskLedgerStore(root); diff --git a/packages/storage/src/agent-run-store.ts b/packages/storage/src/agent-run-store.ts index 12f2491be7..8b9d54be09 100644 --- a/packages/storage/src/agent-run-store.ts +++ b/packages/storage/src/agent-run-store.ts @@ -40,6 +40,7 @@ import { MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_COUNT, messageContentsEqual, + scanToolLedger, validateGenericToolLedgerAppend, validateToolLedgerTransition, type AgentRunEvent, @@ -138,7 +139,16 @@ export interface DurableAgentRunStore extends AgentRunStore, RootTurnAdmissionSt close?(): void; } +export interface ConversationCopyRuntimeEventBatch { + readonly runId: string; + readonly events: readonly RuntimeEvent[]; +} + export interface DurableRuntimeEventStore extends RuntimeEventStore { + importConversationCopyRuntimeEvents( + sessionId: string, + batches: readonly ConversationCopyRuntimeEventBatch[], + ): Promise; readImmutableRuntimeEvents(sessionId: string, runId: string): Promise; readImmutableSteeringMessageProof( sessionId: string, @@ -1771,6 +1781,59 @@ class FileRuntimeEventStore implements DurableRuntimeEventStore { await this.appendRuntimeEventForRun(sessionId, runId, canonicalEvent, options); } + async importConversationCopyRuntimeEvents( + sessionId: string, + batches: readonly ConversationCopyRuntimeEventBatch[], + ): Promise { + assertSafeId(sessionId, 'Invalid session id'); + const canonicalBatches = batches.map(({ runId, events }) => { + assertSafeId(runId, 'Invalid run id'); + return { + runId, + events: events.map(canonicalizeRuntimeEventForStorage), + }; + }); + const canonicalEvents = canonicalBatches.flatMap(({ events }) => events); + if (canonicalEvents.some((event) => event.partial)) { + throw new Error('Conversation copy cannot import partial RuntimeEvents'); + } + const scan = scanToolLedger(canonicalEvents); + if (scan.hasCorruption) { + throw new Error( + `Conversation copy RuntimeEvent ledger is corrupt: ${scan.issues[0]?.code ?? 'unknown'}`, + ); + } + + for (const { runId, events } of canonicalBatches) { + await this.withQueue(sessionId, runId, async () => { + const header = await this.readRunHeader(sessionId, runId); + for (const event of events) decodeRuntimeEvent(event, header); + const path = this.runtimeEventsPath(sessionId, runId); + const existing = await readRuntimeEventJsonl(path, header); + if (existing.length > 0) { + if (!isDeepStrictEqual(existing, events)) { + throw new Error(`Conversation copy RuntimeEvent identity conflict for run ${runId}`); + } + } else if (events.length > 0) { + await appendJsonl( + path, + `${events.map((event) => encodeCanonicalRuntimeEvent(event).json).join('\n')}\n`, + { durable: true, durabilityRoot: this.durabilityRoot }, + ); + } + for (const event of events) { + await this.settleImmutableRuntimeEventPostEffects({ + sessionId, + runId, + event, + path, + ensureDurability: false, + }); + } + }); + } + } + private async appendRuntimeEventForRun( sessionId: string, runId: string, @@ -2957,6 +3020,14 @@ function hasExactKeys(record: Record, expected: readonly string return keys.length === expected.length && expected.every((key) => Object.hasOwn(record, key)); } +async function syncDirectoryIfPresent(path: string): Promise { + try { + await syncDirectory(path); + } catch (error) { + if (!isMissingFile(error)) throw error; + } +} + function isMissingFile(error: unknown): boolean { return (error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT'; } diff --git a/packages/storage/src/artifact-store.ts b/packages/storage/src/artifact-store.ts index 7e843d9d34..068ef42c2f 100644 --- a/packages/storage/src/artifact-store.ts +++ b/packages/storage/src/artifact-store.ts @@ -2,6 +2,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { type BigIntStats, constants as fsConstants, createReadStream, type Dirent } from 'node:fs'; import { access, + copyFile, link, lstat, mkdir, @@ -95,6 +96,8 @@ interface ArtifactRemovalEntry { readonly comparisonIdentity: string; } +type ArtifactRecordDraft = Omit; + interface RecoverableOrphan { readonly canonicalPath: string; readonly dev: number; @@ -130,6 +133,17 @@ export interface ArtifactSessionEntry { readonly record: ArtifactRecord | null; } +export interface ConversationArtifactCopyInput { + readonly sourceSessionId: string; + readonly targetSessionId: string; + readonly turnIds: readonly string[]; +} + +export interface ConversationArtifactCopyResult { + readonly artifactIds: ReadonlyMap; + readonly relativePaths: ReadonlyMap; +} + export interface ArtifactStoreReader { list(sessionId: string, opts?: { includeDeleted?: boolean }): Promise; get(artifactId: string): Promise; @@ -164,6 +178,10 @@ export type ArtifactUserDeleteResult = | { readonly kind: 'protected' }; export interface ArtifactAuthorityStore extends ArtifactStore { + copyConversationArtifacts( + input: ConversationArtifactCopyInput, + ): Promise; + purgeSessionArtifacts(sessionId: string): Promise; deleteUserArtifactInSession( sessionId: string, artifactId: string, @@ -295,22 +313,8 @@ class FileArtifactStore implements ArtifactAuthorityStore { id, canonicalName: name, }); - const target = join(this.artifactRoot, relativePath); - const targetDirectory = dirname(target); - const createdDirectory = await mkdir(targetDirectory, { recursive: true }); - if (createdDirectory !== undefined) { - await syncDirectoryChain(targetDirectory, this.workspaceRoot); - } - await assertArtifactDirectory(this.artifactRoot, targetDirectory); - const tempPath = join(targetDirectory, publicationStagingName(basename(target))); - let preserveStaging = false; - let targetLinked = false; - try { - await writeFile(tempPath, acceptedInput.content, { flag: 'wx' }); - await syncFile(tempPath); - await syncDirectory(targetDirectory); - const size = await stat(tempPath); - const record: ArtifactRecord = { + return this.publishNewArtifactUnlocked( + { id, sessionId: acceptedInput.sessionId, turnId: acceptedInput.turnId, @@ -318,7 +322,6 @@ class FileArtifactStore implements ArtifactAuthorityStore { name, kind: acceptedInput.kind, relativePath, - sizeBytes: size.size, ...(acceptedInput.mimeType ? { mimeType: acceptedInput.mimeType } : {}), ...(acceptedInput.source ? { source: acceptedInput.source } : {}), ...(acceptedInput.summary ? { summary: acceptedInput.summary } : {}), @@ -326,49 +329,172 @@ class FileArtifactStore implements ArtifactAuthorityStore { ? { deepResearchRole: acceptedInput.deepResearchRole } : {}), status: 'live', - }; - const nextRecords = [...this.records, record]; + }, + (tempPath) => writeFile(tempPath, acceptedInput.content, { flag: 'wx' }), + ); + }); + } + + async copyConversationArtifacts( + input: ConversationArtifactCopyInput, + ): Promise { + assertCanonicalArtifactEntityId(input.sourceSessionId, 'sessionId'); + assertCanonicalArtifactEntityId(input.targetSessionId, 'sessionId'); + if (input.sourceSessionId === input.targetSessionId) { + throw new Error('Artifact conversation copy requires distinct Sessions'); + } + const turnIds = new Set(input.turnIds); + for (const turnId of turnIds) assertArtifactTurnKey(turnId); + const records = await this.enqueue(async () => { + await this.load(); + return this.records + .filter( + (record) => record.sessionId === input.sourceSessionId && turnIds.has(record.turnId), + ) + .map((record) => ({ ...record })); + }); + + const artifactIds = new Map(); + const relativePaths = new Map(); + for (const record of records) { + const targetId = conversationCopyArtifactId( + input.sourceSessionId, + input.targetSessionId, + record.id, + ); + const prepared = await this.enqueue(() => + this.prepareRecordRead(record, record.sizeBytes, true), + ); + if (!prepared.ok) { + throw new Error(`Artifact ${record.id} could not be copied: ${prepared.reason}`); + } + const created = await this.copyConversationArtifact( + prepared, + input.targetSessionId, + targetId, + ); + artifactIds.set(record.id, created.id); + relativePaths.set(record.relativePath, created.relativePath); + } + return { artifactIds, relativePaths }; + } + + private copyConversationArtifact( + prepared: PreparedArtifactRead, + targetSessionId: string, + targetId: string, + ): Promise { + const source = prepared.record; + const name = sanitizeArtifactName(source.name); + const relativePath = `${targetSessionId}/${targetId}-${name}`; + const publicationInput = { sessionId: targetSessionId, name }; + assertCanonicalArtifactEntityId(targetId, 'id'); + validateRelativeArtifactPath(relativePath); + validateCanonicalArtifactTargetName(basename(relativePath)); + return this.enqueueMutation(async () => { + await this.prepareMutationUnlocked({ + kind: 'copy', + input: publicationInput, + identity: { id: targetId, canonicalName: name }, + }); + if (this.records.some((record) => record.id === targetId)) { + throw new Error(`Artifact target already exists: ${targetId}`); + } + await this.assertNoCompatiblePublicationStagingUnlocked(publicationInput, { + id: targetId, + canonicalName: name, + }); + await this.assertNoCompatiblePayloadExistsUnlocked(publicationInput, { + id: targetId, + canonicalName: name, + }); + return this.publishNewArtifactUnlocked( + { + ...source, + id: targetId, + sessionId: targetSessionId, + name, + relativePath, + }, + (tempPath) => copyFile(prepared.path, tempPath, fsConstants.COPYFILE_EXCL), + source.sizeBytes, + ); + }); + } + + private async publishNewArtifactUnlocked( + draft: ArtifactRecordDraft, + writeStaging: (tempPath: string) => Promise, + expectedSize?: number, + ): Promise { + const target = join(this.artifactRoot, draft.relativePath); + const targetDirectory = dirname(target); + const createdDirectory = await mkdir(targetDirectory, { recursive: true }); + if (createdDirectory !== undefined) { + await syncDirectoryChain(targetDirectory, this.workspaceRoot); + } + await assertArtifactDirectory(this.artifactRoot, targetDirectory); + const tempPath = join(targetDirectory, publicationStagingName(basename(target))); + let preserveStaging = false; + let targetLinked = false; + try { + await writeStaging(tempPath); + await syncFile(tempPath); + await syncDirectory(targetDirectory); + const size = await stat(tempPath); + if (expectedSize !== undefined && size.size !== expectedSize) { + throw new Error(`Artifact source changed while copying: ${draft.id}`); + } + const record: ArtifactRecord = { ...draft, sizeBytes: size.size }; + const nextRecords = [...this.records, record]; + try { try { - try { - await link(tempPath, target); - targetLinked = true; - } catch (error) { - if (isAlreadyExists(error)) throw new Error(`Artifact target already exists: ${id}`); - throw error; - } - await syncDirectory(targetDirectory); - await this.writeMetadataUnlocked(nextRecords); + await link(tempPath, target); + targetLinked = true; } catch (error) { - if (targetLinked && isPublishedMetadataError(error)) { - preserveStaging = true; - this.invalidateWriterState(); - } else if (targetLinked) { - try { - await removeFileDurably(target, targetDirectory); - } catch (cleanupError) { - preserveStaging = true; - this.invalidateWriterState(); - throw new AggregateError( - [error, cleanupError], - `Artifact ${id} metadata publication and payload cleanup both failed`, - ); - } + if (isAlreadyExists(error)) { + throw new Error(`Artifact target already exists: ${draft.id}`); } throw error; } - this.replaceRecords(nextRecords); - return { ...record }; - } finally { - if (!preserveStaging) { + await syncDirectory(targetDirectory); + await this.writeMetadataUnlocked(nextRecords); + } catch (error) { + if (targetLinked && isPublishedMetadataError(error)) { + preserveStaging = true; + this.invalidateWriterState(); + } else if (targetLinked) { try { - await removeFileDurably(tempPath, targetDirectory); - } catch (error) { + await removeFileDurably(target, targetDirectory); + } catch (cleanupError) { + preserveStaging = true; this.invalidateWriterState(); - throw error; + throw new AggregateError( + [error, cleanupError], + `Artifact ${draft.id} metadata publication and payload cleanup both failed`, + ); } } + throw error; } - }); + this.replaceRecords(nextRecords); + return { ...record }; + } finally { + if (!preserveStaging) { + try { + await removeFileDurably(tempPath, targetDirectory); + } catch (error) { + this.invalidateWriterState(); + throw error; + } + } + } + } + + async purgeSessionArtifacts(sessionId: string): Promise { + assertCanonicalArtifactEntityId(sessionId, 'sessionId'); + const records = await this.list(sessionId, { includeDeleted: true }); + if (records.length > 0) await this.purge(records.map((record) => record.id)); } private async replayExistingArtifactUnlocked( @@ -811,6 +937,11 @@ class FileArtifactStore implements ArtifactAuthorityStore { readonly input: CreateArtifactInput; readonly identity: { readonly id: string; readonly canonicalName: string }; } + | { + readonly kind: 'copy'; + readonly input: Pick; + readonly identity: { readonly id: string; readonly canonicalName: string }; + } | { readonly kind: 'delete' | 'purge' }, ): Promise { if (this.recoveryMode === 'legacy') { @@ -822,8 +953,10 @@ class FileArtifactStore implements ArtifactAuthorityStore { await this.reloadForMutationUnlocked(); await this.recoverPurgeIntentUnlocked(); } - if (purpose.kind === 'create') { + if (purpose.kind === 'create' || purpose.kind === 'copy') { await this.recoverCompatiblePublicationsUnlocked(purpose.input, purpose.identity); + } + if (purpose.kind === 'create') { this.recoverableOrphans = await this.findCompatibleRecoverableOrphansUnlocked( purpose.input, purpose.identity, @@ -1047,7 +1180,7 @@ class FileArtifactStore implements ArtifactAuthorityStore { } private async recoverCompatiblePublicationsUnlocked( - input: CreateArtifactInput, + input: Pick, identity: { id: string; canonicalName: string }, ): Promise { const sessionDirectory = join(this.artifactRoot, input.sessionId); @@ -1076,7 +1209,7 @@ class FileArtifactStore implements ArtifactAuthorityStore { } private async assertNoCompatiblePayloadExistsUnlocked( - input: CreateArtifactInput, + input: Pick, identity: { id: string; canonicalName: string }, ): Promise { const names = compatibleArtifactNames(input.name, identity.canonicalName); @@ -1092,7 +1225,7 @@ class FileArtifactStore implements ArtifactAuthorityStore { } private async assertNoCompatiblePublicationStagingUnlocked( - input: CreateArtifactInput, + input: Pick, identity: { id: string; canonicalName: string }, ): Promise { const targetHashes = new Set( @@ -1439,6 +1572,16 @@ function artifactReplayConflict(artifactId: string): Error { return new Error(`Artifact ${artifactId} already exists with different metadata or content`); } +function conversationCopyArtifactId( + sourceSessionId: string, + targetSessionId: string, + sourceArtifactId: string, +): string { + return `copy_${createHash('sha256') + .update(JSON.stringify([sourceSessionId, targetSessionId, sourceArtifactId])) + .digest('hex')}`; +} + function artifactWriteRecoveryRequired(): Error { return new Error('Artifact write recovery is required before another mutation'); } diff --git a/packages/storage/src/artifact-stores.ts b/packages/storage/src/artifact-stores.ts index 29eb1f90ba..e95060219a 100644 --- a/packages/storage/src/artifact-stores.ts +++ b/packages/storage/src/artifact-stores.ts @@ -3,6 +3,8 @@ import { createArtifactStoreWriteAuthority, type ArtifactAuthorityStore, type ArtifactStoreWriteAuthority, + type ConversationArtifactCopyInput, + type ConversationArtifactCopyResult, type CreateArtifactInput, type DurableArtifactAttachmentReader, } from './artifact-store.js'; @@ -31,6 +33,10 @@ export interface InteractiveArtifactStoreWriter extends DurableArtifactAttachmen readonly [writerBrand]: true; recover(): Promise; create(input: CreateArtifactInput): Promise; + copyConversationArtifacts( + input: ConversationArtifactCopyInput, + ): Promise; + purgeSessionArtifacts(sessionId: string): Promise; listPage: ArtifactAuthorityStore['listPage']; getInSession: ArtifactAuthorityStore['getInSession']; readTextInSession: ArtifactAuthorityStore['readTextInSession']; @@ -168,6 +174,14 @@ function createWriterFacade( const acceptedInput = snapshotCreateInput(input); return run(() => store.create(acceptedInput)); }, + copyConversationArtifacts: (input) => { + const acceptedInput: ConversationArtifactCopyInput = Object.freeze({ + ...input, + turnIds: Object.freeze([...input.turnIds]), + }); + return run(() => store.copyConversationArtifacts(acceptedInput)); + }, + purgeSessionArtifacts: (sessionId) => run(() => store.purgeSessionArtifacts(sessionId)), deleteUserArtifactInSession: (sessionId, artifactId) => run(() => store.deleteUserArtifactInSession(sessionId, artifactId)), }; diff --git a/packages/storage/src/conversation-operational-state.ts b/packages/storage/src/conversation-operational-state.ts new file mode 100644 index 0000000000..ff52234fdf --- /dev/null +++ b/packages/storage/src/conversation-operational-state.ts @@ -0,0 +1,65 @@ +import { + acquireOperationalStateDatabase, + type OperationalStateDatabaseLease, +} from './operational-state-store.js'; +import { isRuntimeStorageSafeId } from './runtime-event-invariants.js'; + +export interface ConversationOperationalStateStore { + purge(sessionId: string): Promise; + close(): void; +} + +export function createConversationOperationalStateStore( + workspaceRoot: string, +): ConversationOperationalStateStore { + return new SqliteConversationOperationalStateStore(workspaceRoot); +} + +class SqliteConversationOperationalStateStore implements ConversationOperationalStateStore { + readonly #lease: OperationalStateDatabaseLease; + + constructor(workspaceRoot: string) { + this.#lease = acquireOperationalStateDatabase(workspaceRoot); + } + + async purge(sessionId: string): Promise { + if (!isRuntimeStorageSafeId(sessionId)) throw new Error('Invalid session id'); + this.#lease.transaction('write', () => { + const database = this.#lease.database; + database + .prepare(` + DELETE FROM tool_journal_events + WHERE runtime_event_id IN ( + SELECT event_id FROM runtime_events WHERE session_id = ? + ) + OR operation_id IN ( + SELECT operation_id + FROM tool_operations + WHERE call_event_id IN (SELECT event_id FROM runtime_events WHERE session_id = ?) + OR dispatch_event_id IN (SELECT event_id FROM runtime_events WHERE session_id = ?) + OR result_event_id IN (SELECT event_id FROM runtime_events WHERE session_id = ?) + ) + `) + .run(sessionId, sessionId, sessionId, sessionId); + database + .prepare(` + DELETE FROM tool_operations + WHERE call_event_id IN (SELECT event_id FROM runtime_events WHERE session_id = ?) + OR dispatch_event_id IN (SELECT event_id FROM runtime_events WHERE session_id = ?) + OR result_event_id IN (SELECT event_id FROM runtime_events WHERE session_id = ?) + `) + .run(sessionId, sessionId, sessionId); + database.prepare('DELETE FROM runtime_partial_snapshots WHERE session_id = ?').run(sessionId); + database.prepare('DELETE FROM runtime_events WHERE session_id = ?').run(sessionId); + database + .prepare('DELETE FROM core_agent_run_projections WHERE session_id = ?') + .run(sessionId); + database.prepare('DELETE FROM core_root_turn_admissions WHERE session_id = ?').run(sessionId); + database.prepare('DELETE FROM core_agent_runs WHERE session_id = ?').run(sessionId); + }); + } + + close(): void { + this.#lease.close(); + } +} diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index 04c4f4776f..853c8640e8 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -19,6 +19,10 @@ import { type RootTurnAdmission, type RootTurnSourceMessageReceipt, } from './agent-run-store.js'; +import { + createConversationOperationalStateStore, + type ConversationOperationalStateStore, +} from './conversation-operational-state.js'; import { createSqliteMessageReceiptStore, type MessageReceiptStore, @@ -99,6 +103,7 @@ export type ExecutionMessageReceiptWriter = MessageReceiptStore; interface ExecutionStoresWriterBase { readonly kind: K; readonly [executionStoresWriterBrand]: K; + purgeConversationOperationalState(sessionId: string): Promise; readonly sessionStore: Readonly; readonly agentRunStore: Readonly; readonly runtimeEventStore: Readonly; @@ -249,10 +254,23 @@ async function createExecutionStoresForWrite {}); + throw error; + } const messageReceiptStore = createSqliteMessageReceiptStore(lease.canonicalPath); await Promise.all([agentRunStore.ready?.(), messageReceiptStore.ready()]).catch(async (error) => { await closeExecutionStorePersistence(sessionStore, runtimePersistence, { agentRunStore, + conversationOperationalStateStore, messageReceiptStore, interactionStore, }).catch(() => {}); @@ -265,12 +283,16 @@ async function createExecutionStoresForWrite + run(() => conversationOperationalStateStore.purge(sessionId)), sessionStore: { create: (input, initialBoundary) => run(() => sessionStore.create(input, initialBoundary)), probeStableSessionCreate: (sessionId, requestFingerprint) => run(() => sessionStore.probeStableSessionCreate(sessionId, requestFingerprint)), createStableSession: (request, initialBoundary) => run(() => sessionStore.createStableSession(request, initialBoundary)), + discardStableConversationCopy: (sessionId, requestFingerprint) => + run(() => sessionStore.discardStableConversationCopy(sessionId, requestFingerprint)), createSubagent: (input, initialBoundary) => run(() => sessionStore.createSubagent(input, initialBoundary)), createAgentGraphOperator: (input, request, expectedRevision, initialBoundary) => @@ -329,6 +351,7 @@ async function createExecutionStoresForWrite closeExecutionStorePersistence(sessionStore, runtimePersistence, { agentRunStore, + conversationOperationalStateStore, messageReceiptStore, interactionStore, }), @@ -366,6 +389,8 @@ async function createExecutionStoresForWrite run(() => runtimeEventStore.appendRuntimeEvent(sessionId, runId, event, options)), + importConversationCopyRuntimeEvents: (sessionId, batches) => + run(() => runtimeEventStore.importConversationCopyRuntimeEvents(sessionId, batches)), ensureTerminalRuntimeEventDurable: (sessionId, runId, event) => run(() => runtimeEventStore.ensureTerminalRuntimeEventDurable(sessionId, runId, event)), readRuntimeEvents: (sessionId, runId) => @@ -502,6 +527,7 @@ async function closeExecutionStorePersistence( runtimePersistence: { close(): void }, extras: { agentRunStore?: Pick; + conversationOperationalStateStore?: Pick; messageReceiptStore?: { close(): void }; interactionStore?: | InteractiveInteractionStoreReaderFacade @@ -524,6 +550,11 @@ async function closeExecutionStorePersistence( } catch (error) { errors.push(error); } + try { + extras.conversationOperationalStateStore?.close(); + } catch (error) { + errors.push(error); + } try { extras.messageReceiptStore?.close(); } catch (error) { diff --git a/packages/storage/src/session-conversation-copy.ts b/packages/storage/src/session-conversation-copy.ts new file mode 100644 index 0000000000..5deaa15818 --- /dev/null +++ b/packages/storage/src/session-conversation-copy.ts @@ -0,0 +1,28 @@ +import type { SessionHeader } from '@maka/core'; + +export function isDiscardableConversationCopy(header: SessionHeader): boolean { + const copy = header.conversationCopy; + return ( + copy?.state === 'preparing' || + (copy?.kind === 'revision' && + copy.state === 'committed' && + header.revisionState === 'preparing') + ); +} + +export function isValidConversationCopyTransition( + current: SessionHeader, + next: SessionHeader['conversationCopy'], +): boolean { + const previous = current.conversationCopy; + return ( + previous !== undefined && + next !== undefined && + previous.kind === next.kind && + previous.sourceSessionId === next.sourceSessionId && + previous.sourceTurnId === next.sourceTurnId && + previous.requestFingerprint === next.requestFingerprint && + (previous.state !== 'committed' || next.state === 'committed') && + (previous.state !== 'preparing' || next.state === 'preparing' || next.state === 'committed') + ); +} diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index dc6dac6d4b..fc5de799c8 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -18,6 +18,10 @@ import { type SqliteSessionMetadataStore, type StableSessionCreateProbe, } from './sqlite-session-metadata-store.js'; +import { + isDiscardableConversationCopy, + isValidConversationCopyTransition, +} from './session-conversation-copy.js'; import { createSessionTranscriptMarker, decodeSessionTranscriptMarker, @@ -36,6 +40,7 @@ import { isOrchestrationMode, isPermissionMode, isSessionBlockedReason, + isSessionConversationCopy, isSubagentSessionParent, isSubagentSessionRuntime, isSubagentSessionSpawn, @@ -54,6 +59,7 @@ import type { SandboxBoundaryRequest, SandboxBoundarySettlement, SessionHeader, + SessionConversationCopy, SessionListFilter, SessionSummary, StoredMessage, @@ -122,9 +128,13 @@ export type SessionCatalogPageResult = export interface CreateStableSessionRequest { readonly sessionId: string; readonly requestFingerprint: string; - readonly input: CreateSessionInput; + readonly input: StableSessionCreateInput; } +export type StableSessionCreateInput = CreateSessionInput & { + readonly conversationCopy?: SessionConversationCopy; +}; + export type CreateStableSessionResult = | { readonly kind: 'created'; readonly record: SessionHeaderSnapshot } | { readonly kind: 'existing'; readonly record: SessionHeaderSnapshot } @@ -210,6 +220,7 @@ export interface SessionAuthorityStore extends SessionStore { request: CreateStableSessionRequest, initialBoundary?: ExecutionBoundary, ): Promise; + discardStableConversationCopy(sessionId: string, requestFingerprint: string): Promise; listCatalogPage( filter: SessionListFilter | undefined, cursor: SessionCatalogPageCursor | undefined, @@ -277,6 +288,7 @@ class SqliteSessionStore implements SessionAuthorityStore { initialBoundary?: ExecutionBoundary, ): Promise { await this.ensureReady(); + assertNoConversationCopyMetadata(input); if (input.subagentSpawn) { throw new Error('Subagent spawn metadata requires createSubagent()'); } @@ -304,6 +316,12 @@ class SqliteSessionStore implements SessionAuthorityStore { initialBoundary?: ExecutionBoundary, ): Promise { await this.ensureReady(); + if ( + request.input.conversationCopy && + request.input.conversationCopy.requestFingerprint !== request.requestFingerprint + ) { + throw new Error('Conversation copy fingerprint does not match the stable create request'); + } if (request.input.subagentSpawn) { throw new Error('Subagent spawn metadata requires createSubagent()'); } @@ -327,11 +345,37 @@ class SqliteSessionStore implements SessionAuthorityStore { : result; } + async discardStableConversationCopy( + sessionId: string, + requestFingerprint: string, + ): Promise { + await this.ensureReady(); + if (!(await this.metadata.hasStableSessionCreateClaim(sessionId, requestFingerprint))) { + throw new Error('Session is not owned by the matching stable create request'); + } + const probe = await this.metadata.probeStableSessionCreate(sessionId, requestFingerprint); + if (probe.kind === 'conflict') { + throw new Error('Stable Session identity belongs to a different request'); + } + if (probe.kind === 'existing') { + const copy = probe.record.header.conversationCopy; + if ( + copy?.requestFingerprint !== requestFingerprint || + !isDiscardableConversationCopy(probe.record.header) + ) { + throw new Error('Only a matching incomplete conversation copy can be discarded'); + } + } + await this.files.remove(sessionId); + return this.metadata.discardStableSessionCreate(sessionId, requestFingerprint); + } + async createSubagent( input: CreateSessionInput, initialBoundary?: ExecutionBoundary, ): Promise<{ header: SessionHeader; created: boolean }> { await this.ensureReady(); + assertNoConversationCopyMetadata(input); const staged = await this.files.createTranscript(input); try { const result = await this.metadata.createSubagent(staged, initialBoundary); @@ -350,6 +394,7 @@ class SqliteSessionStore implements SessionAuthorityStore { initialBoundary?: ExecutionBoundary, ): Promise<{ header: SessionHeader } & AgentGraphOperatorProvisionResult> { await this.ensureReady(); + assertNoConversationCopyMetadata(input); const staged = await this.files.createTranscript(input); try { const result = await this.metadata.createAgentGraphOperator( @@ -413,7 +458,9 @@ class SqliteSessionStore implements SessionAuthorityStore { async list(filter?: SessionListFilter): Promise { await this.ensureReady(); - const records = await this.metadata.list(filter); + const records = (await this.metadata.list(filter)).filter( + (record) => record.header.conversationCopy?.state !== 'preparing', + ); const withPreviews: Array<{ record: SessionMetadataRecord; previewMessages: StoredMessage[]; @@ -727,11 +774,15 @@ class SqliteSessionStore implements SessionAuthorityStore { if (!(await this.metadata.hasPendingCatalogProjectionWrites())) return; const projections = new Map>(); for (const record of await this.metadata.list()) { - const messages = await this.files.readTranscriptMessagesForRecovery( - record.header.id, - record.header, - ); - projections.set(record.header.id, catalogMessageProjection(messages)); + try { + const messages = await this.files.readTranscriptMessagesForRecovery( + record.header.id, + record.header, + ); + projections.set(record.header.id, catalogMessageProjection(messages)); + } catch (error) { + if (!isDiscardableConversationCopy(record.header)) throw error; + } } await this.metadata.recoverCatalogProjections(projections); } @@ -816,6 +867,7 @@ class FileSessionStore implements SessionStore { } async create(input: CreateSessionInput): Promise { + assertNoConversationCopyMetadata(input); if (input.subagentSpawn) { throw new Error('Child-session idempotency requires the SQLite metadata control plane'); } @@ -823,14 +875,21 @@ class FileSessionStore implements SessionStore { } async createTranscript(input: CreateSessionInput, sessionId?: string): Promise { + assertNoConversationCopyMetadata(input); return this.createWithInitialRecord(input, 'transcript-marker', sessionId); } async ensureStableTranscript( - input: CreateSessionInput, + input: StableSessionCreateInput, sessionId: string, ): Promise { - return this.createWithInitialRecord(input, 'transcript-marker', sessionId, true); + return this.createWithInitialRecord( + input, + 'transcript-marker', + sessionId, + true, + input.conversationCopy, + ); } private async createWithInitialRecord( @@ -838,6 +897,7 @@ class FileSessionStore implements SessionStore { initialRecord: 'legacy-header' | 'transcript-marker', sessionId?: string, reuseStableTranscript = false, + conversationCopy?: SessionConversationCopy, ): Promise { if ( input.projectId !== undefined && @@ -888,6 +948,7 @@ class FileSessionStore implements SessionStore { ...(input.subagentRuntime ? { subagentRuntime: input.subagentRuntime } : {}), ...(input.subagentSpawn ? { subagentSpawn: input.subagentSpawn } : {}), ...(input.subagentWorkspace ? { subagentWorkspace: input.subagentWorkspace } : {}), + ...(conversationCopy ? { conversationCopy } : {}), ...(input.revisionRootSessionId ? { revisionRootSessionId: input.revisionRootSessionId } : {}), @@ -1176,6 +1237,7 @@ class FileSessionStore implements SessionStore { let nextHeader: SessionHeader | undefined; await this.withQueue(sessionId, async () => { const { header, messages } = await this.readFilePartsUnlocked(sessionId); + assertConversationCopyTransition(header, patch); nextHeader = { ...header, ...patch }; assertValidSessionLineage(nextHeader); const lines = [ @@ -1691,6 +1753,7 @@ export function normalizeSessionHeader( (header.statusUpdatedAt === undefined || isFiniteNumber(header.statusUpdatedAt)) && (header.parentSessionId === undefined || typeof header.parentSessionId === 'string') && (header.branchOfTurnId === undefined || typeof header.branchOfTurnId === 'string') && + isValidConversationCopyLineage(header) && isValidRevisionLineage(header) && isValidSubagentSessionLineage(header) && (header.lastReadMessageId === undefined || typeof header.lastReadMessageId === 'string') && @@ -1738,6 +1801,9 @@ function isValidRevisionLineage(header: SessionHeader): boolean { } function assertValidSessionLineage(header: SessionHeader): void { + if (!isValidConversationCopyLineage(header)) { + throw new Error('Invalid Session conversation-copy lineage'); + } if (!isValidRevisionLineage(header)) { throw new Error('Invalid session revision lineage'); } @@ -1746,6 +1812,44 @@ function assertValidSessionLineage(header: SessionHeader): void { } } +function isValidConversationCopyLineage(header: SessionHeader): boolean { + const copy = header.conversationCopy; + if (copy === undefined) return true; + if ( + !isSessionConversationCopy(copy) || + !isSafeSessionId(copy.sourceSessionId) || + copy.sourceSessionId === header.id || + header.subagentParent !== undefined + ) { + return false; + } + if (copy.kind === 'branch') { + return ( + header.parentSessionId === copy.sourceSessionId && + header.branchOfTurnId === copy.sourceTurnId && + header.revisionRootSessionId === undefined && + header.revisionParentSessionId === undefined && + header.revisionOfTurnId === undefined && + header.revisionIndex === undefined && + header.revisionState === undefined + ); + } + return ( + header.revisionParentSessionId === copy.sourceSessionId && + header.revisionOfTurnId === copy.sourceTurnId + ); +} + +function assertConversationCopyTransition( + current: SessionHeader, + patch: Partial, +): void { + if (!Object.prototype.hasOwnProperty.call(patch, 'conversationCopy')) return; + if (!isValidConversationCopyTransition(current, patch.conversationCopy)) { + throw new Error('Session conversation-copy identity is immutable'); + } +} + function isValidSubagentSessionLineage(header: SessionHeader): boolean { if (header.subagentParent === undefined) { return ( @@ -1790,6 +1894,12 @@ function hasErrorCode(error: unknown, code: string): boolean { return (error as NodeJS.ErrnoException | undefined)?.code === code; } +function assertNoConversationCopyMetadata(input: CreateSessionInput): void { + if (Object.prototype.hasOwnProperty.call(input, 'conversationCopy')) { + throw new Error('Conversation copy metadata requires createStableSession()'); + } +} + function projectHeaderSnapshot(record: SessionMetadataRecord): SessionHeaderSnapshot { return { header: record.header, diff --git a/packages/storage/src/sqlite-runtime-store.ts b/packages/storage/src/sqlite-runtime-store.ts index 5d6381b5ef..e18d4bbc48 100644 --- a/packages/storage/src/sqlite-runtime-store.ts +++ b/packages/storage/src/sqlite-runtime-store.ts @@ -33,7 +33,10 @@ import { RUNTIME_RECOVERY_AUTHORITY_CAPABILITY_VERSION, SQLITE_RUNTIME_SCHEMA_VERSION, } from './sqlite-runtime-schema.js'; -import type { ImmutableSteeringMessageProof } from './agent-run-store.js'; +import type { + ConversationCopyRuntimeEventBatch, + ImmutableSteeringMessageProof, +} from './agent-run-store.js'; import type { OperationalStateDatabaseLease } from './operational-state-store.js'; import { immutableSteeringMessageId, isRuntimeStorageSafeId } from './runtime-event-invariants.js'; @@ -317,6 +320,65 @@ export class SqliteRuntimeStore implements RuntimeRecoveryBundleStore { }); } + async importConversationCopyRuntimeEvents( + sessionId: string, + batches: readonly ConversationCopyRuntimeEventBatch[], + ): Promise { + assertRuntimeStorageSafeId(sessionId, 'Invalid session id'); + const runIds = new Set(); + const canonicalBatches = batches.map(({ runId, events }) => { + assertRuntimeStorageSafeId(runId, 'Invalid run id'); + if (runIds.has(runId)) { + throw new Error(`Conversation copy contains duplicate run ${runId}`); + } + runIds.add(runId); + return { + runId, + events: events.map(canonicalizeRuntimeEventForStorage), + }; + }); + const canonicalEvents = canonicalBatches.flatMap(({ events }) => events); + for (const { runId, events } of canonicalBatches) { + for (const event of events) { + if (isPartialRuntimeEvent(event)) { + throw new Error('Conversation copy cannot import partial RuntimeEvents'); + } + if (event.sessionId !== sessionId || event.runId !== runId) { + throw new Error(`RuntimeEvent store identity does not match event ${event.id}`); + } + } + } + const scan = scanToolLedger(canonicalEvents); + if (scan.hasCorruption) { + throw new Error( + `Conversation copy RuntimeEvent ledger is corrupt: ${scan.issues[0]?.code ?? 'unknown'}`, + ); + } + this.transaction(() => { + for (const { runId, events } of canonicalBatches) { + const existing = ( + this.db + .prepare(` + SELECT event_id, session_id, invocation_id, run_id, turn_id, payload_json + FROM runtime_events + WHERE session_id = ? AND run_id = ? + ORDER BY event_seq ASC, event_id ASC + `) + .all(sessionId, runId) as unknown as RuntimeEventStorageRow[] + ).map(decodeRuntimeEventStorageRow); + if (existing.length > 0 && !isDeepStrictEqual(existing, events)) { + throw new Error(`Conversation copy RuntimeEvent identity conflict for run ${runId}`); + } + if (existing.length === 0) { + for (const event of events) this.insertRuntimeEvent(event, event.ts, true); + } + } + if (canonicalEvents.some(isToolLedgerBearingEvent)) { + this.rebuildToolProjectionsFromRuntimeEventsSync(sessionId); + } + }); + } + async isRuntimeImportSourceCurrent(path: string, fingerprint: string): Promise { const existing = this.db .prepare(` @@ -632,34 +694,42 @@ export class SqliteRuntimeStore implements RuntimeRecoveryBundleStore { } async rebuildToolProjectionsFromRuntimeEvents(): Promise { - return this.transaction(() => { - const rows = this.db - .prepare(` + return this.transaction(() => this.rebuildToolProjectionsFromRuntimeEventsSync()); + } + + private rebuildToolProjectionsFromRuntimeEventsSync( + sessionId?: string, + ): ToolProjectionRebuildResult { + const statement = this.db.prepare(` SELECT event_id, session_id, invocation_id, run_id, turn_id, event_seq, payload_json, committed_at FROM runtime_events + ${sessionId === undefined ? '' : 'WHERE session_id = ?'} ORDER BY invocation_id ASC, event_seq ASC, event_id ASC - `) - .all() as unknown as Array< - RuntimeEventStorageRow & { event_seq: number; committed_at: number } - >; - const events = rows.map(decodeRuntimeEventStorageRow); - const eventOrder = new Map(events.map((event, index) => [event.id, index] as const)); - const committedAt = new Map( - rows.map((row, index) => [events[index]!.id, row.committed_at] as const), + `); + const rows = (sessionId === undefined + ? statement.all() + : statement.all(sessionId)) as unknown as Array< + RuntimeEventStorageRow & { event_seq: number; committed_at: number } + >; + const events = rows.map(decodeRuntimeEventStorageRow); + const eventOrder = new Map(events.map((event, index) => [event.id, index] as const)); + const committedAt = new Map( + rows.map((row, index) => [events[index]!.id, row.committed_at] as const), + ); + const scan = scanToolLedger(events); + if (scan.hasCorruption) { + const first = scan.issues[0]; + throw new Error( + `Corrupt tool RuntimeEvent ledger: ${first?.code ?? 'unknown'} at ${first?.eventId ?? 'unknown'}`, ); - const scan = scanToolLedger(events); - if (scan.hasCorruption) { - const first = scan.issues[0]; - throw new Error( - `Corrupt tool RuntimeEvent ledger: ${first?.code ?? 'unknown'} at ${first?.eventId ?? 'unknown'}`, - ); - } - const projected = scan.operations.filter((operation) => operation.dispatchEvent); + } + const projected = scan.operations.filter((operation) => operation.dispatchEvent); - // Mainline schema 4 can contain pre-authority projections without a - // dispatch RuntimeEvent. They remain readable but quarantined from - // recovery; only projections backed by canonical T1 facts are rebuilt. + // Mainline schema 4 can contain pre-authority projections without a + // dispatch RuntimeEvent. They remain readable but quarantined from + // recovery; only projections backed by canonical T1 facts are rebuilt. + if (sessionId === undefined) { this.db.exec(` DELETE FROM tool_journal_events WHERE operation_id IN ( @@ -667,123 +737,146 @@ export class SqliteRuntimeStore implements RuntimeRecoveryBundleStore { ); DELETE FROM tool_operations WHERE dispatch_event_id IS NOT NULL; `); - let journalEvents = 0; - for (const operation of projected) { - const call = operation.callEvent; - const event = operation.dispatchEvent; - const dispatch = event?.actions?.toolDispatch; - if (!call || !event || !dispatch) { - throw new Error('Tool projection scan produced an incomplete dispatched operation'); - } - const recovery = interpretScannedToolRecovery(operation, eventOrder); - if (recovery.kind === 'corruption') { - throw new Error( - `Corrupt tool recovery bundle for ${dispatch.operationId}: ${recovery.code}`, - ); - } - const reconcileEvent = recovery.kind === 'valid' ? recovery.reconcileEvent : undefined; - const decisionEvent = recovery.kind === 'valid' ? recovery.decisionEvent : undefined; + } else { + this.db + .prepare(` + DELETE FROM tool_journal_events + WHERE operation_id IN ( + SELECT operation_id + FROM tool_operations + WHERE dispatch_event_id IS NOT NULL + AND call_event_id IN ( + SELECT event_id FROM runtime_events WHERE session_id = ? + ) + ) + `) + .run(sessionId); + this.db + .prepare(` + DELETE FROM tool_operations + WHERE dispatch_event_id IS NOT NULL + AND call_event_id IN ( + SELECT event_id FROM runtime_events WHERE session_id = ? + ) + `) + .run(sessionId); + } + let journalEvents = 0; + for (const operation of projected) { + const call = operation.callEvent; + const event = operation.dispatchEvent; + const dispatch = event?.actions?.toolDispatch; + if (!call || !event || !dispatch) { + throw new Error('Tool projection scan produced an incomplete dispatched operation'); + } + const recovery = interpretScannedToolRecovery(operation, eventOrder); + if (recovery.kind === 'corruption') { + throw new Error( + `Corrupt tool recovery bundle for ${dispatch.operationId}: ${recovery.code}`, + ); + } + const reconcileEvent = recovery.kind === 'valid' ? recovery.reconcileEvent : undefined; + const decisionEvent = recovery.kind === 'valid' ? recovery.decisionEvent : undefined; - this.db - .prepare(` + this.db + .prepare(` INSERT INTO tool_journal_events ( journal_event_id, operation_id, invocation_id, run_id, turn_id, state, runtime_event_id, canonical_args_hash, recovery_mode, committed_at ) VALUES (?, ?, ?, ?, ?, 'prepared', ?, ?, ?, ?) `) - .run( - `${dispatch.operationId}_prepared`, - dispatch.operationId, - event.invocationId, - event.runId, - event.turnId, - event.id, - dispatch.canonicalArgsHash, - dispatch.recoveryMode, - committedAt.get(event.id) ?? event.ts, - ); - journalEvents += 1; - const response = operation.responseEvent; - const decision = recovery.kind === 'valid' ? recovery.decision : undefined; - const currentState = decision - ? decision.disposition === 'completed' - ? 'recovery_completed' - : 'recovery_parked' - : response - ? 'outcome_committed' - : 'prepared'; - const tail = [ - ...(reconcileEvent - ? [{ event: reconcileEvent, state: 'reconcile_observed' as const }] - : []), - ...(response ? [{ event: response, state: 'outcome_committed' as const }] : []), - ...(decisionEvent - ? [ - { - event: decisionEvent, - state: - decision?.disposition === 'parked' - ? ('recovery_parked' as const) - : ('recovery_completed' as const), - }, - ] - : []), - ].sort( - (a, b) => - requireRuntimeEventOrder(eventOrder, a.event.id) - - requireRuntimeEventOrder(eventOrder, b.event.id), + .run( + `${dispatch.operationId}_prepared`, + dispatch.operationId, + event.invocationId, + event.runId, + event.turnId, + event.id, + dispatch.canonicalArgsHash, + dispatch.recoveryMode, + committedAt.get(event.id) ?? event.ts, ); - this.db - .prepare(` + journalEvents += 1; + const response = operation.responseEvent; + const decision = recovery.kind === 'valid' ? recovery.decision : undefined; + const currentState = decision + ? decision.disposition === 'completed' + ? 'recovery_completed' + : 'recovery_parked' + : response + ? 'outcome_committed' + : 'prepared'; + const tail = [ + ...(reconcileEvent + ? [{ event: reconcileEvent, state: 'reconcile_observed' as const }] + : []), + ...(response ? [{ event: response, state: 'outcome_committed' as const }] : []), + ...(decisionEvent + ? [ + { + event: decisionEvent, + state: + decision?.disposition === 'parked' + ? ('recovery_parked' as const) + : ('recovery_completed' as const), + }, + ] + : []), + ].sort( + (a, b) => + requireRuntimeEventOrder(eventOrder, a.event.id) - + requireRuntimeEventOrder(eventOrder, b.event.id), + ); + this.db + .prepare(` INSERT INTO tool_operations ( operation_id, invocation_id, run_id, turn_id, provider_tool_call_id, tool_name, canonical_args_hash, recovery_mode, current_state, call_event_id, dispatch_event_id, result_event_id, version ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) - .run( - dispatch.operationId, - event.invocationId, - event.runId, - event.turnId, - dispatch.providerToolCallId, - dispatch.toolName, - dispatch.canonicalArgsHash, - dispatch.recoveryMode, - currentState, - call.id, - event.id, - response?.id ?? null, - 1 + tail.length, - ); - for (const item of tail) { - this.db - .prepare(` + .run( + dispatch.operationId, + event.invocationId, + event.runId, + event.turnId, + dispatch.providerToolCallId, + dispatch.toolName, + dispatch.canonicalArgsHash, + dispatch.recoveryMode, + currentState, + call.id, + event.id, + response?.id ?? null, + 1 + tail.length, + ); + for (const item of tail) { + this.db + .prepare(` INSERT INTO tool_journal_events ( journal_event_id, operation_id, invocation_id, run_id, turn_id, state, runtime_event_id, canonical_args_hash, recovery_mode, metadata_json, committed_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) - .run( - journalEventIdFor(dispatch.operationId, item.event, item.state), - dispatch.operationId, - item.event.invocationId, - item.event.runId, - item.event.turnId, - item.state, - item.event.id, - dispatch.canonicalArgsHash, - dispatch.recoveryMode, - item.event.actions?.toolRecovery - ? JSON.stringify(item.event.actions.toolRecovery) - : null, - committedAt.get(item.event.id) ?? item.event.ts, - ); - journalEvents += 1; - } + .run( + journalEventIdFor(dispatch.operationId, item.event, item.state), + dispatch.operationId, + item.event.invocationId, + item.event.runId, + item.event.turnId, + item.state, + item.event.id, + dispatch.canonicalArgsHash, + dispatch.recoveryMode, + item.event.actions?.toolRecovery + ? JSON.stringify(item.event.actions.toolRecovery) + : null, + committedAt.get(item.event.id) ?? item.event.ts, + ); + journalEvents += 1; } - return { operations: projected.length, journalEvents }; - }); + } + return { operations: projected.length, journalEvents }; } private commitToolOutcomeSync(input: CommitToolOutcomeInput): ToolCommitResult { diff --git a/packages/storage/src/sqlite-session-catalog-query.ts b/packages/storage/src/sqlite-session-catalog-query.ts index e456f4892f..48e411c9a2 100644 --- a/packages/storage/src/sqlite-session-catalog-query.ts +++ b/packages/storage/src/sqlite-session-catalog-query.ts @@ -18,6 +18,9 @@ export function buildSqliteSessionCatalogPageQuery( const orderBy = usesLabel ? 'selected_label' : 'projection'; const where: string[] = []; const parameters: Array = []; + where.push( + "COALESCE(json_extract(metadata.payload_json, '$.conversationCopy.state'), '') <> 'preparing'", + ); if (filter.labelSlug !== undefined) { where.push('selected_label.label = ?'); parameters.push(filter.labelSlug); diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index e5d007a0e4..4030d235ae 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -63,6 +63,10 @@ import { normalizeSessionHeader, SessionNotFoundError, } from './session-store.js'; +import { + isDiscardableConversationCopy, + isValidConversationCopyTransition, +} from './session-conversation-copy.js'; import { configureSqliteSessionMetadataDatabase, migrateSqliteSessionMetadataDatabase, @@ -661,6 +665,21 @@ export class SqliteSessionMetadataStore { }); } + async hasStableSessionCreateClaim( + sessionId: string, + requestFingerprint: string, + ): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + assertSessionCreateFingerprint(requestFingerprint); + const row = this.db + .prepare( + 'SELECT request_fingerprint AS requestFingerprint FROM session_create_claims WHERE session_id = ?', + ) + .get(sessionId) as { requestFingerprint?: unknown } | undefined; + return row?.requestFingerprint === requestFingerprint; + } + async createStableSession( header: SessionHeader, requestFingerprint: string, @@ -691,6 +710,43 @@ export class SqliteSessionMetadataStore { }); } + async discardStableSessionCreate( + sessionId: string, + requestFingerprint: string, + ): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + assertSessionCreateFingerprint(requestFingerprint); + return this.transaction(() => { + const probe = this.probeStableSessionCreateSync(sessionId, requestFingerprint); + if (probe.kind === 'conflict') { + throw new SessionMetadataConflictError( + 'Stable Session identity belongs to a different request', + ); + } + if (probe.kind === 'existing') { + const copy = probe.record.header.conversationCopy; + if ( + copy?.requestFingerprint !== requestFingerprint || + !isDiscardableConversationCopy(probe.record.header) + ) { + throw new SessionMetadataConflictError( + 'Only a matching incomplete conversation copy can be discarded', + ); + } + } + const deleted = + this.db.prepare('DELETE FROM session_metadata WHERE session_id = ?').run(sessionId) + .changes === 1; + this.db + .prepare( + 'DELETE FROM session_create_claims WHERE session_id = ? AND request_fingerprint = ?', + ) + .run(sessionId, requestFingerprint); + return deleted; + }); + } + async createSubagent( header: SessionHeader, initialBoundary?: ExecutionBoundary, @@ -860,6 +916,10 @@ export class SqliteSessionMetadataStore { JOIN session_metadata metadata ON metadata.session_id = projection.session_id WHERE projection.session_id = ? + AND COALESCE( + json_extract(metadata.payload_json, '$.conversationCopy.state'), + '' + ) <> 'preparing' `) .get(sessionId) as SessionMetadataCatalogRow | undefined; if (!row) throw new SessionNotFoundError(sessionId); @@ -2415,6 +2475,7 @@ export class SqliteSessionMetadataStore { current.metadataVersion, ); } + assertConversationCopyTransition(current.header, patch); const next = normalizeSessionHeader({ ...current.header, ...patch }, sessionId); if (next.id !== sessionId) { throw new SessionMetadataConflictError('Session metadata identity cannot be changed'); @@ -3598,6 +3659,16 @@ function assertSessionCreateFingerprint(value: string): void { } } +function assertConversationCopyTransition( + current: SessionHeader, + patch: Partial, +): void { + if (!Object.prototype.hasOwnProperty.call(patch, 'conversationCopy')) return; + if (!isValidConversationCopyTransition(current, patch.conversationCopy)) { + throw new SessionMetadataConflictError('Session conversation-copy identity is immutable'); + } +} + function requireManagedProfile( boundary: ExecutionBoundary, ): Extract['profile'] { diff --git a/packages/storage/src/task-ledger-authority.ts b/packages/storage/src/task-ledger-authority.ts index 1f0730b00f..15889fefb3 100644 --- a/packages/storage/src/task-ledger-authority.ts +++ b/packages/storage/src/task-ledger-authority.ts @@ -5,13 +5,18 @@ import { StorageRootAuthorityError, type StorageRootLease, } from './root-authority.js'; -import { createSqliteTaskLedgerStore, type SqliteTaskLedgerStore } from './task-ledger-store.js'; +import { + createSqliteTaskLedgerStore, + type ConversationTaskLedgerCopyInput, + type SqliteTaskLedgerStore, +} from './task-ledger-store.js'; import { getTaskLedgerCanonicalReader, type TaskLedgerCanonicalReader, } from './task-ledger-store-internal.js'; export type { TaskLedgerCanonicalReader } from './task-ledger-store-internal.js'; +export type { ConversationTaskLedgerCopyInput } from './task-ledger-store.js'; const writerBrand: unique symbol = Symbol('InteractiveTaskLedgerWriter'); const writers = new WeakSet(); @@ -23,6 +28,8 @@ export interface InteractiveTaskLedgerWriter extends TaskLedgerStore, TaskLedger readonly access: 'write'; readonly [writerBrand]: true; close(): void; + copyConversationTaskLedger(input: ConversationTaskLedgerCopyInput): Promise; + purgeConversationTaskLedger(sessionId: string): Promise; } export function authenticateInteractiveTaskLedgerWriter( @@ -114,6 +121,16 @@ function createInteractiveWriterFacade( run(() => store.claimAvailable(sessionId, id, owner, scope, context)), settleAgentOutcome: (sessionId, id, outcome, context) => run(() => store.settleAgentOutcome(sessionId, id, outcome, context)), + copyConversationTaskLedger: (input) => { + const acceptedInput: ConversationTaskLedgerCopyInput = Object.freeze({ + ...input, + turnIds: Object.freeze([...input.turnIds]), + runIdMap: Object.freeze(input.runIdMap.map((entry) => Object.freeze({ ...entry }))), + }); + return run(() => store.copyConversationTaskLedger(acceptedInput)); + }, + purgeConversationTaskLedger: (sessionId) => + run(() => store.purgeConversationTaskLedger(sessionId)), subscribe: (listener) => store.subscribe(listener), close: () => { if (closed) return; diff --git a/packages/storage/src/task-ledger-store.ts b/packages/storage/src/task-ledger-store.ts index 3ab271289f..c02906f36c 100644 --- a/packages/storage/src/task-ledger-store.ts +++ b/packages/storage/src/task-ledger-store.ts @@ -1,4 +1,4 @@ -import { appendFile, mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises'; +import { appendFile, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import { createHash, randomUUID } from 'node:crypto'; import type { DatabaseSync } from 'node:sqlite'; @@ -35,6 +35,7 @@ import { } from '@maka/core/task-ledger'; import { chainWrite } from './write-queue.js'; import { assertSafeSessionId } from './session-store.js'; +import { syncDirectory } from './stable-storage.js'; import { registerTaskLedgerCanonicalReader } from './task-ledger-store-internal.js'; import { acquireOperationalStateDatabase, @@ -45,11 +46,27 @@ import { export type { TaskLedgerStore } from '@maka/core/task-ledger'; -export function createTaskLedgerStore(workspaceRoot: string): TaskLedgerStore { +export interface ConversationTaskLedgerCopyInput { + readonly sourceSessionId: string; + readonly targetSessionId: string; + readonly turnIds: readonly string[]; + readonly beforeTs?: number; + readonly runIdMap: readonly { + readonly sourceRunId: string; + readonly targetRunId: string; + }[]; +} + +export interface TaskLedgerAuthorityStore extends TaskLedgerStore { + copyConversationTaskLedger(input: ConversationTaskLedgerCopyInput): Promise; + purgeConversationTaskLedger(sessionId: string): Promise; +} + +export function createTaskLedgerStore(workspaceRoot: string): TaskLedgerAuthorityStore { return new FileTaskLedgerStore(workspaceRoot); } -export interface SqliteTaskLedgerStore extends TaskLedgerStore { +export interface SqliteTaskLedgerStore extends TaskLedgerAuthorityStore { ready(): Promise; close(): void; } @@ -65,7 +82,7 @@ export function createSqliteTaskLedgerStore( return new SqliteTaskLedgerStoreImpl(workspaceRoot, options); } -class FileTaskLedgerStore implements TaskLedgerStore { +class FileTaskLedgerStore implements TaskLedgerAuthorityStore { private readonly sessionsRoot: string; private readonly writeQueues = new Map>(); private readonly listeners = new Set<(event: TaskLedgerChangedEvent) => void>(); @@ -117,6 +134,90 @@ class FileTaskLedgerStore implements TaskLedgerStore { return () => this.listeners.delete(listener); } + async copyConversationTaskLedger(input: ConversationTaskLedgerCopyInput): Promise { + assertSafeSessionId(input.sourceSessionId); + assertSafeSessionId(input.targetSessionId); + if (input.sourceSessionId === input.targetSessionId) { + throw new Error('Task Ledger conversation copy requires distinct Sessions'); + } + if ( + input.beforeTs !== undefined && + (!Number.isSafeInteger(input.beforeTs) || input.beforeTs < 0) + ) { + throw new Error('Task Ledger conversation-copy boundary is invalid'); + } + const turnIds = new Set(input.turnIds); + const runIds = new Map( + input.runIdMap.map(({ sourceRunId, targetRunId }) => [sourceRunId, targetRunId]), + ); + const source = await this.readConversationCopyEvents(input.sourceSessionId); + const selected: TaskLedgerEvent[] = []; + let crossedBoundary = false; + for (const event of source) { + const eventTurnId = event.refs?.turnId; + const retained = + eventTurnId !== undefined + ? turnIds.has(eventTurnId) + : input.beforeTs === undefined || event.ts < input.beforeTs; + if (!retained) { + crossedBoundary = true; + continue; + } + if (crossedBoundary) { + throw new Error('Task Ledger events cross the conversation-copy boundary'); + } + selected.push( + rewriteConversationTaskEvent(event, input.sourceSessionId, input.targetSessionId, runIds), + ); + } + if (selected.length === 0) return; + const projection = projectTaskLedgerEvents(selected); + if (projection.diagnostics.length > 0) { + throw new Error( + `Task Ledger conversation copy is not projectable: ${projection.diagnostics.join('; ')}`, + ); + } + + await chainWrite(this.writeQueues, input.targetSessionId, async () => { + if ( + await this.copyCanonicalConversationTaskLedger( + input.targetSessionId, + selected, + projection.tasks, + ) + ) { + return; + } + const eventsPath = this.eventsPath(input.targetSessionId); + const tasksPath = this.filePath(input.targetSessionId); + await assertConversationCopyTargetAbsent(eventsPath); + await assertConversationCopyTargetAbsent(tasksPath); + await mkdir(dirname(eventsPath), { recursive: true }); + await writeFile( + eventsPath, + selected.map((event) => JSON.stringify(event)).join('\n') + '\n', + { encoding: 'utf8', flag: 'wx' }, + ); + await this.write(input.targetSessionId, projection.tasks); + }); + } + + async purgeConversationTaskLedger(sessionId: string): Promise { + assertSafeSessionId(sessionId); + await chainWrite(this.writeQueues, sessionId, async () => { + if (await this.purgeCanonicalConversationTaskLedger(sessionId)) return; + const eventsPath = this.eventsPath(sessionId); + const tasksPath = this.filePath(sessionId); + await rm(eventsPath, { force: true }); + await rm(tasksPath, { force: true }); + try { + await syncDirectory(dirname(eventsPath)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + }); + } + async create( sessionId: string, drafts: unknown, @@ -600,6 +701,21 @@ class FileTaskLedgerStore implements TaskLedgerStore { return events; } + private async readConversationCopyEvents(sessionId: string): Promise { + try { + return await this.readTaskEvents(sessionId); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + const legacy = await this.readForMutateWithSource(sessionId); + if (legacy.tasks.length > 0) { + throw new Error( + 'Legacy Task Ledger snapshots cannot be placed at an exact conversation boundary', + ); + } + return []; + } + } + private async mutate( sessionId: string, fn: (tasks: Task[]) => Task[], @@ -696,6 +812,18 @@ class FileTaskLedgerStore implements TaskLedgerStore { return false; } + protected async copyCanonicalConversationTaskLedger( + _sessionId: string, + _events: readonly TaskLedgerEvent[], + _tasks: readonly Task[], + ): Promise { + return false; + } + + protected async purgeCanonicalConversationTaskLedger(_sessionId: string): Promise { + return false; + } + private applyListOptions(tasks: Task[], options: TaskLedgerListOptions): Task[] { const now = options.now ?? Date.now(); const filtered = tasks.filter((task) => { @@ -777,6 +905,46 @@ class SqliteTaskLedgerStoreImpl extends FileTaskLedgerStore implements SqliteTas }); return true; } + + protected override async copyCanonicalConversationTaskLedger( + sessionId: string, + events: readonly TaskLedgerEvent[], + tasks: readonly Task[], + ): Promise { + await this.#ready; + this.#lease.transaction('write', () => { + const existing = this.#lease.database + .prepare(` + SELECT + (SELECT COUNT(*) FROM workflow_task_ledger_events WHERE session_id = ?) + + (SELECT COUNT(*) FROM workflow_task_ledger_projections WHERE session_id = ?) AS count + `) + .get(sessionId, sessionId) as { count?: unknown }; + if (existing.count !== 0) { + throw new Error('Task Ledger conversation-copy target already exists'); + } + for (const event of events) { + insertTaskLedgerEvent(this.#lease.database, sessionId, event); + } + writeTaskLedgerProjection(this.#lease.database, sessionId, [...tasks]); + }); + return true; + } + + protected override async purgeCanonicalConversationTaskLedger( + sessionId: string, + ): Promise { + await this.#ready; + this.#lease.transaction('write', () => { + this.#lease.database + .prepare('DELETE FROM workflow_task_ledger_events WHERE session_id = ?') + .run(sessionId); + this.#lease.database + .prepare('DELETE FROM workflow_task_ledger_projections WHERE session_id = ?') + .run(sessionId); + }); + return true; + } } interface LegacyTaskLedger { @@ -1172,6 +1340,69 @@ function clearStaleTaskEvidence(task: Task): Task { return next; } +function rewriteConversationTaskEvent( + event: TaskLedgerEvent, + sourceSessionId: string, + targetSessionId: string, + runIds: ReadonlyMap, +): TaskLedgerEvent { + const owner = event.task.owner; + const rewrittenOwner = + owner === undefined + ? undefined + : { + ...owner, + ...(owner.sessionId === sourceSessionId ? { sessionId: targetSessionId } : {}), + ...(owner.runId + ? { + runId: requiredConversationCopyRunId(runIds, owner.runId), + } + : {}), + }; + const refs = + event.refs === undefined + ? undefined + : { + ...event.refs, + ...(event.refs.runId + ? { runId: requiredConversationCopyRunId(runIds, event.refs.runId) } + : {}), + }; + return { + ...event, + eventId: `task-copy-${createHash('sha256') + .update(JSON.stringify([targetSessionId, event.eventId])) + .digest('hex')}`, + sessionId: targetSessionId, + task: { + ...event.task, + ...(rewrittenOwner ? { owner: rewrittenOwner } : {}), + }, + ...(refs ? { refs } : {}), + }; +} + +function requiredConversationCopyRunId( + runIds: ReadonlyMap, + sourceRunId: string, +): string { + const targetRunId = runIds.get(sourceRunId); + if (!targetRunId) { + throw new Error(`Conversation copy is missing AgentRun ${sourceRunId}`); + } + return targetRunId; +} + +async function assertConversationCopyTargetAbsent(path: string): Promise { + try { + await readFile(path, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; + throw error; + } + throw new Error('Task Ledger conversation-copy target already exists'); +} + function buildTaskLedgerEvent(input: { type: TaskLedgerEvent['type']; sessionId: string;