From c61ecd1509c3a56c057065316eed860600955821 Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:10:57 +1100 Subject: [PATCH 1/2] fix(server): native forks after an undo no longer copy later turns Co-Authored-By: Claude Opus 5.5 --- .../ProviderTurnStartService.test.ts | 55 +++++++++++++++++-- .../ProviderTurnStartService.ts | 16 +++++- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index a696fec352025..f7f9dc25ddc92 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -488,6 +488,35 @@ function makeLocalCommandHarness(input: { strength: "strong" as const, }, }; + const parentProviderTurn = (ordinal: number, attemptRunId: RunId) => ({ + attempt: { + ...projection.attempts[0]!, + id: RunAttemptId.make(`side-chat-parent-attempt-${ordinal}`), + runId: attemptRunId, + providerThreadId: parentProviderThread.id, + status: "completed" as const, + }, + turn: { + id: ProviderTurnId.make(`side-chat-parent-turn-${ordinal}`), + providerThreadId: parentProviderThread.id, + nodeId: rootNodeId, + runAttemptId: RunAttemptId.make(`side-chat-parent-attempt-${ordinal}`), + nativeTurnRef: { + driver: providerThread.driver, + nativeId: `side-chat-parent-native-turn-${ordinal}`, + strength: "strong" as const, + }, + ordinal, + status: "completed" as const, + startedAt: now, + completedAt: now, + }, + }); + const parentRolledBackRunId = RunId.make("side-chat-parent-rolled-back-run"); + const parentTurns = [ + parentProviderTurn(1, parentRunId), + parentProviderTurn(2, parentRolledBackRunId), + ]; const parentRecords = { ...projection, thread: { ...projection.thread, id: parentThreadId }, @@ -498,17 +527,27 @@ function makeLocalCommandHarness(input: { threadId: parentThreadId, ordinal: 1, providerThreadId: parentProviderThread.id, - activeAttemptId: null, + activeAttemptId: parentTurns[0]!.attempt.id, status: "completed" as const, completedAt: now, }, + { + ...run, + id: parentRolledBackRunId, + threadId: parentThreadId, + ordinal: 2, + providerThreadId: parentProviderThread.id, + activeAttemptId: parentTurns[1]!.attempt.id, + status: "rolled_back" as const, + completedAt: now, + }, ...(input.sideChat?.nativeForkFrom === "busy" ? [ { ...run, id: RunId.make("side-chat-parent-busy-run"), threadId: parentThreadId, - ordinal: 2, + ordinal: 3, providerThreadId: parentProviderThread.id, status: "running" as const, }, @@ -516,8 +555,8 @@ function makeLocalCommandHarness(input: { : []), ], providerThreads: [parentProviderThread], - attempts: [], - providerTurns: [], + attempts: parentTurns.map(({ attempt }) => attempt), + providerTurns: parentTurns.map(({ turn }) => turn), turnItems: [], }; if (input.sideChat !== undefined) { @@ -749,7 +788,9 @@ function makeLocalCommandHarness(input: { strength: "strong" as const, }, }; - const forkThread = vi.fn(() => Effect.succeed(sideChatThread)); + const forkThread = vi.fn((_fork: ProviderAdapter.ProviderAdapterV2ForkThreadInput) => + Effect.succeed(sideChatThread), + ); const sideChatSession = { driver: providerThread.driver, providerSession, @@ -1273,6 +1314,10 @@ effectIt.effect.each([ yield* harness.start; expect(harness.forkThread).toHaveBeenCalledTimes(forks ? 1 : 0); + // The parent's rolled-back turn is gone from its native conversation. + expect( + harness.forkThread.mock.calls[0]?.[0].sourceProviderTurns?.map((turn) => turn.id), + ).toEqual(forks ? ["side-chat-parent-turn-1"] : undefined); expect(harness.projection().contextTransfers).toMatchObject([ { id: "side-chat-fork", status: transferStatus }, ]); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 62a9e8b88e96d..cd121c7115a2a 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -1150,10 +1150,24 @@ export const layer: Layer.Layer< (candidate) => candidate.ordinal > sourceRun.ordinal && isActiveRun(candidate), ); if (!sourceBusy) { + // Rolled-back turns are no longer in the source conversation, so they cannot bound a fork. + const rolledBackAttemptIds = new Set( + sourceProjection.attempts + .filter((attempt) => + sourceProjection.runs.some( + (candidate) => + candidate.id === attempt.runId && candidate.status === "rolled_back", + ), + ) + .map((attempt) => attempt.id), + ); return yield* loadFromProvider( session.forkThread({ sourceProviderThread, - sourceProviderTurns: sourceProjection.providerTurns, + sourceProviderTurns: sourceProjection.providerTurns.filter( + (turn) => + turn.runAttemptId === null || !rolledBackAttemptIds.has(turn.runAttemptId), + ), targetThreadId: projection.thread.id, modelSelection: run.modelSelection, runtimePolicy: resolvedRuntimePolicy, From afa90123603ba6eff6c50fa9e71773ca8833b446 Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:27:06 +1100 Subject: [PATCH 2/2] fix(claude): forks after a rollback stop at the pinned conversation head Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 14 ++++++++++++++ .../orchestration-v2/Adapters/ClaudeAdapterV2.ts | 3 ++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 45d21b5944e24..c75b99832e4cc 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -6350,9 +6350,15 @@ describe("ClaudeAdapterV2 background wake turns", () => { it.effect("never records a subagent uuid for a turn with no main-thread assistant message", () => Effect.gen(function* () { + const forkCalls: Array = []; const harness = yield* makeWakeHarnessWithOptions({ close: (sdkMessages) => Queue.shutdown(sdkMessages), freshQueueOnReopen: true, + forkSession: (input) => + Effect.sync(() => { + forkCalls.push(input); + return { sessionId: "forked-after-compact-rollback" }; + }), }); const now = yield* DateTime.now; const subagentUuid = "00000000-0000-4000-8000-000000000644"; @@ -6433,6 +6439,14 @@ describe("ClaudeAdapterV2 background wake turns", () => { pastSilentTurn.providerThread.nativeConversationHeadRef?.nativeId, earlierCursor, ); + // Forks omit rolled-back turns, which Claude's transcript still holds past the pinned head. + yield* harness.runtime.forkThread({ + sourceProviderThread: pastSilentTurn.providerThread, + sourceProviderTurns: turnsAcrossSilent.slice(0, 2), + providerTurnId: providerTurn.id, + targetThreadId: ThreadId.make("thread-claude-fork-after-compact-rollback"), + }); + assert.equal(forkCalls[0]?.options.upToMessageId, earlierCursor); const interruptedBeforeCompact = yield* rollbackTo( [ { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 674f55c524d75..b306bcf52d888 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2070,7 +2070,8 @@ const resolveClaudeForkUpToMessageId = Effect.fn("ClaudeAdapterV2.resolveForkUpT .slice(boundaryIndex + 1) .filter(isTerminalProviderTurn); if (terminalTurnsAfterBoundary.length === 0) { - return undefined; + // Rollback pins the head but leaves the rolled-back turns in Claude's transcript. + return yield* getNativeConversationHeadId(input.sourceProviderThread); } return yield* new ProviderAdapter.ProviderAdapterForkThreadError({