From 770c52fd0e72671a676e4a106ff0f9893a17036a Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Tue, 8 Sep 2026 13:16:27 +0800 Subject: [PATCH 01/10] feat(schema): add the session.fork wire frames and the fork operation kind --- .../schema/src/model/conversation.ts | 6 ++-- packages/foundation/schema/src/wire/index.ts | 1 + .../foundation/schema/src/wire/message.ts | 2 +- .../foundation/schema/src/wire/session.ts | 28 ++++++++++++++++++- .../tests/contract/wire/session.test.ts | 20 +++++++++++++ 5 files changed, 53 insertions(+), 4 deletions(-) diff --git a/packages/foundation/schema/src/model/conversation.ts b/packages/foundation/schema/src/model/conversation.ts index 943027223..aa8b88873 100644 --- a/packages/foundation/schema/src/model/conversation.ts +++ b/packages/foundation/schema/src/model/conversation.ts @@ -97,11 +97,13 @@ export const ProviderTurnBindingSchema = z.object({ }); export type ProviderTurnBinding = z.infer; -export const ConversationOperationKindSchema = z.enum(['turn.submit']); +export const ConversationOperationKindSchema = z.enum(['turn.submit', 'session.fork']); export type ConversationOperationKind = z.infer; /** Durable idempotency journal for conversation mutations: consulted before any validation, so a - * reply lost to a disconnect replays the terminal result instead of duplicating a sibling. */ + * reply lost to a disconnect replays the terminal result instead of duplicating a sibling (or a + * forked session). A succeeded `session.fork` names the child's copied leaf as its `turnId` — the + * turn row's `sessionId` is the forked session. */ export const ConversationOperationSchema = z.discriminatedUnion('state', [ z.object({ operationId: OperationIdSchema, diff --git a/packages/foundation/schema/src/wire/index.ts b/packages/foundation/schema/src/wire/index.ts index 1b4b41873..aef4861f8 100644 --- a/packages/foundation/schema/src/wire/index.ts +++ b/packages/foundation/schema/src/wire/index.ts @@ -38,6 +38,7 @@ export { } from './message'; export { WIRE_PAYLOAD_KINDS, type WirePayload, WirePayloadSchema } from './payload'; export { + SESSION_FORK_WIRE_VERSION, type SessionChangeReason, SessionChangeReasonSchema, type SessionSubscriptionMode, diff --git a/packages/foundation/schema/src/wire/message.ts b/packages/foundation/schema/src/wire/message.ts index fd63d3d3b..53693a930 100644 --- a/packages/foundation/schema/src/wire/message.ts +++ b/packages/foundation/schema/src/wire/message.ts @@ -9,7 +9,7 @@ import { WIRE_PAYLOAD_KINDS, WirePayloadSchema } from './payload'; */ /** Stamped on every frame this build sends; bump on any wire schema change. */ -export const WIRE_PROTOCOL_VERSION = 81 as const; +export const WIRE_PROTOCOL_VERSION = 82 as const; /** The oldest `v` this build still accepts. Bump only for a breaking change — a variant or field * removed, renamed, or given a new meaning; additive changes leave it alone. */ diff --git a/packages/foundation/schema/src/wire/session.ts b/packages/foundation/schema/src/wire/session.ts index 71785c98c..2d978c73b 100644 --- a/packages/foundation/schema/src/wire/session.ts +++ b/packages/foundation/schema/src/wire/session.ts @@ -1,7 +1,13 @@ import { z } from 'zod'; import { StartOptionsSchema } from '../model/agent'; import { McpWarningSchema } from '../model/custom-mcp'; -import { AgentHistoryIdSchema, AgentKindSchema, SessionIdSchema } from '../model/primitives'; +import { + AgentHistoryIdSchema, + AgentKindSchema, + OperationIdSchema, + SessionIdSchema, + TurnIdSchema, +} from '../model/primitives'; import { SessionInfoSchema, SessionNotificationSchema, @@ -20,6 +26,9 @@ export type SessionSubscriptionMode = z.infer; +/** The wire version that introduced `session.fork`; clients feature-detect the affordance on it. */ +export const SESSION_FORK_WIRE_VERSION = 82 as const; + /** Session control wire variants — starting, stopping, listing, and resuming sessions. */ export const sessionWireVariants = [ z.object({ @@ -80,6 +89,23 @@ export const sessionWireVariants = [ replyTo: WireRequestIdSchema, record: SessionRecordSchema, }), + /** Fork a new session off `sourceSessionId`: the lineage through `throughTurnId` (that turn + * included, its suffix excluded) is copied onto a provider-native fork of its history, and the + * child starts live. The source is untouched. Idempotent by `operationId` like `turn.submit`; + * `expectedGraphRevision` guards the source graph the caller looked at. */ + z.object({ + kind: z.literal('session.fork'), + clientReqId: WireRequestIdSchema, + sourceSessionId: SessionIdSchema, + throughTurnId: TurnIdSchema, + operationId: OperationIdSchema, + expectedGraphRevision: z.number().int().nonnegative(), + }), + z.object({ + kind: z.literal('session.forked'), + replyTo: WireRequestIdSchema, + sessionId: SessionIdSchema, + }), /** Broadcast when the persisted list changes membership or identity, so a client holding a stale * snapshot knows to revalidate. Deliberately carries no record: `session.listed` stays the one * authority for the list's shape, and status rides `agent.event` for attached sessions only. */ diff --git a/packages/foundation/schema/tests/contract/wire/session.test.ts b/packages/foundation/schema/tests/contract/wire/session.test.ts index ed167b62c..25a474964 100644 --- a/packages/foundation/schema/tests/contract/wire/session.test.ts +++ b/packages/foundation/schema/tests/contract/wire/session.test.ts @@ -62,6 +62,26 @@ describe('session wire variants', () => { expect(parseWireMessage(sessionStart('high', { name: 'feature' })).ok).toBe(false); }); + it('parses session.fork only with its revision guard and replies with the forked id', () => { + const fork = { + kind: 'session.fork', + clientReqId: 'request-1', + sourceSessionId: 'session-source', + throughTurnId: 'turn-through', + operationId: 'op-fork', + expectedGraphRevision: 3, + }; + const envelope = (payload: unknown) => ({ v: WIRE_PROTOCOL_VERSION, id: 'm', ts: 0, payload }); + expect(parseWireMessage(envelope(fork)).ok).toBe(true); + const { expectedGraphRevision: _guard, ...unguarded } = fork; + expect(parseWireMessage(envelope(unguarded)).ok).toBe(false); + expect( + parseWireMessage( + envelope({ kind: 'session.forked', replyTo: 'request-1', sessionId: 'session-child' }), + ).ok, + ).toBe(true); + }); + it('accepts a legacy session.imported record whose runs predate runId', () => { // ≤v79 daemons emit runs without runId; required-ness waits for the floor bump. expect( From 01f54a4109b6900135c316f229e7d47c909fb849 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Tue, 8 Sep 2026 13:29:45 +0800 Subject: [PATCH 02/10] feat(engine,daemon): persist fork commits and hold provisional session records --- .../src/__tests__/conversation-store.test.ts | 159 ++++++++++++++++++ apps/daemon/src/conversation-store.ts | 100 ++++++++--- apps/daemon/src/session-store.ts | 45 ++--- .../src/__tests__/conversation-store.test.ts | 95 +++++++++++ .../__tests__/session-record-registry.test.ts | 67 ++++++++ .../src/conversation/conversation-store.ts | 51 ++++++ packages/host/engine/src/index.ts | 1 + .../src/session/session-record-registry.ts | 54 +++++- 8 files changed, 518 insertions(+), 54 deletions(-) diff --git a/apps/daemon/src/__tests__/conversation-store.test.ts b/apps/daemon/src/__tests__/conversation-store.test.ts index d60cd6104..e79932b2d 100644 --- a/apps/daemon/src/__tests__/conversation-store.test.ts +++ b/apps/daemon/src/__tests__/conversation-store.test.ts @@ -111,6 +111,16 @@ function openOperation(operationId: string, sessionId = 's-1'): ConversationOper }); } +function openFork(operationId: string) { + return { + operationId: OperationIdSchema.parse(operationId), + sessionId: SessionIdSchema.parse('s-1'), + kind: 'session.fork' as const, + state: 'open' as const, + createdAt: 4, + }; +} + async function seedIntent(store: ConversationStore): Promise { await store.persistTurnIntent({ turn: turn({ @@ -481,4 +491,153 @@ describe('SQLite conversation store', () => { { ...first, state: 'failed' }, ]); }); + + it('a turn-less operation respects the open-operation gate and a replayed id', async () => { + const { database } = await databaseWithSessions('s-1'); + const store = createConversationStore(database.client); + const fork = openFork('op-fork'); + await store.persistOperation(fork); + + expect(await store.listOpenOperations(SessionIdSchema.parse('s-1'))).toEqual([fork]); + await expect(async () => + store.persistTurnIntent({ turn: turn({ turnId: 't-1' }), operation: openOperation('op-1') }), + ).rejects.toBeInstanceOf(ConversationSessionBusyError); + await expect(async () => store.persistOperation(openFork('op-fork-2'))).rejects.toBeInstanceOf( + ConversationSessionBusyError, + ); + await store.resolveOperation({ + ...fork, + state: 'failed', + error: { code: 'unsupported', message: 'no checkpoint' }, + resolvedAt: 5, + }); + await expect(async () => store.persistOperation(fork)).rejects.toThrow('UNIQUE'); + }); + + it('commitFork writes the child session, its runs, and its turns with the operation atomically', async () => { + const { path, database } = await databaseWithSessions('s-1'); + const store = createConversationStore(database.client); + await seedIntent(store); + await store.resolveOperation({ + ...openOperation('op-1'), + state: 'succeeded', + turnId: TurnIdSchema.parse('t-prompted'), + resolvedAt: 5, + }); + const fork = openFork('op-fork'); + await store.persistOperation(fork); + const child = SessionRecordSchema.parse({ + sessionId: 's-child', + kind: 'claude-code', + cwd: '/repo', + origin: { type: 'created' }, + forkOrigin: { sourceSessionId: 's-1', sourceTurnId: 't-prompted', forkedAt: 6 }, + createdAt: 6, + updatedAt: 6, + runs: [ + { runId: 'run-child', baseTurnId: 't-copied-2', historyId: 'native-child', startedAt: 6 }, + ], + graphRevision: 0, + eventEpoch: 0, + }); + const copied = [ + turn({ + turnId: 't-copied-1', + sessionId: 's-child', + input: { type: 'prompt', promptId: PromptIdSchema.parse('p-1') }, + runId: 'run-child', + state: 'completed', + createdAt: 2, + }), + turn({ + turnId: 't-copied-2', + sessionId: 's-child', + parentTurnId: TurnIdSchema.parse('t-copied-1'), + runId: 'run-child', + state: 'completed', + createdAt: 3, + }), + ]; + const succeeded = { + ...fork, + state: 'succeeded' as const, + turnId: TurnIdSchema.parse('t-copied-2'), + resolvedAt: 7, + }; + + expect(await store.commitFork({ child, turns: copied, operation: succeeded })).toBe(true); + + closeDatabase(database); + const reopened = openDatabase(path); + const reopenedStore = createConversationStore(reopened.client); + expect(await createSessionStore(reopened.client).load()).toContainEqual(child); + expect(await reopenedStore.listTurns(child.sessionId)).toEqual(copied); + expect(await reopenedStore.getTurn(TurnIdSchema.parse('t-copied-2'))).toEqual(copied[1]); + expect(await reopenedStore.getOperation(fork.operationId)).toEqual(succeeded); + // The source's deletion keeps the prompt the child still references. + await reopenedStore.deleteSession(SessionIdSchema.parse('s-1')); + expect(await reopenedStore.getPrompt(PromptIdSchema.parse('p-1'))).toEqual(prompt('p-1')); + }); + + it('commitFork writes nothing when the operation already resolved or a row is refused', async () => { + const { database } = await databaseWithSessions('s-1'); + const store = createConversationStore(database.client); + const sessionStore = createSessionStore(database.client); + const child = SessionRecordSchema.parse({ + sessionId: 's-child', + kind: 'claude-code', + cwd: '/repo', + origin: { type: 'created' }, + createdAt: 6, + updatedAt: 6, + runs: [], + }); + const failed = openFork('op-fork'); + await store.persistOperation(failed); + await store.resolveOperation({ + ...failed, + state: 'failed', + error: { code: 'timeout', message: 'too slow' }, + resolvedAt: 5, + }); + expect( + await store.commitFork({ + child, + turns: [turn({ turnId: 't-late', sessionId: 's-child', state: 'completed' })], + operation: { + ...failed, + state: 'succeeded', + turnId: TurnIdSchema.parse('t-late'), + resolvedAt: 7, + }, + }), + ).toBe(false); + expect(await sessionStore.load()).toHaveLength(1); + + // A copied turn naming a prompt that no longer exists rolls the whole commit back. + const open = openFork('op-fork-2'); + await store.persistOperation(open); + await expect(async () => + store.commitFork({ + child, + turns: [ + turn({ + turnId: 't-orphan', + sessionId: 's-child', + input: { type: 'prompt', promptId: PromptIdSchema.parse('p-gone') }, + state: 'completed', + }), + ], + operation: { + ...open, + state: 'succeeded', + turnId: TurnIdSchema.parse('t-orphan'), + resolvedAt: 8, + }, + }), + ).rejects.toThrow('FOREIGN KEY'); + expect(await sessionStore.load()).toHaveLength(1); + expect(await store.getOperation(open.operationId)).toEqual(open); + expect(await store.getTurn(TurnIdSchema.parse('t-orphan'))).toBeUndefined(); + }); }); diff --git a/apps/daemon/src/conversation-store.ts b/apps/daemon/src/conversation-store.ts index e69a196e2..b330f16b9 100644 --- a/apps/daemon/src/conversation-store.ts +++ b/apps/daemon/src/conversation-store.ts @@ -1,4 +1,8 @@ -import type { ConversationStore, ConversationTurnIntent } from '@linkcode/engine'; +import type { + ConversationForkCommit, + ConversationStore, + ConversationTurnIntent, +} from '@linkcode/engine'; import { ConversationSessionBusyError } from '@linkcode/engine'; import type { ConversationOperation, @@ -24,8 +28,11 @@ import { promptAttachmentRefs, prompts, providerTurnBindings, + sessionRuns, + sessions, uploadLeases, } from './db/schema'; +import { toSessionRow, toSessionRunRows } from './session-store'; type TurnRow = typeof conversationTurns.$inferSelect; type PromptRow = typeof prompts.$inferSelect; @@ -47,6 +54,36 @@ export function createConversationStore(db: DaemonDatabaseClient): ConversationS .run(); } + function hasOpenOperation(tx: DbOrTx, sessionId: SessionId): boolean { + const open = tx + .select({ operationId: conversationOperations.operationId }) + .from(conversationOperations) + .where( + and( + eq(conversationOperations.sessionId, sessionId), + eq(conversationOperations.state, 'open'), + ), + ) + .get(); + return open !== undefined; + } + + /** The open→terminal transition every resolver races for; `changes === 0` means a first writer + * already stored a terminal result. */ + function transitionOperation(tx: DbOrTx, operation: ConversationOperation): boolean { + const result = tx + .update(conversationOperations) + .set(toOperationRow(operation)) + .where( + and( + eq(conversationOperations.operationId, operation.operationId), + eq(conversationOperations.state, 'open'), + ), + ) + .run(); + return result.changes > 0; + } + return { listTurns(sessionId: SessionId): Promise { const rows = db @@ -58,6 +95,15 @@ export function createConversationStore(db: DaemonDatabaseClient): ConversationS return Promise.resolve(rows.map(toTurn)); }, + getTurn(turnId: TurnId): Promise { + const row = db + .select() + .from(conversationTurns) + .where(eq(conversationTurns.turnId, turnId)) + .get(); + return Promise.resolve(row ? toTurn(row) : undefined); + }, + saveTurn(turn: ConversationTurn): Promise { upsertTurn(db, turn); return Promise.resolve(); @@ -113,17 +159,7 @@ export function createConversationStore(db: DaemonDatabaseClient): ConversationS persistTurnIntent(intent: ConversationTurnIntent): Promise { const { parentTurnId, sessionId } = intent.turn; const persisted = db.transaction((tx) => { - const open = tx - .select({ operationId: conversationOperations.operationId }) - .from(conversationOperations) - .where( - and( - eq(conversationOperations.sessionId, sessionId), - eq(conversationOperations.state, 'open'), - ), - ) - .get(); - if (open) throw new ConversationSessionBusyError(sessionId); + if (hasOpenOperation(tx, sessionId)) throw new ConversationSessionBusyError(sessionId); const siblings = tx .select({ value: count() }) .from(conversationTurns) @@ -167,26 +203,42 @@ export function createConversationStore(db: DaemonDatabaseClient): ConversationS return Promise.resolve(persisted); }, + persistOperation(operation: Extract): Promise { + db.transaction((tx) => { + if (hasOpenOperation(tx, operation.sessionId)) { + throw new ConversationSessionBusyError(operation.sessionId); + } + // Plain insert: a replayed operationId must conflict here, never re-open a terminal row. + tx.insert(conversationOperations).values(toOperationRow(operation)).run(); + }); + return Promise.resolve(); + }, + resolveOperation(operation: ConversationOperation, turn?: ConversationTurn): Promise { const transitioned = db.transaction((tx) => { - const result = tx - .update(conversationOperations) - .set(toOperationRow(operation)) - .where( - and( - eq(conversationOperations.operationId, operation.operationId), - eq(conversationOperations.state, 'open'), - ), - ) - .run(); - // A concurrent resolver already stored a terminal result; the first writer stands. - if (result.changes === 0) return false; + if (!transitionOperation(tx, operation)) return false; if (turn) upsertTurn(tx, turn); return true; }); return Promise.resolve(transitioned); }, + commitFork(commit: ConversationForkCommit): Promise { + const transitioned = db.transaction((tx) => { + if (!transitionOperation(tx, commit.operation)) return false; + // Plain inserts throughout: the child id is fresh, and a parent row must precede its child + // (the caller hands the copied lineage root-first) for the self-referencing foreign key. + tx.insert(sessions).values(toSessionRow(commit.child)).run(); + const runs = toSessionRunRows(commit.child); + if (runs.length > 0) tx.insert(sessionRuns).values(runs).run(); + for (let i = 0, len = commit.turns.length; i < len; i++) { + tx.insert(conversationTurns).values(toTurnRow(commit.turns[i])).run(); + } + return true; + }); + return Promise.resolve(transitioned); + }, + deleteSession(sessionId: SessionId): Promise { db.transaction((tx) => { const rows = tx diff --git a/apps/daemon/src/session-store.ts b/apps/daemon/src/session-store.ts index 986ae07ad..ae7e1379b 100644 --- a/apps/daemon/src/session-store.ts +++ b/apps/daemon/src/session-store.ts @@ -43,27 +43,8 @@ export function createSessionStore(db: DaemonDatabaseClient): SessionStore { .run(); // Runs are few per session; rewriting them keeps save() a whole-record upsert. tx.delete(sessionRuns).where(eq(sessionRuns.sessionId, record.sessionId)).run(); - if (record.runs.length > 0) { - tx.insert(sessionRuns) - .values( - record.runs.map((run, seq) => ({ - sessionId: record.sessionId, - seq, - // runId is optional at the wire parse boundary only; every writer mints it, so a - // runId-less run here is a bug — minting one would drift the durable id per save. - runId: nullthrow(run.runId, `Session run without runId: ${record.sessionId}`), - baseTurnId: run.baseTurnId ?? null, - historyId: run.historyId ?? null, - accountId: run.accountId ?? null, - model: run.model ?? null, - effort: run.effort ?? null, - approvalPolicyId: run.approvalPolicyId ?? null, - startedAt: run.startedAt, - endedAt: run.endedAt ?? null, - })), - ) - .run(); - } + const runs = toSessionRunRows(record); + if (runs.length > 0) tx.insert(sessionRuns).values(runs).run(); }); return Promise.resolve(); }, @@ -76,7 +57,9 @@ export function createSessionStore(db: DaemonDatabaseClient): SessionStore { }; } -function toSessionRow(record: SessionRecord): typeof sessions.$inferInsert { +/** Also used by the conversation store, whose fork commit inserts the child session row in the + * same transaction as the turns that reference it. */ +export function toSessionRow(record: SessionRecord): typeof sessions.$inferInsert { return { sessionId: record.sessionId, kind: record.kind, @@ -99,6 +82,24 @@ function toSessionRow(record: SessionRecord): typeof sessions.$inferInsert { }; } +export function toSessionRunRows(record: SessionRecord): Array { + return record.runs.map((run, seq) => ({ + sessionId: record.sessionId, + seq, + // runId is optional at the wire parse boundary only; every writer mints it, so a runId-less + // run here is a bug — minting one would drift the durable id per save. + runId: nullthrow(run.runId, `Session run without runId: ${record.sessionId}`), + baseTurnId: run.baseTurnId ?? null, + historyId: run.historyId ?? null, + accountId: run.accountId ?? null, + model: run.model ?? null, + effort: run.effort ?? null, + approvalPolicyId: run.approvalPolicyId ?? null, + startedAt: run.startedAt, + endedAt: run.endedAt ?? null, + })); +} + function toRecord(row: SessionRow, runRows: RunRow[]): SessionRecord { return SessionRecordSchema.parse({ sessionId: row.sessionId, diff --git a/packages/host/engine/src/__tests__/conversation-store.test.ts b/packages/host/engine/src/__tests__/conversation-store.test.ts index 7198cdae6..a5394ca0d 100644 --- a/packages/host/engine/src/__tests__/conversation-store.test.ts +++ b/packages/host/engine/src/__tests__/conversation-store.test.ts @@ -55,6 +55,16 @@ function openOperation(operationId: string, sessionId: string): ConversationOper }); } +function openFork(operationId: string, sessionId: string) { + return { + operationId: OperationIdSchema.parse(operationId), + sessionId: SessionIdSchema.parse(sessionId), + kind: 'session.fork' as const, + state: 'open' as const, + createdAt: 1, + }; +} + describe('InMemoryConversationStore', () => { it('persists a turn intent as one unit and resolves its operation', async () => { const store = new InMemoryConversationStore(); @@ -275,4 +285,89 @@ describe('InMemoryConversationStore', () => { { ...persisted, state: 'failed' }, ]); }); + + it('a turn-less operation respects the open-operation gate and a replayed id', async () => { + const store = new InMemoryConversationStore(); + const fork = openFork('op-fork', 's-1'); + await store.persistOperation(fork); + + expect(await store.listOpenOperations(SessionIdSchema.parse('s-1'))).toEqual([fork]); + await expect( + store.persistTurnIntent({ + turn: turn({ turnId: 't-1', sessionId: 's-1' }), + operation: openOperation('op-1', 's-1'), + }), + ).rejects.toBeInstanceOf(ConversationSessionBusyError); + await expect(store.persistOperation(openFork('op-fork-2', 's-1'))).rejects.toBeInstanceOf( + ConversationSessionBusyError, + ); + await store.resolveOperation({ + ...fork, + state: 'failed', + error: { code: 'unsupported', message: 'no checkpoint' }, + resolvedAt: 2, + }); + await expect(store.persistOperation(fork)).rejects.toThrow('already persisted'); + }); + + it('commitFork writes the child turns with the operation exactly once', async () => { + const store = new InMemoryConversationStore(); + const shared = prompt('p-shared'); + await store.persistTurnIntent({ + turn: turn({ turnId: 't-source', sessionId: 's-source', promptId: 'p-shared' }), + prompt: shared, + operation: openOperation('op-1', 's-source'), + }); + await store.resolveOperation({ + ...openOperation('op-1', 's-source'), + state: 'succeeded', + turnId: TurnIdSchema.parse('t-source'), + resolvedAt: 2, + }); + const fork = openFork('op-fork', 's-source'); + await store.persistOperation(fork); + const copied = turn({ + turnId: 't-copied', + sessionId: 's-child', + promptId: 'p-shared', + state: 'completed', + }); + const child = { + sessionId: SessionIdSchema.parse('s-child'), + kind: 'claude-code' as const, + cwd: '/repo', + origin: { type: 'created' as const }, + createdAt: 3, + updatedAt: 3, + runs: [], + graphRevision: 0, + eventEpoch: 0, + }; + const succeeded = { + ...fork, + state: 'succeeded' as const, + turnId: copied.turnId, + resolvedAt: 4, + }; + + expect(await store.commitFork({ child, turns: [copied], operation: succeeded })).toBe(true); + expect(await store.getTurn(copied.turnId)).toEqual(copied); + expect(await store.listTurns(child.sessionId)).toEqual([copied]); + expect(await store.getOperation(fork.operationId)).toEqual(succeeded); + + // The operation already resolved: a second commit must not resurrect the fork's rows. + const late = turn({ turnId: 't-late', sessionId: 's-child-2', promptId: 'p-shared' }); + expect( + await store.commitFork({ + child: { ...child, sessionId: SessionIdSchema.parse('s-child-2') }, + turns: [late], + operation: { ...succeeded, turnId: late.turnId }, + }), + ).toBe(false); + expect(await store.getTurn(late.turnId)).toBeUndefined(); + + // The source's deletion leaves the child's copied prompt in place. + await store.deleteSession(SessionIdSchema.parse('s-source')); + expect(await store.getPrompt(shared.promptId)).toEqual(shared); + }); }); diff --git a/packages/host/engine/src/__tests__/session-record-registry.test.ts b/packages/host/engine/src/__tests__/session-record-registry.test.ts index a26a65d8e..27f818bb7 100644 --- a/packages/host/engine/src/__tests__/session-record-registry.test.ts +++ b/packages/host/engine/src/__tests__/session-record-registry.test.ts @@ -95,6 +95,73 @@ describe('session record registry run addressing', () => { }); }); +describe('session record registry provisional records', () => { + const childId = 'sess-child' as SessionId; + + async function registryWithChild() { + const store = new InMemorySessionStore(); + const changes: Array<[SessionId, string]> = []; + const registry = new SessionRecordRegistry(store, (id, reason) => { + changes.push([id, reason]); + }); + await Effect.runPromise( + registry.start((effect) => { + void Effect.runPromise(effect); + }), + ); + const runId = 'run-child' as RunId; + registry.registerProvisional({ + ...makeRecord(), + sessionId: childId, + runs: [{ runId, startedAt: 1 }], + }); + return { store, changes, registry, runId }; + } + + it('binds live events to a provisional record without listing, persisting, or announcing it', async () => { + const { store, changes, registry, runId } = await registryWithChild(); + + registry.bindHistoryId(childId, runId, asHistoryId('native-child')); + await wait(0); + + expect(registry.get(childId)?.runs[0]?.historyId).toBe('native-child'); + expect(registry.isCurrentRun(childId, runId)).toBe(true); + expect(registry.list(() => 'stopped')).toEqual([]); + expect(await store.load()).toEqual([]); + expect(changes).toEqual([]); + }); + + it('commit announces the record and resumes persisting it', async () => { + const { store, changes, registry, runId } = await registryWithChild(); + registry.bindHistoryId(childId, runId, asHistoryId('native-child')); + + registry.commitProvisional(childId); + await wait(0); + + expect(changes).toEqual([[childId, 'created']]); + expect(registry.list(() => 'stopped').map((session) => session.sessionId)).toEqual([childId]); + expect((await store.load())[0]?.runs[0]?.historyId).toBe('native-child'); + // A second commit is a no-op: nothing announces twice. + registry.commitProvisional(childId); + expect(changes).toHaveLength(1); + }); + + it('discard forgets a provisional record silently and leaves committed ones alone', async () => { + const { store, changes, registry } = await registryWithChild(); + + registry.discardProvisional(childId); + await wait(0); + + expect(registry.get(childId)).toBeUndefined(); + expect(changes).toEqual([]); + expect(await store.load()).toEqual([]); + + registry.register(makeRecord()); + registry.discardProvisional(sessionId); + expect(registry.get(sessionId)).toBeDefined(); + }); +}); + describe('session record registry event epoch', () => { it('bumps the epoch on every run launch', async () => { const registry = await startedRegistry(); diff --git a/packages/host/engine/src/conversation/conversation-store.ts b/packages/host/engine/src/conversation/conversation-store.ts index 054310808..af07d5601 100644 --- a/packages/host/engine/src/conversation/conversation-store.ts +++ b/packages/host/engine/src/conversation/conversation-store.ts @@ -7,6 +7,7 @@ import type { PromptRecord, ProviderTurnBinding, SessionId, + SessionRecord, TurnId, } from '@linkcode/schema'; @@ -19,6 +20,17 @@ export interface ConversationTurnIntent { readonly operation: ConversationOperation; } +/** The durable commit point of a session fork: the child's copied turn rows (the source lineage + * under the child's root run, prompts shared by reference) and the source operation's terminal + * result persist together or not at all. A store that owns session rows inserts `child` in the + * same transaction — turn rows reference it — while one that does not leaves the record to the + * session store. */ +export interface ConversationForkCommit { + readonly child: SessionRecord; + readonly turns: ConversationTurn[]; + readonly operation: Extract; +} + /** Rejection from {@link ConversationStore.persistTurnIntent} when the session already has an * open operation — the durable backstop behind the engine's admit gate. */ export class ConversationSessionBusyError extends Error { @@ -36,6 +48,7 @@ export class ConversationSessionBusyError extends Error { */ export interface ConversationStore { listTurns(sessionId: SessionId): Promise; + getTurn(turnId: TurnId): Promise; /** Upsert by `turnId` — state flips rewrite the row. */ saveTurn(turn: ConversationTurn): Promise; getPrompt(promptId: PromptId): Promise; @@ -51,10 +64,17 @@ export interface ConversationStore { * {@link ConversationSessionBusyError} while the session has an open operation; rows are * plain-inserted, so a replayed operationId conflicts instead of re-opening a terminal row. */ persistTurnIntent(intent: ConversationTurnIntent): Promise; + /** Plain-insert an open operation that carries no turn of its own (a session fork). Rejects + * with {@link ConversationSessionBusyError} while the session has an open operation. */ + persistOperation(operation: Extract): Promise; /** Atomic: store the operation's terminal result and, when given, the turn's new state — but * only while the operation row is still `open`. Returns whether THIS call performed the * transition; the first terminal writer stands and losers must run no side effects. */ resolveOperation(operation: ConversationOperation, turn?: ConversationTurn): Promise; + /** Atomic: the fork's turn rows and the source operation's success, only while that operation + * is still `open`. Returns whether THIS call performed the transition — a loser (the operation + * already failed) writes nothing and must tear the child down. */ + commitFork(commit: ConversationForkCommit): Promise; /** Purge the session's turns, bindings, and operations. Prompts are shared by reference across * forks: one is deleted only when no turn in ANY session still references it. */ deleteSession(sessionId: SessionId): Promise; @@ -100,6 +120,11 @@ export class InMemoryConversationStore implements ConversationStore { return Promise.resolve(turns); } + getTurn(turnId: TurnId): Promise { + const turn = this.turns.get(turnId); + return Promise.resolve(turn && structuredClone(turn)); + } + saveTurn(turn: ConversationTurn): Promise { this.turns.set(turn.turnId, structuredClone(turn)); return Promise.resolve(); @@ -174,6 +199,19 @@ export class InMemoryConversationStore implements ConversationStore { return Promise.resolve(turn); } + persistOperation(operation: Extract): Promise { + for (const existing of this.operations.values()) { + if (existing.sessionId === operation.sessionId && existing.state === 'open') { + return Promise.reject(new ConversationSessionBusyError(operation.sessionId)); + } + } + if (this.operations.has(operation.operationId)) { + return Promise.reject(new Error(`Operation already persisted: ${operation.operationId}`)); + } + this.operations.set(operation.operationId, structuredClone(operation)); + return Promise.resolve(); + } + resolveOperation(operation: ConversationOperation, turn?: ConversationTurn): Promise { if (this.operations.get(operation.operationId)?.state !== 'open') { return Promise.resolve(false); @@ -183,6 +221,19 @@ export class InMemoryConversationStore implements ConversationStore { return Promise.resolve(true); } + commitFork(commit: ConversationForkCommit): Promise { + const { operation } = commit; + if (this.operations.get(operation.operationId)?.state !== 'open') { + return Promise.resolve(false); + } + this.operations.set(operation.operationId, structuredClone(operation)); + for (let i = 0, len = commit.turns.length; i < len; i++) { + const turn = commit.turns[i]; + this.turns.set(turn.turnId, structuredClone(turn)); + } + return Promise.resolve(true); + } + deleteSession(sessionId: SessionId): Promise { for (const [turnId, turn] of this.turns) { if (turn.sessionId !== sessionId) continue; diff --git a/packages/host/engine/src/index.ts b/packages/host/engine/src/index.ts index 16b704ab6..863cf0423 100644 --- a/packages/host/engine/src/index.ts +++ b/packages/host/engine/src/index.ts @@ -18,6 +18,7 @@ export { export { type BlobStage, type BlobStore, FsBlobStore } from './attachment/blob-store'; export type { LoopStore, ScheduleStore } from './automation'; export { + type ConversationForkCommit, ConversationSessionBusyError, type ConversationStore, type ConversationTurnIntent, diff --git a/packages/host/engine/src/session/session-record-registry.ts b/packages/host/engine/src/session/session-record-registry.ts index 883c0459e..204a7fbf4 100644 --- a/packages/host/engine/src/session/session-record-registry.ts +++ b/packages/host/engine/src/session/session-record-registry.ts @@ -38,6 +38,9 @@ export type SessionRunIntent = Omit; export class SessionRecordRegistry { private readonly records = new Map(); + /** Records held in memory ahead of their durable creation (a fork child mid-saga): events bind + * to them, but nothing persists or announces them until the creating transaction commits. */ + private readonly provisional = new Set(); private runTask: RunTask | undefined; /** `onChanged` fires for membership and identity only — never for recency, which would turn a @@ -97,7 +100,11 @@ export class SessionRecordRegistry { } list(statusOf: (sessionId: SessionId) => SessionInfo['status'] | undefined): SessionInfo[] { - return Array.from(this.records.values(), (record) => ({ + const listed: SessionRecord[] = []; + for (const record of this.records.values()) { + if (!this.provisional.has(record.sessionId)) listed.push(record); + } + return listed.map((record) => ({ sessionId: record.sessionId, kind: record.kind, cwd: record.cwd, @@ -118,7 +125,30 @@ export class SessionRecordRegistry { register(record: SessionRecord): void { this.records.set(record.sessionId, record); this.persist(record); - this.onChanged(record.sessionId, 'created'); + this.announce(record.sessionId, 'created'); + } + + /** Hold a record that a transaction elsewhere will create: live events bind to it (history id, + * run identity), `list()` hides it, and {@link persist} skips it until {@link commitProvisional}. */ + registerProvisional(record: SessionRecord): void { + this.provisional.add(record.sessionId); + this.records.set(record.sessionId, record); + } + + /** The creating transaction committed: announce the record and resume persisting it (the + * upsert also carries anything that bound to it since the transaction's snapshot). */ + commitProvisional(sessionId: SessionId): void { + const record = this.records.get(sessionId); + if (!record || !this.provisional.delete(sessionId)) return; + this.persist(record); + this.announce(sessionId, 'created'); + } + + /** The creating transaction never happened: the record was never durable, so nothing announces + * its removal. */ + discardProvisional(sessionId: SessionId): void { + if (!this.provisional.delete(sessionId)) return; + this.records.delete(sessionId); } /** Imported records have no live adapter, so a store failure remains request-fatal. */ @@ -129,7 +159,7 @@ export class SessionRecordRegistry { Effect.tap(() => Effect.sync(() => { this.records.set(record.sessionId, record); - this.onChanged(record.sessionId, 'created'); + this.announce(record.sessionId, 'created'); }), ), ); @@ -143,7 +173,7 @@ export class SessionRecordRegistry { Effect.tap(() => Effect.sync(() => { this.records.delete(sessionId); - this.onChanged(sessionId, 'removed'); + this.announce(sessionId, 'removed'); }), ), ); @@ -157,7 +187,7 @@ export class SessionRecordRegistry { if (!record || !run || run.historyId === historyId) return; run.historyId = historyId; this.persist(record); - this.onChanged(sessionId, 'updated'); + this.announce(sessionId, 'updated'); } /** @@ -248,7 +278,7 @@ export class SessionRecordRegistry { // A new run re-points the identity `list()` projects — `accountId`, `historyId` — so clients // must revalidate. Nothing else announces a relaunch: it sends no `session.started`, and a // resumed run already carries the historyId that would otherwise notify via `bindHistoryId`. - this.onChanged(sessionId, 'updated'); + this.announce(sessionId, 'updated'); return runId; } @@ -270,7 +300,7 @@ export class SessionRecordRegistry { if (title === undefined) return; record.title = title; this.persist(record); - this.onChanged(sessionId, 'updated'); + this.announce(sessionId, 'updated'); } setProviderTitle(sessionId: SessionId, title: string): void { @@ -282,7 +312,7 @@ export class SessionRecordRegistry { } record.title = normalized; this.persist(record); - this.onChanged(sessionId, 'updated'); + this.announce(sessionId, 'updated'); } historyId(sessionId: SessionId): AgentHistoryId | undefined { @@ -313,8 +343,16 @@ export class SessionRecordRegistry { return isObjectEmpty(pin) ? undefined : pin; } + /** A provisional record has no listing to invalidate: nothing about it reaches clients until it + * commits. */ + private announce(sessionId: SessionId, reason: SessionChangeReason): void { + if (this.provisional.has(sessionId)) return; + this.onChanged(sessionId, reason); + } + /** The in-memory record is authoritative while running; persistence is best-effort. */ private persist(record: SessionRecord): void { + if (this.provisional.has(record.sessionId)) return; record.updatedAt = Date.now(); const runTask = nullthrow(this.runTask, 'Session record registry is not started'); runTask( From ff6d2dcd6c57f71f7238fb137afd634361284b84 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Tue, 8 Sep 2026 13:43:03 +0800 Subject: [PATCH 03/10] feat(engine): fork a session through a completed turn onto a provider-native copy --- .../src/__tests__/engine-session-fork.test.ts | 543 ++++++++++++++++++ .../src/conversation/checkpoint-service.ts | 9 +- .../engine/src/conversation/turn-service.ts | 77 ++- packages/host/engine/src/engine.ts | 11 + .../host/engine/src/session/fork-service.ts | 392 +++++++++++++ .../engine/src/session/lifecycle-service.ts | 13 +- .../engine/src/session/request-handler.ts | 37 ++ .../src/session/session-record-registry.ts | 4 + .../host/engine/src/wire/request-router.ts | 1 + 9 files changed, 1078 insertions(+), 9 deletions(-) create mode 100644 packages/host/engine/src/__tests__/engine-session-fork.test.ts create mode 100644 packages/host/engine/src/session/fork-service.ts diff --git a/packages/host/engine/src/__tests__/engine-session-fork.test.ts b/packages/host/engine/src/__tests__/engine-session-fork.test.ts new file mode 100644 index 000000000..159550835 --- /dev/null +++ b/packages/host/engine/src/__tests__/engine-session-fork.test.ts @@ -0,0 +1,543 @@ +import { asHistoryId, HistoryCheckpointInvalidError } from '@linkcode/agent-adapter'; +import type { + AgentHistoryBranchOptions, + AgentHistoryCapabilities, + AgentHistoryEvent, + AgentHistoryReadOptions, + AgentHistoryReadResult, + ConversationReadItem, + MessageId, + SessionId, + StartOptions, + TurnId, + WirePayload, +} from '@linkcode/schema'; +import { OperationIdSchema, SessionIdSchema, TurnIdSchema } from '@linkcode/schema'; +import { nullthrow } from 'foxts/guard'; +import { noop } from 'foxts/noop'; +import { describe, expect, it, vi } from 'vitest'; +import { InMemoryConversationStore } from '../conversation/conversation-store'; +import { InMemorySessionStore } from '../session/session-store'; +import { + FakeAdapter, + createSessionHarness as harness, + settleEngineTasks, + startedSessionId as startedId, +} from './fixtures/session-harness'; + +const SOURCE_HISTORY = asHistoryId('native-1'); +const CHILD_HISTORY = asHistoryId('native-child'); + +type Corpora = Record; + +/** Forks announce the child history at branch time, as the real adapters do; cold reads serve a + * per-history corpus so the child's copied prefix can be attributed against its own history. */ +class ForkingAdapter extends FakeAdapter { + override readonly historyCapabilities: AgentHistoryCapabilities = { + list: false, + read: true, + resume: true, + forkAfterTurn: true, + branch: true, + }; + branchedFrom: AgentHistoryBranchOptions | null = null; + failFork: Error | undefined; + + constructor(private readonly corpora: Corpora = {}) { + super(); + } + + branchHistory(opts: AgentHistoryBranchOptions, startOpts: StartOptions): Promise { + if (this.failFork) return Promise.reject(this.failFork); + this.branchedFrom = opts; + this.startedWith = startOpts; + this.emit({ type: 'session-ref', historyId: CHILD_HISTORY }); + return Promise.resolve(); + } + + override readHistory(opts: AgentHistoryReadOptions): Promise { + return Promise.resolve({ + session: { historyId: opts.historyId, kind: this.kind, cwd: '/repo' }, + events: this.corpora[opts.historyId] ?? [], + }); + } +} + +/** The provider fork never settles until released. */ +class GatedForkAdapter extends ForkingAdapter { + release: () => void = noop; + + override branchHistory(opts: AgentHistoryBranchOptions): Promise { + this.branchedFrom = opts; + return new Promise((resolve) => { + this.release = resolve; + }); + } +} + +class LegacyBranchOnlyAdapter extends ForkingAdapter { + override readonly historyCapabilities: AgentHistoryCapabilities = { + list: false, + read: true, + resume: true, + forkAfterTurn: false, + branch: true, + }; +} + +function cursorRow(itemId: string, text: string, branchCursor: string): AgentHistoryEvent { + return { + historyId: CHILD_HISTORY, + itemId, + event: { + type: 'user-message', + messageId: itemId as MessageId, + content: [{ type: 'text', text }], + branchCursor, + }, + }; +} + +async function startedHarness(makeAdapter: () => FakeAdapter = () => new ForkingAdapter()) { + const store = new InMemorySessionStore(); + const conversationStore = new InMemoryConversationStore(); + const h = harness(store, makeAdapter, undefined, undefined, undefined, undefined, { + conversationStore, + }); + await h.engine.start(); + await h.inject({ + kind: 'session.start', + clientReqId: 'r1', + opts: { kind: 'claude-code', cwd: '/repo' }, + }); + const sessionId = startedId(h.sent, 'r1'); + return { ...h, conversationStore, sessionId, adapter: nullthrow(h.adapters[0]) }; +} + +type Harness = Awaited>; + +function submitPrompt( + h: Harness, + clientReqId: string, + text: string, + sessionId: SessionId = h.sessionId, + extra: Partial<{ parentTurnId: TurnId | null; expectedGraphRevision: number }> = {}, +) { + return h.inject({ + kind: 'turn.submit', + clientReqId, + sessionId, + operationId: OperationIdSchema.parse(`op-${clientReqId}`), + input: { type: 'prompt', blocks: [{ type: 'text', text }] }, + ...extra, + }); +} + +function submittedTurnId(sent: WirePayload[], replyTo: string): TurnId { + const reply = sent.find( + (payload) => payload.kind === 'turn.submitted' && payload.replyTo === replyTo, + ); + if (reply?.kind !== 'turn.submitted') throw new Error(`no turn.submitted for ${replyTo}`); + return reply.turnId; +} + +/** Two settled turns on the source history, each with a live `ending` checkpoint. */ +async function twoCheckpointedTurns(h: Harness): Promise<[TurnId, TurnId]> { + await submitPrompt(h, 's1', 'first'); + const firstTurnId = submittedTurnId(h.sent, 's1'); + h.adapter.emit({ type: 'session-ref', historyId: SOURCE_HISTORY }); + h.adapter.emitCheckpoint({ historyId: SOURCE_HISTORY, cursor: 'cp-1', turn: 'ending' }); + h.adapter.emit({ type: 'status', status: 'idle' }); + await settleEngineTasks(); + await submitPrompt(h, 's2', 'second'); + const secondTurnId = submittedTurnId(h.sent, 's2'); + h.adapter.emitCheckpoint({ historyId: SOURCE_HISTORY, cursor: 'cp-2', turn: 'ending' }); + h.adapter.emit({ type: 'status', status: 'idle' }); + await settleEngineTasks(); + return [firstTurnId, secondTurnId]; +} + +function fork( + h: Harness, + clientReqId: string, + throughTurnId: TurnId, + expectedGraphRevision: number, + extra: Partial<{ sourceSessionId: SessionId; operationId: string }> = {}, +) { + return h.inject({ + kind: 'session.fork', + clientReqId, + sourceSessionId: extra.sourceSessionId ?? h.sessionId, + throughTurnId, + operationId: OperationIdSchema.parse(extra.operationId ?? `op-${clientReqId}`), + expectedGraphRevision, + }); +} + +function forkedSessionId(sent: WirePayload[], replyTo: string): SessionId { + const reply = sent.find( + (payload) => payload.kind === 'session.forked' && payload.replyTo === replyTo, + ); + if (reply?.kind !== 'session.forked') throw new Error(`no session.forked for ${replyTo}`); + return reply.sessionId; +} + +function failure(sent: WirePayload[], replyTo: string) { + const reply = sent.find( + (payload) => payload.kind === 'request.failed' && payload.replyTo === replyTo, + ); + if (reply?.kind !== 'request.failed') throw new Error(`no request.failed for ${replyTo}`); + return reply; +} + +async function listed(h: Harness) { + const clientReqId = `ls-${h.sent.length}`; + await h.inject({ kind: 'session.list', clientReqId }); + const reply = h.sent.find( + (payload) => payload.kind === 'session.listed' && payload.replyTo === clientReqId, + ); + if (reply?.kind !== 'session.listed') throw new Error('no session.listed reply'); + return reply.sessions; +} + +async function readUserTexts(h: Harness, sessionId: SessionId): Promise { + const clientReqId = `read-${h.sent.length}`; + await h.inject({ kind: 'conversation.read', clientReqId, sessionId }); + await settleEngineTasks(); + const reply = h.sent.find( + (payload) => payload.kind === 'conversation.read.result' && payload.replyTo === clientReqId, + ); + if (reply?.kind !== 'conversation.read.result') throw new Error('no conversation.read.result'); + return reply.events.flatMap((item: ConversationReadItem) => + 'event' in item && item.event.type === 'user-message' + ? item.event.content.flatMap((block) => (block.type === 'text' ? [block.text] : [])) + : [], + ); +} + +/** Every adapter the harness forked with, in order. Counting `adapters` itself is meaningless: cold + * reads and capability lookups mint throwaway instances. */ +function forks(adapters: FakeAdapter[]): ForkingAdapter[] { + return adapters.filter( + (adapter): adapter is ForkingAdapter => + adapter instanceof ForkingAdapter && adapter.branchedFrom !== null, + ); +} + +function forkedAdapter(adapters: FakeAdapter[]): ForkingAdapter { + return nullthrow(forks(adapters)[0]); +} + +describe('session.fork saga', () => { + it('forks through a completed turn into a live child that copies the lineage and shares its prompts', async () => { + const h = await startedHarness(); + const [firstTurnId, secondTurnId] = await twoCheckpointedTurns(h); + + await fork(h, 'f1', firstTurnId, 2); + await vi.waitFor(() => forkedSessionId(h.sent, 'f1')); + const childId = forkedSessionId(h.sent, 'f1'); + + expect(childId).not.toBe(h.sessionId); + const child = forkedAdapter(h.adapters); + expect(child.branchedFrom).toEqual({ historyId: SOURCE_HISTORY, cursor: 'cp-1' }); + expect(child.startedWith).toMatchObject({ kind: 'claude-code', cwd: '/repo' }); + // The source is untouched: still live, still on its own leaf and revision. + expect(h.adapter.stopped).toBe(false); + expect(h.sent).toContainEqual({ + kind: 'session.changed', + sessionId: childId, + reason: 'created', + }); + + const sessions = await listed(h); + const listedChild = nullthrow(sessions.find((session) => session.sessionId === childId)); + expect(listedChild).toMatchObject({ + kind: 'claude-code', + cwd: '/repo', + forkOrigin: { sourceSessionId: h.sessionId, sourceTurnId: firstTurnId }, + historyId: CHILD_HISTORY, + }); + expect(listedChild.status).not.toBe('stopped'); + + const [sourceTurn] = await h.conversationStore.listTurns(h.sessionId); + const copies = await h.conversationStore.listTurns(childId); + expect(copies).toHaveLength(1); + const [copy] = copies; + expect(copy.turnId).not.toBe(firstTurnId); + expect(copy).toMatchObject({ + sessionId: childId, + parentTurnId: null, + siblingOrdinal: 1, + input: sourceTurn.input, + state: 'completed', + createdAt: sourceTurn.createdAt, + }); + expect(copy.runId).not.toBe(sourceTurn.runId); + const records = await h.store.load(); + const childRecord = nullthrow(records.find((record) => record.sessionId === childId)); + expect(childRecord.activeLeafTurnId).toBe(copy.turnId); + expect(childRecord.graphRevision).toBe(0); + expect(childRecord.runs).toEqual([ + expect.objectContaining({ + runId: copy.runId, + baseTurnId: copy.turnId, + historyId: CHILD_HISTORY, + }), + ]); + const sourceRecord = nullthrow(records.find((record) => record.sessionId === h.sessionId)); + expect(sourceRecord.activeLeafTurnId).toBe(secondTurnId); + expect(sourceRecord.graphRevision).toBe(2); + expect(await h.conversationStore.listTurns(h.sessionId)).toHaveLength(2); + // The shared prompt renders in the child as its own user row. + expect(await readUserTexts(h, childId)).toEqual(['first']); + expect(await h.conversationStore.getOperation(OperationIdSchema.parse('op-f1'))).toMatchObject({ + kind: 'session.fork', + state: 'succeeded', + turnId: copy.turnId, + }); + }); + + it('replays a lost reply with the same forked session instead of forking twice', async () => { + const h = await startedHarness(); + const [firstTurnId] = await twoCheckpointedTurns(h); + await fork(h, 'f1', firstTurnId, 2); + await vi.waitFor(() => forkedSessionId(h.sent, 'f1')); + + await fork(h, 'f1-again', firstTurnId, 2, { operationId: 'op-f1' }); + await vi.waitFor(() => forkedSessionId(h.sent, 'f1-again')); + + expect(forkedSessionId(h.sent, 'f1-again')).toBe(forkedSessionId(h.sent, 'f1')); + expect(await h.store.load()).toHaveLength(2); + expect(forks(h.adapters)).toHaveLength(1); + }); + + it('refuses typed before any provider work: unknown ids, an unfinished turn, a stale revision, a busy source', async () => { + const h = await startedHarness(); + const [firstTurnId] = await twoCheckpointedTurns(h); + + await fork(h, 'unknown-session', firstTurnId, 2, { + sourceSessionId: SessionIdSchema.parse('sess-nope'), + }); + expect(failure(h.sent, 'unknown-session').code).toBe('not_found'); + await fork(h, 'unknown-turn', TurnIdSchema.parse('turn-nope'), 2); + expect(failure(h.sent, 'unknown-turn').code).toBe('not_found'); + await fork(h, 'stale', firstTurnId, 1); + expect(failure(h.sent, 'stale')).toMatchObject({ + code: 'conflict', + message: 'The conversation graph has moved', + }); + + // A running third turn: the source is busy, and the turn itself has not completed. + await submitPrompt(h, 's3', 'third'); + const thirdTurnId = submittedTurnId(h.sent, 's3'); + h.adapter.emit({ type: 'status', status: 'running' }); + await fork(h, 'busy', firstTurnId, 3); + expect(failure(h.sent, 'busy').code).toBe('busy'); + h.adapter.emit({ type: 'status', status: 'idle' }); + await settleEngineTasks(); + // The third turn settled without a checkpoint: completed, but unforkable at its own cut. + await fork(h, 'no-checkpoint', thirdTurnId, 3); + expect(failure(h.sent, 'no-checkpoint')).toMatchObject({ + code: 'unsupported', + message: 'This turn has no provider checkpoint to fork from', + }); + + // The operation id of another session's fork is a client defect, never a replay. + await fork(h, 'f-ok', firstTurnId, 3); + await vi.waitFor(() => forkedSessionId(h.sent, 'f-ok')); + const childId = forkedSessionId(h.sent, 'f-ok'); + const [copy] = await h.conversationStore.listTurns(childId); + await fork(h, 'foreign', copy.turnId, 0, { sourceSessionId: childId, operationId: 'op-f-ok' }); + expect(failure(h.sent, 'foreign').code).toBe('invalid_request'); + + expect(await h.store.load()).toHaveLength(2); + expect(await h.conversationStore.listOpenOperations()).toEqual([]); + }); + + it('refuses a harness that cannot fork after a turn and one whose turn was never completed', async () => { + const h = await startedHarness(() => new LegacyBranchOnlyAdapter()); + const [firstTurnId] = await twoCheckpointedTurns(h); + + await fork(h, 'legacy', firstTurnId, 2); + + expect(failure(h.sent, 'legacy')).toMatchObject({ + code: 'unsupported', + message: 'claude-code: forking a session is not supported', + }); + expect(forks(h.adapters)).toHaveLength(0); + expect(await h.store.load()).toHaveLength(1); + }); + + it('a provider refusal leaves no child behind, replays the failure, and frees the source', async () => { + const h = await startedHarness(() => { + const adapter = new ForkingAdapter(); + adapter.failFork = new HistoryCheckpointInvalidError( + 'claude-code: checkpoint cp-1 is no longer in transcript native-1', + ); + return adapter; + }); + const [firstTurnId] = await twoCheckpointedTurns(h); + + await fork(h, 'f1', firstTurnId, 2); + await vi.waitFor(() => failure(h.sent, 'f1')); + + expect(failure(h.sent, 'f1')).toMatchObject({ + code: 'unsupported', + message: 'The provider no longer honours this fork checkpoint', + }); + expect(await h.store.load()).toHaveLength(1); + expect(await listed(h)).toHaveLength(1); + // The child adapter that refused was torn down; nothing forked. + expect(h.adapters.some((adapter) => adapter !== h.adapter && adapter.stopped)).toBe(true); + expect(forks(h.adapters)).toHaveLength(0); + expect(await h.conversationStore.getOperation(OperationIdSchema.parse('op-f1'))).toMatchObject({ + state: 'failed', + error: { code: 'unsupported' }, + }); + // A retry replays the stored failure verbatim; the source is not busy. + await fork(h, 'f1-again', firstTurnId, 2, { operationId: 'op-f1' }); + expect(failure(h.sent, 'f1-again').code).toBe('unsupported'); + await submitPrompt(h, 's3', 'third'); + await vi.waitFor(() => submittedTurnId(h.sent, 's3')); + }); + + it('deleting the source keeps the child, its copied lineage, and the shared prompts', async () => { + const h = await startedHarness(); + const [firstTurnId] = await twoCheckpointedTurns(h); + await fork(h, 'f1', firstTurnId, 2); + await vi.waitFor(() => forkedSessionId(h.sent, 'f1')); + const childId = forkedSessionId(h.sent, 'f1'); + const [copy] = await h.conversationStore.listTurns(childId); + + await h.inject({ kind: 'session.delete', clientReqId: 'del', sessionId: h.sessionId }); + + expect(h.sent).toContainEqual({ kind: 'request.succeeded', replyTo: 'del' }); + expect((await h.store.load()).map((record) => record.sessionId)).toEqual([childId]); + expect(await h.conversationStore.listTurns(childId)).toEqual([copy]); + const promptId = copy.input.type === 'prompt' ? copy.input.promptId : null; + expect(promptId).not.toBeNull(); + expect(await h.conversationStore.getPrompt(nullthrow(promptId))).toBeDefined(); + expect(await readUserTexts(h, childId)).toEqual(['first']); + }); + + it('re-binds the copied prefix from an aligned cold read of the child history, else not at all', async () => { + const aligned: Corpora = { + [CHILD_HISTORY]: [ + cursorRow('c1', 'first', 'child-before-first'), + cursorRow('c2', 'second', 'child-before-second'), + ], + }; + const h = await startedHarness(() => new ForkingAdapter(aligned)); + const [, secondTurnId] = await twoCheckpointedTurns(h); + await fork(h, 'f1', secondTurnId, 2); + await vi.waitFor(() => forkedSessionId(h.sent, 'f1')); + const childId = forkedSessionId(h.sent, 'f1'); + const [copy1, copy2] = await h.conversationStore.listTurns(childId); + expect(copy2.parentTurnId).toBe(copy1.turnId); + expect(await h.conversationStore.listBindings(copy1.turnId)).toEqual([]); + + expect(await readUserTexts(h, childId)).toEqual(['first', 'second']); + + expect(await h.conversationStore.listBindings(copy1.turnId)).toEqual([ + { + turnId: copy1.turnId, + runId: copy1.runId, + historyId: CHILD_HISTORY, + checkpoint: 'child-before-second', + capturedFrom: 'replay', + }, + ]); + expect(await h.conversationStore.listBindings(copy2.turnId)).toEqual([]); + // Editing the copied second turn forks the child history at the re-derived cut. + await submitPrompt(h, 'edit', 'second, edited', childId, { + parentTurnId: copy1.turnId, + expectedGraphRevision: 0, + }); + await vi.waitFor(() => submittedTurnId(h.sent, 'edit')); + const editor = nullthrow(forks(h.adapters).at(-1)); + expect(editor.branchedFrom).toEqual({ + historyId: CHILD_HISTORY, + cursor: 'child-before-second', + }); + }); + + it.each([ + ['a position mismatch', [cursorRow('c1', 'first', 'a'), cursorRow('c2', 'not second', 'b')]], + ['a row count mismatch', [cursorRow('c1', 'first', 'a')]], + ])('leaves every copied binding absent on %s', async (_label, rows) => { + const h = await startedHarness(() => new ForkingAdapter({ [CHILD_HISTORY]: rows })); + const [, secondTurnId] = await twoCheckpointedTurns(h); + await fork(h, 'f1', secondTurnId, 2); + await vi.waitFor(() => forkedSessionId(h.sent, 'f1')); + const childId = forkedSessionId(h.sent, 'f1'); + const [copy1, copy2] = await h.conversationStore.listTurns(childId); + + expect(await readUserTexts(h, childId)).toEqual(['first', 'second']); + + expect(await h.conversationStore.listBindings(copy1.turnId)).toEqual([]); + expect(await h.conversationStore.listBindings(copy2.turnId)).toEqual([]); + await submitPrompt(h, 'edit', 'second, edited', childId, { + parentTurnId: copy1.turnId, + expectedGraphRevision: 0, + }); + await vi.waitFor(() => failure(h.sent, 'edit')); + expect(failure(h.sent, 'edit')).toMatchObject({ + code: 'unsupported', + message: 'This turn has no provider checkpoint to fork from', + }); + }); + + it('an engine stop mid-fork resolves the operation and persists no child', async () => { + const h = await startedHarness(() => new GatedForkAdapter()); + const [firstTurnId] = await twoCheckpointedTurns(h); + + await fork(h, 'f1', firstTurnId, 2); + await vi.waitFor(() => { + expect(forks(h.adapters)).toHaveLength(1); + }); + await h.engine.stop(); + + expect(await h.store.load()).toHaveLength(1); + expect(await h.conversationStore.getOperation(OperationIdSchema.parse('op-f1'))).toMatchObject({ + state: 'failed', + error: { code: 'cancelled' }, + }); + }); + + it('boot recovery fails an open fork operation the previous daemon left behind', async () => { + const store = new InMemorySessionStore(); + const conversationStore = new InMemoryConversationStore(); + const sessionId = SessionIdSchema.parse('sess-source'); + await store.save({ + sessionId, + kind: 'claude-code', + cwd: '/repo', + origin: { type: 'created' }, + createdAt: 1, + updatedAt: 1, + runs: [{ runId: 'run-1' as never, startedAt: 1 }], + graphRevision: 0, + eventEpoch: 0, + }); + await conversationStore.persistOperation({ + operationId: OperationIdSchema.parse('op-fork'), + sessionId, + kind: 'session.fork', + state: 'open', + createdAt: 1, + }); + const h = harness(store, undefined, undefined, undefined, undefined, undefined, { + conversationStore, + }); + await h.engine.start(); + + expect(await conversationStore.getOperation(OperationIdSchema.parse('op-fork'))).toMatchObject({ + state: 'failed', + error: { + code: 'operation_failed', + message: 'The daemon restarted before the fork completed', + }, + }); + expect(await conversationStore.listOpenOperations()).toEqual([]); + }); +}); diff --git a/packages/host/engine/src/conversation/checkpoint-service.ts b/packages/host/engine/src/conversation/checkpoint-service.ts index ae0b987ae..a1b2e4cf9 100644 --- a/packages/host/engine/src/conversation/checkpoint-service.ts +++ b/packages/host/engine/src/conversation/checkpoint-service.ts @@ -121,7 +121,14 @@ export class ConversationCheckpointService { liveFingerprint, hasHiddenPrefix(record, path[0]), ); - yield* backfill(expectsProvider, attribution, historyId); + // A forked session's first history is the provider's copy of its prefix, and a copy can drop + // or gain rows (image-only prompts, compaction) that shift a partial alignment; a binding + // backfilled from one is never corrected, so that history backfills every position or none. + const copiedPrefix = + record.forkOrigin !== undefined && record.runs[0]?.historyId === historyId; + if (!copiedPrefix || attribution.attributed.length === expectsProvider.length) { + yield* backfill(expectsProvider, attribution, historyId); + } return attribution; }); } diff --git a/packages/host/engine/src/conversation/turn-service.ts b/packages/host/engine/src/conversation/turn-service.ts index 157096c60..6e233a06d 100644 --- a/packages/host/engine/src/conversation/turn-service.ts +++ b/packages/host/engine/src/conversation/turn-service.ts @@ -25,17 +25,19 @@ import type { AttachmentStore } from '../attachment/attachment-store'; import { InMemoryAttachmentStore } from '../attachment/attachment-store'; import { OperationError, RequestError } from '../failure'; import type { SessionRecordRegistry } from '../session/session-record-registry'; -import type { ConversationStore } from './conversation-store'; +import type { ConversationForkCommit, ConversationStore } from './conversation-store'; import { ConversationSessionBusyError } from './conversation-store'; export function mintOperationId(): OperationId { return `op-${randomUUID()}` as OperationId; } -function mintTurnId(): TurnId { +export function mintTurnId(): TurnId { return `turn-${randomUUID()}` as TurnId; } +type OpenOperation = Extract; + function mintPromptId(): PromptId { return `prompt-${randomUUID()}` as PromptId; } @@ -128,6 +130,10 @@ export class ConversationTurnService { return storeOperation('conversation.turns.list', () => this.store.listTurns(sessionId)); } + getTurn(turnId: TurnId): Effect.Effect { + return storeOperation('conversation.turn.get', () => this.store.getTurn(turnId)); + } + getPrompt(promptId: PromptId): Effect.Effect { return storeOperation('conversation.prompt.get', () => this.store.getPrompt(promptId)); } @@ -270,6 +276,68 @@ export class ConversationTurnService { }); } + /** The durable commit point of a session fork's admission: the open operation alone — the fork + * mints no turn of its own until its provider work succeeds. Busy is the store's own gate. */ + persistOperation(operation: OpenOperation): Effect.Effect { + return storeOperation('conversation.operation.persist', () => + this.store.persistOperation(operation), + ).pipe( + Effect.catch((error) => + Effect.fail( + error.cause instanceof ConversationSessionBusyError + ? new RequestError({ + code: 'busy', + message: 'Another operation is open on this session', + }) + : error, + ), + ), + ); + } + + /** One transaction for the fork's child rows and its operation's success; false when the + * operation had already resolved, in which case the child must be torn down. */ + commitFork(commit: ConversationForkCommit): Effect.Effect { + return storeOperation('conversation.fork.commit', () => this.store.commitFork(commit)); + } + + /** Store a turn-less operation's failure. The first terminal writer stands: a loser gets the + * stored failure back, so the reply never differs from what a retry replays. */ + failOperation( + operation: OpenOperation, + error: TurnFailure, + ): Effect.Effect { + const failed = { + ...operation, + state: 'failed' as const, + error: { code: error.code, message: error.message }, + resolvedAt: Date.now(), + }; + return storeOperation('conversation.operation.resolve', () => + this.store.resolveOperation(failed), + ).pipe( + Effect.flatMap((transitioned) => { + if (transitioned) return Effect.succeed(error); + return this.getOperation(operation.operationId).pipe( + Effect.flatMap((stored) => { + if (stored === undefined || stored.state === 'open') { + return Effect.fail( + new OperationError({ + subsystem: 'store', + operation: 'conversation.operation.resolve', + publicMessage: 'The operation resolution was lost', + cause: undefined, + }), + ); + } + // A single-writer saga cannot lose to its own success; the given error stands then. + return Effect.succeed(stored.state === 'failed' ? stored.error : error); + }), + ); + }), + ); + } + /** The adapter announced `running` for `runId`'s dispatching turn: track it in memory only. A * whole-turn send() (pi, grok) settles before it resolves — tracked late, its own stop would * settle its predecessor. The durable commit stays with the dispatch resolution: an adapter that @@ -429,7 +497,10 @@ export class ConversationTurnService { state: 'failed', error: { code: 'operation_failed', - message: 'The daemon restarted before the turn was dispatched', + message: + operation.kind === 'session.fork' + ? 'The daemon restarted before the fork completed' + : 'The daemon restarted before the turn was dispatched', }, resolvedAt, }), diff --git a/packages/host/engine/src/engine.ts b/packages/host/engine/src/engine.ts index 94fcfa25b..d1d1d8fc3 100644 --- a/packages/host/engine/src/engine.ts +++ b/packages/host/engine/src/engine.ts @@ -58,6 +58,7 @@ import { InMemoryResourceStore } from './resource/resource-store'; import { ResourceService } from './resource/service'; import { ScriptRequestHandler } from './scripts/request-handler'; import { ScriptService } from './scripts/script-service'; +import { SessionForkService } from './session/fork-service'; import { HistoryRequestHandler } from './session/history-request-handler'; import { HistoryService } from './session/history-service'; import { SessionLifecycleService } from './session/lifecycle-service'; @@ -281,11 +282,21 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( materializer, ingest, ); + const sessionForks = new SessionForkService( + sessions, + records, + history, + worktrees, + conversationTurns, + conversationCheckpoints, + sessionLifecycle, + ); const sessionRequests = new SessionRequestHandler( transport, sessionLifecycle, sessions, responder, + sessionForks, ); const historyRequests = new HistoryRequestHandler( transport, diff --git a/packages/host/engine/src/session/fork-service.ts b/packages/host/engine/src/session/fork-service.ts new file mode 100644 index 000000000..075d23da5 --- /dev/null +++ b/packages/host/engine/src/session/fork-service.ts @@ -0,0 +1,392 @@ +import type { + ConversationOperation, + ConversationTurn, + OperationId, + SessionId, + SessionRecord, + TurnId, +} from '@linkcode/schema'; +import { Effect, Exit } from 'effect'; +import { nullthrow } from 'foxts/guard'; +import type { ConversationCheckpointService, ForkCut } from '../conversation/checkpoint-service'; +import { pathToLeaf } from '../conversation/lineage-attribution'; +import type { ConversationTurnService, TurnFailure } from '../conversation/turn-service'; +import { mintTurnId } from '../conversation/turn-service'; +import type { EngineFailure } from '../failure'; +import { + causeToRequestFailure, + OperationError, + OperationTimeout, + RequestError, + toRequestFailure, +} from '../failure'; +import type { WorktreeService } from '../worktree/worktree-service'; +import type { HistoryService } from './history-service'; +import type { SessionLifecycleService } from './lifecycle-service'; +import { LAUNCH_TIMEOUT_MS, runOf } from './lifecycle-service'; +import type { SessionOrchestrator } from './orchestrator'; +import type { SessionRecordRegistry } from './session-record-registry'; +import { mintRunId } from './session-record-registry'; + +export interface SessionForkRequest { + readonly sourceSessionId: SessionId; + readonly throughTurnId: TurnId; + readonly operationId: OperationId; + readonly expectedGraphRevision: number; +} + +/** The reply a fork resolves to: the forked session, or the stored failure a retry replays. */ +export type SessionForkResult = + | { readonly state: 'succeeded'; readonly sessionId: SessionId } + | { readonly state: 'failed'; readonly error: TurnFailure }; + +type OpenForkOperation = Extract & { + readonly kind: 'session.fork'; +}; + +interface AdmittedFork { + readonly source: SessionRecord; + readonly through: ConversationTurn; + /** The source lineage root→through, the prefix the child copies. */ + readonly path: ConversationTurn[]; + readonly cut: ForkCut; + readonly operation: OpenForkOperation; +} + +/** + * The `session.fork` saga — idempotent by `operationId`, mirroring `turn.submit`: replay → admit + * under the source's critical section and persist the open operation → provider fork + child + * adapter start outside it under the launch budget → one transaction commits the child record, + * its copied prefix, and the operation. The source is never stopped or switched. Fork-vs-delete of + * one source serializes at the store: a deleted source fails the commit (its prompts are gone), a + * committed child keeps them through the ref-aware purge. + */ +export class SessionForkService { + constructor( + private readonly sessions: SessionOrchestrator, + private readonly records: SessionRecordRegistry, + private readonly history: HistoryService, + private readonly worktrees: WorktreeService, + private readonly turns: ConversationTurnService, + private readonly checkpoints: ConversationCheckpointService, + private readonly lifecycle: SessionLifecycleService, + ) {} + + forkSession(request: SessionForkRequest): Effect.Effect { + const { turns } = this; + const admit = this.admit.bind(this); + const launchChild = this.launchChild.bind(this); + return Effect.gen(function* () { + // Replay before any validation: a reply lost to a disconnect must not fork twice. + const existing = yield* turns.getOperation(request.operationId); + if (existing !== undefined) { + if (existing.sessionId !== request.sourceSessionId) { + return yield* Effect.fail( + new RequestError({ + code: 'invalid_request', + message: 'The operation id belongs to another session', + }), + ); + } + if (existing.state === 'open') { + return yield* Effect.fail( + new RequestError({ code: 'busy', message: 'The operation is still in flight' }), + ); + } + if (existing.state === 'failed') return { state: 'failed', error: existing.error }; + // A succeeded fork names the child's copied leaf; that turn's session is the child. + const leaf = yield* turns.getTurn(existing.turnId); + if (leaf === undefined) { + return yield* Effect.fail( + new RequestError({ code: 'not_found', message: 'The forked session no longer exists' }), + ); + } + return { state: 'succeeded', sessionId: leaf.sessionId }; + } + const admitted = yield* admit(request); + return yield* launchChild(admitted).pipe( + Effect.matchEffect({ + onSuccess: (sessionId) => + Effect.succeed({ state: 'succeeded', sessionId }), + // Any post-admit failure resolves the operation; a retry replays this stored error. + onFailure: (error) => + turns + .failOperation(admitted.operation, toRequestFailure(error)) + .pipe( + Effect.map((stored): SessionForkResult => ({ state: 'failed', error: stored })), + ), + }), + // Interrupts and defects bypass the typed match; the open operation must still resolve, + // or the source wedges `busy` until the daemon restarts. + Effect.onExit((exit) => + Exit.isFailure(exit) + ? turns.failOperation(admitted.operation, causeToRequestFailure(exit.cause)).pipe( + Effect.catch((error) => + Effect.logError( + 'Failed to resolve the interrupted fork', + { sessionId: request.sourceSessionId }, + error.cause, + ), + ), + Effect.asVoid, + ) + : Effect.void, + ), + ); + }); + } + + /** + * The short critical section under the source's semaphore: typed `busy` while a turn runs or + * another operation is open, the through turn must exist and have completed, the revision must + * match, the harness must fork after a turn and hold a usable cut — then the open operation is + * the durable commit point of the admission. + */ + private admit(request: SessionForkRequest): Effect.Effect { + return this.lifecycle.sessionSemaphore(request.sourceSessionId).withPermit( + Effect.suspend(() => { + const source = this.records.get(request.sourceSessionId); + if (!source) { + return Effect.fail( + new RequestError({ + code: 'not_found', + message: `Unknown session: ${request.sourceSessionId}`, + }), + ); + } + if (this.sessions.isBusy(source.sessionId)) { + return Effect.fail( + new RequestError({ code: 'busy', message: `Session is busy: ${source.sessionId}` }), + ); + } + const { checkpoints, sessions, turns, worktrees } = this; + return Effect.gen(function* () { + if (yield* turns.hasOpenOperation(source.sessionId)) { + return yield* Effect.fail( + new RequestError({ + code: 'busy', + message: 'Another operation is open on this session', + }), + ); + } + const sourceTurns = yield* turns.listTurns(source.sessionId); + const through = sourceTurns.find((turn) => turn.turnId === request.throughTurnId); + if (through === undefined) { + return yield* Effect.fail( + new RequestError({ + code: 'not_found', + message: `Unknown turn: ${request.throughTurnId}`, + }), + ); + } + if (through.state !== 'completed') { + return yield* Effect.fail( + new RequestError({ code: 'conflict', message: 'The turn has not completed' }), + ); + } + if (request.expectedGraphRevision !== source.graphRevision) { + return yield* Effect.fail( + new RequestError({ code: 'conflict', message: 'The conversation graph has moved' }), + ); + } + // A managed worktree has exactly one owning session until worktree leases land; the + // child could not hold the working tree it would share. + if (worktrees.get(source.sessionId) !== undefined) { + return yield* Effect.fail( + new RequestError({ + code: 'unsupported', + message: 'Forking a session on a managed worktree is not supported yet', + }), + ); + } + if (sessions.historyCapabilitiesOf(source.kind).forkAfterTurn !== true) { + return yield* Effect.fail( + new RequestError({ + code: 'unsupported', + message: `${source.kind}: forking a session is not supported`, + }), + ); + } + // A tip forks too: the two sessions must stop sharing one provider history. + const cut = yield* checkpoints.forkCutAfter(source, through.turnId); + if (cut === undefined) { + return yield* Effect.fail( + new RequestError({ + code: 'unsupported', + message: 'This turn has no provider checkpoint to fork from', + }), + ); + } + const path = pathToLeaf( + new Map(sourceTurns.map((turn) => [turn.turnId, turn])), + through.turnId, + ); + const operation: OpenForkOperation = { + operationId: request.operationId, + sessionId: source.sessionId, + kind: 'session.fork', + state: 'open', + createdAt: Date.now(), + }; + yield* turns.persistOperation(operation); + return { source, through, path, cut, operation }; + }); + }), + ); + } + + /** + * The provider work and the commit. The child record is held provisionally while its adapter + * starts on the forked history — the run's `session-ref` and status must bind to it — and + * becomes durable only in the transaction that also writes its copied prefix and the operation's + * success. Every failure exit before that tears the child down; the orphaned provider history is + * logged, never entered. + */ + private launchChild(admitted: AdmittedFork): Effect.Effect { + const { history, lifecycle, records, sessions, turns } = this; + const abandon = this.abandon.bind(this); + return Effect.gen(function* () { + const { source, through, path, cut, operation } = admitted; + const resolved = yield* lifecycle.resolveForRecord(source); + const now = Date.now(); + const runId = mintRunId(); + const childId = lifecycle.nextSessionId(); + const copies: ConversationTurn[] = []; + let parentTurnId: TurnId | null = null; + for (let i = 0, len = path.length; i < len; i++) { + const turnId = mintTurnId(); + // Prompts are shared by reference; the copied lineage is linear, so every ordinal is 1. + copies.push({ + ...path[i], + turnId, + sessionId: childId, + parentTurnId, + siblingOrdinal: 1, + runId, + }); + parentTurnId = turnId; + } + const leafTurnId = nullthrow( + parentTurnId, + 'a fork copies at least the turn it forks through', + ); + const child: SessionRecord = { + sessionId: childId, + kind: source.kind, + cwd: source.cwd, + ...(source.title !== undefined && { title: source.title }), + origin: { type: 'created' }, + forkOrigin: { + sourceSessionId: source.sessionId, + sourceTurnId: through.turnId, + forkedAt: now, + }, + createdAt: now, + updatedAt: now, + runs: [ + { + runId, + baseTurnId: leafTurnId, + startedAt: now, + ...runOf(resolved.options, resolved.accountId), + }, + ], + activeLeafTurnId: leafTurnId, + graphRevision: 0, + eventEpoch: 0, + }; + records.registerProvisional(child); + const start = sessions + .startLive( + undefined, + child, + runId, + (adapter) => history.branch(adapter, cut, resolved.options), + resolved.warnings, + { registerRecord: false }, + ) + .pipe( + Effect.timeoutOrElse({ + duration: LAUNCH_TIMEOUT_MS, + orElse: () => + Effect.fail( + new OperationTimeout({ + operation: 'session.fork.launch', + duration: LAUNCH_TIMEOUT_MS, + publicMessage: 'The provider did not fork in time', + }), + ), + }), + ); + // Uninterruptible from the commit to the announcement: a child that is durable but never + // announced would stay invisible until the next boot. + const commit = turns + .commitFork({ + child, + turns: copies, + operation: { + ...operation, + state: 'succeeded', + turnId: leafTurnId, + resolvedAt: Date.now(), + }, + }) + .pipe( + Effect.flatMap((transitioned) => + transitioned + ? Effect.sync(() => records.commitProvisional(childId)) + : Effect.fail( + new OperationError({ + subsystem: 'store', + operation: 'session.fork.commit', + publicMessage: 'The fork was resolved before it committed', + cause: undefined, + }), + ), + ), + Effect.uninterruptible, + ); + yield* start.pipe( + Effect.andThen(commit), + Effect.onExit((exit) => + Exit.isFailure(exit) && records.isProvisional(childId) ? abandon(child) : Effect.void, + ), + ); + return childId; + }); + } + + /** A fork that never committed: stop the child adapter if it started, forget the record. */ + private abandon(child: SessionRecord): Effect.Effect { + const { records, sessions } = this; + return Effect.suspend(() => { + const stop = + sessions.liveRunId(child.sessionId) === undefined + ? Effect.void + : sessions + .stop(child.sessionId) + .pipe( + Effect.catch((error) => + Effect.logError( + 'Failed to stop the abandoned fork child', + { sessionId: child.sessionId }, + error.cause, + ), + ), + ); + return stop.pipe( + Effect.andThen( + Effect.sync(() => { + records.discardProvisional(child.sessionId); + }), + ), + Effect.andThen( + Effect.logWarning('Abandoned a session fork; its provider child history is orphaned', { + sessionId: child.forkOrigin?.sourceSessionId, + historyId: child.runs[0]?.historyId, + }), + ), + ); + }); + } +} diff --git a/packages/host/engine/src/session/lifecycle-service.ts b/packages/host/engine/src/session/lifecycle-service.ts index 477598899..e89e78d24 100644 --- a/packages/host/engine/src/session/lifecycle-service.ts +++ b/packages/host/engine/src/session/lifecycle-service.ts @@ -67,7 +67,7 @@ type RunEffect = (effect: Effect.Effect, options?: Effect.RunOptions const TURN_SUBMIT_TIMEOUT_MS = 60_000; /** Launch budget: the claude CLI can legitimately take ~3 minutes to cold-start at peak hours; * the other harnesses bound their own startup well under this. */ -const LAUNCH_TIMEOUT_MS = 300_000; +export const LAUNCH_TIMEOUT_MS = 300_000; export interface TurnSubmitRequest { readonly sessionId: SessionId; @@ -1078,7 +1078,7 @@ export class SessionLifecycleService { * configured default answers for new and unpinned sessions, and adopting it here would silently * move a running thread to whatever Settings now says. */ - private resolveForRecord( + resolveForRecord( record: SessionRecord, override?: SessionPin, ): Effect.Effect { @@ -1183,7 +1183,8 @@ export class SessionLifecycleService { }); } - private nextSessionId(): SessionId { + /** The one session-id minter: a second counter could collide within a millisecond. */ + nextSessionId(): SessionId { this.seq += 1; return `sess-${Date.now().toString(36)}-${this.seq.toString(36)}` as SessionId; } @@ -1197,7 +1198,9 @@ export class SessionLifecycleService { return semaphore; } - private sessionSemaphore(sessionId: SessionId): Semaphore.Semaphore { + /** The per-session critical section every saga admits under, so a fork's admission serializes + * with the source's own submits and relaunches. */ + sessionSemaphore(sessionId: SessionId): Semaphore.Semaphore { const existing = this.sessionSemaphores.get(sessionId); if (existing) return existing; const semaphore = Semaphore.makeUnsafe(1); @@ -1250,7 +1253,7 @@ function workspaceRegisterWorktree( * than the options it produced, because only the resolver knows one actually backed the run. * Unresolved fields stay absent rather than writing `undefined` into the record, and are what a later * relaunch reads back to stay put. */ -function runOf(options: StartOptions, accountId: string | undefined): SessionPin { +export function runOf(options: StartOptions, accountId: string | undefined): SessionPin { return { ...(accountId !== undefined && { accountId }), ...(options.model !== undefined && { model: options.model }), diff --git a/packages/host/engine/src/session/request-handler.ts b/packages/host/engine/src/session/request-handler.ts index bbdeb3236..30c8ce800 100644 --- a/packages/host/engine/src/session/request-handler.ts +++ b/packages/host/engine/src/session/request-handler.ts @@ -3,6 +3,7 @@ import type { Transport } from '@linkcode/transport'; import { createWireMessage } from '@linkcode/transport'; import { Effect } from 'effect'; import type { WireResponder } from '../wire/responder'; +import type { SessionForkService } from './fork-service'; import type { SessionLifecycleService } from './lifecycle-service'; import type { SessionOrchestrator } from './orchestrator'; @@ -17,6 +18,7 @@ type SessionRequest = Extract< | 'session.list' | 'session.resume' | 'session.import' + | 'session.fork' | 'session.attach' | 'session.detach'; } @@ -29,6 +31,7 @@ export class SessionRequestHandler { private readonly lifecycle: SessionLifecycleService, private readonly sessions: SessionOrchestrator, private readonly responder: WireResponder, + private readonly forks: SessionForkService, ) {} handle(payload: SessionRequest): Effect.Effect { @@ -103,6 +106,40 @@ export class SessionRequestHandler { ), ), ); + case 'session.fork': + return this.responder.reply( + payload.clientReqId, + this.forks + .forkSession({ + sourceSessionId: payload.sourceSessionId, + throughTurnId: payload.throughTurnId, + operationId: payload.operationId, + expectedGraphRevision: payload.expectedGraphRevision, + }) + .pipe( + Effect.flatMap((result) => + Effect.sync(() => { + // A stored failure replays verbatim: its code/message ARE the terminal result. + this.transport.send( + createWireMessage( + result.state === 'succeeded' + ? { + kind: 'session.forked', + replyTo: payload.clientReqId, + sessionId: result.sessionId, + } + : { + kind: 'request.failed', + replyTo: payload.clientReqId, + code: result.error.code, + message: result.error.message, + }, + ), + ); + }), + ), + ), + ); case 'session.attach': // The Hub already attached this connection. Replay state that history cannot recover; // clients fold it idempotently and deduplicate interactive requests by requestId. diff --git a/packages/host/engine/src/session/session-record-registry.ts b/packages/host/engine/src/session/session-record-registry.ts index 204a7fbf4..689bcef44 100644 --- a/packages/host/engine/src/session/session-record-registry.ts +++ b/packages/host/engine/src/session/session-record-registry.ts @@ -144,6 +144,10 @@ export class SessionRecordRegistry { this.announce(sessionId, 'created'); } + isProvisional(sessionId: SessionId): boolean { + return this.provisional.has(sessionId); + } + /** The creating transaction never happened: the record was never durable, so nothing announces * its removal. */ discardProvisional(sessionId: SessionId): void { diff --git a/packages/host/engine/src/wire/request-router.ts b/packages/host/engine/src/wire/request-router.ts index cea683856..347717340 100644 --- a/packages/host/engine/src/wire/request-router.ts +++ b/packages/host/engine/src/wire/request-router.ts @@ -69,6 +69,7 @@ export class WireRequestRouter { case 'session.list': case 'session.resume': case 'session.import': + case 'session.fork': case 'session.attach': case 'session.detach': { return this.handlers.session.handle(p); From a01bdedd7001a62e2fb96733524e60379598ec11 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Tue, 8 Sep 2026 13:52:04 +0800 Subject: [PATCH 04/10] feat(agent-adapter): announce a fork child before its first prompt and carry claude subagent transcripts --- packages/host/agent-adapter/AGENTS.md | 1 + .../__tests__/claude-code-checkpoint.test.ts | 12 +++ .../claude-code-fork-subagents.test.ts | 81 +++++++++++++++++++ .../agent-adapter/src/native/claude-code.ts | 70 +++++++++++++++- .../agent-adapter/src/native/pi/adapter.ts | 4 +- 5 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 packages/host/agent-adapter/src/__tests__/claude-code-fork-subagents.test.ts diff --git a/packages/host/agent-adapter/AGENTS.md b/packages/host/agent-adapter/AGENTS.md index 79ad886ab..b74b4ff54 100644 --- a/packages/host/agent-adapter/AGENTS.md +++ b/packages/host/agent-adapter/AGENTS.md @@ -45,6 +45,7 @@ Every new adapter MUST honor these (`base.ts`); downstream relies on them, they - **`streamDelta(id, fullText, kind)`** turns a provider's CUMULATIVE per-item text into incremental deltas keyed by item id. opencode reports cumulative and MUST use it; claude/pi/codex emit true incremental deltas and call `emitAssistantText`/`emitThought` directly (codex additionally keeps a per-item length ledger so `item/completed` can backstop deltas the stream dropped). Mixing the two double-renders or drops text. - **`freshSegment()`** opens fresh `messageId` AND `thoughtId` cursors; call it at turn start and after EVERY tool call (`buildConversation` buckets `agent-message-chunk` by `messageId`; `message-grouping.test.ts` guards it). A message's `messageId` must stay STABLE across all its deltas (the Pi adapter once minted a new id per delta and broke dedup). - **`emitCheckpoint(historyId, branchPoint, turn)`** mints the turn's provider fork checkpoint (an `onCheckpoint` subscriber, never an agent event; the engine persists it as the turn's `provider_turn_bindings` row and `branchHistory` later forks at it). Emit it BEFORE the turn's `stop`/`idle`, and only for a genuinely completed turn; the engine keeps the FIRST live binding per `(turn, history)`, so a second mint for the same turn is ignored. Branch points: claude = the last main-agent assistant frame's `uuid` (a chain-correct inclusive cut on the EXPECTATION — unverified pending CODE-632's live multi-block turn — that the SDK streams one frame per persisted row; NOT equal to the next user row's `parentUuid` when a Stop hook ran, since a `system/stop_hook_summary` row then sits between; both cut validly and nothing may equate them); codex = the completed `turn/completed` id (`thread/fork lastTurnId` is inclusive); pi = `sessionManager.getLeafId()`; opencode has no "after" cut — its `session.fork {messageID}` cuts BEFORE a message, so the first user `message.updated` FIRST SEEN inside each turn is minted as a `preceding` checkpoint (the engine binds it to the parent turn); every user id is recorded on sight (pre-seeded from `session.messages` on resume), so a mid-turn compaction (`CompactionPart` on a later user message), a re-emitted settled prompt, or an idle straggler re-emitted inside a later turn never mints. opencode advertises `branch` (the legacy cold-read fork) but not `forkAfterTurn` until `session.fork` cut inclusivity is verified on a live server. `branchHistory` must throw `HistoryCheckpointInvalidError` for a cut the provider no longer honours (row gone from the raw transcript, JSON-RPC refusal, vanished opencode message, missing pi entry) — the engine maps it to a typed `unsupported`; codex `history_mode: "paginated"` rollouts (CODE-645) are refused there AND mint no replay cursors, so they stay fork-dark end to end. +- **`branchHistory` announces the child before any prompt.** A session fork dispatches nothing, so the child's `session-ref` must not wait for the first query's init: claude emits it right after `forkSession` (and copies the source's `subagents/` beside the child — `forkSession` 0.3.215 leaves them behind, and a silent copy failure would be a silently empty subagent card, so it is reported as a recoverable `error` event instead), pi and codex announce the resumed/branched id at start. The engine binds it to the child run and the child's cold read attributes its copied prefix against it. - **`onCommand(name, args)` / `onShellCommand(command)`** (CODE-161) back the `command` / `shell-command` AgentInput variants; both default-reject (`` `${kind}: slash/shell commands are not supported` ``). `AGENT_INPUT_CAPABILITIES` is the complete per-kind source of truth, which Base emits as `capabilities-update` at start; draft composers use the same matrix before a live event stream exists. **`emitCommands(commands)`** advertises the slash-command catalog (`available-commands-update`, full-replace). A missing catalog means discovery is still unavailable and host validation owns an early typed command; an emitted empty catalog is authoritative, so a completed-but-failed discovery must publish `[]` instead of leaving validation fail-open. The engine caches and replays both capabilities and the latest catalog on `session.attach`, prevalidates command/shell inputs before echoing them, and broadcasts an `input_rejected` error when dispatch fails. Adapters must NOT re-emit the user's invocation. ## Slash commands & shell passthrough (CODE-161) diff --git a/packages/host/agent-adapter/src/__tests__/claude-code-checkpoint.test.ts b/packages/host/agent-adapter/src/__tests__/claude-code-checkpoint.test.ts index bea1c103c..eed18ed25 100644 --- a/packages/host/agent-adapter/src/__tests__/claude-code-checkpoint.test.ts +++ b/packages/host/agent-adapter/src/__tests__/claude-code-checkpoint.test.ts @@ -23,6 +23,7 @@ class TestClaude extends ClaudeCodeAdapter { forkSession = vi.fn(forkedChild); supplementUuids: string[] = []; started: StartOptions[] = []; + subagentCopies: Array<[string, string]> = []; feed(value: object): void { this.handleMessage(value as SDKMessage); @@ -32,6 +33,11 @@ class TestClaude extends ClaudeCodeAdapter { return Promise.resolve({ forkSession: this.forkSession } as T); } + protected override copySubagentTranscripts(sourceId: string, childId: string): Promise { + this.subagentCopies.push([sourceId, childId]); + return Promise.resolve(); + } + protected override readTranscriptSupplement(): Promise { return Promise.resolve({ records: new Map(), @@ -187,6 +193,8 @@ describe('ClaudeCodeAdapter.branchHistory checkpoint validity', () => { it('forks through the checkpoint row when the transcript still has it', async () => { const adapter = new TestClaude(); adapter.supplementUuids = ['row-a', 'row-b']; + const events: AgentEvent[] = []; + adapter.onEvent((event) => events.push(event)); await adapter.branchHistory( { @@ -201,6 +209,10 @@ describe('ClaudeCodeAdapter.branchHistory checkpoint validity', () => { dir: '/repo', }); expect(adapter.started).toEqual([start]); + // The child exists on disk before the first prompt: its subagents travel with it and its id is + // announced at once, so a prompt-less fork can read its own history. + expect(adapter.subagentCopies).toEqual([[SESSION, 'sid-child']]); + expect(events).toContainEqual({ type: 'session-ref', historyId: 'sid-child' }); }); it('refuses typed, without forking or starting, when the row is gone (rewritten or deleted transcript)', async () => { diff --git a/packages/host/agent-adapter/src/__tests__/claude-code-fork-subagents.test.ts b/packages/host/agent-adapter/src/__tests__/claude-code-fork-subagents.test.ts new file mode 100644 index 000000000..fa47a6805 --- /dev/null +++ b/packages/host/agent-adapter/src/__tests__/claude-code-fork-subagents.test.ts @@ -0,0 +1,81 @@ +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { copyClaudeSubagentTranscripts } from '../native/claude-code'; + +const roots: string[] = []; + +async function projectsDir(): Promise { + const root = await mkdtemp(path.join(tmpdir(), 'linkcode-claude-projects-')); + roots.push(root); + return root; +} + +async function transcript(projects: string, project: string, sessionId: string): Promise { + await mkdir(path.join(projects, project), { recursive: true }); + await writeFile(path.join(projects, project, `${sessionId}.jsonl`), '{"type":"user"}\n'); +} + +async function subagent( + projects: string, + project: string, + sessionId: string, + agentId: string, +): Promise { + const dir = path.join(projects, project, sessionId, 'subagents'); + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, `agent-${agentId}.jsonl`), `{"agent":"${agentId}"}\n`); +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe('copyClaudeSubagentTranscripts', () => { + it('copies the source subagent transcripts next to the fork child', async () => { + const projects = await projectsDir(); + await transcript(projects, '-Users-me-repo', 'source'); + await subagent(projects, '-Users-me-repo', 'source', 'a1'); + await subagent(projects, '-Users-me-repo', 'source', 'a2'); + await transcript(projects, '-Users-me-repo', 'child'); + + expect(await copyClaudeSubagentTranscripts(projects, 'source', 'child')).toBe(true); + + const copied = path.join(projects, '-Users-me-repo', 'child', 'subagents'); + expect((await readdir(copied)).sort()).toEqual(['agent-a1.jsonl', 'agent-a2.jsonl']); + expect(await readFile(path.join(copied, 'agent-a2.jsonl'), 'utf8')).toBe('{"agent":"a2"}\n'); + // The source keeps its own. + expect( + await readdir(path.join(projects, '-Users-me-repo', 'source', 'subagents')), + ).toHaveLength(2); + }); + + it('finds the child in another project directory and copies nothing when the source has none', async () => { + const projects = await projectsDir(); + await transcript(projects, '-Users-me-repo', 'source'); + await transcript(projects, '-Users-me-other', 'child'); + + expect(await copyClaudeSubagentTranscripts(projects, 'source', 'child')).toBe(false); + expect(await readdir(path.join(projects, '-Users-me-other'))).toEqual(['child.jsonl']); + + await subagent(projects, '-Users-me-repo', 'source', 'a1'); + expect(await copyClaudeSubagentTranscripts(projects, 'source', 'child')).toBe(true); + expect(await readdir(path.join(projects, '-Users-me-other', 'child', 'subagents'))).toEqual([ + 'agent-a1.jsonl', + ]); + }); + + it('copies nothing for an unknown child, a missing projects dir, or a path-shaped id', async () => { + const projects = await projectsDir(); + await transcript(projects, '-Users-me-repo', 'source'); + await subagent(projects, '-Users-me-repo', 'source', 'a1'); + + expect(await copyClaudeSubagentTranscripts(projects, 'source', 'child')).toBe(false); + expect( + await copyClaudeSubagentTranscripts(path.join(projects, 'nope'), 'source', 'child'), + ).toBe(false); + expect(await copyClaudeSubagentTranscripts(projects, '../source', 'child')).toBe(false); + expect(await copyClaudeSubagentTranscripts(projects, 'source', '../../child')).toBe(false); + }); +}); diff --git a/packages/host/agent-adapter/src/native/claude-code.ts b/packages/host/agent-adapter/src/native/claude-code.ts index c38bb870c..d6211f7c0 100644 --- a/packages/host/agent-adapter/src/native/claude-code.ts +++ b/packages/host/agent-adapter/src/native/claude-code.ts @@ -1,4 +1,4 @@ -import { readdir, readFile } from 'node:fs/promises'; +import { access, cp, readdir, readFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import path from 'node:path'; import type { @@ -61,6 +61,7 @@ import { import { asyncRetry } from 'foxts/async-retry'; import { extractErrorMessage } from 'foxts/extract-error-message'; import { nullthrow } from 'foxts/guard'; +import { falseFn, trueFn } from 'foxts/noop'; import { waitWithAbort } from 'foxts/wait'; import { z } from 'zod'; import type { AgentStartCatalogOptions, BrowserToolset, BrowserToolsetFactory } from '../adapter'; @@ -691,11 +692,33 @@ export class ClaudeCodeAdapter extends BaseAgentAdapter { upToMessageId: predecessor, dir: startOpts.cwd, }); + await this.copySubagentTranscripts(opts.historyId, fork.sessionId); this.resumeFrom = fork.sessionId; + // The child transcript exists now, so a fork that dispatches no prompt (a session fork) can + // still read its own history; the first query's init announces the same id and dedupes. + this.emitSessionRef(asHistoryId(fork.sessionId)); } await this.start(startOpts); } + /** `forkSession` copies the transcript alone; without its `subagents/` the child's cold read + * finds no subagent detail. Best-effort: the fork already exists on disk, so a failed copy is + * reported into the session, never a failed fork. */ + protected async copySubagentTranscripts(sourceId: string, childId: string): Promise { + try { + await copyClaudeSubagentTranscripts( + path.join(homedir(), '.claude', 'projects'), + sourceId, + childId, + ); + } catch (error) { + this.emitError( + `claude-code: subagent transcripts were not copied into the fork: ${extractErrorMessage(error)}`, + 'fork_subagents_not_copied', + ); + } + } + override async listHistory(opts?: AgentHistoryListOptions): Promise { const mod = await this.loadSdk( '@anthropic-ai/claude-agent-sdk', @@ -1975,6 +1998,51 @@ async function readClaudeProjectText(segments: readonly string[]): Promise t !== null) ?? null; } +/** The project directory holding `.jsonl`; the SDK keys projects by an encoded cwd + * that nothing outside it reproduces, so the file is found by looking. */ +async function findClaudeProjectDir( + projectsDir: string, + sessionId: string, +): Promise { + const dirs = await readdir(projectsDir).catch(() => []); + const present = await Promise.all( + dirs.map((dir) => + access(path.join(projectsDir, dir, `${sessionId}.jsonl`)) + .then(trueFn) + .catch(falseFn), + ), + ); + const index = present.indexOf(true); + return index < 0 ? undefined : path.join(projectsDir, dirs[index]); +} + +/** + * Copy a session's `subagents/` transcripts next to its fork child (SDK 0.3.215's `forkSession` + * leaves them behind). Transcripts of agents spawned after the cut travel too: their spawning + * tool_use is not in the child transcript, so they are never spliced in. Returns whether anything + * was copied; both ids become path segments and are shape-checked first. + */ +export async function copyClaudeSubagentTranscripts( + projectsDir: string, + sourceId: string, + childId: string, +): Promise { + if (!SAFE_SESSION_ID.test(sourceId) || !SAFE_SESSION_ID.test(childId)) return false; + const [sourceDir, childDir] = await Promise.all([ + findClaudeProjectDir(projectsDir, sourceId), + findClaudeProjectDir(projectsDir, childId), + ]); + if (sourceDir === undefined || childDir === undefined) return false; + const from = path.join(sourceDir, sourceId, 'subagents'); + try { + await access(from); + } catch { + return false; + } + await cp(from, path.join(childDir, childId, 'subagents'), { recursive: true }); + return true; +} + async function readClaudeTranscriptSupplement( sessionId: string, ): Promise { diff --git a/packages/host/agent-adapter/src/native/pi/adapter.ts b/packages/host/agent-adapter/src/native/pi/adapter.ts index 6a1ef5822..5a9625aa7 100644 --- a/packages/host/agent-adapter/src/native/pi/adapter.ts +++ b/packages/host/agent-adapter/src/native/pi/adapter.ts @@ -413,7 +413,9 @@ export class PiAdapter extends BaseAgentAdapter { ); this.emitApprovalPolicy({ availablePolicies: [...POLICIES], currentPolicyId: this.policyId }); if (isEffort(session.thinkingLevel)) this.emitEffort(session.thinkingLevel); - if (this.resumeFrom) this.emitSessionRef(this.resumeFrom); + // A resumed or branched manager already has its file: announce it before any prompt, so a + // fork that dispatches nothing yet (a session fork) can read its own history. + if (manager) this.emitSessionRef(asHistoryId(session.sessionId)); await session.bindExtensions({ uiContext: createPiUiContext({ ask: (tool, questions, signal) => From 37072a9c776bcca09e40245078c84b3c26aa459c Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Tue, 8 Sep 2026 14:00:40 +0800 Subject: [PATCH 05/10] feat(client-core,sdk): expose session.fork behind a wire-version gate --- packages/client/core/src/client.ts | 29 +++++++ .../client/core/src/client/control-channel.ts | 21 +++++ .../core/src/client/pending-registry.ts | 7 ++ .../integration/conversation-client.test.ts | 77 ++++++++++++++++++- packages/client/sdk/src/client.ts | 11 +++ packages/client/sdk/src/operations.ts | 16 ++++ 6 files changed, 160 insertions(+), 1 deletion(-) diff --git a/packages/client/core/src/client.ts b/packages/client/core/src/client.ts index c0d84a6ad..5c8ba1ca9 100644 --- a/packages/client/core/src/client.ts +++ b/packages/client/core/src/client.ts @@ -68,6 +68,7 @@ import type { StartOptions, TerminalMetadata, TerminalReplayEvent, + TurnId, TurnSubmitInput, UploadId, WireMessage, @@ -81,6 +82,7 @@ import { ATTACHMENT_STORE_WIRE_VERSION, CONVERSATION_GRAPH_WIRE_VERSION, MIN_COMPATIBLE_WIRE_VERSION, + SESSION_FORK_WIRE_VERSION, WIRE_PROTOCOL_VERSION, } from '@linkcode/schema'; import type { Transport, Unsubscribe } from '@linkcode/transport'; @@ -121,6 +123,7 @@ import type { PluginMutation, RandomUUID, RequestAck, + SessionForkResult, SessionStartResult, TurnSubmitResult, } from './client/pending-registry'; @@ -149,6 +152,7 @@ export type { ConversationReadPage, PluginList, PluginMutation, + SessionForkResult, SessionStartResult, TurnSubmitResult, } from './client/pending-registry'; @@ -361,6 +365,11 @@ export class LinkCodeClient { return this.peerWire !== null && this.peerWire.version >= ATTACHMENT_STORE_WIRE_VERSION; } + /** Whether the host answers `session.fork`; an older host would drop the frame unanswered. */ + get supportsSessionFork(): boolean { + return this.peerWire !== null && this.peerWire.version >= SESSION_FORK_WIRE_VERSION; + } + private async handshake(): Promise { let settled = false; let cancelTimer: () => void = noop; @@ -460,6 +469,9 @@ export class LinkCodeClient { case 'session.imported': this.pending.resolve('import', p.replyTo, p.record); break; + case 'session.forked': + this.pending.resolve('fork', p.replyTo, { sessionId: p.sessionId }); + break; case 'history.listed': this.pending.resolve('historyList', p.replyTo, p.result); break; @@ -822,6 +834,23 @@ export class LinkCodeClient { return this.control.importSession(agentKind, historyId); } + /** See {@link ControlChannel.forkSession}. Refuses typed before any frame leaves when the host + * predates `session.fork`, which would otherwise drop the request unanswered. */ + forkSession( + sourceSessionId: SessionId, + throughTurnId: TurnId, + expectedGraphRevision: number, + ): Promise { + if (!this.supportsSessionFork) { + return Promise.reject( + Object.assign(new Error('The host does not support forking sessions'), { + code: 'unsupported', + }), + ); + } + return this.control.forkSession(sourceSessionId, throughTurnId, expectedGraphRevision); + } + listHistory( agentKind: AgentKind, opts?: HistoryListClientOptions, diff --git a/packages/client/core/src/client/control-channel.ts b/packages/client/core/src/client/control-channel.ts index 0c0d40c91..e1ba27555 100644 --- a/packages/client/core/src/client/control-channel.ts +++ b/packages/client/core/src/client/control-channel.ts @@ -82,6 +82,7 @@ import type { PluginList, PluginMutation, RequestAck, + SessionForkResult, SessionStartResult, TurnSubmitResult, } from './pending-registry'; @@ -216,6 +217,26 @@ export class ControlChannel { })); } + /** + * Fork a new session off `sourceSessionId` through `throughTurnId` (that turn included). The + * daemon validates the turn and `expectedGraphRevision` — the source graph the caller looked at + * — and answers typed `busy`/`conflict`/`unsupported`. Idempotency is a fresh `operationId`. + */ + forkSession( + sourceSessionId: SessionId, + throughTurnId: TurnId, + expectedGraphRevision: number, + ): Promise { + return this.sendCorrelated('fork', (clientReqId) => ({ + kind: 'session.fork', + clientReqId, + sourceSessionId, + throughTurnId, + operationId: OperationIdSchema.parse(`op-${clientReqId}`), + expectedGraphRevision, + })); + } + /** One page of the host-composed projection toward a leaf. Only the final page carries the live * tail and the `(epoch, seq)` watermark; `readConversationProjection` walks the whole read. */ readConversation( diff --git a/packages/client/core/src/client/pending-registry.ts b/packages/client/core/src/client/pending-registry.ts index abac13972..77ca05ebf 100644 --- a/packages/client/core/src/client/pending-registry.ts +++ b/packages/client/core/src/client/pending-registry.ts @@ -61,6 +61,11 @@ export interface SessionStartResult { mcpWarnings: McpWarning[]; } +/** `session.forked` without its correlation fields: the new session, live and selectable. */ +export interface SessionForkResult { + sessionId: SessionId; +} + /** The `plugin.list.result` payload as one value: catalogs, standalone skills, and per-provider * discovery outcomes travel together so the UI can tell "empty" from "provider CLI failed". */ export interface PluginList { @@ -133,6 +138,7 @@ export function resolveRandomUUID(provider?: RandomUUID): RandomUUID { */ export interface PendingValueMap { start: SessionStartResult; + fork: SessionForkResult; list: SessionInfo[]; import: SessionRecord; historyList: AgentHistoryListResult; @@ -202,6 +208,7 @@ type PendingMaps = { [K in keyof PendingValueMap]: Map { serverTransport.close(); }); + it('forks a session through a turn and resolves with the child, or fails typed', async () => { + const { client, serverTransport } = await createConnectedLocalClient(); + const forks: unknown[] = []; + serverTransport.onMessage((msg) => { + const p = msg.payload; + if (p.kind !== 'session.fork') return; + forks.push(p); + serverTransport.send( + p.expectedGraphRevision === 7 + ? createWireMessage({ + kind: 'session.forked', + replyTo: p.clientReqId, + sessionId: 'sess-child' as SessionId, + }) + : createWireMessage({ + kind: 'request.failed', + replyTo: p.clientReqId, + code: 'conflict', + message: 'The conversation graph has moved', + }), + ); + }); + + expect(client.supportsSessionFork).toBe(true); + await expect(client.forkSession(sessionId, leafTurnId, 7)).resolves.toEqual({ + sessionId: 'sess-child', + }); + await expect(client.forkSession(sessionId, leafTurnId, 6)).rejects.toMatchObject({ + code: 'conflict', + }); + expect(forks).toEqual([ + expect.objectContaining({ + sourceSessionId: sessionId, + throughTurnId: leafTurnId, + expectedGraphRevision: 7, + operationId: expect.stringMatching(rOperationId), + }), + expect.objectContaining({ expectedGraphRevision: 6 }), + ]); + client.dispose(); + serverTransport.close(); + }); + + it('refuses to fork against a host that predates session.fork, before any frame leaves', async () => { + const [clientTransport, serverTransport] = createLocalTransportPair(); + await serverTransport.connect(); + const frames: string[] = []; + serverTransport.onMessage((message) => { + frames.push(message.payload.kind); + if (message.payload.kind === 'ping') { + serverTransport.send( + createWireMessage({ + kind: 'pong', + version: SESSION_FORK_WIRE_VERSION - 1, + minCompatible: SESSION_FORK_WIRE_VERSION - 5, + }), + ); + } + }); + const older = new LinkCodeClient(clientTransport); + await older.connect(); + + expect(older.supportsSessionFork).toBe(false); + await expect(older.forkSession(sessionId, leafTurnId, 1)).rejects.toMatchObject({ + code: 'unsupported', + }); + expect(frames).not.toContain('session.fork'); + older.dispose(); + serverTransport.close(); + }); + it('resolves a plain-send turn.submit without parent or revision', async () => { const { client, serverTransport } = await createConnectedLocalClient(); const submitted: unknown[] = []; diff --git a/packages/client/sdk/src/client.ts b/packages/client/sdk/src/client.ts index 61d59728c..17b1c0edf 100644 --- a/packages/client/sdk/src/client.ts +++ b/packages/client/sdk/src/client.ts @@ -5,6 +5,7 @@ import type { HistoryReadClientOptions, PluginList, PluginMutation, + SessionForkResult, SessionStartResult, } from '@linkcode/client-core'; import { LinkCodeClient } from '@linkcode/client-core'; @@ -60,6 +61,7 @@ import type { StandaloneSkill, StandaloneSkillScope, StartOptions, + TurnId, WorkspaceFile, WorkspaceId, WorkspaceKind, @@ -180,6 +182,15 @@ export class LinkCodeSdkClient { return toResult(this.raw.importSession(agentKind, historyId)); } + /** Fork a live child session off `sourceSessionId` through `throughTurnId` (included). */ + forkSession( + sourceSessionId: SessionId, + throughTurnId: TurnId, + expectedGraphRevision: number, + ): RequestResult { + return toResult(this.raw.forkSession(sourceSessionId, throughTurnId, expectedGraphRevision)); + } + listHistory( agentKind: AgentKind, opts?: HistoryListClientOptions, diff --git a/packages/client/sdk/src/operations.ts b/packages/client/sdk/src/operations.ts index b9117f961..59ebfe343 100644 --- a/packages/client/sdk/src/operations.ts +++ b/packages/client/sdk/src/operations.ts @@ -3,6 +3,7 @@ import type { HistoryReadClientOptions, PluginList, PluginMutation, + SessionForkResult, SessionStartResult, } from '@linkcode/client-core'; import type { @@ -56,6 +57,7 @@ import type { StandaloneSkill, StandaloneSkillScope, StartOptions, + TurnId, WorkspaceFile, WorkspaceId, WorkspaceKind, @@ -135,6 +137,20 @@ export function resumeSessionWithWarnings( return resolveClient(options).resumeSessionWithWarnings(options.sessionId); } +export function forkSession( + options: Options<{ + sourceSessionId: SessionId; + throughTurnId: TurnId; + expectedGraphRevision: number; + }>, +): RequestResult { + return resolveClient(options).forkSession( + options.sourceSessionId, + options.throughTurnId, + options.expectedGraphRevision, + ); +} + export function importSession( options: Options<{ agentKind: AgentKind; historyId: AgentHistoryId }>, ): RequestResult { From 0d72444ed52d6aad0c39da42305d207cb3fc29f1 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Tue, 8 Sep 2026 14:09:58 +0800 Subject: [PATCH 06/10] feat(workbench,ui): fork a new thread from an agent turn --- packages/client/workbench/AGENTS.md | 5 +- .../workbench/src/mock/dev-mock-host.ts | 116 ++++++++++++++++++ .../src/surface/use-workbench-sessions.ts | 29 +++++ .../workbench/src/surface/workbench.tsx | 13 ++ .../integration/dev-mock-lineage.test.ts | 64 ++++++++++ packages/presentation/i18n/src/locales/en.ts | 2 +- .../presentation/i18n/src/locales/zh-cn.ts | 2 +- .../src/chat/__tests__/turn-actions.test.tsx | 34 +++++ .../ui/src/chat/conversation-view.tsx | 4 + .../presentation/ui/src/chat/turn-actions.tsx | 6 +- .../ui/src/chat/turn-segment-view.tsx | 14 +++ packages/presentation/ui/src/chat/types.ts | 3 + .../ui/src/shell/conversation-surface.tsx | 1 + 13 files changed, 288 insertions(+), 5 deletions(-) create mode 100644 packages/presentation/ui/src/chat/__tests__/turn-actions.test.tsx diff --git a/packages/client/workbench/AGENTS.md b/packages/client/workbench/AGENTS.md index f4d858070..54f3a74b1 100644 --- a/packages/client/workbench/AGENTS.md +++ b/packages/client/workbench/AGENTS.md @@ -47,7 +47,10 @@ app-specific entries (`apps/desktop`, `apps/webview`) and pure presentation (`pa explicit-parent `turn.submit`s under the version's last completed turn, an edit is a sibling under the edited turn's parent, and a successful submit follows the host default again (the daemon moved it before replying); the store also releases a parked view once the default runs - through its leaf. + through its leaf. "Fork a new thread from here" on an agent reply is `session.fork` through that + turn (`useWorkbenchSessions.fork`, offered only when the host and the harness can fork after a + turn): the daemon copies the lineage onto a provider-native fork, and this device selects the + child while the source stays as it was. - `terminal/` — the daemon-backed interactive terminal: the panel container, the key-scoped session registry that retains/detaches (rather than kills) a PTY across remounts, viewer attachment containers, and the transport-backed `TerminalSession`. Only the current controller diff --git a/packages/client/workbench/src/mock/dev-mock-host.ts b/packages/client/workbench/src/mock/dev-mock-host.ts index 782747002..fd6d55581 100644 --- a/packages/client/workbench/src/mock/dev-mock-host.ts +++ b/packages/client/workbench/src/mock/dev-mock-host.ts @@ -20,6 +20,7 @@ import type { ManagedAssetKey, ManagedAssetStatus, MessageId, + OperationId, PermissionOutcome, Plugin, PromptId, @@ -294,6 +295,8 @@ export class DevMockHost { private readonly attachmentBegins = new Map(); /** The daemon's `isReachable` roots: sessions whose prompt or resource names the attachment. */ private readonly attachmentSessions = new Map>(); + /** The daemon's operation journal for forks: a replayed id answers with the same child. */ + private readonly forkOperations = new Map(); private uploadSeq = 0; private attachmentSeq = 0; @@ -438,6 +441,10 @@ export class DevMockHost { await wait(CONTROL_LATENCY_MS); this.resumeSession(p.clientReqId, p.sessionId); break; + case 'session.fork': + await wait(CONTROL_LATENCY_MS); + this.forkSession(p); + break; case 'session.stop': await wait(CONTROL_LATENCY_MS); this.stopSession(p.clientReqId, p.sessionId); @@ -1282,6 +1289,114 @@ export class DevMockHost { this.send({ kind: 'session.started', replyTo, sessionId }); } + /** The daemon's `session.fork` reduced to mock parity: its admit rules in its order, then the + * source lineage through the turn copied onto a new live session — new turn ids, the same + * content, the prefix's journal frames as the child's provider copy — with the source untouched. */ + private forkSession(p: Extract): void { + const replayed = this.forkOperations.get(p.operationId); + if (replayed !== undefined) { + this.send({ kind: 'session.forked', replyTo: p.clientReqId, sessionId: replayed }); + return; + } + const source = this.sessions.get(p.sourceSessionId); + if (!source) { + this.sendFailure(p.clientReqId, `Unknown session: ${p.sourceSessionId}`, { + code: 'not_found', + }); + return; + } + if (source.status === 'running') { + this.sendFailure(p.clientReqId, `Session is busy: ${p.sourceSessionId}`, { code: 'busy' }); + return; + } + const through = source.graphTurns.find((turn) => turn.graph.turnId === p.throughTurnId); + if (through === undefined) { + this.sendFailure(p.clientReqId, `Unknown turn: ${p.throughTurnId}`, { code: 'not_found' }); + return; + } + if (through.graph.state !== 'completed') { + this.sendFailure(p.clientReqId, 'The turn has not completed', { code: 'conflict' }); + return; + } + if (p.expectedGraphRevision !== source.graphRevision) { + this.sendFailure(p.clientReqId, 'The conversation graph has moved', { code: 'conflict' }); + return; + } + if (MOCK_HISTORY_CAPABILITIES[source.kind]?.forkAfterTurn !== true) { + this.sendFailure(p.clientReqId, `${source.kind}: forking a session is not supported`, { + code: 'unsupported', + }); + return; + } + const now = Date.now(); + const child = this.addSession({ + kind: source.kind, + cwd: source.cwd, + status: 'idle', + createdAt: now, + updatedAt: now, + model: source.model, + effort: source.effort, + ...(source.title !== undefined && { title: source.title }), + forkOrigin: { + sourceSessionId: source.sessionId, + sourceTurnId: through.graph.turnId, + forkedAt: now, + }, + }); + const path: MockTurn[] = []; + for (let cursor: TurnId | null = through.graph.turnId; cursor !== null; ) { + const id: TurnId = cursor; + const turn = source.graphTurns.find((candidate) => candidate.graph.turnId === id); + if (turn === undefined) break; + path.unshift(turn); + cursor = turn.graph.parentTurnId; + } + const copiedIds = new Map(); + let parentTurnId: TurnId | null = null; + for (let i = 0, len = path.length; i < len; i++) { + const turn = path[i]; + this.turnSeq += 1; + const id = this.turnSeq.toString(36); + const turnId = `turn-mock-${id}` as TurnId; + copiedIds.set(turn.graph.turnId, turnId); + child.graphTurns.push({ + graph: { + ...turn.graph, + turnId, + sessionId: child.sessionId, + parentTurnId, + siblingOrdinal: 1, + runId: `run-mock-fork-${id}` as RunId, + }, + content: turn.content, + ...(turn.readContent !== undefined && { readContent: turn.readContent }), + }); + parentTurnId = turnId; + } + child.activeLeafTurnId = parentTurnId ?? undefined; + for (let i = 0, len = source.journal.length; i < len; i++) { + const entry = source.journal[i]; + const turnId = entry.turnId === undefined ? undefined : copiedIds.get(entry.turnId); + if (turnId === undefined) continue; + child.eventSeq += 1; + child.journal.push({ + epoch: child.eventEpoch, + seq: child.eventSeq, + ts: entry.ts, + turnId, + event: + entry.event.type === 'user-message' + ? { ...entry.event, messageId: userRowMessageId(turnId) } + : entry.event, + }); + } + this.forkOperations.set(p.operationId, child.sessionId); + this.attachSession(child.sessionId); + this.send({ kind: 'session.changed', sessionId: child.sessionId, reason: 'created' }); + this.send({ kind: 'session.forked', replyTo: p.clientReqId, sessionId: child.sessionId }); + } + /** Replay the live state a late subscriber cannot recover from session history. */ private attachSession(sessionId: SessionId): void { const session = this.sessions.get(sessionId); @@ -2533,6 +2648,7 @@ function toSessionInfo(session: MockSession): SessionInfo { updatedAt: session.updatedAt, title: session.title, origin: session.origin, + ...(session.forkOrigin !== undefined && { forkOrigin: session.forkOrigin }), ...(MOCK_HISTORY_CAPABILITIES[session.kind] !== undefined && { historyCapabilities: MOCK_HISTORY_CAPABILITIES[session.kind], }), diff --git a/packages/client/workbench/src/surface/use-workbench-sessions.ts b/packages/client/workbench/src/surface/use-workbench-sessions.ts index fef42d39c..146294487 100644 --- a/packages/client/workbench/src/surface/use-workbench-sessions.ts +++ b/packages/client/workbench/src/surface/use-workbench-sessions.ts @@ -5,10 +5,12 @@ import type { SessionId, SessionInfo, SessionModeId, + TurnId, WorkspaceId, } from '@linkcode/schema'; import { deleteSession, + forkSession, listSessions, resumeSessionWithWarnings, startSessionWithWarnings, @@ -58,6 +60,13 @@ export interface WorkbenchSessions { }) => Promise; /** Stop the session if live and remove it from the list; re-importable from provider history. */ close: (id: SessionId) => void; + /** Fork a live child off a session through one of its turns and select it; the source stays as + * it was. Rejections propagate to the caller (the view stays put) and reach `onError`. */ + fork: ( + sourceSessionId: SessionId, + throughTurnId: TurnId, + expectedGraphRevision: number, + ) => Promise; /** Revalidate the session list — the cue for a mutation made outside this hook (e.g. an import). */ refresh: () => void; } @@ -75,6 +84,7 @@ export function useWorkbenchSessions(onError: (err: unknown) => void): Workbench const tMcpWarnings = useTranslations('workbench.mcpWarnings'); const { data: remoteSessions, isLoading, mutate } = useData(listSessions, {}); const createMutation = useMutation(startSessionWithWarnings, { onError }); + const forkMutation = useMutation(forkSession, { onError }); const closeMutation = useMutation(deleteSession, { onError }); const resumeMutation = useMutation(resumeSessionWithWarnings, { onError }); const selectedId = useSessionSelectionStore((state) => state.selectedId); @@ -233,6 +243,24 @@ export function useWorkbenchSessions(onError: (err: unknown) => void): Workbench return sessionId; } + async function fork( + sourceSessionId: SessionId, + throughTurnId: TurnId, + expectedGraphRevision: number, + ): Promise { + const from = currentLocation; + const { sessionId } = await forkMutation.trigger({ + sourceSessionId, + throughTurnId, + expectedGraphRevision, + }); + // Mutate before selecting to avoid a flash of the previous session. + await mutate().catch(noop); + recordNavigation(from, { surface: 'thread', sessionId }); + setSelectedId(sessionId); + return sessionId; + } + function close(id: SessionId): void { // Closing the open thread drops back to the New Session landing; closing any other thread // leaves the current selection untouched. @@ -265,6 +293,7 @@ export function useWorkbenchSessions(onError: (err: unknown) => void): Workbench goForward, create, close, + fork, refresh, }; } diff --git a/packages/client/workbench/src/surface/workbench.tsx b/packages/client/workbench/src/surface/workbench.tsx index 738f7762d..1c7418b4c 100644 --- a/packages/client/workbench/src/surface/workbench.tsx +++ b/packages/client/workbench/src/surface/workbench.tsx @@ -688,6 +688,18 @@ function WorkbenchSessionSurface({ if (active !== null) useLineageStore.getState().follow(active.sessionId); } + /** Fork a new thread through the turn behind a user row: the daemon copies the lineage onto a + * provider-native fork and this device selects the child; the source is left as it was. */ + function handleForkTurn(messageId: string): void { + if (graph === undefined || active === null) return; + const turn = graph.turns.find((candidate) => userRowMessageId(candidate.turnId) === messageId); + if (turn === undefined) return; + onClearError(); + void sessions.fork(active.sessionId, turn.turnId, graph.graphRevision).catch(noop); + } + const canForkSessions = + client.supportsSessionFork && active?.historyCapabilities?.forkAfterTurn === true; + function handleDismissElsewhere(): void { if (active !== null && graph !== undefined) { useLineageStore.getState().dismissElsewhere(active.sessionId, graph.activeLeafTurnId); @@ -722,6 +734,7 @@ function WorkbenchSessionSurface({ ? 'busy' : 'enabled' : 'unsupported', + ...(canForkSessions && { onForkTurn: handleForkTurn }), }; const conversationComposer: ConversationComposerController = { diff --git a/packages/client/workbench/tests/integration/dev-mock-lineage.test.ts b/packages/client/workbench/tests/integration/dev-mock-lineage.test.ts index 3c699a93b..68a954788 100644 --- a/packages/client/workbench/tests/integration/dev-mock-lineage.test.ts +++ b/packages/client/workbench/tests/integration/dev-mock-lineage.test.ts @@ -198,6 +198,70 @@ describe('dev mock turn lineages', () => { client.dispose(); }); + it('forks a live child through a turn, copying the lineage and leaving the source alone', async () => { + const client = new LinkCodeClient(createDevMockTransport()); + await client.connect(); + const sessionId = await client.startSession({ kind: 'codex', cwd: '/mock/repo' }); + const a = await client.submitTurn(sessionId, { type: 'shell-command', command: 'a' }); + const b = await client.submitTurn(sessionId, { type: 'shell-command', command: 'b' }); + const graph = await client.getConversationGraph(sessionId); + + const { sessionId: childId } = await client.forkSession( + sessionId, + a.turnId, + graph.graphRevision, + ); + + expect(childId).not.toBe(sessionId); + const sessions = await client.listSessions(); + const child = nullthrow(sessions.find((session) => session.sessionId === childId)); + expect(child).toMatchObject({ + kind: 'codex', + cwd: '/mock/repo', + status: 'idle', + forkOrigin: { sourceSessionId: sessionId, sourceTurnId: a.turnId }, + }); + const childGraph = await client.getConversationGraph(childId); + expect(childGraph.turns).toHaveLength(1); + const [copy] = childGraph.turns; + expect(copy.turnId).not.toBe(a.turnId); + expect(copy).toMatchObject({ parentTurnId: null, siblingOrdinal: 1, state: 'completed' }); + expect(childGraph.activeLeafTurnId).toBe(copy.turnId); + expect(userTexts((await client.readConversation(childId)).events)).toEqual(['$ a']); + // The source keeps both turns and its leaf; a replayed operation answers with the same child. + const source = await client.getConversationGraph(sessionId); + expect(source.turns).toHaveLength(2); + expect(source.activeLeafTurnId).toBe(b.turnId); + expect(source.graphRevision).toBe(graph.graphRevision); + client.dispose(); + }); + + it('refuses a fork through an unknown or stale turn and on a harness without forkAfterTurn', async () => { + const client = new LinkCodeClient(createDevMockTransport()); + await client.connect(); + const seeded = (await client.listSessions()).length; + const sessionId = await client.startSession({ kind: 'codex', cwd: '/mock/repo' }); + const a = await client.submitTurn(sessionId, { type: 'shell-command', command: 'a' }); + const graph = await client.getConversationGraph(sessionId); + + await expect( + client.forkSession(sessionId, 'turn-nope' as TurnId, graph.graphRevision), + ).rejects.toMatchObject({ code: 'not_found' }); + await expect( + client.forkSession(sessionId, a.turnId, graph.graphRevision - 1), + ).rejects.toMatchObject({ code: 'conflict' }); + + const opencode = await client.startSession({ kind: 'opencode', cwd: '/mock/repo' }); + const o = await client.submitTurn(opencode, { type: 'shell-command', command: 'o' }); + const opencodeGraph = await client.getConversationGraph(opencode); + await expect( + client.forkSession(opencode, o.turnId, opencodeGraph.graphRevision), + ).rejects.toMatchObject({ code: 'unsupported' }); + // Nothing forked: only the two sessions this test started joined the seeded list. + expect(await client.listSessions()).toHaveLength(seeded + 2); + client.dispose(); + }); + it('refuses to read toward a turn the session does not have', async () => { const client = new LinkCodeClient(createDevMockTransport()); await client.connect(); diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 7ce6ef325..cbbfbbc94 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -243,7 +243,7 @@ export const en = { showLess: 'Show less', goodResponse: 'Good response', badResponse: 'Bad response', - branch: 'Rewrite conversation', + forkFromHere: 'Fork a new thread from here', }, diffSummary: { title: 'Edited {count, plural, one {# file} other {# files}}', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 11c5546e6..b6a7e0ddb 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -239,7 +239,7 @@ export const zhCN = { showLess: '收起', goodResponse: '有帮助', badResponse: '没帮助', - branch: '重写对话', + forkFromHere: '从此处分叉新线程', }, diffSummary: { title: '编辑了 {count} 个文件', diff --git a/packages/presentation/ui/src/chat/__tests__/turn-actions.test.tsx b/packages/presentation/ui/src/chat/__tests__/turn-actions.test.tsx new file mode 100644 index 000000000..66b04ad6d --- /dev/null +++ b/packages/presentation/ui/src/chat/__tests__/turn-actions.test.tsx @@ -0,0 +1,34 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { AgentTurnActions } from '../turn-actions'; + +function translateKey(key: string): string { + return key; +} + +vi.mock('use-intl', () => ({ + useFormatter: () => ({ dateTime: () => '' }), + useTranslations: () => translateKey, +})); + +afterEach(cleanup); + +describe('AgentTurnActions fork action', () => { + it('forks through the turn when the runtime offers it', () => { + const onFork = vi.fn(); + render(); + + const button = screen.getByRole('button', { name: 'forkFromHere' }); + expect(button).toHaveProperty('disabled', false); + fireEvent.click(button); + expect(onFork).toHaveBeenCalledTimes(1); + }); + + it('stays disabled when the host or harness cannot fork sessions', () => { + render(); + + expect(screen.getByRole('button', { name: 'forkFromHere' })).toHaveProperty('disabled', true); + }); +}); diff --git a/packages/presentation/ui/src/chat/conversation-view.tsx b/packages/presentation/ui/src/chat/conversation-view.tsx index 79b9fabc4..c2212d074 100644 --- a/packages/presentation/ui/src/chat/conversation-view.tsx +++ b/packages/presentation/ui/src/chat/conversation-view.tsx @@ -38,6 +38,8 @@ export interface ConversationViewProps { versions?: ReadonlyMap; onSelectVersion?: (messageId: string, direction: -1 | 1) => void; rewritesViaGraph?: boolean; + /** Fork a new thread through the turn a user row opens (by its message id). */ + onForkTurn?: (messageId: string) => void; /** Opens this turn's workspace changes in the host review surface. */ onReviewChanges?: () => void; /** Opens the host-owned LinkCode billing surface for a typed gateway credit error. */ @@ -58,6 +60,7 @@ export function ConversationView({ versions, onSelectVersion, rewritesViaGraph, + onForkTurn, onReviewChanges, onOpenBilling, scrollContextRef, @@ -150,6 +153,7 @@ export function ConversationView({ versions={versions} onSelectVersion={onSelectVersion} rewritesViaGraph={rewritesViaGraph} + onForkTurn={onForkTurn} onExpandTask={setExpandedTaskId} onReviewChanges={onReviewChanges} onOpenBilling={onOpenBilling} diff --git a/packages/presentation/ui/src/chat/turn-actions.tsx b/packages/presentation/ui/src/chat/turn-actions.tsx index 785843311..54a17c089 100644 --- a/packages/presentation/ui/src/chat/turn-actions.tsx +++ b/packages/presentation/ui/src/chat/turn-actions.tsx @@ -13,6 +13,7 @@ export function AgentTurnActions({ receivedAt, agentKind, modelName, + onFork, }: { copyText: string; /** Best-known time of the turn's last event (see ConversationItem.receivedAt). */ @@ -20,6 +21,8 @@ export function AgentTurnActions({ agentKind?: AgentKind; /** The model that served this turn (message stamp, else the session's reported model). */ modelName?: string; + /** Fork a new thread through this turn; absent disables the action. */ + onFork?: () => void; }): React.ReactNode { const t = useTranslations('workbench.message'); const format = useFormatter(); @@ -38,8 +41,7 @@ export function AgentTurnActions({ - {/* TODO(branch): disabled until sessions support forking a conversation mid-way. */} - + diff --git a/packages/presentation/ui/src/chat/turn-segment-view.tsx b/packages/presentation/ui/src/chat/turn-segment-view.tsx index 7d2fe2457..4cde96824 100644 --- a/packages/presentation/ui/src/chat/turn-segment-view.tsx +++ b/packages/presentation/ui/src/chat/turn-segment-view.tsx @@ -49,6 +49,8 @@ export interface TurnSegmentViewProps { versions?: ReadonlyMap; onSelectVersion?: (messageId: string, direction: -1 | 1) => void; rewritesViaGraph?: boolean; + /** Fork a new thread through the turn a user row opens (by its message id). */ + onForkTurn?: (messageId: string) => void; /** Opens a subagent's full transcript in the conversation's viewer rail. */ onExpandTask: (toolCallId: string) => void; /** Opens this turn's workspace changes in the host review surface. */ @@ -79,6 +81,7 @@ export function TurnSegmentView({ versions, onSelectVersion, rewritesViaGraph, + onForkTurn, onExpandTask, onReviewChanges, onOpenBilling, @@ -97,6 +100,16 @@ export function TurnSegmentView({ : null; const agentEntries = leadingUserEntry ? entries.slice(1) : entries; const hasAgentTurnContent = agentEntries.length > 0 || edits || replyText; + // A fork copies the lineage through this turn, so the turn must be graph-known and completed — + // a failed or cancelled version has no provider cut to fork from. + const forkRowId = + leadingUserEntry !== null && versions?.get(leadingUserEntry.item.id)?.state === null + ? leadingUserEntry.item.id + : undefined; + const onFork = + onForkTurn !== undefined && ended && forkRowId !== undefined + ? () => onForkTurn(forkRowId) + : undefined; const renderEntry = (entry: TimelineEntry): React.ReactNode => { if (entry.type === 'run') { @@ -257,6 +270,7 @@ export function TurnSegmentView({ copyText={replyText} modelName={turnModel(segment.items) ?? modelName} receivedAt={latestReceivedAt(segment.items)} + onFork={onFork} /> ) : null} diff --git a/packages/presentation/ui/src/chat/types.ts b/packages/presentation/ui/src/chat/types.ts index fd812700c..8b7e48095 100644 --- a/packages/presentation/ui/src/chat/types.ts +++ b/packages/presentation/ui/src/chat/types.ts @@ -46,6 +46,9 @@ export interface ConversationLineage { promptEditState: PromptEditState; /** Edits submit through the turn graph, so a row the graph knows needs no legacy branch cursor. */ rewritesViaGraph: boolean; + /** Fork a new thread through the turn a user row opens (by its message id); absent when the + * host or the harness cannot fork sessions. */ + onForkTurn?: (messageId: string) => void; } /** diff --git a/packages/presentation/ui/src/shell/conversation-surface.tsx b/packages/presentation/ui/src/shell/conversation-surface.tsx index 4f28907db..4adf0375a 100644 --- a/packages/presentation/ui/src/shell/conversation-surface.tsx +++ b/packages/presentation/ui/src/shell/conversation-surface.tsx @@ -174,6 +174,7 @@ export function ConversationSurface({ versions={lineage?.versions} onSelectVersion={lineage?.onSelectVersion} rewritesViaGraph={lineage?.rewritesViaGraph} + onForkTurn={lineage?.onForkTurn} onReviewChanges={onReviewChanges} onOpenBilling={onOpenBilling} /> From 02d9c64b9329691ffed8e7cc537d02f8ba4c94c9 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Tue, 8 Sep 2026 14:27:30 +0800 Subject: [PATCH 07/10] fix(engine): render a forked session's copied prefix from its source history while the source exists --- .../src/__tests__/engine-session-fork.test.ts | 66 ++++++ .../src/conversation/lineage-attribution.ts | 2 +- .../src/conversation/projection-service.ts | 214 +++++++++++++----- 3 files changed, 225 insertions(+), 57 deletions(-) diff --git a/packages/host/engine/src/__tests__/engine-session-fork.test.ts b/packages/host/engine/src/__tests__/engine-session-fork.test.ts index 159550835..aa1748570 100644 --- a/packages/host/engine/src/__tests__/engine-session-fork.test.ts +++ b/packages/host/engine/src/__tests__/engine-session-fork.test.ts @@ -98,6 +98,42 @@ function cursorRow(itemId: string, text: string, branchCursor: string): AgentHis }; } +function assistantRow(itemId: string, text: string, ts?: number): AgentHistoryEvent { + return { + historyId: CHILD_HISTORY, + itemId, + ...(ts !== undefined && { ts }), + event: { + type: 'agent-message', + messageId: itemId as MessageId, + content: [{ type: 'text', text }], + }, + }; +} + +/** Assistant texts of a child read, in order; the row `ts` rides along for the re-stamp check. */ +async function readAssistantRows(h: Harness, sessionId: SessionId) { + const clientReqId = `read-${h.sent.length}`; + await h.inject({ kind: 'conversation.read', clientReqId, sessionId }); + await settleEngineTasks(); + const reply = h.sent.find( + (payload) => payload.kind === 'conversation.read.result' && payload.replyTo === clientReqId, + ); + if (reply?.kind !== 'conversation.read.result') throw new Error('no conversation.read.result'); + return reply.events.flatMap((item: ConversationReadItem) => + 'event' in item && item.event.type === 'agent-message' + ? [ + { + ts: item.ts, + text: (item.event.content ?? []) + .flatMap((b) => (b.type === 'text' ? [b.text] : [])) + .join(''), + }, + ] + : [], + ); +} + async function startedHarness(makeAdapter: () => FakeAdapter = () => new ForkingAdapter()) { const store = new InMemorySessionStore(); const conversationStore = new InMemoryConversationStore(); @@ -461,6 +497,36 @@ describe('session.fork saga', () => { }); }); + it('renders the copied prefix from the source history while the source exists, then from the copy', async () => { + // The provider's copy carries the same rows re-stamped at the cut (claude), so its `ts` and, + // here, its text differ from the source's original rows. + const corpora: Corpora = { + [SOURCE_HISTORY]: [ + cursorRow('s1', 'first', 'before-first'), + assistantRow('sa1', 'original answer', 1000), + cursorRow('s2', 'second', 'before-second'), + assistantRow('sa2', 'second answer', 2000), + ], + [CHILD_HISTORY]: [ + cursorRow('c1', 'first', 'child-before-first'), + assistantRow('ca1', 'copied answer', 9000), + ], + }; + const h = await startedHarness(() => new ForkingAdapter(corpora)); + const [firstTurnId] = await twoCheckpointedTurns(h); + await fork(h, 'f1', firstTurnId, 2); + await vi.waitFor(() => forkedSessionId(h.sent, 'f1')); + const childId = forkedSessionId(h.sent, 'f1'); + + expect(await readAssistantRows(h, childId)).toEqual([{ ts: 1000, text: 'original answer' }]); + // The copy is still attributed for its bindings: the re-binding gate saw one aligned row. + const [copy] = await h.conversationStore.listTurns(childId); + expect(copy.turnId).not.toBe(firstTurnId); + + await h.inject({ kind: 'session.delete', clientReqId: 'del', sessionId: h.sessionId }); + expect(await readAssistantRows(h, childId)).toEqual([{ ts: 9000, text: 'copied answer' }]); + }); + it.each([ ['a position mismatch', [cursorRow('c1', 'first', 'a'), cursorRow('c2', 'not second', 'b')]], ['a row count mismatch', [cursorRow('c1', 'first', 'a')]], diff --git a/packages/host/engine/src/conversation/lineage-attribution.ts b/packages/host/engine/src/conversation/lineage-attribution.ts index 04b02447c..f5595d415 100644 --- a/packages/host/engine/src/conversation/lineage-attribution.ts +++ b/packages/host/engine/src/conversation/lineage-attribution.ts @@ -53,7 +53,7 @@ export function settledWithProvider(path: readonly ConversationTurn[]): Conversa /** Root→leaf path through `parentTurnId`; a broken chain fails loud rather than rendering wrong. */ export function pathToLeaf( - byId: Map, + byId: ReadonlyMap, leafTurnId: TurnId | undefined, ): ConversationTurn[] { if (leafTurnId === undefined) return []; diff --git a/packages/host/engine/src/conversation/projection-service.ts b/packages/host/engine/src/conversation/projection-service.ts index 2526c287e..d37aaaf3c 100644 --- a/packages/host/engine/src/conversation/projection-service.ts +++ b/packages/host/engine/src/conversation/projection-service.ts @@ -27,7 +27,7 @@ import { RequestError } from '../failure'; import { encodeLiveBranchCursor } from '../session/live-session'; import type { SessionRecordRegistry } from '../session/session-record-registry'; import type { ConversationCheckpointService } from './checkpoint-service'; -import type { CorpusAttribution } from './lineage-attribution'; +import type { CorpusAttribution, ProviderPartition } from './lineage-attribution'; import { pathToLeaf } from './lineage-attribution'; import type { ConversationLiveJournals } from './live-journal'; import { inflightChunkKey } from './live-journal'; @@ -214,67 +214,27 @@ export class ConversationProjectionService { /** Host user rows for every path turn, provider assistant/tool events under the attribution * gate, placeholders where provider content is unavailable or unverifiable. Every turn reads the * history of the run that executed it — never a fork's copy of it (claude re-stamps the rows it - * copies), so two versions render the turns they share identically. */ + * copies), so two versions render the turns they share identically; a forked session's copied + * prefix likewise reads the source session's rows while that session exists. */ private composeDurable( record: SessionRecord, path: ConversationTurn[], sessionTurns: ConversationTurn[], activePath: ConversationTurn[], ): Effect.Effect { - const { checkpoints, records, turns } = this; + const { checkpoints, records } = this; + const readHistories = this.readHistories.bind(this); + const loadContents = this.loadContents.bind(this); + const copiedPrefixPartitions = this.copiedPrefixPartitions.bind(this); return Effect.gen(function* () { - // A provider history is one linear transcript, so the turns whose runs wrote to it, in - // creation order, are its user rows — the alignment the gate needs, whichever lineage reads. - const turnsByHistory = new Map(); - const ordered = [...sessionTurns].sort(byCreation); - for (let i = 0, len = ordered.length; i < len; i++) { - const historyId = runHistoryId(record, ordered[i].runId); - if (historyId === undefined) continue; - const group = turnsByHistory.get(historyId); - if (group) group.push(ordered[i]); - else turnsByHistory.set(historyId, [ordered[i]]); - } + const contents = new Map(); const touched = new Set(); - const needed = new Map(); for (let i = 0, len = path.length; i < len; i++) { - needed.set(path[i].turnId, path[i]); const historyId = runHistoryId(record, path[i].runId); if (historyId !== undefined) touched.add(historyId); } - for (const historyId of touched) { - const group = turnsByHistory.get(historyId) ?? []; - for (let i = 0, len = group.length; i < len; i++) needed.set(group[i].turnId, group[i]); - } - const neededTurns = [...needed.values()]; - const loaded = yield* Effect.forEach(neededTurns, (turn) => turns.hostUserContent(turn)); - const contentOf = new Map(); - for (let i = 0, len = neededTurns.length; i < len; i++) { - contentOf.set(neededTurns[i].turnId, loaded[i]); - } - const contentsOf = (lineage: readonly ConversationTurn[]) => - lineage.map((turn) => contentOf.get(turn.turnId)); - - // Reading a corpus also backfills replay bindings on it. - const reads = new Map(); - for (const historyId of touched) { - const hostTurns = chainOrder(turnsByHistory.get(historyId) ?? []); - const attribution = yield* checkpoints.attributeLineage( - record, - hostTurns, - contentsOf(hostTurns), - historyId, - ); - if (attribution === undefined) continue; - const partition = new Map(); - const failed = new Map(); - for (let i = 0, len = hostTurns.length; i < len; i++) { - const turn = hostTurns[i]; - if (!TERMINAL_TURN_STATES.has(turn.state)) continue; - if (turn.state === 'failed') failed.set(turn.turnId, failed.size); - else partition.set(turn.turnId, partition.size); - } - reads.set(historyId, { attribution, partition, failed }); - } + const reads = yield* readHistories(record, sessionTurns, touched, contents); + const pathContents = yield* loadContents(path, contents); // A fork's copy is still where a later fork after a copied turn cuts once that turn's own // history is gone, so the active lineage also backfills its prefix's bindings on the live // history. Rendering never reads this pass. @@ -284,8 +244,14 @@ export class ConversationProjectionService { liveHistoryId !== undefined && path.some((turn) => runHistoryId(record, turn.runId) !== liveHistoryId) ) { - yield* checkpoints.attributeLineage(record, path, contentsOf(path), liveHistoryId); + yield* checkpoints.attributeLineage(record, path, pathContents, liveHistoryId); } + const copied = yield* copiedPrefixPartitions( + record, + path, + new Map(sessionTurns.map((turn) => [turn.turnId, turn])), + new Set([record.sessionId]), + ); const items: ConversationReadItem[] = []; // Rows ahead of the first user row are pre-graph history: they belong to the root's own @@ -301,16 +267,16 @@ export class ConversationProjectionService { } for (let i = 0, len = path.length; i < len; i++) { const turn = path[i]; - const content = contentOf.get(turn.turnId); + const content = pathContents[i]; if (content !== undefined) { items.push(projectedUserRow(turn, content, runHistoryId(record, turn.runId))); } if (!TERMINAL_TURN_STATES.has(turn.state)) continue; // in-flight output rides the live tail - const historyId = runHistoryId(record, turn.runId); - const read = historyId === undefined ? undefined : reads.get(historyId); if (turn.state === 'failed') { // The state badge is the story; whatever the provider kept of the attempt renders under // it, and a turn that left nothing gets no placeholder — nothing durable ran. + const historyId = runHistoryId(record, turn.runId); + const read = historyId === undefined ? undefined : reads.get(historyId); const index = read?.failed.get(turn.turnId); const partial = index === undefined ? undefined : read?.attribution.failed[index]; if (partial !== undefined) { @@ -320,8 +286,7 @@ export class ConversationProjectionService { } continue; } - const index = read?.partition.get(turn.turnId); - const partition = index === undefined ? undefined : read?.attribution.attributed[index]; + const partition = copied.get(turn.turnId) ?? readPartition(reads, record, turn); if (partition !== undefined) { for (let j = 0, restLen = partition.rest.length; j < restLen; j++) { items.push(projectedItem(turn, partition.rest[j])); @@ -334,6 +299,131 @@ export class ConversationProjectionService { }); } + /** Host user-row content per turn of `lineage`, loaded once per read through `cache`. */ + private loadContents( + lineage: readonly ConversationTurn[], + cache: Map, + ): Effect.Effect, OperationError> { + const { turns } = this; + return Effect.gen(function* () { + const missing = lineage.filter((turn) => !cache.has(turn.turnId)); + const loaded = yield* Effect.forEach(missing, (turn) => turns.hostUserContent(turn)); + for (let i = 0, len = missing.length; i < len; i++) cache.set(missing[i].turnId, loaded[i]); + return lineage.map((turn) => cache.get(turn.turnId)); + }); + } + + /** + * Attribute each history in `historyIds` against the session's turns whose runs wrote to it. A + * provider history is one linear transcript, so those turns in chain order are its user rows — + * the alignment the gate needs, whichever lineage reads. Reading a corpus also backfills replay + * bindings on it. + */ + private readHistories( + record: SessionRecord, + sessionTurns: readonly ConversationTurn[], + historyIds: ReadonlySet, + cache: Map, + ): Effect.Effect, OperationError> { + const { checkpoints } = this; + const loadContents = this.loadContents.bind(this); + return Effect.gen(function* () { + const turnsByHistory = new Map(); + const ordered = [...sessionTurns].sort(byCreation); + for (let i = 0, len = ordered.length; i < len; i++) { + const historyId = runHistoryId(record, ordered[i].runId); + if (historyId === undefined) continue; + const group = turnsByHistory.get(historyId); + if (group) group.push(ordered[i]); + else turnsByHistory.set(historyId, [ordered[i]]); + } + const reads = new Map(); + for (const historyId of historyIds) { + const hostTurns = chainOrder(turnsByHistory.get(historyId) ?? []); + const contents = yield* loadContents(hostTurns, cache); + const attribution = yield* checkpoints.attributeLineage( + record, + hostTurns, + contents, + historyId, + ); + if (attribution === undefined) continue; + const partition = new Map(); + const failed = new Map(); + for (let i = 0, len = hostTurns.length; i < len; i++) { + const turn = hostTurns[i]; + if (!TERMINAL_TURN_STATES.has(turn.state)) continue; + if (turn.state === 'failed') failed.set(turn.turnId, failed.size); + else partition.set(turn.turnId, partition.size); + } + reads.set(historyId, { attribution, partition, failed }); + } + return reads; + }); + } + + /** + * The provider rows a forked session's copied prefix renders from: the source session's own, + * position by position along the lineage it copied, while that session exists. The provider's + * copy is lossy (claude re-stamps the row it cut at), and the source rows are what every other + * view of that lineage renders. A source that is itself a fork defers to its own source the same + * way; a deleted source leaves the copy as the only source there is. + */ + private copiedPrefixPartitions( + record: SessionRecord, + path: readonly ConversationTurn[], + byId: ReadonlyMap, + visited: ReadonlySet, + ): Effect.Effect, OperationError> { + const partitions = new Map(); + const origin = record.forkOrigin; + const copiedLeaf = record.runs[0]?.baseTurnId; + const source = origin === undefined ? undefined : this.records.get(origin.sourceSessionId); + if ( + origin === undefined || + source === undefined || + copiedLeaf === undefined || + visited.has(source.sessionId) + ) { + return Effect.succeed(partitions); + } + const { turns } = this; + const readHistories = this.readHistories.bind(this); + const copiedPrefixPartitions = this.copiedPrefixPartitions.bind(this); + return Effect.gen(function* () { + // Position i of the copied lineage is position i of the source lineage it was copied from; + // the read path shares that lineage only up to its first turn of the session's own. + const copiedPath = pathToLeaf(byId, copiedLeaf); + const sourceTurns = yield* turns.listTurns(source.sessionId); + const sourceById = new Map(sourceTurns.map((turn) => [turn.turnId, turn])); + const sourcePath = pathToLeaf(sourceById, origin.sourceTurnId); + const limit = Math.min(path.length, copiedPath.length, sourcePath.length); + let shared = 0; + while (shared < limit && path[shared].turnId === copiedPath[shared].turnId) shared += 1; + if (shared === 0) return partitions; + const sourcePrefix = sourcePath.slice(0, shared); + const histories = new Set(); + for (let i = 0; i < shared; i++) { + const historyId = runHistoryId(source, sourcePrefix[i].runId); + if (historyId !== undefined) histories.add(historyId); + } + const reads = yield* readHistories(source, sourceTurns, histories, new Map()); + const inherited = yield* copiedPrefixPartitions( + source, + sourcePrefix, + sourceById, + new Set([...visited, source.sessionId]), + ); + for (let i = 0; i < shared; i++) { + const sourceTurn = sourcePrefix[i]; + const partition = + inherited.get(sourceTurn.turnId) ?? readPartition(reads, source, sourceTurn); + if (partition !== undefined) partitions.set(path[i].turnId, partition); + } + return partitions; + }); + } + /** The live tail: retained journal events above the last event attributed to a settled path * turn, minus user echoes (host rows own user display) and headless chunk streams, plus the * authoritative open interactive requests. The journal is the active run's, so a lineage that @@ -568,6 +658,18 @@ function runHistoryId(record: SessionRecord, runId: RunId): AgentHistoryId | und return record.runs.find((run) => run.runId === runId)?.historyId; } +/** The provider partition a settled turn renders from, off the read of its own run's history. */ +function readPartition( + reads: ReadonlyMap, + record: SessionRecord, + turn: ConversationTurn, +): ProviderPartition | undefined { + const historyId = runHistoryId(record, turn.runId); + const read = historyId === undefined ? undefined : reads.get(historyId); + const index = read?.partition.get(turn.turnId); + return index === undefined ? undefined : read?.attribution.attributed[index]; +} + /** The turns that ran on one history in transcript order: the chain through `parentTurnId` from * the turn whose parent ran elsewhere. Creation order (the input) stands when they form no chain. */ function chainOrder(group: ConversationTurn[]): ConversationTurn[] { From f69c2d00b3ace3170fc2d5a968f565a87f9f49b0 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Tue, 8 Sep 2026 16:01:01 +0800 Subject: [PATCH 08/10] fix(engine): resolve a fork child under its own id, allow its hidden prefix, and keep it out of notifications until it commits --- .../foundation/schema/src/wire/session.ts | 2 + .../agent-adapter/src/native/claude-code.ts | 3 + .../src/__tests__/conversation-store.test.ts | 32 ++++ .../src/__tests__/engine-session-fork.test.ts | 156 +++++++++++++++++- .../src/conversation/conversation-store.ts | 7 + .../src/conversation/lineage-attribution.ts | 3 + .../src/conversation/projection-service.ts | 36 ++-- .../engine/src/conversation/turn-service.ts | 11 +- .../host/engine/src/session/fork-service.ts | 33 ++-- .../engine/src/session/lifecycle-service.ts | 8 +- .../engine/src/session/request-handler.ts | 3 + .../src/session/session-event-processor.ts | 3 +- 12 files changed, 263 insertions(+), 34 deletions(-) diff --git a/packages/foundation/schema/src/wire/session.ts b/packages/foundation/schema/src/wire/session.ts index 2d978c73b..b05ae3f8e 100644 --- a/packages/foundation/schema/src/wire/session.ts +++ b/packages/foundation/schema/src/wire/session.ts @@ -105,6 +105,8 @@ export const sessionWireVariants = [ kind: z.literal('session.forked'), replyTo: WireRequestIdSchema, sessionId: SessionIdSchema, + /** The child start's custom-MCP advisories, as `session.started` carries them. */ + mcpWarnings: z.array(McpWarningSchema).optional(), }), /** Broadcast when the persisted list changes membership or identity, so a client holding a stale * snapshot knows to revalidate. Deliberately carries no record: `session.listed` stays the one diff --git a/packages/host/agent-adapter/src/native/claude-code.ts b/packages/host/agent-adapter/src/native/claude-code.ts index d6211f7c0..dc9f5742e 100644 --- a/packages/host/agent-adapter/src/native/claude-code.ts +++ b/packages/host/agent-adapter/src/native/claude-code.ts @@ -694,6 +694,9 @@ export class ClaudeCodeAdapter extends BaseAgentAdapter { }); await this.copySubagentTranscripts(opts.historyId, fork.sessionId); this.resumeFrom = fork.sessionId; + // A query rebuild re-arms `resumeFrom` from here; without it a child that dies before its + // first init would silently start a new conversation instead of resuming the copy. + this.lastSessionRef = fork.sessionId; // The child transcript exists now, so a fork that dispatches no prompt (a session fork) can // still read its own history; the first query's init announces the same id and dedupes. this.emitSessionRef(asHistoryId(fork.sessionId)); diff --git a/packages/host/engine/src/__tests__/conversation-store.test.ts b/packages/host/engine/src/__tests__/conversation-store.test.ts index a5394ca0d..df439f829 100644 --- a/packages/host/engine/src/__tests__/conversation-store.test.ts +++ b/packages/host/engine/src/__tests__/conversation-store.test.ts @@ -370,4 +370,36 @@ describe('InMemoryConversationStore', () => { await store.deleteSession(SessionIdSchema.parse('s-source')); expect(await store.getPrompt(shared.promptId)).toEqual(shared); }); + + it('commitFork refuses a copied turn whose prompt is gone, like the SQLite foreign key', async () => { + const store = new InMemoryConversationStore(); + const fork = openFork('op-fork', 's-other'); + await store.persistOperation(fork); + const orphan = turn({ + turnId: 't-orphan', + sessionId: 's-child', + promptId: 'p-gone', + state: 'completed', + }); + + await expect( + store.commitFork({ + child: { + sessionId: SessionIdSchema.parse('s-child'), + kind: 'claude-code', + cwd: '/repo', + origin: { type: 'created' }, + createdAt: 3, + updatedAt: 3, + runs: [], + graphRevision: 0, + eventEpoch: 0, + }, + turns: [orphan], + operation: { ...fork, state: 'succeeded', turnId: orphan.turnId, resolvedAt: 4 }, + }), + ).rejects.toThrow('no longer exists'); + expect(await store.getOperation(fork.operationId)).toEqual(fork); + expect(await store.getTurn(orphan.turnId)).toBeUndefined(); + }); }); diff --git a/packages/host/engine/src/__tests__/engine-session-fork.test.ts b/packages/host/engine/src/__tests__/engine-session-fork.test.ts index aa1748570..58ff47493 100644 --- a/packages/host/engine/src/__tests__/engine-session-fork.test.ts +++ b/packages/host/engine/src/__tests__/engine-session-fork.test.ts @@ -17,7 +17,9 @@ import { nullthrow } from 'foxts/guard'; import { noop } from 'foxts/noop'; import { describe, expect, it, vi } from 'vitest'; import { InMemoryConversationStore } from '../conversation/conversation-store'; +import type { EngineDeps } from '../deps'; import { InMemorySessionStore } from '../session/session-store'; +import type { SimulatorMcpProvider } from '../simulator/mcp'; import { FakeAdapter, createSessionHarness as harness, @@ -75,6 +77,19 @@ class GatedForkAdapter extends ForkingAdapter { } } +/** The provider fork reports something on the way (claude: subagent transcripts not copied). */ +class NoisyForkAdapter extends ForkingAdapter { + override branchHistory(opts: AgentHistoryBranchOptions, startOpts: StartOptions): Promise { + this.emit({ + type: 'error', + message: 'subagent transcripts were not copied', + code: 'fork_subagents_not_copied', + recoverable: true, + }); + return super.branchHistory(opts, startOpts); + } +} + class LegacyBranchOnlyAdapter extends ForkingAdapter { override readonly historyCapabilities: AgentHistoryCapabilities = { list: false, @@ -134,11 +149,15 @@ async function readAssistantRows(h: Harness, sessionId: SessionId) { ); } -async function startedHarness(makeAdapter: () => FakeAdapter = () => new ForkingAdapter()) { +async function startedHarness( + makeAdapter: () => FakeAdapter = () => new ForkingAdapter(), + extraDeps: EngineDeps = {}, +) { const store = new InMemorySessionStore(); const conversationStore = new InMemoryConversationStore(); const h = harness(store, makeAdapter, undefined, undefined, undefined, undefined, { conversationStore, + ...extraDeps, }); await h.engine.start(); await h.inject({ @@ -178,17 +197,20 @@ function submittedTurnId(sent: WirePayload[], replyTo: string): TurnId { } /** Two settled turns on the source history, each with a live `ending` checkpoint. */ -async function twoCheckpointedTurns(h: Harness): Promise<[TurnId, TurnId]> { +async function twoCheckpointedTurns( + h: Harness, + adapter: FakeAdapter = h.adapter, +): Promise<[TurnId, TurnId]> { await submitPrompt(h, 's1', 'first'); const firstTurnId = submittedTurnId(h.sent, 's1'); - h.adapter.emit({ type: 'session-ref', historyId: SOURCE_HISTORY }); - h.adapter.emitCheckpoint({ historyId: SOURCE_HISTORY, cursor: 'cp-1', turn: 'ending' }); - h.adapter.emit({ type: 'status', status: 'idle' }); + adapter.emit({ type: 'session-ref', historyId: SOURCE_HISTORY }); + adapter.emitCheckpoint({ historyId: SOURCE_HISTORY, cursor: 'cp-1', turn: 'ending' }); + adapter.emit({ type: 'status', status: 'idle' }); await settleEngineTasks(); await submitPrompt(h, 's2', 'second'); const secondTurnId = submittedTurnId(h.sent, 's2'); - h.adapter.emitCheckpoint({ historyId: SOURCE_HISTORY, cursor: 'cp-2', turn: 'ending' }); - h.adapter.emit({ type: 'status', status: 'idle' }); + adapter.emitCheckpoint({ historyId: SOURCE_HISTORY, cursor: 'cp-2', turn: 'ending' }); + adapter.emit({ type: 'status', status: 'idle' }); await settleEngineTasks(); return [firstTurnId, secondTurnId]; } @@ -333,6 +355,123 @@ describe('session.fork saga', () => { }); }); + it('resolves the child start options under the child session id', async () => { + const endpointsFor: SessionId[] = []; + const simulatorMcp: SimulatorMcpProvider = { + endpointFor(sessionId) { + endpointsFor.push(sessionId); + return { type: 'http', name: 'linkcode-sim', url: `http://127.0.0.1:1/mcp/${sessionId}` }; + }, + release: noop, + }; + const h = await startedHarness(() => new ForkingAdapter(), { simulatorMcp }); + const [firstTurnId] = await twoCheckpointedTurns(h); + + await fork(h, 'f1', firstTurnId, 2); + await vi.waitFor(() => forkedSessionId(h.sent, 'f1')); + const childId = forkedSessionId(h.sent, 'f1'); + + // The source's pins carry over, but per-session resources are the child's own. + expect(endpointsFor.at(-1)).toBe(childId); + expect(forkedAdapter(h.adapters).startedWith?.mcpServers).toContainEqual( + expect.objectContaining({ url: `http://127.0.0.1:1/mcp/${childId}` }), + ); + }); + + it('keeps a provisional child out of session notifications', async () => { + const h = await startedHarness(() => new NoisyForkAdapter()); + const [firstTurnId] = await twoCheckpointedTurns(h); + + await fork(h, 'f1', firstTurnId, 2); + await vi.waitFor(() => forkedSessionId(h.sent, 'f1')); + const childId = forkedSessionId(h.sent, 'f1'); + + expect( + h.sent.some( + (payload) => + payload.kind === 'session.notification' && payload.notification.sessionId === childId, + ), + ).toBe(false); + }); + + it('a source deleted mid-fork fails the fork typed and leaves no child behind', async () => { + const h = await startedHarness(() => new GatedForkAdapter()); + const [firstTurnId] = await twoCheckpointedTurns(h); + await fork(h, 'f1', firstTurnId, 2); + const gated = await vi.waitFor(() => + nullthrow( + h.adapters.find( + (adapter): adapter is GatedForkAdapter => + adapter instanceof GatedForkAdapter && adapter.branchedFrom !== null, + ), + ), + ); + + await h.inject({ kind: 'session.delete', clientReqId: 'del', sessionId: h.sessionId }); + expect(h.sent).toContainEqual({ kind: 'request.succeeded', replyTo: 'del' }); + gated.release(); + await vi.waitFor(() => failure(h.sent, 'f1')); + + expect(failure(h.sent, 'f1')).toMatchObject({ + code: 'not_found', + message: 'The source session was deleted', + }); + expect(await h.store.load()).toEqual([]); + expect(gated.stopped).toBe(true); + expect(await h.conversationStore.listOpenOperations()).toEqual([]); + }); + + it('re-binds and renders a child of a pre-graph source, whose copy carries the hidden rows', async () => { + const corpora: Corpora = { + [SOURCE_HISTORY]: [ + cursorRow('e', 'earlier', 'before-earlier'), + assistantRow('ea', 'earlier answer'), + cursorRow('s1', 'first', 'before-first'), + assistantRow('sa1', 'original first'), + cursorRow('s2', 'second', 'before-second'), + assistantRow('sa2', 'original second'), + ], + [CHILD_HISTORY]: [ + cursorRow('c0', 'earlier', 'child-before-earlier'), + assistantRow('ca0', 'copied earlier'), + cursorRow('c1', 'first', 'child-before-first'), + assistantRow('ca1', 'copied first'), + cursorRow('c2', 'second', 'child-before-second'), + assistantRow('ca2', 'copied second'), + ], + }; + const h = await startedHarness(() => new ForkingAdapter(corpora)); + // Provider history before any turn row: the source's first turn sits on a resume run. + h.adapter.emit({ type: 'session-ref', historyId: SOURCE_HISTORY }); + await h.inject({ kind: 'session.stop', clientReqId: 'stop', sessionId: h.sessionId }); + await h.inject({ kind: 'session.resume', clientReqId: 'resume', sessionId: h.sessionId }); + await vi.waitFor(() => startedId(h.sent, 'resume')); + const resumed = nullthrow(h.adapters.find((adapter) => adapter.resumedFrom === SOURCE_HISTORY)); + const [, secondTurnId] = await twoCheckpointedTurns(h, resumed); + + await fork(h, 'f1', secondTurnId, 2); + await vi.waitFor(() => forkedSessionId(h.sent, 'f1')); + const childId = forkedSessionId(h.sent, 'f1'); + const [copy1, copy2] = await h.conversationStore.listTurns(childId); + + const texts = async () => (await readAssistantRows(h, childId)).map((row) => row.text); + // The hidden row leads the read as pre-graph history, from the source like the rest. + expect(await texts()).toEqual(['earlier answer', 'original first', 'original second']); + // The copy has three user rows for two copied turns; the extra one is the hidden prefix, so + // the end-anchored alignment still re-binds the prefix. + expect(await h.conversationStore.listBindings(copy1.turnId)).toEqual([ + expect.objectContaining({ + historyId: CHILD_HISTORY, + checkpoint: 'child-before-second', + capturedFrom: 'replay', + }), + ]); + expect(await h.conversationStore.listBindings(copy2.turnId)).toEqual([]); + + await h.inject({ kind: 'session.delete', clientReqId: 'del', sessionId: h.sessionId }); + expect(await texts()).toEqual(['copied earlier', 'copied first', 'copied second']); + }); + it('replays a lost reply with the same forked session instead of forking twice', async () => { const h = await startedHarness(); const [firstTurnId] = await twoCheckpointedTurns(h); @@ -523,6 +662,9 @@ describe('session.fork saga', () => { const [copy] = await h.conversationStore.listTurns(childId); expect(copy.turnId).not.toBe(firstTurnId); + // A source mid-delete (turns purged, record still registered) has no lineage to read. + await h.conversationStore.deleteSession(h.sessionId); + expect(await readAssistantRows(h, childId)).toEqual([{ ts: 9000, text: 'copied answer' }]); await h.inject({ kind: 'session.delete', clientReqId: 'del', sessionId: h.sessionId }); expect(await readAssistantRows(h, childId)).toEqual([{ ts: 9000, text: 'copied answer' }]); }); diff --git a/packages/host/engine/src/conversation/conversation-store.ts b/packages/host/engine/src/conversation/conversation-store.ts index af07d5601..4e73e8c91 100644 --- a/packages/host/engine/src/conversation/conversation-store.ts +++ b/packages/host/engine/src/conversation/conversation-store.ts @@ -226,6 +226,13 @@ export class InMemoryConversationStore implements ConversationStore { if (this.operations.get(operation.operationId)?.state !== 'open') { return Promise.resolve(false); } + // The SQLite store's prompt foreign key: a source deleted meanwhile took its prompts along. + for (let i = 0, len = commit.turns.length; i < len; i++) { + const { input } = commit.turns[i]; + if (input.type === 'prompt' && input.promptId !== null && !this.prompts.has(input.promptId)) { + return Promise.reject(new Error(`Prompt no longer exists: ${input.promptId}`)); + } + } this.operations.set(operation.operationId, structuredClone(operation)); for (let i = 0, len = commit.turns.length; i < len; i++) { const turn = commit.turns[i]; diff --git a/packages/host/engine/src/conversation/lineage-attribution.ts b/packages/host/engine/src/conversation/lineage-attribution.ts index f5595d415..0f56ef43c 100644 --- a/packages/host/engine/src/conversation/lineage-attribution.ts +++ b/packages/host/engine/src/conversation/lineage-attribution.ts @@ -41,6 +41,9 @@ export function hasHiddenPrefix(record: SessionRecord, root: ConversationTurn): const index = record.runs.findIndex((run) => run.runId === root.runId); // A root whose run cannot be placed (a pre-runId record) takes the safe direction. if (index < 0) return true; + // A forked session's copy carries whatever preceded the source's own root: the provider copies + // rows, not the graph, so the copied root may sit behind the source's hidden history. + if (index === 0 && record.forkOrigin !== undefined) return true; return record.runs .slice(0, index) .some((run) => run.historyId !== undefined && run.abandonedAt === undefined); diff --git a/packages/host/engine/src/conversation/projection-service.ts b/packages/host/engine/src/conversation/projection-service.ts index d37aaaf3c..e3e38c24a 100644 --- a/packages/host/engine/src/conversation/projection-service.ts +++ b/packages/host/engine/src/conversation/projection-service.ts @@ -256,11 +256,11 @@ export class ConversationProjectionService { const items: ConversationReadItem[] = []; // Rows ahead of the first user row are pre-graph history: they belong to the root's own // history alone — a fork child's leading rows are its copy of the prefix, rendered from the - // source above. + // source above; a forked session's come from the source session it copied them from. const rootHistoryId = path.length === 0 ? undefined : runHistoryId(record, path[0].runId); const rootRead = rootHistoryId === undefined ? undefined : reads.get(rootHistoryId); - if (rootRead !== undefined) { - const { leading } = rootRead.attribution; + const leading = copied.leading ?? rootRead?.attribution.leading; + if (leading !== undefined) { for (let i = 0, len = leading.length; i < len; i++) { items.push(projectedItem(undefined, leading[i])); } @@ -286,7 +286,7 @@ export class ConversationProjectionService { } continue; } - const partition = copied.get(turn.turnId) ?? readPartition(reads, record, turn); + const partition = copied.partitions.get(turn.turnId) ?? readPartition(reads, record, turn); if (partition !== undefined) { for (let j = 0, restLen = partition.rest.length; j < restLen; j++) { items.push(projectedItem(turn, partition.rest[j])); @@ -367,14 +367,15 @@ export class ConversationProjectionService { * position by position along the lineage it copied, while that session exists. The provider's * copy is lossy (claude re-stamps the row it cut at), and the source rows are what every other * view of that lineage renders. A source that is itself a fork defers to its own source the same - * way; a deleted source leaves the copy as the only source there is. + * way; a deleted source leaves the copy as the only source there is. Each level costs the + * source's turn list and one provider read per touched history (TTL-cached like any read). */ private copiedPrefixPartitions( record: SessionRecord, path: readonly ConversationTurn[], byId: ReadonlyMap, visited: ReadonlySet, - ): Effect.Effect, OperationError> { + ): Effect.Effect { const partitions = new Map(); const origin = record.forkOrigin; const copiedLeaf = record.runs[0]?.baseTurnId; @@ -385,7 +386,7 @@ export class ConversationProjectionService { copiedLeaf === undefined || visited.has(source.sessionId) ) { - return Effect.succeed(partitions); + return Effect.succeed({ partitions }); } const { turns } = this; const readHistories = this.readHistories.bind(this); @@ -396,11 +397,14 @@ export class ConversationProjectionService { const copiedPath = pathToLeaf(byId, copiedLeaf); const sourceTurns = yield* turns.listTurns(source.sessionId); const sourceById = new Map(sourceTurns.map((turn) => [turn.turnId, turn])); + // A source mid-delete (turns purged, record still registered) or half-deleted has no + // lineage to read: the copy is what there is. + if (!sourceById.has(origin.sourceTurnId)) return { partitions }; const sourcePath = pathToLeaf(sourceById, origin.sourceTurnId); const limit = Math.min(path.length, copiedPath.length, sourcePath.length); let shared = 0; while (shared < limit && path[shared].turnId === copiedPath[shared].turnId) shared += 1; - if (shared === 0) return partitions; + if (shared === 0) return { partitions }; const sourcePrefix = sourcePath.slice(0, shared); const histories = new Set(); for (let i = 0; i < shared; i++) { @@ -417,10 +421,15 @@ export class ConversationProjectionService { for (let i = 0; i < shared; i++) { const sourceTurn = sourcePrefix[i]; const partition = - inherited.get(sourceTurn.turnId) ?? readPartition(reads, source, sourceTurn); + inherited.partitions.get(sourceTurn.turnId) ?? readPartition(reads, source, sourceTurn); if (partition !== undefined) partitions.set(path[i].turnId, partition); } - return partitions; + // The copied root's leading rows (hidden pre-graph history) are the source's too. + const rootHistoryId = runHistoryId(source, sourcePrefix[0].runId); + const leading = + inherited.leading ?? + (rootHistoryId === undefined ? undefined : reads.get(rootHistoryId)?.attribution.leading); + return { partitions, leading }; }); } @@ -658,6 +667,13 @@ function runHistoryId(record: SessionRecord, runId: RunId): AgentHistoryId | und return record.runs.find((run) => run.runId === runId)?.historyId; } +/** What a forked session's copied prefix renders from: the source rows per copied turn, and the + * source root's leading rows; both absent when the copy is all there is. */ +interface CopiedPrefix { + readonly partitions: ReadonlyMap; + readonly leading?: CorpusAttribution['leading']; +} + /** The provider partition a settled turn renders from, off the read of its own run's history. */ function readPartition( reads: ReadonlyMap, diff --git a/packages/host/engine/src/conversation/turn-service.ts b/packages/host/engine/src/conversation/turn-service.ts index 6e233a06d..2fee6b5bd 100644 --- a/packages/host/engine/src/conversation/turn-service.ts +++ b/packages/host/engine/src/conversation/turn-service.ts @@ -302,7 +302,8 @@ export class ConversationTurnService { } /** Store a turn-less operation's failure. The first terminal writer stands: a loser gets the - * stored failure back, so the reply never differs from what a retry replays. */ + * stored failure back, so the reply never differs from what a retry replays. An operation row + * that vanished went with its session — the only thing that deletes one. */ failOperation( operation: OpenOperation, error: TurnFailure, @@ -320,7 +321,13 @@ export class ConversationTurnService { if (transitioned) return Effect.succeed(error); return this.getOperation(operation.operationId).pipe( Effect.flatMap((stored) => { - if (stored === undefined || stored.state === 'open') { + if (stored === undefined) { + return Effect.succeed({ + code: 'not_found', + message: 'The source session was deleted', + }); + } + if (stored.state === 'open') { return Effect.fail( new OperationError({ subsystem: 'store', diff --git a/packages/host/engine/src/session/fork-service.ts b/packages/host/engine/src/session/fork-service.ts index 075d23da5..1247fc74d 100644 --- a/packages/host/engine/src/session/fork-service.ts +++ b/packages/host/engine/src/session/fork-service.ts @@ -1,6 +1,7 @@ import type { ConversationOperation, ConversationTurn, + McpWarning, OperationId, SessionId, SessionRecord, @@ -35,9 +36,14 @@ export interface SessionForkRequest { readonly expectedGraphRevision: number; } -/** The reply a fork resolves to: the forked session, or the stored failure a retry replays. */ +/** The reply a fork resolves to: the forked session (with the child start's custom-MCP + * advisories, which only this reply can carry), or the stored failure a retry replays. */ export type SessionForkResult = - | { readonly state: 'succeeded'; readonly sessionId: SessionId } + | { + readonly state: 'succeeded'; + readonly sessionId: SessionId; + readonly mcpWarnings: readonly McpWarning[]; + } | { readonly state: 'failed'; readonly error: TurnFailure }; type OpenForkOperation = Extract & { @@ -94,20 +100,20 @@ export class SessionForkService { ); } if (existing.state === 'failed') return { state: 'failed', error: existing.error }; - // A succeeded fork names the child's copied leaf; that turn's session is the child. + // A succeeded fork names the child's copied leaf; that turn's session is the child. The + // start's advisories were delivered once, on the reply that started it. const leaf = yield* turns.getTurn(existing.turnId); if (leaf === undefined) { return yield* Effect.fail( new RequestError({ code: 'not_found', message: 'The forked session no longer exists' }), ); } - return { state: 'succeeded', sessionId: leaf.sessionId }; + return { state: 'succeeded', sessionId: leaf.sessionId, mcpWarnings: [] }; } const admitted = yield* admit(request); return yield* launchChild(admitted).pipe( Effect.matchEffect({ - onSuccess: (sessionId) => - Effect.succeed({ state: 'succeeded', sessionId }), + onSuccess: (child) => Effect.succeed({ state: 'succeeded', ...child }), // Any post-admit failure resolves the operation; a retry replays this stored error. onFailure: (error) => turns @@ -242,15 +248,22 @@ export class SessionForkService { * success. Every failure exit before that tears the child down; the orphaned provider history is * logged, never entered. */ - private launchChild(admitted: AdmittedFork): Effect.Effect { + private launchChild( + admitted: AdmittedFork, + ): Effect.Effect< + { readonly sessionId: SessionId; readonly mcpWarnings: readonly McpWarning[] }, + EngineFailure + > { const { history, lifecycle, records, sessions, turns } = this; const abandon = this.abandon.bind(this); return Effect.gen(function* () { const { source, through, path, cut, operation } = admitted; - const resolved = yield* lifecycle.resolveForRecord(source); + const childId = lifecycle.nextSessionId(); + // The source's pins, resolved for the child: per-session resources such as the simulator + // MCP endpoint token must belong to the child, or its tools act as the source's. + const resolved = yield* lifecycle.resolveForRecord(source, undefined, childId); const now = Date.now(); const runId = mintRunId(); - const childId = lifecycle.nextSessionId(); const copies: ConversationTurn[] = []; let parentTurnId: TurnId | null = null; for (let i = 0, len = path.length; i < len; i++) { @@ -352,7 +365,7 @@ export class SessionForkService { Exit.isFailure(exit) && records.isProvisional(childId) ? abandon(child) : Effect.void, ), ); - return childId; + return { sessionId: childId, mcpWarnings: resolved.warnings }; }); } diff --git a/packages/host/engine/src/session/lifecycle-service.ts b/packages/host/engine/src/session/lifecycle-service.ts index e89e78d24..ae34aed70 100644 --- a/packages/host/engine/src/session/lifecycle-service.ts +++ b/packages/host/engine/src/session/lifecycle-service.ts @@ -1081,12 +1081,12 @@ export class SessionLifecycleService { resolveForRecord( record: SessionRecord, override?: SessionPin, + /** The session the options are for: a fork resolves the source's pins for its child, whose + * own id must own the per-session resources (the simulator MCP endpoint token). */ + sessionId: SessionId = record.sessionId, ): Effect.Effect { const pinned = override ?? this.records.pinnedOptions(record.sessionId); - return this.startOptions.resolve( - { kind: record.kind, cwd: record.cwd, ...pinned }, - record.sessionId, - ); + return this.startOptions.resolve({ kind: record.kind, cwd: record.cwd, ...pinned }, sessionId); } /** Record the run this launch begins, then bind the record to a fresh adapter. Every relaunch of diff --git a/packages/host/engine/src/session/request-handler.ts b/packages/host/engine/src/session/request-handler.ts index 30c8ce800..58569fb54 100644 --- a/packages/host/engine/src/session/request-handler.ts +++ b/packages/host/engine/src/session/request-handler.ts @@ -127,6 +127,9 @@ export class SessionRequestHandler { kind: 'session.forked', replyTo: payload.clientReqId, sessionId: result.sessionId, + ...(result.mcpWarnings.length > 0 && { + mcpWarnings: [...result.mcpWarnings], + }), } : { kind: 'request.failed', diff --git a/packages/host/engine/src/session/session-event-processor.ts b/packages/host/engine/src/session/session-event-processor.ts index 19d21892e..240687a43 100644 --- a/packages/host/engine/src/session/session-event-processor.ts +++ b/packages/host/engine/src/session/session-event-processor.ts @@ -179,7 +179,8 @@ export class SessionEventProcessor { private notify(sessionId: SessionId, event: AgentEvent): void { const reason = notificationReason(event); const record = this.records.get(sessionId); - if (!reason || !record) return; + // A provisional record (a fork child mid-saga) is in no client's list yet. + if (!reason || !record || this.records.isProvisional(sessionId)) return; this.transport.send( createWireMessage({ kind: 'session.notification', From 27ac29b9cf7fe3e0ecc28913479c2f959021541f Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Tue, 8 Sep 2026 16:02:36 +0800 Subject: [PATCH 09/10] fix(workbench,client-core): hold the fork affordance while a fork is in flight and carry the child's MCP warnings --- packages/client/core/src/client.ts | 5 +++- .../core/src/client/pending-registry.ts | 4 ++- .../integration/conversation-client.test.ts | 2 ++ .../workbench/src/mock/dev-mock-host.ts | 28 +++++++++++++++---- .../src/surface/use-workbench-sessions.ts | 7 ++++- .../workbench/src/surface/workbench.tsx | 8 ++++-- .../integration/dev-mock-lineage.test.ts | 19 +++++++++++++ 7 files changed, 62 insertions(+), 11 deletions(-) diff --git a/packages/client/core/src/client.ts b/packages/client/core/src/client.ts index 5c8ba1ca9..88875f063 100644 --- a/packages/client/core/src/client.ts +++ b/packages/client/core/src/client.ts @@ -470,7 +470,10 @@ export class LinkCodeClient { this.pending.resolve('import', p.replyTo, p.record); break; case 'session.forked': - this.pending.resolve('fork', p.replyTo, { sessionId: p.sessionId }); + this.pending.resolve('fork', p.replyTo, { + sessionId: p.sessionId, + mcpWarnings: p.mcpWarnings ?? [], + }); break; case 'history.listed': this.pending.resolve('historyList', p.replyTo, p.result); diff --git a/packages/client/core/src/client/pending-registry.ts b/packages/client/core/src/client/pending-registry.ts index 77ca05ebf..787a68552 100644 --- a/packages/client/core/src/client/pending-registry.ts +++ b/packages/client/core/src/client/pending-registry.ts @@ -61,9 +61,11 @@ export interface SessionStartResult { mcpWarnings: McpWarning[]; } -/** `session.forked` without its correlation fields: the new session, live and selectable. */ +/** `session.forked` without its correlation fields: the new session, live and selectable, and + * the child start's custom-MCP advisories (delivered only on this reply, like a start's). */ export interface SessionForkResult { sessionId: SessionId; + mcpWarnings: McpWarning[]; } /** The `plugin.list.result` payload as one value: catalogs, standalone skills, and per-provider diff --git a/packages/client/core/tests/integration/conversation-client.test.ts b/packages/client/core/tests/integration/conversation-client.test.ts index 6e478be4c..292659ddf 100644 --- a/packages/client/core/tests/integration/conversation-client.test.ts +++ b/packages/client/core/tests/integration/conversation-client.test.ts @@ -187,6 +187,7 @@ describe('LinkCodeClient conversation graph API', () => { kind: 'session.forked', replyTo: p.clientReqId, sessionId: 'sess-child' as SessionId, + mcpWarnings: [{ serverName: 'linear', reason: 'provider-unsupported' }], }) : createWireMessage({ kind: 'request.failed', @@ -200,6 +201,7 @@ describe('LinkCodeClient conversation graph API', () => { expect(client.supportsSessionFork).toBe(true); await expect(client.forkSession(sessionId, leafTurnId, 7)).resolves.toEqual({ sessionId: 'sess-child', + mcpWarnings: [{ serverName: 'linear', reason: 'provider-unsupported' }], }); await expect(client.forkSession(sessionId, leafTurnId, 6)).rejects.toMatchObject({ code: 'conflict', diff --git a/packages/client/workbench/src/mock/dev-mock-host.ts b/packages/client/workbench/src/mock/dev-mock-host.ts index fd6d55581..5c762b305 100644 --- a/packages/client/workbench/src/mock/dev-mock-host.ts +++ b/packages/client/workbench/src/mock/dev-mock-host.ts @@ -297,6 +297,8 @@ export class DevMockHost { private readonly attachmentSessions = new Map>(); /** The daemon's operation journal for forks: a replayed id answers with the same child. */ private readonly forkOperations = new Map(); + /** Sources with a fork in flight — the daemon's open operation, which refuses a second one. */ + private readonly forkingSessions = new Set(); private uploadSeq = 0; private attachmentSeq = 0; @@ -442,8 +444,7 @@ export class DevMockHost { this.resumeSession(p.clientReqId, p.sessionId); break; case 'session.fork': - await wait(CONTROL_LATENCY_MS); - this.forkSession(p); + await this.forkSession(p); break; case 'session.stop': await wait(CONTROL_LATENCY_MS); @@ -1292,7 +1293,8 @@ export class DevMockHost { /** The daemon's `session.fork` reduced to mock parity: its admit rules in its order, then the * source lineage through the turn copied onto a new live session — new turn ids, the same * content, the prefix's journal frames as the child's provider copy — with the source untouched. */ - private forkSession(p: Extract): void { + private async forkSession(p: Extract): Promise { + await wait(CONTROL_LATENCY_MS); const replayed = this.forkOperations.get(p.operationId); if (replayed !== undefined) { this.send({ kind: 'session.forked', replyTo: p.clientReqId, sessionId: replayed }); @@ -1309,6 +1311,12 @@ export class DevMockHost { this.sendFailure(p.clientReqId, `Session is busy: ${p.sourceSessionId}`, { code: 'busy' }); return; } + if (this.forkingSessions.has(source.sessionId)) { + this.sendFailure(p.clientReqId, 'Another operation is open on this session', { + code: 'busy', + }); + return; + } const through = source.graphTurns.find((turn) => turn.graph.turnId === p.throughTurnId); if (through === undefined) { this.sendFailure(p.clientReqId, `Unknown turn: ${p.throughTurnId}`, { code: 'not_found' }); @@ -1328,6 +1336,13 @@ export class DevMockHost { }); return; } + // The provider fork takes a moment; the source's operation slot is held meanwhile. + this.forkingSessions.add(source.sessionId); + try { + await wait(CONTROL_LATENCY_MS); + } finally { + this.forkingSessions.delete(source.sessionId); + } const now = Date.now(); const child = this.addSession({ kind: source.kind, @@ -1353,12 +1368,13 @@ export class DevMockHost { cursor = turn.graph.parentTurnId; } const copiedIds = new Map(); + // The whole copied prefix sits on the child's one root run, as the daemon writes it. + const runId = `run-mock-fork-${this.sessionSeq.toString(36)}` as RunId; let parentTurnId: TurnId | null = null; for (let i = 0, len = path.length; i < len; i++) { const turn = path[i]; this.turnSeq += 1; - const id = this.turnSeq.toString(36); - const turnId = `turn-mock-${id}` as TurnId; + const turnId = `turn-mock-${this.turnSeq.toString(36)}` as TurnId; copiedIds.set(turn.graph.turnId, turnId); child.graphTurns.push({ graph: { @@ -1367,7 +1383,7 @@ export class DevMockHost { sessionId: child.sessionId, parentTurnId, siblingOrdinal: 1, - runId: `run-mock-fork-${id}` as RunId, + runId, }, content: turn.content, ...(turn.readContent !== undefined && { readContent: turn.readContent }), diff --git a/packages/client/workbench/src/surface/use-workbench-sessions.ts b/packages/client/workbench/src/surface/use-workbench-sessions.ts index 146294487..df98db80e 100644 --- a/packages/client/workbench/src/surface/use-workbench-sessions.ts +++ b/packages/client/workbench/src/surface/use-workbench-sessions.ts @@ -67,6 +67,9 @@ export interface WorkbenchSessions { throughTurnId: TurnId, expectedGraphRevision: number, ) => Promise; + /** A fork is in flight: the daemon holds the source's operation slot until it lands, so the + * affordance must not offer a second one meanwhile. */ + forking: boolean; /** Revalidate the session list — the cue for a mutation made outside this hook (e.g. an import). */ refresh: () => void; } @@ -249,11 +252,12 @@ export function useWorkbenchSessions(onError: (err: unknown) => void): Workbench expectedGraphRevision: number, ): Promise { const from = currentLocation; - const { sessionId } = await forkMutation.trigger({ + const { sessionId, mcpWarnings } = await forkMutation.trigger({ sourceSessionId, throughTurnId, expectedGraphRevision, }); + showMcpWarnings(mcpWarnings, tMcpWarnings); // Mutate before selecting to avoid a flash of the previous session. await mutate().catch(noop); recordNavigation(from, { surface: 'thread', sessionId }); @@ -294,6 +298,7 @@ export function useWorkbenchSessions(onError: (err: unknown) => void): Workbench create, close, fork, + forking: forkMutation.isMutating, refresh, }; } diff --git a/packages/client/workbench/src/surface/workbench.tsx b/packages/client/workbench/src/surface/workbench.tsx index 1c7418b4c..388512164 100644 --- a/packages/client/workbench/src/surface/workbench.tsx +++ b/packages/client/workbench/src/surface/workbench.tsx @@ -693,12 +693,16 @@ function WorkbenchSessionSurface({ function handleForkTurn(messageId: string): void { if (graph === undefined || active === null) return; const turn = graph.turns.find((candidate) => userRowMessageId(candidate.turnId) === messageId); - if (turn === undefined) return; + // A settled row can outrun the snapshot by one round trip; the daemon refuses an uncompleted + // turn anyway, so a click in that window does nothing rather than raise an error. + if (turn?.state !== 'completed') return; onClearError(); void sessions.fork(active.sessionId, turn.turnId, graph.graphRevision).catch(noop); } const canForkSessions = - client.supportsSessionFork && active?.historyCapabilities?.forkAfterTurn === true; + client.supportsSessionFork && + active?.historyCapabilities?.forkAfterTurn === true && + !sessions.forking; function handleDismissElsewhere(): void { if (active !== null && graph !== undefined) { diff --git a/packages/client/workbench/tests/integration/dev-mock-lineage.test.ts b/packages/client/workbench/tests/integration/dev-mock-lineage.test.ts index 68a954788..0f71a4e9e 100644 --- a/packages/client/workbench/tests/integration/dev-mock-lineage.test.ts +++ b/packages/client/workbench/tests/integration/dev-mock-lineage.test.ts @@ -228,6 +228,15 @@ describe('dev mock turn lineages', () => { expect(copy).toMatchObject({ parentTurnId: null, siblingOrdinal: 1, state: 'completed' }); expect(childGraph.activeLeafTurnId).toBe(copy.turnId); expect(userTexts((await client.readConversation(childId)).events)).toEqual(['$ a']); + // A longer prefix shares the child's one root run, as the daemon writes it. + const { sessionId: deeper } = await client.forkSession( + sessionId, + b.turnId, + graph.graphRevision, + ); + const deeperTurns = (await client.getConversationGraph(deeper)).turns; + expect(new Set(deeperTurns.map((turn) => turn.runId)).size).toBe(1); + expect(deeperTurns).toHaveLength(2); // The source keeps both turns and its leaf; a replayed operation answers with the same child. const source = await client.getConversationGraph(sessionId); expect(source.turns).toHaveLength(2); @@ -259,6 +268,16 @@ describe('dev mock turn lineages', () => { ).rejects.toMatchObject({ code: 'unsupported' }); // Nothing forked: only the two sessions this test started joined the seeded list. expect(await client.listSessions()).toHaveLength(seeded + 2); + + // A fork holds the source's operation slot: a second one in flight is refused `busy`. + const outcomes = await Promise.allSettled([ + client.forkSession(sessionId, a.turnId, graph.graphRevision), + client.forkSession(sessionId, a.turnId, graph.graphRevision), + ]); + expect(outcomes.map((outcome) => outcome.status).sort()).toEqual(['fulfilled', 'rejected']); + const refused = outcomes.find((outcome) => outcome.status === 'rejected'); + expect(refused?.reason).toMatchObject({ code: 'busy' }); + expect(await client.listSessions()).toHaveLength(seeded + 3); client.dispose(); }); From c6045642305711bbe6b0e7f899a5beb823e5b8b0 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Thu, 10 Sep 2026 16:10:36 +0800 Subject: [PATCH 10/10] fix(agent-adapter,engine): surface a skipped subagent copy and name the abandoned fork child --- .../claude-code-fork-subagents.test.ts | 20 ++++++++++++------- .../agent-adapter/src/native/claude-code.ts | 14 +++++++++---- .../host/engine/src/session/fork-service.ts | 3 ++- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/packages/host/agent-adapter/src/__tests__/claude-code-fork-subagents.test.ts b/packages/host/agent-adapter/src/__tests__/claude-code-fork-subagents.test.ts index fa47a6805..2d5c3344e 100644 --- a/packages/host/agent-adapter/src/__tests__/claude-code-fork-subagents.test.ts +++ b/packages/host/agent-adapter/src/__tests__/claude-code-fork-subagents.test.ts @@ -66,16 +66,22 @@ describe('copyClaudeSubagentTranscripts', () => { ]); }); - it('copies nothing for an unknown child, a missing projects dir, or a path-shaped id', async () => { + it('refuses an unknown child, a missing projects dir, or a path-shaped id instead of skipping quietly', async () => { const projects = await projectsDir(); await transcript(projects, '-Users-me-repo', 'source'); await subagent(projects, '-Users-me-repo', 'source', 'a1'); - expect(await copyClaudeSubagentTranscripts(projects, 'source', 'child')).toBe(false); - expect( - await copyClaudeSubagentTranscripts(path.join(projects, 'nope'), 'source', 'child'), - ).toBe(false); - expect(await copyClaudeSubagentTranscripts(projects, '../source', 'child')).toBe(false); - expect(await copyClaudeSubagentTranscripts(projects, 'source', '../../child')).toBe(false); + await expect(copyClaudeSubagentTranscripts(projects, 'source', 'child')).rejects.toThrow( + `transcript child not found under ${projects}`, + ); + await expect( + copyClaudeSubagentTranscripts(path.join(projects, 'nope'), 'source', 'child'), + ).rejects.toThrow('transcript source not found'); + await expect(copyClaudeSubagentTranscripts(projects, '../source', 'child')).rejects.toThrow( + 'session id is not a path segment: ../source', + ); + await expect(copyClaudeSubagentTranscripts(projects, 'source', '../../child')).rejects.toThrow( + 'session id is not a path segment: ../../child', + ); }); }); diff --git a/packages/host/agent-adapter/src/native/claude-code.ts b/packages/host/agent-adapter/src/native/claude-code.ts index dc9f5742e..8ae213782 100644 --- a/packages/host/agent-adapter/src/native/claude-code.ts +++ b/packages/host/agent-adapter/src/native/claude-code.ts @@ -2022,20 +2022,26 @@ async function findClaudeProjectDir( /** * Copy a session's `subagents/` transcripts next to its fork child (SDK 0.3.215's `forkSession` * leaves them behind). Transcripts of agents spawned after the cut travel too: their spawning - * tool_use is not in the child transcript, so they are never spliced in. Returns whether anything - * was copied; both ids become path segments and are shape-checked first. + * tool_use is not in the child transcript, so they are never spliced in. Resolves false when the + * source has none to copy; throws when either transcript cannot be located, so the caller reports + * it instead of leaving the child's subagent cards silently empty. */ export async function copyClaudeSubagentTranscripts( projectsDir: string, sourceId: string, childId: string, ): Promise { - if (!SAFE_SESSION_ID.test(sourceId) || !SAFE_SESSION_ID.test(childId)) return false; + // Both ids become path segments. + const unsafe = [sourceId, childId].find((id) => !SAFE_SESSION_ID.test(id)); + if (unsafe !== undefined) throw new Error(`session id is not a path segment: ${unsafe}`); const [sourceDir, childDir] = await Promise.all([ findClaudeProjectDir(projectsDir, sourceId), findClaudeProjectDir(projectsDir, childId), ]); - if (sourceDir === undefined || childDir === undefined) return false; + if (sourceDir === undefined || childDir === undefined) { + const missing = sourceDir === undefined ? sourceId : childId; + throw new Error(`transcript ${missing} not found under ${projectsDir}`); + } const from = path.join(sourceDir, sourceId, 'subagents'); try { await access(from); diff --git a/packages/host/engine/src/session/fork-service.ts b/packages/host/engine/src/session/fork-service.ts index 1247fc74d..1e226271a 100644 --- a/packages/host/engine/src/session/fork-service.ts +++ b/packages/host/engine/src/session/fork-service.ts @@ -395,7 +395,8 @@ export class SessionForkService { ), Effect.andThen( Effect.logWarning('Abandoned a session fork; its provider child history is orphaned', { - sessionId: child.forkOrigin?.sourceSessionId, + sessionId: child.sessionId, + sourceSessionId: child.forkOrigin?.sourceSessionId, historyId: child.runs[0]?.historyId, }), ),