From 35dde9708fb184ee6c20fd0db4e9d9f246458f8e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 9 Jul 2026 07:55:50 +0200 Subject: [PATCH 1/3] fix worktree metadata during branch sync Co-authored-by: codex --- apps/web/src/components/GitActionsControl.logic.test.ts | 9 +++++++++ apps/web/src/components/GitActionsControl.logic.ts | 6 ++++++ apps/web/src/components/GitActionsControl.tsx | 5 ++--- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 3781fad8336..6c7ab6c0944 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,14 @@ describe("resolveLiveThreadBranchUpdate", () => { }); }); +describe("resolveThreadBranchMetadataPatch", () => { + it("does not overwrite worktree metadata while reconciling a branch", () => { + assert.deepEqual(resolveThreadBranchMetadataPatch("feature/current-ref"), { + branch: "feature/current-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..bd3d58a8500 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -373,6 +373,12 @@ export function resolveThreadBranchUpdate( }; } +export function resolveThreadBranchMetadataPatch(branch: string | null): { + branch: string | null; +} { + return { branch }; +} + 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..2b5d09d56be 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), }, }); From 16b9c7dca922a782ad8ec1d05ec2485ed7f447e6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 9 Jul 2026 08:04:15 +0200 Subject: [PATCH 2/3] prevent stale temporary branch regressions Co-authored-by: codex --- .../Layers/OrchestrationEngine.test.ts | 52 +++++++++++++++++++ apps/server/src/orchestration/decider.ts | 13 ++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index edfda7c837a..53c28560fa8 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -477,6 +477,58 @@ 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", + }), + ); + + const snapshot = await system.readModel(); + expect(snapshot.threads[0]?.branch).toBe("t3code/generated-branch-name"); + 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..9c6d9862ef1 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -4,6 +4,7 @@ import { type OrchestrationEvent, type OrchestrationReadModel, } from "@t3tools/contracts"; +import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -313,11 +314,19 @@ 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.branch !== null && + thread.branch !== null && + isTemporaryWorktreeBranch(command.branch) && + !isTemporaryWorktreeBranch(thread.branch) + ? thread.branch + : command.branch; const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ @@ -333,7 +342,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, }, From c8405efe493dc48248a7c6c14dcef1807552c48f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 9 Jul 2026 08:33:21 +0200 Subject: [PATCH 3/3] guard branch sync with expected state Co-authored-by: codex --- .../Layers/OrchestrationEngine.test.ts | 54 +++++++++++++++++++ apps/server/src/orchestration/decider.ts | 7 +-- .../GitActionsControl.logic.test.ts | 10 ++-- .../src/components/GitActionsControl.logic.ts | 8 ++- apps/web/src/components/GitActionsControl.tsx | 2 +- packages/contracts/src/orchestration.ts | 1 + 6 files changed, 71 insertions(+), 11 deletions(-) diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 53c28560fa8..405103450e8 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -521,6 +521,7 @@ describe("OrchestrationEngine", () => { commandId: CommandId.make("cmd-stale-temporary-branch-sync"), threadId: ThreadId.make("thread-branch-race"), branch: "t3code/1234abcd", + expectedBranch: "t3code/1234abcd", }), ); @@ -529,6 +530,59 @@ describe("OrchestrationEngine", () => { 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 9c6d9862ef1..9c95b42269d 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -4,7 +4,6 @@ import { type OrchestrationEvent, type OrchestrationReadModel, } from "@t3tools/contracts"; -import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -321,10 +320,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }); const branch = command.branch !== undefined && - command.branch !== null && - thread.branch !== null && - isTemporaryWorktreeBranch(command.branch) && - !isTemporaryWorktreeBranch(thread.branch) + command.expectedBranch !== undefined && + thread.branch !== command.expectedBranch ? thread.branch : command.branch; const occurredAt = yield* nowIso; diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 6c7ab6c0944..f302e976ca7 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -1114,9 +1114,13 @@ describe("resolveLiveThreadBranchUpdate", () => { describe("resolveThreadBranchMetadataPatch", () => { it("does not overwrite worktree metadata while reconciling a branch", () => { - assert.deepEqual(resolveThreadBranchMetadataPatch("feature/current-ref"), { - branch: "feature/current-ref", - }); + assert.deepEqual( + resolveThreadBranchMetadataPatch("feature/current-ref", "feature/previous-ref"), + { + branch: "feature/current-ref", + expectedBranch: "feature/previous-ref", + }, + ); }); }); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index bd3d58a8500..96f7af794ac 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -373,10 +373,14 @@ export function resolveThreadBranchUpdate( }; } -export function resolveThreadBranchMetadataPatch(branch: string | null): { +export function resolveThreadBranchMetadataPatch( + branch: string | null, + expectedBranch: string | null, +): { branch: string | null; + expectedBranch: string | null; } { - return { branch }; + return { branch, expectedBranch }; } export function resolveLiveThreadBranchUpdate(input: { diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 2b5d09d56be..f71e855a18f 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1038,7 +1038,7 @@ export default function GitActionsControl({ environmentId: activeThreadRef.environmentId, input: { threadId: activeThreadRef.threadId, - ...resolveThreadBranchMetadataPatch(branch), + ...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)), });