From 3201e00ad4f59cc287517e61a19b069b497c16d2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 9 Jul 2026 00:01:23 -0700 Subject: [PATCH] [codex] Preserve worktree metadata during branch sync (#3822) Co-authored-by: codex --- .../Layers/OrchestrationEngine.test.ts | 106 ++++++++++++++++++ apps/server/src/orchestration/decider.ts | 10 +- .../GitActionsControl.logic.test.ts | 13 +++ .../src/components/GitActionsControl.logic.ts | 10 ++ apps/web/src/components/GitActionsControl.tsx | 5 +- packages/contracts/src/orchestration.ts | 1 + 6 files changed, 140 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index edfda7c837ab8ba74428824ade3bda90ba0a628f..405103450e8fe3bd277f84456a90063c6c329d39 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -477,6 +477,112 @@ describe("OrchestrationEngine", () => { await system.dispose(); }); + it("does not regress a generated branch to a stale temporary worktree branch", async () => { + const system = await createOrchestrationSystem(); + const { engine } = system; + const createdAt = now(); + + await system.run( + engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-branch-race-project-create"), + projectId: asProjectId("project-branch-race"), + title: "Branch Race Project", + workspaceRoot: "/tmp/project-branch-race", + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + createdAt, + }), + ); + await system.run( + engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-branch-race-thread-create"), + threadId: ThreadId.make("thread-branch-race"), + projectId: asProjectId("project-branch-race"), + title: "Branch Race Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: "t3code/generated-branch-name", + worktreePath: "/tmp/project-branch-race-worktree", + createdAt, + }), + ); + + await system.run( + engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-stale-temporary-branch-sync"), + threadId: ThreadId.make("thread-branch-race"), + branch: "t3code/1234abcd", + expectedBranch: "t3code/1234abcd", + }), + ); + + const snapshot = await system.readModel(); + expect(snapshot.threads[0]?.branch).toBe("t3code/generated-branch-name"); + await system.dispose(); + }); + + it("allows authoritative worktree bootstrap to assign a temporary branch", async () => { + const system = await createOrchestrationSystem(); + const { engine } = system; + const createdAt = now(); + + await system.run( + engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-worktree-bootstrap-project-create"), + projectId: asProjectId("project-worktree-bootstrap"), + title: "Worktree Bootstrap Project", + workspaceRoot: "/tmp/project-worktree-bootstrap", + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + createdAt, + }), + ); + await system.run( + engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-worktree-bootstrap-thread-create"), + threadId: ThreadId.make("thread-worktree-bootstrap"), + projectId: asProjectId("project-worktree-bootstrap"), + title: "Worktree Bootstrap Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: "main", + worktreePath: null, + createdAt, + }), + ); + await system.run( + engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-authoritative-worktree-bootstrap"), + threadId: ThreadId.make("thread-worktree-bootstrap"), + branch: "t3code/1234abcd", + worktreePath: "/tmp/project-worktree-bootstrap-worktree", + }), + ); + + const snapshot = await system.readModel(); + expect(snapshot.threads[0]?.branch).toBe("t3code/1234abcd"); + expect(snapshot.threads[0]?.worktreePath).toBe("/tmp/project-worktree-bootstrap-worktree"); + await system.dispose(); + }); + it("records command ack duration using the first committed event type", async () => { const system = await createOrchestrationSystem(); const { engine } = system; diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 0d4af771ca8afd2eb99ac76388bd175e2359d37f..9c95b42269d61860ebd9a015628d656953be0e55 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -313,11 +313,17 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.meta.update": { - yield* requireThread({ + const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); + const branch = + command.branch !== undefined && + command.expectedBranch !== undefined && + thread.branch !== command.expectedBranch + ? thread.branch + : command.branch; const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ @@ -333,7 +339,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.modelSelection !== undefined ? { modelSelection: command.modelSelection } : {}), - ...(command.branch !== undefined ? { branch: command.branch } : {}), + ...(branch !== undefined ? { branch } : {}), ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), updatedAt: occurredAt, }, diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 3781fad83369fae8216ee654e27fe7abdef68d72..f302e976ca70c685f8e8a7faa5126ddafc36205f 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -9,6 +9,7 @@ import { resolveLiveThreadBranchUpdate, resolveQuickAction, resolveThreadBranchUpdate, + resolveThreadBranchMetadataPatch, } from "./GitActionsControl.logic"; function status(overrides: Partial = {}): VcsStatusResult { @@ -1111,6 +1112,18 @@ describe("resolveLiveThreadBranchUpdate", () => { }); }); +describe("resolveThreadBranchMetadataPatch", () => { + it("does not overwrite worktree metadata while reconciling a branch", () => { + assert.deepEqual( + resolveThreadBranchMetadataPatch("feature/current-ref", "feature/previous-ref"), + { + branch: "feature/current-ref", + expectedBranch: "feature/previous-ref", + }, + ); + }); +}); + describe("resolveAutoFeatureBranchName", () => { it("uses semantic preferred ref names when available", () => { const ref = resolveAutoFeatureBranchName(["main", "feature/other"], "fix toast copy"); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 3f6bae61cdd5af917b434e16e8c6c930bc803441..96f7af794ace97e4d8271fb647daf78b0486f482 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -373,6 +373,16 @@ export function resolveThreadBranchUpdate( }; } +export function resolveThreadBranchMetadataPatch( + branch: string | null, + expectedBranch: string | null, +): { + branch: string | null; + expectedBranch: string | null; +} { + return { branch, expectedBranch }; +} + export function resolveLiveThreadBranchUpdate(input: { threadBranch: string | null; gitStatus: VcsStatusResult | null; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index c98167194524d058592f64cf3ad93f9ad37d850a..f71e855a18f9cbfff126ba446d0a54ae7381ff4a 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -45,6 +45,7 @@ import { requiresDefaultBranchConfirmation, resolveDefaultBranchActionDialogCopy, resolveLiveThreadBranchUpdate, + resolveThreadBranchMetadataPatch, resolveQuickAction, resolveThreadBranchUpdate, } from "./GitActionsControl.logic"; @@ -1033,13 +1034,11 @@ export default function GitActionsControl({ return; } - const worktreePath = activeServerThread.worktreePath; void updateThreadMetadata({ environmentId: activeThreadRef.environmentId, input: { threadId: activeThreadRef.threadId, - branch, - worktreePath, + ...resolveThreadBranchMetadataPatch(branch, activeServerThread.branch), }, }); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 49a626e20af78cf4489fd3d0563c25fdc1a123c5..7e9c421b5df4ed41230fd9aa4c229d05ab1e4781 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -552,6 +552,7 @@ const ThreadMetaUpdateCommand = Schema.Struct({ title: Schema.optional(TrimmedNonEmptyString), modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + expectedBranch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), }); -- 2.54.0