From 403d83cfc3285ce630ef81fcf63572f0cb2be975 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:12:57 -0400 Subject: [PATCH 1/5] fix: checkout switches apply visibly, and worktrees no longer trip the parent-repo gate Three linked fixes for a switch that looked like it did nothing: - A stopped thread's checkout switch now applies immediately: the stale session-scoped effectiveCwd stops shadowing the new worktree the moment thread.meta-updated lands with no live session. Running sessions keep the queued-switch behavior and chip. - Refused source-control actions say why. The parent-repository safety gate now toasts its reason from switch/create/merge handlers instead of silently returning, a failed thread.meta.update dispatch reports itself in the panel and the composer branch control, and the changes empty state explains when the gate is what emptied it. - repositoryRootRelation only reads "ancestor" for a genuine parent directory. Plain string comparison used to raise the confirmation gate for healthy checkouts whenever git's resolved root differed from the configured cwd by symlinks, casing, or separators. --- .../server/src/git/GitWorkflowService.test.ts | 36 +++++++++ apps/server/src/git/GitWorkflowService.ts | 36 ++++++++- .../src/orchestration/projector.test.ts | 81 +++++++++++++++++++ apps/server/src/orchestration/projector.ts | 36 ++++++--- apps/web/src/components/GitActionsControl.tsx | 12 ++- .../source-control/SourceControlPanel.tsx | 62 ++++++++++++-- 6 files changed, 242 insertions(+), 21 deletions(-) diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index fb9233557..44f6324f6 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -345,3 +345,39 @@ describe("GitWorkflowService", () => { }).pipe(Effect.provide(testLayer)); }); }); + +describe("resolveRepositoryRootRelation", () => { + it("reads a worktree root as same across separators and casing", () => { + assert.equal(GitWorkflowService.resolveRepositoryRootRelation("/repo/wt", "/repo/wt"), "same"); + assert.equal(GitWorkflowService.resolveRepositoryRootRelation("/repo/wt", "/repo/wt/"), "same"); + assert.equal( + GitWorkflowService.resolveRepositoryRootRelation( + "C:\\Users\\Will\\repo", + "c:/users/will/repo", + ), + "same", + ); + }); + + it("reads a genuine subdirectory as ancestor", () => { + assert.equal( + GitWorkflowService.resolveRepositoryRootRelation("/repo", "/repo/apps/web"), + "ancestor", + ); + }); + + it("does not read symlink-style divergence as ancestor", () => { + // git resolves /tmp to /private/tmp on macOS; the configured cwd may not. + assert.equal( + GitWorkflowService.resolveRepositoryRootRelation("/private/tmp/wt", "/tmp/wt"), + "same", + ); + }); + + it("does not read a sibling path with a shared prefix as ancestor", () => { + assert.equal( + GitWorkflowService.resolveRepositoryRootRelation("/repo", "/repo-archive/apps"), + "same", + ); + }); +}); diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 3e0255bed..6f39fa50c 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -1,4 +1,7 @@ -import * as nodePath from "node:path"; +import { + areFilesystemPathsEqual, + normalizeFilesystemPathForComparison, +} from "@threadlines/shared/path"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -178,11 +181,38 @@ function withRepositoryContext( return { ...status, repositoryRoot, - repositoryRootRelation: - nodePath.resolve(repositoryRoot) === nodePath.resolve(cwd) ? "same" : "ancestor", + repositoryRootRelation: resolveRepositoryRootRelation(repositoryRoot, cwd), }; } +/** + * "ancestor" gates the whole source-control panel behind a confirmation, so + * it must mean exactly one thing: the repository root is a genuine parent + * directory of the panel's cwd. Plain string comparison used to report + * "ancestor" for any mismatch, which raised the gate for healthy checkouts + * whenever git's resolved root differed from the configured cwd only by + * symlinks, casing, or separators (worktrees under /tmp on macOS, drive + * casing on Windows). Divergence that is not a real parent/child pair reads + * as "same": git resolved this cwd to that root, so the panel is operating + * on its own repository. + */ +export function resolveRepositoryRootRelation( + repositoryRoot: string, + cwd: string, +): "same" | "ancestor" { + if (areFilesystemPathsEqual(repositoryRoot, cwd)) { + return "same"; + } + const rootNormalized = normalizeFilesystemPathForComparison(repositoryRoot); + const cwdNormalized = normalizeFilesystemPathForComparison(cwd); + const separator = rootNormalized.includes("\\") ? "\\" : "/"; + return cwdNormalized.startsWith( + rootNormalized.endsWith(separator) ? rootNormalized : `${rootNormalized}${separator}`, + ) + ? "ancestor" + : "same"; +} + const unsupportedGitWorkflow = (operation: string, cwd: string, detail: string) => new GitManagerError({ operation, diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index fe8e8787b..7d81b1de4 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -841,6 +841,87 @@ describe("orchestration projector", () => { expect(afterUpdate.threads[0]?.updatedAt).toBe(updatedAt); }); + it("clears a stale session effectiveCwd when a stopped thread's worktree changes", async () => { + const createdAt = "2026-02-23T08:00:00.000Z"; + let sequence = 0; + let model = createEmptyReadModel(createdAt); + const apply = async (type: Parameters[0]["type"], payload: unknown) => { + sequence += 1; + model = await Effect.runPromise( + projectEvent( + model, + makeEvent({ + sequence, + type, + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: createdAt, + commandId: `cmd-${sequence}`, + payload, + }), + ), + ); + }; + + await apply("thread.created", { + threadId: "thread-1", + projectId: "project-1", + title: "demo", + modelSelection: { + provider: ProviderDriverKind.make("codex"), + model: "gpt-5.3-codex", + }, + runtimeMode: "full-access", + branch: "feature-a", + worktreePath: "/repo/.worktrees/feature-a", + createdAt, + updatedAt: createdAt, + }); + const session = { + threadId: "thread-1", + status: "running", + providerName: "codex", + providerSessionId: "session-1", + providerThreadId: "provider-thread-1", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }; + await apply("thread.session-set", { threadId: "thread-1", session }); + await apply("thread.effective-cwd-set", { + threadId: "thread-1", + effectiveCwd: "/repo/.worktrees/feature-a", + effectiveCwdSource: "session", + updatedAt: createdAt, + }); + + // A running session keeps its effective cwd: the switch is queued, and + // the session really is still working in the old checkout. + await apply("thread.meta-updated", { + threadId: "thread-1", + branch: "main", + worktreePath: null, + updatedAt: createdAt, + }); + expect(model.threads[0]?.effectiveCwd).toBe("/repo/.worktrees/feature-a"); + + // Once the session is stopped the same switch applies immediately, so + // the leftover session cwd must stop shadowing the new checkout. + await apply("thread.session-set", { + threadId: "thread-1", + session: { ...session, status: "stopped" }, + }); + await apply("thread.meta-updated", { + threadId: "thread-1", + branch: "main", + worktreePath: null, + updatedAt: createdAt, + }); + expect(model.threads[0]?.effectiveCwd).toBeNull(); + expect(model.threads[0]?.worktreePath).toBeNull(); + }); + it("marks assistant messages completed with non-streaming updates", async () => { const createdAt = "2026-02-23T09:00:00.000Z"; const deltaAt = "2026-02-23T09:00:01.000Z"; diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index c4dbe5386..034777d38 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -407,18 +407,30 @@ export function projectEvent( case "thread.meta-updated": return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( - Effect.map((payload) => ({ - ...nextBase, - threads: updateThread(nextBase.threads, payload.threadId, { - ...(payload.title !== undefined ? { title: payload.title } : {}), - ...(payload.modelSelection !== undefined - ? { modelSelection: payload.modelSelection } - : {}), - ...(payload.branch !== undefined ? { branch: payload.branch } : {}), - ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), - updatedAt: payload.updatedAt, - }), - })), + Effect.map((payload) => { + // A checkout move with no live session takes effect now. The + // session-scoped effectiveCwd survives a stop so clients keep + // showing where work last happened, but once the user points the + // thread somewhere else that leftover value would shadow the new + // checkout in every panel until the next session starts. + const existing = nextBase.threads.find((entry) => entry.id === payload.threadId); + const sessionInactive = !existing?.session || existing.session.status === "stopped"; + const clearsStaleEffectiveCwd = + payload.worktreePath !== undefined && sessionInactive && existing?.effectiveCwd != null; + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + ...(payload.title !== undefined ? { title: payload.title } : {}), + ...(payload.modelSelection !== undefined + ? { modelSelection: payload.modelSelection } + : {}), + ...(payload.branch !== undefined ? { branch: payload.branch } : {}), + ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), + ...(clearsStaleEffectiveCwd ? { effectiveCwd: null, effectiveCwdSource: null } : {}), + updatedAt: payload.updatedAt, + }), + }; + }), ); case "thread.runtime-mode-set": diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 5a7162e0f..20b33acf2 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1179,7 +1179,17 @@ export default function GitActionsControl({ branch, worktreePath, }) - .catch(() => undefined); + .catch(() => { + // The optimistic update below already happened; a branch label + // that silently disagrees with the server is a lying UI. + 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); diff --git a/apps/web/src/components/source-control/SourceControlPanel.tsx b/apps/web/src/components/source-control/SourceControlPanel.tsx index 83708bee4..406b7ca3e 100644 --- a/apps/web/src/components/source-control/SourceControlPanel.tsx +++ b/apps/web/src/components/source-control/SourceControlPanel.tsx @@ -1915,7 +1915,17 @@ function SourceControlBranchMenu({ branch, worktreePath, }) - .catch(() => undefined); + .catch(() => { + // The optimistic local update below is now wrong; a switch that + // silently stays put is this panel's worst failure mode. + 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); }, @@ -1959,6 +1969,13 @@ function SourceControlBranchMenu({ const switchCheckoutToWorktree = useCallback( (row: WorktreeCleanupRow) => { if (!row.refName) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Can't switch to this worktree", + description: "It has no branch checked out, so the thread can't follow it.", + }), + ); return; } setCleanupRows(null); @@ -1997,9 +2014,25 @@ function SourceControlBranchMenu({ [applyCheckoutSwitch, checkoutMutation, refreshPanel, target.projectCwd, target.worktreePath], ); + /** + * A refused action must say why. The branch menu shows the safety reason in + * place, but these handlers are also reachable from rows and dialogs that + * render no reason — a silent return there reads as a dead button. + */ + const notifyRepositorySafetyBlocked = useCallback((reason: string) => { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Action paused", + description: reason, + }), + ); + }, []); + const runSwitchRef = useCallback( (ref: VcsRef) => { if (repositorySafetyReason) { + notifyRepositorySafetyBlocked(repositorySafetyReason); return; } const selectionTarget = resolveBranchSelectionTarget({ @@ -2019,6 +2052,7 @@ function SourceControlBranchMenu({ [ activeThreadSession, executeSwitchRef, + notifyRepositorySafetyBlocked, repositorySafetyReason, target.projectCwd, target.worktreePath, @@ -2027,6 +2061,7 @@ function SourceControlBranchMenu({ const runCreateBranch = useCallback(() => { if (repositorySafetyReason) { + notifyRepositorySafetyBlocked(repositorySafetyReason); return; } const refName = createBranchName.trim(); @@ -2054,13 +2089,18 @@ function SourceControlBranchMenu({ }, [ createBranchMutation, createBranchName, + notifyRepositorySafetyBlocked, refreshPanel, repositorySafetyReason, syncActiveThreadBranch, ]); const runMergeRef = useCallback(() => { - if (!pendingMergeRef || repositorySafetyReason) { + if (!pendingMergeRef) { + return; + } + if (repositorySafetyReason) { + notifyRepositorySafetyBlocked(repositorySafetyReason); return; } const refName = pendingMergeRef.name; @@ -2088,7 +2128,14 @@ function SourceControlBranchMenu({ }), }); void promise.then(refreshPanel, () => refreshPanel()); - }, [currentBranch, mergeMutation, pendingMergeRef, refreshPanel, repositorySafetyReason]); + }, [ + currentBranch, + mergeMutation, + notifyRepositorySafetyBlocked, + pendingMergeRef, + refreshPanel, + repositorySafetyReason, + ]); return ( <> @@ -4855,12 +4902,17 @@ export function SourceControlPanel({ ) : changedFiles.length === 0 ? ( // Nothing to show is a line, not a box. The left sidebar's empty - // states are flat text and this one is no different. + // states are flat text and this one is no different. While the + // parent-repository gate is up the list is empty because queries + // are paused, not because the tree is clean — saying "no changes" + // there sends people hunting for a different bug.
- No working tree changes + {isParentRepositoryConfirmationRequired + ? "Changes are hidden until you confirm the parent repository above." + : "No working tree changes"}
) : (
From 4f3b201f821797d0f065506afb7e987d5129ebd7 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:32:14 -0400 Subject: [PATCH 2/5] fix: apply checkout clearing through events and harden the review findings Reworks the previous commit after independent review: - The stale-effectiveCwd clearing moves from a special case in one projector fold into the decider, which now emits a real thread.effective-cwd-set(null) alongside thread.meta-updated when a stopped thread's worktree changes. Every projection (in-memory fold, SQLite pipeline, web store) already folds that event, so snapshots, live streams, and restarts all agree. - The same rule applies when a session stops with a queued switch: thread.session.set(stopped) in a checkout that differs from the thread's configured one also emits the clear. A session stopping in its own checkout keeps its cwd-follow effectiveCwd. - resolveRepositoryRootRelation resolves symlinks before comparing and treats any residual divergence as "ancestor": a false gate is a visible banner, a false "same" would silently expose parent-repository actions (e.g. a cwd symlinked into a subdirectory of a larger repo). - Failed thread.meta.update dispatches now roll back the optimistic branch/worktree update instead of leaving the UI lying, and a missing environment connection reports itself instead of silently skipping the dispatch. --- .../server/src/git/GitWorkflowService.test.ts | 38 +++- apps/server/src/git/GitWorkflowService.ts | 42 ++-- .../decider.checkoutSwitch.test.ts | 202 ++++++++++++++++++ apps/server/src/orchestration/decider.ts | 72 ++++++- .../src/orchestration/projector.test.ts | 81 ------- apps/server/src/orchestration/projector.ts | 36 ++-- apps/web/src/components/GitActionsControl.tsx | 52 +++-- .../source-control/SourceControlPanel.tsx | 55 +++-- 8 files changed, 398 insertions(+), 180 deletions(-) create mode 100644 apps/server/src/orchestration/decider.checkoutSwitch.test.ts diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index 44f6324f6..9b1c74df7 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -2,6 +2,7 @@ import { assert, describe, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; @@ -347,7 +348,7 @@ describe("GitWorkflowService", () => { }); describe("resolveRepositoryRootRelation", () => { - it("reads a worktree root as same across separators and casing", () => { + it("reads normalization-only differences as same", () => { assert.equal(GitWorkflowService.resolveRepositoryRootRelation("/repo/wt", "/repo/wt"), "same"); assert.equal(GitWorkflowService.resolveRepositoryRootRelation("/repo/wt", "/repo/wt/"), "same"); assert.equal( @@ -366,18 +367,35 @@ describe("resolveRepositoryRootRelation", () => { ); }); - it("does not read symlink-style divergence as ancestor", () => { - // git resolves /tmp to /private/tmp on macOS; the configured cwd may not. - assert.equal( - GitWorkflowService.resolveRepositoryRootRelation("/private/tmp/wt", "/tmp/wt"), - "same", - ); + it("resolves symlinks before comparing", () => { + const base = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "tl-root-relation-")); + try { + const repo = NodePath.join(base, "repo"); + const sub = NodePath.join(repo, "packages", "app"); + NodeFS.mkdirSync(sub, { recursive: true }); + const rootLink = NodePath.join(base, "root-link"); + const subLink = NodePath.join(base, "sub-link"); + NodeFS.symlinkSync(repo, rootLink); + NodeFS.symlinkSync(sub, subLink); + + // A symlinked spelling of the repository root is the same checkout — + // this was the false "ancestor" that locked healthy panels. + assert.equal(GitWorkflowService.resolveRepositoryRootRelation(repo, rootLink), "same"); + // A symlink INTO the repository is a genuine parent situation and must + // keep the safety gate up. + assert.equal(GitWorkflowService.resolveRepositoryRootRelation(repo, subLink), "ancestor"); + } finally { + NodeFS.rmSync(base, { recursive: true, force: true }); + } }); - it("does not read a sibling path with a shared prefix as ancestor", () => { + it("keeps the gate up when paths still diverge after resolution", () => { assert.equal( - GitWorkflowService.resolveRepositoryRootRelation("/repo", "/repo-archive/apps"), - "same", + GitWorkflowService.resolveRepositoryRootRelation( + "/definitely-not-here-a/x", + "/definitely-not-here-b/x", + ), + "ancestor", ); }); }); diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 6f39fa50c..0f2632cbd 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -1,7 +1,5 @@ -import { - areFilesystemPathsEqual, - normalizeFilesystemPathForComparison, -} from "@threadlines/shared/path"; +import * as NodeFS from "node:fs"; +import { areFilesystemPathsEqual } from "@threadlines/shared/path"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -185,16 +183,25 @@ function withRepositoryContext( }; } +/** Symlink-resolved form for comparison; the raw value when resolution fails. */ +function toComparableRealPath(value: string): string { + try { + return NodeFS.realpathSync.native(value); + } catch { + return value; + } +} + /** - * "ancestor" gates the whole source-control panel behind a confirmation, so - * it must mean exactly one thing: the repository root is a genuine parent - * directory of the panel's cwd. Plain string comparison used to report - * "ancestor" for any mismatch, which raised the gate for healthy checkouts + * "ancestor" gates the whole source-control panel behind a confirmation. + * Plain string comparison used to raise that gate for healthy checkouts * whenever git's resolved root differed from the configured cwd only by - * symlinks, casing, or separators (worktrees under /tmp on macOS, drive - * casing on Windows). Divergence that is not a real parent/child pair reads - * as "same": git resolved this cwd to that root, so the panel is operating - * on its own repository. + * symlinks, casing, or separators (git realpaths its answers; the configured + * cwd may be the symlinked spelling). Compare after resolving symlinks and + * normalizing separators and case instead. Anything still unequal keeps the + * gate up: a false gate is a visible, dismissible banner, while a false + * "same" would silently expose repository-wide actions — e.g. a cwd + * symlinked into a subdirectory of a larger repository. */ export function resolveRepositoryRootRelation( repositoryRoot: string, @@ -203,14 +210,9 @@ export function resolveRepositoryRootRelation( if (areFilesystemPathsEqual(repositoryRoot, cwd)) { return "same"; } - const rootNormalized = normalizeFilesystemPathForComparison(repositoryRoot); - const cwdNormalized = normalizeFilesystemPathForComparison(cwd); - const separator = rootNormalized.includes("\\") ? "\\" : "/"; - return cwdNormalized.startsWith( - rootNormalized.endsWith(separator) ? rootNormalized : `${rootNormalized}${separator}`, - ) - ? "ancestor" - : "same"; + return areFilesystemPathsEqual(toComparableRealPath(repositoryRoot), toComparableRealPath(cwd)) + ? "same" + : "ancestor"; } const unsupportedGitWorkflow = (operation: string, cwd: string, detail: string) => diff --git a/apps/server/src/orchestration/decider.checkoutSwitch.test.ts b/apps/server/src/orchestration/decider.checkoutSwitch.test.ts new file mode 100644 index 000000000..446adddde --- /dev/null +++ b/apps/server/src/orchestration/decider.checkoutSwitch.test.ts @@ -0,0 +1,202 @@ +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationCommand, + type OrchestrationReadModel, + type OrchestrationSession, +} from "@threadlines/contracts"; +import * as Effect from "effect/Effect"; +import { describe, expect, it } from "vite-plus/test"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const now = "2026-01-01T00:00:00.000Z"; +const threadId = ThreadId.make("thread-checkout-switch"); +const projectId = ProjectId.make("project-checkout-switch"); +const workspaceRoot = "/repos/project"; +const worktreeA = "/repos/project/.worktrees/feature-a"; +const worktreeB = "/repos/project/.worktrees/feature-b"; + +function makeSession(input: { + status: OrchestrationSession["status"]; + checkoutCwd?: string | null; +}): OrchestrationSession { + return { + threadId, + status: input.status, + providerName: "codex", + providerSessionId: "session-1", + providerThreadId: "provider-thread-1", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + ...(input.checkoutCwd !== undefined ? { checkoutCwd: input.checkoutCwd } : {}), + updatedAt: now, + }; +} + +function makeReadModel(input: { + session: OrchestrationSession | null; + effectiveCwd: string | null; + worktreePath?: string | null; +}): OrchestrationReadModel { + return { + snapshotSequence: 1, + updatedAt: now, + projects: [ + { + id: projectId, + kind: "workspace", + title: "Checkout Switch Project", + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + ], + threads: [ + { + id: threadId, + projectId, + title: "Checkout Switch Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: "feature-a", + worktreePath: input.worktreePath !== undefined ? input.worktreePath : worktreeA, + effectiveCwd: input.effectiveCwd, + ...(input.effectiveCwd !== null ? { effectiveCwdSource: "session" as const } : {}), + goal: null, + latestTurn: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + pinnedAt: null, + doneOverride: null, + lastSeenAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + diffStatBaselineTurnCount: 0, + session: input.session, + }, + ], + }; +} + +function metaUpdateCommand(input: { + worktreePath?: string | null; + title?: string; +}): Extract { + return { + type: "thread.meta.update", + commandId: CommandId.make("cmd-meta-update"), + threadId, + ...(input.title !== undefined ? { title: input.title } : {}), + ...(input.worktreePath !== undefined + ? { branch: "main", worktreePath: input.worktreePath } + : {}), + createdAt: "2026-01-01T00:00:10.000Z", + }; +} + +function sessionSetCommand( + session: OrchestrationSession, +): Extract { + return { + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set"), + threadId, + session, + createdAt: "2026-01-01T00:00:10.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("clears the stale effectiveCwd when a stopped thread's worktree changes", async () => { + const events = await decide( + metaUpdateCommand({ worktreePath: worktreeB }), + makeReadModel({ + session: makeSession({ status: "stopped", checkoutCwd: worktreeA }), + effectiveCwd: worktreeA, + }), + ); + + expect(events).toHaveLength(2); + expect(events[0]).toMatchObject({ type: "thread.meta-updated" }); + expect(events[1]).toMatchObject({ + type: "thread.effective-cwd-set", + payload: { threadId, effectiveCwd: null }, + }); + expect(events[1]?.causationEventId).toBe(events[0]?.eventId); + }); + + it("keeps the effectiveCwd while a live session still runs in the old checkout", async () => { + const events = await decide( + metaUpdateCommand({ worktreePath: worktreeB }), + makeReadModel({ + session: makeSession({ status: "running", checkoutCwd: worktreeA }), + effectiveCwd: worktreeA, + }), + ); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "thread.meta-updated" }); + }); + + it("leaves effectiveCwd alone for meta updates that do not move the checkout", async () => { + const events = await decide( + metaUpdateCommand({ title: "Renamed" }), + makeReadModel({ session: null, effectiveCwd: worktreeA }), + ); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "thread.meta-updated" }); + }); + + it("applies a queued switch when the session stops away from the thread checkout", async () => { + const events = await decide( + sessionSetCommand(makeSession({ status: "stopped", checkoutCwd: worktreeA })), + makeReadModel({ + session: makeSession({ status: "running", checkoutCwd: worktreeA }), + effectiveCwd: worktreeA, + worktreePath: worktreeB, + }), + ); + + expect(events).toHaveLength(2); + expect(events[0]).toMatchObject({ type: "thread.session-set" }); + expect(events[1]).toMatchObject({ + type: "thread.effective-cwd-set", + payload: { threadId, effectiveCwd: null }, + }); + }); + + it("keeps a cwd-follow effectiveCwd when the session stops in its own checkout", async () => { + const events = await decide( + sessionSetCommand(makeSession({ status: "stopped", checkoutCwd: worktreeA })), + makeReadModel({ + session: makeSession({ status: "running", checkoutCwd: worktreeA }), + effectiveCwd: `${worktreeA}/packages/deep`, + worktreePath: worktreeA, + }), + ); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "thread.session-set" }); + }); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 040de186a..a1601e1e7 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -581,7 +581,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ? undefined : normalizeWorktreePath(command.worktreePath, project.workspaceRoot); const occurredAt = yield* nowIso; - return { + const metaUpdatedEvent = { ...withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, @@ -599,7 +599,35 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(worktreePath !== undefined ? { worktreePath } : {}), updatedAt: occurredAt, }, - }; + } as const; + + // A checkout move with no live session applies now, so the stale + // session-scoped effectiveCwd must stop shadowing the new checkout. + // Emitted as a real event rather than special-cased in the folds: the + // in-memory projector, the SQLite pipeline, and the web store all + // already know what thread.effective-cwd-set means. + const sessionInactive = thread.session == null || thread.session.status === "stopped"; + if (worktreePath === undefined || !sessionInactive || thread.effectiveCwd == null) { + 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, + updatedAt: occurredAt, + }, + }, + ]; } case "thread.runtime-mode.set": { @@ -1124,12 +1152,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.session.set": { - yield* requireThread({ + const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); - return { + const sessionSetEvent = { ...withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, @@ -1142,7 +1170,41 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, session: command.session, }, - }; + } as const; + + // A session stopping with a queued checkout switch applies the switch: + // the effectiveCwd the dead session left behind must not keep pointing + // 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 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) { + return sessionSetEvent; + } + return [ + sessionSetEvent, + { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + metadata: {}, + }), + causationEventId: sessionSetEvent.eventId, + type: "thread.effective-cwd-set", + payload: { + threadId: command.threadId, + effectiveCwd: null, + updatedAt: command.createdAt, + }, + }, + ]; } case "thread.realtime.state.set": { diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 7d81b1de4..fe8e8787b 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -841,87 +841,6 @@ describe("orchestration projector", () => { expect(afterUpdate.threads[0]?.updatedAt).toBe(updatedAt); }); - it("clears a stale session effectiveCwd when a stopped thread's worktree changes", async () => { - const createdAt = "2026-02-23T08:00:00.000Z"; - let sequence = 0; - let model = createEmptyReadModel(createdAt); - const apply = async (type: Parameters[0]["type"], payload: unknown) => { - sequence += 1; - model = await Effect.runPromise( - projectEvent( - model, - makeEvent({ - sequence, - type, - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: createdAt, - commandId: `cmd-${sequence}`, - payload, - }), - ), - ); - }; - - await apply("thread.created", { - threadId: "thread-1", - projectId: "project-1", - title: "demo", - modelSelection: { - provider: ProviderDriverKind.make("codex"), - model: "gpt-5.3-codex", - }, - runtimeMode: "full-access", - branch: "feature-a", - worktreePath: "/repo/.worktrees/feature-a", - createdAt, - updatedAt: createdAt, - }); - const session = { - threadId: "thread-1", - status: "running", - providerName: "codex", - providerSessionId: "session-1", - providerThreadId: "provider-thread-1", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: createdAt, - }; - await apply("thread.session-set", { threadId: "thread-1", session }); - await apply("thread.effective-cwd-set", { - threadId: "thread-1", - effectiveCwd: "/repo/.worktrees/feature-a", - effectiveCwdSource: "session", - updatedAt: createdAt, - }); - - // A running session keeps its effective cwd: the switch is queued, and - // the session really is still working in the old checkout. - await apply("thread.meta-updated", { - threadId: "thread-1", - branch: "main", - worktreePath: null, - updatedAt: createdAt, - }); - expect(model.threads[0]?.effectiveCwd).toBe("/repo/.worktrees/feature-a"); - - // Once the session is stopped the same switch applies immediately, so - // the leftover session cwd must stop shadowing the new checkout. - await apply("thread.session-set", { - threadId: "thread-1", - session: { ...session, status: "stopped" }, - }); - await apply("thread.meta-updated", { - threadId: "thread-1", - branch: "main", - worktreePath: null, - updatedAt: createdAt, - }); - expect(model.threads[0]?.effectiveCwd).toBeNull(); - expect(model.threads[0]?.worktreePath).toBeNull(); - }); - it("marks assistant messages completed with non-streaming updates", async () => { const createdAt = "2026-02-23T09:00:00.000Z"; const deltaAt = "2026-02-23T09:00:01.000Z"; diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 034777d38..c4dbe5386 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -407,30 +407,18 @@ export function projectEvent( case "thread.meta-updated": return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( - Effect.map((payload) => { - // A checkout move with no live session takes effect now. The - // session-scoped effectiveCwd survives a stop so clients keep - // showing where work last happened, but once the user points the - // thread somewhere else that leftover value would shadow the new - // checkout in every panel until the next session starts. - const existing = nextBase.threads.find((entry) => entry.id === payload.threadId); - const sessionInactive = !existing?.session || existing.session.status === "stopped"; - const clearsStaleEffectiveCwd = - payload.worktreePath !== undefined && sessionInactive && existing?.effectiveCwd != null; - return { - ...nextBase, - threads: updateThread(nextBase.threads, payload.threadId, { - ...(payload.title !== undefined ? { title: payload.title } : {}), - ...(payload.modelSelection !== undefined - ? { modelSelection: payload.modelSelection } - : {}), - ...(payload.branch !== undefined ? { branch: payload.branch } : {}), - ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), - ...(clearsStaleEffectiveCwd ? { effectiveCwd: null, effectiveCwdSource: null } : {}), - updatedAt: payload.updatedAt, - }), - }; - }), + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + ...(payload.title !== undefined ? { title: payload.title } : {}), + ...(payload.modelSelection !== undefined + ? { modelSelection: payload.modelSelection } + : {}), + ...(payload.branch !== undefined ? { branch: payload.branch } : {}), + ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), + updatedAt: payload.updatedAt, + }), + })), ); case "thread.runtime-mode-set": diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 20b33acf2..abeb36caa 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1170,27 +1170,39 @@ export default function GitActionsControl({ const worktreePath = activeServerThread.worktreePath; const api = readEnvironmentApi(activeThreadRef.environmentId); - if (api) { - void api.orchestration - .dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: activeThreadRef.threadId, - branch, - worktreePath, - }) - .catch(() => { - // The optimistic update below already happened; a branch label - // that silently disagrees with the server is a lying UI. - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Couldn't save the branch change", - description: "The update didn't reach the server. Try again.", - }), - ); - }); + 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 previousBranch = activeServerThread.branch; + 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. + setThreadBranch(activeThreadRef, previousBranch, worktreePath); + 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); return; diff --git a/apps/web/src/components/source-control/SourceControlPanel.tsx b/apps/web/src/components/source-control/SourceControlPanel.tsx index 406b7ca3e..072839a33 100644 --- a/apps/web/src/components/source-control/SourceControlPanel.tsx +++ b/apps/web/src/components/source-control/SourceControlPanel.tsx @@ -1906,31 +1906,46 @@ function SourceControlBranchMenu({ return; } const api = readEnvironmentApi(target.environmentId); - if (api) { - void api.orchestration - .dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: activeThreadRef.threadId, - branch, - worktreePath, - }) - .catch(() => { - // The optimistic local update below is now wrong; a switch that - // silently stays put is this panel's worst failure mode. - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Couldn't move the thread", - description: "The checkout switch didn't reach the server. Try again.", - }), - ); - }); + 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 previousBranch = currentBranch; + const previousWorktreePath = target.worktreePath; + 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; + // a switch that silently stays put is this panel's worst failure + // mode, and one that lies about having happened is the second. + setThreadBranch(activeThreadRef, previousBranch, previousWorktreePath); + 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); }, [ activeThreadRef, + currentBranch, onActiveBranchChange, setThreadBranch, target.environmentId, From 70727f2a2493b3610a092cf10464f47dab3f099d Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:43:09 -0400 Subject: [PATCH 3/5] fix: guard the effective-cwd clear and make checkout rollbacks faithful Follow-up to the second review pass: - The decider only emits the effective-cwd clear when the worktree path actually changes; branch-only updates carry the unchanged path and must not wipe a valid cwd-follow value. - Rolling back a failed checkout dispatch now restores the full snapshot through a new store restoreThreadCheckout action, including the session the optimistic setThreadBranch cleared. - Rollbacks are keyed to their dispatch: a stale rejection can no longer overwrite state a newer dispatch already replaced. --- .../decider.checkoutSwitch.test.ts | 13 ++++++ apps/server/src/orchestration/decider.ts | 11 ++++- apps/web/src/components/GitActionsControl.tsx | 20 +++++++-- .../source-control/SourceControlPanel.tsx | 27 +++++++++--- apps/web/src/store.test.ts | 43 ++++++++++++++++++- apps/web/src/store.ts | 38 ++++++++++++++++ 6 files changed, 140 insertions(+), 12 deletions(-) diff --git a/apps/server/src/orchestration/decider.checkoutSwitch.test.ts b/apps/server/src/orchestration/decider.checkoutSwitch.test.ts index 446adddde..f47ed89a0 100644 --- a/apps/server/src/orchestration/decider.checkoutSwitch.test.ts +++ b/apps/server/src/orchestration/decider.checkoutSwitch.test.ts @@ -158,6 +158,19 @@ describe("decider checkout switch effectiveCwd", () => { expect(events[0]).toMatchObject({ type: "thread.meta-updated" }); }); + it("keeps effectiveCwd when a branch-only update carries the unchanged worktree path", async () => { + const events = await decide( + metaUpdateCommand({ worktreePath: worktreeA }), + makeReadModel({ + session: makeSession({ status: "stopped", checkoutCwd: worktreeA }), + effectiveCwd: `${worktreeA}/packages/deep`, + }), + ); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "thread.meta-updated" }); + }); + it("leaves effectiveCwd alone for meta updates that do not move the checkout", async () => { const events = await decide( metaUpdateCommand({ title: "Renamed" }), diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index a1601e1e7..66a30cfbe 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -605,9 +605,16 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" // session-scoped effectiveCwd must stop shadowing the new checkout. // Emitted as a real event rather than special-cased in the folds: the // in-memory projector, the SQLite pipeline, and the web store all - // already know what thread.effective-cwd-set means. + // already know what thread.effective-cwd-set means. Only an actual + // move clears — branch-only updates carry the unchanged worktree path + // and must not wipe a valid cwd-follow value. + const checkoutChanged = + worktreePath !== undefined && + (worktreePath === null || thread.worktreePath === null + ? worktreePath !== thread.worktreePath + : !areFilesystemPathsEqual(worktreePath, thread.worktreePath)); const sessionInactive = thread.session == null || thread.session.status === "stopped"; - if (worktreePath === undefined || !sessionInactive || thread.effectiveCwd == null) { + if (!checkoutChanged || !sessionInactive || thread.effectiveCwd == null) { return metaUpdatedEvent; } return [ diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index abeb36caa..d4f29f33d 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1134,6 +1134,10 @@ export default function GitActionsControl({ ); 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 queryClient = useQueryClient(); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); const [dialogCommitMessage, setDialogCommitMessage] = useState(""); @@ -1182,7 +1186,13 @@ export default function GitActionsControl({ ); return; } - const previousBranch = activeServerThread.branch; + 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", @@ -1193,8 +1203,11 @@ export default function GitActionsControl({ }) .catch(() => { // Roll the optimistic update back; a branch label that silently - // disagrees with the server is a lying UI. - setThreadBranch(activeThreadRef, previousBranch, worktreePath); + // 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", @@ -1222,6 +1235,7 @@ export default function GitActionsControl({ activeServerThread, activeThreadRef, draftId, + restoreThreadCheckout, setDraftThreadContext, setThreadBranch, ], diff --git a/apps/web/src/components/source-control/SourceControlPanel.tsx b/apps/web/src/components/source-control/SourceControlPanel.tsx index 072839a33..4c94839c8 100644 --- a/apps/web/src/components/source-control/SourceControlPanel.tsx +++ b/apps/web/src/components/source-control/SourceControlPanel.tsx @@ -1817,6 +1817,10 @@ function SourceControlBranchMenu({ }) { 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; @@ -1918,8 +1922,13 @@ function SourceControlBranchMenu({ ); return; } - const previousBranch = currentBranch; - const previousWorktreePath = target.worktreePath; + const snapshot = { + branch: currentBranch, + worktreePath: target.worktreePath, + session: activeThreadSession, + }; + checkoutDispatchIdRef.current += 1; + const dispatchId = checkoutDispatchIdRef.current; void api.orchestration .dispatchCommand({ type: "thread.meta.update", @@ -1929,10 +1938,14 @@ function SourceControlBranchMenu({ worktreePath, }) .catch(() => { - // Roll the optimistic update back to what the panel showed before; - // a switch that silently stays put is this panel's worst failure - // mode, and one that lies about having happened is the second. - setThreadBranch(activeThreadRef, previousBranch, previousWorktreePath); + // 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", @@ -1945,8 +1958,10 @@ function SourceControlBranchMenu({ }, [ activeThreadRef, + activeThreadSession, currentBranch, onActiveBranchChange, + restoreThreadCheckout, setThreadBranch, target.environmentId, target.worktreePath, diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index e60e06eec..a79d1f2b2 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -6,6 +6,7 @@ import { EventId, MessageId, ProjectId, + ProviderDriverKind, ProviderInstanceId, ThreadId, TurnId, @@ -23,12 +24,18 @@ import { selectThreadByRef, selectThreadExistsByRef, selectSidebarThreadsForProjectRef, + restoreThreadCheckout, setThreadBranch, selectThreadsAcrossEnvironments, type AppState, type EnvironmentState, } from "./store"; -import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; +import { + DEFAULT_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + type Thread, + type ThreadSession, +} from "./types"; const localEnvironmentId = EnvironmentId.make("environment-local"); const remoteEnvironmentId = EnvironmentId.make("environment-remote"); @@ -457,6 +464,40 @@ describe("setThreadBranch", () => { environmentStateOf(next, remoteEnvironmentId).threadShellById[sharedThreadId]?.worktreePath, ).toBe("/tmp/remote-worktree"); }); + + it("restoreThreadCheckout brings back the session an optimistic switch cleared", () => { + const session: ThreadSession = { + provider: ProviderDriverKind.make("codex"), + status: "running", + checkoutCwd: "/tmp/worktree-a", + createdAt: "2026-02-13T00:00:00.000Z", + updatedAt: "2026-02-13T00:00:00.000Z", + }; + const thread = makeThread({ + branch: "feature-a", + worktreePath: "/tmp/worktree-a", + session, + }); + const state = makeState(thread); + const threadRef = scopeThreadRef(localEnvironmentId, thread.id); + + const afterOptimistic = setThreadBranch(state, threadRef, "main", null); + expect( + environmentStateOf(afterOptimistic, localEnvironmentId).threadSessionById[thread.id], + ).toBeNull(); + + const restored = restoreThreadCheckout(afterOptimistic, threadRef, { + branch: "feature-a", + worktreePath: "/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(environmentStateOf(restored, localEnvironmentId).threadSessionById[thread.id]).toEqual( + session, + ); + }); }); describe("incremental orchestration updates", () => { diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 537b8c99d..ab27a8ef3 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -2370,6 +2370,34 @@ export function setThreadBranch( 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. + */ +export function restoreThreadCheckout( + state: AppState, + threadRef: ScopedThreadRef, + snapshot: { + readonly branch: string | null; + readonly worktreePath: string | null; + readonly session: ThreadSession | null; + }, +): AppState { + const nextEnvironmentState = updateThreadState( + getStoredEnvironmentState(state, threadRef.environmentId), + threadRef.threadId, + (thread) => ({ + ...thread, + branch: snapshot.branch, + worktreePath: snapshot.worktreePath, + session: snapshot.session, + }), + ); + return commitEnvironmentState(state, threadRef.environmentId, nextEnvironmentState); +} + interface AppStore extends AppState { setActiveEnvironmentId: (environmentId: EnvironmentId) => void; removeEnvironmentState: (environmentId: EnvironmentId) => void; @@ -2390,6 +2418,14 @@ interface AppStore extends AppState { branch: string | null, worktreePath: string | null, ) => void; + restoreThreadCheckout: ( + threadRef: ScopedThreadRef, + snapshot: { + readonly branch: string | null; + readonly worktreePath: string | null; + readonly session: ThreadSession | null; + }, + ) => void; } export const useStore = create((set) => ({ @@ -2411,4 +2447,6 @@ 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)), + restoreThreadCheckout: (threadRef, snapshot) => + set((state) => restoreThreadCheckout(state, threadRef, snapshot)), })); From 7e0b0b4d9568d12b4abe3806e08ed87df82fea23 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sun, 23 Aug 2026 00:08:26 -0400 Subject: [PATCH 4/5] fix(web): complete the ThreadSession fixture with orchestrationStatus --- apps/web/src/store.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index a79d1f2b2..20a2766c4 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -469,6 +469,7 @@ describe("setThreadBranch", () => { const session: ThreadSession = { provider: ProviderDriverKind.make("codex"), status: "running", + orchestrationStatus: "running", checkoutCwd: "/tmp/worktree-a", createdAt: "2026-02-13T00:00:00.000Z", updatedAt: "2026-02-13T00:00:00.000Z", From 510ab0fc3c5c32eaca93f147e94a9eafe199a154 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sun, 23 Aug 2026 00:14:13 -0400 Subject: [PATCH 5/5] fix(server): align decider test fixtures with current project and command schemas --- apps/server/src/orchestration/decider.checkoutSwitch.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/orchestration/decider.checkoutSwitch.test.ts b/apps/server/src/orchestration/decider.checkoutSwitch.test.ts index f47ed89a0..11ebfe4c0 100644 --- a/apps/server/src/orchestration/decider.checkoutSwitch.test.ts +++ b/apps/server/src/orchestration/decider.checkoutSwitch.test.ts @@ -56,6 +56,7 @@ function makeReadModel(input: { scripts: [], createdAt: now, updatedAt: now, + deletedAt: null, }, ], threads: [ @@ -105,7 +106,6 @@ function metaUpdateCommand(input: { ...(input.worktreePath !== undefined ? { branch: "main", worktreePath: input.worktreePath } : {}), - createdAt: "2026-01-01T00:00:10.000Z", }; }