diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index aecc68fa..0c95e8f7 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -762,9 +762,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...existingRow.value, effectiveCwd: event.payload.effectiveCwd, effectiveCwdSource: - event.payload.effectiveCwd === null - ? null - : (event.payload.effectiveCwdSource ?? "session"), + event.payload.effectiveCwdSource ?? + (event.payload.effectiveCwd === null ? null : "session"), updatedAt: event.payload.updatedAt, }); return; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index d5c3362b..e7168c86 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -25,6 +25,7 @@ import { type OrchestrationThreadDiffStat, type OrchestrationThreadDoneOverride, type OrchestrationThreadShell, + type ThreadEffectiveCwdSource, ModelSelection, OrchestrationThreadGoal, ProjectId, @@ -110,6 +111,15 @@ const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields( files: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)), }), ); + +function projectEffectiveCwdSource(input: { + readonly effectiveCwd: string | null; + readonly effectiveCwdSource?: ThreadEffectiveCwdSource | null | undefined; +}): { readonly effectiveCwdSource?: ThreadEffectiveCwdSource } { + const source = + input.effectiveCwdSource ?? (input.effectiveCwd === null ? null : ("session" as const)); + return source === null ? {} : { effectiveCwdSource: source }; +} /** * Per-thread rollup of the turn file summaries the checkpoints projector * writes into `projection_turns.checkpoint_files_json`. Aggregated in SQL so a @@ -1688,9 +1698,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { branch: row.branch, worktreePath: row.worktreePath, effectiveCwd: row.effectiveCwd, - ...(row.effectiveCwd === null - ? {} - : { effectiveCwdSource: row.effectiveCwdSource ?? "session" }), + ...projectEffectiveCwdSource(row), goal: row.goal, voiceActive: (row.voiceActive ?? 0) > 0, latestTurn: latestTurnByThread.get(row.threadId) ?? null, @@ -1933,9 +1941,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { branch: row.branch, worktreePath: row.worktreePath, effectiveCwd: row.effectiveCwd, - ...(row.effectiveCwd === null - ? {} - : { effectiveCwdSource: row.effectiveCwdSource ?? "session" }), + ...projectEffectiveCwdSource(row), goal: row.goal, voiceActive: (row.voiceActive ?? 0) > 0, latestTurn: latestTurnByThread.get(row.threadId) ?? null, @@ -2084,9 +2090,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { branch: row.branch, worktreePath: row.worktreePath, effectiveCwd: row.effectiveCwd, - ...(row.effectiveCwd === null - ? {} - : { effectiveCwdSource: row.effectiveCwdSource ?? "session" }), + ...projectEffectiveCwdSource(row), goal: row.goal, voiceActive: (row.voiceActive ?? 0) > 0, latestTurn: latestTurnByThread.get(row.threadId) ?? null, @@ -2238,9 +2242,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { branch: row.branch, worktreePath: row.worktreePath, effectiveCwd: row.effectiveCwd, - ...(row.effectiveCwd === null - ? {} - : { effectiveCwdSource: row.effectiveCwdSource ?? "session" }), + ...projectEffectiveCwdSource(row), goal: row.goal, voiceActive: (row.voiceActive ?? 0) > 0, latestTurn: latestTurnByThread.get(row.threadId) ?? null, @@ -2521,9 +2523,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, effectiveCwd: threadRow.value.effectiveCwd, - ...(threadRow.value.effectiveCwd === null - ? {} - : { effectiveCwdSource: threadRow.value.effectiveCwdSource ?? "session" }), + ...projectEffectiveCwdSource(threadRow.value), goal: threadRow.value.goal, voiceActive: (threadRow.value.voiceActive ?? 0) > 0, latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, @@ -2635,9 +2635,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, effectiveCwd: threadRow.value.effectiveCwd, - ...(threadRow.value.effectiveCwd === null - ? {} - : { effectiveCwdSource: threadRow.value.effectiveCwdSource ?? "session" }), + ...projectEffectiveCwdSource(threadRow.value), goal: threadRow.value.goal, voiceActive: (threadRow.value.voiceActive ?? 0) > 0, latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 3690ab83..eec12fe3 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -166,6 +166,9 @@ describe("ProviderCommandReactor", () => { readonly failNativeForkStart?: boolean; readonly failRealtimeStart?: boolean; readonly interruptTurn?: ProviderServiceShape["interruptTurn"]; + /** Mirror the provider lifecycle's projected `starting` state while a + * replacement session is being bound. */ + readonly projectStartingDuringRestart?: boolean; }) { const now = "2026-01-01T00:00:00.000Z"; const baseDir = @@ -184,6 +187,8 @@ describe("ProviderCommandReactor", () => { model: "gpt-5-codex", }; const failNativeForkStart = input?.failNativeForkStart === true; + const projectStartingDuringRestart = input?.projectStartingDuringRestart === true; + let projectRestartStarting: ((session: ProviderSession) => Effect.Effect) | null = null; const startSession = vi.fn( (_: unknown, input: unknown): Effect.Effect => { if ( @@ -254,7 +259,11 @@ describe("ProviderCommandReactor", () => { updatedAt: now, }; runtimeSessions.push(session); - return Effect.succeed(session); + const projectStarting = + projectStartingDuringRestart && sessionIndex > 1 && projectRestartStarting + ? projectRestartStarting(session) + : Effect.void; + return projectStarting.pipe(Effect.as(session)); }, ); const sendTurn = vi.fn((_: unknown) => @@ -507,6 +516,28 @@ describe("ProviderCommandReactor", () => { runtime = ManagedRuntime.make(layer); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); + projectRestartStarting = (session) => + engine + .dispatch({ + type: "thread.session.set", + commandId: CommandId.make(`cmd-project-restart-starting-${nextSessionIndex}`), + threadId: session.threadId, + session: { + threadId: session.threadId, + status: "starting", + providerName: session.provider, + providerInstanceId: session.providerInstanceId, + providerSessionId: null, + providerThreadId: session.providerThreadId ?? null, + runtimeMode: session.runtimeMode, + checkoutCwd: session.cwd ?? null, + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }) + .pipe(Effect.orDie, Effect.asVoid); const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery)); const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor)); scope = await Effect.runPromise(Scope.make("sequential")); @@ -2506,7 +2537,7 @@ describe("ProviderCommandReactor", () => { }); it("applies a checkout switch immediately when the session is idle", async () => { - const harness = await createHarness(); + const harness = await createHarness({ projectStartingDuringRestart: true }); const threadId = ThreadId.make("thread-1"); const now = "2026-01-01T00:00:00.000Z"; @@ -2565,6 +2596,15 @@ describe("ProviderCommandReactor", () => { }); expect(harness.sendTurn.mock.calls.length).toBe(1); expect(harness.stopSession.mock.calls.length).toBe(0); + await waitFor(async () => { + const current = await harness.readModel(); + const restarted = current.threads.find((thread) => thread.id === threadId)?.session; + return ( + restarted?.status === "ready" && + restarted.activeTurnId === null && + restarted.checkoutCwd === PROJECT_WORKTREE_ROOT + ); + }); }); it("keeps a running turn in its original checkout when a follow-up arrives after a checkout switch", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 710a5600..22d4f5c3 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -730,6 +730,9 @@ const make = Effect.gen(function* () { options?: { readonly modelSelection?: ModelSelection; readonly excludeContextSeedMessageId?: MessageId; + /** Keep the projected startup state until a requested turn reaches the + * provider. Idle session recycling must settle back to ready. */ + readonly preservePendingTurnStartup?: boolean; /** Same-driver native fork request for a fresh session start. Falls * back to a plain start (context-seed seeding) when the fork fails. */ readonly forkFrom?: ProviderSessionForkFrom; @@ -923,7 +926,9 @@ const make = Effect.gen(function* () { const latestSession = latestThread.session; const mappedStatus = mapProviderSessionStatusToOrchestrationStatus(session.status); const shouldPreservePendingTurnStartup = - latestSession?.status === "starting" && mappedStatus === "ready"; + options?.preservePendingTurnStartup === true && + latestSession?.status === "starting" && + mappedStatus === "ready"; // Provider-side identifiers can arrive through runtime ingestion or // directly on the started session. Prefer the durable projection, but // let the runtime heal an older missing value. Projected identifiers @@ -1192,6 +1197,7 @@ const make = Effect.gen(function* () { const ensured = yield* ensureSessionForThread(input.threadId, input.createdAt, { ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), excludeContextSeedMessageId: input.messageId, + preservePendingTurnStartup: true, ...(forkFrom !== undefined ? { forkFrom } : {}), }); const nativeForkApplied = ensured.nativeForkApplied; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 4a3c1cc3..e25f5542 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -864,6 +864,54 @@ describe("ProviderRuntimeIngestion", () => { expect((await harness.readModel()).threads[0]?.effectiveCwd).toBe(sessionCwd); }); + it("does not reassert a subagent worktree after the user selects the project checkout", async () => { + const harness = await createClaudeHarness(); + const worktree = `${harness.workspaceRoot}/.claude/worktrees/agent-a`; + harness.setRepositoryWorktrees([harness.workspaceRoot, worktree]); + harness.setSubagentWorktree("toolu_a", worktree); + + startAgentTask(harness, "task-a", "toolu_a"); + await waitForThread(harness.readModel, (thread) => thread.effectiveCwd === worktree); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.checkout.select", + commandId: CommandId.make("cmd-select-project-checkout"), + threadId: ThreadId.make("thread-1"), + branch: "main", + worktreePath: null, + }), + ); + await waitForThread( + harness.readModel, + (thread) => thread.effectiveCwd === null && thread.effectiveCwdSource === "selection", + ); + + // Model a lookup that read the old state before the selection but only + // reached the serialized decider afterward. + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.effective-cwd.set", + commandId: CommandId.make("cmd-stale-subagent-result"), + threadId: ThreadId.make("thread-1"), + effectiveCwd: worktree, + effectiveCwdSource: "subagent", + createdAt: "2026-01-01T00:00:05.000Z", + }), + ); + await waitForThread( + harness.readModel, + (thread) => thread.effectiveCwd === null && thread.effectiveCwdSource === "selection", + ); + + completeTask(harness, "task-a"); + await harness.drain(); + await Effect.runPromise(Effect.sleep("100 millis")); + const selected = (await harness.readModel()).threads[0]; + expect(selected?.effectiveCwd).toBeNull(); + expect(selected?.effectiveCwdSource).not.toBe("subagent"); + }); + it("stops following when the provider session exits", async () => { const harness = await createClaudeHarness(); const worktree = `${harness.workspaceRoot}/.claude/worktrees/agent-a`; @@ -1196,6 +1244,50 @@ describe("ProviderRuntimeIngestion", () => { expect(thread?.session?.updatedAt).toBe(reboundAt); }); + it("settles an idle session restart instead of inventing a pending turn", async () => { + const harness = await createHarness(); + const restartAt = "2026-01-01T00:00:01.000Z"; + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-idle-session-restart-starting"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "starting", + providerName: "codex", + providerSessionId: null, + providerThreadId: null, + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: restartAt, + }, + createdAt: restartAt, + }), + ); + + harness.emit({ + type: "session.started", + eventId: asEventId("evt-idle-session-restarted"), + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + createdAt: "2026-01-01T00:00:05.000Z", + payload: { + message: "ready", + }, + }); + await harness.drain(); + + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === asThreadId("thread-1"), + ); + expect(thread?.session?.status).toBe("ready"); + expect(thread?.session?.activeTurnId).toBeNull(); + expect(thread?.session?.updatedAt).toBe("2026-01-01T00:00:05.000Z"); + }); + it("keeps pending turn startup visible until the provider turn starts", async () => { const harness = await createHarness(); const requestedAt = "2026-01-01T00:00:01.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 6201b5a5..e9a05e9d 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -2504,13 +2504,20 @@ const make = Effect.gen(function* () { return activeTurnId !== null ? "running" : "ready"; } })(); - const shouldPreservePendingTurnStartup = + const canPreservePendingTurnStartup = thread.session?.status === "starting" && nextActiveTurnId === null && runtimeStatus === "ready" && (event.type === "session.started" || event.type === "thread.started" || event.type === "session.state.changed"); + const shouldPreservePendingTurnStartup = canPreservePendingTurnStartup + ? Option.isSome( + yield* projectionTurnRepository.getPendingTurnStartByThreadId({ + threadId: thread.id, + }), + ) + : false; const status = shouldPreservePendingTurnStartup ? "starting" : runtimeStatus; const sessionUpdatedAt = shouldPreservePendingTurnStartup ? (thread.session?.updatedAt ?? now) diff --git a/apps/server/src/orchestration/Layers/SubagentWorktreeFollower.ts b/apps/server/src/orchestration/Layers/SubagentWorktreeFollower.ts index 237b98d6..94be2523 100644 Binary files a/apps/server/src/orchestration/Layers/SubagentWorktreeFollower.ts and b/apps/server/src/orchestration/Layers/SubagentWorktreeFollower.ts differ diff --git a/apps/server/src/orchestration/decider.checkoutSwitch.test.ts b/apps/server/src/orchestration/decider.checkoutSwitch.test.ts index 11ebfe4c..bbcc99f2 100644 --- a/apps/server/src/orchestration/decider.checkoutSwitch.test.ts +++ b/apps/server/src/orchestration/decider.checkoutSwitch.test.ts @@ -23,6 +23,7 @@ const worktreeB = "/repos/project/.worktrees/feature-b"; function makeSession(input: { status: OrchestrationSession["status"]; checkoutCwd?: string | null; + pendingBackgroundTaskCount?: number; }): OrchestrationSession { return { threadId, @@ -32,6 +33,7 @@ function makeSession(input: { providerThreadId: "provider-thread-1", runtimeMode: "full-access", activeTurnId: null, + pendingBackgroundTaskCount: input.pendingBackgroundTaskCount ?? 0, lastError: null, ...(input.checkoutCwd !== undefined ? { checkoutCwd: input.checkoutCwd } : {}), updatedAt: now, @@ -41,6 +43,7 @@ function makeSession(input: { function makeReadModel(input: { session: OrchestrationSession | null; effectiveCwd: string | null; + effectiveCwdSource?: "session" | "subagent" | "selection"; worktreePath?: string | null; }): OrchestrationReadModel { return { @@ -73,7 +76,11 @@ function makeReadModel(input: { branch: "feature-a", worktreePath: input.worktreePath !== undefined ? input.worktreePath : worktreeA, effectiveCwd: input.effectiveCwd, - ...(input.effectiveCwd !== null ? { effectiveCwdSource: "session" as const } : {}), + ...(input.effectiveCwdSource !== undefined + ? { effectiveCwdSource: input.effectiveCwdSource } + : input.effectiveCwd !== null + ? { effectiveCwdSource: "session" as const } + : {}), goal: null, latestTurn: null, createdAt: now, @@ -94,6 +101,19 @@ function makeReadModel(input: { }; } +function checkoutSelectCommand(input: { + branch: string; + worktreePath: string | null; +}): Extract { + return { + type: "thread.checkout.select", + commandId: CommandId.make("cmd-checkout-select"), + threadId, + branch: input.branch, + worktreePath: input.worktreePath, + }; +} + function metaUpdateCommand(input: { worktreePath?: string | null; title?: string; @@ -121,12 +141,135 @@ function sessionSetCommand( }; } +function subagentEffectiveCwdCommand( + effectiveCwd: string | null, +): Extract { + return { + type: "thread.effective-cwd.set", + commandId: CommandId.make("cmd-stale-subagent-cwd"), + threadId, + effectiveCwd, + effectiveCwdSource: "subagent", + createdAt: "2026-01-01T00:00:11.000Z", + }; +} + async function decide(command: OrchestrationCommand, readModel: OrchestrationReadModel) { const decided = await Effect.runPromise(decideOrchestrationCommand({ command, readModel })); return Array.isArray(decided) ? decided : [decided]; } describe("decider checkout switch effectiveCwd", () => { + it("makes an explicit main selection override a lingering subagent worktree", async () => { + const events = await decide( + checkoutSelectCommand({ branch: "main", worktreePath: null }), + makeReadModel({ + session: makeSession({ status: "ready", checkoutCwd: workspaceRoot }), + effectiveCwd: worktreeA, + effectiveCwdSource: "subagent", + worktreePath: null, + }), + ); + + expect(events).toHaveLength(2); + expect(events[0]).toMatchObject({ + type: "thread.meta-updated", + payload: { threadId, branch: "main", worktreePath: null }, + }); + expect(events[1]).toMatchObject({ + type: "thread.effective-cwd-set", + payload: { + threadId, + effectiveCwd: null, + effectiveCwdSource: "selection", + }, + }); + expect(events[1]?.causationEventId).toBe(events[0]?.eventId); + }); + + it("records an explicit worktree selection even when it is already configured", async () => { + const events = await decide( + checkoutSelectCommand({ branch: "feature-a", worktreePath: worktreeA }), + makeReadModel({ + session: makeSession({ status: "running", checkoutCwd: workspaceRoot }), + effectiveCwd: null, + worktreePath: worktreeA, + }), + ); + + expect(events).toHaveLength(2); + expect(events[1]).toMatchObject({ + type: "thread.effective-cwd-set", + payload: { + threadId, + effectiveCwd: null, + effectiveCwdSource: "selection", + }, + }); + }); + + it("rejects stale subagent inference after an explicit selection", async () => { + const events = await decide( + subagentEffectiveCwdCommand(worktreeA), + makeReadModel({ + session: makeSession({ status: "running", checkoutCwd: workspaceRoot }), + effectiveCwd: null, + effectiveCwdSource: "selection", + worktreePath: null, + }), + ); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "thread.effective-cwd-set", + payload: { + threadId, + effectiveCwd: null, + effectiveCwdSource: "selection", + }, + }); + }); + + it("releases the selection marker after the old session work settles", async () => { + const events = await decide( + sessionSetCommand( + makeSession({ status: "ready", checkoutCwd: workspaceRoot, pendingBackgroundTaskCount: 0 }), + ), + makeReadModel({ + session: makeSession({ + status: "ready", + checkoutCwd: workspaceRoot, + pendingBackgroundTaskCount: 1, + }), + effectiveCwd: null, + effectiveCwdSource: "selection", + worktreePath: null, + }), + ); + + expect(events).toHaveLength(2); + expect(events[1]).toMatchObject({ + type: "thread.effective-cwd-set", + payload: { threadId, effectiveCwd: null }, + }); + expect(events[1]?.payload).not.toHaveProperty("effectiveCwdSource"); + }); + + it("keeps the selection marker until the session reaches the selected checkout", async () => { + const events = await decide( + sessionSetCommand(makeSession({ status: "ready", checkoutCwd: worktreeA })), + makeReadModel({ + session: makeSession({ status: "running", checkoutCwd: worktreeA }), + effectiveCwd: null, + effectiveCwdSource: "selection", + worktreePath: worktreeB, + }), + ); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "thread.session-set" }); + }); + it("clears the stale effectiveCwd when a stopped thread's worktree changes", async () => { const events = await decide( metaUpdateCommand({ worktreePath: worktreeB }), diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 66a30cfb..583860d1 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -637,6 +637,67 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ]; } + case "thread.checkout.select": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const project = yield* requireProject({ + readModel, + command, + projectId: thread.projectId, + }); + const worktreePath = normalizeWorktreePath(command.worktreePath, project.workspaceRoot); + const occurredAt = yield* nowIso; + const metaUpdatedEvent = { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + }), + type: "thread.meta-updated", + payload: { + threadId: command.threadId, + branch: command.branch, + worktreePath, + updatedAt: occurredAt, + }, + } as const; + const hasLiveWork = + thread.session?.status === "starting" || + thread.session?.status === "running" || + thread.session?.activeTurnId != null || + (thread.session?.pendingBackgroundTaskCount ?? 0) > 0; + const selectionAuthorityRequired = thread.effectiveCwd !== null || hasLiveWork; + if (!selectionAuthorityRequired && thread.effectiveCwdSource !== "selection") { + return metaUpdatedEvent; + } + if (selectionAuthorityRequired && thread.effectiveCwdSource === "selection") { + return metaUpdatedEvent; + } + return [ + metaUpdatedEvent, + { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + }), + causationEventId: metaUpdatedEvent.eventId, + type: "thread.effective-cwd-set", + payload: { + threadId: command.threadId, + effectiveCwd: null, + ...(selectionAuthorityRequired ? { effectiveCwdSource: "selection" as const } : {}), + updatedAt: occurredAt, + }, + }, + ]; + } + case "thread.runtime-mode.set": { yield* requireThread({ readModel, @@ -1164,6 +1225,11 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); + const project = yield* requireProject({ + readModel, + command, + projectId: thread.projectId, + }); const sessionSetEvent = { ...withEventBase({ aggregateKind: "thread", @@ -1184,13 +1250,22 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" // panels at the checkout the user already moved away from. A session // stopping in its own configured checkout keeps its effectiveCwd, so a // cwd-follow into a subfolder still reads correctly after a stop. - const configuredCheckout = thread.worktreePath ?? null; + const configuredCheckout = thread.worktreePath ?? project.workspaceRoot; const sessionCheckout = command.session.checkoutCwd ?? null; const checkoutDiffers = configuredCheckout === null || sessionCheckout === null ? configuredCheckout !== sessionCheckout : !areFilesystemPathsEqual(configuredCheckout, sessionCheckout); - if (command.session.status !== "stopped" || !checkoutDiffers || thread.effectiveCwd == null) { + const selectionSettled = + thread.effectiveCwdSource === "selection" && + command.session.status !== "starting" && + command.session.status !== "running" && + command.session.activeTurnId === null && + (command.session.pendingBackgroundTaskCount ?? 0) === 0 && + (command.session.status === "stopped" || !checkoutDiffers); + const stoppedCheckoutMove = + command.session.status === "stopped" && checkoutDiffers && thread.effectiveCwd !== null; + if (!selectionSettled && !stoppedCheckoutMove) { return sessionSetEvent; } return [ @@ -1238,11 +1313,33 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.effective-cwd.set": { - yield* requireThread({ + const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); + // A lookup can finish after a user has explicitly moved the thread. + // Re-check authority here, inside the serialized decider, so that stale + // subagent inference cannot win the race after its earlier snapshot. + if (command.effectiveCwdSource === "subagent" && thread.effectiveCwdSource === "selection") { + const retainedAt = yield* nowIso; + return { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: retainedAt, + commandId: command.commandId, + metadata: {}, + }), + type: "thread.effective-cwd-set", + payload: { + threadId: command.threadId, + effectiveCwd: null, + effectiveCwdSource: "selection", + updatedAt: retainedAt, + }, + }; + } return { ...withEventBase({ aggregateKind: "thread", diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index c4dbe538..84461831 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -660,7 +660,7 @@ export function projectEvent( threads: updateThread(nextBase.threads, payload.threadId, { effectiveCwd: payload.effectiveCwd, effectiveCwdSource: - payload.effectiveCwd === null ? null : (payload.effectiveCwdSource ?? "session"), + payload.effectiveCwdSource ?? (payload.effectiveCwd === null ? null : "session"), updatedAt: event.occurredAt, }), }; diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 70f228b2..8634a23d 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -19,13 +19,13 @@ import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { readEnvironmentApi } from "../environmentApi"; import { gitBranchSearchInfiniteQueryOptions, gitQueryKeys } from "../lib/gitReactQuery"; import { useGitStatus } from "../lib/gitStatusState"; -import { newCommandId } from "../lib/utils"; import { cn } from "../lib/utils"; import { parsePullRequestReference } from "../pullRequestReference"; import { getSourceControlPresentation } from "../sourceControlPresentation"; import { getVcsRefBadge } from "../worktreeCleanup"; import { useStore } from "../store"; import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; +import { useThreadCheckoutSelection } from "../hooks/useThreadCheckoutSelection"; import { annotateMissingCheckoutLabel, deriveLocalBranchNameFromRemoteRef, @@ -122,7 +122,6 @@ export function BranchToolbarBranchSelector({ const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]); const serverThread = useStore(serverThreadSelector); const serverSession = serverThread?.session ?? null; - const setThreadBranchAction = useStore((store) => store.setThreadBranch); const draftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), ); @@ -163,29 +162,20 @@ export function BranchToolbarBranchSelector({ draftThreadEnvMode: draftThread?.envMode, }); + const selectServerThreadCheckout = useThreadCheckoutSelection({ + threadRef: serverThread ? threadRef : null, + thread: serverThread ?? null, + onBranchOverrideChange: onActiveThreadBranchOverrideChange, + }); + // --------------------------------------------------------------------------- // Thread branch mutation (colocated — only this component calls it) // --------------------------------------------------------------------------- const setThreadBranch = useCallback( (branch: string | null, worktreePath: string | null) => { if (!activeThreadId || !activeProject) return; - const api = readEnvironmentApi(environmentId); - // Picking a checkout never stops a live session. It records the thread's - // target checkout for the next turn, exactly like the model picker - // records a model; the server cycles the runtime into the new checkout - // when that turn is dispatched. - if (api && hasServerThread) { - void api.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: activeThreadId, - branch, - worktreePath, - }); - } if (hasServerThread) { - onActiveThreadBranchOverrideChange?.(branch); - setThreadBranchAction(threadRef, branch, worktreePath); + selectServerThreadCheckout(branch, worktreePath); return; } const nextDraftEnvMode = resolveDraftEnvModeAfterBranchChange({ @@ -205,8 +195,7 @@ export function BranchToolbarBranchSelector({ activeProject, activeWorktreePath, hasServerThread, - onActiveThreadBranchOverrideChange, - setThreadBranchAction, + selectServerThreadCheckout, setDraftThreadContext, draftId, threadRef, diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index d4f29f33..9d1ca239 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -76,10 +76,10 @@ import { } from "~/lib/gitReactQuery"; import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState"; import { useSourceControlDiscovery } from "~/lib/sourceControlDiscoveryState"; -import { newCommandId, randomUUID } from "~/lib/utils"; +import { randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; -import { readEnvironmentApi } from "~/environmentApi"; +import { useThreadCheckoutSelection } from "~/hooks/useThreadCheckoutSelection"; import { readLocalApi } from "~/localApi"; import { getSourceControlPresentation } from "~/sourceControlPresentation"; import { useStore } from "~/store"; @@ -1133,11 +1133,10 @@ export default function GitActionsControl({ : null, ); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); - const setThreadBranch = useStore((store) => store.setThreadBranch); - const restoreThreadCheckout = useStore((store) => store.restoreThreadCheckout); - // Identifies the newest optimistic branch dispatch so a stale rejection - // cannot roll back state a later dispatch already replaced. - const branchDispatchIdRef = useRef(0); + const selectServerThreadCheckout = useThreadCheckoutSelection({ + threadRef: activeServerThread ? activeThreadRef : null, + thread: activeServerThread ?? null, + }); const queryClient = useQueryClient(); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); const [dialogCommitMessage, setDialogCommitMessage] = useState(""); @@ -1171,53 +1170,7 @@ export default function GitActionsControl({ if (activeServerThread.branch === branch) { return; } - - const worktreePath = activeServerThread.worktreePath; - const api = readEnvironmentApi(activeThreadRef.environmentId); - if (!api) { - // No connection means the change cannot be saved at all; applying - // the optimistic update anyway would leave the label lying. - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Couldn't save the branch change", - description: "Not connected to the environment. Try again once it reconnects.", - }), - ); - return; - } - const snapshot = { - branch: activeServerThread.branch, - worktreePath, - session: activeServerThread.session ?? null, - }; - branchDispatchIdRef.current += 1; - const dispatchId = branchDispatchIdRef.current; - void api.orchestration - .dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: activeThreadRef.threadId, - branch, - worktreePath, - }) - .catch(() => { - // Roll the optimistic update back; a branch label that silently - // disagrees with the server is a lying UI. A stale rejection - // never overwrites a newer dispatch's state. - if (branchDispatchIdRef.current === dispatchId) { - restoreThreadCheckout(activeThreadRef, snapshot); - } - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Couldn't save the branch change", - description: "The update didn't reach the server. Try again.", - }), - ); - }); - - setThreadBranch(activeThreadRef, branch, worktreePath); + selectServerThreadCheckout(branch, activeServerThread.worktreePath); return; } @@ -1235,9 +1188,8 @@ export default function GitActionsControl({ activeServerThread, activeThreadRef, draftId, - restoreThreadCheckout, + selectServerThreadCheckout, setDraftThreadContext, - setThreadBranch, ], ); diff --git a/apps/web/src/components/source-control/SourceControlPanel.browser.tsx b/apps/web/src/components/source-control/SourceControlPanel.browser.tsx index 433c8e90..ea6e1ab3 100644 --- a/apps/web/src/components/source-control/SourceControlPanel.browser.tsx +++ b/apps/web/src/components/source-control/SourceControlPanel.browser.tsx @@ -345,6 +345,7 @@ function seedWorktreeThread(worktreePath: string): void { }, }, threadIds: [threadId], + threadIdsByProjectId: { [projectId]: [threadId] }, threadShellById: { [threadId]: { id: threadId, @@ -369,6 +370,8 @@ function seedWorktreeThread(worktreePath: string): void { proposedPlanByThreadId: {}, turnDiffIdsByThreadId: {}, turnDiffSummaryByThreadId: {}, + sidebarThreadSummaryById: {}, + bootstrapComplete: true, }, }, } as never); @@ -1900,7 +1903,7 @@ describe("SourceControlPanel changes", () => { } }); - it("clears worktree context when switching a worktree target to the primary checkout", async () => { + it("can leave a dirty worktree for the primary checkout without rewriting either checkout", async () => { const projectCwd = "C:\\Users\\Ada\\Code\\Threadlines"; const primaryWorktreePath = "c:/users/ada/code/threadlines/"; const worktreePath = "C:\\Users\\Ada\\.threadlines\\worktrees\\feature-source-control"; @@ -1927,7 +1930,9 @@ describe("SourceControlPanel changes", () => { const switchRef: EnvironmentApi["vcs"]["switchRef"] = vi.fn(async (input) => ({ refName: input.refName, })); - const onActiveBranchChange = vi.fn(); + const dispatchCommand = vi.fn(async () => ({ sequence: 1 })); + const liveThreadId = ThreadId.make("source-control-live-thread"); + seedWorktreeThread(worktreePath); const mounted = await renderPanel({ target: { ...TARGET, @@ -1935,9 +1940,28 @@ describe("SourceControlPanel changes", () => { cwd: worktreePath, worktreePath, }, - status: makeStatus({ refName: "feature/source-control" }), - environmentApi: makeEnvironmentApi({ vcs: { listRefs, switchRef } }), - onActiveBranchChange, + status: makeStatus({ + refName: "feature/source-control", + hasWorkingTreeChanges: true, + workingTree: { + files: [ + { + path: "src/changed.ts", + indexStatus: null, + worktreeStatus: "modified", + insertions: 1, + deletions: 0, + }, + ], + insertions: 1, + deletions: 0, + }, + }), + activeThreadRef: scopeThreadRef(ENVIRONMENT_ID, liveThreadId), + environmentApi: makeEnvironmentApi({ + vcs: { listRefs, switchRef }, + orchestration: { dispatchCommand }, + }), }); try { @@ -1951,12 +1975,79 @@ describe("SourceControlPanel changes", () => { await expect.element(mainMenuItem).toBeVisible(); await mainMenuItem.click(); + expect(switchRef).not.toHaveBeenCalled(); + expect(dispatchCommand).toHaveBeenCalledWith( + expect.objectContaining({ + type: "thread.checkout.select", + threadId: "source-control-live-thread", + branch: "main", + worktreePath: null, + }), + ); + const selected = + useStore.getState().environmentStateById[ENVIRONMENT_ID]?.threadShellById[liveThreadId]; + expect(selected).toMatchObject({ branch: "main", worktreePath: null, effectiveCwd: null }); + } finally { + await mounted.cleanup(); + resetSeededThreads(); + } + }); + + it("restores the checkout when the shared selection command is rejected", async () => { + const worktreePath = "C:\\Users\\Ada\\.threadlines\\worktrees\\feature-source-control"; + const listRefs: EnvironmentApi["vcs"]["listRefs"] = vi.fn(async () => ({ + refs: [ + { + name: "main", + current: false, + isDefault: true, + isRemote: false, + worktreePath: CWD, + }, + { + name: "feature/source-control", + current: true, + isDefault: false, + isRemote: false, + worktreePath, + }, + ], + isRepo: true, + hasPrimaryRemote: true, + nextCursor: null, + totalCount: 2, + })); + const dispatchCommand = vi.fn(async () => { + throw new Error("offline"); + }); + const liveThreadId = ThreadId.make("source-control-live-thread"); + seedWorktreeThread(worktreePath); + const mounted = await renderPanel({ + target: { ...TARGET, cwd: worktreePath, worktreePath }, + status: makeStatus({ refName: "feature/source-control" }), + activeThreadRef: scopeThreadRef(ENVIRONMENT_ID, liveThreadId), + environmentApi: makeEnvironmentApi({ + vcs: { listRefs }, + orchestration: { dispatchCommand }, + }), + }); + + try { + await page.getByRole("button", { name: "Branch: feature/source-control" }).click(); + await page.getByText("Switch to").hover(); + await page.getByRole("menuitem", { name: /main/ }).click(); + await vi.waitFor(() => { - expect(switchRef).toHaveBeenCalledWith({ cwd: primaryWorktreePath, refName: "main" }); + const restored = + useStore.getState().environmentStateById[ENVIRONMENT_ID]?.threadShellById[liveThreadId]; + expect(restored).toMatchObject({ branch: null, worktreePath, effectiveCwd: null }); }); - expect(onActiveBranchChange).toHaveBeenCalledWith("main", null); + expect(gitActionMock.toastAdd).toHaveBeenCalledWith( + expect.objectContaining({ title: "Couldn't move the thread" }), + ); } finally { await mounted.cleanup(); + resetSeededThreads(); } }); diff --git a/apps/web/src/components/source-control/SourceControlPanel.tsx b/apps/web/src/components/source-control/SourceControlPanel.tsx index 4c94839c..faabeeee 100644 --- a/apps/web/src/components/source-control/SourceControlPanel.tsx +++ b/apps/web/src/components/source-control/SourceControlPanel.tsx @@ -107,10 +107,11 @@ import { useGitStatus, } from "~/lib/gitStatusState"; import { copyTextToClipboard } from "~/lib/clipboard"; -import { cn, newCommandId, newThreadId, randomUUID } from "~/lib/utils"; +import { cn, newThreadId, randomUUID } from "~/lib/utils"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { useSettings } from "~/hooks/useSettings"; import { useCheckoutRecovery } from "~/hooks/useCheckoutRecovery"; +import { useThreadCheckoutSelection } from "~/hooks/useThreadCheckoutSelection"; import { readLocalApi } from "~/localApi"; import { useComposerDraftStore } from "~/composerDraftStore"; import { @@ -1471,6 +1472,9 @@ function getBranchActionDisabledReason(input: { readonly isBusy: boolean; readonly action: "switch" | "create" | "merge"; readonly repositorySafetyReason?: string | null; + /** Cross-checkout picks do not rewrite this working tree, so keep the menu + * reachable and validate the selected ref in the handler. */ + readonly allowDirtyCheckoutExit?: boolean; }): string | null { if (input.repositorySafetyReason) { return input.repositorySafetyReason; @@ -1482,7 +1486,7 @@ function getBranchActionDisabledReason(input: { return "No Git repository."; } if (input.action === "switch") { - if (input.status.hasWorkingTreeChanges) { + if (input.status.hasWorkingTreeChanges && input.allowDirtyCheckoutExit !== true) { return "Commit or stash changes before switching branches."; } } @@ -1816,14 +1820,14 @@ function SourceControlBranchMenu({ readonly refreshPanel: () => void; }) { const queryClient = useQueryClient(); - const setThreadBranch = useStore((store) => store.setThreadBranch); - const restoreThreadCheckout = useStore((store) => store.restoreThreadCheckout); - // Identifies the newest optimistic checkout dispatch so a stale rejection - // cannot roll back state a later dispatch already replaced. - const checkoutDispatchIdRef = useRef(0); - const activeThreadSession = - useStore(useMemo(() => createThreadSelectorByRef(activeThreadRef), [activeThreadRef])) - ?.session ?? null; + const activeThread = useStore( + useMemo(() => createThreadSelectorByRef(activeThreadRef), [activeThreadRef]), + ); + const activeThreadSession = activeThread?.session ?? null; + const selectServerThreadCheckout = useThreadCheckoutSelection({ + threadRef: activeThread ? activeThreadRef : null, + thread: activeThread ?? null, + }); const [pendingWorkingTreeSwitchRef, setPendingWorkingTreeSwitchRef] = useState( null, ); @@ -1885,7 +1889,11 @@ function SourceControlBranchMenu({ status, isBusy: isBusy || checkoutMutation.isPending || createBranchMutation.isPending, action: "switch", - repositorySafetyReason, + // Picking a branch that already has another checkout only changes thread + // metadata. The handler still enforces repository safety before any Git + // mutation inside the checkout currently shown here. + repositorySafetyReason: null, + allowDirtyCheckoutExit: true, }); const createDisabledReason = getBranchActionDisabledReason({ status, @@ -1909,71 +1917,16 @@ function SourceControlBranchMenu({ if (!activeThreadRef) { return; } - const api = readEnvironmentApi(target.environmentId); - if (!api) { - // No connection means the switch cannot happen at all; applying the - // optimistic update anyway would leave the panel lying indefinitely. - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Couldn't move the thread", - description: "Not connected to the environment. Try again once it reconnects.", - }), - ); - return; - } - const snapshot = { - branch: currentBranch, - worktreePath: target.worktreePath, - session: activeThreadSession, - }; - checkoutDispatchIdRef.current += 1; - const dispatchId = checkoutDispatchIdRef.current; - void api.orchestration - .dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: activeThreadRef.threadId, - branch, - worktreePath, - }) - .catch(() => { - // Roll the optimistic update back to what the panel showed before — - // including the session the optimistic switch cleared. A switch - // that silently stays put is this panel's worst failure mode, and - // one that lies about having happened is the second. A stale - // rejection never overwrites a newer dispatch's state. - if (checkoutDispatchIdRef.current === dispatchId) { - restoreThreadCheckout(activeThreadRef, snapshot); - } - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Couldn't move the thread", - description: "The checkout switch didn't reach the server. Try again.", - }), - ); - }); - setThreadBranch(activeThreadRef, branch, worktreePath); + selectServerThreadCheckout(branch, worktreePath); }, - [ - activeThreadRef, - activeThreadSession, - currentBranch, - onActiveBranchChange, - restoreThreadCheckout, - setThreadBranch, - target.environmentId, - target.worktreePath, - ], + [activeThreadRef, onActiveBranchChange, selectServerThreadCheckout, target.worktreePath], ); /** * Points the thread at a checkout. Nothing runs in git: the thread records - * where its next turn belongs, and the server cycles the runtime there when - * that turn is dispatched. A pick that leaves the live session in a different - * checkout is queued, not applied, and the composer chip saying so is easy to - * miss, so it is announced here too. + * where its next turn belongs, and the server cycles an idle runtime there as + * soon as it is safe. An active turn finishes first, so that queued move is + * announced here too. */ const applyCheckoutSwitch = useCallback( (branch: string | null, nextWorktreePath: string | null) => { @@ -2021,6 +1974,10 @@ function SourceControlBranchMenu({ activeWorktreePath: target.worktreePath, refName: ref, }); + if (selectionTarget.reuseExistingWorktree) { + applyCheckoutSwitch(ref.name, selectionTarget.nextWorktreePath); + return; + } const promise = checkoutMutation .mutateAsync({ cwd: selectionTarget.checkoutCwd, refName: ref.name }) .then((result) => { @@ -2061,15 +2018,19 @@ function SourceControlBranchMenu({ const runSwitchRef = useCallback( (ref: VcsRef) => { - if (repositorySafetyReason) { - notifyRepositorySafetyBlocked(repositorySafetyReason); - return; - } const selectionTarget = resolveBranchSelectionTarget({ activeProjectCwd: target.projectCwd, activeWorktreePath: target.worktreePath, refName: ref, }); + if (!selectionTarget.reuseExistingWorktree && repositorySafetyReason) { + notifyRepositorySafetyBlocked(repositorySafetyReason); + return; + } + if (!selectionTarget.reuseExistingWorktree && status?.hasWorkingTreeChanges) { + notifyRepositorySafetyBlocked("Commit or stash changes before switching branches."); + return; + } // Switching a branch inside the checkout an agent is working in swaps // its files mid-turn. Selecting a ref that lives in another checkout is // a checkout switch instead, and the next turn picks that up on its own. @@ -2084,6 +2045,7 @@ function SourceControlBranchMenu({ executeSwitchRef, notifyRepositorySafetyBlocked, repositorySafetyReason, + status?.hasWorkingTreeChanges, target.projectCwd, target.worktreePath, ], diff --git a/apps/web/src/hooks/useCheckoutRecovery.ts b/apps/web/src/hooks/useCheckoutRecovery.ts index 4c9d77c9..53176bad 100644 --- a/apps/web/src/hooks/useCheckoutRecovery.ts +++ b/apps/web/src/hooks/useCheckoutRecovery.ts @@ -123,9 +123,10 @@ export function useCheckoutRecovery(input: { // into it on the next turn. void run((api) => api.orchestration.dispatchCommand({ - type: "thread.meta.update", + type: "thread.checkout.select", commandId: newCommandId(), threadId, + branch: null, worktreePath: null, }), ); diff --git a/apps/web/src/hooks/useThreadCheckoutSelection.ts b/apps/web/src/hooks/useThreadCheckoutSelection.ts new file mode 100644 index 00000000..73a3c85e --- /dev/null +++ b/apps/web/src/hooks/useThreadCheckoutSelection.ts @@ -0,0 +1,104 @@ +import type { ScopedThreadRef } from "@threadlines/contracts"; +import { useCallback } from "react"; + +import { readEnvironmentApi } from "../environmentApi"; +import { newCommandId } from "../lib/utils"; +import { useStore } from "../store"; +import type { ThreadSession } from "../types"; +import { stackedThreadToast, toastManager } from "../components/ui/toast"; + +interface SelectedThreadCheckout { + readonly branch: string | null; + readonly worktreePath: string | null; + readonly effectiveCwd: string | null; + readonly session: ThreadSession | null; +} + +let nextCheckoutDispatchId = 0; +const latestCheckoutDispatchByThread = new Map(); + +function checkoutDispatchKey(threadRef: ScopedThreadRef): string { + return `${threadRef.environmentId}\0${threadRef.threadId}`; +} + +/** + * One durable checkout-selection action shared by the composer and Source. + * It owns connection checks, optimistic state, stale-safe rollback, and the + * explicit-selection command that overrides inferred subagent following. + */ +export function useThreadCheckoutSelection(input: { + readonly threadRef: ScopedThreadRef | null; + readonly thread: SelectedThreadCheckout | null; + readonly onBranchOverrideChange?: ((branch: string | null) => void) | undefined; +}) { + const { threadRef, thread, onBranchOverrideChange } = input; + const selectThreadCheckout = useStore((store) => store.selectThreadCheckout); + const restoreThreadCheckout = useStore((store) => store.restoreThreadCheckout); + + return useCallback( + (branch: string | null, worktreePath: string | null): boolean => { + if (!threadRef || !thread) { + return false; + } + const api = readEnvironmentApi(threadRef.environmentId); + if (!api) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Couldn't move the thread", + description: "Not connected to the environment. Try again once it reconnects.", + }), + ); + return false; + } + + const snapshot = { + branch: thread.branch, + worktreePath: thread.worktreePath, + effectiveCwd: thread.effectiveCwd, + session: thread.session, + }; + nextCheckoutDispatchId += 1; + const dispatchId = nextCheckoutDispatchId; + const dispatchKey = checkoutDispatchKey(threadRef); + latestCheckoutDispatchByThread.set(dispatchKey, dispatchId); + void api.orchestration + .dispatchCommand({ + type: "thread.checkout.select", + commandId: newCommandId(), + threadId: threadRef.threadId, + branch, + worktreePath, + }) + .then( + () => { + if (latestCheckoutDispatchByThread.get(dispatchKey) === dispatchId) { + latestCheckoutDispatchByThread.delete(dispatchKey); + } + }, + () => { + // The composer and Source can both issue this action. Only the + // newest selection across every mounted surface may roll back. + if (latestCheckoutDispatchByThread.get(dispatchKey) !== dispatchId) { + return; + } + latestCheckoutDispatchByThread.delete(dispatchKey); + restoreThreadCheckout(threadRef, snapshot); + onBranchOverrideChange?.(snapshot.branch); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Couldn't move the thread", + description: "The checkout switch didn't reach the server. Try again.", + }), + ); + }, + ); + + onBranchOverrideChange?.(branch); + selectThreadCheckout(threadRef, branch, worktreePath); + return true; + }, + [onBranchOverrideChange, restoreThreadCheckout, selectThreadCheckout, thread, threadRef], + ); +} diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index e568703b..1e2a91aa 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -25,6 +25,7 @@ import { selectThreadExistsByRef, selectSidebarThreadsForProjectRef, restoreThreadCheckout, + selectThreadCheckout, setThreadBranch, selectThreadsAcrossEnvironments, type AppState, @@ -427,6 +428,22 @@ describe("thread selection memoization", () => { }); describe("setThreadBranch", () => { + it("an explicit main selection replaces a followed worktree immediately", () => { + const thread = makeThread({ + branch: "main", + worktreePath: null, + effectiveCwd: "/tmp/project-worktree", + }); + const state = makeState(thread); + const threadRef = scopeThreadRef(localEnvironmentId, thread.id); + + const next = selectThreadCheckout(state, threadRef, "main", null); + const shell = environmentStateOf(next, localEnvironmentId).threadShellById[thread.id]; + expect(shell?.branch).toBe("main"); + expect(shell?.worktreePath).toBeNull(); + expect(shell?.effectiveCwd).toBeNull(); + }); + it("updates only the scoped thread environment", () => { const sharedThreadId = ThreadId.make("thread-shared"); const localThread = makeThread({ @@ -477,24 +494,31 @@ describe("setThreadBranch", () => { const thread = makeThread({ branch: "feature-a", worktreePath: "/tmp/worktree-a", + effectiveCwd: "/tmp/worktree-a", session, }); const state = makeState(thread); const threadRef = scopeThreadRef(localEnvironmentId, thread.id); - const afterOptimistic = setThreadBranch(state, threadRef, "main", null); + const afterOptimistic = selectThreadCheckout(state, threadRef, "main", null); expect( environmentStateOf(afterOptimistic, localEnvironmentId).threadSessionById[thread.id], ).toBeNull(); + expect( + environmentStateOf(afterOptimistic, localEnvironmentId).threadShellById[thread.id] + ?.effectiveCwd, + ).toBeNull(); const restored = restoreThreadCheckout(afterOptimistic, threadRef, { branch: "feature-a", worktreePath: "/tmp/worktree-a", + effectiveCwd: "/tmp/worktree-a", session, }); const shell = environmentStateOf(restored, localEnvironmentId).threadShellById[thread.id]; expect(shell?.branch).toBe("feature-a"); expect(shell?.worktreePath).toBe("/tmp/worktree-a"); + expect(shell?.effectiveCwd).toBe("/tmp/worktree-a"); expect(environmentStateOf(restored, localEnvironmentId).threadSessionById[thread.id]).toEqual( session, ); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index ab27a8ef..606461d9 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -2370,11 +2370,34 @@ export function setThreadBranch( return commitEnvironmentState(state, threadRef.environmentId, nextEnvironmentState); } +/** Optimistically applies the user's checkout selection, including the cwd + * surfaces should show while the provider catches up. */ +export function selectThreadCheckout( + state: AppState, + threadRef: ScopedThreadRef, + branch: string | null, + worktreePath: string | null, +): AppState { + const environmentState = getStoredEnvironmentState(state, threadRef.environmentId); + const nextEnvironmentState = updateThreadState(environmentState, threadRef.threadId, (thread) => { + const cwdChanged = thread.worktreePath !== worktreePath; + return { + ...thread, + branch, + worktreePath, + // `effectiveCwd` is observed runtime state, not the configured checkout. + // The server keeps a separate selection marker while old work settles. + effectiveCwd: null, + ...(cwdChanged ? { session: null } : {}), + }; + }); + return commitEnvironmentState(state, threadRef.environmentId, nextEnvironmentState); +} + /** * Restores a checkout snapshot captured before an optimistic - * `setThreadBranch`, including the session that call clears when the cwd - * changes. Used to roll back a thread.meta.update dispatch that never - * reached the server; plain `setThreadBranch` cannot bring the session back. + * a branch/checkout update, including the session an optimistic cwd change + * clears and the effective cwd an explicit selection replaces. */ export function restoreThreadCheckout( state: AppState, @@ -2383,6 +2406,7 @@ export function restoreThreadCheckout( readonly branch: string | null; readonly worktreePath: string | null; readonly session: ThreadSession | null; + readonly effectiveCwd?: string | null; }, ): AppState { const nextEnvironmentState = updateThreadState( @@ -2393,6 +2417,7 @@ export function restoreThreadCheckout( branch: snapshot.branch, worktreePath: snapshot.worktreePath, session: snapshot.session, + ...(snapshot.effectiveCwd !== undefined ? { effectiveCwd: snapshot.effectiveCwd } : {}), }), ); return commitEnvironmentState(state, threadRef.environmentId, nextEnvironmentState); @@ -2418,12 +2443,18 @@ interface AppStore extends AppState { branch: string | null, worktreePath: string | null, ) => void; + selectThreadCheckout: ( + threadRef: ScopedThreadRef, + branch: string | null, + worktreePath: string | null, + ) => void; restoreThreadCheckout: ( threadRef: ScopedThreadRef, snapshot: { readonly branch: string | null; readonly worktreePath: string | null; readonly session: ThreadSession | null; + readonly effectiveCwd?: string | null; }, ) => void; } @@ -2447,6 +2478,8 @@ export const useStore = create((set) => ({ setError: (threadId, error) => set((state) => setError(state, threadId, error)), setThreadBranch: (threadRef, branch, worktreePath) => set((state) => setThreadBranch(state, threadRef, branch, worktreePath)), + selectThreadCheckout: (threadRef, branch, worktreePath) => + set((state) => selectThreadCheckout(state, threadRef, branch, worktreePath)), restoreThreadCheckout: (threadRef, snapshot) => set((state) => restoreThreadCheckout(state, threadRef, snapshot)), })); diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index ccf68aa3..0302cbd3 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -443,6 +443,26 @@ it.effect("decodes thread.meta-updated payloads with explicit provider", () => }), ); +it.effect("decodes an explicit thread checkout selection", () => + Effect.gen(function* () { + const parsed = yield* decodeClientOrchestrationCommand({ + type: "thread.checkout.select", + commandId: "cmd-checkout-select", + threadId: "thread-1", + branch: "main", + worktreePath: null, + }); + + assert.deepStrictEqual(parsed, { + type: "thread.checkout.select", + commandId: "cmd-checkout-select", + threadId: "thread-1", + branch: "main", + worktreePath: null, + }); + }), +); + it.effect("decodes thread archive, unarchive, pin, and unpin commands", () => Effect.gen(function* () { const archive = yield* decodeOrchestrationCommand({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 9daab58e..c257370e 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -460,11 +460,11 @@ export type ThreadForkSeedOutcomePayload = typeof ThreadForkSeedOutcomePayload.T /** * Payload of the `thread.checkout.switch-deferred` activity. A queued checkout - * switch normally applies by cycling the provider session when the next turn - * dispatches, but the session's background tasks (subagents, backgrounded - * commands) live inside that runtime and would be killed. When any are still - * running the turn runs in the session's current checkout instead and the - * switch stays queued; this activity records that once per deferral streak. + * switch applies as soon as the provider session is idle, but the session's + * background tasks (subagents, backgrounded commands) live inside that runtime + * and would be killed. While any are still running, turns stay in the current + * checkout and the switch remains queued until those tasks settle; this + * activity records that once per deferral streak. */ export const ThreadCheckoutSwitchDeferredActivityKind = "thread.checkout.switch-deferred"; export const ThreadCheckoutSwitchDeferredPayload = Schema.Struct({ @@ -725,7 +725,7 @@ export const OrchestrationLatestTurn = Schema.Struct({ export type OrchestrationLatestTurn = typeof OrchestrationLatestTurn.Type; /** Where a thread's `effectiveCwd` came from. See OrchestrationThread. */ -export const ThreadEffectiveCwdSource = Schema.Literals(["session", "subagent"]); +export const ThreadEffectiveCwdSource = Schema.Literals(["session", "subagent", "selection"]); export type ThreadEffectiveCwdSource = typeof ThreadEffectiveCwdSource.Type; export const OrchestrationThread = Schema.Struct({ @@ -749,12 +749,12 @@ export const OrchestrationThread = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(null)), ), /** - * Why `effectiveCwd` holds what it holds. `session` means the provider - * session itself reported working there; `subagent` means it was inferred - * from an isolated subagent's checkout. Inference must never overwrite or - * clear a session-sourced value, so this has to outlive a server restart. - * Clients do not need to read it — the same chip renders either way. - * Always null while `effectiveCwd` is null. + * Why the effective-cwd state is authoritative. `session` means the provider + * reported the divergent cwd; `subagent` means it was inferred from an + * isolated subagent; `selection` is a temporary marker that the user chose + * the configured checkout while prior work was still active. That marker has + * a null `effectiveCwd`, so configured and observed cwd remain distinct. + * Clients do not need to read it — only server-side inference does. */ effectiveCwdSource: Schema.optional(Schema.NullOr(ThreadEffectiveCwdSource)), goal: Schema.NullOr(OrchestrationThreadGoal).pipe( @@ -1105,6 +1105,19 @@ const ThreadMetaUpdateCommand = Schema.Struct({ worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), }); +/** + * Select the checkout a thread should show and use for its next safe turn. + * Kept separate from generic metadata updates so provider-driven branch/title + * bookkeeping cannot accidentally override an explicit user selection. + */ +const ThreadCheckoutSelectCommand = Schema.Struct({ + type: Schema.Literal("thread.checkout.select"), + commandId: CommandId, + threadId: ThreadId, + branch: Schema.NullOr(TrimmedNonEmptyString), + worktreePath: Schema.NullOr(TrimmedNonEmptyString), +}); + const ThreadRuntimeModeSetCommand = Schema.Struct({ type: Schema.Literal("thread.runtime-mode.set"), commandId: CommandId, @@ -1345,6 +1358,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadDoneOverrideSetCommand, ThreadSeenSetCommand, ThreadMetaUpdateCommand, + ThreadCheckoutSelectCommand, ThreadForkCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -1379,6 +1393,7 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadDoneOverrideSetCommand, ThreadSeenSetCommand, ThreadMetaUpdateCommand, + ThreadCheckoutSelectCommand, ClientThreadForkCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -1425,8 +1440,8 @@ const ThreadEffectiveCwdSetCommand = Schema.Struct({ commandId: CommandId, threadId: ThreadId, effectiveCwd: Schema.NullOr(TrimmedNonEmptyString), - /** Omitted means `session`, which is what every emitter did before subagent - * worktree inference existed. Ignored when `effectiveCwd` is null. */ + /** Omitted means `session` for a non-null cwd. Subagent emitters include + * their source even when clearing so stale inference can be rejected. */ effectiveCwdSource: Schema.optional(ThreadEffectiveCwdSource), createdAt: IsoDateTime, }); @@ -1841,8 +1856,8 @@ export const ThreadSessionSetPayload = Schema.Struct({ export const ThreadEffectiveCwdSetPayload = Schema.Struct({ threadId: ThreadId, effectiveCwd: Schema.NullOr(TrimmedNonEmptyString), - /** Absent on events recorded before subagent worktree inference shipped; - * those were all session-sourced. */ + /** Absent on events recorded before cwd source tracking shipped; those were + * all session-sourced. */ effectiveCwdSource: Schema.optional(ThreadEffectiveCwdSource), updatedAt: IsoDateTime, });