diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index edfda7c837a..405103450e8 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 0d4af771ca8..9c95b42269d 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 3781fad8336..f302e976ca7 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 3f6bae61cdd..96f7af794ac 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 c9816719452..f71e855a18f 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 49a626e20af..7e9c421b5df 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)), });