From c8cfca4f3464d110fe740521a6c3260f53838344 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Mon, 10 Aug 2026 02:46:39 -0400 Subject: [PATCH] Apply queued checkout switches on idle and announce them at pick time A checkout switch used to wait for the user's next message, and while a subagent ran in the target worktree the only signal was a small chip by the composer. Two changes: - The reactor now applies a queued switch as soon as the session is safe to move: idle status, no active turn, no pending background tasks. Triggered from thread.meta-updated (pick while idle) and thread.session-set (background tasks settling, turn finishing), so the source control panel and diff surfaces follow without needing a new message. - Picking a branch that queues a switch behind a busy session now raises an info toast from both the composer branch picker and the source control panel, saying where the agent still is and when it will move. --- .../Layers/ProviderCommandReactor.test.ts | 148 ++++++++++++++++++ .../Layers/ProviderCommandReactor.ts | 94 +++++++++++ .../components/BranchToolbar.logic.test.ts | 66 ++++++++ .../web/src/components/BranchToolbar.logic.ts | 54 ++++++- .../BranchToolbarBranchSelector.tsx | 16 ++ .../source-control/SourceControlPanel.tsx | 18 ++- 6 files changed, 390 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index eea8c49b4..96acfbae8 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -2196,6 +2196,154 @@ describe("ProviderCommandReactor", () => { }); }); + it("applies a queued checkout switch on its own once background tasks settle", async () => { + const harness = await createHarness(); + const threadId = ThreadId.make("thread-1"); + const now = "2026-01-01T00:00:00.000Z"; + const settleSession = async (pendingBackgroundTaskCount: number, commandSuffix: string) => { + const snapshot = await harness.readModel(); + const session = snapshot.threads.find((thread) => thread.id === threadId)?.session; + if (!session) throw new Error("expected a projected session for thread-1"); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make(`cmd-idle-apply-${commandSuffix}`), + threadId, + session: { + ...session, + status: "ready", + activeTurnId: null, + pendingBackgroundTaskCount, + updatedAt: now, + }, + createdAt: now, + }), + ); + }; + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-idle-apply-1"), + threadId, + message: { + messageId: asMessageId("user-message-idle-apply-1"), + role: "user", + text: "first in project root", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + // The turn finished but a subagent still runs inside the session, and the + // user queues a checkout switch on top of it. Nothing may cycle yet. + await settleSession(1, "busy"); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-worktree-change-idle-apply"), + threadId, + worktreePath: "/tmp/provider-project-worktree", + }), + ); + await waitFor(async () => { + const snapshot = await harness.readModel(); + return ( + snapshot.threads.find((thread) => thread.id === threadId)?.worktreePath === + "/tmp/provider-project-worktree" + ); + }); + expect(harness.startSession.mock.calls.length).toBe(1); + + // The background task finishing is enough to apply the switch: no new + // user message is dispatched, the session cycles on the settle alone. + await settleSession(0, "settled"); + await waitFor(() => harness.startSession.mock.calls.length === 2); + expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ + threadId, + cwd: "/tmp/provider-project-worktree", + resumeCursor: { opaque: "resume-1" }, + }); + expect(harness.sendTurn.mock.calls.length).toBe(1); + expect(harness.stopSession.mock.calls.length).toBe(0); + + // The restarted session reports the new checkout, which clears the + // pending-switch chip on the client. + await waitFor(async () => { + const snapshot = await harness.readModel(); + return ( + snapshot.threads.find((thread) => thread.id === threadId)?.session?.checkoutCwd === + "/tmp/provider-project-worktree" + ); + }); + }); + + it("applies a checkout switch immediately when the session is idle", async () => { + const harness = await createHarness(); + const threadId = ThreadId.make("thread-1"); + const now = "2026-01-01T00:00:00.000Z"; + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-idle-pick-1"), + threadId, + message: { + messageId: asMessageId("user-message-idle-pick-1"), + role: "user", + text: "first in project root", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + const snapshot = await harness.readModel(); + const session = snapshot.threads.find((thread) => thread.id === threadId)?.session; + if (!session) throw new Error("expected a projected session for thread-1"); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-idle-pick-settle"), + threadId, + session: { + ...session, + status: "ready", + activeTurnId: null, + pendingBackgroundTaskCount: 0, + updatedAt: now, + }, + createdAt: now, + }), + ); + + // Picking a different checkout while the session sits idle cycles it + // right away instead of waiting for the next message. + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-worktree-change-idle-pick"), + threadId, + worktreePath: "/tmp/provider-project-worktree", + }), + ); + await waitFor(() => harness.startSession.mock.calls.length === 2); + expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ + threadId, + cwd: "/tmp/provider-project-worktree", + resumeCursor: { opaque: "resume-1" }, + }); + expect(harness.sendTurn.mock.calls.length).toBe(1); + expect(harness.stopSession.mock.calls.length).toBe(0); + }); + it("keeps a running turn in its original checkout when a follow-up arrives after a checkout switch", async () => { const harness = await createHarness(); const threadId = ThreadId.make("thread-1"); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 2ea609393..0b502a594 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -86,6 +86,7 @@ type ProviderIntentEvent = Extract< { type: | "thread.runtime-mode-set" + | "thread.meta-updated" | "thread.turn-start-requested" | "thread.follow-up-submitted" | "thread.turn-interrupt-requested" @@ -1959,6 +1960,79 @@ const make = Effect.gen(function* () { }); }); + /** + * Session statuses in which a queued checkout switch can apply right away: + * the runtime is alive but owns no in-flight turn whose files a restart + * would swap out from under it. + */ + const idleSessionStatuses: ReadonlySet = new Set([ + "idle", + "ready", + "interrupted", + ]); + + /** + * A queued checkout switch normally applies when the next turn is + * dispatched. When the session is already idle with no background tasks + * left, waiting for the user's next message just leaves every surface + * pointed at the old checkout, so cycle the session now instead. Only a + * live, projected-and-bound session is moved — a thread without a running + * runtime keeps lazy semantics (its next turn starts in the right place). + */ + const maybeApplyQueuedCheckoutSwitch = Effect.fnUntraced(function* ( + threadId: ThreadId, + occurredAt: string, + ) { + const thread = yield* resolveThread(threadId); + const session = thread?.session; + if (!thread || !session || !idleSessionStatuses.has(session.status)) { + return; + } + if (session.activeTurnId !== null || (session.pendingBackgroundTaskCount ?? 0) > 0) { + return; + } + // The projected checkoutCwd is rewritten on every (re)bind; null means we + // never learned where the runtime runs, so leave the switch to the next + // turn dispatch rather than guessing. + const sessionCheckoutCwd = session.checkoutCwd ?? undefined; + if (sessionCheckoutCwd === undefined) { + return; + } + const project = yield* resolveProject(thread.projectId); + if (!project || project.kind === "general-chat") { + return; + } + const targetCwd = resolveThreadWorkspaceCwd({ thread, projects: [project] }); + if (!targetCwd || isSameWorkspaceCwd(targetCwd, sessionCheckoutCwd)) { + return; + } + const activeSession = (yield* providerService.listSessions()).find( + (candidate) => candidate.threadId === threadId, + ); + if (!activeSession || isSameWorkspaceCwd(targetCwd, activeSession.cwd)) { + return; + } + yield* Effect.logInfo("provider command reactor applying queued checkout switch on idle", { + threadId, + fromCwd: sessionCheckoutCwd, + toCwd: targetCwd, + }); + const cachedModelSelection = threadModelSelections.get(threadId); + yield* ensureSessionForThread( + threadId, + occurredAt, + cachedModelSelection !== undefined ? { modelSelection: cachedModelSelection } : {}, + ).pipe( + Effect.catch((error) => + Effect.logWarning("provider command reactor failed to apply queued checkout switch", { + threadId, + toCwd: targetCwd, + detail: String(error), + }), + ), + ); + }); + /** * Pending approval / user-input prompts are answered through the live * provider session; once that session stops (explicit stop, inactivity @@ -1971,6 +2045,16 @@ const make = Effect.gen(function* () { event: Extract, ) { if (event.payload.session.status !== "stopped") { + // Cheap payload precheck; the apply path re-validates against the + // freshly projected thread before touching the session. + const session = event.payload.session; + if ( + idleSessionStatuses.has(session.status) && + session.activeTurnId === null && + (session.pendingBackgroundTaskCount ?? 0) === 0 + ) { + yield* maybeApplyQueuedCheckoutSwitch(event.payload.threadId, event.occurredAt); + } return; } const thread = yield* resolveThread(event.payload.threadId); @@ -2025,6 +2109,15 @@ const make = Effect.gen(function* () { ); return; } + case "thread.meta-updated": { + // Only a checkout retarget can queue a switch; title/model updates + // (which fire constantly, e.g. auto-titling) never need this. + if (event.payload.worktreePath === undefined) { + return; + } + yield* maybeApplyQueuedCheckoutSwitch(event.payload.threadId, event.occurredAt); + return; + } case "thread.turn-start-requested": yield* processTurnStartRequested(event); return; @@ -2088,6 +2181,7 @@ const make = Effect.gen(function* () { const processEvent = Effect.fn("processEvent")(function* (event: OrchestrationEvent) { if ( event.type === "thread.runtime-mode-set" || + event.type === "thread.meta-updated" || event.type === "thread.turn-start-requested" || event.type === "thread.follow-up-submitted" || event.type === "thread.turn-interrupt-requested" || diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 843294780..c3ea0d256 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -13,6 +13,7 @@ import { resolveBranchToolbarValue, resolveLockedWorkspaceLabel, resolvePendingCheckoutSwitch, + queuedCheckoutSwitchToast, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; @@ -480,6 +481,71 @@ describe("resolvePendingCheckoutSwitch", () => { }); }); +describe("queuedCheckoutSwitchToast", () => { + it("announces a switch queued behind a running background task", () => { + const toast = queuedCheckoutSwitchToast({ + session: { + orchestrationStatus: "running", + checkoutCwd: "/repo", + pendingBackgroundTaskCount: 1, + }, + activeProjectCwd: "/repo", + nextWorktreePath: "/repo/.threadlines/worktrees/feature-a", + }); + expect(toast?.title).toBe("Checkout switch queued"); + expect(toast?.description).toContain("background task"); + expect(toast?.description).toContain("feature-a"); + }); + + it("announces a switch queued behind the running turn", () => { + const toast = queuedCheckoutSwitchToast({ + session: { + orchestrationStatus: "running", + checkoutCwd: "/repo", + pendingBackgroundTaskCount: 0, + }, + activeProjectCwd: "/repo", + nextWorktreePath: "/repo/.threadlines/worktrees/feature-a", + }); + expect(toast?.description).toContain("current turn"); + }); + + it("stays quiet when the session is idle: the server applies the switch right away", () => { + expect( + queuedCheckoutSwitchToast({ + session: { + orchestrationStatus: "ready", + checkoutCwd: "/repo", + pendingBackgroundTaskCount: 0, + }, + activeProjectCwd: "/repo", + nextWorktreePath: "/repo/.threadlines/worktrees/feature-a", + }), + ).toBeNull(); + }); + + it("stays quiet when the pick does not move the session", () => { + expect( + queuedCheckoutSwitchToast({ + session: { + orchestrationStatus: "running", + checkoutCwd: "/repo", + pendingBackgroundTaskCount: 0, + }, + activeProjectCwd: "/repo", + nextWorktreePath: null, + }), + ).toBeNull(); + expect( + queuedCheckoutSwitchToast({ + session: null, + activeProjectCwd: "/repo", + nextWorktreePath: "/repo/.threadlines/worktrees/feature-a", + }), + ).toBeNull(); + }); +}); + describe("shouldIncludeBranchPickerItem", () => { it("keeps the synthetic checkout PR item visible for gh pr checkout input", () => { expect( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 7bd0e32e0..d56609951 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -187,10 +187,11 @@ export interface PendingCheckoutSwitch { } /** - * A checkout switch is a property of the next turn, never a live mutation: the - * thread's target checkout moves immediately while the running session stays - * where it started. The server cycles the session into the new checkout when - * the next turn is dispatched, so surface the queued switch until then. + * A checkout switch is never a live mutation: the thread's target checkout + * moves immediately while the running session stays where it started. The + * server cycles the session into the new checkout as soon as it is safe — the + * session idle with no background tasks left — or at the latest when the next + * turn is dispatched, so surface the queued switch until then. * * Returns null when nothing is queued (no live session, or it already runs in * the target checkout). @@ -247,7 +248,50 @@ export function checkoutSwitchChipText(pending: PendingCheckoutSwitch): { export function checkoutSwitchExplanation(pending: PendingCheckoutSwitch): string { return pending.deferred ? `A background task is still running inside this session, so it stays in ${pending.fromLabel}. The switch to ${pending.toLabel} applies on its own once that task finishes; until then your messages run in ${pending.fromLabel}.` - : `This session is finishing in ${pending.fromLabel}. Your next message starts it in ${pending.toLabel}.`; + : `This session is finishing in ${pending.fromLabel}. It moves to ${pending.toLabel} as soon as the current turn ends.`; +} + +/** + * Toast copy for the moment a branch pick queues a checkout switch behind a + * busy session. Null when nothing ends up queued, or when the session is idle: + * an idle session is cycled by the server right away, so there is nothing to + * announce. + */ +export function queuedCheckoutSwitchToast(input: { + session: + | { + readonly orchestrationStatus: OrchestrationSessionStatus; + readonly checkoutCwd?: string | null | undefined; + readonly pendingBackgroundTaskCount?: number | null | undefined; + } + | null + | undefined; + activeProjectCwd: string | null; + nextWorktreePath: string | null; +}): { title: string; description: string } | null { + const pending = resolvePendingCheckoutSwitch({ + sessionCheckoutCwd: input.session?.checkoutCwd, + sessionStatus: input.session?.orchestrationStatus, + pendingBackgroundTaskCount: input.session?.pendingBackgroundTaskCount, + activeProjectCwd: input.activeProjectCwd, + activeWorktreePath: resolveActiveWorktreePath(input.activeProjectCwd, input.nextWorktreePath), + }); + if (!pending) { + return null; + } + if (pending.deferred) { + return { + title: "Checkout switch queued", + description: `A background task is still running in ${pending.fromLabel}. The agent moves to ${pending.toLabel} when it finishes.`, + }; + } + if (!hasActiveThreadTurn(input.session)) { + return null; + } + return { + title: "Checkout switch queued", + description: `The agent moves to ${pending.toLabel} when the current turn finishes.`, + }; } export function shouldIncludeBranchPickerItem(input: { diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index dea575fbf..417ac5829 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -28,6 +28,7 @@ import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeS import { deriveLocalBranchNameFromRemoteRef, hasActiveThreadTurn, + queuedCheckoutSwitchToast, resolveActiveWorktreePath, resolveBranchSelectionTarget, resolveBranchToolbarValue, @@ -334,6 +335,19 @@ export function BranchToolbarBranchSelector({ }); }; + // A pick that leaves the live session in a different checkout is queued, not + // applied; the chip near the composer is easy to miss, so say it out loud. + const announceQueuedCheckoutSwitch = (nextWorktreePath: string | null) => { + const queued = queuedCheckoutSwitchToast({ + session: serverSession, + activeProjectCwd, + nextWorktreePath, + }); + if (queued) { + toastManager.add(stackedThreadToast({ type: "info", ...queued })); + } + }; + const runSwitchRef = (refName: VcsRef, checkoutCwd: string, nextWorktreePath: string | null) => { const api = readEnvironmentApi(environmentId); if (!api) return; @@ -354,6 +368,7 @@ export function BranchToolbarBranchSelector({ : selectedBranchName; setOptimisticBranch(nextBranchName); setThreadBranch(nextBranchName, nextWorktreePath); + announceQueuedCheckoutSwitch(nextWorktreePath); } catch (error) { setOptimisticBranch(previousBranch); toastManager.add( @@ -414,6 +429,7 @@ export function BranchToolbarBranchSelector({ if (selectionTarget.reuseExistingWorktree) { setThreadBranch(refName.name, selectionTarget.nextWorktreePath); + announceQueuedCheckoutSwitch(selectionTarget.nextWorktreePath); setIsBranchMenuOpen(false); onComposerFocusRequest?.(); return; diff --git a/apps/web/src/components/source-control/SourceControlPanel.tsx b/apps/web/src/components/source-control/SourceControlPanel.tsx index 165ee4422..fb0164758 100644 --- a/apps/web/src/components/source-control/SourceControlPanel.tsx +++ b/apps/web/src/components/source-control/SourceControlPanel.tsx @@ -196,7 +196,11 @@ import { type SourceControlFileTreeNode, takeCommitGraphRowRefs, } from "./SourceControlPanel.logic"; -import { hasActiveThreadTurn, resolveBranchSelectionTarget } from "../BranchToolbar.logic"; +import { + hasActiveThreadTurn, + queuedCheckoutSwitchToast, + resolveBranchSelectionTarget, +} from "../BranchToolbar.logic"; import { threadWorkingCwdLabel } from "@threadlines/shared/threadCwd"; export interface SourceControlProjectTarget { @@ -1617,6 +1621,17 @@ function SourceControlBranchMenu({ .then((result) => { const nextBranch = result.refName ?? ref.name; syncActiveThreadBranch(nextBranch, selectionTarget.nextWorktreePath); + // A pick that leaves the live session in a different checkout is + // queued, not applied; the composer chip is easy to miss, so say it + // out loud here too. + const queued = queuedCheckoutSwitchToast({ + session: activeThreadSession, + activeProjectCwd: target.projectCwd, + nextWorktreePath: selectionTarget.nextWorktreePath, + }); + if (queued) { + toastManager.add(stackedThreadToast({ type: "info", ...queued })); + } return nextBranch; }); void toastManager.promise(promise, { @@ -1633,6 +1648,7 @@ function SourceControlBranchMenu({ void promise.then(refreshPanel, () => undefined); }, [ + activeThreadSession, checkoutMutation, refreshPanel, syncActiveThreadBranch,