Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 124 additions & 17 deletions apps/server/src/orchestration/Layers/CheckpointReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
Expand Down Expand Up @@ -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 },
}),
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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";
Expand Down
83 changes: 81 additions & 2 deletions apps/server/src/orchestration/Layers/CheckpointReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Temp branch blocks drift adopt

Medium Severity

followWorktreeBranchDrift returns early when the thread’s recorded branch is still a temporary placeholder, even if the checkout is already a real branch. The PR only calls for ignoring temporary checkouts, and expectedBranch already drops a stale adopt if first-turn rename wins the race. When rename fails and the agent has already branched, this leave the recorded branch stuck and the PR unlinked on the server path.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2566f5c. Configure here.


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(
Expand Down
Loading