diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 74bc41e5e8cd..738440c8531d 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -59,7 +59,15 @@ export interface ProjectSetupScriptRunnerInput { readonly projectId?: string; readonly projectCwd?: string; readonly worktreePath: string; + /** + * The checkout `worktreePath` was created from, exposed to the script as + * `T3CODE_REPO_ROOT`. A multi-repo project's root is not a repo, so this is + * how a script finds files such as `.env` to copy from the original repo. + */ + readonly repoRoot?: string; readonly preferredTerminalId?: string; + /** Appended to the default terminal id so one thread can run several setups. */ + readonly terminalIdSuffix?: string; /** * Wrap the command so the shell reports its exit code back through the * terminal stream, and forward cleaned output lines while it runs. The @@ -345,11 +353,16 @@ export const make = Effect.gen(function* () { } as const; } - const terminalId = input.preferredTerminalId ?? `setup-${script.id}`; + const terminalId = + input.preferredTerminalId ?? + (input.terminalIdSuffix + ? `setup-${script.id}-${input.terminalIdSuffix}`.slice(0, 128) + : `setup-${script.id}`); const cwd = input.worktreePath; const env = projectScriptRuntimeEnv({ project: { cwd: project.workspaceRoot }, worktreePath: input.worktreePath, + ...(input.repoRoot ? { extraEnv: { T3CODE_REPO_ROOT: input.repoRoot } } : {}), }); const observe = input.observeCompletion; const completionToken = observe ? NodeCrypto.randomUUID().replaceAll("-", "") : null; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 7c780a482dc9..8894e99d06da 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -12166,6 +12166,166 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("runs the setup script in every worktree of a multi-repo run", () => + Effect.gen(function* () { + const anchorRepoRoot = "/tmp/workspace/api"; + const cousinRepoRoot = "/tmp/workspace/web"; + const dispatchedCommands: Array = []; + const runForThread = vi.fn( + ( + input: Parameters< + ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]["runForThread"] + >[0], + ) => + Effect.succeed({ + status: "started" as const, + scriptId: "setup", + scriptName: "Setup", + scriptCommand: "npm install", + terminalId: input.terminalIdSuffix + ? `setup-setup-${input.terminalIdSuffix}` + : "setup-setup", + cwd: input.worktreePath, + async: false, + completion: (input.observeCompletion?.onOutputLine?.("installed") ?? Effect.void).pipe( + // Only the cousin's install fails. + Effect.as({ exitCode: input.repoRoot === cousinRepoRoot ? 1 : 0, durationMs: 1 }), + ), + }), + ); + + yield* buildAppUnderTest({ + layers: { + gitVcsDriver: { + execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION), + resolveDefaultBranchName: () => Effect.succeed(null), + createWorktree: (input) => + Effect.succeed({ + worktree: { refName: "t3code/bootstrap-refName", path: `${input.cwd}-worktree` }, + }), + }, + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, + gitManager: { + localStatus: () => + Effect.succeed({ + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: true, + refName: "main", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + }, + projectionSnapshotQuery: { + getProjectShellById: () => + Effect.succeed( + Option.some({ + id: defaultProjectId, + title: "Workspace", + workspaceRoot: "/tmp/workspace", + workspaceFile: "/tmp/workspace/project.code-workspace", + repoRoots: [anchorRepoRoot, cousinRepoRoot], + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + projectSetupScriptRunner: { + runForThread, + }, + }, + }); + + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-multi-repo-setup"), + threadId: ThreadId.make("thread-bootstrap-multi-repo-setup"), + message: { + messageId: MessageId.make("msg-bootstrap-multi-repo-setup"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/workspace", + baseBranch: "main", + branch: "t3code/bootstrap-refName", + }, + runSetupScript: true, + }, + createdAt, + }), + ), + ); + + // Each repo's worktree runs the script against its own original + // checkout. The cousin gets its own terminal; the anchor keeps the + // terminal id the card links to. + assert.deepEqual( + runForThread.mock.calls.map(([input]) => ({ + worktreePath: input.worktreePath, + repoRoot: input.repoRoot, + terminalIdSuffix: input.terminalIdSuffix, + })), + [ + { + worktreePath: `${anchorRepoRoot}-worktree`, + repoRoot: anchorRepoRoot, + terminalIdSuffix: undefined, + }, + { + worktreePath: `${cousinRepoRoot}-worktree`, + repoRoot: cousinRepoRoot, + terminalIdSuffix: "web-worktree", + }, + ], + ); + + // One card stage covers both runs: output and failures name the repo. + const outcome = dispatchedCommands.findLast( + (command) => + command.type === "thread.activity.append" && command.activity.kind === "worktree-setup", + ); + assertTrue(outcome?.type === "thread.activity.append"); + const snapshot = outcome.activity.payload as WorktreeSetupSnapshot; + const setupStage = snapshot.stages.find((stage) => stage.id === "setup-script"); + assert.equal(setupStage?.status, "failed"); + assert.equal(setupStage?.detail, "web-worktree: exit 1"); + assert.deepEqual(setupStage?.tail, ["[api-worktree] installed", "[web-worktree] installed"]); + assertTrue(dispatchedCommands.some((command) => command.type === "thread.turn.start")); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("bases each multi-repo cousin on its pick, else its own default branch", () => Effect.gen(function* () { // A `.code-workspace` project's `projectCwd` is the directory holding diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 72f5b53ae5a3..1eb24080f9eb 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1103,9 +1103,12 @@ const makeWsRpcLayer = ( let targetWorktreeRepoRoot = bootstrap?.prepareWorktree?.projectCwd ?? null; // Extra per-repo worktrees of a multi-repo fan-out, removed on cancel. const claimedCousinWorktrees: Array<{ repoRoot: string; worktreePath: string }> = []; - // The setup script's terminal, once started. Cancel closes only this - // one so terminals the user opened meanwhile survive. - let setupTerminalId: string | null = null; + // Every worktree of a multi-repo fan-out, anchor first. The setup + // script runs in each of them. + let fannedOutWorktrees: ReadonlyArray<{ repoRoot: string; worktreePath: string }> = []; + // The setup scripts' terminals, once started. Cancel closes only + // these so terminals the user opened meanwhile survive. + const setupTerminalIds: Array = []; // Set once the checkout starts; see the session.set below. let preparingSessionSet = false; @@ -1226,120 +1229,176 @@ const makeWsRpcLayer = ( const threadId = command.threadId; const track = (effect: Effect.Effect) => (tracked ? effect : Effect.void); - // Starts the setup script. For tracked bootstraps it returns the - // effect that waits for the script to exit and records the outcome - // on the card; whether the agent stage waits on it depends on the - // script's `async` flag. Returns null when nothing is left to await. - // Untracked callers keep the old fire-and-forget behavior. + // Starts the setup script in the thread's worktree, or in every + // worktree of a multi-repo fan-out. For tracked bootstraps it + // returns the effect that waits for the scripts to exit and records + // the outcome on the card; whether the agent stage waits on it + // depends on the script's `async` flag. Returns null when nothing is + // left to await. Untracked callers keep the old fire-and-forget + // behavior. const runSetupProgram = () => Effect.gen(function* () { if (!bootstrap?.runSetupScript || !targetWorktreePath) { yield* track(worktreeSetupTracker.stageStatus(threadId, "setup-script", "skipped")); return null; } - const worktreePath = targetWorktreePath; + const anchorWorktreePath = targetWorktreePath; + const targets = + fannedOutWorktrees.length > 0 + ? fannedOutWorktrees + : [{ repoRoot: targetWorktreeRepoRoot, worktreePath: anchorWorktreePath }]; + // Several runs share one card stage, so each line and failure is + // labelled with the repo it came from. + const labelFor = (worktreePath: string) => + targets.length > 1 ? (worktreePath.split(/[\\/]/).at(-1) ?? null) : null; const requestedAt = yield* nowIso; yield* track(worktreeSetupTracker.stageStatus(threadId, "setup-script", "running")); - const setupResult = yield* projectSetupScriptRunner - .runForThread({ - threadId, - ...(targetProjectId ? { projectId: targetProjectId } : {}), - ...(targetProjectCwd ? { projectCwd: targetProjectCwd } : {}), - worktreePath, - ...(tracked - ? { - observeCompletion: { - onOutputLine: (line) => - worktreeSetupTracker.appendTail(threadId, "setup-script", line), + type SetupLaunch = { + readonly label: string | null; + readonly outcome: + | "no-script" + | "failed" + | ProjectSetupScriptRunner.ProjectSetupScriptRunnerResultStarted; + }; + const launches = yield* Effect.forEach( + targets, + (target): Effect.Effect => { + const worktreePath = target.worktreePath; + const label = labelFor(worktreePath); + return projectSetupScriptRunner + .runForThread({ + threadId, + ...(targetProjectId ? { projectId: targetProjectId } : {}), + ...(targetProjectCwd ? { projectCwd: targetProjectCwd } : {}), + worktreePath, + ...(target.repoRoot ? { repoRoot: target.repoRoot } : {}), + // The anchor keeps the plain id so its terminal is the one + // the card and existing links open. + ...(label && worktreePath !== anchorWorktreePath + ? { terminalIdSuffix: label } + : {}), + ...(tracked + ? { + observeCompletion: { + onOutputLine: (line) => + worktreeSetupTracker.appendTail( + threadId, + "setup-script", + label ? `[${label}] ${line}` : line, + ), + }, + } + : {}), + }) + .pipe( + Effect.matchEffect({ + onFailure: (error) => + recordSetupScriptLaunchFailure({ + error, + requestedAt, + worktreePath, + }).pipe(Effect.as({ label, outcome: "failed" })), + onSuccess: (setupResult) => { + if (setupResult.status !== "started") { + return Effect.succeed({ label, outcome: "no-script" }); + } + setupTerminalIds.push(setupResult.terminalId); + return recordSetupScriptStarted({ + requestedAt, + worktreePath, + scriptId: setupResult.scriptId, + scriptName: setupResult.scriptName, + terminalId: setupResult.terminalId, + }).pipe(Effect.as({ label, outcome: setupResult })); }, - } - : {}), - }) - .pipe( - Effect.matchEffect({ - onFailure: (error) => - recordSetupScriptLaunchFailure({ - error, - requestedAt, - worktreePath, - }).pipe( - Effect.andThen( - track( - worktreeSetupTracker.stageStatus( - threadId, - "setup-script", - "failed", - "failed to start", - ), - ), - ), - Effect.as(null), + }), + ); + }, + ); + const started = launches.flatMap((launch) => + typeof launch.outcome === "string" ? [] : [{ ...launch, result: launch.outcome }], + ); + const failedToStart = launches.filter((launch) => launch.outcome === "failed"); + const withLabel = (label: string | null, detail: string) => + label ? `${label}: ${detail}` : detail; + const firstStarted = started[0]; + if (!firstStarted) { + yield* track( + failedToStart.length > 0 + ? worktreeSetupTracker.stageStatus( + threadId, + "setup-script", + "failed", + failedToStart + .map((launch) => withLabel(launch.label, "failed to start")) + .join(", "), + ) + : worktreeSetupTracker.stageStatus( + threadId, + "setup-script", + "skipped", + "no setup script", ), - onSuccess: (setupResult) => { - if (setupResult.status !== "started") { - return track( - worktreeSetupTracker.stageStatus( - threadId, - "setup-script", - "skipped", - "no setup script", - ), - ).pipe(Effect.as(null)); - } - setupTerminalId = setupResult.terminalId; - return recordSetupScriptStarted({ - requestedAt, - worktreePath, - scriptId: setupResult.scriptId, - scriptName: setupResult.scriptName, - terminalId: setupResult.terminalId, - }).pipe( - Effect.andThen( - track( - worktreeSetupTracker.update(threadId, (snapshot) => ({ - ...snapshot, - setupScript: { - name: setupResult.scriptName, - command: setupResult.scriptCommand, - terminalId: setupResult.terminalId, - }, - })), - ), - ), - Effect.as(setupResult), - ); - }, - }), ); - if (!tracked || !setupResult?.completion) { + return null; + } + yield* track( + worktreeSetupTracker.update(threadId, (snapshot) => ({ + ...snapshot, + setupScript: { + name: firstStarted.result.scriptName, + command: firstStarted.result.scriptCommand, + terminalId: firstStarted.result.terminalId, + }, + })), + ); + const observed = started.flatMap(({ label, result }) => + result.completion ? [{ label, completion: result.completion }] : [], + ); + if (!tracked || observed.length === 0) { return null; } // The setup script is best effort, like the untracked path: a // failed install must not throw away the worktree the user just // waited for. The card keeps the failed stage and its terminal. - // Forked right away so the terminal listener behind `completion` - // is always consumed, even when the turn dispatch fails before - // anyone would otherwise wait on it. The tracker update is a - // no-op once the snapshot has been dropped. - const completionFiber = yield* setupResult.completion.pipe( - Effect.flatMap((completion) => { - if (completion.exitCode === 0) { - return worktreeSetupTracker.stageStatus(threadId, "setup-script", "done"); - } - const detail = - completion.exitCode === null - ? "terminal closed before the script finished" - : `exit ${completion.exitCode}`; - return worktreeSetupTracker.stageStatus( - threadId, - "setup-script", - "failed", - detail, - ); + // Forked right away so the terminal listeners behind + // `completion` are always consumed, even when the turn dispatch + // fails before anyone would otherwise wait on them. The tracker + // update is a no-op once the snapshot has been dropped. + const completionFiber = yield* Effect.forEach( + observed, + ({ label, completion }) => + completion.pipe( + Effect.map(({ exitCode }) => + exitCode === 0 + ? null + : withLabel( + label, + exitCode === null + ? "terminal closed before the script finished" + : `exit ${exitCode}`, + ), + ), + ), + { concurrency: "unbounded" }, + ).pipe( + Effect.flatMap((exitFailures) => { + const failures = [ + ...failedToStart.map((launch) => withLabel(launch.label, "failed to start")), + ...exitFailures.filter((failure) => failure !== null), + ]; + return failures.length === 0 + ? worktreeSetupTracker.stageStatus(threadId, "setup-script", "done") + : worktreeSetupTracker.stageStatus( + threadId, + "setup-script", + "failed", + failures.join(", "), + ); }), Effect.forkDetach, ); - if (!setupResult.async) { + if (!firstStarted.result.async) { yield* Fiber.join(completionFiber); return null; } @@ -1723,6 +1782,7 @@ const makeWsRpcLayer = ( repoRoot: entry.repoRoot, worktreePath: entry.worktreePath, })); + fannedOutWorktrees = worktrees; // Give the fanned-out run its own `.code-workspace` so "Open in" // lands on the worktrees rather than the original checkouts. It // lives in the per-thread directory, so it goes when that does. @@ -1866,17 +1926,18 @@ const makeWsRpcLayer = ( if (Cause.hasInterruptsOnly(cause)) { // A user cancel interrupts the forked bootstrap fiber. The // created thread is rolled back like any other failure so the - // draft returns to the composer. The setup terminal is closed - // first so a still-running script cannot hold files open in - // the worktree while git removes it. Closing kills the + // draft returns to the composer. The setup terminals are + // closed first so a still-running script cannot hold files + // open in a worktree while git removes it. Closing kills the // process asynchronously, so the removal retries briefly. - const closeSetupTerminal = setupTerminalId - ? terminalManager.close({ - threadId, - terminalId: setupTerminalId, - deleteHistory: true, - }) - : Effect.void; + const closeSetupTerminal = Effect.forEach( + setupTerminalIds, + (terminalId) => + terminalManager + .close({ threadId, terminalId, deleteHistory: true }) + .pipe(Effect.ignoreCause({ log: true })), + { discard: true }, + ); const removeCreatedWorktree = tracked && targetWorktreePath && targetWorktreeRepoRoot ? closeSetupTerminal.pipe(