diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 707c87c43c9..e72a100069f 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -277,6 +277,9 @@ describe("CheckpointReactor", () => { readonly seedFilesystemCheckpoints?: boolean; readonly projectWorkspaceRoot?: string; readonly threadWorktreePath?: string | null; + readonly threadBranch?: string | null; + readonly secondThreadSharingWorktree?: boolean; + readonly localStatusRefName?: string | null; readonly providerSessionCwd?: string; readonly providerName?: ProviderDriverKind; readonly gitStatusRefreshCalls?: Array; @@ -315,7 +318,8 @@ describe("CheckpointReactor", () => { isRepo: true, hasPrimaryRemote: false, isDefaultRef: true, - refName: "main", + refName: + options?.localStatusRefName !== undefined ? options.localStatusRefName : "main", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, }), @@ -370,22 +374,45 @@ describe("CheckpointReactor", () => { }), ); await Effect.runPromise( - engine.dispatch({ - type: "thread.create", - commandId: CommandId.make("cmd-thread-create"), - threadId: ThreadId.make("thread-1"), - projectId: asProjectId("project-1"), - title: "Thread", - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5-codex", - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - branch: null, - worktreePath: options?.threadWorktreePath ?? cwd, - createdAt, - }), + engine + .dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-create"), + threadId: ThreadId.make("thread-1"), + projectId: asProjectId("project-1"), + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: options?.threadBranch ?? null, + worktreePath: options?.threadWorktreePath ?? cwd, + createdAt, + }) + .pipe( + options?.secondThreadSharingWorktree + ? Effect.andThen( + engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-create-2"), + threadId: ThreadId.make("thread-2"), + projectId: asProjectId("project-1"), + title: "Thread 2", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: options?.threadWorktreePath ?? cwd, + createdAt, + }), + ) + : Effect.asVoid, + ), ); if (options?.seedFilesystemCheckpoints ?? true) { @@ -518,6 +545,86 @@ describe("CheckpointReactor", () => { expect(gitStatusRefreshCalls).toEqual([harness.cwd]); }); + it("adopts a drifted checkout as the thread branch on a dedicated worktree", async () => { + const harness = await createHarness({ + seedFilesystemCheckpoints: false, + threadBranch: "t3code/original-branch", + localStatusRefName: "t3code/renamed-by-agent", + }); + + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.make("evt-turn-completed-branch-drift"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-branch-drift"), + payload: { state: "completed" }, + }); + + await harness.drain(); + await waitForEvent( + harness.engine, + (event) => + event.type === "thread.meta-updated" && + (event as unknown as { payload: { branch?: string } }).payload.branch === + "t3code/renamed-by-agent", + ); + + const snapshot = await harness.readModel(); + const thread = snapshot.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.branch).toBe("t3code/renamed-by-agent"); + }); + + it("does not adopt a drifted checkout when the worktree is shared by another thread", async () => { + const harness = await createHarness({ + seedFilesystemCheckpoints: false, + threadBranch: "t3code/original-branch", + localStatusRefName: "t3code/renamed-by-agent", + secondThreadSharingWorktree: true, + }); + + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.make("evt-turn-completed-branch-drift-shared"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-branch-drift-shared"), + payload: { state: "completed" }, + }); + + await harness.drain(); + + const snapshot = await harness.readModel(); + const thread = snapshot.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.branch).toBe("t3code/original-branch"); + }); + + it("does not adopt a temporary placeholder checkout as the thread branch", async () => { + const harness = await createHarness({ + seedFilesystemCheckpoints: false, + threadBranch: "t3code/original-branch", + localStatusRefName: "t3code/0a1b2c3d", + }); + + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.make("evt-turn-completed-branch-drift-temp"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-branch-drift-temp"), + payload: { state: "completed" }, + }); + + await harness.drain(); + + const snapshot = await harness.readModel(); + const thread = snapshot.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.branch).toBe("t3code/original-branch"); + }); + it("ignores auxiliary thread turn completion while primary turn is active", async () => { const harness = await createHarness({ seedFilesystemCheckpoints: false }); const createdAt = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 3ba244ddf2c..54cbe3eb5bf 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -8,6 +8,7 @@ import { TurnId, type OrchestrationEvent, type ProviderRuntimeEvent, + type VcsStatusLocalResult, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; @@ -18,6 +19,7 @@ import * as Option from "effect/Option"; import type * as PlatformError from "effect/PlatformError"; import * as Stream from "effect/Stream"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts"; import { @@ -534,16 +536,93 @@ const make = Effect.gen(function* () { return; } - yield* vcsStatusBroadcaster.refreshLocalStatus(sessionRuntime.value.cwd).pipe( + const local = yield* vcsStatusBroadcaster.refreshLocalStatus(sessionRuntime.value.cwd).pipe( Effect.catch((error) => Effect.logWarning("failed to refresh local git status after turn completion", { threadId: event.threadId, turnId: event.turnId ?? null, cwd: sessionRuntime.value.cwd, detail: error.message, - }), + }).pipe(Effect.as(null)), ), ); + if (local !== null) { + yield* followWorktreeBranchDrift({ + threadId: event.threadId, + cwd: sessionRuntime.value.cwd, + local, + }); + } + }); + + // A `git checkout` run inside a thread's dedicated worktree (by an agent or + // the user) bypasses T3's commands, so the thread's recorded branch goes + // stale. Since #4460 the client only attributes PR state to a thread when + // the checked-out branch equals the recorded one, so stale metadata silently + // orphans the thread's PR. Follow the drift here: adopt the checked-out + // branch as the thread's branch, but only when the worktree belongs to + // exactly this thread — for shared cwds the strict matching is the point. + const followWorktreeBranchDrift = Effect.fn("followWorktreeBranchDrift")(function* (input: { + readonly threadId: ThreadId; + readonly cwd: string; + readonly local: VcsStatusLocalResult; + }) { + // Detached HEAD has no branch to adopt; a temporary placeholder checkout + // means the first-turn auto-rename is still in flight — don't race it. + const checkedOutBranch = input.local.refName; + if (checkedOutBranch === null || isTemporaryWorktreeBranch(checkedOutBranch)) { + return; + } + + yield* Effect.gen(function* () { + const thread = yield* projectionSnapshotQuery + .getThreadShellById(input.threadId) + .pipe(Effect.map(Option.getOrUndefined)); + if ( + !thread || + thread.branch === null || + thread.branch === checkedOutBranch || + thread.worktreePath === null || + thread.worktreePath !== input.cwd || + isTemporaryWorktreeBranch(thread.branch) + ) { + return; + } + + const shell = yield* projectionSnapshotQuery.getShellSnapshot(); + const worktreeIsShared = shell.threads.some( + (other) => other.id !== thread.id && other.worktreePath === thread.worktreePath, + ); + if (worktreeIsShared) { + return; + } + + // expectedBranch makes this a compare-and-swap in the decider: if the + // recorded branch moved between our read and the dispatch (rename, + // concurrent drift-follow), the stale update is dropped. + yield* orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: yield* serverCommandId("worktree-branch-drift"), + threadId: thread.id, + branch: checkedOutBranch, + expectedBranch: thread.branch, + }); + yield* Effect.logInfo("thread branch followed worktree checkout", { + threadId: thread.id, + previousBranch: thread.branch, + branch: checkedOutBranch, + }); + }).pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.failCause(cause); + } + return Effect.logWarning("failed to follow worktree branch drift", { + threadId: input.threadId, + cause: Cause.pretty(cause), + }); + }), + ); }); const ensurePreTurnBaselineFromDomainTurnStart = Effect.fn(