Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,7 @@ export function NewTaskDraftScreen(props: {
branch: creationBranch,
worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath,
startFromOrigin,
worktreeBranchPrefix: selectedEnvironmentServerConfig?.settings.worktreeBranchPrefix,
runtimeMode,
interactionMode,
initialMessageText,
Expand Down
5 changes: 2 additions & 3 deletions apps/mobile/src/features/threads/use-project-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,13 @@ import {
type ProviderInteractionMode,
type RuntimeMode,
} from "@t3tools/contracts";
import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
import * as Cause from "effect/Cause";
import { AsyncResult } from "effect/unstable/reactivity";

import { threadEnvironment } from "../../state/threads";
import type { DraftComposerImageAttachment } from "../../lib/composerImages";
import { makeTurnCommandMetadata, type TurnCommandMetadata } from "../../lib/commandMetadata";
import { buildProjectThreadStartTurnInput } from "../../lib/projectThreadStartTurn";
import { randomHex } from "../../lib/uuid";
import { useAtomCommand } from "../../state/use-atom-command";
import { setPendingConnectionError } from "../../state/use-remote-environment-registry";
import { validateProjectThreadCreation } from "./projectThreadCreationValidation";
Expand All @@ -33,6 +31,7 @@ export function useCreateProjectThread() {
readonly branch: string | null;
readonly worktreePath: string | null;
readonly startFromOrigin?: boolean;
readonly worktreeBranchPrefix?: string;
readonly runtimeMode: RuntimeMode;
readonly interactionMode: ProviderInteractionMode;
readonly initialMessageText: string;
Expand Down Expand Up @@ -74,7 +73,7 @@ export function useCreateProjectThread() {
branch: input.branch,
worktreePath: input.worktreePath,
startFromOrigin: input.startFromOrigin ?? false,
worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex),
worktreeBranchPrefix: input.worktreeBranchPrefix,
}),
});
if (AsyncResult.isFailure(result)) {
Expand Down
65 changes: 65 additions & 0 deletions apps/mobile/src/lib/projectThreadStartTurn.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, expect, it, vi } from "vite-plus/test";

import { ProjectId, ProviderInstanceId } from "@t3tools/contracts";

vi.mock("./uuid", () => ({
randomHex: () => "deadbeef",
}));

import {
buildProjectThreadStartTurnInput,
type ProjectThreadStartTurnSpec,
} from "./projectThreadStartTurn";

const makeSpec = (
overrides: Partial<ProjectThreadStartTurnSpec> = {},
): ProjectThreadStartTurnSpec => ({
projectId: ProjectId.make("project-1"),
projectCwd: "/workspace/project-1",
threadId: "thread-1",
commandId: "command-1",
messageId: "message-1",
createdAt: "2026-08-10T12:00:00.000Z",
text: "Build the mobile change",
attachments: [],
modelSelection: {
instanceId: ProviderInstanceId.make("codex"),
model: "gpt-5.4",
},
runtimeMode: "approval-required",
interactionMode: "default",
workspaceMode: "worktree",
branch: "main",
worktreePath: null,
startFromOrigin: false,
...overrides,
});

describe("buildProjectThreadStartTurnInput", () => {
it("uses the configured prefix for a worktree bootstrap branch", () => {
const input = buildProjectThreadStartTurnInput(
makeSpec({
worktreeBranchPrefix: "mobile-team",
}),
);

expect(input.bootstrap.prepareWorktree?.branch).toBe("mobile-team/deadbeef");
});

it("falls back to the default prefix when the server configuration is unavailable", () => {
const input = buildProjectThreadStartTurnInput(makeSpec());

expect(input.bootstrap.prepareWorktree?.branch).toBe("t3code/deadbeef");
});

it("omits worktree preparation for a local thread", () => {
const input = buildProjectThreadStartTurnInput(
makeSpec({
workspaceMode: "local",
worktreePath: "/workspace/project-1",
}),
);

expect(input.bootstrap.prepareWorktree).toBeUndefined();
});
});
8 changes: 5 additions & 3 deletions apps/mobile/src/lib/projectThreadStartTurn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import {
type ProviderInteractionMode,
type RuntimeMode,
} from "@t3tools/contracts";
import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";

import { toUploadChatImageAttachments, type DraftComposerImageAttachment } from "./composerImages";
import { randomHex } from "./uuid";

export function deriveThreadTitleFromPrompt(value: string): string {
const trimmed = value.trim();
Expand Down Expand Up @@ -36,8 +38,8 @@ export interface ProjectThreadStartTurnSpec {
readonly branch: string | null;
readonly worktreePath: string | null;
readonly startFromOrigin: boolean;
/** Generated temp branch for worktree mode; unused for local mode. */
readonly worktreeBranchName: string;
/** Target environment setting; defaults for unavailable older configurations. */
readonly worktreeBranchPrefix?: string;
}

/**
Expand Down Expand Up @@ -77,7 +79,7 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe
prepareWorktree: {
projectCwd: spec.projectCwd,
baseBranch: spec.branch!,
branch: spec.worktreeBranchName,
branch: buildTemporaryWorktreeBranchName(randomHex, spec.worktreeBranchPrefix),
...(spec.startFromOrigin ? { startFromOrigin: true } : {}),
},
runSetupScript: true,
Expand Down
18 changes: 13 additions & 5 deletions apps/mobile/src/state/use-thread-outbox-drain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,15 @@ import {
DEFAULT_RUNTIME_MODE,
type MessageId,
} from "@t3tools/contracts";
import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
import * as Cause from "effect/Cause";
import { AsyncResult, Atom } from "effect/unstable/reactivity";
import { useCallback, useEffect, useRef, useState } from "react";

import { scopedThreadKey } from "../lib/scopedEntities";
import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn";
import { toUploadChatImageAttachments } from "../lib/composerImages";
import { randomHex } from "../lib/uuid";
import { appAtomRegistry } from "./atom-registry";
import { useProjects, useThreadShells } from "./entities";
import { useProjects, useServerConfigs, useThreadShells } from "./entities";
import {
confirmThreadOutboxMessageQueued,
ensureThreadOutboxLoaded,
Expand Down Expand Up @@ -102,6 +100,7 @@ export function useThreadOutboxDrain(): void {
const shellStatuses = useThreadOutboxShellStatuses();
const threads = useThreadShells();
const projects = useProjects();
const serverConfigs = useServerConfigs();
const { connectedEnvironments } = useRemoteConnectionStatus();
const [retryTick, setRetryTick] = useState(0);
const retryAttemptRef = useRef(new Map<MessageId, number>());
Expand Down Expand Up @@ -250,6 +249,7 @@ export function useThreadOutboxDrain(): void {
queuedMessage: QueuedThreadMessage,
creation: QueuedThreadCreation,
projectCwd: string,
worktreeBranchPrefix: string | undefined,
) => {
const modelSelection = queuedMessage.modelSelection;
if (modelSelection === undefined) {
Expand All @@ -274,7 +274,7 @@ export function useThreadOutboxDrain(): void {
branch: creation.branch,
worktreePath: creation.worktreePath,
startFromOrigin: creation.startFromOrigin ?? false,
worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex),
worktreeBranchPrefix,
}),
});
return completeDelivery(deliveryResult);
Expand Down Expand Up @@ -305,6 +305,8 @@ export function useThreadOutboxDrain(): void {
}

const creation = nextQueuedMessage.creation;
const worktreeBranchPrefix = serverConfigs.get(nextQueuedMessage.environmentId)?.settings
.worktreeBranchPrefix;
const environment = connectedEnvironments.find(
(candidate) => candidate.environmentId === nextQueuedMessage.environmentId,
);
Expand Down Expand Up @@ -372,7 +374,12 @@ export function useThreadOutboxDrain(): void {
? removeQueuedMessage("[thread-outbox] failed to remove message for a missing thread")
: creation !== undefined
? creationProjectCwd !== null
? sendQueuedCreation(nextQueuedMessage, creation, creationProjectCwd)
? sendQueuedCreation(
nextQueuedMessage,
creation,
creationProjectCwd,
worktreeBranchPrefix,
)
: removeQueuedMessage("[thread-outbox] dropped pending task for a missing project")
: thread !== undefined
? sendQueuedMessage(nextQueuedMessage, thread)
Expand Down Expand Up @@ -417,6 +424,7 @@ export function useThreadOutboxDrain(): void {
projects,
queuedMessagesByThreadKey,
retryTick,
serverConfigs,
sendQueuedCreation,
sendQueuedMessage,
shellStatuses,
Expand Down
28 changes: 27 additions & 1 deletion apps/server/src/orchestration/Layers/CheckpointReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
} from "../../provider/Services/ProviderService.ts";
import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts";
import { ServerConfig } from "../../config.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts";
import * as WorkspacePaths from "../../workspace/WorkspacePaths.ts";

Expand Down Expand Up @@ -351,6 +352,7 @@ describe("CheckpointReactor", () => {
Layer.provideMerge(WorkspacePaths.layer),
Layer.provideMerge(VcsProcess.layer),
Layer.provideMerge(ServerConfigLayer),
Layer.provideMerge(ServerSettingsService.layerTest({ worktreeBranchPrefix: "team" })),
Layer.provideMerge(NodeServices.layer),
);

Expand Down Expand Up @@ -612,7 +614,7 @@ describe("CheckpointReactor", () => {
const harness = await createHarness({
seedFilesystemCheckpoints: false,
threadBranch: "t3code/original-branch",
localStatusRefName: "t3code/0a1b2c3d",
localStatusRefName: "t3code/12345678-1234-4abc-8def-1234567890ab",
});

harness.provider.emit({
Expand All @@ -632,6 +634,30 @@ describe("CheckpointReactor", () => {
expect(thread?.branch).toBe("t3code/original-branch");
});

it("does not adopt a configured temporary placeholder checkout as the thread branch", async () => {
const harness = await createHarness({
seedFilesystemCheckpoints: false,
threadBranch: "team/original-branch",
localStatusRefName: "team/0a1b2c3d",
});

harness.provider.emit({
type: "turn.completed",
eventId: EventId.make("evt-turn-completed-branch-drift-custom-temp"),
provider: ProviderDriverKind.make("codex"),
createdAt: "2026-01-01T00:00:00.000Z",
threadId: ThreadId.make("thread-1"),
turnId: asTurnId("turn-branch-drift-custom-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("team/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
20 changes: 18 additions & 2 deletions apps/server/src/orchestration/Layers/CheckpointReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import type { OrchestrationDispatchError } from "../Errors.ts";
import { isGitRepository } from "../../git/Utils.ts";
import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts";
import { ServerSettingsService } from "../../serverSettings.ts";

const nowIso = Effect.map(DateTime.now, DateTime.formatIso);

Expand Down Expand Up @@ -88,6 +89,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 @@ -571,7 +573,21 @@ const make = Effect.gen(function* () {
// 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)) {
if (checkedOutBranch === null) {
return;
}
const settings = yield* serverSettingsService.getSettings.pipe(
Effect.catch((error) =>
Effect.logWarning("failed to read settings while following worktree branch drift", {
threadId: input.threadId,
detail: error.message,
}).pipe(Effect.as(null)),
),
);
if (settings === null) {
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.

Settings failure skips branch drift

Medium Severity

A failed getSettings read now returns from followWorktreeBranchDrift before any checkout adoption. That path previously followed non-temporary branch changes without settings. Transient settings errors can leave thread branch metadata stale and orphan PR attribution, including when the checkout is a normal branch that does not need the prefix.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit cec345a. Configure here.

if (isTemporaryWorktreeBranch(checkedOutBranch, settings.worktreeBranchPrefix)) {
return;
}

Expand All @@ -585,7 +601,7 @@ const make = Effect.gen(function* () {
thread.branch === checkedOutBranch ||
thread.worktreePath === null ||
thread.worktreePath !== input.cwd ||
isTemporaryWorktreeBranch(thread.branch)
isTemporaryWorktreeBranch(thread.branch, settings.worktreeBranchPrefix)
) {
return;
}
Expand Down
Loading
Loading