Skip to content
Open
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
3 changes: 3 additions & 0 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,9 @@ export function NewTaskDraftScreen(props: {
branch: creationBranch,
worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath,
startFromOrigin,
...(selectedEnvironmentServerConfig
? { worktreeBranchPrefix: selectedEnvironmentServerConfig.settings.worktreeBranchPrefix }
: {}),
Comment thread
cursor[bot] marked this conversation as resolved.
runtimeMode,
interactionMode,
initialMessageText,
Expand Down
7 changes: 6 additions & 1 deletion apps/mobile/src/features/threads/use-project-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type ModelSelection,
type ProviderInteractionMode,
type RuntimeMode,
type WorktreeBranchPrefix,
} from "@t3tools/contracts";
import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
import * as Cause from "effect/Cause";
Expand All @@ -33,6 +34,7 @@ export function useCreateProjectThread() {
readonly branch: string | null;
readonly worktreePath: string | null;
readonly startFromOrigin?: boolean;
readonly worktreeBranchPrefix?: WorktreeBranchPrefix;
readonly runtimeMode: RuntimeMode;
readonly interactionMode: ProviderInteractionMode;
readonly initialMessageText: string;
Expand Down Expand Up @@ -74,7 +76,10 @@ export function useCreateProjectThread() {
branch: input.branch,
worktreePath: input.worktreePath,
startFromOrigin: input.startFromOrigin ?? false,
worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex),
worktreeBranchName: buildTemporaryWorktreeBranchName(
randomHex,
input.worktreeBranchPrefix,
),
}),
});
if (AsyncResult.isFailure(result)) {
Expand Down
11 changes: 10 additions & 1 deletion apps/mobile/src/state/use-thread-outbox-drain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { toUploadChatImageAttachments } from "../lib/composerImages";
import { randomHex } from "../lib/uuid";
import { appAtomRegistry } from "./atom-registry";
import { useProjects, useThreadShells } from "./entities";
import { serverEnvironment } from "./server";
import {
confirmThreadOutboxMessageQueued,
ensureThreadOutboxLoaded,
Expand Down Expand Up @@ -256,6 +257,11 @@ export function useThreadOutboxDrain(): void {
return false;
}
const { completeDelivery } = makeDeliveryHelpers(queuedMessage);
// Dispatch-time snapshot: the queued creation should use whatever prefix
// the environment reports when the message finally sends.
const environmentServerConfig = appAtomRegistry.get(
serverEnvironment.configValueAtom(queuedMessage.environmentId),
);
const deliveryResult = await startTurn({
environmentId: queuedMessage.environmentId,
input: buildProjectThreadStartTurnInput({
Expand All @@ -274,7 +280,10 @@ export function useThreadOutboxDrain(): void {
branch: creation.branch,
worktreePath: creation.worktreePath,
startFromOrigin: creation.startFromOrigin ?? false,
worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex),
worktreeBranchName: buildTemporaryWorktreeBranchName(
randomHex,
environmentServerConfig?.settings.worktreeBranchPrefix,
),
}),
});
return completeDelivery(deliveryResult);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts";
import * as VcsProcess from "../../vcs/VcsProcess.ts";
import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
import * as ServerSettings from "../../serverSettings.ts";
import { CheckpointReactorLive } from "./CheckpointReactor.ts";
import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
Expand Down Expand Up @@ -349,6 +350,7 @@ describe("CheckpointReactor", () => {
),
Layer.provideMerge(WorkspacePaths.layer),
Layer.provideMerge(VcsProcess.layer),
Layer.provideMerge(ServerSettings.layerTest()),
Layer.provideMerge(ServerConfigLayer),
Layer.provideMerge(NodeServices.layer),
);
Expand Down
21 changes: 16 additions & 5 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 ServerSettingsError,
type VcsStatusLocalResult,
} from "@t3tools/contracts";
import * as Cause from "effect/Cause";
Expand All @@ -30,6 +31,7 @@ import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts";
import { ProviderService } from "../../provider/Services/ProviderService.ts";
import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts";
import { forkParked } from "../../serverActivation.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts";
Expand Down Expand Up @@ -88,6 +90,7 @@ const make = Effect.gen(function* () {
const receiptBus = yield* RuntimeReceiptBus;
const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
const serverSettingsService = yield* ServerSettingsService;

const appendRevertFailureActivity = (input: {
readonly threadId: ThreadId;
Expand Down Expand Up @@ -568,14 +571,19 @@ const make = Effect.gen(function* () {
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.
// Detached HEAD has no branch to adopt.
const checkedOutBranch = input.local.refName;
if (checkedOutBranch === null || isTemporaryWorktreeBranch(checkedOutBranch)) {
if (checkedOutBranch === null) {
return;
}

yield* Effect.gen(function* () {
// A temporary placeholder checkout means the first-turn auto-rename is
// still in flight — don't race it.
const { worktreeBranchPrefix } = yield* serverSettingsService.getSettings;
if (isTemporaryWorktreeBranch(checkedOutBranch, worktreeBranchPrefix)) {

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.

🟡 Medium Layers/CheckpointReactor.ts:584

When a worktree created with team-a is still on its temporary branch team-a/1234abcd and worktreeBranchPrefix changes to team-b, this check no longer recognizes the branch as temporary, so the rename-race path adopts it into thread.branch and can orphan the thread’s PR association. Preserve or derive the prefix used by the active worktree instead of validating only against the current global setting.

Also found in 1 other location(s)

apps/web/src/components/GitActionsControl.tsx:1138

Reconciliation uses only the current serverConfig.settings.worktreeBranchPrefix. If a worktree was created as team/1234abcd and the user changes the setting to org before its temporary branch is renamed, this dependency reruns the effect and the old custom-prefix branch is no longer recognized as temporary. persistThreadBranchSync then replaces the draft's semantic branch metadata with team/1234abcd. The prefix used to create the active worktree must remain recognizable across settings changes.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration/Layers/CheckpointReactor.ts around line 584:

When a worktree created with `team-a` is still on its temporary branch `team-a/1234abcd` and `worktreeBranchPrefix` changes to `team-b`, this check no longer recognizes the branch as temporary, so the rename-race path adopts it into `thread.branch` and can orphan the thread’s PR association. Preserve or derive the prefix used by the active worktree instead of validating only against the current global setting.

Also found in 1 other location(s):
- apps/web/src/components/GitActionsControl.tsx:1138 -- Reconciliation uses only the *current* `serverConfig.settings.worktreeBranchPrefix`. If a worktree was created as `team/1234abcd` and the user changes the setting to `org` before its temporary branch is renamed, this dependency reruns the effect and the old custom-prefix branch is no longer recognized as temporary. `persistThreadBranchSync` then replaces the draft's semantic branch metadata with `team/1234abcd`. The prefix used to create the active worktree must remain recognizable across settings changes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Acknowledged, but leaving this as is. The window requires the prefix setting to change between worktree creation and the first-turn rename, which is seconds on an active thread. If it does happen, the drift dispatch is a compare-and-swap on expectedBranch, so a concurrent rename drops the stale update rather than corrupting the recorded branch. Detecting temporary branches under arbitrary former prefixes would reintroduce the broad eight-hex matching that misclassified branches like release/20260714, which an earlier round of review asked us to remove. The configured-plus-legacy check covers every case that survives a restart.

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.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

return;
}
const thread = yield* projectionSnapshotQuery
.getThreadShellById(input.threadId)
.pipe(Effect.map(Option.getOrUndefined));
Expand All @@ -585,7 +593,7 @@ const make = Effect.gen(function* () {
thread.branch === checkedOutBranch ||
thread.worktreePath === null ||
thread.worktreePath !== input.cwd ||
isTemporaryWorktreeBranch(thread.branch)
isTemporaryWorktreeBranch(thread.branch, worktreeBranchPrefix)
) {
return;
}
Expand Down Expand Up @@ -891,7 +899,10 @@ const make = Effect.gen(function* () {
input: ReactorInput,
): Effect.Effect<
void,
CheckpointStoreError | OrchestrationDispatchError | PlatformError.PlatformError,
| CheckpointStoreError
| OrchestrationDispatchError
| PlatformError.PlatformError
| ServerSettingsError,
never
> =>
input.source === "domain" ? processDomainEvent(input.event) : processRuntimeEvent(input.event);
Expand Down
108 changes: 57 additions & 51 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Clock from "effect/Clock";
import { ServerSettingsService } from "../../serverSettings.ts";
import * as ServerSettings from "../../serverSettings.ts";
import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
import * as GitWorkflowService from "../../git/GitWorkflowService.ts";

Expand Down Expand Up @@ -153,6 +153,7 @@ describe("ProviderCommandReactor", () => {
readonly startSessionEffect?: (
session: ProviderSession,
) => Effect.Effect<ProviderSession, ProviderAdapterRequestError>;
readonly worktreeBranchPrefix?: string;
}) {
const now = "2026-01-01T00:00:00.000Z";
const baseDir =
Expand Down Expand Up @@ -412,7 +413,13 @@ describe("ProviderCommandReactor", () => {
generateThreadTitle,
}),
),
Layer.provideMerge(ServerSettingsService.layerTest()),
Layer.provideMerge(
ServerSettings.layerTest(
input?.worktreeBranchPrefix
? { worktreeBranchPrefix: input.worktreeBranchPrefix }
: undefined,
),
),
Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)),
Layer.provideMerge(NodeServices.layer),
);
Expand Down Expand Up @@ -1456,59 +1463,58 @@ describe("ProviderCommandReactor", () => {
expect(thread?.title).toBe("Reconnect spinner resume bug");
});

it("generates a worktree branch name for the first turn", async () => {
const harness = await createHarness();
const now = "2026-01-01T00:00:00.000Z";
it.each([
["configured", "codex/team/1234abcd", "codex/team/feature/reconnect-backoff"],
["legacy", "t3code/1234abcd", "codex/team/feature/reconnect-backoff"],
] as const)(
"renames the %s temporary branch into the configured prefix on the first turn",
async (_kind, temporaryBranch, expectedBranch) => {
const harness = await createHarness({ worktreeBranchPrefix: "codex/team" });
const now = "2026-01-01T00:00:00.000Z";

await Effect.runPromise(
harness.engine.dispatch({
type: "thread.meta.update",
commandId: CommandId.make("cmd-thread-branch"),
threadId: ThreadId.make("thread-1"),
branch: "t3code/1234abcd",
worktreePath: "/tmp/provider-project-worktree",
}),
);
await Effect.runPromise(
harness.engine.dispatch({
type: "thread.meta.update",
commandId: CommandId.make("cmd-thread-branch"),
threadId: ThreadId.make("thread-1"),
branch: temporaryBranch,
worktreePath: "/tmp/provider-project-worktree",
}),
);

harness.generateBranchName.mockImplementation((input: unknown) =>
Effect.succeed({
branch:
typeof input === "object" &&
input !== null &&
"modelSelection" in input &&
typeof input.modelSelection === "object" &&
input.modelSelection !== null &&
"model" in input.modelSelection &&
typeof input.modelSelection.model === "string"
? `feature/${input.modelSelection.model}`
: "feature/generated",
}),
);
harness.generateBranchName.mockImplementation(() =>
Effect.succeed({ branch: "feature/reconnect-backoff" }),
);

await Effect.runPromise(
harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.make("cmd-turn-start-branch-model"),
threadId: ThreadId.make("thread-1"),
message: {
messageId: asMessageId("user-message-branch-model"),
role: "user",
text: "Add a safer reconnect backoff.",
attachments: [],
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: now,
}),
);
await Effect.runPromise(
harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.make("cmd-turn-start-branch-model"),
threadId: ThreadId.make("thread-1"),
message: {
messageId: asMessageId("user-message-branch-model"),
role: "user",
text: "Add a safer reconnect backoff.",
attachments: [],
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: now,
}),
);

await waitFor(() => harness.generateBranchName.mock.calls.length === 1);
await waitFor(() => harness.refreshStatus.mock.calls.length === 1);
expect(harness.generateBranchName.mock.calls[0]?.[0]).toMatchObject({
message: "Add a safer reconnect backoff.",
});
expect(harness.refreshStatus.mock.calls[0]?.[0]).toBe("/tmp/provider-project-worktree");
});
await waitFor(() => harness.generateBranchName.mock.calls.length === 1);
await waitFor(() => harness.refreshStatus.mock.calls.length === 1);
expect(harness.generateBranchName.mock.calls[0]?.[0]).toMatchObject({
message: "Add a safer reconnect backoff.",
});
expect(harness.renameBranch.mock.calls[0]?.[0]).toMatchObject({
oldBranch: temporaryBranch,
newBranch: expectedBranch,
});
expect(harness.refreshStatus.mock.calls[0]?.[0]).toBe("/tmp/provider-project-worktree");
},
);

it("forwards codex model options through session start and turn send", async () => {
const harness = await createHarness();
Expand Down
40 changes: 11 additions & 29 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
type RuntimeMode,
type TurnId,
} from "@t3tools/contracts";
import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@t3tools/shared/git";
import { buildGeneratedWorktreeBranchName, isTemporaryWorktreeBranch } from "@t3tools/shared/git";
import * as Cache from "effect/Cache";
import * as Cause from "effect/Cause";
import * as Crypto from "effect/Crypto";
Expand Down Expand Up @@ -275,29 +275,6 @@ function stalePendingRequestDetail(
return `Stale pending ${requestKind} request: ${requestId}. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.`;
}

function buildGeneratedWorktreeBranchName(raw: string): string {
const normalized = raw
.trim()
.toLowerCase()
.replace(/^refs\/heads\//, "")
.replace(/['"`]/g, "");

const withoutPrefix = normalized.startsWith(`${WORKTREE_BRANCH_PREFIX}/`)
? normalized.slice(`${WORKTREE_BRANCH_PREFIX}/`.length)
: normalized;

const branchFragment = withoutPrefix
.replace(/[^a-z0-9/_-]+/g, "-")
.replace(/\/+/g, "/")
.replace(/-+/g, "-")
.replace(/^[./_-]+|[./_-]+$/g, "")
.slice(0, 64)
.replace(/[./_-]+$/g, "");

const safeFragment = branchFragment.length > 0 ? branchFragment : "update";
return `${WORKTREE_BRANCH_PREFIX}/${safeFragment}`;
}

const make = Effect.gen(function* () {
const crypto = yield* Crypto.Crypto;
const orchestrationEngine = yield* OrchestrationEngineService;
Expand Down Expand Up @@ -793,10 +770,6 @@ const make = Effect.gen(function* () {
if (!input.branch || !input.worktreePath) {
return;
}
if (!isTemporaryWorktreeBranch(input.branch)) {
return;
}

const oldBranch = input.branch;
const cwd = input.worktreePath;
const attachments = input.attachments ?? [];
Expand All @@ -809,6 +782,12 @@ const make = Effect.gen(function* () {
settings,
yield* providerRegistry.getProviders,
);
// Rename into the configured prefix even when the temporary branch was
// created under the legacy default (e.g. a client that had not loaded
// the setting yet), so a stale namespace heals on the first turn.
if (!isTemporaryWorktreeBranch(oldBranch, settings.worktreeBranchPrefix)) {
return;
}

const generated = yield* textGeneration.generateBranchName({
cwd,
Expand All @@ -818,7 +797,10 @@ const make = Effect.gen(function* () {
});
if (!generated) return;

const targetBranch = buildGeneratedWorktreeBranchName(generated.branch);
const targetBranch = buildGeneratedWorktreeBranchName(
generated.branch,
settings.worktreeBranchPrefix,
);
if (targetBranch === oldBranch) return;

const renamed = yield* gitWorkflow.renameBranch({ cwd, oldBranch, newBranch: targetBranch });
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5433,7 +5433,10 @@ function ChatViewContent(props: ChatViewProps) {
prepareWorktree: {
projectCwd: activeProject.workspaceRoot,
baseBranch: baseBranchForWorktree,
branch: buildTemporaryWorktreeBranchName(randomHex),
branch: buildTemporaryWorktreeBranchName(
randomHex,
settings.worktreeBranchPrefix,
),
...(startFromOrigin ? { startFromOrigin: true } : {}),
},
runSetupScript: true,
Expand Down
Loading
Loading