authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-09 00:01:23-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-09 09:01:23+02:00
log3201e00ad4f59cc287517e61a19b069b497c16d2
treec31d6c3becc9b1028abf254c4996e5d6cd605c6a
parente9127658a630c51a0d78a51fe550b28807514eae
signature Signed by PGP key B5690EEEBB952194

[codex] Preserve worktree metadata during branch sync (#3822)

Co-authored-by: codex <codex@users.noreply.github.com>

6 files changed, 140 insertions(+), 5 deletions(-)

apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+106
...@@ -477,6 +477,112 @@ describe("OrchestrationEngine", () => {...@@ -477,6 +477,112 @@ describe("OrchestrationEngine", () => {
477 await system.dispose();477 await system.dispose();
478 });478 });
479479
480 it("does not regress a generated branch to a stale temporary worktree branch", async () => {
481 const system = await createOrchestrationSystem();
482 const { engine } = system;
483 const createdAt = now();
484
485 await system.run(
486 engine.dispatch({
487 type: "project.create",
488 commandId: CommandId.make("cmd-branch-race-project-create"),
489 projectId: asProjectId("project-branch-race"),
490 title: "Branch Race Project",
491 workspaceRoot: "/tmp/project-branch-race",
492 defaultModelSelection: {
493 instanceId: ProviderInstanceId.make("codex"),
494 model: "gpt-5-codex",
495 },
496 createdAt,
497 }),
498 );
499 await system.run(
500 engine.dispatch({
501 type: "thread.create",
502 commandId: CommandId.make("cmd-branch-race-thread-create"),
503 threadId: ThreadId.make("thread-branch-race"),
504 projectId: asProjectId("project-branch-race"),
505 title: "Branch Race Thread",
506 modelSelection: {
507 instanceId: ProviderInstanceId.make("codex"),
508 model: "gpt-5-codex",
509 },
510 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
511 runtimeMode: "approval-required",
512 branch: "t3code/generated-branch-name",
513 worktreePath: "/tmp/project-branch-race-worktree",
514 createdAt,
515 }),
516 );
517
518 await system.run(
519 engine.dispatch({
520 type: "thread.meta.update",
521 commandId: CommandId.make("cmd-stale-temporary-branch-sync"),
522 threadId: ThreadId.make("thread-branch-race"),
523 branch: "t3code/1234abcd",
524 expectedBranch: "t3code/1234abcd",
525 }),
526 );
527
528 const snapshot = await system.readModel();
529 expect(snapshot.threads[0]?.branch).toBe("t3code/generated-branch-name");
530 await system.dispose();
531 });
532
533 it("allows authoritative worktree bootstrap to assign a temporary branch", async () => {
534 const system = await createOrchestrationSystem();
535 const { engine } = system;
536 const createdAt = now();
537
538 await system.run(
539 engine.dispatch({
540 type: "project.create",
541 commandId: CommandId.make("cmd-worktree-bootstrap-project-create"),
542 projectId: asProjectId("project-worktree-bootstrap"),
543 title: "Worktree Bootstrap Project",
544 workspaceRoot: "/tmp/project-worktree-bootstrap",
545 defaultModelSelection: {
546 instanceId: ProviderInstanceId.make("codex"),
547 model: "gpt-5-codex",
548 },
549 createdAt,
550 }),
551 );
552 await system.run(
553 engine.dispatch({
554 type: "thread.create",
555 commandId: CommandId.make("cmd-worktree-bootstrap-thread-create"),
556 threadId: ThreadId.make("thread-worktree-bootstrap"),
557 projectId: asProjectId("project-worktree-bootstrap"),
558 title: "Worktree Bootstrap Thread",
559 modelSelection: {
560 instanceId: ProviderInstanceId.make("codex"),
561 model: "gpt-5-codex",
562 },
563 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
564 runtimeMode: "approval-required",
565 branch: "main",
566 worktreePath: null,
567 createdAt,
568 }),
569 );
570 await system.run(
571 engine.dispatch({
572 type: "thread.meta.update",
573 commandId: CommandId.make("cmd-authoritative-worktree-bootstrap"),
574 threadId: ThreadId.make("thread-worktree-bootstrap"),
575 branch: "t3code/1234abcd",
576 worktreePath: "/tmp/project-worktree-bootstrap-worktree",
577 }),
578 );
579
580 const snapshot = await system.readModel();
581 expect(snapshot.threads[0]?.branch).toBe("t3code/1234abcd");
582 expect(snapshot.threads[0]?.worktreePath).toBe("/tmp/project-worktree-bootstrap-worktree");
583 await system.dispose();
584 });
585
480 it("records command ack duration using the first committed event type", async () => {586 it("records command ack duration using the first committed event type", async () => {
481 const system = await createOrchestrationSystem();587 const system = await createOrchestrationSystem();
482 const { engine } = system;588 const { engine } = system;
apps/server/src/orchestration/decider.ts+8-2
...@@ -313,11 +313,17 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -313,11 +313,17 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
313 }313 }
314314
315 case "thread.meta.update": {315 case "thread.meta.update": {
316 yield* requireThread({316 const thread = yield* requireThread({
317 readModel,317 readModel,
318 command,318 command,
319 threadId: command.threadId,319 threadId: command.threadId,
320 });320 });
321 const branch =
322 command.branch !== undefined &&
323 command.expectedBranch !== undefined &&
324 thread.branch !== command.expectedBranch
325 ? thread.branch
326 : command.branch;
321 const occurredAt = yield* nowIso;327 const occurredAt = yield* nowIso;
322 return {328 return {
323 ...(yield* withEventBase({329 ...(yield* withEventBase({
...@@ -333,7 +339,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -333,7 +339,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
333 ...(command.modelSelection !== undefined339 ...(command.modelSelection !== undefined
334 ? { modelSelection: command.modelSelection }340 ? { modelSelection: command.modelSelection }
335 : {}),341 : {}),
336 ...(command.branch !== undefined ? { branch: command.branch } : {}),342 ...(branch !== undefined ? { branch } : {}),
337 ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}),343 ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}),
338 updatedAt: occurredAt,344 updatedAt: occurredAt,
339 },345 },
apps/web/src/components/GitActionsControl.logic.test.ts+13
...@@ -9,6 +9,7 @@ import {...@@ -9,6 +9,7 @@ import {
9 resolveLiveThreadBranchUpdate,9 resolveLiveThreadBranchUpdate,
10 resolveQuickAction,10 resolveQuickAction,
11 resolveThreadBranchUpdate,11 resolveThreadBranchUpdate,
12 resolveThreadBranchMetadataPatch,
12} from "./GitActionsControl.logic";13} from "./GitActionsControl.logic";
1314
14function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult {15function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult {
...@@ -1111,6 +1112,18 @@ describe("resolveLiveThreadBranchUpdate", () => {...@@ -1111,6 +1112,18 @@ describe("resolveLiveThreadBranchUpdate", () => {
1111 });1112 });
1112});1113});
11131114
1115describe("resolveThreadBranchMetadataPatch", () => {
1116 it("does not overwrite worktree metadata while reconciling a branch", () => {
1117 assert.deepEqual(
1118 resolveThreadBranchMetadataPatch("feature/current-ref", "feature/previous-ref"),
1119 {
1120 branch: "feature/current-ref",
1121 expectedBranch: "feature/previous-ref",
1122 },
1123 );
1124 });
1125});
1126
1114describe("resolveAutoFeatureBranchName", () => {1127describe("resolveAutoFeatureBranchName", () => {
1115 it("uses semantic preferred ref names when available", () => {1128 it("uses semantic preferred ref names when available", () => {
1116 const ref = resolveAutoFeatureBranchName(["main", "feature/other"], "fix toast copy");1129 const ref = resolveAutoFeatureBranchName(["main", "feature/other"], "fix toast copy");
apps/web/src/components/GitActionsControl.logic.ts+10
...@@ -373,6 +373,16 @@ export function resolveThreadBranchUpdate(...@@ -373,6 +373,16 @@ export function resolveThreadBranchUpdate(
373 };373 };
374}374}
375375
376export function resolveThreadBranchMetadataPatch(
377 branch: string | null,
378 expectedBranch: string | null,
379): {
380 branch: string | null;
381 expectedBranch: string | null;
382} {
383 return { branch, expectedBranch };
384}
385
376export function resolveLiveThreadBranchUpdate(input: {386export function resolveLiveThreadBranchUpdate(input: {
377 threadBranch: string | null;387 threadBranch: string | null;
378 gitStatus: VcsStatusResult | null;388 gitStatus: VcsStatusResult | null;
apps/web/src/components/GitActionsControl.tsx+2-3
...@@ -45,6 +45,7 @@ import {...@@ -45,6 +45,7 @@ import {
45 requiresDefaultBranchConfirmation,45 requiresDefaultBranchConfirmation,
46 resolveDefaultBranchActionDialogCopy,46 resolveDefaultBranchActionDialogCopy,
47 resolveLiveThreadBranchUpdate,47 resolveLiveThreadBranchUpdate,
48 resolveThreadBranchMetadataPatch,
48 resolveQuickAction,49 resolveQuickAction,
49 resolveThreadBranchUpdate,50 resolveThreadBranchUpdate,
50} from "./GitActionsControl.logic";51} from "./GitActionsControl.logic";
...@@ -1033,13 +1034,11 @@ export default function GitActionsControl({...@@ -1033,13 +1034,11 @@ export default function GitActionsControl({
1033 return;1034 return;
1034 }1035 }
10351036
1036 const worktreePath = activeServerThread.worktreePath;
1037 void updateThreadMetadata({1037 void updateThreadMetadata({
1038 environmentId: activeThreadRef.environmentId,1038 environmentId: activeThreadRef.environmentId,
1039 input: {1039 input: {
1040 threadId: activeThreadRef.threadId,1040 threadId: activeThreadRef.threadId,
1041 branch,1041 ...resolveThreadBranchMetadataPatch(branch, activeServerThread.branch),
1042 worktreePath,
1043 },1042 },
1044 });1043 });
10451044
packages/contracts/src/orchestration.ts+1
...@@ -552,6 +552,7 @@ const ThreadMetaUpdateCommand = Schema.Struct({...@@ -552,6 +552,7 @@ const ThreadMetaUpdateCommand = Schema.Struct({
552 title: Schema.optional(TrimmedNonEmptyString),552 title: Schema.optional(TrimmedNonEmptyString),
553 modelSelection: Schema.optional(ModelSelection),553 modelSelection: Schema.optional(ModelSelection),
554 branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),554 branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
555 expectedBranch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
555 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),556 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
556});557});
557558