authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-13 21:28:59-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 21:28:59-07:00
log73b206f4bf99e7e98ae144a9d9edbf7cb2700fc1
tree666297d86d075308a8aaab5f8d745452a498a294
parentb70015b6db8ffafd902174f78cb260cc8082fc6e
signature Signed by PGP key B5690EEEBB952194

feat(web): show each worktree setup step and let users cancel it (#11372)

Starting a thread in a new worktree showed one static "Setting up worktree" line while git fetched, checked out files, and the setup script ran. Users could not tell which step was slow, see setup script output without hunting for the terminal, or stop a setup that was going wrong. The timeline now shows a card with each step and its elapsed time. Check out files has a percent bar fed by git's own progress output. The setup script step shows the last lines of its terminal inline and links to the full terminal. Cancel stops the bootstrap and removes the half built worktree. Work locally cancels, switches the draft to the project checkout, and resends. The server keeps an in-memory per-thread snapshot of the bootstrap stages and streams it over a new subscribeWorktreeSetup RPC. The bootstrap runs as a child fiber so worktreeSetup.cancel can interrupt it, and the turn handoff is uninterruptible. The setup script's exit code comes from a per-run sentinel echoed after the command in the setup PTY. Created with Claude Fable 5.1 in Claude Code.

24 files changed, 2352 insertions(+), 79 deletions(-)

apps/server/src/auth/RpcAuthorization.ts+2
...@@ -111,6 +111,8 @@ export const RPC_REQUIRED_SCOPES = {...@@ -111,6 +111,8 @@ export const RPC_REQUIRED_SCOPES = {
111 [WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope,111 [WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope,
112 [WS_METHODS.providerUploadFeedback]: AuthOrchestrationOperateScope,112 [WS_METHODS.providerUploadFeedback]: AuthOrchestrationOperateScope,
113 [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope,113 [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope,
114 [WS_METHODS.subscribeWorktreeSetup]: AuthOrchestrationReadScope,
115 [WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope,
114 [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope,116 [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope,
115 [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope,117 [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope,
116 [WS_METHODS.vcsPull]: AuthOrchestrationOperateScope,118 [WS_METHODS.vcsPull]: AuthOrchestrationOperateScope,
apps/server/src/git/GitWorkflowService.ts+3-2
...@@ -69,6 +69,7 @@ export class GitWorkflowService extends Context.Service<...@@ -69,6 +69,7 @@ export class GitWorkflowService extends Context.Service<
69 ) => Effect.Effect<VcsListRefsResult, GitCommandError>;69 ) => Effect.Effect<VcsListRefsResult, GitCommandError>;
70 readonly createWorktree: (70 readonly createWorktree: (
71 input: VcsCreateWorktreeInput,71 input: VcsCreateWorktreeInput,
72 options?: GitVcsDriver.CreateWorktreeOptions,
72 ) => Effect.Effect<VcsCreateWorktreeResult, GitCommandError>;73 ) => Effect.Effect<VcsCreateWorktreeResult, GitCommandError>;
73 readonly fetchRemote: (input: {74 readonly fetchRemote: (input: {
74 readonly cwd: string;75 readonly cwd: string;
...@@ -338,9 +339,9 @@ export const make = Effect.gen(function* () {...@@ -338,9 +339,9 @@ export const make = Effect.gen(function* () {
338 isGitRepository ? git.listRefs(input) : Effect.succeed(nonRepositoryListRefs()),339 isGitRepository ? git.listRefs(input) : Effect.succeed(nonRepositoryListRefs()),
339 ),340 ),
340 ),341 ),
341 createWorktree: (input) =>342 createWorktree: (input, options) =>
342 ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe(343 ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe(
343 Effect.andThen(git.createWorktree(input)),344 Effect.andThen(git.createWorktree(input, options)),
344 ),345 ),
345 fetchRemote: (input) =>346 fetchRemote: (input) =>
346 ensureGitCommand("GitWorkflowService.fetchRemote", input.cwd).pipe(347 ensureGitCommand("GitWorkflowService.fetchRemote", input.cwd).pipe(
apps/server/src/project/ProjectSetupScriptRunner.test.ts+212-6
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import { describe, expect, it, vi } from "@effect/vitest";1import { describe, expect, it, vi } from "@effect/vitest";
2import { type OrchestrationProject, ProjectId } from "@t3tools/contracts";2import { type OrchestrationProject, ProjectId, type TerminalEvent } from "@t3tools/contracts";
3import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";6import * as Option from "effect/Option";
...@@ -54,11 +55,11 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>...@@ -54,11 +55,11 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>
54 searchThreads: () => Effect.succeed({ matches: [] }),55 searchThreads: () => Effect.succeed({ matches: [] }),
55 });56 });
5657
57const makeTerminalManagerLayer = (58type TerminalOverrides = Pick<TerminalManager.TerminalManager["Service"], "open" | "write"> &
58 overrides: Pick<TerminalManager.TerminalManager["Service"], "open" | "write">,59 Partial<Pick<TerminalManager.TerminalManager["Service"], "subscribe">>;
59) =>60
61const makeTerminalManagerLayer = (overrides: TerminalOverrides) =>
60 Layer.succeed(TerminalManager.TerminalManager, {62 Layer.succeed(TerminalManager.TerminalManager, {
61 ...overrides,
62 attachStream: () => Effect.die(new Error("unused")),63 attachStream: () => Effect.die(new Error("unused")),
63 resize: () => Effect.void,64 resize: () => Effect.void,
64 clear: () => Effect.void,65 clear: () => Effect.void,
...@@ -66,11 +67,12 @@ const makeTerminalManagerLayer = (...@@ -66,11 +67,12 @@ const makeTerminalManagerLayer = (
66 close: () => Effect.void,67 close: () => Effect.void,
67 subscribe: () => Effect.succeed(() => undefined),68 subscribe: () => Effect.succeed(() => undefined),
68 subscribeMetadata: () => Effect.succeed(() => undefined),69 subscribeMetadata: () => Effect.succeed(() => undefined),
70 ...overrides,
69 });71 });
7072
71const testLayer = (73const testLayer = (
72 project: OrchestrationProject,74 project: OrchestrationProject,
73 terminal: Pick<TerminalManager.TerminalManager["Service"], "open" | "write">,75 terminal: TerminalOverrides,
74 settings = ServerSettings.layerTest(),76 settings = ServerSettings.layerTest(),
75) =>77) =>
76 ProjectSetupScriptRunner.layer.pipe(78 ProjectSetupScriptRunner.layer.pipe(
...@@ -198,6 +200,7 @@ describe("ProjectSetupScriptRunner", () => {...@@ -198,6 +200,7 @@ describe("ProjectSetupScriptRunner", () => {
198 status: "started",200 status: "started",
199 scriptId: "setup",201 scriptId: "setup",
200 scriptName: "Setup",202 scriptName: "Setup",
203 scriptCommand: "bun install",
201 terminalId: "setup-setup",204 terminalId: "setup-setup",
202 cwd: "/repo/worktrees/a",205 cwd: "/repo/worktrees/a",
203 });206 });
...@@ -220,6 +223,209 @@ describe("ProjectSetupScriptRunner", () => {...@@ -220,6 +223,209 @@ describe("ProjectSetupScriptRunner", () => {
220 },223 },
221 );224 );
222225
226 it.effect(
227 "wraps the command with a completion sentinel and resolves the exit code from terminal output",
228 () => {
229 const open = vi.fn(() =>
230 Effect.succeed({
231 threadId: "thread-1",
232 terminalId: "setup-setup",
233 cwd: "/repo/worktrees/a",
234 worktreePath: "/repo/worktrees/a",
235 status: "running" as const,
236 pid: 123,
237 history: "",
238 exitCode: null,
239 exitSignal: null,
240 label: "setup-setup",
241 updatedAt: "2026-01-01T00:00:00.000Z",
242 }),
243 );
244 const writes: string[] = [];
245 const write = vi.fn((input: { data: string }) =>
246 Effect.sync(() => void writes.push(input.data)),
247 );
248 let listener: ((event: TerminalEvent) => Effect.Effect<void>) | null = null;
249 const subscribe = vi.fn((next: (event: TerminalEvent) => Effect.Effect<void>) => {
250 listener = next;
251 return Effect.succeed(() => {
252 listener = null;
253 });
254 });
255 const project = makeProject([
256 {
257 id: "setup",
258 name: "Setup",
259 command: "bun install",
260 icon: "configure",
261 runOnWorktreeCreate: true,
262 },
263 ]);
264 const emit = (data: string) =>
265 Effect.suspend(() =>
266 listener
267 ? listener({ threadId: "thread-1", terminalId: "setup-setup", type: "output", data })
268 : Effect.void,
269 );
270
271 return Effect.gen(function* () {
272 const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
273 const seen: string[] = [];
274 const result = yield* runner.runForThread({
275 threadId: "thread-1",
276 projectCwd: "/repo/project",
277 worktreePath: "/repo/worktrees/a",
278 observeCompletion: {
279 onOutputLine: (line) => Effect.sync(() => void seen.push(line)),
280 },
281 });
282 expect(result.status).toBe("started");
283 if (result.status !== "started") return;
284 expect(result.completion).toBeDefined();
285
286 // The subscription is attached before the command is written.
287 expect(subscribe).toHaveBeenCalledTimes(1);
288 expect(writes).toHaveLength(1);
289 // The block closes on its own line so a trailing comment in the
290 // command cannot swallow the sentinel, and the sentinel carries a
291 // per-run token so script output cannot spoof it.
292 const written = writes[0] ?? "";
293 const sentinel = /__T3_SETUP_DONE___[0-9a-f]{32}:/.exec(written)?.[0];
294 expect(sentinel).toBeDefined();
295 expect(written).toBe(`( bun install\r); printf '\\n${sentinel}%s\\n' "$?"\r`);
296
297 // Output arrives in chunks; partial lines are buffered until a newline,
298 // control sequences are stripped, and the echoed wrapper is hidden.
299 yield* emit(`( bun install\r\n> ); printf '\\n${sentinel}%s\\n' "$?"\r\n`);
300 yield* emit("\u001b[32mResolving");
301 yield* emit(" deps\u001b[0m\r\nDone in 2s\r\n");
302 // A spoofed sentinel from the script itself must not settle completion.
303 yield* emit("__T3_SETUP_DONE__:0\r\n");
304 yield* emit(`__T3_SETUP_DONE___${"0".repeat(32)}:0\r\n`);
305 yield* emit(`${sentinel}3\r\n`);
306
307 const completion = yield* result.completion!;
308 expect(completion.exitCode).toBe(3);
309 expect(seen).toEqual([
310 "Resolving deps",
311 "Done in 2s",
312 "__T3_SETUP_DONE__:0",
313 `__T3_SETUP_DONE___${"0".repeat(32)}:0`,
314 ]);
315 // The subscription is torn down once the sentinel arrives.
316 expect(listener).toBeNull();
317 }).pipe(
318 Effect.provide(testLayer(project, { open, write, subscribe })),
319 Effect.provideService(HostProcessPlatform, "linux"),
320 Effect.provideService(HostProcessEnvironment, { SHELL: "/bin/zsh" }),
321 );
322 },
323 );
324
325 it.effect("unsubscribes from terminal output when the command cannot be written", () => {
326 const open = vi.fn(() =>
327 Effect.succeed({
328 threadId: "thread-1",
329 terminalId: "setup-setup",
330 cwd: "/repo/worktrees/a",
331 worktreePath: "/repo/worktrees/a",
332 status: "running" as const,
333 pid: 123,
334 history: "",
335 exitCode: null,
336 exitSignal: null,
337 label: "setup-setup",
338 updatedAt: "2026-01-01T00:00:00.000Z",
339 }),
340 );
341 const write = vi.fn(() =>
342 Effect.fail(
343 new TerminalManager.TerminalCwdStatError({ cwd: "/repo/worktrees/a", cause: {} }),
344 ),
345 );
346 const unsubscribe = vi.fn();
347 const subscribe = vi.fn(() => Effect.succeed(unsubscribe));
348 const project = makeProject([
349 {
350 id: "setup",
351 name: "Setup",
352 command: "bun install",
353 icon: "configure",
354 runOnWorktreeCreate: true,
355 },
356 ]);
357
358 return Effect.gen(function* () {
359 const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
360 const result = yield* runner
361 .runForThread({
362 threadId: "thread-1",
363 projectCwd: "/repo/project",
364 worktreePath: "/repo/worktrees/a",
365 observeCompletion: {},
366 })
367 .pipe(Effect.result);
368 expect(result._tag).toBe("Failure");
369 expect(unsubscribe).toHaveBeenCalledTimes(1);
370 }).pipe(Effect.provide(testLayer(project, { open, write, subscribe })));
371 });
372
373 it.effect.each([
374 {
375 shell: "/usr/bin/fish",
376 expected:
377 /^begin\rbun install\rend; printf '\\n__T3_SETUP_DONE___[0-9a-f]{32}:%s\\n' \$status\r$/,
378 },
379 {
380 shell: "/bin/bash",
381 expected: /^\( bun install\r\); printf '\\n__T3_SETUP_DONE___[0-9a-f]{32}:%s\\n' "\$\?"\r$/,
382 },
383 ])("wraps the command for the $shell syntax", ({ shell, expected }) => {
384 const open = vi.fn(() =>
385 Effect.succeed({
386 threadId: "thread-1",
387 terminalId: "setup-setup",
388 cwd: "/repo/worktrees/a",
389 worktreePath: "/repo/worktrees/a",
390 status: "running" as const,
391 pid: 123,
392 history: "",
393 exitCode: null,
394 exitSignal: null,
395 label: "setup-setup",
396 updatedAt: "2026-01-01T00:00:00.000Z",
397 }),
398 );
399 const writes: string[] = [];
400 const write = vi.fn((input: { data: string }) =>
401 Effect.sync(() => void writes.push(input.data)),
402 );
403 const project = makeProject([
404 {
405 id: "setup",
406 name: "Setup",
407 command: "bun install",
408 icon: "configure",
409 runOnWorktreeCreate: true,
410 },
411 ]);
412 return Effect.gen(function* () {
413 const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
414 yield* runner.runForThread({
415 threadId: "thread-1",
416 projectCwd: "/repo/project",
417 worktreePath: "/repo/worktrees/a",
418 observeCompletion: {},
419 });
420 expect(writes).toHaveLength(1);
421 expect(writes[0]).toMatch(expected);
422 }).pipe(
423 Effect.provide(testLayer(project, { open, write })),
424 Effect.provideService(HostProcessPlatform, "linux"),
425 Effect.provideService(HostProcessEnvironment, { SHELL: shell }),
426 );
427 });
428
223 it.effect("keeps terminal failures as the exact cause of a structured operation error", () => {429 it.effect("keeps terminal failures as the exact cause of a structured operation error", () => {
224 const rootCause = new Error("stat failed");430 const rootCause = new Error("stat failed");
225 const terminalError = new TerminalManager.TerminalCwdStatError({431 const terminalError = new TerminalManager.TerminalCwdStatError({
apps/server/src/project/ProjectSetupScriptRunner.ts+216-1
...@@ -1,10 +1,15 @@...@@ -1,10 +1,15 @@
1import { ProjectId } from "@t3tools/contracts";1import { ProjectId } from "@t3tools/contracts";
2import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
2import {3import {
3 projectScriptRuntimeEnv,4 projectScriptRuntimeEnv,
4 resolveProjectScripts,5 resolveProjectScripts,
5 setupProjectScript,6 setupProjectScript,
6} from "@t3tools/shared/projectScripts";7} from "@t3tools/shared/projectScripts";
8import * as NodeCrypto from "node:crypto";
9
10import * as Clock from "effect/Clock";
7import * as Context from "effect/Context";11import * as Context from "effect/Context";
12import * as Deferred from "effect/Deferred";
8import * as Effect from "effect/Effect";13import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";14import * as Layer from "effect/Layer";
10import * as Option from "effect/Option";15import * as Option from "effect/Option";
...@@ -22,8 +27,24 @@ export interface ProjectSetupScriptRunnerResultStarted {...@@ -22,8 +27,24 @@ export interface ProjectSetupScriptRunnerResultStarted {
22 readonly status: "started";27 readonly status: "started";
23 readonly scriptId: string;28 readonly scriptId: string;
24 readonly scriptName: string;29 readonly scriptName: string;
30 readonly scriptCommand: string;
25 readonly terminalId: string;31 readonly terminalId: string;
26 readonly cwd: string;32 readonly cwd: string;
33 /**
34 * Resolves when the script's shell prints the completion sentinel. The
35 * exit code is null when the terminal exited or was closed before the
36 * sentinel arrived. Only present when `observeCompletion` was requested.
37 */
38 readonly completion?: Effect.Effect<ProjectSetupScriptCompletion>;
39}
40
41export interface ProjectSetupScriptCompletion {
42 readonly exitCode: number | null;
43 readonly durationMs: number;
44}
45
46export interface ProjectSetupScriptOutputLine {
47 readonly line: string;
27}48}
2849
29export type ProjectSetupScriptRunnerResult =50export type ProjectSetupScriptRunnerResult =
...@@ -36,6 +57,14 @@ export interface ProjectSetupScriptRunnerInput {...@@ -36,6 +57,14 @@ export interface ProjectSetupScriptRunnerInput {
36 readonly projectCwd?: string;57 readonly projectCwd?: string;
37 readonly worktreePath: string;58 readonly worktreePath: string;
38 readonly preferredTerminalId?: string;59 readonly preferredTerminalId?: string;
60 /**
61 * Wrap the command so the shell reports its exit code back through the
62 * terminal stream, and forward cleaned output lines while it runs. The
63 * bootstrap flow uses this to drive the worktree setup card.
64 */
65 readonly observeCompletion?: {
66 readonly onOutputLine?: (line: string) => Effect.Effect<void>;
67 };
39}68}
4069
41export class ProjectSetupScriptOperationError extends Schema.TaggedError<ProjectSetupScriptOperationError>()(70export class ProjectSetupScriptOperationError extends Schema.TaggedError<ProjectSetupScriptOperationError>()(
...@@ -83,11 +112,170 @@ export class ProjectSetupScriptRunner extends Context.Service<...@@ -83,11 +112,170 @@ export class ProjectSetupScriptRunner extends Context.Service<
83 }112 }
84>()("t3/project/ProjectSetupScriptRunner") {}113>()("t3/project/ProjectSetupScriptRunner") {}
85114
115/** @public Service construction is part of the canonical Effect module API. */
116/**
117 * Marker the wrapped setup command echoes so the exit code can be read from
118 * the PTY stream. Each run gets its own random token so script output cannot
119 * spoof completion, and the sentinel pattern is built per run from it.
120 */
121const COMPLETION_SENTINEL_PREFIX = "__T3_SETUP_DONE__";
122const OUTPUT_LINE_MAX_LENGTH = 400;
123/** A partial line longer than this is a byte stream, not a line. Keep only the tail. */
124const PARTIAL_LINE_MAX_LENGTH = 4_096;
125
126function completionSentinel(token: string): string {
127 return `${COMPLETION_SENTINEL_PREFIX}_${token}:`;
128}
129
130function completionSentinelPattern(token: string): RegExp {
131 return new RegExp(`${COMPLETION_SENTINEL_PREFIX}_${token}:(-?\\d+)`);
132}
133
134/** Removes ANSI escape sequences and cursor controls so lines can be shown as plain text. */
135function stripTerminalControl(text: string): string {
136 return (
137 text
138 .replace(
139 // eslint-disable-next-line no-control-regex
140 /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][A-Za-z0-9]|\x1b[=>]/g,
141 "",
142 )
143 // eslint-disable-next-line no-control-regex
144 .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
145 );
146}
147
148type CompletionShell = "posix" | "fish" | "powershell";
149
150/**
151 * Predicts the shell TerminalManager will spawn for the setup terminal. The
152 * manager takes `$SHELL` on POSIX and PowerShell on Windows, falling back to
153 * other shells only when that one fails to spawn.
154 */
155function resolveCompletionShell(
156 platform: NodeJS.Platform,
157 env: NodeJS.ProcessEnv,
158): CompletionShell {
159 if (platform === "win32") return "powershell";
160 const shell = env.SHELL ?? "";
161 const name = shell.split("/").at(-1) ?? shell;
162 if (name === "fish") return "fish";
163 if (name === "pwsh" || name === "powershell") return "powershell";
164 return "posix";
165}
166
167/**
168 * Builds the shell input for the setup script. The command runs inside a
169 * block and the block closes on its own line, so a trailing `# comment` or a
170 * heredoc terminator in the command cannot swallow the sentinel. The shell
171 * reads the whole block before running any of it, so a script that reads
172 * stdin cannot consume the sentinel line either. Lines are separated by `\r`
173 * because that is the Enter key for every shell's line editor.
174 */
175function wrapCommandForCompletion(
176 command: string,
177 shell: CompletionShell,
178 sentinel: string,
179): string {
180 const body = command.replace(/\r?\n/g, "\r");
181 switch (shell) {
182 case "powershell":
183 return `$global:LASTEXITCODE = $null; & {\r${body}\r}; if ($null -ne $LASTEXITCODE) { $__t3c = $LASTEXITCODE } elseif ($?) { $__t3c = 0 } else { $__t3c = 1 }; Write-Host "${sentinel}$__t3c"`;
184 case "fish":
185 return `begin\r${body}\rend; printf '\\n${sentinel}%s\\n' $status`;
186 case "posix":
187 return `( ${body}\r); printf '\\n${sentinel}%s\\n' "$?"`;
188 }
189}
190
86/** @public Service construction is part of the canonical Effect module API. */191/** @public Service construction is part of the canonical Effect module API. */
87export const make = Effect.gen(function* () {192export const make = Effect.gen(function* () {
88 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;193 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
89 const terminalManager = yield* TerminalManager.TerminalManager;194 const terminalManager = yield* TerminalManager.TerminalManager;
90 const serverSettings = yield* ServerSettings.ServerSettingsService;195 const serverSettings = yield* ServerSettings.ServerSettingsService;
196 const completionShell = resolveCompletionShell(
197 yield* HostProcessPlatform,
198 yield* HostProcessEnvironment,
199 );
200
201 /**
202 * Watches the setup terminal for the completion sentinel. Terminal output is
203 * a byte stream, so partial lines are buffered until a newline. The
204 * subscription is torn down once the sentinel, an exit, or a close arrives.
205 */
206 const observeTerminalCompletion = (input: {
207 readonly threadId: string;
208 readonly terminalId: string;
209 /** Per-run sentinel, so only this run's wrapper can settle completion. */
210 readonly sentinel: string;
211 readonly sentinelPattern: RegExp;
212 /** The shell echoes typed input; lines ending with these are the wrapper, not output. */
213 readonly echoedWrapperLines: ReadonlyArray<string>;
214 readonly onOutputLine: ((line: string) => Effect.Effect<void>) | undefined;
215 }) =>
216 Effect.gen(function* () {
217 const startedAtMs = yield* Clock.currentTimeMillis;
218 const done = yield* Deferred.make<ProjectSetupScriptCompletion>();
219 let lineBuffer = "";
220 let settled = false;
221
222 const settle = (exitCode: number | null) =>
223 Effect.suspend(() => {
224 if (settled) return Effect.void;
225 settled = true;
226 return Clock.currentTimeMillis.pipe(
227 Effect.flatMap((nowMs) =>
228 Deferred.succeed(done, { exitCode, durationMs: nowMs - startedAtMs }),
229 ),
230 Effect.asVoid,
231 );
232 });
233
234 const handleLine = (rawLine: string) =>
235 Effect.suspend(() => {
236 const sentinel = input.sentinelPattern.exec(rawLine);
237 if (sentinel) {
238 const parsed = Number(sentinel[1]);
239 return settle(Number.isFinite(parsed) ? parsed : null);
240 }
241 const cleaned = stripTerminalControl(rawLine).trimEnd();
242 if (
243 cleaned.length === 0 ||
244 cleaned.includes(input.sentinel) ||
245 input.echoedWrapperLines.some((echoed) => cleaned.endsWith(echoed)) ||
246 input.onOutputLine === undefined
247 ) {
248 return Effect.void;
249 }
250 return input.onOutputLine(cleaned.slice(0, OUTPUT_LINE_MAX_LENGTH));
251 });
252
253 const unsubscribe = yield* terminalManager.subscribe((event) => {
254 if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) {
255 return Effect.void;
256 }
257 if (event.type === "output") {
258 lineBuffer += event.data;
259 const lines = lineBuffer.split(/\r?\n/);
260 lineBuffer = lines.pop() ?? "";
261 // A script that never prints a newline must not grow this forever.
262 // The sentinel is always on its own line, so keeping the tail is safe.
263 if (lineBuffer.length > PARTIAL_LINE_MAX_LENGTH) {
264 lineBuffer = lineBuffer.slice(-PARTIAL_LINE_MAX_LENGTH);
265 }
266 return Effect.forEach(lines, handleLine, { discard: true });
267 }
268 if (event.type === "exited" || event.type === "closed") {
269 return settle(null);
270 }
271 return Effect.void;
272 });
273
274 const completion = Deferred.await(done).pipe(
275 Effect.ensuring(Effect.sync(() => unsubscribe())),
276 );
277 return { completion, unsubscribe };
278 });
91279
92 const runForThread: ProjectSetupScriptRunner["Service"]["runForThread"] = Effect.fn(280 const runForThread: ProjectSetupScriptRunner["Service"]["runForThread"] = Effect.fn(
93 "ProjectSetupScriptRunner.runForThread",281 "ProjectSetupScriptRunner.runForThread",
...@@ -154,6 +342,16 @@ export const make = Effect.gen(function* () {...@@ -154,6 +342,16 @@ export const make = Effect.gen(function* () {
154 project: { cwd: project.workspaceRoot },342 project: { cwd: project.workspaceRoot },
155 worktreePath: input.worktreePath,343 worktreePath: input.worktreePath,
156 });344 });
345 const observe = input.observeCompletion;
346 const completionToken = observe ? NodeCrypto.randomUUID().replaceAll("-", "") : null;
347 const commandLine =
348 observe && completionToken
349 ? wrapCommandForCompletion(
350 script.command,
351 completionShell,
352 completionSentinel(completionToken),
353 )
354 : script.command;
157355
158 yield* terminalManager356 yield* terminalManager
159 .open({357 .open({
...@@ -173,11 +371,24 @@ export const make = Effect.gen(function* () {...@@ -173,11 +371,24 @@ export const make = Effect.gen(function* () {
173 }),371 }),
174 ),372 ),
175 );373 );
374 // Subscribe before writing so the sentinel cannot race past the listener.
375 const observed =
376 observe && completionToken
377 ? yield* observeTerminalCompletion({
378 threadId: input.threadId,
379 terminalId,
380 sentinel: completionSentinel(completionToken),
381 sentinelPattern: completionSentinelPattern(completionToken),
382 echoedWrapperLines: commandLine.split("\r").filter((line) => line.length > 0),
383 onOutputLine: observe.onOutputLine,
384 })
385 : undefined;
386
176 yield* terminalManager387 yield* terminalManager
177 .write({388 .write({
178 threadId: input.threadId,389 threadId: input.threadId,
179 terminalId,390 terminalId,
180 data: `${script.command}\r`,391 data: `${commandLine}\r`,
181 })392 })
182 .pipe(393 .pipe(
183 Effect.mapError(394 Effect.mapError(
...@@ -188,14 +399,18 @@ export const make = Effect.gen(function* () {...@@ -188,14 +399,18 @@ export const make = Effect.gen(function* () {
188 cause,399 cause,
189 }),400 }),
190 ),401 ),
402 // Nothing will ever settle the completion if the command never ran.
403 Effect.tapError(() => Effect.sync(() => observed?.unsubscribe())),
191 );404 );
192405
193 return {406 return {
194 status: "started",407 status: "started",
195 scriptId: script.id,408 scriptId: script.id,
196 scriptName: script.name,409 scriptName: script.name,
410 scriptCommand: script.command,
197 terminalId,411 terminalId,
198 cwd,412 cwd,
413 ...(observed ? { completion: observed.completion } : {}),
199 } as const;414 } as const;
200 });415 });
201416
apps/server/src/project/WorktreeSetupTracker.test.ts created+226
...@@ -0,0 +1,226 @@
1import { describe, expect, it } from "@effect/vitest";
2import { ThreadId, WorktreeSetupSnapshot } from "@t3tools/contracts";
3import * as Deferred from "effect/Deferred";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";
7import * as Fiber from "effect/Fiber";
8import * as Schema from "effect/Schema";
9import * as Stream from "effect/Stream";
10import * as TestClock from "effect/testing/TestClock";
11
12import * as WorktreeSetupTracker from "./WorktreeSetupTracker.ts";
13
14const threadId = ThreadId.make("thread-1");
15
16describe("WorktreeSetupTracker", () => {
17 it.effect("records stage transitions, checkout progress, and the final phase", () =>
18 Effect.gen(function* () {
19 const tracker = yield* WorktreeSetupTracker.make;
20 yield* tracker.begin({
21 threadId,
22 branch: "feature",
23 baseRef: "main",
24 stages: ["checkout", "fetch", "agent"],
25 fiber: null,
26 });
27
28 const initial = yield* tracker.get(threadId);
29 // Stages are reordered into the canonical setup order.
30 expect(initial?.stages.map((stage) => stage.id)).toEqual(["fetch", "checkout", "agent"]);
31 expect(initial?.phase).toBe("running");
32
33 yield* tracker.stageStatus(threadId, "fetch", "running");
34 yield* tracker.stageStatus(threadId, "fetch", "done", "origin/main at abc1234");
35 yield* tracker.stageStatus(threadId, "checkout", "running");
36 yield* tracker.stage(threadId, "checkout", { percent: 42, detail: "42 / 100 files" });
37 yield* tracker.finish(threadId, "failed", "boom");
38
39 const final = yield* tracker.get(threadId);
40 expect(final?.phase).toBe("failed");
41 expect(final?.error).toBe("boom");
42 const [fetch, checkout, agent] = final?.stages ?? [];
43 expect(fetch).toMatchObject({ status: "done", detail: "origin/main at abc1234" });
44 expect(fetch?.startedAt).not.toBeNull();
45 expect(fetch?.endedAt).not.toBeNull();
46 // A stage still running when the setup fails is marked failed.
47 expect(checkout).toMatchObject({ status: "failed", percent: 42 });
48 expect(agent?.status).toBe("pending");
49 expect(final?.sequence).toBeGreaterThan(initial?.sequence ?? 0);
50 }),
51 );
52
53 it.effect("stream emits the current snapshot first and then only newer ones", () =>
54 Effect.gen(function* () {
55 const tracker = yield* WorktreeSetupTracker.make;
56 yield* tracker.begin({
57 threadId,
58 branch: null,
59 baseRef: null,
60 stages: ["agent"],
61 fiber: null,
62 });
63
64 const collected = yield* tracker.stream(threadId).pipe(
65 Stream.takeUntil((snapshot) => snapshot?.sequence === 2),
66 Stream.runCollect,
67 Effect.forkChild,
68 );
69 yield* Effect.yieldNow;
70 yield* tracker.stageStatus(threadId, "agent", "running");
71 yield* tracker.appendTail(threadId, "agent", "line 1");
72
73 const snapshots = yield* Fiber.join(collected);
74 const sequences = snapshots.map((snapshot) => snapshot?.sequence ?? -1);
75 expect(sequences.at(-1)).toBe(2);
76 // Delivery is latest-value per subscriber, so intermediates may be
77 // skipped but never delivered out of order.
78 expect(sequences).toEqual([...sequences].toSorted((a, b) => a - b));
79 expect(snapshots.at(-1)?.stages[0]?.tail).toEqual(["line 1"]);
80 }),
81 );
82
83 it.effect("stream never steps back behind the snapshot it started from", () =>
84 Effect.gen(function* () {
85 const tracker = yield* WorktreeSetupTracker.make;
86 yield* tracker.begin({
87 threadId,
88 branch: null,
89 baseRef: null,
90 stages: ["agent"],
91 fiber: null,
92 });
93 yield* tracker.stageStatus(threadId, "agent", "running");
94 yield* tracker.stageStatus(threadId, "agent", "done");
95
96 // A late subscriber starts at sequence 2 and must never see 0 or 1.
97 const collected = yield* tracker.stream(threadId).pipe(
98 Stream.takeUntil((snapshot) => snapshot?.phase === "done"),
99 Stream.runCollect,
100 Effect.forkChild,
101 );
102 yield* Effect.yieldNow;
103 yield* tracker.finish(threadId, "done");
104
105 const snapshots = yield* Fiber.join(collected);
106 expect(snapshots.length).toBeGreaterThan(0);
107 expect(snapshots.every((snapshot) => (snapshot?.sequence ?? -1) >= 2)).toBe(true);
108 expect(snapshots.at(-1)?.phase).toBe("done");
109 }),
110 );
111
112 it.effect("a new setup on the same thread keeps sequences increasing", () =>
113 Effect.gen(function* () {
114 const tracker = yield* WorktreeSetupTracker.make;
115 yield* tracker.begin({
116 threadId,
117 branch: "first",
118 baseRef: null,
119 stages: ["agent"],
120 fiber: null,
121 });
122 yield* tracker.finish(threadId, "failed", "boom");
123 const failedSequence = (yield* tracker.get(threadId))?.sequence ?? -1;
124
125 // A stream opened on the failed setup must still receive the next one.
126 const collected = yield* tracker.stream(threadId).pipe(
127 Stream.takeUntil((snapshot) => snapshot?.branch === "second"),
128 Stream.runCollect,
129 Effect.forkChild,
130 );
131 yield* Effect.yieldNow;
132 yield* tracker.begin({
133 threadId,
134 branch: "second",
135 baseRef: null,
136 stages: ["agent"],
137 fiber: null,
138 });
139
140 const snapshots = yield* Fiber.join(collected);
141 const last = snapshots.at(-1);
142 expect(last?.phase).toBe("running");
143 expect(last?.sequence).toBeGreaterThan(failedSequence);
144 }),
145 );
146
147 it.effect("finished setups are dropped after the retention window", () =>
148 Effect.gen(function* () {
149 const tracker = yield* WorktreeSetupTracker.make;
150 yield* tracker.begin({
151 threadId,
152 branch: null,
153 baseRef: null,
154 stages: ["agent"],
155 fiber: null,
156 });
157 yield* tracker.finish(threadId, "done");
158 expect((yield* tracker.get(threadId))?.phase).toBe("done");
159
160 yield* TestClock.adjust(Duration.seconds(31));
161 expect(yield* tracker.get(threadId)).toBeNull();
162 }),
163 );
164
165 it.effect("cancel interrupts the bootstrap fiber and reports whether one was running", () =>
166 Effect.gen(function* () {
167 const tracker = yield* WorktreeSetupTracker.make;
168 const started = yield* Deferred.make<void>();
169 const fiber = yield* Deferred.succeed(started, undefined).pipe(
170 Effect.andThen(Effect.never),
171 Effect.forkChild,
172 );
173 yield* Deferred.await(started);
174 yield* tracker.begin({ threadId, branch: null, baseRef: null, stages: ["agent"], fiber });
175
176 expect(yield* tracker.cancel(threadId)).toBe(true);
177 // cancel returns only after the bootstrap fiber has unwound.
178 const exit = yield* Fiber.await(fiber);
179 expect(Exit.hasInterrupts(exit)).toBe(true);
180
181 yield* tracker.finish(threadId, "cancelled");
182 expect(yield* tracker.cancel(threadId)).toBe(false);
183 expect(yield* tracker.cancel(ThreadId.make("unknown"))).toBe(false);
184 }),
185 );
186
187 it.effect("markUncancellable makes a later cancel a no-op while the setup keeps running", () =>
188 Effect.gen(function* () {
189 const tracker = yield* WorktreeSetupTracker.make;
190 const fiber = yield* Effect.forkChild(Effect.never);
191 yield* tracker.begin({ threadId, branch: null, baseRef: null, stages: ["agent"], fiber });
192
193 yield* tracker.markUncancellable(threadId);
194 expect(yield* tracker.cancel(threadId)).toBe(false);
195 expect((yield* tracker.get(threadId))?.phase).toBe("running");
196 yield* Fiber.interrupt(fiber);
197 }),
198 );
199
200 it.effect("clamps free text to the contract limits before publishing", () =>
201 Effect.gen(function* () {
202 const tracker = yield* WorktreeSetupTracker.make;
203 yield* tracker.begin({
204 threadId,
205 branch: null,
206 baseRef: null,
207 stages: ["checkout", "setup-script"],
208 fiber: null,
209 });
210 const long = "x".repeat(2_000);
211
212 yield* tracker.stageStatus(threadId, "checkout", "done", long);
213 yield* tracker.stage(threadId, "setup-script", { detail: long });
214 yield* tracker.appendTail(threadId, "setup-script", long);
215 yield* tracker.finish(threadId, "failed", long);
216
217 const snapshot = yield* tracker.get(threadId);
218 expect(snapshot?.stages[0]?.detail?.length).toBe(200);
219 expect(snapshot?.stages[1]?.detail?.length).toBe(200);
220 expect(snapshot?.stages[1]?.tail[0]?.length).toBe(400);
221 expect(snapshot?.error?.length).toBe(1000);
222 // The wire schema must accept what the tracker publishes.
223 expect(Schema.is(WorktreeSetupSnapshot)(snapshot)).toBe(true);
224 }),
225 );
226});
apps/server/src/project/WorktreeSetupTracker.ts created+364
...@@ -0,0 +1,364 @@
1import type {
2 ThreadId,
3 WorktreeSetupSnapshot,
4 WorktreeSetupStage,
5 WorktreeSetupStageId,
6 WorktreeSetupStageStatus,
7} from "@t3tools/contracts";
8import {
9 WORKTREE_SETUP_DETAIL_MAX_LENGTH,
10 WORKTREE_SETUP_ERROR_MAX_LENGTH,
11 WORKTREE_SETUP_STAGE_ORDER,
12 WORKTREE_SETUP_TAIL_LINE_MAX_LENGTH,
13} from "@t3tools/contracts";
14import * as Context from "effect/Context";
15import * as DateTime from "effect/DateTime";
16import * as Effect from "effect/Effect";
17import * as Fiber from "effect/Fiber";
18import * as Layer from "effect/Layer";
19import * as PubSub from "effect/PubSub";
20import * as Queue from "effect/Queue";
21import * as Ref from "effect/Ref";
22import * as Stream from "effect/Stream";
23
24/**
25 * Tracks the live stages of a bootstrap worktree setup per thread so clients
26 * can render a progress card while the first turn is still being prepared.
27 *
28 * State is memory only. It exists from the first `begin` until the turn starts
29 * or the setup fails, plus a short grace window so a client that subscribes
30 * late still sees the final state. Nothing here is persisted or event-sourced:
31 * the durable record of a setup is the thread's worktree path and the setup
32 * script activities, both of which already exist.
33 */
34export class WorktreeSetupTracker extends Context.Service<
35 WorktreeSetupTracker,
36 {
37 /** Creates a fresh running snapshot for the thread, replacing any prior one. */
38 readonly begin: (input: {
39 readonly threadId: ThreadId;
40 readonly branch: string | null;
41 readonly baseRef: string | null;
42 readonly stages: ReadonlyArray<WorktreeSetupStageId>;
43 /** Interrupting this fiber cancels the bootstrap. */
44 readonly fiber: Fiber.Fiber<unknown, unknown> | null;
45 }) => Effect.Effect<void>;
46 readonly update: (
47 threadId: ThreadId,
48 mutate: (snapshot: WorktreeSetupSnapshot) => WorktreeSetupSnapshot,
49 ) => Effect.Effect<void>;
50 readonly stage: (
51 threadId: ThreadId,
52 stageId: WorktreeSetupStageId,
53 patch: Partial<Omit<WorktreeSetupStage, "id">>,
54 ) => Effect.Effect<void>;
55 readonly stageStatus: (
56 threadId: ThreadId,
57 stageId: WorktreeSetupStageId,
58 status: WorktreeSetupStageStatus,
59 detail?: string | null,
60 ) => Effect.Effect<void>;
61 readonly appendTail: (
62 threadId: ThreadId,
63 stageId: WorktreeSetupStageId,
64 line: string,
65 ) => Effect.Effect<void>;
66 readonly finish: (
67 threadId: ThreadId,
68 phase: "done" | "failed" | "cancelled",
69 error?: string | null,
70 ) => Effect.Effect<void>;
71 /**
72 * Drops the cancel handle. Called right before the turn is dispatched so a
73 * late cancel cannot roll back a thread whose agent has already started.
74 */
75 readonly markUncancellable: (threadId: ThreadId) => Effect.Effect<void>;
76 /**
77 * Interrupts the running bootstrap and waits for it to unwind, so the
78 * caller's dispatch has already failed and rolled back when this returns.
79 * Returns false when nothing is running or the setup is past cancellation.
80 */
81 readonly cancel: (threadId: ThreadId) => Effect.Effect<boolean>;
82 readonly get: (threadId: ThreadId) => Effect.Effect<WorktreeSetupSnapshot | null>;
83 /** Emits the current snapshot (or null) first, then every change until unsubscribed. */
84 readonly stream: (threadId: ThreadId) => Stream.Stream<WorktreeSetupSnapshot | null>;
85 }
86>()("t3/project/WorktreeSetupTracker") {}
87
88const TAIL_LINE_LIMIT = 4;
89
90/** Keeps free text inside the contract limit, ending in an ellipsis when cut. */
91function clampText(text: string, maxLength: number): string {
92 return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}\u2026`;
93}
94
95const clampDetail = (detail: string | null): string | null =>
96 detail === null ? null : clampText(detail, WORKTREE_SETUP_DETAIL_MAX_LENGTH);
97/** Finished snapshots stay visible this long so a late subscriber sees the outcome. */
98const FINISHED_RETENTION = "30 seconds";
99
100const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
101
102interface TrackedSetup {
103 readonly snapshot: WorktreeSetupSnapshot;
104 readonly fiber: Fiber.Fiber<unknown, unknown> | null;
105}
106
107function emptyStage(id: WorktreeSetupStageId): WorktreeSetupStage {
108 return {
109 id,
110 status: "pending",
111 startedAt: null,
112 endedAt: null,
113 percent: null,
114 detail: null,
115 tail: [],
116 };
117}
118
119export const make = Effect.gen(function* () {
120 const setups = yield* Ref.make(new Map<ThreadId, TrackedSetup>());
121 const changes = yield* PubSub.unbounded<{
122 readonly threadId: ThreadId;
123 readonly snapshot: WorktreeSetupSnapshot | null;
124 }>();
125 const retentionFibers = new Map<ThreadId, Fiber.Fiber<void, never>>();
126 // Sequences keep increasing across setups of the same thread so a stream
127 // opened during a previous setup still accepts the next one's first snapshot.
128 const lastSequenceByThread = new Map<ThreadId, number>();
129
130 const publish = (threadId: ThreadId, snapshot: WorktreeSetupSnapshot | null) =>
131 PubSub.publish(changes, { threadId, snapshot }).pipe(Effect.asVoid);
132
133 const modify = (
134 threadId: ThreadId,
135 mutate: (tracked: TrackedSetup) => TrackedSetup,
136 ): Effect.Effect<WorktreeSetupSnapshot | null> =>
137 Ref.modify(setups, (current) => {
138 const existing = current.get(threadId);
139 if (!existing) return [null, current] as const;
140 const nextTracked = mutate(existing);
141 const nextSnapshot = {
142 ...nextTracked.snapshot,
143 sequence: existing.snapshot.sequence + 1,
144 };
145 lastSequenceByThread.set(threadId, nextSnapshot.sequence);
146 const next = new Map(current);
147 next.set(threadId, { ...nextTracked, snapshot: nextSnapshot });
148 return [nextSnapshot, next] as const;
149 }).pipe(Effect.tap((snapshot) => (snapshot ? publish(threadId, snapshot) : Effect.void)));
150
151 const clearRetention = (threadId: ThreadId) => {
152 const fiber = retentionFibers.get(threadId);
153 retentionFibers.delete(threadId);
154 return fiber ? Fiber.interrupt(fiber).pipe(Effect.ignore) : Effect.void;
155 };
156
157 const remove = (threadId: ThreadId) =>
158 Ref.update(setups, (current) => {
159 if (!current.has(threadId)) return current;
160 const next = new Map(current);
161 next.delete(threadId);
162 return next;
163 }).pipe(
164 Effect.andThen(publish(threadId, null)),
165 // A subscriber that outlives retention sees `null` here and accepts any
166 // sequence after it, so the counter can start over for this thread.
167 Effect.tap(() => Effect.sync(() => lastSequenceByThread.delete(threadId))),
168 );
169
170 const begin: WorktreeSetupTracker["Service"]["begin"] = (input) =>
171 Effect.gen(function* () {
172 yield* clearRetention(input.threadId);
173 const startedAt = yield* nowIso;
174 const ordered = WORKTREE_SETUP_STAGE_ORDER.filter((id) => input.stages.includes(id));
175 const snapshot: WorktreeSetupSnapshot = {
176 threadId: input.threadId,
177 phase: "running",
178 startedAt,
179 endedAt: null,
180 branch: input.branch,
181 baseRef: input.baseRef,
182 worktreePath: null,
183 setupScript: null,
184 stages: ordered.map(emptyStage),
185 error: null,
186 sequence: (lastSequenceByThread.get(input.threadId) ?? -1) + 1,
187 };
188 lastSequenceByThread.set(input.threadId, snapshot.sequence);
189 yield* Ref.update(setups, (current) => {
190 const next = new Map(current);
191 next.set(input.threadId, { snapshot, fiber: input.fiber });
192 return next;
193 });
194 yield* publish(input.threadId, snapshot);
195 });
196
197 const update: WorktreeSetupTracker["Service"]["update"] = (threadId, mutate) =>
198 modify(threadId, (tracked) => ({ ...tracked, snapshot: mutate(tracked.snapshot) })).pipe(
199 Effect.asVoid,
200 );
201
202 const stage: WorktreeSetupTracker["Service"]["stage"] = (threadId, stageId, patch) =>
203 update(threadId, (snapshot) => ({
204 ...snapshot,
205 stages: snapshot.stages.map((entry) =>
206 entry.id === stageId
207 ? {
208 ...entry,
209 ...patch,
210 ...(patch.detail === undefined ? {} : { detail: clampDetail(patch.detail ?? null) }),
211 }
212 : entry,
213 ),
214 }));
215
216 const stageStatus: WorktreeSetupTracker["Service"]["stageStatus"] = (
217 threadId,
218 stageId,
219 status,
220 detail,
221 ) =>
222 nowIso.pipe(
223 Effect.flatMap((at) =>
224 update(threadId, (snapshot) => ({
225 ...snapshot,
226 stages: snapshot.stages.map((entry) => {
227 if (entry.id !== stageId) return entry;
228 const startedAt = entry.startedAt ?? (status === "pending" ? null : at);
229 const endedAt =
230 status === "running" || status === "pending" ? null : (entry.endedAt ?? at);
231 return {
232 ...entry,
233 status,
234 startedAt,
235 endedAt,
236 ...(detail === undefined ? {} : { detail: clampDetail(detail ?? null) }),
237 };
238 }),
239 })),
240 ),
241 );
242
243 const appendTail: WorktreeSetupTracker["Service"]["appendTail"] = (threadId, stageId, line) =>
244 update(threadId, (snapshot) => ({
245 ...snapshot,
246 stages: snapshot.stages.map((entry) =>
247 entry.id === stageId
248 ? {
249 ...entry,
250 tail: [...entry.tail, clampText(line, WORKTREE_SETUP_TAIL_LINE_MAX_LENGTH)].slice(
251 -TAIL_LINE_LIMIT,
252 ),
253 }
254 : entry,
255 ),
256 }));
257
258 const finish: WorktreeSetupTracker["Service"]["finish"] = (threadId, phase, error) =>
259 Effect.gen(function* () {
260 const endedAt = yield* nowIso;
261 const snapshot = yield* modify(threadId, (tracked) => ({
262 fiber: null,
263 snapshot: {
264 ...tracked.snapshot,
265 phase,
266 endedAt,
267 error:
268 error === undefined || error === null
269 ? null
270 : clampText(error, WORKTREE_SETUP_ERROR_MAX_LENGTH),
271 stages: tracked.snapshot.stages.map((entry) =>
272 entry.status === "running"
273 ? {
274 ...entry,
275 status: phase === "done" ? "done" : phase === "cancelled" ? "skipped" : "failed",
276 endedAt,
277 }
278 : entry,
279 ),
280 },
281 }));
282 if (!snapshot) return;
283 yield* clearRetention(threadId);
284 const fiber = yield* remove(threadId).pipe(
285 Effect.delay(FINISHED_RETENTION),
286 Effect.ensuring(
287 Effect.sync(() => {
288 // Only drop our own entry: a newer setup may have replaced it.
289 if (retentionFibers.get(threadId) === fiber) retentionFibers.delete(threadId);
290 }),
291 ),
292 Effect.forkDetach,
293 );
294 retentionFibers.set(threadId, fiber);
295 });
296
297 const markUncancellable: WorktreeSetupTracker["Service"]["markUncancellable"] = (threadId) =>
298 Ref.update(setups, (current) => {
299 const existing = current.get(threadId);
300 if (!existing || existing.fiber === null) return current;
301 const next = new Map(current);
302 next.set(threadId, { ...existing, fiber: null });
303 return next;
304 });
305
306 const cancel: WorktreeSetupTracker["Service"]["cancel"] = (threadId) =>
307 Effect.gen(function* () {
308 const current = yield* Ref.get(setups);
309 const tracked = current.get(threadId);
310 if (!tracked || tracked.snapshot.phase !== "running" || !tracked.fiber) {
311 return false;
312 }
313 yield* Fiber.interrupt(tracked.fiber);
314 return true;
315 });
316
317 const get: WorktreeSetupTracker["Service"]["get"] = (threadId) =>
318 Ref.get(setups).pipe(Effect.map((current) => current.get(threadId)?.snapshot ?? null));
319
320 /**
321 * Each subscriber gets a one-slot sliding mailbox: a slow WebSocket only
322 * ever holds the newest snapshot, so a chatty setup script cannot grow the
323 * server heap. Snapshots are whole states, so skipping intermediates is safe.
324 */
325 const stream: WorktreeSetupTracker["Service"]["stream"] = (threadId) =>
326 Stream.callback<WorktreeSetupSnapshot | null>(
327 (mailbox) =>
328 Effect.gen(function* () {
329 const subscription = yield* PubSub.subscribe(changes);
330 const initial = yield* get(threadId);
331 // Changes published between subscribing and reading `initial` are
332 // already folded into it. Drop them so the client never steps back.
333 let lastSequence = initial?.sequence ?? -1;
334 Queue.offerUnsafe(mailbox, initial);
335 yield* Stream.fromSubscription(subscription).pipe(
336 Stream.runForEach((change) =>
337 Effect.sync(() => {
338 if (change.threadId !== threadId) return;
339 if (change.snapshot !== null && change.snapshot.sequence <= lastSequence) return;
340 lastSequence = change.snapshot?.sequence ?? -1;
341 Queue.offerUnsafe(mailbox, change.snapshot);
342 }),
343 ),
344 Effect.forkScoped,
345 );
346 }),
347 { bufferSize: 1, strategy: "sliding" },
348 );
349
350 return WorktreeSetupTracker.of({
351 begin,
352 update,
353 stage,
354 stageStatus,
355 appendTail,
356 finish,
357 markUncancellable,
358 cancel,
359 get,
360 stream,
361 });
362});
363
364export const layer = Layer.effect(WorktreeSetupTracker, make);
apps/server/src/server.test.ts+26-9
...@@ -148,6 +148,7 @@ import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";...@@ -148,6 +148,7 @@ import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
148import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts";148import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts";
149import * as ServerSettings from "./serverSettings.ts";149import * as ServerSettings from "./serverSettings.ts";
150import * as TerminalManager from "./terminal/Manager.ts";150import * as TerminalManager from "./terminal/Manager.ts";
151import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts";
151import * as PreviewManager from "./preview/Manager.ts";152import * as PreviewManager from "./preview/Manager.ts";
152import * as PortScanner from "./preview/PortScanner.ts";153import * as PortScanner from "./preview/PortScanner.ts";
153import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";154import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";
...@@ -924,9 +925,12 @@ const buildAppUnderTest = (options?: {...@@ -924,9 +925,12 @@ const buildAppUnderTest = (options?: {
924 }),925 }),
925 ),926 ),
926 Layer.provide(927 Layer.provide(
927 Layer.mock(TerminalManager.TerminalManager)({928 Layer.mergeAll(
928 ...options?.layers?.terminalManager,929 Layer.mock(TerminalManager.TerminalManager)({
929 }),930 ...options?.layers?.terminalManager,
931 }),
932 WorktreeSetupTracker.layer,
933 ),
930 ),934 ),
931 Layer.provide(935 Layer.provide(
932 Layer.mergeAll(936 Layer.mergeAll(
...@@ -10558,6 +10562,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -10558,6 +10562,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
10558 status: "started" as const,10562 status: "started" as const,
10559 scriptId: "setup",10563 scriptId: "setup",
10560 scriptName: "Setup",10564 scriptName: "Setup",
10565 scriptCommand: "npm install",
10561 terminalId: "setup-setup",10566 terminalId: "setup-setup",
10562 cwd: "/tmp/bootstrap-worktree",10567 cwd: "/tmp/bootstrap-worktree",
10563 }),10568 }),
...@@ -10673,12 +10678,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -10673,12 +10678,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
10673 "resolve-remote-commit",10678 "resolve-remote-commit",
10674 "create-worktree",10679 "create-worktree",
10675 ]);10680 ]);
10676 assert.deepEqual(runForThread.mock.calls[0]?.[0], {10681 const runForThreadInput = runForThread.mock.calls[0]?.[0];
10677 threadId: ThreadId.make("thread-bootstrap"),10682 assert.deepEqual(
10678 projectId: defaultProjectId,10683 runForThreadInput && {
10679 projectCwd: "/tmp/project",10684 threadId: runForThreadInput.threadId,
10680 worktreePath: "/tmp/bootstrap-worktree",10685 projectId: runForThreadInput.projectId,
10681 });10686 projectCwd: runForThreadInput.projectCwd,
10687 worktreePath: runForThreadInput.worktreePath,
10688 },
10689 {
10690 threadId: ThreadId.make("thread-bootstrap"),
10691 projectId: defaultProjectId,
10692 projectCwd: "/tmp/project",
10693 worktreePath: "/tmp/bootstrap-worktree",
10694 },
10695 );
10696 // Worktree bootstraps observe script completion so the setup card can show the exit code.
10697 assert.isDefined(runForThreadInput?.observeCompletion);
10682 assert.deepEqual(refreshStatus.mock.calls[0]?.[0], "/tmp/bootstrap-worktree");10698 assert.deepEqual(refreshStatus.mock.calls[0]?.[0], "/tmp/bootstrap-worktree");
1068310699
10684 const setupActivities = dispatchedCommands.filter(10700 const setupActivities = dispatchedCommands.filter(
...@@ -11111,6 +11127,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -11111,6 +11127,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
11111 status: "started" as const,11127 status: "started" as const,
11112 scriptId: "setup",11128 scriptId: "setup",
11113 scriptName: "Setup",11129 scriptName: "Setup",
11130 scriptCommand: "npm install",
11114 terminalId: "setup-setup",11131 terminalId: "setup-setup",
11115 cwd: "/tmp/bootstrap-worktree",11132 cwd: "/tmp/bootstrap-worktree",
11116 }),11133 }),
apps/server/src/server.ts+2
...@@ -110,6 +110,7 @@ import * as PullRequestReadCache from "./pullRequest/PullRequestReadCache.ts";...@@ -110,6 +110,7 @@ import * as PullRequestReadCache from "./pullRequest/PullRequestReadCache.ts";
110import * as SourceControlRateLimit from "./sourceControl/SourceControlRateLimit.ts";110import * as SourceControlRateLimit from "./sourceControl/SourceControlRateLimit.ts";
111import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";111import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
112import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";112import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
113import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts";
113import { ObservabilityLive } from "./observability/Layers/Observability.ts";114import { ObservabilityLive } from "./observability/Layers/Observability.ts";
114import * as ServerEnvironment from "./environment/ServerEnvironment.ts";115import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
115import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";116import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";
...@@ -332,6 +333,7 @@ const PullRequestServiceLive = PullRequestService.layer.pipe(...@@ -332,6 +333,7 @@ const PullRequestServiceLive = PullRequestService.layer.pipe(
332333
333const GitManagerLayerLive = GitManager.layer.pipe(334const GitManagerLayerLive = GitManager.layer.pipe(
334 Layer.provideMerge(ProjectSetupScriptRunner.layer.pipe(Layer.provide(ServerSettingsLayerLive))),335 Layer.provideMerge(ProjectSetupScriptRunner.layer.pipe(Layer.provide(ServerSettingsLayerLive))),
336 Layer.provideMerge(WorktreeSetupTracker.layer),
335 Layer.provideMerge(GitVcsDriver.layer),337 Layer.provideMerge(GitVcsDriver.layer),
336 Layer.provideMerge(SourceControlProviderRegistryLayerLive),338 Layer.provideMerge(SourceControlProviderRegistryLayerLive),
337 Layer.provideMerge(TextGeneration.layer),339 Layer.provideMerge(TextGeneration.layer),
apps/server/src/vcs/GitVcsDriver.ts+31
...@@ -102,6 +102,36 @@ export interface ExecuteGitProgress {...@@ -102,6 +102,36 @@ export interface ExecuteGitProgress {
102 }) => Effect.Effect<void, never>;102 }) => Effect.Effect<void, never>;
103}103}
104104
105/**
106 * Progress callbacks for `createWorktree`. Git prints `Updating files: 78% (2104/2700)`
107 * to stderr during checkout, and `Submodule path 'x': checked out` during
108 * submodule init. The tracker uses these to drive the worktree setup card.
109 */
110export interface CreateWorktreeProgress {
111 /**
112 * Fires once `git worktree add` has created and registered the directory,
113 * before the (possibly long) submodule step. Git refuses an existing path,
114 * so a path reported here belongs to this call and is safe to remove on
115 * cancel.
116 */
117 readonly onWorktreeClaimed?: (path: string) => Effect.Effect<void, never>;
118 readonly onCheckoutProgress?: (input: {
119 percent: number;
120 completed: number;
121 total: number;
122 }) => Effect.Effect<void, never>;
123 readonly onSubmodulesStarted?: () => Effect.Effect<void, never>;
124 readonly onSubmoduleLine?: (line: string) => Effect.Effect<void, never>;
125 readonly onSubmodulesFinished?: (input: {
126 ok: boolean;
127 detail: string | null;
128 }) => Effect.Effect<void, never>;
129}
130
131export interface CreateWorktreeOptions {
132 readonly progress?: CreateWorktreeProgress;
133}
134
105export interface GitCommitProgress {135export interface GitCommitProgress {
106 readonly onOutputLine?: (input: {136 readonly onOutputLine?: (input: {
107 stream: "stdout" | "stderr";137 stream: "stdout" | "stderr";
...@@ -280,6 +310,7 @@ export class GitVcsDriver extends Context.Service<...@@ -280,6 +310,7 @@ export class GitVcsDriver extends Context.Service<
280 readonly pullCurrentBranch: (cwd: string) => Effect.Effect<VcsPullResult, GitCommandError>;310 readonly pullCurrentBranch: (cwd: string) => Effect.Effect<VcsPullResult, GitCommandError>;
281 readonly createWorktree: (311 readonly createWorktree: (
282 input: VcsCreateWorktreeInput,312 input: VcsCreateWorktreeInput,
313 options?: CreateWorktreeOptions,
283 ) => Effect.Effect<VcsCreateWorktreeResult, GitCommandError>;314 ) => Effect.Effect<VcsCreateWorktreeResult, GitCommandError>;
284 readonly fetchPullRequestBranch: (315 readonly fetchPullRequestBranch: (
285 input: GitFetchPullRequestBranchInput,316 input: GitFetchPullRequestBranchInput,
apps/server/src/vcs/GitVcsDriverCore.test.ts+66-1
...@@ -20,7 +20,11 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";...@@ -20,7 +20,11 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2020
21import { GitCommandError, type ReviewDiffFileContentsInput } from "@t3tools/contracts";21import { GitCommandError, type ReviewDiffFileContentsInput } from "@t3tools/contracts";
22import { ServerConfig } from "../config.ts";22import { ServerConfig } from "../config.ts";
23import { makeGitVcsDriverCore, splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts";23import {
24 makeGitVcsDriverCore,
25 parseGitCheckoutProgressLine,
26 splitNullSeparatedGitStdoutPaths,
27} from "./GitVcsDriverCore.ts";
24import * as GitVcsDriver from "./GitVcsDriver.ts";28import * as GitVcsDriver from "./GitVcsDriver.ts";
2529
26const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {30const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
...@@ -1553,6 +1557,20 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {...@@ -1553,6 +1557,20 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
1553 });1557 });
15541558
1555 describe("worktree operations", () => {1559 describe("worktree operations", () => {
1560 it("parses checkout progress lines from git's stderr", () => {
1561 assert.deepStrictEqual(parseGitCheckoutProgressLine("Updating files: 78% (2104/2700)"), {
1562 percent: 78,
1563 completed: 2104,
1564 total: 2700,
1565 });
1566 // Progress lines arrive carriage-return separated and end with a done marker.
1567 assert.deepStrictEqual(
1568 parseGitCheckoutProgressLine("Updating files: 100% (2700/2700), done."),
1569 { percent: 100, completed: 2700, total: 2700 },
1570 );
1571 assert.strictEqual(parseGitCheckoutProgressLine("Preparing worktree (new branch 'x')"), null);
1572 });
1573
1556 // NTFS rejects a newline in a file name, so there is nothing to preserve there.1574 // NTFS rejects a newline in a file name, so there is nothing to preserve there.
1557 it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")(1575 it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")(
1558 "preserves newline characters in worktree paths when listing refs",1576 "preserves newline characters in worktree paths when listing refs",
...@@ -1668,6 +1686,53 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {...@@ -1668,6 +1686,53 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
1668 }),1686 }),
1669 );1687 );
16701688
1689 it.effect("reports checkout progress while creating a worktree", () =>
1690 Effect.gen(function* () {
1691 const cwd = yield* makeTmpDir();
1692 const { initialBranch } = yield* initRepoWithCommit(cwd);
1693 for (let index = 0; index < 5; index += 1) {
1694 yield* writeTextFile(cwd, `file-${index}.txt`, `${index}\n`);
1695 }
1696 yield* git(cwd, ["add", "."]);
1697 yield* git(cwd, ["commit", "-m", "add files"]);
1698 const pathService = yield* Path.Path;
1699 const worktreePath = pathService.join(
1700 yield* makeTmpDir("git-worktrees-"),
1701 "progress-worktree",
1702 );
1703 const driver = yield* GitVcsDriver.GitVcsDriver;
1704 const seen = yield* Ref.make<Array<{ percent: number; completed: number; total: number }>>(
1705 [],
1706 );
1707 const claimed = yield* Ref.make<{ path: string; existed: boolean } | null>(null);
1708
1709 yield* driver.createWorktree(
1710 { cwd, path: worktreePath, refName: initialBranch, newRefName: "feature/progress" },
1711 {
1712 progress: {
1713 onWorktreeClaimed: (path) =>
1714 Ref.set(claimed, { path, existed: NodeFS.existsSync(path) }),
1715 onCheckoutProgress: (update) => Ref.update(seen, (all) => [...all, update]),
1716 },
1717 },
1718 );
1719 // Claimed only once git has registered the directory.
1720 assert.deepEqual(yield* Ref.get(claimed), { path: worktreePath, existed: true });
1721
1722 // Git separates live progress updates with `\r`, so the driver must
1723 // surface every intermediate percentage, not just the final line.
1724 const updates = yield* Ref.get(seen);
1725 assert.isAbove(updates.length, 1);
1726 assert.equal(updates.at(-1)?.percent, 100);
1727 assert.equal(updates.at(-1)?.total, 6);
1728 const completed = updates.map((update) => update.completed);
1729 assert.deepEqual(
1730 completed,
1731 completed.toSorted((a, b) => a - b),
1732 );
1733 }),
1734 );
1735
1671 it.effect("creates and removes a worktree for a new refName", () =>1736 it.effect("creates and removes a worktree for a new refName", () =>
1672 Effect.gen(function* () {1737 Effect.gen(function* () {
1673 const cwd = yield* makeTmpDir();1738 const cwd = yield* makeTmpDir();
apps/server/src/vcs/GitVcsDriverCore.ts+81-20
...@@ -641,6 +641,25 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* (...@@ -641,6 +641,25 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* (
641 };641 };
642});642});
643643
644const GIT_CHECKOUT_PROGRESS_LINE = /Updating files:\s+(\d+)%\s+\((\d+)\/(\d+)\)/;
645
646/** Parses `Updating files: 78% (2104/2700)` from git's stderr progress output. */
647export function parseGitCheckoutProgressLine(
648 line: string,
649): { percent: number; completed: number; total: number } | null {
650 const match = GIT_CHECKOUT_PROGRESS_LINE.exec(line);
651 if (!match) return null;
652 const percent = Number(match[1]);
653 const completed = Number(match[2]);
654 const total = Number(match[3]);
655 if (!Number.isFinite(percent) || !Number.isFinite(completed) || !Number.isFinite(total)) {
656 return null;
657 }
658 return { percent: Math.max(0, Math.min(100, percent)), completed, total };
659}
660
661const OUTPUT_LINE_SEPARATOR = /\r\n|\r|\n/;
662
644const collectOutput = Effect.fnUntraced(function* (663const collectOutput = Effect.fnUntraced(function* (
645 input: Pick<GitVcsDriver.ExecuteGitInput, "operation" | "cwd" | "args">,664 input: Pick<GitVcsDriver.ExecuteGitInput, "operation" | "cwd" | "args">,
646 stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>,665 stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>,
...@@ -654,19 +673,21 @@ const collectOutput = Effect.fnUntraced(function* (...@@ -654,19 +673,21 @@ const collectOutput = Effect.fnUntraced(function* (
654 let lineBuffer = "";673 let lineBuffer = "";
655 let truncated = false;674 let truncated = false;
656675
676 // Git redraws progress with a bare `\r` between updates and only ends the
677 // line once the step is done, so `\r` has to count as a line break here.
657 const emitCompleteLines = Effect.fnUntraced(function* (flush: boolean) {678 const emitCompleteLines = Effect.fnUntraced(function* (flush: boolean) {
658 let newlineIndex = lineBuffer.indexOf("\n");679 let separator = OUTPUT_LINE_SEPARATOR.exec(lineBuffer);
659 while (newlineIndex >= 0) {680 while (separator) {
660 const line = lineBuffer.slice(0, newlineIndex).replace(/\r$/, "");681 const line = lineBuffer.slice(0, separator.index);
661 lineBuffer = lineBuffer.slice(newlineIndex + 1);682 lineBuffer = lineBuffer.slice(separator.index + separator[0].length);
662 if (line.length > 0 && onLine) {683 if (line.length > 0 && onLine) {
663 yield* onLine(line);684 yield* onLine(line);
664 }685 }
665 newlineIndex = lineBuffer.indexOf("\n");686 separator = OUTPUT_LINE_SEPARATOR.exec(lineBuffer);
666 }687 }
667688
668 if (flush) {689 if (flush) {
669 const trailing = lineBuffer.replace(/\r$/, "");690 const trailing = lineBuffer;
670 lineBuffer = "";691 lineBuffer = "";
671 if (trailing.length > 0 && onLine) {692 if (trailing.length > 0 && onLine) {
672 yield* onLine(trailing);693 yield* onLine(trailing);
...@@ -3010,7 +3031,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -3010,7 +3031,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
30103031
3011 const createWorktree: GitVcsDriver.GitVcsDriver["Service"]["createWorktree"] = Effect.fn(3032 const createWorktree: GitVcsDriver.GitVcsDriver["Service"]["createWorktree"] = Effect.fn(
3012 "createWorktree",3033 "createWorktree",
3013 )(function* (input) {3034 )(function* (input, options) {
3014 const targetBranch = input.newRefName ?? input.refName;3035 const targetBranch = input.newRefName ?? input.refName;
3015 const sanitizedBranch = targetBranch.replace(/\//g, "-");3036 const sanitizedBranch = targetBranch.replace(/\//g, "-");
3016 const repoName = path.basename(input.cwd);3037 const repoName = path.basename(input.cwd);
...@@ -3018,12 +3039,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -3018,12 +3039,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
3018 const args = input.newRefName3039 const args = input.newRefName
3019 ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName]3040 ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName]
3020 : ["worktree", "add", worktreePath, input.refName];3041 : ["worktree", "add", worktreePath, input.refName];
3042 const progress = options?.progress;
3043 const onCheckoutProgress = progress?.onCheckoutProgress;
30213044
3022 yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, {3045 yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, {
3023 fallbackErrorDetail: "git worktree add failed",3046 fallbackErrorDetail: "git worktree add failed",
3024 timeoutMs: WORKTREE_ADD_TIMEOUT_MS,3047 timeoutMs: WORKTREE_ADD_TIMEOUT_MS,
3048 ...(onCheckoutProgress
3049 ? {
3050 // Git only prints checkout progress when stderr is a tty or the
3051 // delay elapsed. GIT_PROGRESS_DELAY=0 forces it through the pipe.
3052 env: { GIT_PROGRESS_DELAY: "0", LC_ALL: "C" },
3053 progress: {
3054 onStderrLine: (line) => {
3055 const parsed = parseGitCheckoutProgressLine(line);
3056 return parsed ? onCheckoutProgress(parsed) : Effect.void;
3057 },
3058 },
3059 }
3060 : {}),
3025 });3061 });
30263062
3063 if (progress?.onWorktreeClaimed) {
3064 yield* progress.onWorktreeClaimed(worktreePath);
3065 }
3066
3027 // `git worktree add` leaves submodules empty, so a repo that keeps agent3067 // `git worktree add` leaves submodules empty, so a repo that keeps agent
3028 // skills, tooling or source in one gets a worktree that is quietly missing3068 // skills, tooling or source in one gets a worktree that is quietly missing
3029 // them. Best-effort: the objects are usually already in the parent's3069 // them. Best-effort: the objects are usually already in the parent's
...@@ -3033,18 +3073,38 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -3033,18 +3073,38 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
3033 .exists(path.join(worktreePath, ".gitmodules"))3073 .exists(path.join(worktreePath, ".gitmodules"))
3034 .pipe(Effect.orElseSucceed(() => false));3074 .pipe(Effect.orElseSucceed(() => false));
3035 if (hasSubmodules) {3075 if (hasSubmodules) {
3036 yield* runGit("GitVcsDriver.createWorktree.updateSubmodules", worktreePath, [3076 if (progress?.onSubmodulesStarted) {
3037 "submodule",3077 yield* progress.onSubmodulesStarted();
3038 "update",3078 }
3039 "--init",3079 const onSubmoduleLine = progress?.onSubmoduleLine;
3040 "--recursive",3080 yield* runGit(
3041 ]).pipe(3081 "GitVcsDriver.createWorktree.updateSubmodules",
3042 Effect.catch((cause) =>3082 worktreePath,
3043 Effect.logWarning("worktree submodule checkout failed; submodule paths are empty", {3083 ["submodule", "update", "--init", "--recursive"],
3044 worktreePath,3084 onSubmoduleLine
3045 cause,3085 ? {
3046 }),3086 env: { LC_ALL: "C" },
3047 ),3087 progress: { onStdoutLine: onSubmoduleLine, onStderrLine: onSubmoduleLine },
3088 }
3089 : {},
3090 ).pipe(
3091 Effect.matchEffect({
3092 onFailure: (cause) =>
3093 Effect.logWarning("worktree submodule checkout failed; submodule paths are empty", {
3094 worktreePath,
3095 cause,
3096 }).pipe(
3097 Effect.andThen(
3098 progress?.onSubmodulesFinished
3099 ? progress.onSubmodulesFinished({ ok: false, detail: cause.message })
3100 : Effect.void,
3101 ),
3102 ),
3103 onSuccess: () =>
3104 progress?.onSubmodulesFinished
3105 ? progress.onSubmodulesFinished({ ok: true, detail: null })
3106 : Effect.void,
3107 }),
3048 );3108 );
3049 }3109 }
30503110
...@@ -3496,7 +3556,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -3496,7 +3556,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
3496 getReviewDiffFileContents,3556 getReviewDiffFileContents,
3497 readConfigValue,3557 readConfigValue,
3498 listRefs,3558 listRefs,
3499 createWorktree: (input) => withListRefsInvalidation(input.cwd, createWorktree(input)),3559 createWorktree: (input, options) =>
3560 withListRefsInvalidation(input.cwd, createWorktree(input, options)),
3500 fetchPullRequestBranch: (input) =>3561 fetchPullRequestBranch: (input) =>
3501 withListRefsInvalidation(input.cwd, fetchPullRequestBranch(input)),3562 withListRefsInvalidation(input.cwd, fetchPullRequestBranch(input)),
3502 fetchPullRequestHeadCommit,3563 fetchPullRequestHeadCommit,
apps/server/src/ws.ts+319-37
...@@ -7,11 +7,13 @@ import * as Crypto from "effect/Crypto";...@@ -7,11 +7,13 @@ import * as Crypto from "effect/Crypto";
7import * as DateTime from "effect/DateTime";7import * as DateTime from "effect/DateTime";
8import * as Duration from "effect/Duration";8import * as Duration from "effect/Duration";
9import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
10import * as Fiber from "effect/Fiber";
10import * as Layer from "effect/Layer";11import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";12import * as Option from "effect/Option";
12import * as Path from "effect/Path";13import * as Path from "effect/Path";
13import * as Queue from "effect/Queue";14import * as Queue from "effect/Queue";
14import * as Ref from "effect/Ref";15import * as Ref from "effect/Ref";
16import * as Schedule from "effect/Schedule";
15import * as Schema from "effect/Schema";17import * as Schema from "effect/Schema";
16import * as Stream from "effect/Stream";18import * as Stream from "effect/Stream";
17import {19import {
...@@ -129,6 +131,7 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts";...@@ -129,6 +131,7 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts";
129import { linkCreatedPullRequest } from "./git/linkCreatedPullRequest.ts";131import { linkCreatedPullRequest } from "./git/linkCreatedPullRequest.ts";
130import * as ReviewService from "./review/ReviewService.ts";132import * as ReviewService from "./review/ReviewService.ts";
131import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";133import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
134import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts";
132import * as AgentSessionScanner from "./project/AgentSessionScanner.ts";135import * as AgentSessionScanner from "./project/AgentSessionScanner.ts";
133import { importRecentAgentThreads } from "./project/AgentSessionImporter.ts";136import { importRecentAgentThreads } from "./project/AgentSessionImporter.ts";
134import * as ServerEnvironment from "./environment/ServerEnvironment.ts";137import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
...@@ -597,6 +600,7 @@ const makeWsRpcLayer = (...@@ -597,6 +600,7 @@ const makeWsRpcLayer = (
597 return true;600 return true;
598 });601 });
599 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;602 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
603 const worktreeSetupTracker = yield* WorktreeSetupTracker.WorktreeSetupTracker;
600 const agentSessionScanner = yield* AgentSessionScanner.AgentSessionScanner;604 const agentSessionScanner = yield* AgentSessionScanner.AgentSessionScanner;
601 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;605 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
602 const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy;606 const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy;
...@@ -991,6 +995,9 @@ const makeWsRpcLayer = (...@@ -991,6 +995,9 @@ const makeWsRpcLayer = (
991 let targetProjectId = bootstrap?.createThread?.projectId;995 let targetProjectId = bootstrap?.createThread?.projectId;
992 let targetProjectCwd = bootstrap?.prepareWorktree?.projectCwd;996 let targetProjectCwd = bootstrap?.prepareWorktree?.projectCwd;
993 let targetWorktreePath = bootstrap?.createThread?.worktreePath ?? null;997 let targetWorktreePath = bootstrap?.createThread?.worktreePath ?? null;
998 // The setup script's terminal, once started. Cancel closes only this
999 // one so terminals the user opened meanwhile survive.
1000 let setupTerminalId: string | null = null;
9941001
995 const cleanupCreatedThread = () =>1002 const cleanupCreatedThread = () =>
996 createdThread1003 createdThread
...@@ -1083,19 +1090,37 @@ const makeWsRpcLayer = (...@@ -1083,19 +1090,37 @@ const makeWsRpcLayer = (
1083 );1090 );
1084 });1091 });
10851092
1093 const tracked = bootstrap?.prepareWorktree !== undefined;
1094 const threadId = command.threadId;
1095 const track = (effect: Effect.Effect<void>) => (tracked ? effect : Effect.void);
1096
1097 // Runs the setup script and, for tracked bootstraps, waits for it to
1098 // exit so the card can show the exit code and the agent stage never
1099 // starts on a half-installed tree. Untracked callers keep the old
1100 // fire-and-forget behavior.
1086 const runSetupProgram = () =>1101 const runSetupProgram = () =>
1087 Effect.gen(function* () {1102 Effect.gen(function* () {
1088 if (!bootstrap?.runSetupScript || !targetWorktreePath) {1103 if (!bootstrap?.runSetupScript || !targetWorktreePath) {
1104 yield* track(worktreeSetupTracker.stageStatus(threadId, "setup-script", "skipped"));
1089 return;1105 return;
1090 }1106 }
1091 const worktreePath = targetWorktreePath;1107 const worktreePath = targetWorktreePath;
1092 const requestedAt = yield* nowIso;1108 const requestedAt = yield* nowIso;
1093 yield* projectSetupScriptRunner1109 yield* track(worktreeSetupTracker.stageStatus(threadId, "setup-script", "running"));
1110 const setupResult = yield* projectSetupScriptRunner
1094 .runForThread({1111 .runForThread({
1095 threadId: command.threadId,1112 threadId,
1096 ...(targetProjectId ? { projectId: targetProjectId } : {}),1113 ...(targetProjectId ? { projectId: targetProjectId } : {}),
1097 ...(targetProjectCwd ? { projectCwd: targetProjectCwd } : {}),1114 ...(targetProjectCwd ? { projectCwd: targetProjectCwd } : {}),
1098 worktreePath,1115 worktreePath,
1116 ...(tracked
1117 ? {
1118 observeCompletion: {
1119 onOutputLine: (line) =>
1120 worktreeSetupTracker.appendTail(threadId, "setup-script", line),
1121 },
1122 }
1123 : {}),
1099 })1124 })
1100 .pipe(1125 .pipe(
1101 Effect.matchEffect({1126 Effect.matchEffect({
...@@ -1104,21 +1129,71 @@ const makeWsRpcLayer = (...@@ -1104,21 +1129,71 @@ const makeWsRpcLayer = (
1104 error,1129 error,
1105 requestedAt,1130 requestedAt,
1106 worktreePath,1131 worktreePath,
1107 }),1132 }).pipe(
1133 Effect.andThen(
1134 track(
1135 worktreeSetupTracker.stageStatus(
1136 threadId,
1137 "setup-script",
1138 "failed",
1139 "failed to start",
1140 ),
1141 ),
1142 ),
1143 Effect.as(null),
1144 ),
1108 onSuccess: (setupResult) => {1145 onSuccess: (setupResult) => {
1109 if (setupResult.status !== "started") {1146 if (setupResult.status !== "started") {
1110 return Effect.void;1147 return track(
1148 worktreeSetupTracker.stageStatus(
1149 threadId,
1150 "setup-script",
1151 "skipped",
1152 "no setup script",
1153 ),
1154 ).pipe(Effect.as(null));
1111 }1155 }
1156 setupTerminalId = setupResult.terminalId;
1112 return recordSetupScriptStarted({1157 return recordSetupScriptStarted({
1113 requestedAt,1158 requestedAt,
1114 worktreePath,1159 worktreePath,
1115 scriptId: setupResult.scriptId,1160 scriptId: setupResult.scriptId,
1116 scriptName: setupResult.scriptName,1161 scriptName: setupResult.scriptName,
1117 terminalId: setupResult.terminalId,1162 terminalId: setupResult.terminalId,
1118 });1163 }).pipe(
1164 Effect.andThen(
1165 track(
1166 worktreeSetupTracker.update(threadId, (snapshot) => ({
1167 ...snapshot,
1168 setupScript: {
1169 name: setupResult.scriptName,
1170 command: setupResult.scriptCommand,
1171 terminalId: setupResult.terminalId,
1172 },
1173 })),
1174 ),
1175 ),
1176 Effect.as(setupResult),
1177 );
1119 },1178 },
1120 }),1179 }),
1121 );1180 );
1181 if (!tracked || !setupResult?.completion) {
1182 return;
1183 }
1184 // The setup script is best effort, like the untracked path: a
1185 // failed install must not throw away the worktree the user just
1186 // waited for. The card keeps the failed stage and its terminal.
1187 const completion = yield* setupResult.completion;
1188 if (completion.exitCode === 0) {
1189 yield* worktreeSetupTracker.stageStatus(threadId, "setup-script", "done");
1190 return;
1191 }
1192 const detail =
1193 completion.exitCode === null
1194 ? "terminal closed before the script finished"
1195 : `exit ${completion.exitCode}`;
1196 yield* worktreeSetupTracker.stageStatus(threadId, "setup-script", "failed", detail);
1122 });1197 });
11231198
1124 const bootstrapProgram = Effect.gen(function* () {1199 const bootstrapProgram = Effect.gen(function* () {
...@@ -1138,6 +1213,7 @@ const makeWsRpcLayer = (...@@ -1138,6 +1213,7 @@ const makeWsRpcLayer = (
1138 remoteName: "origin",1213 remoteName: "origin",
1139 }));1214 }));
1140 if (startFromOrigin) {1215 if (startFromOrigin) {
1216 yield* track(worktreeSetupTracker.stageStatus(threadId, "fetch", "running"));
1141 yield* gitWorkflow.fetchRemote({1217 yield* gitWorkflow.fetchRemote({
1142 cwd: prepareWorktree.projectCwd,1218 cwd: prepareWorktree.projectCwd,
1143 remoteName: "origin",1219 remoteName: "origin",
...@@ -1154,7 +1230,26 @@ const makeWsRpcLayer = (...@@ -1154,7 +1230,26 @@ const makeWsRpcLayer = (
1154 fallbackRemoteName: "origin",1230 fallbackRemoteName: "origin",
1155 });1231 });
1156 worktreeBaseRef = resolvedRemoteBase.commitSha;1232 worktreeBaseRef = resolvedRemoteBase.commitSha;
1233 yield* track(
1234 worktreeSetupTracker.stageStatus(
1235 threadId,
1236 "fetch",
1237 "done",
1238 `origin/${prepareWorktree.baseBranch} at ${resolvedRemoteBase.commitSha.slice(0, 7)}`,
1239 ),
1240 );
1241 } else {
1242 yield* track(
1243 worktreeSetupTracker.stageStatus(
1244 threadId,
1245 "fetch",
1246 "warning",
1247 `origin/${prepareWorktree.baseBranch} not found, using local branch`,
1248 ),
1249 );
1157 }1250 }
1251 } else {
1252 yield* track(worktreeSetupTracker.stageStatus(threadId, "fetch", "skipped"));
1158 }1253 }
11591254
1160 const resolvedWorktreeBaseRef = worktreeBaseRef ?? prepareWorktree.baseBranch;1255 const resolvedWorktreeBaseRef = worktreeBaseRef ?? prepareWorktree.baseBranch;
...@@ -1163,6 +1258,27 @@ const makeWsRpcLayer = (...@@ -1163,6 +1258,27 @@ const makeWsRpcLayer = (
1163 refName: resolvedWorktreeBaseRef,1258 refName: resolvedWorktreeBaseRef,
1164 });1259 });
1165 worktreeBaseRef = resolvedWorktreeBaseRef;1260 worktreeBaseRef = resolvedWorktreeBaseRef;
1261 yield* track(
1262 worktreeSetupTracker.update(threadId, (snapshot) => ({
1263 ...snapshot,
1264 baseRef: resolvedWorktreeBaseRef,
1265 })),
1266 );
1267 }
1268
1269 if (prepareWorktree && !shouldPrepareWorktree) {
1270 // Not a git repo, or the base has no commit: the thread runs in
1271 // the project checkout instead. The card says so and moves on.
1272 yield* track(
1273 worktreeSetupTracker.update(threadId, (snapshot) => ({
1274 ...snapshot,
1275 stages: snapshot.stages.map((stage) =>
1276 stage.id === "fetch" || stage.id === "checkout" || stage.id === "submodules"
1277 ? { ...stage, status: "skipped", detail: "using project checkout" }
1278 : stage,
1279 ),
1280 })),
1281 );
1166 }1282 }
11671283
1168 if (bootstrap?.createThread) {1284 if (bootstrap?.createThread) {
...@@ -1188,18 +1304,92 @@ const makeWsRpcLayer = (...@@ -1188,18 +1304,92 @@ const makeWsRpcLayer = (
1188 }1304 }
11891305
1190 if (prepareWorktree && shouldPrepareWorktree && worktreeBaseRef) {1306 if (prepareWorktree && shouldPrepareWorktree && worktreeBaseRef) {
1191 const worktree = yield* gitWorkflow.createWorktree({1307 yield* worktreeSetupTracker.stageStatus(threadId, "checkout", "running");
1192 cwd: prepareWorktree.projectCwd,1308 let checkoutTotal: number | null = null;
1193 refName: worktreeBaseRef,1309 const worktree = yield* gitWorkflow.createWorktree(
1194 newRefName: prepareWorktree.branch,1310 {
1195 baseRefName: prepareWorktree.baseBranch,1311 cwd: prepareWorktree.projectCwd,
1196 path: null,1312 refName: worktreeBaseRef,
1197 });1313 newRefName: prepareWorktree.branch,
1314 baseRefName: prepareWorktree.baseBranch,
1315 path: null,
1316 },
1317 {
1318 progress: {
1319 // Git has registered the directory at this point, so a
1320 // cancel during the submodule step can still remove it.
1321 onWorktreeClaimed: (path) =>
1322 Effect.sync(() => {
1323 targetWorktreePath = path;
1324 }),
1325 onCheckoutProgress: ({ percent, completed, total }) => {
1326 checkoutTotal = total;
1327 return worktreeSetupTracker.stage(threadId, "checkout", {
1328 percent,
1329 detail: `${completed.toLocaleString("en-US")} / ${total.toLocaleString("en-US")} files`,
1330 });
1331 },
1332 onSubmodulesStarted: () =>
1333 worktreeSetupTracker
1334 .stageStatus(
1335 threadId,
1336 "checkout",
1337 "done",
1338 checkoutTotal === null
1339 ? null
1340 : `${checkoutTotal.toLocaleString("en-US")} files`,
1341 )
1342 .pipe(
1343 Effect.andThen(
1344 worktreeSetupTracker.stageStatus(threadId, "submodules", "running"),
1345 ),
1346 ),
1347 onSubmoduleLine: (line) => {
1348 const submodulePath = /Submodule path '([^']+)'/.exec(line)?.[1];
1349 return submodulePath === undefined
1350 ? Effect.void
1351 : worktreeSetupTracker.stage(threadId, "submodules", {
1352 detail: submodulePath,
1353 });
1354 },
1355 onSubmodulesFinished: ({ ok, detail }) =>
1356 worktreeSetupTracker.stageStatus(
1357 threadId,
1358 "submodules",
1359 ok ? "done" : "warning",
1360 ok ? undefined : (detail ?? "submodule checkout failed"),
1361 ),
1362 },
1363 },
1364 );
1365 const checkoutEndedAt = yield* nowIso;
1366 yield* worktreeSetupTracker.update(threadId, (snapshot) => ({
1367 ...snapshot,
1368 worktreePath: worktree.worktree.path,
1369 stages: snapshot.stages.map((stage) => {
1370 if (stage.id === "checkout" && stage.status === "running") {
1371 return {
1372 ...stage,
1373 status: "done",
1374 percent: 100,
1375 endedAt: checkoutEndedAt,
1376 detail:
1377 checkoutTotal === null
1378 ? stage.detail
1379 : `${checkoutTotal.toLocaleString("en-US")} files`,
1380 };
1381 }
1382 if (stage.id === "submodules" && stage.status === "pending") {
1383 return { ...stage, status: "skipped", detail: "none" };
1384 }
1385 return stage;
1386 }),
1387 }));
1198 targetWorktreePath = worktree.worktree.path;1388 targetWorktreePath = worktree.worktree.path;
1199 yield* dispatchFromClient({1389 yield* dispatchFromClient({
1200 type: "thread.meta.update",1390 type: "thread.meta.update",
1201 commandId: yield* serverCommandId("bootstrap-thread-meta-update"),1391 commandId: yield* serverCommandId("bootstrap-thread-meta-update"),
1202 threadId: command.threadId,1392 threadId,
1203 branch: worktree.worktree.refName,1393 branch: worktree.worktree.refName,
1204 worktreePath: targetWorktreePath,1394 worktreePath: targetWorktreePath,
1205 });1395 });
...@@ -1208,36 +1398,114 @@ const makeWsRpcLayer = (...@@ -1208,36 +1398,114 @@ const makeWsRpcLayer = (
12081398
1209 yield* runSetupProgram();1399 yield* runSetupProgram();
12101400
1211 return yield* dispatchFromClient(finalTurnStartCommand);1401 yield* track(worktreeSetupTracker.stageStatus(threadId, "agent", "running"));
1402 // Past this point a cancel would roll back a thread whose turn has
1403 // started. Drop the cancel handle and make the handoff atomic.
1404 yield* track(worktreeSetupTracker.markUncancellable(threadId));
1405 const started = yield* Effect.uninterruptible(
1406 dispatchFromClient(finalTurnStartCommand),
1407 );
1408 yield* track(
1409 worktreeSetupTracker
1410 .stageStatus(threadId, "agent", "done")
1411 .pipe(Effect.andThen(worktreeSetupTracker.finish(threadId, "done"))),
1412 );
1413 return started;
1212 });1414 });
12131415
1214 return yield* bootstrapProgram.pipe(1416 const runBootstrap = tracked
1417 ? Effect.gen(function* () {
1418 const fiber = yield* Effect.forkChild(bootstrapProgram);
1419 yield* worktreeSetupTracker.begin({
1420 threadId,
1421 branch: bootstrap?.prepareWorktree?.branch ?? null,
1422 baseRef: bootstrap?.prepareWorktree?.baseBranch ?? null,
1423 stages: ["fetch", "checkout", "submodules", "setup-script", "agent"],
1424 fiber,
1425 });
1426 return yield* Fiber.join(fiber);
1427 })
1428 : bootstrapProgram;
1429
1430 const cleanupAndFail = (
1431 cause: Cause.Cause<unknown>,
1432 dispatchError: OrchestrationDispatchCommandError,
1433 ) =>
1434 Effect.uninterruptible(cleanupCreatedThread()).pipe(
1435 Effect.matchCauseEffect({
1436 onFailure: (cleanupCause) =>
1437 Effect.logWarning("bootstrap thread cleanup failed", {
1438 threadId,
1439 detail: Cause.pretty(cleanupCause),
1440 }).pipe(Effect.flatMap(() => Effect.fail(dispatchError))),
1441 onSuccess: (threadDeleted) =>
1442 Effect.fail(
1443 threadDeleted
1444 ? new OrchestrationDispatchCommandError({
1445 message: dispatchError.message,
1446 ...(dispatchError.cause !== undefined
1447 ? { cause: dispatchError.cause }
1448 : {}),
1449 bootstrapThreadDisposition: "deleted",
1450 })
1451 : dispatchError,
1452 ),
1453 }),
1454 );
1455
1456 return yield* runBootstrap.pipe(
1215 Effect.catchCause((cause) => {1457 Effect.catchCause((cause) => {
1216 const dispatchError = toBootstrapDispatchCommandCauseError(cause);1458 const dispatchError = toBootstrapDispatchCommandCauseError(cause);
1217 if (Cause.hasInterruptsOnly(cause)) {1459 if (Cause.hasInterruptsOnly(cause)) {
1218 return Effect.fail(dispatchError);1460 // A user cancel interrupts the forked bootstrap fiber. The
1461 // created thread is rolled back like any other failure so the
1462 // draft returns to the composer. The setup terminal is closed
1463 // first so a still-running script cannot hold files open in
1464 // the worktree while git removes it. Closing kills the
1465 // process asynchronously, so the removal retries briefly.
1466 const closeSetupTerminal = setupTerminalId
1467 ? terminalManager.close({
1468 threadId,
1469 terminalId: setupTerminalId,
1470 deleteHistory: true,
1471 })
1472 : Effect.void;
1473 const removeCreatedWorktree =
1474 tracked && targetWorktreePath && bootstrap?.prepareWorktree
1475 ? closeSetupTerminal.pipe(
1476 Effect.ignoreCause({ log: true }),
1477 Effect.andThen(
1478 gitWorkflow
1479 .removeWorktree({
1480 cwd: bootstrap.prepareWorktree.projectCwd,
1481 path: targetWorktreePath,
1482 force: true,
1483 })
1484 .pipe(
1485 Effect.retry({ times: 4, schedule: Schedule.spaced("500 millis") }),
1486 ),
1487 ),
1488 Effect.ignoreCause({ log: true }),
1489 Effect.uninterruptible,
1490 )
1491 : Effect.void;
1492 return track(worktreeSetupTracker.finish(threadId, "cancelled")).pipe(
1493 Effect.andThen(removeCreatedWorktree),
1494 Effect.andThen(
1495 tracked
1496 ? cleanupAndFail(
1497 cause,
1498 new OrchestrationDispatchCommandError({
1499 message: "Worktree setup cancelled.",
1500 }),
1501 )
1502 : Effect.fail(dispatchError),
1503 ),
1504 );
1219 }1505 }
1220 return Effect.uninterruptible(cleanupCreatedThread()).pipe(1506 return track(
1221 Effect.matchCauseEffect({1507 worktreeSetupTracker.finish(threadId, "failed", dispatchError.message),
1222 onFailure: (cleanupCause) =>1508 ).pipe(Effect.andThen(cleanupAndFail(cause, dispatchError)));
1223 Effect.logWarning("bootstrap thread cleanup failed", {
1224 threadId: command.threadId,
1225 detail: Cause.pretty(cleanupCause),
1226 }).pipe(Effect.flatMap(() => Effect.fail(dispatchError))),
1227 onSuccess: (threadDeleted) =>
1228 Effect.fail(
1229 threadDeleted
1230 ? new OrchestrationDispatchCommandError({
1231 message: dispatchError.message,
1232 ...(dispatchError.cause !== undefined
1233 ? { cause: dispatchError.cause }
1234 : {}),
1235 bootstrapThreadDisposition: "deleted",
1236 })
1237 : dispatchError,
1238 ),
1239 }),
1240 );
1241 }),1509 }),
1242 );1510 );
1243 });1511 });
...@@ -2614,6 +2882,20 @@ const makeWsRpcLayer = (...@@ -2614,6 +2882,20 @@ const makeWsRpcLayer = (
2614 "rpc.aggregate": "vcs",2882 "rpc.aggregate": "vcs",
2615 },2883 },
2616 ),2884 ),
2885 [WS_METHODS.subscribeWorktreeSetup]: (input) =>
2886 observeRpcStream(
2887 WS_METHODS.subscribeWorktreeSetup,
2888 worktreeSetupTracker.stream(input.threadId),
2889 { "rpc.aggregate": "vcs" },
2890 ),
2891 [WS_METHODS.worktreeSetupCancel]: (input) =>
2892 observeRpcEffect(
2893 WS_METHODS.worktreeSetupCancel,
2894 worktreeSetupTracker
2895 .cancel(input.threadId)
2896 .pipe(Effect.map((cancelled) => ({ cancelled }))),
2897 { "rpc.aggregate": "vcs" },
2898 ),
2617 [WS_METHODS.vcsRefreshStatus]: (input) =>2899 [WS_METHODS.vcsRefreshStatus]: (input) =>
2618 observeRpcEffect(2900 observeRpcEffect(
2619 WS_METHODS.vcsRefreshStatus,2901 WS_METHODS.vcsRefreshStatus,
apps/web/src/components/ChatView.logic.test.ts+24
...@@ -597,6 +597,30 @@ describe("draft hero submission transition", () => {...@@ -597,6 +597,30 @@ describe("draft hero submission transition", () => {
597 ).toBe(false);597 ).toBe(false);
598 });598 });
599599
600 it("leaves the hero layout while a worktree setup card is on the timeline", () => {
601 expect(
602 resolveDraftHeroState({
603 isLocalDraftThread: true,
604 hasTimelineEntries: false,
605 isWorking: false,
606 draftHeroDockRequested: false,
607 backgroundSubmissionPending: false,
608 hasWorktreeSetupCard: true,
609 }),
610 ).toBe(false);
611 // A background submission normally pins the hero, but never over the card.
612 expect(
613 resolveDraftHeroState({
614 isLocalDraftThread: true,
615 hasTimelineEntries: false,
616 isWorking: false,
617 draftHeroDockRequested: false,
618 backgroundSubmissionPending: true,
619 hasWorktreeSetupCard: true,
620 }),
621 ).toBe(false);
622 });
623
600 it("keeps the composer in the hero layout until navigation after server promotion", () => {624 it("keeps the composer in the hero layout until navigation after server promotion", () => {
601 expect(625 expect(
602 resolveDraftHeroState({626 resolveDraftHeroState({
apps/web/src/components/ChatView.logic.ts+5
...@@ -257,7 +257,12 @@ export function resolveDraftHeroState(input: {...@@ -257,7 +257,12 @@ export function resolveDraftHeroState(input: {
257 isWorking: boolean;257 isWorking: boolean;
258 draftHeroDockRequested: boolean;258 draftHeroDockRequested: boolean;
259 backgroundSubmissionPending: boolean;259 backgroundSubmissionPending: boolean;
260 /** A worktree setup card is on the timeline, so the timeline must stay visible. */
261 hasWorktreeSetupCard?: boolean;
260}): boolean {262}): boolean {
263 if (input.hasWorktreeSetupCard) {
264 return false;
265 }
261 if (input.backgroundSubmissionPending) {266 if (input.backgroundSubmissionPending) {
262 return true;267 return true;
263 }268 }
apps/web/src/components/ChatView.tsx+156
...@@ -43,6 +43,7 @@ import {...@@ -43,6 +43,7 @@ import {
43 resolveEnvironmentMachineKind,43 resolveEnvironmentMachineKind,
44 RuntimeMode,44 RuntimeMode,
45 TerminalOpenInput,45 TerminalOpenInput,
46 type WorktreeSetupSnapshot,
46} from "@t3tools/contracts";47} from "@t3tools/contracts";
47import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection";48import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection";
48import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors";49import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors";
...@@ -1645,6 +1646,27 @@ export default function ChatView(props: ChatViewProps) {...@@ -1645,6 +1646,27 @@ export default function ChatView(props: ChatViewProps) {
1645 return () => revokeBlobPreviewUrl(src);1646 return () => revokeBlobPreviewUrl(src);
1646 }, [expandedImage]);1647 }, [expandedImage]);
1647 const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);1648 const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
1649 // The bootstrap worktree setup this composer last dispatched. Set when a
1650 // worktree send starts and cleared once the turn starts or the next send
1651 // begins, so a failed or cancelled card stays until the user acts.
1652 const [worktreeSetupRef, setWorktreeSetupRef] = useState<{
1653 environmentId: EnvironmentId;
1654 threadId: ThreadId;
1655 ownerKey: string;
1656 } | null>(null);
1657 const [heldWorktreeSetup, setHeldWorktreeSetup] = useState<WorktreeSetupSnapshot | null>(null);
1658 // Set by "Work locally": the draft whose restored message should be resent
1659 // once the cancelled dispatch has settled and the draft is in local mode.
1660 // Keyed by draft id so a bootstrap rotating the thread id keeps it, while
1661 // moving to another draft drops it without an effect.
1662 const [workLocallyResendDraftId, setWorkLocallyResendDraftId] = useState<DraftId | null>(null);
1663 // The draft route reuses this component across drafts, so a resend recorded
1664 // for one draft must not fire when the user comes back to it later.
1665 useEffect(() => {
1666 if (workLocallyResendDraftId !== null && workLocallyResendDraftId !== draftId) {
1667 setWorkLocallyResendDraftId(null);
1668 }
1669 }, [draftId, workLocallyResendDraftId]);
1648 const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState<1670 const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState<
1649 Record<string, ReadonlyArray<CodexFeedbackSubmission>>1671 Record<string, ReadonlyArray<CodexFeedbackSubmission>>
1650 >({});1672 >({});
...@@ -3337,6 +3359,64 @@ export default function ChatView(props: ChatViewProps) {...@@ -3337,6 +3359,64 @@ export default function ChatView(props: ChatViewProps) {
3337 activeThreadKey,3359 activeThreadKey,
3338 );3360 );
3339 const displayedThreadRef = parseScopedThreadKey(displayedTimelineKey);3361 const displayedThreadRef = parseScopedThreadKey(displayedTimelineKey);
3362 // Live stages of a bootstrap worktree setup. The subscription follows the
3363 // thread that was set up, not the route: a deleted bootstrap thread rotates
3364 // the draft's thread id, and the failed card must survive that.
3365 const worktreeSetupOwnerKey = draftId ?? routeThreadKey;
3366 const worktreeSetupActive =
3367 worktreeSetupRef !== null && worktreeSetupRef.ownerKey === worktreeSetupOwnerKey;
3368 // The setup runs on the environment that received the dispatch, so both
3369 // the subscription and cancel target that one even if the draft's machine
3370 // picker changes underneath.
3371 const worktreeSetupQuery = useEnvironmentQuery(
3372 worktreeSetupActive
3373 ? vcsEnvironment.worktreeSetup({
3374 environmentId: worktreeSetupRef.environmentId,
3375 input: { threadId: worktreeSetupRef.threadId },
3376 })
3377 : null,
3378 );
3379 const latestWorktreeSetup = worktreeSetupQuery.data;
3380 useEffect(() => {
3381 // The server drops finished snapshots after a grace period and emits null.
3382 // Hold the last real snapshot so a settled card does not vanish.
3383 if (latestWorktreeSetup) setHeldWorktreeSetup(latestWorktreeSetup);
3384 }, [latestWorktreeSetup]);
3385 const worktreeSetup =
3386 worktreeSetupActive && heldWorktreeSetup?.threadId === worktreeSetupRef.threadId
3387 ? heldWorktreeSetup
3388 : null;
3389 // A finished card is dropped once the agent's turn shows in the timeline:
3390 // the card belongs to the send, and the agent takes over from there.
3391 const worktreeSetupDoneAndTurnVisible =
3392 worktreeSetup?.phase === "done" && activeThread?.latestTurn?.startedAt != null;
3393 useEffect(() => {
3394 if (!worktreeSetupDoneAndTurnVisible) return;
3395 setWorktreeSetupRef(null);
3396 setHeldWorktreeSetup(null);
3397 }, [worktreeSetupDoneAndTurnVisible]);
3398 const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup, {
3399 reportFailure: false,
3400 });
3401 const onCancelWorktreeSetup = useCallback(() => {
3402 if (!worktreeSetup || !worktreeSetupRef || worktreeSetup.phase !== "running") return;
3403 void cancelWorktreeSetup({
3404 environmentId: worktreeSetupRef.environmentId,
3405 input: { threadId: worktreeSetup.threadId },
3406 });
3407 }, [cancelWorktreeSetup, worktreeSetup, worktreeSetupRef]);
3408 // The setup terminal belongs to the thread that was set up. A failed
3409 // bootstrap deletes that thread and closes its terminals, so only offer the
3410 // terminal while the setup thread is still the active one.
3411 const onOpenWorktreeSetupTerminal = useMemo(() => {
3412 if (!worktreeSetup || !activeThreadRef || worktreeSetup.threadId !== activeThreadRef.threadId) {
3413 return null;
3414 }
3415 const setupThreadRef = activeThreadRef;
3416 return (terminalId: string) => {
3417 storeEnsureTerminal(setupThreadRef, terminalId, { open: true, active: true });
3418 };
3419 }, [activeThreadRef, storeEnsureTerminal, worktreeSetup]);
3340 const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState<string | null>(null);3420 const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState<string | null>(null);
3341 const draftHeroDockRequested =3421 const draftHeroDockRequested =
3342 activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey;3422 activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey;
...@@ -3346,6 +3426,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -3346,6 +3426,9 @@ export default function ChatView(props: ChatViewProps) {
3346 isWorking,3426 isWorking,
3347 draftHeroDockRequested,3427 draftHeroDockRequested,
3348 backgroundSubmissionPending,3428 backgroundSubmissionPending,
3429 // A cancelled or failed setup card stays on the draft's timeline; the
3430 // hero headline would paint over it.
3431 hasWorktreeSetupCard: worktreeSetup !== null,
3349 });3432 });
3350 const [3433 const [
3351 attachDraftHeroTransitionGroupRef,3434 attachDraftHeroTransitionGroupRef,
...@@ -7279,6 +7362,11 @@ export default function ChatView(props: ChatViewProps) {...@@ -7279,6 +7362,11 @@ export default function ChatView(props: ChatViewProps) {
7279 preparingWorktree: Boolean(baseBranchForWorktree),7362 preparingWorktree: Boolean(baseBranchForWorktree),
7280 submissionIntent: resolvedSubmissionIntent,7363 submissionIntent: resolvedSubmissionIntent,
7281 });7364 });
7365 setWorktreeSetupRef(
7366 baseBranchForWorktree
7367 ? { environmentId, threadId: threadIdForSend, ownerKey: worktreeSetupOwnerKey }
7368 : null,
7369 );
72827370
7283 const messageIdForSend = newMessageId();7371 const messageIdForSend = newMessageId();
7284 const messageCreatedAt = new Date().toISOString();7372 const messageCreatedAt = new Date().toISOString();
...@@ -8365,6 +8453,70 @@ export default function ChatView(props: ChatViewProps) {...@@ -8365,6 +8453,70 @@ export default function ChatView(props: ChatViewProps) {
8365 ],8453 ],
8366 );8454 );
83678455
8456 // "Work locally" on the setup card: cancel the bootstrap and remember the
8457 // draft. The cancelled dispatch deletes the half-made thread and puts the
8458 // message back in the composer; the effect below then flips the draft to
8459 // local mode and resends. The draft is a server thread for the whole
8460 // setup (the bootstrap created it), so this keys off the route, not
8461 // `isLocalDraftThread`.
8462 const onWorktreeSetupWorkLocally = useCallback(() => {
8463 if (!worktreeSetup || !worktreeSetupRef || worktreeSetup.phase !== "running" || !draftId) {
8464 return;
8465 }
8466 const target = {
8467 environmentId: worktreeSetupRef.environmentId,
8468 input: { threadId: worktreeSetup.threadId },
8469 };
8470 void (async () => {
8471 const result = await cancelWorktreeSetup(target);
8472 if (result._tag !== "Success" || !result.value.cancelled) return;
8473 setWorkLocallyResendDraftId(draftId);
8474 })();
8475 }, [cancelWorktreeSetup, draftId, worktreeSetup, worktreeSetupRef]);
8476 const onSendRef = useRef(onSend);
8477 onSendRef.current = onSend;
8478 // Resend once the cancelled dispatch has settled and the composer is free.
8479 // Every state that makes `onSend` bail and wait is part of the readiness
8480 // check, so the flag survives a reconnect, a reverting checkpoint, or a
8481 // feedback upload in between. What remains inside `onSend` are the checks
8482 // that need the user to change something, and those should not auto retry.
8483 const workLocallyResendReady =
8484 workLocallyResendDraftId !== null &&
8485 workLocallyResendDraftId === draftId &&
8486 isLocalDraftThread &&
8487 !isSendBusy &&
8488 !isConnecting &&
8489 !isRevertingCheckpoint &&
8490 !threadDetailLoading &&
8491 clientSettingsHydrated &&
8492 !needsLoadBalancing &&
8493 !activeEnvironmentUnavailable &&
8494 !activePendingProgress &&
8495 !feedbackUploading;
8496 useEffect(() => {
8497 if (
8498 !workLocallyResendReady ||
8499 sendInFlightRef.current ||
8500 feedbackUploadsInFlightRef.current.has(routeThreadKey)
8501 ) {
8502 return;
8503 }
8504 if (sendEnvMode !== "local") {
8505 // The draft is back; switch it to the project checkout and let the next
8506 // render resend.
8507 setDraftThreadContext(composerDraftTarget, { envMode: "local", startFromOrigin: false });
8508 return;
8509 }
8510 setWorkLocallyResendDraftId(null);
8511 void onSendRef.current();
8512 }, [
8513 composerDraftTarget,
8514 routeThreadKey,
8515 sendEnvMode,
8516 setDraftThreadContext,
8517 workLocallyResendReady,
8518 ]);
8519
8368 const onStartFromOriginChange = (nextStartFromOrigin: boolean) => {8520 const onStartFromOriginChange = (nextStartFromOrigin: boolean) => {
8369 if (canOverrideServerThreadEnvMode && activeThread) {8521 if (canOverrideServerThreadEnvMode && activeThread) {
8370 setPendingServerThreadStartFromOriginByThreadId((current) =>8522 setPendingServerThreadStartFromOriginByThreadId((current) =>
...@@ -8808,6 +8960,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -8808,6 +8960,10 @@ export default function ChatView(props: ChatViewProps) {
8808 isPreparingWorktree={!paintOnlyDisplayedTimeline && isPreparingWorktree}8960 isPreparingWorktree={!paintOnlyDisplayedTimeline && isPreparingWorktree}
8809 isCompacting={!paintOnlyDisplayedTimeline && isCompacting}8961 isCompacting={!paintOnlyDisplayedTimeline && isCompacting}
8810 activeTurnStartedAt={paintOnlyDisplayedTimeline ? null : activeWorkStartedAt}8962 activeTurnStartedAt={paintOnlyDisplayedTimeline ? null : activeWorkStartedAt}
8963 worktreeSetup={paintOnlyDisplayedTimeline ? null : worktreeSetup}
8964 onCancelWorktreeSetup={onCancelWorktreeSetup}
8965 {...(draftId ? { onWorktreeSetupWorkLocally } : {})}
8966 {...(onOpenWorktreeSetupTerminal ? { onOpenWorktreeSetupTerminal } : {})}
8811 listRef={legendListRef}8967 listRef={legendListRef}
8812 timelineEntries={displayedTimeline.entries}8968 timelineEntries={displayedTimeline.entries}
8813 latestTurn={paintOnlyDisplayedTimeline ? null : activeLatestTurn}8969 latestTurn={paintOnlyDisplayedTimeline ? null : activeLatestTurn}
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+78
...@@ -10,6 +10,7 @@ import {...@@ -10,6 +10,7 @@ import {
10 ThreadId,10 ThreadId,
11 TurnId,11 TurnId,
12 type OrchestrationThread,12 type OrchestrationThread,
13 type WorktreeSetupSnapshot,
13} from "@t3tools/contracts";14} from "@t3tools/contracts";
14import {15import {
15 applyThreadDetailEvent,16 applyThreadDetailEvent,
...@@ -31,6 +32,7 @@ import {...@@ -31,6 +32,7 @@ import {
31 shouldPreserveAssistantLineBreaks,32 shouldPreserveAssistantLineBreaks,
32 type MessagesTimelineRow,33 type MessagesTimelineRow,
33 type MessagesTimelineRowsProjection,34 type MessagesTimelineRowsProjection,
35 WORKTREE_SETUP_ROW_ID,
34 workEntryDisplayLabel,36 workEntryDisplayLabel,
35} from "./MessagesTimeline.logic";37} from "./MessagesTimeline.logic";
36import {38import {
...@@ -1090,6 +1092,82 @@ describe("resolveAssistantMessageCopyState", () => {...@@ -1090,6 +1092,82 @@ describe("resolveAssistantMessageCopyState", () => {
1090});1092});
10911093
1092describe("deriveMessagesTimelineRows", () => {1094describe("deriveMessagesTimelineRows", () => {
1095 it("shows the worktree setup card instead of the working placeholder", () => {
1096 const snapshot: WorktreeSetupSnapshot = {
1097 threadId: ThreadId.make("thread-setup"),
1098 phase: "running",
1099 startedAt: "2026-01-01T00:00:00Z",
1100 endedAt: null,
1101 branch: "feature",
1102 baseRef: "main",
1103 worktreePath: null,
1104 setupScript: null,
1105 stages: [],
1106 error: null,
1107 sequence: 3,
1108 };
1109 const userEntry = {
1110 id: "user-entry",
1111 kind: "message",
1112 createdAt: "2026-01-01T00:00:00Z",
1113 message: {
1114 id: "user-1" as never,
1115 role: "user",
1116 text: "Build it",
1117 turnId: null,
1118 createdAt: "2026-01-01T00:00:00Z",
1119 updatedAt: "2026-01-01T00:00:00Z",
1120 streaming: false,
1121 },
1122 } as const;
1123 const assistantEntry = {
1124 id: "assistant-entry",
1125 kind: "message",
1126 createdAt: "2026-01-01T00:00:30Z",
1127 message: {
1128 id: "assistant-1" as never,
1129 role: "assistant",
1130 text: "On it",
1131 turnId: "turn-1" as never,
1132 createdAt: "2026-01-01T00:00:30Z",
1133 updatedAt: "2026-01-01T00:00:30Z",
1134 streaming: true,
1135 },
1136 } as const;
1137 const withoutMessages = deriveMessagesTimelineRows({
1138 timelineEntries: [],
1139 isWorking: true,
1140 activeTurnStartedAt: "2026-01-01T00:00:00Z",
1141 turnDiffSummaries: [],
1142 supportsConversationRollback: false,
1143 worktreeSetup: snapshot,
1144 });
1145 expect(withoutMessages).toEqual([
1146 {
1147 kind: "worktree-setup",
1148 id: WORKTREE_SETUP_ROW_ID,
1149 createdAt: "2026-01-01T00:00:00Z",
1150 snapshot,
1151 },
1152 ]);
1153
1154 // Once the agent has replied the finished card stays under the send.
1155 const withMessages = deriveMessagesTimelineRows({
1156 timelineEntries: [userEntry, assistantEntry],
1157 isWorking: true,
1158 activeTurnStartedAt: "2026-01-01T00:00:00Z",
1159 turnDiffSummaries: [],
1160 supportsConversationRollback: false,
1161 worktreeSetup: { ...snapshot, phase: "done" },
1162 });
1163 expect(withMessages.map((row) => row.kind)).toEqual([
1164 "message",
1165 "worktree-setup",
1166 "working",
1167 "message",
1168 ]);
1169 });
1170
1093 it("keeps context compaction visible outside folded work", () => {1171 it("keeps context compaction visible outside folded work", () => {
1094 const rows = deriveMessagesTimelineRows({1172 const rows = deriveMessagesTimelineRows({
1095 timelineEntries: [1173 timelineEntries: [
apps/web/src/components/chat/MessagesTimeline.logic.ts+42-1
...@@ -28,7 +28,12 @@ import {...@@ -28,7 +28,12 @@ import {
28 type WorkLogEntry,28 type WorkLogEntry,
29} from "../../session-logic";29} from "../../session-logic";
30import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";30import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";
31import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts";31import {
32 type MessageId,
33 type OrchestrationLatestTurn,
34 type TurnId,
35 type WorktreeSetupSnapshot,
36} from "@t3tools/contracts";
32import { formatWorkspaceRelativePath } from "../../filePathDisplay";37import { formatWorkspaceRelativePath } from "../../filePathDisplay";
3338
34const TIMELINE_MINIMAP_ITEM_SPACING = 8;39const TIMELINE_MINIMAP_ITEM_SPACING = 8;
...@@ -389,6 +394,12 @@ export type MessagesTimelineRow =...@@ -389,6 +394,12 @@ export type MessagesTimelineRow =
389 kind: "thinking";394 kind: "thinking";
390 id: string;395 id: string;
391 createdAt: string | null;396 createdAt: string | null;
397 }
398 | {
399 kind: "worktree-setup";
400 id: string;
401 createdAt: string | null;
402 snapshot: WorktreeSetupSnapshot;
392 };403 };
393404
394export interface StableMessagesTimelineRowsState {405export interface StableMessagesTimelineRowsState {
...@@ -857,6 +868,8 @@ export function deriveMessagesTimelineRows(input: {...@@ -857,6 +868,8 @@ export function deriveMessagesTimelineRows(input: {
857 supportsConversationRollback: boolean;868 supportsConversationRollback: boolean;
858 /** Task ids of subagents still working, used by the active tool indicator. */869 /** Task ids of subagents still working, used by the active tool indicator. */
859 liveAgentTaskIds?: ReadonlySet<string> | undefined;870 liveAgentTaskIds?: ReadonlySet<string> | undefined;
871 /** Live bootstrap progress. Renders a stage card under the first user message. */
872 worktreeSetup?: WorktreeSetupSnapshot | null;
860}): MessagesTimelineRow[] {873}): MessagesTimelineRow[] {
861 const turnDiffSummaryByAssistantMessageId = new Map<MessageId, TurnDiffSummary>();874 const turnDiffSummaryByAssistantMessageId = new Map<MessageId, TurnDiffSummary>();
862 for (const summary of input.turnDiffSummaries) {875 for (const summary of input.turnDiffSummaries) {
...@@ -1247,6 +1260,30 @@ export function deriveMessagesTimelineRows(input: {...@@ -1247,6 +1260,30 @@ export function deriveMessagesTimelineRows(input: {
1247 });1260 });
1248 }1261 }
12491262
1263 // The setup card takes the place of the working and thinking placeholders
1264 // while a worktree is being prepared. It stays after the setup settles so a
1265 // failure and its actions remain visible until the thread state moves on.
1266 if (input.worktreeSetup) {
1267 const setupRow = {
1268 kind: "worktree-setup",
1269 id: WORKTREE_SETUP_ROW_ID,
1270 createdAt: input.worktreeSetup.startedAt,
1271 snapshot: input.worktreeSetup,
1272 } as const;
1273 // Sit directly under the first user message: a finished snapshot can
1274 // outlive the first assistant reply, and it belongs to the send, not the
1275 // end of the thread.
1276 const firstUserRowIndex = nextRows.findIndex(
1277 (row) => row.kind === "message" && row.message.role === "user",
1278 );
1279 if (firstUserRowIndex >= 0) {
1280 nextRows.splice(firstUserRowIndex + 1, 0, setupRow);
1281 } else {
1282 nextRows.push(setupRow);
1283 }
1284 return attachTrailingToolGroupsToAssistant(nextRows);
1285 }
1286
1250 if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) {1287 if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) {
1251 appendWorkingRow();1288 appendWorkingRow();
1252 }1289 }
...@@ -1261,6 +1298,8 @@ export function deriveMessagesTimelineRows(input: {...@@ -1261,6 +1298,8 @@ export function deriveMessagesTimelineRows(input: {
1261 return attachTrailingToolGroupsToAssistant(nextRows);1298 return attachTrailingToolGroupsToAssistant(nextRows);
1262}1299}
12631300
1301export const WORKTREE_SETUP_ROW_ID = "worktree-setup-row";
1302
1264type MessagesTimelineRowsInput = Parameters<typeof deriveMessagesTimelineRows>[0];1303type MessagesTimelineRowsInput = Parameters<typeof deriveMessagesTimelineRows>[0];
12651304
1266export interface MessagesTimelineRowsProjection {1305export interface MessagesTimelineRowsProjection {
...@@ -1363,6 +1402,8 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean...@@ -1363,6 +1402,8 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
1363 case "working":1402 case "working":
1364 case "thinking":1403 case "thinking":
1365 return a.createdAt === (b as typeof a).createdAt;1404 return a.createdAt === (b as typeof a).createdAt;
1405 case "worktree-setup":
1406 return a.snapshot === (b as typeof a).snapshot;
13661407
1367 case "assistant-meta": {1408 case "assistant-meta": {
1368 const bm = b as typeof a;1409 const bm = b as typeof a;
apps/web/src/components/chat/MessagesTimeline.tsx+47-1
...@@ -19,6 +19,7 @@ import {...@@ -19,6 +19,7 @@ import {
19 type ServerProviderSkill,19 type ServerProviderSkill,
20 type ToolActivityIcon,20 type ToolActivityIcon,
21 type TurnId,21 type TurnId,
22 type WorktreeSetupSnapshot,
22} from "@t3tools/contracts";23} from "@t3tools/contracts";
23import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";24import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
24import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";25import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";
...@@ -188,6 +189,7 @@ import {...@@ -188,6 +189,7 @@ import {
188} from "./MessagesTimeline.logic";189} from "./MessagesTimeline.logic";
189import { TerminalContextInlineChip } from "./TerminalContextInlineChip";190import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
190import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";191import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
192import { WorktreeSetupCard } from "./WorktreeSetupCard";
191import {193import {
192 ContextChipPopover as UserMessageContextPopover,194 ContextChipPopover as UserMessageContextPopover,
193 ContextChipShell,195 ContextChipShell,
...@@ -275,6 +277,9 @@ interface TimelineRowSharedState {...@@ -275,6 +277,9 @@ interface TimelineRowSharedState {
275 agentPanelModel: AgentPanelModel;277 agentPanelModel: AgentPanelModel;
276 expandedSpawnEntryIds: ReadonlySet<string>;278 expandedSpawnEntryIds: ReadonlySet<string>;
277 onOpenAgents: () => void;279 onOpenAgents: () => void;
280 onCancelWorktreeSetup: (() => void) | null;
281 onWorktreeSetupWorkLocally: (() => void) | null;
282 onOpenWorktreeSetupTerminal: ((terminalId: string) => void) | null;
278}283}
279284
280interface TimelineRowActivityState {285interface TimelineRowActivityState {
...@@ -374,6 +379,11 @@ interface MessagesTimelineProps {...@@ -374,6 +379,11 @@ interface MessagesTimelineProps {
374 isPreparingWorktree?: boolean;379 isPreparingWorktree?: boolean;
375 isCompacting?: boolean;380 isCompacting?: boolean;
376 activeTurnStartedAt: string | null;381 activeTurnStartedAt: string | null;
382 /** Live bootstrap progress for this thread, or null when none is tracked. */
383 worktreeSetup?: WorktreeSetupSnapshot | null;
384 onCancelWorktreeSetup?: () => void;
385 onWorktreeSetupWorkLocally?: () => void;
386 onOpenWorktreeSetupTerminal?: (terminalId: string) => void;
377 listRef: React.RefObject<LegendListRef | null>;387 listRef: React.RefObject<LegendListRef | null>;
378 timelineEntries: ReturnType<typeof deriveTimelineEntries>;388 timelineEntries: ReturnType<typeof deriveTimelineEntries>;
379 latestTurn: TimelineLatestTurn | null;389 latestTurn: TimelineLatestTurn | null;
...@@ -433,6 +443,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -433,6 +443,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({
433 citationHistoryLoading = false,443 citationHistoryLoading = false,
434 onCiteAssistantText,444 onCiteAssistantText,
435 isWorking,445 isWorking,
446 worktreeSetup = null,
447 onCancelWorktreeSetup,
448 onWorktreeSetupWorkLocally,
449 onOpenWorktreeSetupTerminal,
436 isPreparingWorktree = false,450 isPreparingWorktree = false,
437 isCompacting = false,451 isCompacting = false,
438 activeTurnStartedAt,452 activeTurnStartedAt,
...@@ -692,6 +706,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -692,6 +706,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
692 turnDiffSummaries,706 turnDiffSummaries,
693 supportsConversationRollback,707 supportsConversationRollback,
694 liveAgentTaskIds,708 liveAgentTaskIds,
709 worktreeSetup,
695 },710 },
696 previous?.threadKey === listIdentityKey && previous.workspaceRoot === workspaceRoot711 previous?.threadKey === listIdentityKey && previous.workspaceRoot === workspaceRoot
697 ? previous.projection712 ? previous.projection
...@@ -713,6 +728,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -713,6 +728,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
713 turnDiffSummaries,728 turnDiffSummaries,
714 supportsConversationRollback,729 supportsConversationRollback,
715 liveAgentTaskIds,730 liveAgentTaskIds,
731 worktreeSetup,
716 ]);732 ]);
717 const rows = useStableRows(rawRows, listIdentityKey);733 const rows = useStableRows(rawRows, listIdentityKey);
718 const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);734 const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);
...@@ -905,6 +921,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -905,6 +921,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
905 agentPanelModel: agentPanelModel ?? EMPTY_AGENT_PANEL_MODEL,921 agentPanelModel: agentPanelModel ?? EMPTY_AGENT_PANEL_MODEL,
906 expandedSpawnEntryIds: paintedExpandedSpawnEntryIds,922 expandedSpawnEntryIds: paintedExpandedSpawnEntryIds,
907 onOpenAgents,923 onOpenAgents,
924 onCancelWorktreeSetup: onCancelWorktreeSetup ?? null,
925 onWorktreeSetupWorkLocally: onWorktreeSetupWorkLocally ?? null,
926 onOpenWorktreeSetupTerminal: onOpenWorktreeSetupTerminal ?? null,
908 }),927 }),
909 [928 [
910 readyCitationRequest,929 readyCitationRequest,
...@@ -932,6 +951,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -932,6 +951,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
932 agentPanelModel,951 agentPanelModel,
933 paintedExpandedSpawnEntryIds,952 paintedExpandedSpawnEntryIds,
934 onOpenAgents,953 onOpenAgents,
954 onCancelWorktreeSetup,
955 onWorktreeSetupWorkLocally,
956 onOpenWorktreeSetupTerminal,
935 ],957 ],
936 );958 );
937 const activityState = useMemo<TimelineRowActivityState>(959 const activityState = useMemo<TimelineRowActivityState>(
...@@ -1386,7 +1408,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time...@@ -1386,7 +1408,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
1386 row.kind === "work" ||1408 row.kind === "work" ||
1387 row.kind === "work-live" ||1409 row.kind === "work-live" ||
1388 row.kind === "work-toggle" ||1410 row.kind === "work-toggle" ||
1389 row.kind === "thinking"1411 row.kind === "thinking" ||
1412 row.kind === "worktree-setup"
1390 ? "pb-2"1413 ? "pb-2"
1391 : "pb-4",1414 : "pb-4",
1392 (row.kind === "message" && row.message.role === "assistant") ||1415 (row.kind === "message" && row.message.role === "assistant") ||
...@@ -1421,10 +1444,33 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time...@@ -1421,10 +1444,33 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
1421 {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null}1444 {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null}
1422 {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}1445 {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}
1423 {row.kind === "thinking" ? <ThinkingTimelineRow /> : null}1446 {row.kind === "thinking" ? <ThinkingTimelineRow /> : null}
1447 {row.kind === "worktree-setup" ? <WorktreeSetupTimelineRow row={row} /> : null}
1424 </div>1448 </div>
1425 );1449 );
1426});1450});
14271451
1452function WorktreeSetupTimelineRow({
1453 row,
1454}: {
1455 row: Extract<TimelineRow, { kind: "worktree-setup" }>;
1456}) {
1457 const ctx = use(TimelineRowCtx);
1458 const terminalId = row.snapshot.setupScript?.terminalId ?? null;
1459 const openTerminal = ctx.onOpenWorktreeSetupTerminal;
1460 const onOpenTerminal = useMemo(
1461 () => (openTerminal && terminalId ? () => openTerminal(terminalId) : null),
1462 [openTerminal, terminalId],
1463 );
1464 return (
1465 <WorktreeSetupCard
1466 snapshot={row.snapshot}
1467 onCancel={ctx.onCancelWorktreeSetup}
1468 onWorkLocally={row.snapshot.phase === "running" ? ctx.onWorktreeSetupWorkLocally : null}
1469 onOpenTerminal={onOpenTerminal}
1470 />
1471 );
1472}
1473
1428function ContextCompactionTimelineRow({1474function ContextCompactionTimelineRow({
1429 row,1475 row,
1430}: {1476}: {
apps/web/src/components/chat/WorktreeSetupCard.tsx created+296
...@@ -0,0 +1,296 @@
1import {
2 worktreeSetupStageLabel,
3 type WorktreeSetupSnapshot,
4 type WorktreeSetupStage,
5} from "@t3tools/contracts";
6import { formatDuration } from "@t3tools/shared/orchestrationTiming";
7import {
8 CheckIcon,
9 ChevronDownIcon,
10 ChevronRightIcon,
11 CircleAlertIcon,
12 CircleIcon,
13 GitBranchIcon,
14 LaptopIcon,
15 MinusIcon,
16 TerminalIcon,
17 XIcon,
18} from "lucide-react";
19import { useEffect, useState } from "react";
20
21import { Button } from "~/components/ui/button";
22import { Spinner } from "~/components/ui/spinner";
23import { cn } from "~/lib/utils";
24
25interface WorktreeSetupCardProps {
26 snapshot: WorktreeSetupSnapshot;
27 /** Interrupts the server-side bootstrap. Hidden once the setup has settled. */
28 onCancel: (() => void) | null;
29 /** Restarts the same message in the project checkout instead of a worktree. */
30 onWorkLocally: (() => void) | null;
31 /** Reveals the setup script terminal tab. Null when no script ran. */
32 onOpenTerminal: (() => void) | null;
33}
34
35function stageElapsedMs(stage: WorktreeSetupStage, nowMs: number): number | null {
36 if (!stage.startedAt) return null;
37 const start = Date.parse(stage.startedAt);
38 const end = stage.endedAt ? Date.parse(stage.endedAt) : nowMs;
39 if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
40 return Math.max(0, end - start);
41}
42
43/**
44 * Ticks once a second while any stage runs so elapsed labels stay live
45 * without pushing a React commit through the timeline for every second.
46 */
47function useNowWhile(active: boolean): number {
48 const [nowMs, setNowMs] = useState(() => Date.now());
49 useEffect(() => {
50 if (!active) return;
51 const id = setInterval(() => setNowMs(Date.now()), 1_000);
52 return () => clearInterval(id);
53 }, [active]);
54 return nowMs;
55}
56
57function StageIcon({ status }: { status: WorktreeSetupStage["status"] }) {
58 const className = "size-4 shrink-0 stroke-[1.8]";
59 switch (status) {
60 case "done":
61 return <CheckIcon aria-hidden className={className} />;
62 case "running":
63 return <Spinner className={className} />;
64 case "failed":
65 return <XIcon aria-hidden className={className} />;
66 case "warning":
67 return <CircleAlertIcon aria-hidden className={className} />;
68 case "skipped":
69 return <MinusIcon aria-hidden className={className} />;
70 case "pending":
71 return <CircleIcon aria-hidden className={className} />;
72 }
73}
74
75function stageRowClassName(status: WorktreeSetupStage["status"]): string {
76 switch (status) {
77 case "running":
78 return "text-foreground";
79 case "failed":
80 return "text-destructive-foreground";
81 case "warning":
82 return "text-warning-foreground";
83 case "pending":
84 case "skipped":
85 return "text-secondary-label opacity-50";
86 case "done":
87 return "text-secondary-label";
88 }
89}
90
91function StageRow({
92 stage,
93 nowMs,
94 scriptName,
95}: {
96 stage: WorktreeSetupStage;
97 nowMs: number;
98 scriptName: string | null;
99}) {
100 const elapsed = stageElapsedMs(stage, nowMs);
101 const label =
102 stage.id === "setup-script" && scriptName ? scriptName : worktreeSetupStageLabel(stage.id);
103 const showBar = stage.id === "checkout" && stage.status === "running" && stage.percent !== null;
104 const trailing =
105 stage.status === "pending"
106 ? null
107 : stage.status === "skipped"
108 ? (stage.detail ?? "skipped")
109 : stage.detail;
110
111 return (
112 <div
113 className={cn(
114 "flex min-h-6 items-center gap-1.5 px-0.5 py-0.5 text-sm leading-relaxed",
115 stageRowClassName(stage.status),
116 )}
117 data-worktree-setup-stage={stage.id}
118 data-worktree-setup-status={stage.status}
119 >
120 <span className="flex size-6 shrink-0 items-center justify-center">
121 <StageIcon status={stage.status} />
122 </span>
123 <span className="min-w-0 flex-1 truncate">{label}</span>
124 <span className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground tabular-nums">
125 {showBar ? (
126 <>
127 <span className="h-1 w-18 overflow-hidden rounded-full bg-input">
128 <span
129 className="block h-full rounded-full bg-primary"
130 style={{ width: `${stage.percent ?? 0}%` }}
131 />
132 </span>
133 <span className="text-foreground">{stage.percent}%</span>
134 </>
135 ) : null}
136 {!showBar && trailing ? <span className="truncate">{trailing}</span> : null}
137 {elapsed !== null && stage.status !== "skipped" && stage.status !== "pending" ? (
138 <span>{formatDuration(elapsed)}</span>
139 ) : null}
140 </span>
141 </div>
142 );
143}
144
145function OutputTail({ lines, failed }: { lines: ReadonlyArray<string>; failed: boolean }) {
146 if (lines.length === 0) return null;
147 return (
148 <pre
149 className={cn(
150 "mb-1 ml-8 rounded-md border px-2.5 py-1.5 font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-all select-text",
151 failed
152 ? "border-destructive/20 bg-error-surface text-destructive-foreground"
153 : "border-border bg-code text-muted-foreground",
154 )}
155 >
156 {lines.join("\n")}
157 </pre>
158 );
159}
160
161function headerLabel(snapshot: WorktreeSetupSnapshot): string {
162 switch (snapshot.phase) {
163 case "running":
164 return "Creating worktree";
165 case "done":
166 return snapshot.stages.some((stage) => stage.status === "failed")
167 ? "Worktree ready, setup script failed"
168 : "Worktree ready";
169 case "failed":
170 return "Worktree setup failed";
171 case "cancelled":
172 return "Worktree setup cancelled";
173 }
174}
175
176export function WorktreeSetupCard({
177 snapshot,
178 onCancel,
179 onWorkLocally,
180 onOpenTerminal,
181}: WorktreeSetupCardProps) {
182 const running = snapshot.phase === "running";
183 const nowMs = useNowWhile(running);
184 const [detailsOpen, setDetailsOpen] = useState(false);
185 const totalElapsed = (() => {
186 const start = Date.parse(snapshot.startedAt);
187 const end = snapshot.endedAt ? Date.parse(snapshot.endedAt) : nowMs;
188 return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : null;
189 })();
190 const setupStage = snapshot.stages.find((stage) => stage.id === "setup-script");
191 const failed = snapshot.phase === "failed";
192 const finishedWithFailedStage =
193 snapshot.phase === "done" && snapshot.stages.some((stage) => stage.status === "failed");
194 const headerClassName = failed
195 ? "text-destructive-foreground"
196 : finishedWithFailedStage
197 ? "text-warning-foreground"
198 : snapshot.phase === "cancelled"
199 ? "text-muted-foreground"
200 : "text-secondary-label";
201
202 return (
203 <section
204 aria-label="Worktree setup"
205 className="mt-3 rounded-lg border border-border bg-card/60 px-2.5 pt-1.5 pb-2"
206 data-worktree-setup-phase={snapshot.phase}
207 >
208 <div className={cn("flex min-h-6 items-center gap-1.5 px-0.5 text-sm", headerClassName)}>
209 <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
210 <GitBranchIcon aria-hidden className="size-4 shrink-0 stroke-[1.8]" />
211 </span>
212 <span className="min-w-0 flex-1 truncate">{headerLabel(snapshot)}</span>
213 {totalElapsed !== null ? (
214 <span className="shrink-0 text-xs text-muted-foreground tabular-nums">
215 {formatDuration(totalElapsed)}
216 </span>
217 ) : null}
218 </div>
219
220 {snapshot.stages.map((stage) => (
221 <div key={stage.id}>
222 <StageRow stage={stage} nowMs={nowMs} scriptName={snapshot.setupScript?.name ?? null} />
223 {stage.id === "setup-script" &&
224 (stage.status === "running" || stage.status === "failed") ? (
225 <OutputTail lines={stage.tail} failed={stage.status === "failed"} />
226 ) : null}
227 </div>
228 ))}
229
230 {failed && snapshot.error ? (
231 <p className="mt-1 px-0.5 text-xs text-muted-foreground">{snapshot.error}</p>
232 ) : null}
233
234 {detailsOpen ? (
235 <dl className="mt-1 mb-1.5 ml-8 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
236 {snapshot.branch ? (
237 <>
238 <dt className="text-foreground/80">Branch</dt>
239 <dd className="truncate font-mono">{snapshot.branch}</dd>
240 </>
241 ) : null}
242 {snapshot.baseRef ? (
243 <>
244 <dt className="text-foreground/80">Base</dt>
245 <dd className="truncate font-mono">{snapshot.baseRef}</dd>
246 </>
247 ) : null}
248 {snapshot.worktreePath ? (
249 <>
250 <dt className="text-foreground/80">Path</dt>
251 <dd className="truncate font-mono">{snapshot.worktreePath}</dd>
252 </>
253 ) : null}
254 {snapshot.setupScript ? (
255 <>
256 <dt className="text-foreground/80">Setup</dt>
257 <dd className="truncate font-mono">{snapshot.setupScript.command}</dd>
258 </>
259 ) : null}
260 </dl>
261 ) : null}
262
263 <div className="mt-1 flex flex-wrap items-center gap-1.5">
264 <Button
265 type="button"
266 size="xs"
267 variant="ghost-muted"
268 aria-expanded={detailsOpen}
269 onClick={() => setDetailsOpen((open) => !open)}
270 >
271 {detailsOpen ? <ChevronDownIcon aria-hidden /> : <ChevronRightIcon aria-hidden />}
272 Details
273 </Button>
274 <span className="flex-1" />
275 {onOpenTerminal && setupStage && setupStage.status !== "pending" ? (
276 <Button type="button" size="xs" variant="outline" onClick={onOpenTerminal}>
277 <TerminalIcon aria-hidden />
278 Open terminal
279 </Button>
280 ) : null}
281 {onWorkLocally ? (
282 <Button type="button" size="xs" variant="outline" onClick={onWorkLocally}>
283 <LaptopIcon aria-hidden />
284 Work locally
285 </Button>
286 ) : null}
287 {onCancel && running ? (
288 <Button type="button" size="xs" variant="outline" onClick={onCancel}>
289 <XIcon aria-hidden />
290 Cancel
291 </Button>
292 ) : null}
293 </div>
294 </section>
295 );
296}
packages/client-runtime/src/rpc/client.ts+1
...@@ -56,6 +56,7 @@ export type EnvironmentSubscriptionRpcTag =...@@ -56,6 +56,7 @@ export type EnvironmentSubscriptionRpcTag =
56 | typeof WS_METHODS.pullRequestsSubscribeRefreshes56 | typeof WS_METHODS.pullRequestsSubscribeRefreshes
57 | typeof WS_METHODS.previewAutomationConnect57 | typeof WS_METHODS.previewAutomationConnect
58 | typeof WS_METHODS.subscribeVcsStatus58 | typeof WS_METHODS.subscribeVcsStatus
59 | typeof WS_METHODS.subscribeWorktreeSetup
59 | typeof WS_METHODS.terminalAttach;60 | typeof WS_METHODS.terminalAttach;
6061
61export type EnvironmentStreamCommandRpcTag =62export type EnvironmentStreamCommandRpcTag =
packages/client-runtime/src/state/vcs.ts+17-1
...@@ -15,7 +15,11 @@ import * as Stream from "effect/Stream";...@@ -15,7 +15,11 @@ import * as Stream from "effect/Stream";
15import * as SubscriptionRef from "effect/SubscriptionRef";15import * as SubscriptionRef from "effect/SubscriptionRef";
16import { Atom, AtomRegistry } from "effect/unstable/reactivity";16import { Atom, AtomRegistry } from "effect/unstable/reactivity";
1717
18import { createEnvironmentRpcCommand, createEnvironmentSubscriptionAtomFamily } from "./runtime.ts";18import {
19 createEnvironmentRpcCommand,
20 createEnvironmentRpcSubscriptionAtomFamily,
21 createEnvironmentSubscriptionAtomFamily,
22} from "./runtime.ts";
19import type { EnvironmentRegistry } from "../connection/registry.ts";23import type { EnvironmentRegistry } from "../connection/registry.ts";
20import { EnvironmentSupervisor } from "../connection/supervisor.ts";24import { EnvironmentSupervisor } from "../connection/supervisor.ts";
21import { safeErrorLogAttributes } from "../errors/safeLog.ts";25import { safeErrorLogAttributes } from "../errors/safeLog.ts";
...@@ -311,6 +315,18 @@ export function createVcsEnvironmentAtoms<R, E>(...@@ -311,6 +315,18 @@ export function createVcsEnvironmentAtoms<R, E>(
311 concurrency: vcsCommandConcurrency,315 concurrency: vcsCommandConcurrency,
312 onSettled: invalidateRefs,316 onSettled: invalidateRefs,
313 }),317 }),
318 // Live stages of a bootstrap worktree setup. Null until the server begins
319 // tracking, then a snapshot per change, then null again after the setup
320 // is dropped. Short TTL so a closed thread releases its subscription.
321 worktreeSetup: createEnvironmentRpcSubscriptionAtomFamily(runtime, {
322 label: "environment-data:vcs:worktree-setup",
323 tag: WS_METHODS.subscribeWorktreeSetup,
324 idleTtlMs: VCS_STATUS_IDLE_TTL_MS,
325 }),
326 cancelWorktreeSetup: createEnvironmentRpcCommand(runtime, {
327 label: "environment-data:vcs:cancel-worktree-setup",
328 tag: WS_METHODS.worktreeSetupCancel,
329 }),
314 removeWorktree: createEnvironmentRpcCommand(runtime, {330 removeWorktree: createEnvironmentRpcCommand(runtime, {
315 label: "environment-data:vcs:remove-worktree",331 label: "environment-data:vcs:remove-worktree",
316 tag: WS_METHODS.vcsRemoveWorktree,332 tag: WS_METHODS.vcsRemoveWorktree,
packages/contracts/src/index.ts+1
...@@ -42,3 +42,4 @@ export * from "./previewAutomation.ts";...@@ -42,3 +42,4 @@ export * from "./previewAutomation.ts";
42export * from "./resourceTelemetry.ts";42export * from "./resourceTelemetry.ts";
43export * from "./usage.ts";43export * from "./usage.ts";
44export * from "./rpc.ts";44export * from "./rpc.ts";
45export * from "./worktreeSetup.ts";
packages/contracts/src/rpc.ts+23
...@@ -46,6 +46,12 @@ import {...@@ -46,6 +46,12 @@ import {
46 AttachmentDeleteInput,46 AttachmentDeleteInput,
47 AttachmentUploadSigningKeyError,47 AttachmentUploadSigningKeyError,
48} from "./assets.ts";48} from "./assets.ts";
49import {
50 WorktreeSetupCancelInput,
51 WorktreeSetupCancelResult,
52 WorktreeSetupStreamEvent,
53 WorktreeSetupSubscribeInput,
54} from "./worktreeSetup.ts";
49import {55import {
50 GitActionProgressEvent,56 GitActionProgressEvent,
51 VcsSwitchRefInput,57 VcsSwitchRefInput,
...@@ -404,6 +410,8 @@ export const WS_METHODS = {...@@ -404,6 +410,8 @@ export const WS_METHODS = {
404410
405 // Streaming subscriptions411 // Streaming subscriptions
406 subscribeVcsStatus: "subscribeVcsStatus",412 subscribeVcsStatus: "subscribeVcsStatus",
413 subscribeWorktreeSetup: "subscribeWorktreeSetup",
414 worktreeSetupCancel: "worktreeSetup.cancel",
407 subscribeTerminalEvents: "subscribeTerminalEvents",415 subscribeTerminalEvents: "subscribeTerminalEvents",
408 subscribeTerminalMetadata: "subscribeTerminalMetadata",416 subscribeTerminalMetadata: "subscribeTerminalMetadata",
409 subscribePreviewEvents: "subscribePreviewEvents",417 subscribePreviewEvents: "subscribePreviewEvents",
...@@ -940,6 +948,19 @@ const WsVcsRefreshStatusRpc = Rpc.make(WS_METHODS.vcsRefreshStatus, {...@@ -940,6 +948,19 @@ const WsVcsRefreshStatusRpc = Rpc.make(WS_METHODS.vcsRefreshStatus, {
940 error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]),948 error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]),
941});949});
942950
951const WsSubscribeWorktreeSetupRpc = Rpc.make(WS_METHODS.subscribeWorktreeSetup, {
952 payload: WorktreeSetupSubscribeInput,
953 success: WorktreeSetupStreamEvent,
954 error: EnvironmentAuthorizationError,
955 stream: true,
956});
957
958const WsWorktreeSetupCancelRpc = Rpc.make(WS_METHODS.worktreeSetupCancel, {
959 payload: WorktreeSetupCancelInput,
960 success: WorktreeSetupCancelResult,
961 error: EnvironmentAuthorizationError,
962});
963
943const WsGitRunStackedActionRpc = Rpc.make(WS_METHODS.gitRunStackedAction, {964const WsGitRunStackedActionRpc = Rpc.make(WS_METHODS.gitRunStackedAction, {
944 payload: GitRunStackedActionInput,965 payload: GitRunStackedActionInput,
945 success: GitActionProgressEvent,966 success: GitActionProgressEvent,
...@@ -1372,6 +1393,8 @@ export const WsRpcGroup = RpcGroup.make(...@@ -1372,6 +1393,8 @@ export const WsRpcGroup = RpcGroup.make(
1372 WsAttachmentsDeleteRpc,1393 WsAttachmentsDeleteRpc,
1373 WsProviderUploadFeedbackRpc,1394 WsProviderUploadFeedbackRpc,
1374 WsSubscribeVcsStatusRpc,1395 WsSubscribeVcsStatusRpc,
1396 WsSubscribeWorktreeSetupRpc,
1397 WsWorktreeSetupCancelRpc,
1375 WsVcsPullRpc,1398 WsVcsPullRpc,
1376 WsVcsRefreshStatusRpc,1399 WsVcsRefreshStatusRpc,
1377 WsGitRunStackedActionRpc,1400 WsGitRunStackedActionRpc,
packages/contracts/src/worktreeSetup.ts created+114
...@@ -0,0 +1,114 @@
1import * as Schema from "effect/Schema";
2
3import { IsoDateTime, NonNegativeInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
4
5/**
6 * Live progress for a thread whose first turn is creating a worktree. The
7 * server keeps this in memory only; a client that reconnects mid-setup gets a
8 * fresh snapshot, and a finished setup is dropped once its turn starts.
9 */
10/** Producers clamp free text to these before publishing so encoding never fails. */
11export const WORKTREE_SETUP_DETAIL_MAX_LENGTH = 200;
12export const WORKTREE_SETUP_TAIL_LINE_MAX_LENGTH = 400;
13export const WORKTREE_SETUP_ERROR_MAX_LENGTH = 1000;
14
15export const WorktreeSetupStageId = Schema.Literals([
16 "fetch",
17 "checkout",
18 "submodules",
19 "setup-script",
20 "agent",
21]);
22export type WorktreeSetupStageId = typeof WorktreeSetupStageId.Type;
23
24export const WorktreeSetupStageStatus = Schema.Literals([
25 "pending",
26 "running",
27 "done",
28 "skipped",
29 "warning",
30 "failed",
31]);
32export type WorktreeSetupStageStatus = typeof WorktreeSetupStageStatus.Type;
33
34export const WorktreeSetupStage = Schema.Struct({
35 id: WorktreeSetupStageId,
36 status: WorktreeSetupStageStatus,
37 startedAt: Schema.NullOr(IsoDateTime),
38 endedAt: Schema.NullOr(IsoDateTime),
39 /** Only the checkout stage reports a real percentage, parsed from git's `Updating files` lines. */
40 percent: Schema.NullOr(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 }))),
41 /** Short trailing text for the row: a file count, an exit code, a submodule name. */
42 detail: Schema.NullOr(Schema.String.check(Schema.isMaxLength(WORKTREE_SETUP_DETAIL_MAX_LENGTH))),
43 /** Last few output lines from the setup script, ANSI stripped, newest last. */
44 tail: Schema.Array(Schema.String.check(Schema.isMaxLength(WORKTREE_SETUP_TAIL_LINE_MAX_LENGTH))),
45});
46export type WorktreeSetupStage = typeof WorktreeSetupStage.Type;
47
48export const WorktreeSetupPhase = Schema.Literals(["running", "done", "failed", "cancelled"]);
49export type WorktreeSetupPhase = typeof WorktreeSetupPhase.Type;
50
51export const WorktreeSetupSnapshot = Schema.Struct({
52 threadId: ThreadId,
53 phase: WorktreeSetupPhase,
54 startedAt: IsoDateTime,
55 endedAt: Schema.NullOr(IsoDateTime),
56 branch: Schema.NullOr(TrimmedNonEmptyString),
57 baseRef: Schema.NullOr(TrimmedNonEmptyString),
58 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
59 /** Display name plus command of the setup script from t3.json, when one runs. */
60 setupScript: Schema.NullOr(
61 Schema.Struct({
62 name: TrimmedNonEmptyString,
63 command: TrimmedNonEmptyString,
64 terminalId: TrimmedNonEmptyString,
65 }),
66 ),
67 stages: Schema.Array(WorktreeSetupStage),
68 /** Human readable reason when phase is failed. */
69 error: Schema.NullOr(Schema.String.check(Schema.isMaxLength(WORKTREE_SETUP_ERROR_MAX_LENGTH))),
70 sequence: NonNegativeInt,
71});
72export type WorktreeSetupSnapshot = typeof WorktreeSetupSnapshot.Type;
73
74export const WorktreeSetupSubscribeInput = Schema.Struct({
75 threadId: ThreadId,
76});
77export type WorktreeSetupSubscribeInput = typeof WorktreeSetupSubscribeInput.Type;
78
79/** Null means no setup is tracked for that thread. Sent first, then after every change. */
80export const WorktreeSetupStreamEvent = Schema.NullOr(WorktreeSetupSnapshot);
81export type WorktreeSetupStreamEvent = typeof WorktreeSetupStreamEvent.Type;
82
83export const WorktreeSetupCancelInput = Schema.Struct({
84 threadId: ThreadId,
85});
86export type WorktreeSetupCancelInput = typeof WorktreeSetupCancelInput.Type;
87
88export const WorktreeSetupCancelResult = Schema.Struct({
89 cancelled: Schema.Boolean,
90});
91export type WorktreeSetupCancelResult = typeof WorktreeSetupCancelResult.Type;
92
93export const WORKTREE_SETUP_STAGE_ORDER: ReadonlyArray<WorktreeSetupStageId> = [
94 "fetch",
95 "checkout",
96 "submodules",
97 "setup-script",
98 "agent",
99];
100
101export function worktreeSetupStageLabel(id: WorktreeSetupStageId): string {
102 switch (id) {
103 case "fetch":
104 return "Fetch base branch";
105 case "checkout":
106 return "Check out files";
107 case "submodules":
108 return "Init submodules";
109 case "setup-script":
110 return "Run setup script";
111 case "agent":
112 return "Start agent";
113 }
114}