authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-31 20:18:58-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-31 20:18:58-07:00
log0ad91b6e7fc1fcb6d5f4bc736d84c337e912bc62
tree36b313b4e183590ec0e62b1315ec595f67c9944f
parentca72e381c64f25d771236eecf70219f68e5f365b
signature Signed by PGP key B5690EEEBB952194

fix(server): follow branch drift in dedicated worktrees so PRs link to their thread (#5159)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

2 files changed, 205 insertions(+), 19 deletions(-)

apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+124-17
......@@ -277,6 +277,9 @@ describe("CheckpointReactor", () => {
277277 readonly seedFilesystemCheckpoints?: boolean;
278278 readonly projectWorkspaceRoot?: string;
279279 readonly threadWorktreePath?: string | null;
280 readonly threadBranch?: string | null;
281 readonly secondThreadSharingWorktree?: boolean;
282 readonly localStatusRefName?: string | null;
280283 readonly providerSessionCwd?: string;
281284 readonly providerName?: ProviderDriverKind;
282285 readonly gitStatusRefreshCalls?: Array<string>;
......@@ -315,7 +318,8 @@ describe("CheckpointReactor", () => {
315318 isRepo: true,
316319 hasPrimaryRemote: false,
317320 isDefaultRef: true,
318 refName: "main",
321 refName:
322 options?.localStatusRefName !== undefined ? options.localStatusRefName : "main",
319323 hasWorkingTreeChanges: false,
320324 workingTree: { files: [], insertions: 0, deletions: 0 },
321325 }),
......@@ -370,22 +374,45 @@ describe("CheckpointReactor", () => {
370374 }),
371375 );
372376 await Effect.runPromise(
373 engine.dispatch({
374 type: "thread.create",
375 commandId: CommandId.make("cmd-thread-create"),
376 threadId: ThreadId.make("thread-1"),
377 projectId: asProjectId("project-1"),
378 title: "Thread",
379 modelSelection: {
380 instanceId: ProviderInstanceId.make("codex"),
381 model: "gpt-5-codex",
382 },
383 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
384 runtimeMode: "approval-required",
385 branch: null,
386 worktreePath: options?.threadWorktreePath ?? cwd,
387 createdAt,
388 }),
377 engine
378 .dispatch({
379 type: "thread.create",
380 commandId: CommandId.make("cmd-thread-create"),
381 threadId: ThreadId.make("thread-1"),
382 projectId: asProjectId("project-1"),
383 title: "Thread",
384 modelSelection: {
385 instanceId: ProviderInstanceId.make("codex"),
386 model: "gpt-5-codex",
387 },
388 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
389 runtimeMode: "approval-required",
390 branch: options?.threadBranch ?? null,
391 worktreePath: options?.threadWorktreePath ?? cwd,
392 createdAt,
393 })
394 .pipe(
395 options?.secondThreadSharingWorktree
396 ? Effect.andThen(
397 engine.dispatch({
398 type: "thread.create",
399 commandId: CommandId.make("cmd-thread-create-2"),
400 threadId: ThreadId.make("thread-2"),
401 projectId: asProjectId("project-1"),
402 title: "Thread 2",
403 modelSelection: {
404 instanceId: ProviderInstanceId.make("codex"),
405 model: "gpt-5-codex",
406 },
407 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
408 runtimeMode: "approval-required",
409 branch: null,
410 worktreePath: options?.threadWorktreePath ?? cwd,
411 createdAt,
412 }),
413 )
414 : Effect.asVoid,
415 ),
389416 );
390417
391418 if (options?.seedFilesystemCheckpoints ?? true) {
......@@ -518,6 +545,86 @@ describe("CheckpointReactor", () => {
518545 expect(gitStatusRefreshCalls).toEqual([harness.cwd]);
519546 });
520547
548 it("adopts a drifted checkout as the thread branch on a dedicated worktree", async () => {
549 const harness = await createHarness({
550 seedFilesystemCheckpoints: false,
551 threadBranch: "t3code/original-branch",
552 localStatusRefName: "t3code/renamed-by-agent",
553 });
554
555 harness.provider.emit({
556 type: "turn.completed",
557 eventId: EventId.make("evt-turn-completed-branch-drift"),
558 provider: ProviderDriverKind.make("codex"),
559 createdAt: "2026-01-01T00:00:00.000Z",
560 threadId: ThreadId.make("thread-1"),
561 turnId: asTurnId("turn-branch-drift"),
562 payload: { state: "completed" },
563 });
564
565 await harness.drain();
566 await waitForEvent(
567 harness.engine,
568 (event) =>
569 event.type === "thread.meta-updated" &&
570 (event as unknown as { payload: { branch?: string } }).payload.branch ===
571 "t3code/renamed-by-agent",
572 );
573
574 const snapshot = await harness.readModel();
575 const thread = snapshot.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
576 expect(thread?.branch).toBe("t3code/renamed-by-agent");
577 });
578
579 it("does not adopt a drifted checkout when the worktree is shared by another thread", async () => {
580 const harness = await createHarness({
581 seedFilesystemCheckpoints: false,
582 threadBranch: "t3code/original-branch",
583 localStatusRefName: "t3code/renamed-by-agent",
584 secondThreadSharingWorktree: true,
585 });
586
587 harness.provider.emit({
588 type: "turn.completed",
589 eventId: EventId.make("evt-turn-completed-branch-drift-shared"),
590 provider: ProviderDriverKind.make("codex"),
591 createdAt: "2026-01-01T00:00:00.000Z",
592 threadId: ThreadId.make("thread-1"),
593 turnId: asTurnId("turn-branch-drift-shared"),
594 payload: { state: "completed" },
595 });
596
597 await harness.drain();
598
599 const snapshot = await harness.readModel();
600 const thread = snapshot.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
601 expect(thread?.branch).toBe("t3code/original-branch");
602 });
603
604 it("does not adopt a temporary placeholder checkout as the thread branch", async () => {
605 const harness = await createHarness({
606 seedFilesystemCheckpoints: false,
607 threadBranch: "t3code/original-branch",
608 localStatusRefName: "t3code/0a1b2c3d",
609 });
610
611 harness.provider.emit({
612 type: "turn.completed",
613 eventId: EventId.make("evt-turn-completed-branch-drift-temp"),
614 provider: ProviderDriverKind.make("codex"),
615 createdAt: "2026-01-01T00:00:00.000Z",
616 threadId: ThreadId.make("thread-1"),
617 turnId: asTurnId("turn-branch-drift-temp"),
618 payload: { state: "completed" },
619 });
620
621 await harness.drain();
622
623 const snapshot = await harness.readModel();
624 const thread = snapshot.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
625 expect(thread?.branch).toBe("t3code/original-branch");
626 });
627
521628 it("ignores auxiliary thread turn completion while primary turn is active", async () => {
522629 const harness = await createHarness({ seedFilesystemCheckpoints: false });
523630 const createdAt = "2026-01-01T00:00:00.000Z";
apps/server/src/orchestration/Layers/CheckpointReactor.ts+81-2
......@@ -8,6 +8,7 @@ import {
88 TurnId,
99 type OrchestrationEvent,
1010 type ProviderRuntimeEvent,
11 type VcsStatusLocalResult,
1112} from "@t3tools/contracts";
1213import * as Cause from "effect/Cause";
1314import * as Crypto from "effect/Crypto";
......@@ -18,6 +19,7 @@ import * as Option from "effect/Option";
1819import type * as PlatformError from "effect/PlatformError";
1920import * as Stream from "effect/Stream";
2021import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
22import { isTemporaryWorktreeBranch } from "@t3tools/shared/git";
2123
2224import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts";
2325import {
......@@ -534,16 +536,93 @@ const make = Effect.gen(function* () {
534536 return;
535537 }
536538
537 yield* vcsStatusBroadcaster.refreshLocalStatus(sessionRuntime.value.cwd).pipe(
539 const local = yield* vcsStatusBroadcaster.refreshLocalStatus(sessionRuntime.value.cwd).pipe(
538540 Effect.catch((error) =>
539541 Effect.logWarning("failed to refresh local git status after turn completion", {
540542 threadId: event.threadId,
541543 turnId: event.turnId ?? null,
542544 cwd: sessionRuntime.value.cwd,
543545 detail: error.message,
544 }),
546 }).pipe(Effect.as(null)),
545547 ),
546548 );
549 if (local !== null) {
550 yield* followWorktreeBranchDrift({
551 threadId: event.threadId,
552 cwd: sessionRuntime.value.cwd,
553 local,
554 });
555 }
556 });
557
558 // A `git checkout` run inside a thread's dedicated worktree (by an agent or
559 // the user) bypasses T3's commands, so the thread's recorded branch goes
560 // stale. Since #4460 the client only attributes PR state to a thread when
561 // the checked-out branch equals the recorded one, so stale metadata silently
562 // orphans the thread's PR. Follow the drift here: adopt the checked-out
563 // branch as the thread's branch, but only when the worktree belongs to
564 // exactly this thread — for shared cwds the strict matching is the point.
565 const followWorktreeBranchDrift = Effect.fn("followWorktreeBranchDrift")(function* (input: {
566 readonly threadId: ThreadId;
567 readonly cwd: string;
568 readonly local: VcsStatusLocalResult;
569 }) {
570 // Detached HEAD has no branch to adopt; a temporary placeholder checkout
571 // means the first-turn auto-rename is still in flight — don't race it.
572 const checkedOutBranch = input.local.refName;
573 if (checkedOutBranch === null || isTemporaryWorktreeBranch(checkedOutBranch)) {
574 return;
575 }
576
577 yield* Effect.gen(function* () {
578 const thread = yield* projectionSnapshotQuery
579 .getThreadShellById(input.threadId)
580 .pipe(Effect.map(Option.getOrUndefined));
581 if (
582 !thread ||
583 thread.branch === null ||
584 thread.branch === checkedOutBranch ||
585 thread.worktreePath === null ||
586 thread.worktreePath !== input.cwd ||
587 isTemporaryWorktreeBranch(thread.branch)
588 ) {
589 return;
590 }
591
592 const shell = yield* projectionSnapshotQuery.getShellSnapshot();
593 const worktreeIsShared = shell.threads.some(
594 (other) => other.id !== thread.id && other.worktreePath === thread.worktreePath,
595 );
596 if (worktreeIsShared) {
597 return;
598 }
599
600 // expectedBranch makes this a compare-and-swap in the decider: if the
601 // recorded branch moved between our read and the dispatch (rename,
602 // concurrent drift-follow), the stale update is dropped.
603 yield* orchestrationEngine.dispatch({
604 type: "thread.meta.update",
605 commandId: yield* serverCommandId("worktree-branch-drift"),
606 threadId: thread.id,
607 branch: checkedOutBranch,
608 expectedBranch: thread.branch,
609 });
610 yield* Effect.logInfo("thread branch followed worktree checkout", {
611 threadId: thread.id,
612 previousBranch: thread.branch,
613 branch: checkedOutBranch,
614 });
615 }).pipe(
616 Effect.catchCause((cause) => {
617 if (Cause.hasInterruptsOnly(cause)) {
618 return Effect.failCause(cause);
619 }
620 return Effect.logWarning("failed to follow worktree branch drift", {
621 threadId: input.threadId,
622 cause: Cause.pretty(cause),
623 });
624 }),
625 );
547626 });
548627
549628 const ensurePreTurnBaselineFromDomainTurnStart = Effect.fn(