diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 681f6c5ee52d..b68d62eb03c4 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -984,7 +984,6 @@ export function NewTaskDraftScreen(props: { }); const workspaceLabel = resolveNewTaskWorkspaceLabel({ workspaceMode: flow.workspaceMode, - worktreePath: flow.selectedWorktreePath, }); const showBranchLoading = flow.branchesLoading && flow.availableBranches.length === 0; @@ -1186,6 +1185,7 @@ export function NewTaskDraftScreen(props: { draft.modelSelection ?? null, ) ?? flow.selectedModel; const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode; + const executionScope = draft.executionScope ?? flow.executionScope; const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName; const initialMessageText = draft.text.trim(); @@ -1194,7 +1194,7 @@ export function NewTaskDraftScreen(props: { !modelSelection || initialMessageText.length === 0 || flow.submitting || - (workspaceMode === "worktree" && !selectedBranchName) + (executionScope === "coding" && workspaceMode === "worktree" && !selectedBranchName) ) { return; } @@ -1299,6 +1299,7 @@ export function NewTaskDraftScreen(props: { // until the write confirms it. clearComposerDraftContent(draftKey, { clearModelSelection: true, + clearExecutionScope: true, clearWorkspaceSelection: true, deferAttachmentCleanup: true, }); @@ -1347,7 +1348,11 @@ export function NewTaskDraftScreen(props: { !flow.submitting && pendingPastedTextAttachmentCount === 0 && !voiceInput.blocksSubmission && - !(flow.workspaceMode === "worktree" && !flow.selectedBranchName); + !( + flow.executionScope === "coding" && + flow.workspaceMode === "worktree" && + !flow.selectedBranchName + ); const openDraftDocument = (attachment: ComposerDocumentAttachment) => { // A draft attachment lives only in the draft. Without its key the screen would fall through // to a remote lookup for bytes the server has never seen. @@ -1514,30 +1519,56 @@ export function NewTaskDraftScreen(props: { const workspaceControls = ( + icon={flow.executionScope === "coordinator" ? "text.bubble" : "hammer"} + label={flow.executionScope === "coordinator" ? "Coordinator" : "Coding"} + maxWidth={136} + onPress={() => + flow.setExecutionScope(flow.executionScope === "coordinator" ? "coding" : "coordinator") } - label={workspaceLabel} - maxWidth={flow.workspaceMode === "local" ? 220 : 148} - onPress={() => flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")} showChevron={false} /> + {flow.executionScope === "coding" ? ( + <> + + } + label={workspaceLabel} + maxWidth={flow.workspaceMode === "local" ? 220 : 148} + onPress={() => + flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local") + } + showChevron={false} + /> - openContextPicker("NewTaskBranch")} - /> + openContextPicker("NewTaskBranch")} + /> + + ) : null} ); diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index e5bfc1590cf0..74c1ffdcd78e 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -5,6 +5,7 @@ import { clearComposerDraftContent, } from "../../state/use-composer-drafts"; import { useWorktreeSetup } from "./use-worktree-setup"; +import { newThreadCreationPreparesWorktree } from "./new-task-context-presentation"; import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; import { ScreenHeader } from "../../components/ScreenHeader"; import { ScreenHeaderButton } from "../../components/ScreenHeaderButton"; @@ -817,7 +818,8 @@ function ThreadRouteContent( threadId: selectedThread?.id ?? null, activities: selectedThreadDetail?.activities ?? [], preparing: - selectedThreadCreation?.message.creation?.workspaceMode === "worktree" && + selectedThreadCreation !== null && + newThreadCreationPreparesWorktree(selectedThreadCreation.message.creation) && selectedThreadCreation.outcome == null, turnStarted: selectedThreadDetail?.latestTurn?.startedAt != null, followUpSent: @@ -901,7 +903,7 @@ function ThreadRouteContent( }) .catch((error) => Alert.alert( - "Could not work locally", + "Could not retry coding worktree", error instanceof Error ? error.message : String(error), ), ); @@ -919,7 +921,7 @@ function ThreadRouteContent( } return { kind: "preparing", - preparingWorktree: selectedThreadCreation.message.creation?.workspaceMode === "worktree", + preparingWorktree: newThreadCreationPreparesWorktree(selectedThreadCreation.message.creation), }; })(); if (!environmentId || !threadId) { diff --git a/apps/mobile/src/features/threads/new-task-context-presentation.test.ts b/apps/mobile/src/features/threads/new-task-context-presentation.test.ts index 3c81d8231216..2051c6e4c24e 100644 --- a/apps/mobile/src/features/threads/new-task-context-presentation.test.ts +++ b/apps/mobile/src/features/threads/new-task-context-presentation.test.ts @@ -1,11 +1,31 @@ import { describe, expect, it } from "vite-plus/test"; import { + newThreadCreationPreparesWorktree, resolveNewTaskBranchWorktreePath, resolveNewTaskBranchLabel, resolveNewTaskLocalWorkspaceSelection, } from "./new-task-context-presentation"; +describe("newThreadCreationPreparesWorktree", () => { + it("reports worktree preparation for local-mode coding and legacy coding creations", () => { + expect( + newThreadCreationPreparesWorktree({ executionScope: "coding", workspaceMode: "local" }), + ).toBe(true); + expect(newThreadCreationPreparesWorktree({ workspaceMode: "local" })).toBe(true); + }); + + it("does not report repository setup for Coordinator creation", () => { + expect( + newThreadCreationPreparesWorktree({ + executionScope: "coordinator", + workspaceMode: "worktree", + }), + ).toBe(false); + expect(newThreadCreationPreparesWorktree(null)).toBe(false); + }); +}); + describe("resolveNewTaskLocalWorkspaceSelection", () => { it("waits for refs instead of carrying a worktree base into Current checkout", () => { expect( diff --git a/apps/mobile/src/features/threads/new-task-context-presentation.ts b/apps/mobile/src/features/threads/new-task-context-presentation.ts index 99eee3ea48ae..eadb9942e1fe 100644 --- a/apps/mobile/src/features/threads/new-task-context-presentation.ts +++ b/apps/mobile/src/features/threads/new-task-context-presentation.ts @@ -1,13 +1,25 @@ type WorkspaceMode = "local" | "worktree"; +/** Every newly created coding thread receives a server-owned worktree. */ +export function newThreadCreationPreparesWorktree( + input: + | { + readonly executionScope?: "coding" | "coordinator" | null; + readonly workspaceMode?: WorkspaceMode; + } + | null + | undefined, +): boolean { + return input !== null && input !== undefined && input.executionScope !== "coordinator"; +} + export function resolveNewTaskWorkspaceLabel(input: { readonly workspaceMode: WorkspaceMode; - readonly worktreePath: string | null; -}): "Current checkout" | "Current worktree" | "New worktree" { +}): "Coding worktree" | "New worktree" { if (input.workspaceMode === "worktree") { return "New worktree"; } - return input.worktreePath ? "Current worktree" : "Current checkout"; + return "Coding worktree"; } export function resolveNewTaskBranchWorktreePath(input: { diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index f2801255b3bc..ebc8602688ea 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -141,6 +141,7 @@ type NewTaskFlowContextValue = { readonly selectedProjectKey: string | null; readonly selectedModelKey: string | null; readonly workspaceMode: WorkspaceMode; + readonly executionScope: "coding" | "coordinator"; readonly selectedBranchName: string | null; readonly selectedWorktreePath: string | null; readonly startFromOrigin: boolean; @@ -185,6 +186,7 @@ type NewTaskFlowContextValue = { options?: ReadonlyArray, ) => void; readonly setWorkspaceMode: (mode: WorkspaceMode) => void; + readonly setExecutionScope: (scope: "coding" | "coordinator") => void; readonly selectBranch: (branch: VcsRef) => void; readonly setStartFromOrigin: (value: boolean) => void; readonly beginEditingPendingTask: (messageId: string) => boolean; @@ -457,6 +459,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { projectSettings.sources.defaultThreadEnvMode !== "environment" || !t3ProjectFileQuery.isPending; const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode; + const executionScope = selectedProjectDraft.executionScope ?? "coding"; const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; // Keep the user's explicit choice separate from the resolved display value: @@ -781,6 +784,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ], ); + const setExecutionScope = useCallback( + (scope: "coding" | "coordinator") => { + if (!selectedProjectDraftKey) return; + updateComposerDraftSettings(selectedProjectDraftKey, { executionScope: scope }); + }, + [selectedProjectDraftKey], + ); + useEffect(() => { if ( workspaceMode !== "local" || @@ -933,6 +944,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { modelSelection: message.modelSelection, runtimeMode: message.runtimeMode, interactionMode: message.interactionMode, + executionScope: message.creation.executionScope ?? "coding", workspaceSelection: { mode: message.creation.workspaceMode, branch: message.creation.branch, @@ -976,6 +988,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { // Fall back to the resolved mode (server default) so queued tasks drain // with the same mode the composer displayed. const mode = workspaceSelection?.mode ?? workspaceMode; + const scope = draft.executionScope ?? executionScope; // When the selection is the stand-in built from the queued snapshot, // persist the original (possibly absent) snapshot values — the // stand-in's placeholder title/workspaceRoot must never be written back @@ -1011,16 +1024,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ...(projectTitle !== undefined ? { projectTitle } : {}), ...(projectCwd !== undefined ? { projectCwd } : {}), workspaceMode: mode, + executionScope: scope, // An explicit picker choice wins. Otherwise only a task sending now // records the current checkout: a queued local task drains days // later against whatever is checked out then, so a queue-time // guess would pin a stale label to a thread that ran somewhere else. - branch: resolveProjectThreadCreationBranch({ - workspaceMode: mode, - selectedBranch: workspaceSelection?.branch ?? null, - currentCheckoutBranch: options?.currentCheckoutBranch ?? null, - }), - worktreePath: mode === "worktree" ? null : (workspaceSelection?.worktreePath ?? null), + branch: + scope === "coordinator" + ? null + : resolveProjectThreadCreationBranch({ + workspaceMode: mode, + selectedBranch: workspaceSelection?.branch ?? null, + currentCheckoutBranch: options?.currentCheckoutBranch ?? null, + }), + worktreePath: null, // The draft only carries the flag when the user touched it; fall // back to the resolved default (server settings) so queued tasks // drain with the same origin mode the composer displayed. @@ -1042,6 +1059,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { legacyPlanModeEnabled, planModePreferenceLoaded, startFromOrigin, + executionScope, workspaceMode, ], ); @@ -1155,6 +1173,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedProjectKey, selectedModelKey, workspaceMode, + executionScope, selectedBranchName, selectedWorktreePath, startFromOrigin, @@ -1188,6 +1207,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectEnvironment, setSelectedModelKey, setWorkspaceMode, + setExecutionScope, selectBranch, setStartFromOrigin, beginEditingPendingTask, @@ -1220,6 +1240,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { cancelEditingPendingTask, currentCheckoutBranchName, editingPendingTask, + executionScope, environments, expandedProvider, filteredBranches, @@ -1257,6 +1278,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { setSelectedModelKey, setStartFromOrigin, setWorkspaceMode, + setExecutionScope, startFromOrigin, submitting, workspaceMode, diff --git a/apps/mobile/src/features/threads/worktree-setup-card.tsx b/apps/mobile/src/features/threads/worktree-setup-card.tsx index 86b71992710f..7524df9169c4 100644 --- a/apps/mobile/src/features/threads/worktree-setup-card.tsx +++ b/apps/mobile/src/features/threads/worktree-setup-card.tsx @@ -259,7 +259,7 @@ function SetupDetailsSheet({ }} className="min-h-11 justify-center px-2" > - Work locally + Retry as coding worktree ) : null} diff --git a/apps/mobile/src/lib/composerContext.test.ts b/apps/mobile/src/lib/composerContext.test.ts index d28b58d055b0..50ed3fb4af8b 100644 --- a/apps/mobile/src/lib/composerContext.test.ts +++ b/apps/mobile/src/lib/composerContext.test.ts @@ -247,6 +247,7 @@ describe("host context compatibility", () => { modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, runtimeMode: "full-access", interactionMode: "default", + executionScope: "coding", workspaceMode: "local", branch: null, worktreePath: null, diff --git a/apps/mobile/src/lib/projectThreadStartTurn.test.ts b/apps/mobile/src/lib/projectThreadStartTurn.test.ts index 57df389cff3e..4c71bc9564d6 100644 --- a/apps/mobile/src/lib/projectThreadStartTurn.test.ts +++ b/apps/mobile/src/lib/projectThreadStartTurn.test.ts @@ -54,6 +54,7 @@ describe("project thread title", () => { modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, runtimeMode: "full-access", interactionMode: "default", + executionScope: "coding", workspaceMode: "local", branch: null, worktreePath: null, @@ -67,9 +68,9 @@ describe("project thread title", () => { }); }); -describe("new thread on an existing branch", () => { +describe("coding thread bootstrap", () => { it.each([null, "/worktrees/existing"])( - "reuses the selected workspace %s without preparing a new worktree", + "requests a server-assigned worktree when the selected workspace is %s", (worktreePath) => { const input = buildProjectThreadStartTurnInput({ projectId: ProjectId.make("project"), @@ -83,6 +84,7 @@ describe("new thread on an existing branch", () => { modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, runtimeMode: "full-access", interactionMode: "default", + executionScope: "coding", workspaceMode: "local", branch: "feature/existing", worktreePath, @@ -93,11 +95,42 @@ describe("new thread on an existing branch", () => { expect(input.bootstrap.createThread).toMatchObject({ projectId: "project", branch: "feature/existing", - worktreePath, + executionScope: "coding", + worktreePath: null, }); expect(input.bootstrap).not.toHaveProperty("prepareWorktree"); expect(input.bootstrap).not.toHaveProperty("runSetupScript"); expect(input.threadId).toBe("new-thread"); }, ); + + it("starts an explicitly selected Coordinator in its server-owned scratch workspace", () => { + const input = buildProjectThreadStartTurnInput({ + projectId: ProjectId.make("project"), + projectCwd: "/workspace", + threadId: "coordinator-thread", + commandId: "command", + messageId: "message", + createdAt: "2026-09-06T00:00:00Z", + text: "Plan the migration", + uploadedAttachments: [], + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, + runtimeMode: "full-access", + interactionMode: "default", + executionScope: "coordinator", + workspaceMode: "worktree", + branch: "feature/existing", + worktreePath: "/repo/.t3/worktrees/existing", + startFromOrigin: true, + worktreeBranchName: "unused", + }); + + expect(input.bootstrap.createThread).toMatchObject({ + executionScope: "coordinator", + branch: null, + worktreePath: null, + }); + expect(input.bootstrap).not.toHaveProperty("prepareWorktree"); + expect(input.bootstrap).not.toHaveProperty("runSetupScript"); + }); }); diff --git a/apps/mobile/src/lib/projectThreadStartTurn.ts b/apps/mobile/src/lib/projectThreadStartTurn.ts index f895888e1486..b843475dc52f 100644 --- a/apps/mobile/src/lib/projectThreadStartTurn.ts +++ b/apps/mobile/src/lib/projectThreadStartTurn.ts @@ -36,6 +36,7 @@ export interface ProjectThreadStartTurnSpec { readonly modelSelection: ModelSelection; readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; + readonly executionScope: "coding" | "coordinator"; readonly workspaceMode: "local" | "worktree"; readonly branch: string | null; readonly worktreePath: string | null; @@ -51,7 +52,8 @@ export interface ProjectThreadStartTurnSpec { */ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpec) { const title = deriveThreadTitleFromPrompt(spec.text); - const isWorktree = spec.workspaceMode === "worktree"; + const isCoordinator = spec.executionScope === "coordinator"; + const isWorktree = !isCoordinator && spec.workspaceMode === "worktree"; return { commandId: CommandId.make(spec.commandId), threadId: ThreadId.make(spec.threadId), @@ -73,8 +75,11 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe modelSelection: spec.modelSelection, runtimeMode: spec.runtimeMode, interactionMode: spec.interactionMode, - branch: spec.branch, - worktreePath: isWorktree ? null : spec.worktreePath, + executionScope: spec.executionScope, + branch: isCoordinator ? null : spec.branch, + // Coding creates always ask the server for a destination-owned worktree. + // Coordinator gets its dedicated scratch directory from the server. + worktreePath: null, createdAt: spec.createdAt, }, ...(isWorktree diff --git a/apps/mobile/src/state/pending-thread-creation.test.ts b/apps/mobile/src/state/pending-thread-creation.test.ts index f397200d33b6..20f11a46f340 100644 --- a/apps/mobile/src/state/pending-thread-creation.test.ts +++ b/apps/mobile/src/state/pending-thread-creation.test.ts @@ -169,13 +169,14 @@ describe("pendingThreadCreationShell", () => { interactionMode: "default", branch: "main", worktreePath: null, + executionScope: "coding", latestTurn: null, session: null, latestUserMessageAt: creation.createdAt, }); }); - it("keeps a local task's explicit worktree path", () => { + it("does not expose a shared path before a dedicated coding worktree is assigned", () => { expect( pendingThreadCreationShell({ ...creation, @@ -185,7 +186,7 @@ describe("pendingThreadCreationShell", () => { worktreePath: "/repo/.worktrees/feature", }, })?.worktreePath, - ).toBe("/repo/.worktrees/feature"); + ).toBeNull(); }); it("returns null for a follow-up message or a creation without a model", () => { diff --git a/apps/mobile/src/state/pending-thread-creation.ts b/apps/mobile/src/state/pending-thread-creation.ts index 4a2d7839e13e..9063c5f8c598 100644 --- a/apps/mobile/src/state/pending-thread-creation.ts +++ b/apps/mobile/src/state/pending-thread-creation.ts @@ -146,7 +146,8 @@ export function pendingThreadCreationShell( interactionMode: message.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE, branch: creation.branch, pullRequests: [], - worktreePath: creation.workspaceMode === "worktree" ? null : creation.worktreePath, + worktreePath: null, + executionScope: creation.executionScope ?? "coding", linkedPullRequest: null, latestTurn: null, createdAt: message.createdAt, diff --git a/apps/mobile/src/state/recover-failed-thread-draft.ts b/apps/mobile/src/state/recover-failed-thread-draft.ts index ac4aa4d9e187..20f27a80fbae 100644 --- a/apps/mobile/src/state/recover-failed-thread-draft.ts +++ b/apps/mobile/src/state/recover-failed-thread-draft.ts @@ -7,6 +7,7 @@ import { flushComposerDrafts, getComposerDraftSnapshot, mergeComposerDraftContent, + updateComposerDraftSettings, } from "./use-composer-drafts"; /** Move unsent setup edits into the restored task before reopening its editor. */ @@ -14,6 +15,11 @@ export async function recoverFailedThreadDraft(message: QueuedThreadMessage): Pr const sourceKey = scopedThreadKey(message.environmentId, message.threadId); const targetKey = restoredNewTaskDraftKey(message.messageId); const source = getComposerDraftSnapshot(sourceKey); + if (message.creation) { + updateComposerDraftSettings(targetKey, { + executionScope: message.creation.executionScope ?? "coding", + }); + } if (source.text.length === 0 && source.attachments.length === 0) return; await mergeComposerDraftContent(targetKey, { diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 3d2231fb3ac3..b1e9005c54c8 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -39,6 +39,7 @@ const QueuedThreadCreationSchema = Schema.Struct({ projectTitle: Schema.optional(Schema.String), projectCwd: Schema.optional(Schema.String), workspaceMode: Schema.Literals(["local", "worktree"]), + executionScope: Schema.optional(Schema.Literals(["coding", "coordinator"])), branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), startFromOrigin: Schema.optional(Schema.Boolean), @@ -70,6 +71,7 @@ export interface QueuedThreadCreation { readonly projectTitle?: string; readonly projectCwd?: string; readonly workspaceMode: "local" | "worktree"; + readonly executionScope?: "coding" | "coordinator"; readonly branch: string | null; readonly worktreePath: string | null; readonly startFromOrigin?: boolean; @@ -241,7 +243,11 @@ export function isQueuedThreadCreationSendable(message: QueuedThreadMessage): bo if (message.text.trim().length === 0 || message.modelSelection === undefined) { return false; } - return message.creation.workspaceMode !== "worktree" || Boolean(message.creation.branch); + return ( + message.creation.executionScope === "coordinator" || + message.creation.workspaceMode !== "worktree" || + Boolean(message.creation.branch) + ); } function errorMessage(error: unknown): string | null { diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index 5a8f2d90f606..9655ea9d5e19 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -1395,6 +1395,7 @@ describe("thread outbox", () => { creation: { projectId: ProjectId.make("project-1"), workspaceMode: "worktree", + executionScope: "coordinator", branch: "main", worktreePath: null, startFromOrigin: true, @@ -1410,11 +1411,21 @@ describe("thread outbox", () => { ...creationMessage, creation: { ...creationMessage.creation, branch: null }, }), + ).toBe(true); + expect( + isQueuedThreadCreationSendable({ + ...creationMessage, + creation: { ...creationMessage.creation, executionScope: "coding", branch: null }, + }), ).toBe(false); expect( isQueuedThreadCreationSendable({ ...creationMessage, - creation: { ...creationMessage.creation, branch: "" }, + creation: { + ...creationMessage.creation, + executionScope: "coding", + branch: "", + }, }), ).toBe(false); expect(isQueuedThreadCreationSendable({ ...creationMessage, modelSelection: undefined })).toBe( diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 734199dcef5e..ce39307d5f06 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -326,6 +326,7 @@ export interface ComposerDraft { readonly modelSelection?: ModelSelection; readonly runtimeMode?: RuntimeMode; readonly interactionMode?: ProviderInteractionMode; + readonly executionScope?: "coding" | "coordinator"; readonly workspaceSelection?: ComposerDraftWorkspaceSelection; /** * Set on new-task drafts only. The project is stored here rather than in @@ -357,7 +358,12 @@ export interface ComposerDraftWorkspaceSelection { export type ComposerDraftSettingsUpdate = Pick< ComposerDraft, - "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection" | "project" + | "modelSelection" + | "runtimeMode" + | "interactionMode" + | "executionScope" + | "workspaceSelection" + | "project" >; const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({ @@ -388,6 +394,7 @@ const ComposerDraftSchema = Schema.Struct({ modelSelection: Schema.optional(ModelSelectionSchema), runtimeMode: Schema.optional(RuntimeModeSchema), interactionMode: Schema.optional(ProviderInteractionModeSchema), + executionScope: Schema.optional(Schema.Literals(["coding", "coordinator"])), workspaceSelection: Schema.optional(ComposerDraftWorkspaceSelectionSchema), project: Schema.optional(ComposerDraftProjectSchema), }); @@ -543,6 +550,7 @@ function isEmptyDraft(draft: ComposerDraft): boolean { draft.modelSelection === undefined && draft.runtimeMode === undefined && draft.interactionMode === undefined && + draft.executionScope === undefined && draft.workspaceSelection === undefined ); } @@ -619,7 +627,7 @@ export function decodePersistedComposerState(value: unknown): { // model-precedence fix carry a bare modelSelection with no // other selector settings. Strip it so the next compose pass // re-resolves project → sticky → provider defaults. Drafts - // with runtime/interaction/workspace settings or actual text / + // with runtime/interaction/scope/workspace settings or actual text / // attachments were deliberately configured and are left alone. isNewTaskDraftKey(key) && draft.modelSelection && @@ -627,6 +635,7 @@ export function decodePersistedComposerState(value: unknown): { draft.attachments.length === 0 && draft.runtimeMode === undefined && draft.interactionMode === undefined && + draft.executionScope === undefined && draft.workspaceSelection === undefined ? { ...draft, modelSelection: undefined } : draft, @@ -1148,6 +1157,8 @@ export async function removeDeliveredCloudQueuedMessage( (editor.runtimeMode !== undefined && editor.runtimeMode !== message.runtimeMode) || (editor.interactionMode !== undefined && editor.interactionMode !== message.interactionMode) || + (editor.executionScope !== undefined && + editor.executionScope !== message.creation?.executionScope) || (editor.workspaceSelection !== undefined && (editor.workspaceSelection.mode !== message.creation?.workspaceMode || editor.workspaceSelection.branch !== message.creation?.branch || @@ -1471,6 +1482,7 @@ export function clearComposerDraftContentState( draftKey: string, options?: { readonly clearModelSelection?: boolean; + readonly clearExecutionScope?: boolean; readonly clearWorkspaceSelection?: boolean; }, ): Record { @@ -1485,6 +1497,7 @@ export function clearComposerDraftContentState( importedShareIds: _importedShareIds, context: _context, modelSelection, + executionScope, workspaceSelection, project: _project, ...retained @@ -1492,6 +1505,7 @@ export function clearComposerDraftContentState( const draft = { ...retained, ...(options?.clearModelSelection || modelSelection === undefined ? {} : { modelSelection }), + ...(options?.clearExecutionScope || executionScope === undefined ? {} : { executionScope }), ...(options?.clearWorkspaceSelection || workspaceSelection === undefined ? {} : { workspaceSelection }), @@ -1675,6 +1689,7 @@ export function sameComposerDraftState(a: ComposerDraft, b: ComposerDraft): bool a.modelSelection === b.modelSelection && a.runtimeMode === b.runtimeMode && a.interactionMode === b.interactionMode && + a.executionScope === b.executionScope && a.workspaceSelection === b.workspaceSelection ); } @@ -1708,7 +1723,12 @@ export function undoComposerDraftMergeState( // A setting still holding the merge's value is the merge's doing: restore // the snapshot's. One the user changed since the merge stays theirs. const undoSetting = < - K extends "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection", + K extends + | "modelSelection" + | "runtimeMode" + | "interactionMode" + | "executionScope" + | "workspaceSelection", >( key: K, ): ComposerDraft[K] => (existing[key] === merged[key] ? snapshot[key] : existing[key]); @@ -1728,6 +1748,7 @@ export function undoComposerDraftMergeState( modelSelection: undoSetting("modelSelection"), runtimeMode: undoSetting("runtimeMode"), interactionMode: undoSetting("interactionMode"), + executionScope: undoSetting("executionScope"), workspaceSelection: undoSetting("workspaceSelection"), }; return withComposerDraft(current, draftKey, draft); @@ -1763,6 +1784,7 @@ export function clearComposerDraftContent( draftKey: string, options?: { readonly clearModelSelection?: boolean; + readonly clearExecutionScope?: boolean; readonly clearWorkspaceSelection?: boolean; // Send clears the draft while the durable outbox write is still in // flight. Sweeping then would race the write: a failed enqueue rolls the diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 1147e9fd47af..8a3a8513eb8d 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -413,6 +413,7 @@ export async function restoreRejectedQueuedMessage( ...(queuedMessage.interactionMode ? { interactionMode: queuedMessage.interactionMode } : {}), ...(queuedMessage.creation ? { + executionScope: queuedMessage.creation.executionScope ?? "coding", workspaceSelection: { mode: queuedMessage.creation.workspaceMode, branch: queuedMessage.creation.branch, @@ -949,6 +950,7 @@ export function useThreadOutboxDrain(): void { modelSelection: sendSettings.modelSelection, runtimeMode: sendSettings.runtimeMode, interactionMode: sendSettings.interactionMode, + executionScope: creation.executionScope ?? "coding", workspaceMode: creation.workspaceMode, branch: creation.branch, worktreePath: creation.worktreePath, diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index d1cce1bd3d01..751c71cabebc 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -5,6 +5,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { ApprovalRequestId, CodexSettings, + EnvironmentId, ProviderDriverKind, type OrchestrationEvent, type OrchestrationThread, @@ -89,6 +90,7 @@ import { GitWorkflowService } from "../src/git/GitWorkflowService.ts"; import * as VcsProcess from "../src/vcs/VcsProcess.ts"; import * as AgentAwarenessRelay from "../src/relay/AgentAwarenessRelay.ts"; import * as PullRequestService from "../src/pullRequest/PullRequestService.ts"; +import * as ServerEnvironment from "../src/environment/ServerEnvironment.ts"; const decodeCodexSettings = Schema.decodeEffect(CodexSettings); @@ -268,10 +270,17 @@ export const makeOrchestrationIntegrationHarness = ( yield* initializeGitWorkspace(workspaceDir); const persistenceLayer = makeSqlitePersistenceLive(dbPath); + const serverEnvironmentIdentityLayer = Layer.succeed( + ServerEnvironment.ServerEnvironmentIdentity, + { + getEnvironmentId: Effect.succeed(EnvironmentId.make("integration-test-environment")), + }, + ); const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(serverEnvironmentIdentityLayer), ); const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(ProviderSessionRuntime.layer), @@ -287,6 +296,7 @@ export const makeOrchestrationIntegrationHarness = ( }), ).pipe( Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), + Layer.provideMerge(serverEnvironmentIdentityLayer), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(providerSessionDirectoryLayer), ); @@ -343,6 +353,7 @@ export const makeOrchestrationIntegrationHarness = ( }), ), Layer.provideMerge(runtimeServicesLayer), + Layer.provide(serverEnvironmentIdentityLayer), Layer.provideMerge(gitWorkflowLayer), Layer.provideMerge(textGenerationLayer), Layer.provideMerge(serverSettingsLayer), @@ -357,12 +368,12 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge( Layer.succeed(VcsStatusBroadcaster, { getStatus: () => Effect.die("getStatus should not be called in this test"), - refreshLocalStatus: () => + refreshLocalStatus: (cwd) => Effect.succeed({ isRepo: true, hasPrimaryRemote: false, isDefaultRef: true, - refName: "main", + refName: runGit(cwd, ["symbolic-ref", "--quiet", "--short", "HEAD"]).trim(), hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, }), @@ -432,6 +443,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(RepositoryIdentityResolver.layer), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), + Layer.provideMerge(serverEnvironmentIdentityLayer), Layer.provideMerge(NodeServices.layer), Layer.provideMerge( options?.tracer ? Layer.succeed(Tracer.Tracer, options.tracer) : Layer.empty, diff --git a/apps/server/integration/TestProviderAdapter.integration.ts b/apps/server/integration/TestProviderAdapter.integration.ts index 095cca4e5e74..29bb12e70664 100644 --- a/apps/server/integration/TestProviderAdapter.integration.ts +++ b/apps/server/integration/TestProviderAdapter.integration.ts @@ -186,6 +186,10 @@ export interface TestProviderAdapterHarness { readonly queueTurnResponseForNextSession: ( response: TestTurnResponse, ) => Effect.Effect; + readonly queueTurnResponseForThreadStart: ( + threadId: ThreadId, + response: TestTurnResponse, + ) => Effect.Effect; readonly getStartCount: () => number; readonly getRollbackCalls: (threadId: ThreadId) => ReadonlyArray; readonly getInterruptCalls: (threadId: ThreadId) => ReadonlyArray; @@ -230,6 +234,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter let eventCount = 0; const sessions = new Map(); const queuedResponsesForNextSession: TestTurnResponse[] = []; + const queuedResponsesByStartingThread = new Map(); const interruptCallsBySession = new Map>(); const approvalResponsesBySession = new Map< ThreadId, @@ -281,7 +286,10 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter turns: [], }, turnCount: 0, - queuedResponses: queuedResponsesForNextSession.splice(0), + queuedResponses: [ + ...queuedResponsesForNextSession.splice(0), + ...(queuedResponsesByStartingThread.get(threadId)?.splice(0) ?? []), + ], rollbackCalls: [], }); @@ -519,6 +527,16 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter queuedResponsesForNextSession.push(response); }); + const queueTurnResponseForThreadStart = ( + threadId: ThreadId, + response: TestTurnResponse, + ): Effect.Effect => + Effect.sync(() => { + const responses = queuedResponsesByStartingThread.get(threadId) ?? []; + responses.push(response); + queuedResponsesByStartingThread.set(threadId, responses); + }); + const getRollbackCalls = (threadId: ThreadId): ReadonlyArray => { const state = sessions.get(threadId); if (!state) { @@ -559,6 +577,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter provider, queueTurnResponse, queueTurnResponseForNextSession, + queueTurnResponseForThreadStart, getStartCount, getRollbackCalls, getInterruptCalls, diff --git a/apps/server/integration/TransferBudgetScenario.integration.ts b/apps/server/integration/TransferBudgetScenario.integration.ts index ccede4204f66..4c50d6029560 100644 --- a/apps/server/integration/TransferBudgetScenario.integration.ts +++ b/apps/server/integration/TransferBudgetScenario.integration.ts @@ -58,6 +58,7 @@ const waitForTurnQuiesced = Effect.fn("TransferBudget.waitForTurnQuiesced")(func return receipt; }); +// Seed the replay-only historical thread without claiming the harness root as an owned worktree. export const seedTransferBudgetHistory = Effect.fn("TransferBudget.seedHistory")(function* ( harness: OrchestrationIntegrationHarness, provider: ProviderDriverKind, @@ -87,6 +88,7 @@ export const seedTransferBudgetHistory = Effect.fn("TransferBudget.seedHistory") interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, branch: "main", worktreePath: harness.workspaceDir, + historyImport: true, createdAt: turnTimestamp(0), }); diff --git a/apps/server/integration/envchkP1WireAcceptance.integration.test.ts b/apps/server/integration/envchkP1WireAcceptance.integration.test.ts index fc193e57d1da..02be9407449a 100644 --- a/apps/server/integration/envchkP1WireAcceptance.integration.test.ts +++ b/apps/server/integration/envchkP1WireAcceptance.integration.test.ts @@ -9,6 +9,7 @@ import { WsRpcGroup, type OrchestrationThreadStreamItem, } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeSocket from "@effect/platform-node/NodeSocket"; import { assert, it } from "@effect/vitest"; @@ -52,6 +53,7 @@ const findFreePort = (): Promise => interface Fixture { readonly baseDir: string; + readonly workspaceRoot: string; readonly root: string; readonly fakeDir: string; readonly argvLogPath: string; @@ -66,6 +68,9 @@ const makeFixture = (): Effect.Effect => const baseDir = yield* Effect.promise(() => fs.mkdtemp(NodePath.join(NodeOS.tmpdir(), "envchk-p1-")), ); + const workspaceRoot = yield* Effect.promise(() => + fs.mkdtemp(NodePath.join(NodeOS.tmpdir(), "envchk-p1-repo-")), + ); const root = yield* Effect.promise(() => fs.mkdtemp(NodePath.join(NodeOS.tmpdir(), "envchk-p1-root-")), ); @@ -92,15 +97,36 @@ const makeFixture = (): Effect.Effect => ', String(process.pid) + "\\n");\n' + execScriptSource({ scriptPath: mockAgentPath, argvLogPath }), }); - yield* Effect.promise( - () => - new Promise((resolve, reject) => { - NodeChildProcess.execFile("git", ["init", "-q"], { cwd: root }, (error) => - error ? reject(error) : resolve(), - ); - }), - ); - return { baseDir, root, fakeDir, argvLogPath, pidLogPath, wrapperPath }; + yield* Effect.promise(async () => { + NodeChildProcess.execFileSync("git", ["init", "-q", "--initial-branch=main"], { + cwd: workspaceRoot, + stdio: "ignore", + }); + NodeChildProcess.execFileSync("git", ["config", "user.email", "test@example.com"], { + cwd: workspaceRoot, + stdio: "ignore", + }); + NodeChildProcess.execFileSync("git", ["config", "user.name", "Test User"], { + cwd: workspaceRoot, + stdio: "ignore", + }); + await fs.writeFile(NodePath.join(workspaceRoot, "README.md"), "committed base\n"); + NodeChildProcess.execFileSync("git", ["add", "README.md"], { + cwd: workspaceRoot, + stdio: "ignore", + }); + NodeChildProcess.execFileSync("git", ["commit", "-m", "Initial"], { + cwd: workspaceRoot, + stdio: "ignore", + }); + await fs.rm(root, { recursive: true, force: true }); + NodeChildProcess.execFileSync( + "git", + ["worktree", "add", "-b", "t3/envchk-p1", root, "main"], + { cwd: workspaceRoot, stdio: "ignore" }, + ); + }); + return { baseDir, workspaceRoot, root, fakeDir, argvLogPath, pidLogPath, wrapperPath }; }).pipe(Effect.orDie); interface SpawnedServer { @@ -221,6 +247,18 @@ interface OwnedProcessSignal { const defaultOwnedProcessSignal: OwnedProcessSignal = { terminate: (pid) => process.kill(pid, "SIGKILL"), isAlive: (pid) => { + if (HostProcessPlatform.defaultValue() === "linux") { + try { + const stat = NodeFS.readFileSync(`/proc/${pid}/stat`, "utf8"); + const state = stat + .slice(stat.lastIndexOf(")") + 1) + .trimStart() + .split(" ", 1)[0]; + if (state === "Z" || state === "X") return false; + } catch { + // Fall through to the signal probe if procfs is unavailable. + } + } try { process.kill(pid, 0); return true; @@ -372,6 +410,7 @@ const cleanupFixtureProcesses = async (input: FixtureCleanupInput): Promise @@ -210,6 +218,7 @@ it.live( interactionMode: "default", branch: null, worktreePath: null, + historyImport: true, createdAt: "2026-09-28T00:00:00.000Z", }); @@ -445,6 +454,7 @@ it.live( interactionMode: "default", branch: null, worktreePath: null, + historyImport: true, createdAt: "2026-09-28T00:00:00.000Z", }); const session = yield* provider.startSession(threadId, { diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index 5c1616536b2d..db842ba1a4e5 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -1,6 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; +import * as NodeChildProcess from "node:child_process"; import { ApprovalRequestId, @@ -50,6 +51,21 @@ type IntegrationProvider = ProviderDriverKind; const CODEX_PROVIDER = ProviderDriverKind.make("codex"); const CLAUDE_AGENT_PROVIDER = ProviderDriverKind.make("claudeAgent"); +function threadWorktreePath(harness: OrchestrationIntegrationHarness, name: string) { + return NodePath.join(harness.rootDir, "worktrees", name); +} + +function createThreadWorktree(harness: OrchestrationIntegrationHarness, name: string) { + const branch = `t3/integration-${name}`; + const worktreePath = threadWorktreePath(harness, name); + NodeFS.mkdirSync(NodePath.dirname(worktreePath), { recursive: true }); + NodeChildProcess.execFileSync("git", ["worktree", "add", "-b", branch, worktreePath, "main"], { + cwd: harness.workspaceDir, + stdio: "ignore", + }); + return { branch, worktreePath }; +} + function nowIso() { return "2026-05-01T00:00:00.000Z"; } @@ -136,6 +152,8 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => createdAt, }); + const worktree = createThreadWorktree(harness, "thread-1"); + yield* harness.engine.dispatch({ type: "thread.create", commandId: CommandId.make("cmd-thread-create"), @@ -148,8 +166,8 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", - branch: null, - worktreePath: harness.workspaceDir, + branch: worktree.branch, + worktreePath: worktree.worktreePath, createdAt, }); }); @@ -336,6 +354,8 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( createdAt, }); + const worktree = createThreadWorktree(harness, "real-codex-thread"); + yield* harness.engine.dispatch({ type: "thread.create", commandId: CommandId.make("cmd-thread-create-real-codex"), @@ -348,8 +368,8 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", - branch: null, - worktreePath: harness.workspaceDir, + branch: worktree.branch, + worktreePath: worktree.worktreePath, createdAt, }); @@ -910,7 +930,10 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git true, ); assert.equal( - NodeFS.readFileSync(NodePath.join(harness.workspaceDir, "README.md"), "utf8"), + NodeFS.readFileSync( + NodePath.join(threadWorktreePath(harness, "thread-1"), "README.md"), + "utf8", + ), "v2\n", ); assert.equal( diff --git a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts index 4461c133ccc5..83bb0d442fe8 100644 --- a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts +++ b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts @@ -48,6 +48,7 @@ import * as AnalyticsService from "../src/telemetry/AnalyticsService.ts"; import * as GitVcsDriver from "../src/vcs/GitVcsDriver.ts"; import * as VcsProcess from "../src/vcs/VcsProcess.ts"; +// These fixtures hydrate pre-existing provider sessions; no new coding worktree is launched. const providerInstanceId = ProviderInstanceId.make("codex"); const projectId = ProjectId.make("project-startup-orphan"); const threadId = ThreadId.make("thread-startup-orphan"); @@ -62,6 +63,11 @@ const makePersistedRuntimeLayer = (dbPath: string) => { const persistence = makeSqlitePersistenceLive(dbPath); const orchestration = OrchestrationLayerLive.pipe( Layer.provideMerge(RepositoryIdentityResolver.layer), + Layer.provide( + Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed(EnvironmentId.make("environment-startup-orphan")), + }), + ), Layer.provideMerge(persistence), ); const directory = ProviderSessionDirectoryLive.pipe( @@ -93,6 +99,9 @@ const startupDependencies = Layer.mergeAll( capabilities: {}, } as never), }), + Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed(EnvironmentId.make("environment-startup-orphan")), + }), Layer.mock(EnvironmentAuth.EnvironmentAuth)({ issueStartupPairingUrl: (baseUrl: string) => Effect.succeed(`${baseUrl}/pair`), }), @@ -174,6 +183,7 @@ it.effect( runtimeMode: "full-access", branch: null, worktreePath: null, + historyImport: true, createdAt, }); yield* engine.dispatch({ @@ -226,6 +236,7 @@ it.effect( runtimeMode: "full-access", branch: null, worktreePath: null, + historyImport: true, createdAt, }); yield* engine.dispatch({ @@ -411,6 +422,7 @@ it.effect.each(["opt-in desktop restart", "marked remote update"] as const)( runtimeMode: "full-access", branch: null, worktreePath: null, + historyImport: true, createdAt, }); yield* engine.dispatch({ diff --git a/apps/server/integration/threadWorktree.integration.test.ts b/apps/server/integration/threadWorktree.integration.test.ts new file mode 100644 index 000000000000..eda1ebbca3b8 --- /dev/null +++ b/apps/server/integration/threadWorktree.integration.test.ts @@ -0,0 +1,483 @@ +// @effect-diagnostics nodeBuiltinImport:off - real disposable repository/provider-cwd acceptance. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import { + CommandId, + EventId, + EnvironmentId, + MessageId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { makeOrchestrationIntegrationHarness } from "./OrchestrationEngineHarness.integration.ts"; +import { qualifyThreadWorktree } from "../src/project/ThreadWorktree.ts"; + +const at = "2026-10-02T00:00:00.000Z"; +const projectId = ProjectId.make("isolation-project"); +const selection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.3-codex" }; +const integrationEnvironmentId = EnvironmentId.make("integration-test-environment"); +const git = (cwd: string, ...args: string[]) => + NodeChildProcess.execFileSync("git", args, { cwd, encoding: "utf8" }); + +it.live("rejects an unbound coding thread before persistence or any provider launch", () => + Effect.acquireUseRelease( + makeOrchestrationIntegrationHarness(), + (harness) => + Effect.gen(function* () { + yield* harness.engine.dispatch({ + type: "project.create", + commandId: CommandId.make("project"), + projectId, + title: "Fixture", + workspaceRoot: harness.workspaceDir, + defaultModelSelection: selection, + createdAt: at, + }); + const threadId = ThreadId.make("unbound-coding"); + const before = yield* harness.engine.latestSequence; + const outcome = yield* Effect.exit( + harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-unbound"), + threadId, + projectId, + title: "Coding", + modelSelection: selection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: at, + }), + ); + assert.isTrue(Exit.isFailure(outcome)); + assert.equal(yield* harness.engine.latestSequence, before); + assert.equal(harness.adapterHarness!.getStartCount(), 0); + const thread = yield* harness.snapshotQuery.getThreadDetailById(threadId); + assert.isTrue(Option.isNone(thread)); + }), + (harness) => harness.dispose, + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.live( + "two coding threads execute file effects only in their owned worktrees and resume the same cwd", + () => + Effect.acquireUseRelease( + makeOrchestrationIntegrationHarness(), + (harness) => + Effect.gen(function* () { + yield* harness.engine.dispatch({ + type: "project.create", + commandId: CommandId.make("project"), + projectId, + title: "Fixture", + workspaceRoot: harness.workspaceDir, + defaultModelSelection: selection, + createdAt: at, + }); + const bindings = ["coding-a", "coding-b"].map((name) => { + const worktreePath = NodePath.join(harness.rootDir, name); + git(harness.workspaceDir, "worktree", "add", "-b", name, worktreePath, "main"); + return { + threadId: ThreadId.make(name), + projectCwd: harness.workspaceDir, + branch: name, + worktreePath, + }; + }); + for (const binding of bindings) { + yield* qualifyThreadWorktree(binding, { + claimWorktree: true, + environmentId: integrationEnvironmentId, + }); + yield* harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make(`create-${binding.threadId}`), + threadId: binding.threadId, + projectId, + title: binding.threadId, + modelSelection: selection, + runtimeMode: "full-access", + interactionMode: "default", + executionScope: "coding", + branch: binding.branch, + worktreePath: binding.worktreePath, + createdAt: at, + }); + } + const start = (threadId: ThreadId, suffix: string) => + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(`turn-${threadId}-${suffix}`), + threadId, + message: { + messageId: MessageId.make(`message-${threadId}-${suffix}`), + role: "user", + text: "fixture effect", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: at, + }); + const response = { + events: [ + { + type: "turn.completed", + eventId: EventId.make("unused"), + provider: ProviderDriverKind.make("codex"), + createdAt: at, + threadId: ThreadId.make("unused"), + turnId: TurnId.make("unused"), + payload: { state: "completed" }, + }, + ], + mutateWorkspace: ({ cwd }: { cwd: string }) => + Effect.sync(() => + NodeFS.writeFileSync(NodePath.join(cwd, "provider-effect.txt"), cwd), + ), + }; + yield* Effect.forEach( + bindings, + (binding) => + harness.adapterHarness!.queueTurnResponseForThreadStart( + binding.threadId, + response as Parameters< + NonNullable["queueTurnResponseForThreadStart"] + >[1], + ), + { concurrency: "unbounded" }, + ); + yield* Effect.forEach(bindings, (binding) => start(binding.threadId, "first"), { + concurrency: "unbounded", + }); + yield* Effect.forEach( + bindings, + (binding) => + harness.waitForReceipt( + (receipt) => + receipt.type === "turn.processing.quiesced" && + receipt.threadId === binding.threadId, + ), + { concurrency: "unbounded" }, + ); + const sessions = yield* harness.providerService.listSessions(); + assert.equal(harness.adapterHarness!.getStartCount(), 2); + for (const binding of bindings) { + assert.equal( + sessions.find((session) => session.threadId === binding.threadId)?.cwd, + binding.worktreePath, + ); + assert.equal( + NodeFS.readFileSync( + NodePath.join(binding.worktreePath, "provider-effect.txt"), + "utf8", + ), + binding.worktreePath, + ); + } + assert.isFalse( + NodeFS.existsSync(NodePath.join(harness.workspaceDir, "provider-effect.txt")), + ); + const a = bindings[0]!; + yield* harness.adapterHarness!.queueTurnResponse( + a.threadId, + response as Parameters< + NonNullable["queueTurnResponse"] + >[1], + ); + yield* start(a.threadId, "resume"); + yield* harness.waitForReceipt( + (receipt) => + receipt.type === "turn.processing.quiesced" && + receipt.threadId === a.threadId && + receipt.checkpointTurnCount === 2, + ); + assert.equal(harness.adapterHarness!.getStartCount(), 2); + assert.equal( + NodeFS.readFileSync(NodePath.join(a.worktreePath, "provider-effect.txt"), "utf8"), + a.worktreePath, + ); + }), + (harness) => harness.dispose, + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.live( + "refuses turns after the assigned worktree disappears without touching the shared checkout", + () => + Effect.acquireUseRelease( + makeOrchestrationIntegrationHarness(), + (harness) => + Effect.gen(function* () { + yield* harness.engine.dispatch({ + type: "project.create", + commandId: CommandId.make("project-removed-worktree"), + projectId, + title: "Fixture", + workspaceRoot: harness.workspaceDir, + defaultModelSelection: selection, + createdAt: at, + }); + const worktreePath = NodePath.join(harness.rootDir, "removed-assignment"); + git( + harness.workspaceDir, + "worktree", + "add", + "-b", + "removed-assignment", + worktreePath, + "main", + ); + const threadId = ThreadId.make("removed-assignment"); + yield* qualifyThreadWorktree( + { + threadId, + projectCwd: harness.workspaceDir, + branch: "removed-assignment", + worktreePath, + }, + { claimWorktree: true, environmentId: integrationEnvironmentId }, + ); + yield* harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-removed-assignment"), + threadId, + projectId, + title: "Assigned coding thread", + modelSelection: selection, + runtimeMode: "full-access", + interactionMode: "default", + executionScope: "coding", + branch: "removed-assignment", + worktreePath, + createdAt: at, + }); + NodeFS.writeFileSync( + NodePath.join(harness.workspaceDir, "user-checkout-data.txt"), + "keep these bytes\n", + ); + const before = yield* harness.engine.latestSequence; + git(harness.workspaceDir, "worktree", "remove", "--force", worktreePath); + const outcome = yield* Effect.exit( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("turn-missing-assignment"), + threadId, + message: { + messageId: MessageId.make("message-missing-assignment"), + role: "user", + text: "must fail before provider launch", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: at, + }), + ); + assert.isTrue(Exit.isFailure(outcome)); + assert.equal(yield* harness.engine.latestSequence, before); + assert.equal(harness.adapterHarness!.getStartCount(), 0); + assert.isFalse( + NodeFS.existsSync(NodePath.join(harness.workspaceDir, "must-not-run.txt")), + ); + assert.equal( + NodeFS.readFileSync( + NodePath.join(harness.workspaceDir, "user-checkout-data.txt"), + "utf8", + ), + "keep these bytes\n", + ); + }), + (harness) => harness.dispose, + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("rejects a second coding thread attaching to an already owned worktree", () => + Effect.acquireUseRelease( + makeOrchestrationIntegrationHarness(), + (harness) => + Effect.gen(function* () { + yield* harness.engine.dispatch({ + type: "project.create", + commandId: CommandId.make("project-attach"), + projectId, + title: "Fixture", + workspaceRoot: harness.workspaceDir, + defaultModelSelection: selection, + createdAt: at, + }); + const worktreePath = NodePath.join(harness.rootDir, "shared-worktree"); + git(harness.workspaceDir, "worktree", "add", "-b", "shared", worktreePath, "main"); + const binding = { + threadId: ThreadId.make("owner-thread"), + projectCwd: harness.workspaceDir, + branch: "shared", + worktreePath, + }; + yield* qualifyThreadWorktree(binding, { + claimWorktree: true, + environmentId: integrationEnvironmentId, + }); + const create = (threadId: ThreadId, commandId: string) => + harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make(commandId), + threadId, + projectId, + title: String(threadId), + modelSelection: selection, + runtimeMode: "full-access", + interactionMode: "default", + executionScope: "coding", + branch: binding.branch, + worktreePath, + createdAt: at, + }); + yield* create(binding.threadId, "create-owner"); + const duplicate = yield* Effect.exit( + create(ThreadId.make("second-owner"), "create-duplicate"), + ); + assert.isTrue(Exit.isFailure(duplicate)); + assert.equal(harness.adapterHarness!.getStartCount(), 0); + const thread = yield* harness.snapshotQuery.getThreadDetailById( + ThreadId.make("second-owner"), + ); + assert.isTrue(Option.isNone(thread)); + }), + (harness) => harness.dispose, + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("does not claim a second worktree when a live thread id is reused", () => + Effect.acquireUseRelease( + makeOrchestrationIntegrationHarness(), + (harness) => + Effect.gen(function* () { + yield* harness.engine.dispatch({ + type: "project.create", + commandId: CommandId.make("project-duplicate-thread-id"), + projectId, + title: "Fixture", + workspaceRoot: harness.workspaceDir, + defaultModelSelection: selection, + createdAt: at, + }); + const firstPath = NodePath.join(harness.rootDir, "duplicate-id-first"); + const secondPath = NodePath.join(harness.rootDir, "duplicate-id-second"); + git(harness.workspaceDir, "worktree", "add", "-b", "duplicate-id-first", firstPath, "main"); + git( + harness.workspaceDir, + "worktree", + "add", + "-b", + "duplicate-id-second", + secondPath, + "main", + ); + const threadId = ThreadId.make("duplicate-live-thread"); + yield* qualifyThreadWorktree( + { + threadId, + projectCwd: harness.workspaceDir, + branch: "duplicate-id-first", + worktreePath: firstPath, + }, + { claimWorktree: true, environmentId: integrationEnvironmentId }, + ); + const create = (branch: string, worktreePath: string, commandId: string) => + harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make(commandId), + threadId, + projectId, + title: "Coding", + modelSelection: selection, + runtimeMode: "full-access", + interactionMode: "default", + executionScope: "coding", + branch, + worktreePath, + createdAt: at, + }); + yield* create("duplicate-id-first", firstPath, "create-first-duplicate-id"); + const secondGitDir = git(secondPath, "rev-parse", "--absolute-git-dir"); + const secondOwnerMarker = NodePath.join(secondGitDir, "t3-thread-owner.json"); + assert.isFalse(NodeFS.existsSync(secondOwnerMarker)); + const duplicate = yield* Effect.exit( + create("duplicate-id-second", secondPath, "create-second-duplicate-id"), + ); + assert.isTrue(Exit.isFailure(duplicate)); + assert.isFalse(NodeFS.existsSync(secondOwnerMarker)); + assert.equal(harness.adapterHarness!.getStartCount(), 0); + }), + (harness) => harness.dispose, + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("rejects a legacy shared-checkout owner before writing a worktree claim", () => + Effect.acquireUseRelease( + makeOrchestrationIntegrationHarness(), + (harness) => + Effect.gen(function* () { + yield* harness.engine.dispatch({ + type: "project.create", + commandId: CommandId.make("project-legacy-owner"), + projectId, + title: "Fixture", + workspaceRoot: harness.workspaceDir, + defaultModelSelection: selection, + createdAt: at, + }); + const worktreePath = NodePath.join(harness.rootDir, "legacy-shared-worktree"); + git(harness.workspaceDir, "worktree", "add", "-b", "legacy-shared", worktreePath, "main"); + const gitDir = git(worktreePath, "rev-parse", "--absolute-git-dir"); + const ownerMarker = NodePath.join(gitDir, "t3-thread-owner.json"); + assert.isFalse(NodeFS.existsSync(ownerMarker)); + yield* harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-legacy-owner"), + threadId: ThreadId.make("legacy-owner"), + projectId, + title: "Legacy", + modelSelection: selection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "legacy-shared", + worktreePath, + historyImport: true, + createdAt: at, + }); + const attach = yield* Effect.exit( + harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-coding-conflict"), + threadId: ThreadId.make("coding-conflict"), + projectId, + title: "Coding", + modelSelection: selection, + runtimeMode: "full-access", + interactionMode: "default", + executionScope: "coding", + branch: "legacy-shared", + worktreePath, + createdAt: at, + }), + ); + assert.isTrue(Exit.isFailure(attach)); + assert.isFalse(NodeFS.existsSync(ownerMarker)); + assert.equal(harness.adapterHarness!.getStartCount(), 0); + }), + (harness) => harness.dispose, + ).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 9bc20fff84e1..8d253ea121ca 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -9,6 +9,7 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { CommandId, + EnvironmentId, EnvironmentOrchestrationHttpApi, ProviderInstanceId, ThreadId, @@ -55,6 +56,10 @@ import { environmentAuthenticatedAuthLayer } from "./auth/http.ts"; import packageJson from "../package.json" with { type: "json" }; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); +const serverEnvironmentIdentityTestLayer = Layer.succeed( + ServerEnvironment.ServerEnvironmentIdentity, + { getEnvironmentId: Effect.succeed(EnvironmentId.make("bin-test")) }, +); const DisconnectedLauncherChildLayer = Layer.mergeAll( Layer.succeed(HostProcessEnvironment, { ...process.env, @@ -129,6 +134,7 @@ const makeProjectPersistenceLayer = (config: ServerConfig.ServerConfig["Service" Layer.mergeAll( OrchestrationLayerLive.pipe( Layer.provideMerge(RepositoryIdentityResolver.layer), + Layer.provide(serverEnvironmentIdentityTestLayer), Layer.provideMerge(SqlitePersistenceLayerLive), ), WorkspacePaths.layer, @@ -164,6 +170,7 @@ const makeProjectLookupFixture = Effect.fn("makeProjectLookupFixture")(function* const config = yield* makeCliTestServerConfig(baseDir); yield* Effect.gen(function* () { const engine = yield* OrchestrationEngine.OrchestrationEngineService; + // The CLI removal fixture needs an existing aggregate, not a launched coding thread. yield* engine.dispatch({ type: "thread.create", commandId: CommandId.make("cmd-project-lookup-thread"), @@ -175,6 +182,7 @@ const makeProjectLookupFixture = Effect.fn("makeProjectLookupFixture")(function* runtimeMode: "approval-required", branch: null, worktreePath: null, + historyImport: true, createdAt: DateTime.formatIso(yield* DateTime.now), }); }).pipe(Effect.provide(makeProjectPersistenceLayer(config))); @@ -397,6 +405,7 @@ const withLiveProjectCliServer = (baseDir: string, run: () => Effect.Ef }), ), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(serverEnvironmentIdentityTestLayer), Layer.provide(ServerConfig.layer(config)), ); @@ -783,6 +792,8 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { runtimeMode: "approval-required", branch: null, worktreePath: null, + // The removal command only needs a historical child thread in this fixture. + historyImport: true, createdAt: DateTime.formatIso(yield* DateTime.now), }); }).pipe(Effect.provide(makeProjectPersistenceLayer(config))); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 09bfc17598bd..e915a9180e2d 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -14,6 +14,7 @@ import { } from "@t3tools/contracts"; import { CommandId, + EnvironmentId, CheckpointRef, DEFAULT_PROVIDER_INTERACTION_MODE, EventId, @@ -66,6 +67,7 @@ import { import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts"; import { ProviderValidationError } from "../../provider/Errors.ts"; import { ServerConfig } from "../../config.ts"; +import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; import * as WorkspacePaths from "../../workspace/WorkspacePaths.ts"; import { PullRequestService } from "../../pullRequest/PullRequestService.ts"; @@ -274,6 +276,10 @@ describe("CheckpointReactor", () => { > | null = null; let scope: Scope.Closeable | null = null; const tempDirs: string[] = []; + const serverEnvironmentIdentityTestLayer = Layer.succeed( + ServerEnvironment.ServerEnvironmentIdentity, + { getEnvironmentId: Effect.succeed(EnvironmentId.make("checkpoint-reactor-test")) }, + ); afterEach(async () => { if (scope) { @@ -332,6 +338,7 @@ describe("CheckpointReactor", () => { Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolver.layer), + Layer.provide(serverEnvironmentIdentityTestLayer), Layer.provide(SqlitePersistenceMemory), ); const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( @@ -401,6 +408,7 @@ describe("CheckpointReactor", () => { ), Layer.provideMerge(WorkspacePaths.layer), Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(serverEnvironmentIdentityTestLayer), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), ); @@ -446,6 +454,7 @@ describe("CheckpointReactor", () => { ); await Effect.runPromise( engine + // These tests replay provider/checkpoint events against synthetic legacy aggregates. .dispatch({ type: "thread.create", commandId: CommandId.make("cmd-thread-create"), @@ -461,6 +470,7 @@ describe("CheckpointReactor", () => { branch: options?.threadBranch ?? null, worktreePath: options?.threadWorktreePath !== undefined ? options.threadWorktreePath : cwd, + historyImport: true, createdAt, }) .pipe( @@ -481,6 +491,7 @@ describe("CheckpointReactor", () => { branch: null, worktreePath: options?.secondThreadWorktreePath?.(cwd) ?? options?.threadWorktreePath ?? cwd, + historyImport: true, createdAt, }), ) diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 2ef5f4e193af..a8e066130ab4 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -9,6 +9,7 @@ import { CheckpointRef, CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, + EnvironmentId, MessageId, ProjectId, ThreadId, @@ -56,12 +57,17 @@ import { } from "../Services/ProjectionPipeline.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { ServerConfig } from "../../config.ts"; +import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asMessageId = (value: string): MessageId => MessageId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value); +const serverEnvironmentIdentityTestLayer = Layer.succeed( + ServerEnvironment.ServerEnvironmentIdentity, + { getEnvironmentId: Effect.succeed(EnvironmentId.make("orchestration-engine-test")) }, +); const providerServiceForEngineTests = { listSessions: () => Effect.succeed([]), @@ -78,6 +84,8 @@ const providerServiceForEngineTests = { }), } as unknown as ProviderService["Service"]; +// These engine tests seed synthetic legacy threads without owning Git worktrees. +// Mark setup creates as history imports so the assertions stay focused on engine behavior. function makeOrchestrationLayer( databasePath?: string, repositoryIdentityResolver?: RepositoryIdentityResolver.RepositoryIdentityResolver["Service"], @@ -110,6 +118,7 @@ function makeOrchestrationLayer( : RepositoryIdentityResolver.layer, ), Layer.provide(persistence), + Layer.provideMerge(serverEnvironmentIdentityTestLayer), Layer.provideMerge(ServerConfigLayer), Layer.provide(Layer.succeed(ProviderService, providerService)), Layer.provideMerge(NodeServices.layer), @@ -186,6 +195,7 @@ describe("OrchestrationEngine", () => { interactionMode: "default", branch: null, worktreePath: null, + historyImport: true, createdAt: now(), }), ); @@ -492,6 +502,7 @@ describe("OrchestrationEngine", () => { ), Layer.provide(Layer.succeed(OrchestrationEventStore, eventStore)), Layer.provide(Layer.succeed(ProviderService, providerServiceForEngineTests)), + Layer.provide(serverEnvironmentIdentityTestLayer), Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(SqlitePersistenceMemory), @@ -549,6 +560,7 @@ describe("OrchestrationEngine", () => { runtimeMode: "full-access", branch: null, worktreePath: null, + historyImport: true, createdAt, }); yield* engine.dispatch({ @@ -628,6 +640,7 @@ describe("OrchestrationEngine", () => { interactionMode: "default", branch: null, worktreePath: null, + historyImport: true, createdAt, }); yield* engine.dispatch({ @@ -777,6 +790,7 @@ describe("OrchestrationEngine", () => { interactionMode: "default", branch: null, worktreePath: null, + historyImport: true, createdAt, }); const retainedSession = { @@ -883,6 +897,7 @@ describe("OrchestrationEngine", () => { interactionMode: "default", branch: null, worktreePath: null, + historyImport: true, createdAt, }); const retainedSession = { @@ -965,6 +980,7 @@ describe("OrchestrationEngine", () => { interactionMode: "default", branch: null, worktreePath: null, + historyImport: true, createdAt: now(), }); yield* engine.dispatch({ @@ -1203,6 +1219,7 @@ describe("OrchestrationEngine", () => { runtimeMode: "full-access", branch: null, worktreePath: null, + historyImport: true, createdAt: now(), }); } @@ -1334,6 +1351,7 @@ describe("OrchestrationEngine", () => { runtimeMode: "approval-required", branch: null, worktreePath: null, + historyImport: true, createdAt, }), ); @@ -1394,6 +1412,7 @@ describe("OrchestrationEngine", () => { runtimeMode: "full-access", branch: null, worktreePath: null, + historyImport: true, createdAt, }), ); @@ -1487,6 +1506,7 @@ describe("OrchestrationEngine", () => { runtimeMode: "approval-required", branch: null, worktreePath: null, + historyImport: true, createdAt, }), ); @@ -1555,6 +1575,7 @@ describe("OrchestrationEngine", () => { runtimeMode: "approval-required", branch: null, worktreePath: null, + historyImport: true, createdAt, }); yield* engine.dispatch({ @@ -1606,6 +1627,7 @@ describe("OrchestrationEngine", () => { runtimeMode: "approval-required", branch: "t3code/generated-branch-name", worktreePath: "/tmp/project-branch-race-worktree", + historyImport: true, createdAt, }), ); @@ -1681,6 +1703,7 @@ describe("OrchestrationEngine", () => { interactionMode: "default", branch: "feature", worktreePath: null, + historyImport: true, createdAt: now(), }), ); @@ -1791,6 +1814,7 @@ describe("OrchestrationEngine", () => { interactionMode: "default", branch: "feature", worktreePath: null, + historyImport: true, createdAt: now(), }), ); @@ -1889,6 +1913,7 @@ describe("OrchestrationEngine", () => { runtimeMode: "approval-required", branch: "main", worktreePath: null, + historyImport: true, createdAt, }), ); @@ -1943,6 +1968,7 @@ describe("OrchestrationEngine", () => { runtimeMode: "full-access", branch: null, worktreePath: null, + historyImport: true, createdAt, }), ); @@ -1983,7 +2009,7 @@ describe("OrchestrationEngine", () => { createdAt, }), ), - ).rejects.toThrow("does not exist"); + ).rejects.toThrow("Thread destination project is missing."); const snapshots = await system.run(Metric.snapshot); expect( @@ -2031,6 +2057,7 @@ describe("OrchestrationEngine", () => { runtimeMode: "approval-required", branch: null, worktreePath: null, + historyImport: true, createdAt, }), ); @@ -2121,6 +2148,7 @@ describe("OrchestrationEngine", () => { Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolver.layer), + Layer.provide(serverEnvironmentIdentityTestLayer), Layer.provide(SqlitePersistenceMemory), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), @@ -2160,6 +2188,7 @@ describe("OrchestrationEngine", () => { runtimeMode: "approval-required", branch: null, worktreePath: null, + historyImport: true, createdAt, }), ), @@ -2180,6 +2209,7 @@ describe("OrchestrationEngine", () => { runtimeMode: "approval-required", branch: null, worktreePath: null, + historyImport: true, createdAt, }), ); @@ -2232,6 +2262,7 @@ describe("OrchestrationEngine", () => { Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolver.layer), + Layer.provide(serverEnvironmentIdentityTestLayer), Layer.provide(SqlitePersistenceMemory), Layer.provide(NodeServices.layer), ), @@ -2268,6 +2299,7 @@ describe("OrchestrationEngine", () => { runtimeMode: "approval-required", branch: null, worktreePath: null, + historyImport: true, createdAt, }), ); @@ -2384,6 +2416,7 @@ describe("OrchestrationEngine", () => { Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolver.layer), + Layer.provide(serverEnvironmentIdentityTestLayer), Layer.provide(SqlitePersistenceMemory), Layer.provide(NodeServices.layer), ), @@ -2420,6 +2453,7 @@ describe("OrchestrationEngine", () => { runtimeMode: "approval-required", branch: null, worktreePath: null, + historyImport: true, createdAt, }), ); @@ -2508,6 +2542,7 @@ describe("OrchestrationEngine", () => { runtimeMode: "approval-required", branch: null, worktreePath: null, + historyImport: true, createdAt, }), ); @@ -2570,6 +2605,7 @@ describe("OrchestrationEngine", () => { runtimeMode: "approval-required", branch: null, worktreePath: null, + historyImport: true, createdAt, }), ); @@ -2635,6 +2671,7 @@ describe("OrchestrationEngine", () => { runtimeMode: "approval-required", branch: null, worktreePath: null, + historyImport: true, createdAt, }), ); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 089ec8008798..73447803b922 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -1,3 +1,8 @@ +import { + qualifyCoordinatorWorkspace, + qualifyThreadWorktree, + rejectConflictingWorktree, +} from "../../project/ThreadWorktree.ts"; import type { OrchestrationClientOrigin, OrchestrationEvent, @@ -24,6 +29,7 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; import { metricAttributes, orchestrationCommandAckDuration, @@ -91,6 +97,8 @@ const makeOrchestrationEngine = Effect.gen(function* () { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService; const providerService = yield* Effect.serviceOption(ProviderService); + const environmentIdentity = yield* ServerEnvironment.ServerEnvironmentIdentity; + const environmentId = yield* environmentIdentity.getEnvironmentId; const crypto = yield* Crypto.Crypto; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -340,6 +348,236 @@ const makeOrchestrationEngine = Effect.gen(function* () { envelope.command.type === "thread.user-input.dismiss" ? yield* projectionSnapshotQuery.getUserInputActivity(envelope.command) : Option.none(); + // After callback/duplicate admission, before accepting any execution event. + // The existing command queue serializes ownership checks with binding changes. + const command = envelope.command; + const thread = + "threadId" in command + ? commandReadModel.threads.find( + (item) => item.id === command.threadId && item.deletedAt === null, + ) + : undefined; + if (command.type === "thread.create" && command.historyImport !== true) { + const existingThread = commandReadModel.threads.find( + (item) => item.id === command.threadId, + ); + if (existingThread !== undefined && existingThread.deletedAt === null) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Thread '${command.threadId}' already exists and cannot be created twice.`, + }); + } + const executionScope = command.executionScope ?? "coding"; + const project = commandReadModel.projects.find( + (item) => item.id === command.projectId && item.deletedAt === null, + ); + if (!project) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Thread destination project is missing.", + }); + } + if (executionScope === "coding") { + if (command.worktreePath === null || command.branch === null) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Coding threads require an assigned Git worktree and branch.", + }); + } + yield* rejectConflictingWorktree( + command.threadId, + command.worktreePath, + commandReadModel.threads, + ).pipe( + Effect.mapError( + (cause) => + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: + cause instanceof Error ? cause.message : "Worktree ownership check failed.", + }), + ), + ); + yield* qualifyThreadWorktree( + { + threadId: command.threadId, + projectCwd: project.workspaceRoot, + branch: command.branch, + worktreePath: command.worktreePath, + }, + { claimWorktree: true, environmentId }, + ).pipe( + Effect.mapError( + (cause) => + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: cause instanceof Error ? cause.message : "Worktree attach refused.", + }), + ), + ); + } else { + if (command.worktreePath === null) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Coordinator threads require an assigned workspace.", + }); + } + yield* rejectConflictingWorktree( + command.threadId, + command.worktreePath, + commandReadModel.threads, + ).pipe( + Effect.andThen( + qualifyCoordinatorWorkspace( + project.workspaceRoot, + command.worktreePath, + command.threadId, + { claimWorkspace: true, environmentId }, + ), + ), + Effect.mapError( + (cause) => + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: + cause instanceof Error ? cause.message : "Coordinator workspace refused.", + }), + ), + ); + } + } + if ( + command.type === "thread.meta.update" && + thread?.executionScope === "coding" && + command.worktreePath !== undefined + ) { + const nextWorktreePath = command.worktreePath; + if ( + nextWorktreePath === null || + (thread.worktreePath !== null && nextWorktreePath !== thread.worktreePath) + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "A coding thread cannot detach or replace its assigned worktree.", + }); + } + const project = commandReadModel.projects.find( + (item) => item.id === thread.projectId && item.deletedAt === null, + ); + if (!project) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Coding project is missing.", + }); + } + yield* rejectConflictingWorktree( + thread.id, + nextWorktreePath, + commandReadModel.threads, + ).pipe( + Effect.andThen( + qualifyThreadWorktree( + { + threadId: thread.id, + projectCwd: project.workspaceRoot, + branch: command.branch !== undefined ? command.branch : thread.branch, + worktreePath: nextWorktreePath, + }, + { claimWorktree: true, environmentId }, + ), + ), + Effect.mapError( + (cause) => + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: cause instanceof Error ? cause.message : "Worktree attach refused.", + }), + ), + ); + } + if ( + command.type === "thread.meta.update" && + thread?.executionScope === "coordinator" && + command.worktreePath !== undefined + ) { + if ( + command.worktreePath === null || + (thread.worktreePath !== null && command.worktreePath !== thread.worktreePath) + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "A coordinator thread cannot detach or replace its assigned workspace.", + }); + } + const project = commandReadModel.projects.find( + (item) => item.id === thread.projectId && item.deletedAt === null, + ); + if (!project) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Coordinator project is missing.", + }); + } + yield* rejectConflictingWorktree( + thread.id, + command.worktreePath, + commandReadModel.threads, + ).pipe( + Effect.andThen( + qualifyCoordinatorWorkspace(project.workspaceRoot, command.worktreePath, thread.id, { + environmentId, + }), + ), + Effect.mapError( + (cause) => + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: cause instanceof Error ? cause.message : "Coordinator workspace refused.", + }), + ), + ); + } + if (command.type === "thread.turn.start" && thread?.executionScope === "coding") { + const project = commandReadModel.projects.find( + (item) => item.id === thread.projectId && item.deletedAt === null, + ); + if (!project) + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Coding project is missing.", + }); + yield* qualifyThreadWorktree( + { + threadId: thread.id, + projectCwd: project.workspaceRoot, + worktreePath: thread.worktreePath, + branch: thread.branch, + }, + { environmentId }, + ).pipe( + Effect.andThen( + thread.worktreePath === null + ? Effect.void + : rejectConflictingWorktree( + thread.id, + thread.worktreePath, + commandReadModel.threads, + ), + ), + + Effect.mapError( + (cause) => + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: + cause instanceof Error + ? cause.message + : "Coding worktree qualification failed.", + }), + ), + ); + } + const eventBase = yield* decideOrchestrationCommand({ command: envelope.command, readModel: commandReadModel, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index f3ea5d988af3..3bc2abd62638 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -4,6 +4,7 @@ import { CommandId, CorrelationId, DEFAULT_PROVIDER_INTERACTION_MODE, + EnvironmentId, EventId, MessageId, ProjectId, @@ -46,6 +47,12 @@ import * as ThreadPostStartActivity from "../ThreadPostStartActivity.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; import { ServerConfig } from "../../config.ts"; +import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; + +const serverEnvironmentIdentityTestLayer = Layer.succeed( + ServerEnvironment.ServerEnvironmentIdentity, + { getEnvironmentId: Effect.succeed(EnvironmentId.make("projection-pipeline-test")) }, +); const makeProjectionPipelinePrefixedTestLayer = (prefix: string) => OrchestrationProjectionPipelineLive.pipe( @@ -4371,6 +4378,7 @@ const engineLayer = it.layer( }), ), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(serverEnvironmentIdentityTestLayer), ), ); @@ -4547,6 +4555,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { interactionMode: "default", branch: null, worktreePath: null, + historyImport: true, createdAt, }); const countRowsForThread = (table: string) => @@ -4710,6 +4719,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { runtimeMode: "full-access", branch: null, worktreePath: null, + historyImport: true, createdAt, }); } diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index b5e6cb0cdd54..f73c036562e1 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -619,6 +619,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti interactionMode: event.payload.interactionMode, branch: event.payload.branch, worktreePath: event.payload.worktreePath, + executionScope: event.payload.executionScope ?? null, linkedPullRequest: null, branchPullRequest: null, latestTurnId: null, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 77eb34acdb58..8024b9448e8a 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -463,6 +463,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runtimeMode: "full-access", branch: null, worktreePath: null, + executionScope: null, pullRequests: expectedPullRequests, branchPullRequest, latestTurn: { @@ -589,6 +590,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runtimeMode: "full-access", branch: null, worktreePath: null, + executionScope: null, pullRequests: expectedPullRequests, branchPullRequest, latestTurn: { diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 4d220b7f798b..0c80c49b1550 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -583,6 +583,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + execution_scope AS "executionScope", linked_pull_request_json AS "linkedPullRequest", branch_pull_request_json AS "branchPullRequest", latest_turn_id AS "latestTurnId", @@ -624,6 +625,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + execution_scope AS "executionScope", linked_pull_request_json AS "linkedPullRequest", branch_pull_request_json AS "branchPullRequest", latest_turn_id AS "latestTurnId", @@ -697,6 +699,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + execution_scope AS "executionScope", linked_pull_request_json AS "linkedPullRequest", branch_pull_request_json AS "branchPullRequest", latest_turn_id AS "latestTurnId", @@ -1262,6 +1265,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + execution_scope AS "executionScope", linked_pull_request_json AS "linkedPullRequest", branch_pull_request_json AS "branchPullRequest", latest_turn_id AS "latestTurnId", @@ -2045,6 +2049,7 @@ pending_approval_requests AS ( threads.project_id AS "projectId", projects.workspace_root AS "workspaceRoot", threads.worktree_path AS "worktreePath", + threads.execution_scope AS "executionScope", ( SELECT MAX(turns.checkpoint_turn_count) FROM projection_turns AS turns @@ -2333,6 +2338,7 @@ pending_approval_requests AS ( interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + executionScope: row.executionScope ?? null, ...mapThreadPullRequests( pullRequestsByThread.get(row.threadId) ?? [], row.projectId, @@ -2578,6 +2584,7 @@ pending_approval_requests AS ( interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + executionScope: row.executionScope ?? null, ...mapThreadPullRequests( pullRequestsByThread.get(row.threadId) ?? [], row.projectId, @@ -2735,6 +2742,7 @@ pending_approval_requests AS ( interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + executionScope: row.executionScope ?? null, branchPullRequest: row.branchPullRequest, ...mapThreadPullRequests( pullRequestsByThread.get(row.threadId) ?? [], @@ -2900,6 +2908,7 @@ pending_approval_requests AS ( interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + executionScope: row.executionScope ?? null, branchPullRequest: row.branchPullRequest, ...mapThreadPullRequests( pullRequestsByThread.get(row.threadId) ?? [], @@ -3256,6 +3265,7 @@ pending_approval_requests AS ( interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, + executionScope: threadRow.value.executionScope ?? null, ...mapThreadPullRequests( pullRequestRows.map(mapPullRequestRow), threadRow.value.projectId, @@ -3558,6 +3568,7 @@ pending_approval_requests AS ( interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, + executionScope: threadRow.value.executionScope ?? null, ...mapThreadPullRequests( pullRequestRows.map(mapPullRequestRow), threadRow.value.projectId, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 22a649f63b49..49b8a4126f68 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -76,6 +76,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Clock from "effect/Clock"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ServerActivation } from "../../serverActivation.ts"; +import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import * as GitWorkflowService from "../../git/GitWorkflowService.ts"; @@ -83,6 +84,10 @@ const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value); const asMessageId = (value: string): MessageId => MessageId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); +const serverEnvironmentIdentityTestLayer = Layer.succeed( + ServerEnvironment.ServerEnvironmentIdentity, + { getEnvironmentId: Effect.succeed(EnvironmentId.make("provider-command-reactor-test")) }, +); const assistantQuoteText = "Retain the reconnect backoff."; const assistantCitation = { @@ -170,6 +175,7 @@ describe("ProviderCommandReactor", () => { }); }); + // Reactor scenarios reuse a synthetic historical thread instead of provisioning Git worktrees. async function createHarness(input?: { readonly baseDir?: string; readonly initialTitle?: string; @@ -422,6 +428,7 @@ describe("ProviderCommandReactor", () => { Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(serverEnvironmentIdentityTestLayer), ); const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( Layer.provide(ThreadBackgroundLiveness.layer), @@ -510,6 +517,7 @@ describe("ProviderCommandReactor", () => { }), ), Layer.provideMerge(ServerSettingsService.layerTest(input?.serverSettingsOverrides ?? {})), + Layer.provideMerge(serverEnvironmentIdentityTestLayer), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), Layer.provideMerge(NodeServices.layer), @@ -544,6 +552,7 @@ describe("ProviderCommandReactor", () => { runtimeMode: "approval-required", branch: null, worktreePath: null, + historyImport: true, createdAt: now, }), ); @@ -577,6 +586,7 @@ describe("ProviderCommandReactor", () => { runtimeMode: "approval-required", branch: null, worktreePath: null, + historyImport: true, createdAt: now, }), ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 79dd2a664799..4d248a1769f3 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -1,3 +1,7 @@ +import { + qualifyCoordinatorWorkspace, + qualifyThreadWorktree, +} from "../../project/ThreadWorktree.ts"; import { withWorkspaceLease } from "../../workspace/workspaceLease.ts"; import { type ChatAttachment, @@ -9,6 +13,7 @@ import { type ProjectId, type OrchestrationSession, ThreadId, + type ThreadExecutionScope, type ProviderSession, type RuntimeMode, type TurnId, @@ -34,6 +39,7 @@ import * as Stream from "effect/Stream"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; +import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; import { ProviderAdapterProcessError, @@ -213,6 +219,8 @@ function buildGeneratedWorktreeBranchName(raw: string): string { const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; + const environmentIdentity = yield* ServerEnvironment.ServerEnvironmentIdentity; + const environmentId = yield* environmentIdentity.getEnvironmentId; const orchestrationEngine = yield* OrchestrationEngineService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const providerAuthService = yield* ProviderAuthService; @@ -482,8 +490,14 @@ const make = Effect.gen(function* () { readonly projectId: ProjectId; readonly branch: string | null; readonly worktreePath: string | null; + readonly executionScope?: ThreadExecutionScope | null | undefined; }) { const { worktreePath, branch } = thread; + // A coding assignment is immutable. Recreating a missing path after the + // user removed it could silently bind this thread to another checkout. + if (thread.executionScope === "coding") { + return; + } if (!worktreePath || !branch) { return; } @@ -719,6 +733,57 @@ const make = Effect.gen(function* () { thread, projects: project ? [project] : [], }); + // Recheck immediately before starting/resuming the provider; admission and execution + // are asynchronous, so a removed/rebound checkout cannot use a shared cwd fallback. + if (thread.executionScope === "coding") { + if (!project) + return yield* new ProviderAdapterRequestError({ + provider: preferredProvider, + method: "thread.turn.start", + detail: "Coding project is missing.", + }); + yield* qualifyThreadWorktree( + { + threadId, + projectCwd: project.workspaceRoot, + branch: thread.branch, + worktreePath: thread.worktreePath, + }, + { environmentId }, + ).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: preferredProvider, + method: "thread.turn.start", + detail: + cause instanceof Error ? cause.message : "Coding worktree qualification failed.", + }), + ), + ); + } else if (thread.executionScope === "coordinator") { + if (!project) + return yield* new ProviderAdapterRequestError({ + provider: preferredProvider, + method: "thread.turn.start", + detail: "Coordinator project is missing.", + }); + yield* qualifyCoordinatorWorkspace(project.workspaceRoot, thread.worktreePath, thread.id, { + environmentId, + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: preferredProvider, + method: "thread.turn.start", + detail: + cause instanceof Error + ? cause.message + : "Coordinator workspace qualification failed.", + }), + ), + ); + } const refreshWorkspaceSnapshot = effectiveCwd ? providerRegistry .refreshWorkspaceSnapshot({ instanceId: desiredInstanceId, cwd: effectiveCwd }) @@ -1597,6 +1662,83 @@ const make = Effect.gen(function* () { } } + const latestThread = yield* resolveThreadShell(event.payload.threadId); + if ( + latestThread?.executionScope === "coding" || + latestThread?.executionScope === "coordinator" + ) { + const project = yield* resolveProject(latestThread.projectId); + if (!project) { + yield* handleTurnStartFailure( + Cause.fail( + new ProviderAdapterRequestError({ + provider: providerErrorLabel(latestThread.session?.providerName ?? undefined), + method: "thread.turn.start", + detail: "Thread destination project is missing.", + }), + ), + ); + return; + } + const qualification = Effect.gen(function* () { + if (latestThread.executionScope === "coding") { + yield* qualifyThreadWorktree( + { + threadId: latestThread.id, + projectCwd: project.workspaceRoot, + worktreePath: latestThread.worktreePath, + branch: latestThread.branch, + }, + { environmentId }, + ); + } else { + yield* qualifyCoordinatorWorkspace( + project.workspaceRoot, + latestThread.worktreePath, + latestThread.id, + { environmentId }, + ); + } + return true; + }); + const qualified = yield* qualification.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: providerErrorLabel(latestThread.session?.providerName ?? undefined), + method: "thread.turn.start", + detail: + cause instanceof Error ? cause.message : "Coding worktree qualification failed.", + }), + ), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : recoverTurnStartFailure(cause).pipe(Effect.as(false)), + ), + ); + if (!qualified) return; + const session = yield* providerService + .listSessions() + .pipe( + Effect.map((sessions) => sessions.find((candidate) => candidate.threadId === thread.id)), + ); + const expectedCwd = path.resolve(latestThread.worktreePath ?? ""); + if (!session?.cwd || path.resolve(session.cwd) !== expectedCwd) { + yield* handleTurnStartFailure( + Cause.fail( + new ProviderAdapterRequestError({ + provider: providerErrorLabel(latestThread.session?.providerName ?? undefined), + method: "thread.turn.start", + detail: + "The destination thread no longer has a provider session bound to its assigned worktree. The existing message is preserved; open the thread and retry after its workspace is repaired.", + }), + ), + ); + return; + } + } + // Bound callbacks keep this final route fence adjacent to sendTurn: a // session.set dispatch here would yield and let the route change before // the provider effect. Retained bound sessions keep their current status. diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 637bc345bf3b..bf1425848bca 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -15,6 +15,7 @@ import { import { ApprovalRequestId, CommandId, + EnvironmentId, DEFAULT_PROVIDER_INTERACTION_MODE, EventId, MessageId, @@ -69,6 +70,7 @@ import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { ServerConfig } from "../../config.ts"; +import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { makeSqlStatementCounter } from "../../../integration/SqlStatementCounter.integration.ts"; @@ -262,6 +264,10 @@ describe("ProviderRuntimeIngestion", () => { > | null = null; let scope: Scope.Closeable | null = null; const tempDirs: string[] = []; + const serverEnvironmentIdentityTestLayer = Layer.succeed( + ServerEnvironment.ServerEnvironmentIdentity, + { getEnvironmentId: Effect.succeed(EnvironmentId.make("provider-runtime-ingestion-test")) }, + ); function makeTempDir(prefix: string): string { const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), prefix)); @@ -305,6 +311,7 @@ describe("ProviderRuntimeIngestion", () => { Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolver.layer), + Layer.provide(serverEnvironmentIdentityTestLayer), Layer.provide(SqlitePersistenceMemory), ); const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( @@ -350,6 +357,7 @@ describe("ProviderRuntimeIngestion", () => { Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)), + Layer.provideMerge(serverEnvironmentIdentityTestLayer), Layer.provideMerge( Layer.effect( CheckpointStore.CheckpointStore, @@ -395,6 +403,7 @@ describe("ProviderRuntimeIngestion", () => { ); const createdAt = "2026-01-01T00:00:00.000Z"; + // Ingestion tests seed a pre-existing thread and exercise provider callbacks, not launch. await dispatch({ type: "project.create", commandId: CommandId.make("cmd-provider-project-create"), @@ -421,6 +430,7 @@ describe("ProviderRuntimeIngestion", () => { runtimeMode: "approval-required", branch: null, worktreePath: null, + historyImport: true, createdAt, }); await dispatch({ @@ -2511,6 +2521,7 @@ describe("ProviderRuntimeIngestion", () => { runtimeMode: "approval-required", branch: null, worktreePath: null, + historyImport: true, createdAt, }), ); @@ -2546,6 +2557,7 @@ describe("ProviderRuntimeIngestion", () => { runtimeMode: "approval-required", branch: null, worktreePath: null, + historyImport: true, createdAt, }), ); @@ -2724,6 +2736,7 @@ describe("ProviderRuntimeIngestion", () => { runtimeMode: "approval-required", branch: null, worktreePath: null, + historyImport: true, createdAt, }), harness.engine.dispatch({ @@ -2962,6 +2975,7 @@ describe("ProviderRuntimeIngestion", () => { runtimeMode: "approval-required", branch: null, worktreePath: null, + historyImport: true, createdAt, }), ); @@ -2997,6 +3011,7 @@ describe("ProviderRuntimeIngestion", () => { runtimeMode: "approval-required", branch: null, worktreePath: null, + historyImport: true, createdAt, }), ); diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts index d4959f39af59..bce23a599425 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -131,6 +131,10 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => } satisfies OrchestrationCommand; } + if (canonicalCommand.type === "thread.create" && canonicalCommand.historyImport !== true) { + return { ...canonicalCommand, executionScope: canonicalCommand.executionScope ?? "coding" }; + } + if ( canonicalCommand.type !== "thread.turn.start" && canonicalCommand.type !== "thread.user-input.respond" @@ -335,6 +339,17 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => }; return { ...canonicalCommand, + ...(canonicalCommand.bootstrap?.createThread + ? { + bootstrap: { + ...canonicalCommand.bootstrap, + createThread: { + ...canonicalCommand.bootstrap.createThread, + executionScope: canonicalCommand.bootstrap.createThread.executionScope ?? "coding", + }, + }, + } + : {}), message: { ...canonicalCommand.message, attachments: normalizedAttachments, diff --git a/apps/server/src/orchestration/decider.import.test.ts b/apps/server/src/orchestration/decider.import.test.ts index c809c733800a..42c9be0da232 100644 --- a/apps/server/src/orchestration/decider.import.test.ts +++ b/apps/server/src/orchestration/decider.import.test.ts @@ -69,8 +69,9 @@ it.layer(NodeServices.layer)("thread history import", (it) => { expect(imported).toMatchObject({ type: "thread.created", metadata: { historyImport: true }, + payload: { executionScope: null }, }); - expect(live).toMatchObject({ type: "thread.created" }); + expect(live).toMatchObject({ type: "thread.created", payload: { executionScope: "coding" } }); expect(live).not.toMatchObject({ metadata: { historyImport: true } }); }), ); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index bf675fe65d09..db8796de67fe 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -404,6 +404,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" interactionMode: command.interactionMode, branch: command.branch, worktreePath: command.worktreePath, + executionScope: + command.executionScope ?? (command.historyImport === true ? null : "coding"), createdAt: command.createdAt, updatedAt: command.createdAt, }, diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index c4e1996f1ddd..1bdf74e451d8 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -86,6 +86,7 @@ describe("orchestration projector", () => { interactionMode: "default", branch: null, worktreePath: null, + executionScope: null, pullRequests: [], branchPullRequest: null, latestTurn: null, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 53013770b15b..dad0bf1fbfea 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -430,6 +430,7 @@ export function projectEvent( interactionMode: payload.interactionMode, branch: payload.branch, worktreePath: payload.worktreePath, + executionScope: payload.executionScope ?? null, pullRequests: [], branchPullRequest: null, latestTurn: null, diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index af36578f286e..56bc8de78148 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -40,6 +40,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode, branch, worktree_path, + execution_scope, linked_pull_request_json, branch_pull_request_json, latest_turn_id, @@ -72,6 +73,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.interactionMode}, ${row.branch}, ${row.worktreePath}, + ${row.executionScope ?? null}, ${row.linkedPullRequest === undefined || row.linkedPullRequest === null ? null : JSON.stringify(row.linkedPullRequest)}, ${row.branchPullRequest === undefined || row.branchPullRequest === null ? null : JSON.stringify(row.branchPullRequest)}, ${row.latestTurnId}, @@ -104,6 +106,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode = excluded.interaction_mode, branch = excluded.branch, worktree_path = excluded.worktree_path, + execution_scope = excluded.execution_scope, linked_pull_request_json = excluded.linked_pull_request_json, branch_pull_request_json = excluded.branch_pull_request_json, latest_turn_id = excluded.latest_turn_id, @@ -143,6 +146,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + execution_scope AS "executionScope", linked_pull_request_json AS "linkedPullRequest", branch_pull_request_json AS "branchPullRequest", latest_turn_id AS "latestTurnId", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 62e9b5574712..4276b84f5553 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -67,6 +67,7 @@ import Migration0052 from "./Migrations/052_ProjectionThreadTitleState.ts"; import Migration0053 from "./Migrations/053_PullRequestFilesViewed.ts"; import Migration0054 from "./Migrations/054_ProviderSessionHistory.ts"; import Migration0055 from "./Migrations/055_ThreadRouteEvents.ts"; +import Migration0056 from "./Migrations/056_ThreadExecutionScope.ts"; /** * Migration loader with all migrations defined inline. @@ -134,6 +135,7 @@ const migrationEntries = [ [53, "PullRequestFilesViewed", Migration0053], [54, "ProviderSessionHistory", Migration0054], [55, "ThreadRouteEvents", Migration0055], + [56, "ThreadExecutionScope", Migration0056], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/056_ThreadExecutionScope.ts b/apps/server/src/persistence/Migrations/056_ThreadExecutionScope.ts new file mode 100644 index 000000000000..dee8713cbc30 --- /dev/null +++ b/apps/server/src/persistence/Migrations/056_ThreadExecutionScope.ts @@ -0,0 +1,11 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>`PRAGMA table_info(projection_threads)`; + if (!columns.some((column) => column.name === "execution_scope")) { + yield* sql`ALTER TABLE projection_threads ADD COLUMN execution_scope TEXT`; + } + // Historical threads keep null: upgrading never rebinds an active provider or rewrites history. +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 895fe596db24..3f2958fc7b23 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -17,6 +17,7 @@ import { ThreadLinkedPullRequest, ThreadTitleState, ThreadId, + ThreadExecutionScope, TurnId, } from "@t3tools/contracts"; import * as Option from "effect/Option"; @@ -36,6 +37,7 @@ export const ProjectionThread = Schema.Struct({ interactionMode: ProviderInteractionMode, branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), + executionScope: Schema.optional(Schema.NullOr(ThreadExecutionScope)), linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), latestTurnId: Schema.NullOr(TurnId), diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index 18af8b242d06..efc65d4f5378 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "@effect/vitest"; import { AgentSessionImportProjectChangedError, CommandId, + EnvironmentId, MessageId, ProjectId, ProviderDriverKind, @@ -26,6 +27,7 @@ import * as TestClock from "effect/testing/TestClock"; import { makeTestProviderAdapterHarness } from "../../integration/TestProviderAdapter.integration.ts"; import { ServerConfig } from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { GitWorkflowService } from "../git/GitWorkflowService.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../persistence/Layers/OrchestrationCommandReceipts.ts"; import { OrchestrationEventStoreLive } from "../persistence/Layers/OrchestrationEventStore.ts"; @@ -63,6 +65,10 @@ import { importRecentAgentThreads } from "./AgentSessionImporter.ts"; import * as AgentSessionScanner from "./AgentSessionScanner.ts"; const PROJECT_ID = ProjectId.make("project-1"); +const serverEnvironmentIdentityTestLayer = Layer.succeed( + ServerEnvironment.ServerEnvironmentIdentity, + { getEnvironmentId: Effect.succeed(EnvironmentId.make("agent-session-importer-test")) }, +); const WORKSPACE_ROOT = "/tmp/project-from-server"; const CLAUDE_SESSION_ID = "123e4567-e89b-42d3-a456-426614174000"; const encodeTranscriptRecord = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); @@ -578,6 +584,7 @@ const integrationLayer = Layer.mergeAll( Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(serverEnvironmentIdentityTestLayer), Layer.provideMerge(integrationServerConfig), Layer.provideMerge(NodeServices.layer), ); @@ -1155,6 +1162,7 @@ it.layer(integrationLayer)("AgentSessionImporter integration", (it) => { defaultModelSelection: null, createdAt: "2026-08-24T09:00:00.000Z", }); + // This race fixture starts from a historical partial aggregate while import is active. yield* engine.dispatch({ type: "thread.create", commandId: CommandId.make("create-import-turn-race-thread"), @@ -1166,6 +1174,7 @@ it.layer(integrationLayer)("AgentSessionImporter integration", (it) => { interactionMode: "default", branch: null, worktreePath: null, + historyImport: true, createdAt: "2026-08-24T10:00:00.000Z", }); diff --git a/apps/server/src/project/ThreadWorktree.test.ts b/apps/server/src/project/ThreadWorktree.test.ts new file mode 100644 index 000000000000..c412875c6fb1 --- /dev/null +++ b/apps/server/src/project/ThreadWorktree.test.ts @@ -0,0 +1,207 @@ +// @effect-diagnostics nodeBuiltinImport:off - disposable real Git fixtures verify filesystem identity. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { describe, it, expect, afterEach } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Layer from "effect/Layer"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import { EnvironmentId } from "@t3tools/contracts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import { + qualifyThreadWorktree, + qualifyCoordinatorWorkspace, + rejectConflictingWorktree, + safeThreadPathSegment, +} from "./ThreadWorktree.ts"; + +const roots: string[] = []; +const testServices = (environmentId = "worktree-test-environment") => + Layer.mergeAll( + NodeServices.layer, + Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed(EnvironmentId.make(environmentId)), + }), + ); +const git = (cwd: string, ...args: string[]) => + NodeChildProcess.execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); +const fixture = () => { + const container = NodeFS.realpathSync( + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-worktree-policy-")), + ); + roots.push(container); + const root = NodePath.join(container, "repo"); + NodeFS.mkdirSync(root); + git(root, "init", "--initial-branch=main"); + git(root, "config", "user.name", "Fixture"); + git(root, "config", "user.email", "fixture@example.invalid"); + NodeFS.writeFileSync(NodePath.join(root, "tracked.txt"), "accepted bytes\n"); + git(root, "add", "tracked.txt"); + git(root, "commit", "-m", "fixture"); + return root; +}; +const add = (root: string, name: string) => { + const worktreePath = NodePath.join(root, "..", name); + git(root, "worktree", "add", "-b", name, worktreePath, "main"); + return { threadId: name, projectCwd: root, branch: name, worktreePath }; +}; +const isFailureContaining = (exit: Exit.Exit, text: string) => + Exit.isFailure(exit) && String(exit.cause).includes(text); + +afterEach(() => { + for (const root of roots.splice(0)) { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); + +it.effect("refuses ownership claims when the server environment identity is unavailable", () => + Effect.gen(function* () { + const root = fixture(); + const binding = add(root, "missing-environment-identity"); + const failure = yield* Effect.exit(qualifyThreadWorktree(binding, { claimWorktree: true })); + expect(isFailureContaining(failure, "environment identity is unavailable")).toBe(true); + }).pipe(Effect.provide(NodeServices.layer)), +); + +describe("destination-local coding worktrees", () => { + it("encodes arbitrary thread ids as safe single path and branch segments", () => { + const segment = safeThreadPathSegment("../../repo/name\\with space"); + expect(segment).toMatch(/^id-[0-9a-f-]+$/); + expect(segment).not.toContain("/"); + expect(segment).not.toContain(".."); + }); + + it.effect("qualifies concurrent distinct claims and reuses the same assignment", () => + Effect.gen(function* () { + const root = fixture(); + const a = add(root, "thread-a"); + const b = add(root, "thread-b"); + const [qa, qb] = yield* Effect.all( + [a, b].map((input) => qualifyThreadWorktree(input, { claimWorktree: true })), + { concurrency: "unbounded" }, + ); + expect(qa!.commonDir).toBe(qb!.commonDir); + expect(qa!.worktreePath).not.toBe(qb!.worktreePath); + NodeFS.writeFileSync(NodePath.join(a.worktreePath, "own-effect.txt"), "thread-a\n"); + expect(yield* qualifyThreadWorktree(a)).toEqual(qa); + expect(git(root, "status", "--porcelain")).not.toContain("own-effect"); + expect(git(b.worktreePath, "status", "--porcelain")).not.toContain("own-effect"); + }).pipe(Effect.provide(testServices())), + ); + + it.effect("rejects shared checkout, wrong repo, wrong branch, stale and foreign attach", () => + Effect.gen(function* () { + const root = fixture(); + const other = fixture(); + const a = add(root, "thread-a"); + yield* qualifyThreadWorktree(a, { claimWorktree: true }); + for (const input of [ + { ...a, worktreePath: root, branch: "main" }, + { ...a, projectCwd: other }, + { ...a, branch: "other" }, + { ...a, worktreePath: NodePath.join(root, "missing") }, + { ...a, threadId: "foreign" }, + ]) { + const failure = yield* Effect.exit(qualifyThreadWorktree(input)); + expect(Exit.isFailure(failure)).toBe(true); + } + const unclaimed = add(root, "unclaimed"); + const unclaimedFailure = yield* Effect.exit(qualifyThreadWorktree(unclaimed)); + expect(Exit.isFailure(unclaimedFailure)).toBe(true); + git(root, "worktree", "lock", a.worktreePath); + const locked = yield* Effect.exit(qualifyThreadWorktree(a)); + expect(isFailureContaining(locked, "locked")).toBe(true); + }).pipe(Effect.provide(testServices())), + ); + + it.effect("rejects concurrent ownership through symlink aliases without overwriting", () => + Effect.gen(function* () { + const root = fixture(); + const a = add(root, "thread-a"); + yield* qualifyThreadWorktree(a, { claimWorktree: true }); + const alias = NodePath.join(root, "alias"); + NodeFS.symlinkSync(a.worktreePath, alias, "junction"); + const conflict = yield* Effect.exit( + rejectConflictingWorktree("foreign", alias, [ + { id: a.threadId, worktreePath: a.worktreePath, deletedAt: null }, + ]), + ); + expect(isFailureContaining(conflict, "already bound")).toBe(true); + const foreign = yield* Effect.exit( + qualifyThreadWorktree({ ...a, threadId: "foreign" }, { claimWorktree: true }), + ); + expect(Exit.isFailure(foreign)).toBe(true); + expect(yield* qualifyThreadWorktree(a)).toBeDefined(); + }).pipe(Effect.provide(testServices())), + ); + + it.effect("binds an owner marker to the stable server environment identity", () => + Effect.gen(function* () { + const root = fixture(); + const binding = add(root, "environment-bound"); + yield* qualifyThreadWorktree(binding, { claimWorktree: true }); + const otherEnvironment = yield* Effect.exit( + qualifyThreadWorktree(binding, { claimWorktree: true }).pipe( + Effect.provide(testServices("different-worktree-environment")), + ), + ); + expect(isFailureContaining(otherEnvironment, "belongs to another thread")).toBe(true); + expect(yield* qualifyThreadWorktree(binding)).toBeDefined(); + }).pipe(Effect.provide(testServices())), + ); + + it.effect("preserves a dirty canonical checkout's tracked and untracked bytes", () => + Effect.gen(function* () { + const root = fixture(); + NodeFS.writeFileSync(NodePath.join(root, "tracked.txt"), "active unsaved work\n"); + NodeFS.writeFileSync(NodePath.join(root, "untracked.txt"), "user work\n"); + const before = git(root, "status", "--porcelain"); + const head = git(root, "rev-parse", "HEAD"); + const a = add(root, "thread-a"); + yield* qualifyThreadWorktree(a, { claimWorktree: true }); + expect(NodeFS.readFileSync(NodePath.join(root, "tracked.txt"), "utf8")).toBe( + "active unsaved work\n", + ); + expect(NodeFS.readFileSync(NodePath.join(root, "untracked.txt"), "utf8")).toBe("user work\n"); + expect(git(root, "status", "--porcelain")).toBe(before); + expect(git(root, "rev-parse", "HEAD")).toBe(head); + }).pipe(Effect.provide(testServices())), + ); +}); + +describe("coordinator workspace boundary", () => { + it.effect("requires an existing workspace outside the project checkout", () => + Effect.gen(function* () { + const root = fixture(); + const workspace = NodePath.join(root, "..", "coordinator-workspace"); + NodeFS.mkdirSync(workspace); + expect( + yield* qualifyCoordinatorWorkspace(root, workspace, "coordinator-a", { + claimWorkspace: true, + }), + ).toBe(NodeFS.realpathSync(workspace)); + const otherEnvironment = yield* Effect.exit( + qualifyCoordinatorWorkspace(root, workspace, "coordinator-a").pipe( + Effect.provide(testServices("different-coordinator-environment")), + ), + ); + expect(isFailureContaining(otherEnvironment, "belongs to another thread")).toBe(true); + const foreign = yield* Effect.exit( + qualifyCoordinatorWorkspace(root, workspace, "coordinator-b"), + ); + expect(isFailureContaining(foreign, "belongs to another thread")).toBe(true); + const nested = NodePath.join(root, "nested-coordinator"); + NodeFS.mkdirSync(nested); + const dotPrefix = NodePath.join(root, "..foo-coordinator"); + NodeFS.mkdirSync(dotPrefix); + for (const path of [root, nested, dotPrefix]) { + const rejected = yield* Effect.exit(qualifyCoordinatorWorkspace(root, path, "nested")); + expect(isFailureContaining(rejected, "separate from the project checkout")).toBe(true); + } + const missing = yield* Effect.exit(qualifyCoordinatorWorkspace(root, null, "missing")); + expect(isFailureContaining(missing, "dedicated destination-local workspace")).toBe(true); + }).pipe(Effect.provide(testServices())), + ); +}); diff --git a/apps/server/src/project/ThreadWorktree.ts b/apps/server/src/project/ThreadWorktree.ts new file mode 100644 index 000000000000..d83e7d080b9b --- /dev/null +++ b/apps/server/src/project/ThreadWorktree.ts @@ -0,0 +1,290 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as VcsProcessLayer from "../vcs/VcsProcess.ts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { VcsProcess } from "../vcs/VcsProcess.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; + +const threadWorktreeServicesLayer = VcsProcessLayer.layer.pipe( + Layer.provideMerge(NodeServices.layer), +); + +export class ThreadWorktreeError extends Schema.TaggedError()( + "ThreadWorktreeError", + { detail: Schema.String }, +) { + override get message(): string { + return this.detail; + } +} + +interface WorktreeBinding { + readonly threadId: string; + readonly projectCwd: string; + readonly branch: string | null; + readonly worktreePath: string | null; +} + +const WorktreeOwnerSchema = Schema.Struct({ + threadId: Schema.String, + environmentId: Schema.String, + worktreePath: Schema.String, + commonDir: Schema.String, +}); +const WorktreeOwnerJson = Schema.fromJsonString(WorktreeOwnerSchema); +const encodeWorktreeOwner = Schema.encodeSync(WorktreeOwnerJson); +const decodeWorktreeOwner = Schema.decodeUnknownOption(WorktreeOwnerJson); +const CoordinatorOwnerSchema = Schema.Struct({ + threadId: Schema.String, + environmentId: Schema.String, + projectCwd: Schema.String, + workspacePath: Schema.String, +}); +const CoordinatorOwnerJson = Schema.fromJsonString(CoordinatorOwnerSchema); +const encodeCoordinatorOwner = Schema.encodeSync(CoordinatorOwnerJson); +const decodeCoordinatorOwner = Schema.decodeUnknownOption(CoordinatorOwnerJson); + +export const coordinationWorkspacePath = (stateDir: string, threadId: string, path: Path.Path) => + path.join(stateDir, "coordination-workspaces", `thread-${safeThreadPathSegment(threadId)}`); + +export const safeThreadPathSegment = (threadId: string): string => + `id-${Array.from(threadId, (character) => character.codePointAt(0)!.toString(16)).join("-")}`; + +const qualifyCoordinatorWorkspaceWithServices = Effect.fn("qualifyCoordinatorWorkspace")(function* ( + projectCwd: string, + workspacePath: string | null, + threadId: string, + options?: { readonly claimWorkspace?: boolean; readonly environmentId?: string }, +) { + if (!workspacePath) { + return yield* new ThreadWorktreeError({ + detail: "Coordinator threads require a dedicated destination-local workspace.", + }); + } + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const environmentId = + options?.environmentId ?? + (yield* Effect.serviceOption(ServerEnvironment.ServerEnvironmentIdentity).pipe( + Effect.flatMap((identity) => + identity._tag === "Some" + ? identity.value.getEnvironmentId + : Effect.fail( + new ThreadWorktreeError({ + detail: + "Server environment identity is unavailable; workspace ownership cannot be claimed.", + }), + ), + ), + )); + const resolvedWorkspace = yield* fs.realPath(workspacePath); + const resolvedProject = yield* fs.realPath(projectCwd); + const relative = path.relative(resolvedProject, resolvedWorkspace); + const isOutsideProject = + path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`); + if (!isOutsideProject) { + return yield* new ThreadWorktreeError({ + detail: "Coordinator workspace must be separate from the project checkout.", + }); + } + const ownerPath = path.join(resolvedWorkspace, ".t3-thread-owner.json"); + const owner = { + threadId, + environmentId, + projectCwd: resolvedProject, + workspacePath: resolvedWorkspace, + }; + if (options?.claimWorkspace === true) { + yield* fs + .writeFileString(ownerPath, encodeCoordinatorOwner(owner), { flag: "wx", mode: 0o600 }) + .pipe( + Effect.catchIf( + (cause) => cause.reason._tag === "AlreadyExists", + () => Effect.void, + ), + ); + } + const persistedOwner = yield* fs.readFileString(ownerPath).pipe( + Effect.mapError( + () => + new ThreadWorktreeError({ + detail: "Coordinator workspace has no thread owner; create it through the server.", + }), + ), + ); + const decodedOwner = decodeCoordinatorOwner(persistedOwner); + if ( + decodedOwner._tag !== "Some" || + decodedOwner.value.threadId !== owner.threadId || + decodedOwner.value.environmentId !== owner.environmentId || + decodedOwner.value.projectCwd !== owner.projectCwd || + decodedOwner.value.workspacePath !== owner.workspacePath + ) { + return yield* new ThreadWorktreeError({ + detail: "Coordinator workspace belongs to another thread; attach is refused.", + }); + } + return resolvedWorkspace; +}); + +// Read Git's destination-local identity rather than trusting a path sent by another host. +const qualifyWithServices = Effect.fn("qualifyThreadWorktree")(function* ( + input: WorktreeBinding, + options?: { readonly claimWorktree?: boolean; readonly environmentId?: string }, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const vcs = yield* VcsProcess; + const environmentId = + options?.environmentId ?? + (yield* Effect.serviceOption(ServerEnvironment.ServerEnvironmentIdentity).pipe( + Effect.flatMap((identity) => + identity._tag === "Some" + ? identity.value.getEnvironmentId + : Effect.fail( + new ThreadWorktreeError({ + detail: + "Server environment identity is unavailable; workspace ownership cannot be claimed.", + }), + ), + ), + )); + if (!input.worktreePath || !input.branch) { + return yield* new ThreadWorktreeError({ + detail: "Coding requires a dedicated Git worktree and branch before provider execution.", + }); + } + const git = (cwd: string, args: ReadonlyArray) => + vcs + .run({ operation: "ThreadWorktree.qualify", command: "git", args, cwd }) + .pipe(Effect.map((result) => result.stdout.trim())); + const worktreePath = yield* fs.realPath(input.worktreePath); + const projectPath = yield* fs.realPath(input.projectCwd); + const root = yield* fs.realPath(yield* git(worktreePath, ["rev-parse", "--show-toplevel"])); + const commonDir = yield* fs.realPath( + yield* git(worktreePath, ["rev-parse", "--path-format=absolute", "--git-common-dir"]), + ); + const projectCommonDir = yield* fs.realPath( + yield* git(projectPath, ["rev-parse", "--path-format=absolute", "--git-common-dir"]), + ); + const gitDir = yield* fs.realPath(yield* git(worktreePath, ["rev-parse", "--absolute-git-dir"])); + const branch = yield* git(worktreePath, ["symbolic-ref", "--quiet", "--short", "HEAD"]); + if ( + root !== worktreePath || + commonDir !== projectCommonDir || + gitDir === commonDir || + branch !== input.branch + ) { + return yield* new ThreadWorktreeError({ + detail: + "Worktree root, repository or branch does not match the coding thread; shared checkout fallback is refused.", + }); + } + const entries = (yield* git(projectPath, ["worktree", "list", "--porcelain", "-z"])).split( + "\0\0", + ); + let entry: string[] | undefined; + for (const fields of entries.map((item) => item.split("\0"))) { + const listedPath = fields[0]?.startsWith("worktree ") ? fields[0].slice(9) : null; + if ( + listedPath !== null && + (yield* fs.realPath(listedPath).pipe(Effect.orElseSucceed(() => null))) === worktreePath + ) { + entry = fields; + break; + } + } + if (!entry || entry.some((field) => field.startsWith("locked") || field.startsWith("prunable"))) { + return yield* new ThreadWorktreeError({ detail: "Worktree is missing, locked or stale." }); + } + const ownerPath = path.join(gitDir, "t3-thread-owner.json"); + const owner = { threadId: input.threadId, environmentId, worktreePath, commonDir }; + if (options?.claimWorktree === true) { + // Explicit create/attach paths may claim an unowned checkout. Exclusive + // creation makes competing attach attempts choose one owner atomically. + yield* fs + .writeFileString(ownerPath, encodeWorktreeOwner(owner), { flag: "wx", mode: 0o600 }) + .pipe( + Effect.catchIf( + (cause) => cause.reason._tag === "AlreadyExists", + () => Effect.void, + ), + ); + } + const persistedOwner = yield* fs.readFileString(ownerPath).pipe( + Effect.mapError( + () => + new ThreadWorktreeError({ + detail: "Worktree has no thread owner; attach it through a thread create operation.", + }), + ), + ); + const decodedOwner = decodeWorktreeOwner(persistedOwner); + if ( + decodedOwner._tag !== "Some" || + decodedOwner.value.threadId !== owner.threadId || + decodedOwner.value.environmentId !== owner.environmentId || + decodedOwner.value.worktreePath !== owner.worktreePath || + decodedOwner.value.commonDir !== owner.commonDir + ) { + return yield* new ThreadWorktreeError({ + detail: "Worktree belongs to another thread; attach is refused.", + }); + } + return { worktreePath, branch, commonDir }; +}); + +// Serialization belongs to the existing engine queue. Never allocate on a turn/callback. +const rejectConflictingWithServices = Effect.fn("rejectConflictingWorktree")(function* ( + threadId: string, + worktreePath: string, + threads: ReadonlyArray<{ + readonly id: string; + readonly worktreePath: string | null; + readonly deletedAt: string | null; + }>, +) { + const fs = yield* FileSystem.FileSystem; + const claimedPath = yield* fs.realPath(worktreePath); + for (const thread of threads) { + if (thread.id === threadId || thread.deletedAt !== null || thread.worktreePath === null) + continue; + const peerPath = yield* fs + .realPath(thread.worktreePath) + .pipe(Effect.orElseSucceed(() => thread.worktreePath)); + if (peerPath === claimedPath) { + return yield* new ThreadWorktreeError({ + detail: `Worktree is already bound to thread '${thread.id}'.`, + }); + } + } +}); + +// This is a destination-local filesystem/process boundary. Provision the existing +// platform/VCS implementation here so unrelated engine clients need no new service. +export const qualifyThreadWorktree = ( + input: WorktreeBinding, + options?: { readonly claimWorktree?: boolean; readonly environmentId?: string }, +) => + qualifyWithServices(input, options).pipe( + Effect.provide(threadWorktreeServicesLayer), + Effect.scoped, + ); + +export const qualifyCoordinatorWorkspace = ( + projectCwd: string, + workspacePath: string | null, + threadId: string, + options?: { readonly claimWorkspace?: boolean; readonly environmentId?: string }, +) => + qualifyCoordinatorWorkspaceWithServices(projectCwd, workspacePath, threadId, options).pipe( + Effect.provide(NodeServices.layer), + ); + +export const rejectConflictingWorktree = ( + ...input: Parameters +) => rejectConflictingWithServices(...input).pipe(Effect.provide(NodeServices.layer)); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 4b5b7c816be3..f9730d7aef66 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -367,6 +367,16 @@ const makeDefaultOrchestrationReadModel = () => { ], }; }; +const bootstrapProjectShell = { + ...makeDefaultOrchestrationReadModel().projects[0]!, + workspaceRoot: "/tmp/project", +}; +const bootstrapProjectSnapshotQuery = { + getProjectShellById: (projectId: ProjectId) => + Effect.succeed( + projectId === defaultProjectId ? Option.some(bootstrapProjectShell) : Option.none(), + ), +}; const makeDefaultOrchestrationThreadShell = ( overrides: Partial = {}, @@ -545,6 +555,7 @@ const buildAppUnderTest = (options?: { ProviderSessionDirectory.ProviderSessionDirectory["Service"] >; terminalManager?: Partial; + worktreeSetupTracker?: Partial; orchestrationEngine?: Partial; threadDeletionReactor?: Partial; analyticsService?: Partial; @@ -730,6 +741,17 @@ const buildAppUnderTest = (options?: { Layer.provideMerge(gitVcsDriverLayer), Layer.provideMerge(gitManagerLayer), ); + const worktreeSetupTrackerLayer = options?.layers?.worktreeSetupTracker + ? Layer.effect( + WorktreeSetupTracker.WorktreeSetupTracker, + WorktreeSetupTracker.make.pipe( + Effect.map((tracker) => ({ + ...tracker, + ...options.layers?.worktreeSetupTracker, + })), + ), + ) + : WorktreeSetupTracker.layer; const vcsProvisioningLayer = VcsProvisioningService.layer.pipe( Layer.provide(vcsDriverRegistryLayer), ); @@ -949,7 +971,7 @@ const buildAppUnderTest = (options?: { Layer.mock(TerminalManager.TerminalManager)({ ...options?.layers?.terminalManager, }), - WorktreeSetupTracker.layer, + worktreeSetupTrackerLayer, ProjectCloneTracker.layer.pipe( Layer.provide( Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ @@ -1158,11 +1180,16 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(ServerEnvironment.ServerEnvironment)({ - getEnvironmentId: Effect.succeed(testEnvironmentDescriptor.environmentId), - getDescriptor: Effect.succeed(testEnvironmentDescriptor), - ...options?.layers?.serverEnvironment, - }), + Layer.mergeAll( + Layer.mock(ServerEnvironment.ServerEnvironment)({ + getEnvironmentId: Effect.succeed(testEnvironmentDescriptor.environmentId), + getDescriptor: Effect.succeed(testEnvironmentDescriptor), + ...options?.layers?.serverEnvironment, + }), + Layer.mock(ServerEnvironment.ServerEnvironmentIdentity)({ + getEnvironmentId: Effect.succeed(testEnvironmentDescriptor.environmentId), + }), + ), ), Layer.provide( Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({ @@ -7480,6 +7507,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { modelSelection: defaultModelSelection, runtimeMode: "full-access", interactionMode: "default", + executionScope: "coordinator", branch: null, worktreePath: null, createdAt: "2026-01-01T00:00:00.000Z", @@ -10965,21 +10993,24 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }; }), ); - const createWorktree = vi.fn( - (_: Parameters[0]) => + const createWorktree = vi.fn( + ( + _: Parameters[0], + _options?: Parameters[1], + ) => Effect.sync(() => { bootstrapGitOperations.push("create-worktree"); return { worktree: { refName: "t3code/bootstrap-refName", - path: "/tmp/bootstrap-worktree", + path: _.path!, }, }; }), ); const runForThread = vi.fn( ( - _: Parameters< + input: Parameters< ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]["runForThread"] >[0], ) => @@ -10989,13 +11020,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { scriptName: "Setup", scriptCommand: "npm install", terminalId: "setup-setup", - cwd: "/tmp/bootstrap-worktree", + cwd: input.worktreePath, async: true, }), ); yield* buildAppUnderTest({ layers: { + projectionSnapshotQuery: bootstrapProjectSnapshotQuery, vcsDriver: { isInsideWorkTree: () => Effect.succeed(true), }, @@ -11134,13 +11166,27 @@ it.layer(NodeServices.layer)("server router seam", (it) => { defaultModelSelection.instanceId, ); } - assert.deepEqual(createWorktree.mock.calls[0]?.[0], { - cwd: "/tmp/project", - refName: fetchedOriginCommit, - newRefName: "t3code/bootstrap-refName", - baseRefName: "main", - path: null, - }); + const createWorktreeInput = createWorktree.mock.calls[0]?.[0]; + assert.deepEqual( + createWorktreeInput && { + cwd: createWorktreeInput.cwd, + refName: createWorktreeInput.refName, + newRefName: createWorktreeInput.newRefName, + baseRefName: createWorktreeInput.baseRefName, + }, + { + cwd: "/tmp/project", + refName: fetchedOriginCommit, + newRefName: "t3code/bootstrap-refName", + baseRefName: "main", + }, + ); + assert.equal( + createWorktreeInput?.path, + dispatchedCommands[0]?.type === "thread.create" + ? dispatchedCommands[0].worktreePath + : undefined, + ); assert.deepEqual(fetchRemote.mock.calls[0]?.[0], { cwd: "/tmp/project", remoteName: "origin", @@ -11175,12 +11221,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { threadId: ThreadId.make("thread-bootstrap"), projectId: defaultProjectId, projectCwd: "/tmp/project", - worktreePath: "/tmp/bootstrap-worktree", + worktreePath: createWorktreeInput!.path!, }, ); // Worktree bootstraps observe script completion so the setup card can show the exit code. assert.isDefined(runForThreadInput?.observeCompletion); - assert.deepEqual(refreshStatus.mock.calls[0]?.[0], "/tmp/bootstrap-worktree"); + assert.deepEqual(refreshStatus.mock.calls[0]?.[0], createWorktreeInput?.path); const setupActivities = dispatchedCommands.filter( (command): command is Extract => @@ -11250,6 +11296,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { + projectionSnapshotQuery: bootstrapProjectSnapshotQuery, vcsDriver: { isInsideWorkTree: () => Effect.succeed(true), }, @@ -11326,13 +11373,22 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }); } assert.equal(resolveRemoteTrackingCommit.mock.calls.length, 0); - assert.deepEqual(createWorktree.mock.calls[0]?.[0], { - cwd: "/tmp/project", - refName: "main", - newRefName: "t3code/bootstrap-refName", - baseRefName: "main", - path: null, - }); + const createWorktreeInput = createWorktree.mock.calls[0]?.[0]; + assert.deepEqual( + createWorktreeInput && { + cwd: "/tmp/project", + refName: "main", + newRefName: "t3code/bootstrap-refName", + baseRefName: "main", + }, + { + cwd: "/tmp/project", + refName: "main", + newRefName: "t3code/bootstrap-refName", + baseRefName: "main", + }, + ); + assert.isString(createWorktreeInput?.path); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -11352,6 +11408,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { + projectionSnapshotQuery: bootstrapProjectSnapshotQuery, vcsDriver: { isInsideWorkTree: () => Effect.succeed(isRepository), }, @@ -11432,7 +11489,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("falls back to the project checkout when worktree mode targets a non-repository", () => + it.effect("fails closed when worktree mode targets a non-repository", () => Effect.gen(function* () { const dispatchedCommands: Array = []; const createWorktree = vi.fn( @@ -11442,6 +11499,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { + projectionSnapshotQuery: bootstrapProjectSnapshotQuery, gitVcsDriver: { execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION), createWorktree, @@ -11459,7 +11517,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const createdAt = "2026-01-01T00:00:00.000Z"; const wsUrl = yield* getWsServerUrl("/ws"); - const response = yield* Effect.scoped( + const result = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ type: "thread.turn.start", @@ -11489,35 +11547,31 @@ it.layer(NodeServices.layer)("server router seam", (it) => { projectCwd: "/tmp/project", baseBranch: "main", branch: "t3code/bootstrap-refName", + requireWorktree: true, }, runSetupScript: true, }, createdAt, }), - ), + ).pipe(Effect.result), ); - assert.equal(response.sequence, 4); + assertTrue(result._tag === "Failure"); + assertTrue(result.failure._tag === "OrchestrationDispatchCommandError"); + assert.include(result.failure.message, "separate worktree requires"); assert.equal(createWorktree.mock.calls.length, 0); - assert.deepEqual( + assert.notInclude( dispatchedCommands.map((command) => command.type), - [ - "thread.create", - "thread.message.user.append", - "thread.activity.append", - "thread.turn.start", - "thread.activity.append", - ], + "thread.create", + ); + assert.notInclude( + dispatchedCommands.map((command) => command.type), + "thread.turn.start", ); - const finalCommand = dispatchedCommands[3]; - assertTrue(finalCommand?.type === "thread.turn.start"); - if (finalCommand?.type === "thread.turn.start") { - assert.equal(finalCommand.bootstrap, undefined); - } }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("falls back to the project checkout when the worktree base has no commit", () => + it.effect("fails closed when the worktree base has no commit", () => Effect.gen(function* () { const dispatchedCommands: Array = []; const createWorktree = vi.fn( @@ -11527,6 +11581,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { + projectionSnapshotQuery: bootstrapProjectSnapshotQuery, vcsDriver: { isInsideWorkTree: () => Effect.succeed(true), }, @@ -11552,7 +11607,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const createdAt = "2026-01-01T00:00:00.000Z"; const wsUrl = yield* getWsServerUrl("/ws"); - const response = yield* Effect.scoped( + const result = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ type: "thread.turn.start", @@ -11582,29 +11637,140 @@ it.layer(NodeServices.layer)("server router seam", (it) => { projectCwd: "/tmp/project", baseBranch: "main", branch: "t3code/bootstrap-refName", + requireWorktree: true, }, runSetupScript: true, }, createdAt, }), - ), + ).pipe(Effect.result), ); - assert.equal(response.sequence, 4); + assertTrue(result._tag === "Failure"); + assertTrue(result.failure._tag === "OrchestrationDispatchCommandError"); + assert.include(result.failure.message, "separate worktree requires"); assert.equal(createWorktree.mock.calls.length, 0); - assert.deepEqual( + assert.notInclude( dispatchedCommands.map((command) => command.type), - [ - "thread.create", - "thread.message.user.append", - "thread.activity.append", - "thread.turn.start", - "thread.activity.append", - ], + "thread.create", + ); + assert.notInclude( + dispatchedCommands.map((command) => command.type), + "thread.turn.start", ); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect( + "preserves the accepted thread and worktree when setup tracking fails after handoff", + () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const fileSystem = yield* FileSystem.FileSystem; + const createdWorktreePaths: Array = []; + const removedWorktreePaths: Array = []; + const createWorktree = vi.fn( + ( + input: Parameters[0], + _options?: Parameters[1], + ) => + fileSystem.makeDirectory(input.path!, { recursive: true }).pipe( + Effect.orDie, + Effect.tap(() => Effect.sync(() => createdWorktreePaths.push(input.path!))), + Effect.as({ + worktree: { refName: input.newRefName!, path: input.path! }, + }), + ), + ); + const removeWorktree = vi.fn( + (input: Parameters[0]) => + Effect.sync(() => removedWorktreePaths.push(input.path)), + ); + const config = yield* buildAppUnderTest({ + layers: { + projectionSnapshotQuery: bootstrapProjectSnapshotQuery, + vcsDriver: { isInsideWorkTree: () => Effect.succeed(true) }, + gitVcsDriver: { + execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION), + createWorktree, + removeWorktree, + }, + worktreeSetupTracker: { + stageStatus: (_threadId, stageId, status) => + stageId === "agent" && status === "done" ? Effect.interrupt : Effect.void, + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + }, + }); + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-accepted-handoff"), + threadId: ThreadId.make("thread-bootstrap-accepted-handoff"), + message: { + messageId: MessageId.make("msg-bootstrap-accepted-handoff"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Accepted bootstrap", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "t3code/accepted-handoff", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "main", + branch: "t3code/accepted-handoff", + requireWorktree: true, + }, + runSetupScript: false, + }, + createdAt, + }), + ).pipe(Effect.result), + ); + + assertTrue(result._tag === "Failure"); + assertTrue(result.failure._tag === "OrchestrationDispatchCommandError"); + assert.include(result.failure.message, "cancelled"); + assert.isUndefined(result.failure.bootstrapThreadDisposition); + assert.include( + dispatchedCommands.map((command) => command.type), + "thread.turn.start", + ); + assert.notInclude( + dispatchedCommands.map((command) => command.type), + "thread.delete", + ); + assert.deepEqual(removedWorktreePaths, []); + assert.equal(createdWorktreePaths.length, 1); + assert.isTrue(yield* fileSystem.exists(createdWorktreePaths[0]!)); + assert.isTrue(yield* fileSystem.exists(config.worktreesDir)); + assert.equal(createWorktree.mock.calls.length, 1); + assert.equal(removeWorktree.mock.calls.length, 0); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("records setup-script failures without aborting bootstrap turn start", () => Effect.gen(function* () { const dispatchedCommands: Array = []; @@ -11635,6 +11801,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { + projectionSnapshotQuery: bootstrapProjectSnapshotQuery, vcsDriver: { isInsideWorkTree: () => Effect.succeed(true), }, @@ -11755,6 +11922,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { + projectionSnapshotQuery: bootstrapProjectSnapshotQuery, vcsDriver: { isInsideWorkTree: () => Effect.succeed(true), }, @@ -11901,6 +12069,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { + projectionSnapshotQuery: bootstrapProjectSnapshotQuery, vcsDriver: { isInsideWorkTree: () => Effect.succeed(true), }, @@ -12048,7 +12217,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("cleans up created bootstrap threads when worktree creation defects", () => + it.effect("does not create a bootstrap thread when worktree creation defects", () => Effect.gen(function* () { const dispatchedCommands: Array = []; const fileSystem = yield* FileSystem.FileSystem; @@ -12060,6 +12229,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const config = yield* buildAppUnderTest({ layers: { + projectionSnapshotQuery: bootstrapProjectSnapshotQuery, vcsDriver: { isInsideWorkTree: () => Effect.succeed(true), }, @@ -12143,17 +12313,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assertTrue(result._tag === "Failure"); assertTrue(result.failure._tag === "OrchestrationDispatchCommandError"); assert.include(result.failure.message, "worktree exploded"); - assert.strictEqual(result.failure.bootstrapThreadDisposition, "deleted"); - assert.deepEqual( + assert.strictEqual(result.failure.bootstrapThreadDisposition, "not-created"); + assert.notInclude( dispatchedCommands.map((command) => command.type), - [ - "thread.create", - "thread.message.user.append", - "thread.activity.append", - "thread.session.set", - "thread.activity.append", - "thread.delete", - ], + "thread.create", + ); + assert.notInclude( + dispatchedCommands.map((command) => command.type), + "thread.turn.start", ); assert.isDefined(pendingAttachmentId); assert.isTrue( @@ -12176,6 +12343,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const cleanupDone = yield* Deferred.make(); yield* buildAppUnderTest({ layers: { + projectionSnapshotQuery: bootstrapProjectSnapshotQuery, threadDeletionReactor: { drainThrough: (sequence) => Effect.gen(function* () { @@ -12212,6 +12380,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { modelSelection: defaultModelSelection, runtimeMode: "full-access", interactionMode: "default", + executionScope: "coordinator", branch: null, worktreePath: null, createdAt, @@ -12253,6 +12422,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { modelSelection: defaultModelSelection, runtimeMode: "full-access", interactionMode: "default", + executionScope: "coordinator", branch: null, worktreePath: null, createdAt, @@ -12275,7 +12445,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("does not report a deleted bootstrap thread when cleanup fails", () => + it.effect("does not persist a thread when bootstrap worktree creation fails", () => Effect.gen(function* () { const dispatchedCommands: Array = []; const createWorktree = vi.fn( @@ -12285,6 +12455,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { + projectionSnapshotQuery: bootstrapProjectSnapshotQuery, vcsDriver: { isInsideWorkTree: () => Effect.succeed(true), }, @@ -12353,27 +12524,15 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assertTrue(result._tag === "Failure"); assertTrue(result.failure._tag === "OrchestrationDispatchCommandError"); assert.include(result.failure.message, "worktree exploded"); - assert.strictEqual(result.failure.bootstrapThreadDisposition, undefined); - assert.deepEqual( + assert.strictEqual(result.failure.bootstrapThreadDisposition, "not-created"); + assert.notInclude( dispatchedCommands.map((command) => command.type), - [ - "thread.create", - "thread.message.user.append", - "thread.activity.append", - "thread.session.set", - "thread.activity.append", - "thread.delete", - "thread.session.set", - ], + "thread.create", + ); + assert.notInclude( + dispatchedCommands.map((command) => command.type), + "thread.turn.start", ); - // The surviving thread must not keep its preparing session, or it would - // read as working forever. - const failedSession = dispatchedCommands[6]; - assertTrue(failedSession?.type === "thread.session.set"); - if (failedSession?.type === "thread.session.set") { - assert.equal(failedSession.session.status, "error"); - assert.include(failedSession.session.lastError ?? "", "worktree exploded"); - } }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index ab654e15c70b..3e08091bf7f0 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -256,7 +256,7 @@ it.effect.each([ machineMode: "full-access", projectMode: "auto-accept-edits", }, -] as const)("auto-bootstrap model and permissions precedence: %j", (options) => +] as const)("does not create an unassigned startup thread before user intent: %j", (options) => Effect.gen(function* () { const { existing, machineModel, projectModel, machineMode, projectMode } = options; const machineSelection = machineModel @@ -348,23 +348,13 @@ it.effect.each([ ); assert.equal(typeof targets.bootstrapProjectId, "string"); - assert.equal(typeof targets.bootstrapThreadId, "string"); + assert.equal(targets.bootstrapThreadId, undefined); assert.equal(targets.bootstrapProjectCreated, !existing); - assert.equal(targets.bootstrapThreadCreated, true); + assert.equal(targets.bootstrapThreadCreated, undefined); const commands = yield* Ref.get(dispatchCalls); assert.deepStrictEqual( commands.map((command) => command.type), - existing ? ["thread.create"] : ["project.create", "thread.create"], - ); - if (!existing) assert.equal("defaultModelSelection" in commands[0]!, false); - assert.equal(commands.at(-1)?.runtimeMode, projectMode ?? machineMode); - assert.deepStrictEqual( - commands.at(-1)?.modelSelection, - projectSelection ?? - machineSelection ?? { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }, + existing ? [] : ["project.create"], ); }), ); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 5165ecffa42a..212ede2a0364 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -1,14 +1,10 @@ import { CommandId, EventId, - DEFAULT_MODEL, - DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_SERVER_SETTINGS, type ServerSettings as ServerSettingsValue, - type ModelSelection, type OrchestrationProjectShell, ProjectId, - ProviderInstanceId, ThreadId, TurnId, WORKTREE_SETUP_ACTIVITY_KIND, @@ -177,11 +173,6 @@ const recordStartupHeartbeat = Effect.gen(function* () { }); }); -const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({ - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, -}); - export const resolveWelcomeBase = Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const segments = serverConfig.cwd.split(/[/\\]/).filter(Boolean); @@ -207,21 +198,16 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { let bootstrapThreadCreated = false; if (serverConfig.autoBootstrapProjectFromCwd) { - const settings = yield* (yield* ServerSettings.ServerSettingsService).getSettings; - const defaultModelSelection = - settings.defaultModelSelection ?? getAutoBootstrapThreadModelSelection(); yield* Effect.gen(function* () { const existingProject = yield* projectionReadModelQuery.getActiveProjectByWorkspaceRoot( serverConfig.cwd, ); let nextProjectId: ProjectId; - let nextThreadModelSelection: ModelSelection; if (Option.isNone(existingProject)) { const createdAt = DateTime.formatIso(yield* DateTime.now); nextProjectId = ProjectId.make(yield* randomUUID); const bootstrapProjectTitle = path.basename(serverConfig.cwd) || "project"; - nextThreadModelSelection = defaultModelSelection; yield* orchestrationEngine.dispatch({ type: "project.create", commandId: CommandId.make(yield* randomUUID), @@ -235,34 +221,18 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { } else { nextProjectId = existingProject.value.id; bootstrapProjectId = nextProjectId; - nextThreadModelSelection = - resolveProjectSettings(settings, nextProjectId, existingProject.value).settings - .defaultModelSelection ?? defaultModelSelection; } yield* Effect.gen(function* () { const existingThreadId = yield* projectionReadModelQuery.getFirstActiveThreadIdByProjectId(nextProjectId); if (Option.isNone(existingThreadId)) { - const createdAt = DateTime.formatIso(yield* DateTime.now); - const createdThreadId = ThreadId.make(yield* randomUUID); - yield* orchestrationEngine.dispatch({ - type: "thread.create", - commandId: CommandId.make(yield* randomUUID), - threadId: createdThreadId, - projectId: nextProjectId, - title: "New thread", - modelSelection: nextThreadModelSelection, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: resolveProjectSettings(settings, nextProjectId).settings - .defaultRuntimeMode, - branch: null, - worktreePath: null, - createdAt, - }); - bootstrapThreadId = createdThreadId; - bootstrapThreadCreated = true; - } else { + // Leave the first thread as a local draft. The client knows whether + // Coding can allocate a destination worktree and can select the + // explicit Coordinator exception for a non-Git destination. + return; + } + if (Option.isSome(existingThreadId)) { bootstrapThreadId = existingThreadId.value; } }).pipe( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index e38cad7aaca7..241fa1983c9e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,3 +1,4 @@ +import { coordinationWorkspacePath, safeThreadPathSegment } from "./project/ThreadWorktree.ts"; import { sameUsageLimitCommandCoverage, withUsageLimitsCommands, @@ -15,6 +16,7 @@ import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; +import * as Semaphore from "effect/Semaphore"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { @@ -178,6 +180,32 @@ import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; + +const threadCreateLeases = new Map< + string, + { readonly semaphore: Semaphore.Semaphore; users: number } +>(); +const withThreadCreateLease = ( + threadId: string, + effect: Effect.Effect, +): Effect.Effect => + Effect.suspend(() => { + const lease = threadCreateLeases.get(threadId) ?? { + semaphore: Semaphore.makeUnsafe(1), + users: 0, + }; + threadCreateLeases.set(threadId, lease); + lease.users++; + return lease.semaphore.withPermit(effect).pipe( + Effect.ensuring( + Effect.sync(() => { + lease.users--; + if (lease.users === 0) threadCreateLeases.delete(threadId); + }), + ), + ); + }); + const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -1088,12 +1116,111 @@ const makeWsRpcLayer = ( "A destination-bound callback cannot use thread bootstrap. No setup or message was ingested; submit it to the existing destination thread after correcting its route.", }); } - const bootstrap = command.bootstrap; + let bootstrap = command.bootstrap; + if (bootstrap?.createThread) { + const create = bootstrap.createThread; + const scope = create.executionScope ?? "coding"; + const project = yield* projectionSnapshotQuery + .getProjectShellById(create.projectId) + .pipe( + Effect.map(Option.getOrNull), + Effect.mapError((cause) => + toDispatchCommandError(cause, "Cannot resolve destination project"), + ), + ); + if (!project) + return yield* new OrchestrationDispatchCommandError({ + message: "Destination project is missing.", + }); + if (scope === "coordinator") { + if (bootstrap.prepareWorktree) { + return yield* new OrchestrationDispatchCommandError({ + message: "Coordinator threads cannot request a repository worktree.", + }); + } + const cwd = yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = coordinationWorkspacePath(config.stateDir, command.threadId, path); + yield* fs.makeDirectory(cwd, { recursive: true }); + return cwd; + }).pipe( + Effect.provide(normalizerContext), + Effect.mapError((cause) => + toDispatchCommandError(cause, "Cannot prepare coordinator workspace"), + ), + ); + bootstrap = { + ...bootstrap, + createThread: { + ...create, + executionScope: scope, + branch: null, + worktreePath: cwd, + }, + }; + } else { + if ( + bootstrap.prepareWorktree && + bootstrap.prepareWorktree.projectCwd !== project.workspaceRoot + ) { + return yield* new OrchestrationDispatchCommandError({ + message: + "Worktree allocation must use the destination project's local repository path.", + }); + } + if (create.worktreePath === null) { + bootstrap = { + ...bootstrap, + createThread: { ...create, executionScope: scope }, + prepareWorktree: { + ...(bootstrap.prepareWorktree ?? { + projectCwd: project.workspaceRoot, + baseBranch: create.branch ?? "HEAD", + branch: `t3/thread-${safeThreadPathSegment(command.threadId)}`, + }), + requireWorktree: true, + }, + }; + } else { + bootstrap = { ...bootstrap, createThread: { ...create, executionScope: scope } }; + } + } + } else if (bootstrap?.prepareWorktree) { + const thread = yield* projectionSnapshotQuery.getThreadShellById(command.threadId).pipe( + Effect.map(Option.getOrNull), + Effect.mapError((cause) => + toDispatchCommandError(cause, "Cannot resolve destination thread"), + ), + ); + if (thread?.executionScope === "coding") { + const project = yield* projectionSnapshotQuery + .getProjectShellById(thread.projectId) + .pipe( + Effect.map(Option.getOrNull), + Effect.mapError((cause) => + toDispatchCommandError(cause, "Cannot resolve destination project"), + ), + ); + if (!project || project.workspaceRoot !== bootstrap.prepareWorktree.projectCwd) { + return yield* new OrchestrationDispatchCommandError({ + message: + "Worktree allocation must use the destination project's local repository path.", + }); + } + bootstrap = { + ...bootstrap, + prepareWorktree: { ...bootstrap.prepareWorktree, requireWorktree: true }, + }; + } + } const { bootstrap: _bootstrap, ...finalTurnStartCommand } = command; let createdThread = false; let targetProjectId = bootstrap?.createThread?.projectId; let targetProjectCwd = bootstrap?.prepareWorktree?.projectCwd; let targetWorktreePath = bootstrap?.createThread?.worktreePath ?? null; + let allocatedWorktreeThisAttempt = false; + let acceptedHandoff = false; // The setup script's terminal, once started. Cancel closes only this // one so terminals the user opened meanwhile survive. let setupTerminalId: string | null = null; @@ -1123,7 +1250,7 @@ const makeWsRpcLayer = ( }); }); const cleanupCreatedThread = () => - createdThread + createdThread && !acceptedHandoff ? serverCommandId("bootstrap-thread-delete").pipe( Effect.flatMap((commandId) => dispatchFromClient({ @@ -1429,7 +1556,10 @@ const makeWsRpcLayer = ( ); } - if (bootstrap?.createThread) { + if ( + bootstrap?.createThread && + (bootstrap.createThread.executionScope === "coordinator" || !prepareWorktree) + ) { const created = yield* dispatchFromClient({ type: "thread.create", commandId: yield* serverCommandId("bootstrap-thread-create"), @@ -1441,6 +1571,7 @@ const makeWsRpcLayer = ( interactionMode: bootstrap.createThread.interactionMode, branch: bootstrap.createThread.branch, worktreePath: bootstrap.createThread.worktreePath, + executionScope: bootstrap.createThread.executionScope ?? "coding", createdAt: bootstrap.createThread.createdAt, }); // The successful create is a fence in the engine command queue: @@ -1506,74 +1637,88 @@ const makeWsRpcLayer = ( threadId, projectId: targetProjectId ?? null, }); - const worktree = yield* gitWorkflow.createWorktree( - { - cwd: prepareWorktree.projectCwd, - refName: worktreeBaseRef, - newRefName: prepareWorktree.branch, - baseRefName: prepareWorktree.baseBranch, - path: null, - }, - { - submodules, - progress: { - // Git has registered the directory at this point, so a - // cancel during the submodule step can still remove it. - onWorktreeClaimed: (path) => - Effect.sync(() => { - targetWorktreePath = path; - }), - onCheckoutProgress: ({ percent, completed, total }) => { - checkoutTotal = total; - return worktreeSetupTracker.stage(threadId, "checkout", { - percent, - detail: `${completed.toLocaleString("en-US")} / ${total.toLocaleString("en-US")} files`, - }); - }, - onSubmodulesStarted: () => - worktreeSetupTracker - .stageStatus( - threadId, - "checkout", - "done", - checkoutTotal === null - ? null - : `${checkoutTotal.toLocaleString("en-US")} files`, - ) - .pipe( - Effect.andThen( - worktreeSetupTracker.stageStatus(threadId, "submodules", "running"), + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + let branch = prepareWorktree.branch ?? `t3/thread-${safeThreadPathSegment(threadId)}`; + let worktreePath = path.join( + config.worktreesDir, + path.basename(prepareWorktree.projectCwd), + branch.replaceAll("/", "-"), + ); + const existingWorktree = yield* fs.exists(worktreePath); + if (!existingWorktree) { + const worktree = yield* gitWorkflow.createWorktree( + { + cwd: prepareWorktree.projectCwd, + refName: worktreeBaseRef, + newRefName: branch, + baseRefName: prepareWorktree.baseBranch, + path: worktreePath, + }, + { + submodules, + progress: { + // Git has registered the directory at this point, so a + // cancel during the submodule step can still remove it. + onWorktreeClaimed: (path) => + Effect.sync(() => { + targetWorktreePath = path; + allocatedWorktreeThisAttempt = true; + }), + onCheckoutProgress: ({ percent, completed, total }) => { + checkoutTotal = total; + return worktreeSetupTracker.stage(threadId, "checkout", { + percent, + detail: `${completed.toLocaleString("en-US")} / ${total.toLocaleString("en-US")} files`, + }); + }, + onSubmodulesStarted: () => + worktreeSetupTracker + .stageStatus( + threadId, + "checkout", + "done", + checkoutTotal === null + ? null + : `${checkoutTotal.toLocaleString("en-US")} files`, + ) + .pipe( + Effect.andThen( + worktreeSetupTracker.stageStatus(threadId, "submodules", "running"), + ), ), + onSubmodulesDisabled: ({ source }) => + worktreeSetupTracker.stageStatus( + threadId, + "submodules", + "skipped", + `disabled in ${source}`, + ), + onSubmoduleLine: (line) => { + const submodulePath = /Submodule path '([^']+)'/.exec(line)?.[1]; + return submodulePath === undefined + ? Effect.void + : worktreeSetupTracker.stage(threadId, "submodules", { + detail: submodulePath, + }); + }, + onSubmodulesFinished: ({ ok, detail }) => + worktreeSetupTracker.stageStatus( + threadId, + "submodules", + ok ? "done" : "warning", + ok ? undefined : (detail ?? "submodule checkout failed"), ), - onSubmodulesDisabled: ({ source }) => - worktreeSetupTracker.stageStatus( - threadId, - "submodules", - "skipped", - `disabled in ${source}`, - ), - onSubmoduleLine: (line) => { - const submodulePath = /Submodule path '([^']+)'/.exec(line)?.[1]; - return submodulePath === undefined - ? Effect.void - : worktreeSetupTracker.stage(threadId, "submodules", { - detail: submodulePath, - }); }, - onSubmodulesFinished: ({ ok, detail }) => - worktreeSetupTracker.stageStatus( - threadId, - "submodules", - ok ? "done" : "warning", - ok ? undefined : (detail ?? "submodule checkout failed"), - ), }, - }, - ); + ); + branch = worktree.worktree.refName; + worktreePath = worktree.worktree.path; + } const checkoutEndedAt = yield* nowIso; yield* worktreeSetupTracker.update(threadId, (snapshot) => ({ ...snapshot, - worktreePath: worktree.worktree.path, + worktreePath, stages: snapshot.stages.map((stage) => { if (stage.id === "checkout" && stage.status === "running") { return { @@ -1593,12 +1738,66 @@ const makeWsRpcLayer = ( return stage; }), })); - targetWorktreePath = worktree.worktree.path; + targetWorktreePath = worktreePath; + if (bootstrap?.createThread && !createdThread) { + const created = yield* dispatchFromClient({ + type: "thread.create", + commandId: yield* serverCommandId("bootstrap-thread-create"), + threadId: command.threadId, + projectId: bootstrap.createThread.projectId, + title: bootstrap.createThread.title, + modelSelection: bootstrap.createThread.modelSelection, + runtimeMode: bootstrap.createThread.runtimeMode, + interactionMode: bootstrap.createThread.interactionMode, + branch, + worktreePath: targetWorktreePath, + executionScope: "coding", + createdAt: bootstrap.createThread.createdAt, + }); + createdThread = true; + yield* threadDeletionReactor.drainThrough(created.sequence); + yield* dispatchFromClient({ + type: "thread.message.user.append", + commandId: yield* serverCommandId("bootstrap-thread-message"), + threadId: command.threadId, + message: { + messageId: command.message.messageId, + text: command.message.text, + attachments: command.message.attachments, + ...(command.message.context !== undefined + ? { context: command.message.context } + : {}), + }, + createdAt: command.createdAt, + }); + if (tracked) { + const running = yield* worktreeSetupTracker.get(threadId); + if (running) yield* recordWorktreeSetup(running); + const preparingAt = yield* nowIso; + yield* dispatchFromClient({ + type: "thread.session.set", + commandId: yield* serverCommandId("bootstrap-thread-preparing"), + threadId, + session: { + threadId, + status: "starting", + providerName: null, + providerInstanceId: bootstrap.createThread.modelSelection.instanceId, + runtimeMode: command.runtimeMode, + activeTurnId: null, + lastError: null, + updatedAt: preparingAt, + }, + createdAt: preparingAt, + }); + preparingSessionSet = true; + } + } yield* dispatchFromClient({ type: "thread.meta.update", commandId: yield* serverCommandId("bootstrap-thread-meta-update"), threadId, - branch: worktree.worktree.refName, + branch, worktreePath: targetWorktreePath, }); yield* refreshGitStatus(targetWorktreePath); @@ -1613,6 +1812,9 @@ const makeWsRpcLayer = ( const started = yield* Effect.uninterruptible( dispatchFromClient(finalTurnStartCommand), ); + // The agent has accepted the turn. Later setup-card bookkeeping + // must not roll back an accepted thread or its worktree. + acceptedHandoff = true; yield* track(worktreeSetupTracker.stageStatus(threadId, "agent", "done")); // An async setup script outlives the handoff: the snapshot stays // running so the client keeps its row next to the agent's work, @@ -1699,7 +1901,10 @@ const makeWsRpcLayer = ( }) : Effect.void; const removeCreatedWorktree = - tracked && targetWorktreePath && bootstrap?.prepareWorktree + !acceptedHandoff && + allocatedWorktreeThisAttempt && + targetWorktreePath && + bootstrap?.prepareWorktree ? closeSetupTerminal.pipe( Effect.ignoreCause({ log: true }), Effect.andThen( @@ -1781,15 +1986,104 @@ const makeWsRpcLayer = ( : settledBootstrapProgram; return yield* runBootstrap; - }); + }).pipe( + Effect.provideContext(normalizerContext), + Effect.mapError((cause) => + toDispatchCommandError(cause, "Failed to prepare destination worktree"), + ), + ); + + const prepareDirectThreadCreate = Effect.fnUntraced(function* ( + command: Extract, + ) { + // History import preserves the provider's recorded cwd, and is not a new coding launch. + const existing = yield* projectionSnapshotQuery.getThreadShellById(command.threadId); + // Replay reaches the existing command receipt without allocating again. + if (Option.isSome(existing) || command.historyImport === true) return command; + const executionScope = command.executionScope ?? "coding"; + if (executionScope !== "coding") { + if (executionScope === "coordinator" && command.worktreePath === null) { + const cwd = yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = coordinationWorkspacePath(config.stateDir, command.threadId, path); + yield* fs.makeDirectory(cwd, { recursive: true }); + return cwd; + }).pipe(Effect.provide(normalizerContext)); + return { ...command, executionScope, worktreePath: cwd }; + } + return { ...command, executionScope }; + } + const project = yield* projectionSnapshotQuery + .getProjectShellById(command.projectId) + .pipe(Effect.map(Option.getOrNull)); + if (!project) + return yield* new OrchestrationDispatchCommandError({ + message: "Destination project is missing.", + }); + let branch = command.branch; + let worktreePath = command.worktreePath; + if (worktreePath === null) { + const refName = branch ?? "HEAD"; + if ( + !(yield* gitWorkflow.isRepository(project.workspaceRoot)) || + !(yield* gitWorkflow.hasCommit({ cwd: project.workspaceRoot, refName })) + ) { + return yield* new OrchestrationDispatchCommandError({ + message: + "Coding requires a Git repository with a committed base. Select a repository project or choose Coordinator for noncoding work.", + }); + } + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + branch = `t3/thread-${safeThreadPathSegment(command.threadId)}`; + worktreePath = path.join( + config.worktreesDir, + path.basename(project.workspaceRoot), + branch.replaceAll("/", "-"), + ); + if (!(yield* fs.exists(worktreePath))) { + const worktree = yield* gitWorkflow.createWorktree({ + cwd: project.workspaceRoot, + refName, + newRefName: branch, + baseRefName: refName, + path: worktreePath, + }); + worktreePath = worktree.worktree.path; + } + } + return { ...command, executionScope, branch, worktreePath }; + }); const dispatchNormalizedCommand = ( normalizedCommand: OrchestrationCommand, ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => { + if ( + normalizedCommand.type === "thread.create" && + normalizedCommand.historyImport === true + ) { + return Effect.fail( + new OrchestrationDispatchCommandError({ + message: "History imports are reserved for the server's internal session importer.", + }), + ); + } const dispatchEffect = normalizedCommand.type === "thread.turn.start" && normalizedCommand.bootstrap - ? dispatchBootstrapTurnStart(normalizedCommand) - : dispatchFromClient(normalizedCommand).pipe( + ? withThreadCreateLease( + normalizedCommand.threadId, + dispatchBootstrapTurnStart(normalizedCommand), + ) + : (normalizedCommand.type === "thread.create" + ? withThreadCreateLease( + normalizedCommand.threadId, + prepareDirectThreadCreate(normalizedCommand).pipe( + Effect.flatMap(dispatchFromClient), + ), + ).pipe(Effect.provideContext(normalizerContext)) + : dispatchFromClient(normalizedCommand) + ).pipe( Effect.tap(({ sequence }) => // Returning from thread.create is the handoff point at which // clients may start resources for the new incarnation. Use diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 607236f2d63b..daa77731e790 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -73,6 +73,7 @@ interface BranchToolbarProps { threadId: ThreadId; showGitControls: boolean; draftId?: DraftId; + draftScopeLocked?: boolean; onEnvModeChange: (mode: EnvMode) => void; effectiveEnvModeOverride?: EnvMode; activeThreadBranchOverride?: string | null; @@ -488,6 +489,7 @@ export const BranchToolbar = memo(function BranchToolbar({ threadId, showGitControls, draftId, + draftScopeLocked = false, onEnvModeChange, effectiveEnvModeOverride, activeThreadBranchOverride, @@ -537,7 +539,11 @@ export const BranchToolbar = memo(function BranchToolbar({ // of this project — the "keep going where I just was" follow-up flow. Only // drafts can hop; started server threads have their workspace pinned. const canUsePreviousWorktree = - draftThread !== null && serverThread === null && !envModeLocked && !forceNewWorktree; + draftThread !== null && + serverThread === null && + !draftScopeLocked && + !envModeLocked && + !forceNewWorktree; const projectRefsForWorktreeLookup = useMemo( () => (canUsePreviousWorktree && activeProjectRef ? [activeProjectRef] : []), [canUsePreviousWorktree, activeProjectRef], @@ -661,7 +667,7 @@ export const BranchToolbar = memo(function BranchToolbar({ ) : null} )} - {showGitControls ? ( + {showGitControls && !draftScopeLocked ? ( ({ context: { @@ -5888,10 +5894,12 @@ export default function ChatView(props: ChatViewProps) { ? (pendingServerThreadStartFromOriginByThreadId[activeThread?.id ?? ""] ?? activeProjectSettings.settings.newWorktreesStartFromOrigin) : false; - const sendEnvMode = resolveSendEnvMode({ - requestedEnvMode: envMode, - isGitRepo, - }); + const firstLaunchScope = draftThread?.executionScope ?? "coding"; + const sendEnvMode = isLocalDraftThread + ? firstLaunchScope === "coding" + ? "worktree" + : "local" + : resolveSendEnvMode({ requestedEnvMode: envMode, isGitRepo }); const localCheckoutBranchMismatch = useMemo( () => isServerThread @@ -7724,7 +7732,10 @@ export default function ChatView(props: ChatViewProps) { const shouldCreateWorktree = isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath; if (shouldCreateWorktree && !activeThreadBranch) { - setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode."); + setThreadError( + threadIdForSend, + "Coding needs a Git repository and a committed base branch. Select a repository project or choose Coordinator for noncoding work.", + ); return; } @@ -8066,6 +8077,7 @@ export default function ChatView(props: ChatViewProps) { bootstrap: { createThread: { projectId: activeProject.id, + executionScope: "coding", title, modelSelection: target.selection, runtimeMode, @@ -8403,6 +8415,7 @@ export default function ChatView(props: ChatViewProps) { ? { createThread: { projectId: activeProject.id, + executionScope: firstLaunchScope, title, modelSelection: threadCreateModelSelection, runtimeMode, @@ -10240,12 +10253,52 @@ export default function ChatView(props: ChatViewProps) { > {mountComposerContextStrip && (
+ {isLocalDraftThread && ( + + )} ()( worktreePath: nextWorktreePath, envMode: options.envMode ?? (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")), + executionScope: options.executionScope ?? existing.executionScope ?? "coding", startFromOrigin: nextStartFromOrigin, promotedTo: existing.promotedTo ?? null, }; @@ -2804,6 +2817,7 @@ const composerDraftStore = create()( nextDraftThread.branch === existing.branch && nextDraftThread.worktreePath === existing.worktreePath && nextDraftThread.envMode === existing.envMode && + nextDraftThread.executionScope === existing.executionScope && nextDraftThread.startFromOrigin === existing.startFromOrigin && scopedThreadRefsEqual(nextDraftThread.promotedTo, existing.promotedTo); if (isUnchanged) { diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 71b03419ab1f..9a82c5599f0e 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -837,6 +837,10 @@ export const ThreadPullRequestLink = Schema.Struct({ }); export type ThreadPullRequestLink = typeof ThreadPullRequestLink.Type; +// Null/absent belongs to pre-isolation history; new creates default to coding on the server. +export const ThreadExecutionScope = Schema.Literals(["coding", "coordinator"]); +export type ThreadExecutionScope = typeof ThreadExecutionScope.Type; + export const OrchestrationThread = Schema.Struct({ id: ThreadId, projectId: ProjectId, @@ -848,6 +852,7 @@ export const OrchestrationThread = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + executionScope: Schema.optional(Schema.NullOr(ThreadExecutionScope)), linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), // Optional so payloads from pre-link servers still decode. pullRequests: Schema.Array(ThreadPullRequestLink).pipe( @@ -935,6 +940,7 @@ export const OrchestrationThreadShell = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + executionScope: Schema.optional(Schema.NullOr(ThreadExecutionScope)), linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), pullRequests: Schema.Array(ThreadPullRequestLink).pipe( Schema.withDecodingDefault(Effect.succeed([])), @@ -1174,6 +1180,7 @@ const ThreadCreateCommand = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + executionScope: Schema.optional(Schema.NullOr(ThreadExecutionScope)), createdAt: IsoDateTime, historyImport: Schema.optional(Schema.Literal(true)), }); @@ -1334,6 +1341,7 @@ const ThreadTurnStartBootstrapCreateThread = Schema.Struct({ interactionMode: ProviderInteractionMode, branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + executionScope: Schema.optional(Schema.NullOr(ThreadExecutionScope)), createdAt: IsoDateTime, }); @@ -1816,6 +1824,7 @@ export const ThreadCreatedPayload = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + executionScope: Schema.optional(Schema.NullOr(ThreadExecutionScope)), createdAt: IsoDateTime, updatedAt: IsoDateTime, });