From ef77936d88ba70989999791ddda517feb9533569 Mon Sep 17 00:00:00 2001 From: Godfrey Hendrix Date: Mon, 9 Nov 2026 23:14:57 -0500 Subject: [PATCH 1/6] fix(server): worktree setup shows submodule downloads instead of a stuck checkout Creating a worktree runs `git submodule update`, but the setup card counted that time as "Check out files", which sat at 100% until every submodule had downloaded. The new orchestrator stopped registering the "Init submodules" step and its progress callbacks, and git prints no transfer progress to a pipe unless asked. The launch service registers the step again and reports git's transfer progress in it, at most once a second. The driver passes --progress to the submodule update and truncates oversized output instead of failing. Co-Authored-By: Claude Opus 5.5 --- .../ThreadLaunchService.test.ts | 77 +++++++++++++++++++ .../orchestration-v2/ThreadLaunchService.ts | 51 +++++++++++- apps/server/src/vcs/GitVcsDriverCore.test.ts | 9 ++- apps/server/src/vcs/GitVcsDriverCore.ts | 18 ++++- 4 files changed, 148 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index fb7a2ffc2d897..35a7ccf073f9d 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -2348,6 +2348,79 @@ it.effect("stops an in-flight branch rename when tracked setup is cancelled", () }), ); +it.effect("reports submodule downloads as their own setup step", () => + Effect.gen(function* () { + const progressReady = + yield* Deferred.make< + NonNullable< + NonNullable< + Parameters[1] + >["progress"] + > + >(); + const release = yield* Deferred.make(); + const harness = makeHarness({ + createWorktree: (input, options) => + Deferred.succeed(progressReady, options?.progress ?? {}).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as({ + worktree: { + path: "/repo-worktrees/feature", + refName: input.newRefName, + headSha: "abc", + }, + } as never), + ), + }); + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const tracker = yield* WorktreeSetupTracker.WorktreeSetupTracker; + const launched = yield* launches.launch( + launchInput({ + command: "launch:submodule-progress", + thread: "thread:submodule-progress", + message: "Start", + workspace: { type: "worktree", baseRef: "main" }, + }), + ); + const stage = (id: string) => + tracker + .get(launched.threadId) + .pipe(Effect.map((snapshot) => snapshot?.stages.find((entry) => entry.id === id))); + const progress = yield* Deferred.await(progressReady); + const line = (text: string) => progress.onSubmoduleLine?.(text) ?? Effect.void; + + yield* progress.onSubmodulesStarted?.() ?? Effect.void; + yield* line("Receiving objects: 20% (2/10), 4.00 MiB | 1.50 MiB/s"); + yield* line("Receiving objects: 30% (3/10), 6.00 MiB | 1.50 MiB/s"); + assert.equal((yield* stage("checkout"))?.status, "done"); + assert.deepInclude(yield* stage("submodules"), { + status: "running", + detail: "Receiving objects: 20% (2/10), 4.00 MiB | 1.50 MiB/s", + }); + + yield* TestClock.adjust("1 second"); + yield* line("Receiving objects: 60% (6/10), 12.00 MiB | 1.50 MiB/s"); + assert.equal( + (yield* stage("submodules"))?.detail, + "Receiving objects: 60% (6/10), 12.00 MiB | 1.50 MiB/s", + ); + + yield* line("Submodule path 'vendor/assets': checked out 'abc'"); + yield* progress.onSubmodulesFinished?.({ ok: true, detail: null }) ?? Effect.void; + yield* Deferred.succeed(release, undefined); + yield* tracker.stream(launched.threadId).pipe( + Stream.filter((snapshot) => snapshot?.phase !== "running"), + Stream.runHead, + ); + assert.deepInclude(yield* stage("submodules"), { + status: "done", + detail: "vendor/assets", + }); + }).pipe(Effect.provide(harness.layer)); + }), +); + it.effect.each([0, 1])("releases an async setup before its completion with exit %s", (exitCode) => Effect.gen(function* () { const completion = yield* Deferred.make<{ exitCode: number | null; durationMs: number }>(); @@ -2397,6 +2470,10 @@ it.effect.each([0, 1])("releases an async setup before its completion with exit settled?.stages.find((stage) => stage.id === "setup-script")?.status, exitCode === 0 ? "done" : "failed", ); + assert.deepInclude( + settled?.stages.find((stage) => stage.id === "submodules"), + { status: "skipped", detail: "none" }, + ); assert.equal( (yield* threads.getThreadProjection(launched.threadId)).runs[0]?.status, "starting", diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 7e7829ba947cf..07032fd3cd16c 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -22,6 +22,7 @@ import { ThreadId, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -40,6 +41,7 @@ import { } from "@t3tools/shared/git"; import * as GitWorkflow from "../git/GitWorkflowService.ts"; +import { parseGitCloneProgressLine } from "../project/gitCloneProgress.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; @@ -321,7 +323,7 @@ const make = Effect.gen(function* () { threadId, branch: input.workspaceStrategy.branch ?? null, baseRef: input.workspaceStrategy.baseRef, - stages: ["fetch", "checkout", "setup-script", "agent"], + stages: ["fetch", "checkout", "submodules", "setup-script", "agent"], fiber: yield* Effect.fiber, }); } else if (reused !== undefined) { @@ -435,6 +437,7 @@ const make = Effect.gen(function* () { branch = flattenTemporaryWorktreeBranchName(branch); } yield* setupTracker.stageStatus(threadId, "checkout", "running"); + let submoduleProgressReportedAt = Number.NEGATIVE_INFINITY; const worktree = yield* git .createWorktree( { @@ -453,6 +456,41 @@ const make = Effect.gen(function* () { }), onCheckoutProgress: (progress) => setupTracker.stage(threadId, "checkout", { percent: progress.percent }), + onSubmodulesStarted: () => + setupTracker + .stageStatus(threadId, "checkout", "done") + .pipe( + Effect.andThen(setupTracker.stageStatus(threadId, "submodules", "running")), + ), + onSubmodulesDisabled: ({ source }) => + setupTracker.stageStatus( + threadId, + "submodules", + "skipped", + `disabled in ${source}`, + ), + onSubmoduleLine: (line) => { + const submodulePath = /Submodule path '([^']+)'/.exec(line)?.[1]; + if (submodulePath !== undefined) { + return setupTracker.stage(threadId, "submodules", { detail: submodulePath }); + } + if (parseGitCloneProgressLine(line) === null) return Effect.void; + // Git redraws on every percent; hold the websocket to one update a second. + return Effect.flatMap(Clock.currentTimeMillis, (now) => { + if (now - submoduleProgressReportedAt < 1000) return Effect.void; + submoduleProgressReportedAt = now; + return setupTracker.stage(threadId, "submodules", { + detail: line.trim().replace(/\s+/g, " "), + }); + }); + }, + onSubmodulesFinished: ({ ok, detail }) => + setupTracker.stageStatus( + threadId, + "submodules", + ok ? "done" : "warning", + ok ? undefined : (detail ?? "submodule checkout failed"), + ), }, }, ) @@ -461,7 +499,16 @@ const make = Effect.gen(function* () { branch = worktree.worktree.refName; createdWorktreePath = worktreePath; createdBranch = branch; - yield* setupTracker.update(threadId, (snapshot) => ({ ...snapshot, worktreePath, branch })); + yield* setupTracker.update(threadId, (snapshot) => ({ + ...snapshot, + worktreePath, + branch, + stages: snapshot.stages.map((stage) => + stage.id === "submodules" && stage.status === "pending" + ? { ...stage, status: "skipped", detail: "none" } + : stage, + ), + })); yield* setupTracker.stageStatus(threadId, "checkout", "done"); } diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index baf1368054d4e..c486c9ffad58b 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -4904,7 +4904,8 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { const cwd = yield* makeTmpDir(); const { initialBranch } = yield* initRepoWithCommit(cwd); - yield* git(cwd, ["submodule", "add", submoduleRepo, "shared"]); + // A file URL transfers a pack like a network remote; a plain path hardlinks silently. + yield* git(cwd, ["submodule", "add", NodeURL.pathToFileURL(submoduleRepo).href, "shared"]); yield* git(cwd, ["commit", "-m", "add submodule"]); const recordedHead = yield* git(submoduleRepo, ["rev-parse", "HEAD"]); yield* writeTextFile(submoduleRepo, "SHARED.md", "# newer remote default\n"); @@ -4979,7 +4980,11 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* git(pathService.join(worktreePath, "shared"), ["status", "--porcelain"]), "", ); - if (progress) assert.isNotEmpty(yield* Ref.get(lines)); + if (progress) { + assert.isTrue( + (yield* Ref.get(lines)).some((line) => line.startsWith("Receiving objects")), + ); + } }).pipe(withGitFileProtocol), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 4fcf1b05fe6f5..1a4636b49752e 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -194,6 +194,7 @@ interface ExecuteGitOptions { env?: NodeJS.ProcessEnv | undefined; maxOutputBytes?: number | undefined; appendTruncationMarker?: boolean | undefined; + keepLineCallbacksAfterTruncation?: boolean | undefined; progress?: GitVcsDriver.ExecuteGitProgress | undefined; writesSharedConfig?: boolean | undefined; } @@ -1170,6 +1171,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ...(options.appendTruncationMarker !== undefined ? { appendTruncationMarker: options.appendTruncationMarker } : {}), + ...(options.keepLineCallbacksAfterTruncation + ? { keepLineCallbacksAfterTruncation: true } + : {}), ...(options.progress ? { progress: options.progress } : {}), ...(options.writesSharedConfig ? { writesSharedConfig: true } : {}), }).pipe( @@ -4383,9 +4387,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* runGit( "GitVcsDriver.createWorktree.updateSubmodules", worktreePath, - submoduleMode.value === "recursive" - ? ["submodule", "update", "--init", "--recursive"] - : ["submodule", "update", "--init"], + [ + "submodule", + "update", + "--init", + ...(submoduleMode.value === "recursive" ? ["--recursive"] : []), + // Git reports transfer progress to a pipe only when forced. + ...(onSubmoduleLine ? ["--progress"] : []), + ], { // Cloning scales with remote size; let it finish or be cancelled, like worktree add. timeoutMs: null, @@ -4393,6 +4402,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ? { env: { LANGUAGE: "C" }, progress: { onStdoutLine: onSubmoduleLine, onStderrLine: onSubmoduleLine }, + // Progress for many submodules can pass the output cap; truncate instead of failing. + appendTruncationMarker: true, + keepLineCallbacksAfterTruncation: true, } : {}), }, From 242ffdf15f1416bcd5f73142a7c309931905372e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 05:09:08 +0000 Subject: [PATCH 2/6] fix(server): git progress lines parse in linear time A long space run in a progress line made the PERCENT regex backtrack cubically on the server's main thread. Submodule output now reaches this parser, so a submodule remote could stall the server. Match the counters, then trim the tail in code. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/project/ProjectCloneTracker.test.ts | 3 +++ apps/server/src/project/gitCloneProgress.ts | 9 +++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/server/src/project/ProjectCloneTracker.test.ts b/apps/server/src/project/ProjectCloneTracker.test.ts index ed775e55a985a..b0c7ae2fbb7ae 100644 --- a/apps/server/src/project/ProjectCloneTracker.test.ts +++ b/apps/server/src/project/ProjectCloneTracker.test.ts @@ -313,6 +313,9 @@ describe("parseGitCloneProgressLine", () => { expect( parseGitCloneProgressLine("Receiving objects: 45% (4500/10000), 12.30 MiB | 5.00 MiB/s"), ).toEqual({ stage: "receiving", percent: 45, detail: "12.30 MiB | 5.00 MiB/s" }); + expect( + parseGitCloneProgressLine("Receiving objects: 100% (5/5), 40.00 MiB | 1.40 MiB/s, done."), + ).toEqual({ stage: "receiving", percent: 100, detail: "40.00 MiB | 1.40 MiB/s" }); expect(parseGitCloneProgressLine("Resolving deltas: 100% (700/700), done.")).toEqual({ stage: "resolving", percent: 100, diff --git a/apps/server/src/project/gitCloneProgress.ts b/apps/server/src/project/gitCloneProgress.ts index f1d75b532bc7a..a71a05efde670 100644 --- a/apps/server/src/project/gitCloneProgress.ts +++ b/apps/server/src/project/gitCloneProgress.ts @@ -16,7 +16,9 @@ const STAGE_PREFIXES: ReadonlyArray = [ [/^Checking out files/, "checkout"], ]; -const PERCENT = /:\s+(\d+)%\s+\((\d+)\/(\d+)\)(?:,\s*(.*?))?\s*(?:,\s*done\.)?\s*$/; +// The tail is trimmed in code: optional groups of `\s*` and `.*?` backtrack cubically on long space runs. +const PERCENT = /:\s+(\d+)%\s+\((\d+)\/(\d+)\)(.*)$/; +const DONE_SUFFIX = ", done."; /** Pass each bare-CR progress redraw as a separate line. */ export function parseGitCloneProgressLine(line: string): GitCloneProgressLine | null { @@ -26,8 +28,11 @@ export function parseGitCloneProgressLine(line: string): GitCloneProgressLine | const stage = stageEntry[1]; const match = PERCENT.exec(trimmed); if (!match) return { stage, percent: null, detail: null }; + let tail = (match[4] ?? "").trimEnd(); + if (tail.endsWith(DONE_SUFFIX)) tail = tail.slice(0, -DONE_SUFFIX.length).trimEnd(); + if (tail.length > 0 && !tail.startsWith(",")) return { stage, percent: null, detail: null }; const percent = Number(match[1]); - const rawDetail = match[4]?.trim() ?? ""; + const rawDetail = tail.slice(1).trim(); const detail = rawDetail.length > 0 && rawDetail !== "done." ? rawDetail : null; return { stage, From a03b0df6f873dbecc30c6518e9aa129e6bddfe38 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 05:16:06 +0000 Subject: [PATCH 3/6] fix(server): progress counters stay linear across Unicode line separators `.` stops at U+2028, so the unanchored counter regex retried at every later counter on a line of repeated counters. Let the tail span them. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/project/gitCloneProgress.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/server/src/project/gitCloneProgress.ts b/apps/server/src/project/gitCloneProgress.ts index a71a05efde670..b74ecf6da84e1 100644 --- a/apps/server/src/project/gitCloneProgress.ts +++ b/apps/server/src/project/gitCloneProgress.ts @@ -16,8 +16,9 @@ const STAGE_PREFIXES: ReadonlyArray = [ [/^Checking out files/, "checkout"], ]; -// The tail is trimmed in code: optional groups of `\s*` and `.*?` backtrack cubically on long space runs. -const PERCENT = /:\s+(\d+)%\s+\((\d+)\/(\d+)\)(.*)$/; +// Linear on hostile remote output: the tail is trimmed in code (optional `\s*`/`.*?` groups backtrack +// cubically), and `s` lets it span U+2028 so a failed `$` cannot retry at every later counter. +const PERCENT = /:\s+(\d+)%\s+\((\d+)\/(\d+)\)(.*)$/s; const DONE_SUFFIX = ", done."; /** Pass each bare-CR progress redraw as a separate line. */ From dcf1afe4e0b4bcbba076d2360c2f35c88a1b7192 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 05:21:04 +0000 Subject: [PATCH 4/6] fix(server): submodule paths with quotes show in full Git closes the path in 'Submodule path' lines with "': "; stopping at the first quote cut paths such as vendor/o'hare short. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/orchestration-v2/ThreadLaunchService.test.ts | 4 ++-- apps/server/src/orchestration-v2/ThreadLaunchService.ts | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 35a7ccf073f9d..9701417d24a7e 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -2406,7 +2406,7 @@ it.effect("reports submodule downloads as their own setup step", () => "Receiving objects: 60% (6/10), 12.00 MiB | 1.50 MiB/s", ); - yield* line("Submodule path 'vendor/assets': checked out 'abc'"); + yield* line("Submodule path 'vendor/o'hare': checked out 'abc'"); yield* progress.onSubmodulesFinished?.({ ok: true, detail: null }) ?? Effect.void; yield* Deferred.succeed(release, undefined); yield* tracker.stream(launched.threadId).pipe( @@ -2415,7 +2415,7 @@ it.effect("reports submodule downloads as their own setup step", () => ); assert.deepInclude(yield* stage("submodules"), { status: "done", - detail: "vendor/assets", + detail: "vendor/o'hare", }); }).pipe(Effect.provide(harness.layer)); }), diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 07032fd3cd16c..d75b29451807a 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -470,7 +470,8 @@ const make = Effect.gen(function* () { `disabled in ${source}`, ), onSubmoduleLine: (line) => { - const submodulePath = /Submodule path '([^']+)'/.exec(line)?.[1]; + // Paths may contain quotes; git closes the path with "': ". + const submodulePath = /^\s*Submodule path '(.+)': /.exec(line)?.[1]; if (submodulePath !== undefined) { return setupTracker.stage(threadId, "submodules", { detail: submodulePath }); } From a0c4eaf98a200a02f659d65c0a671c518fee4795 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 06:29:11 +0000 Subject: [PATCH 5/6] fix(web): long setup stage details no longer hide the stage name A failed submodule update reports git's full error as the stage detail, which took the whole row and squeezed out the label and icon. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/chat/WorktreeSetupCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/WorktreeSetupCard.tsx b/apps/web/src/components/chat/WorktreeSetupCard.tsx index 2f8f514bed41e..2b6b5671f2c3c 100644 --- a/apps/web/src/components/chat/WorktreeSetupCard.tsx +++ b/apps/web/src/components/chat/WorktreeSetupCard.tsx @@ -201,7 +201,7 @@ function StageRow({ trailing={ <> {trailing ? ( - + {trailing} ) : null} From a9b132e116e056bdda7aa3bcb9ce7b5eb379fcf3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 06:51:30 +0000 Subject: [PATCH 6/6] fix(web): setup stage details take at most half the row At 390 px a two-thirds cap left the stage name about 38 px; half keeps it readable while git's transfer line still fits at desktop widths. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/chat/WorktreeSetupCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/WorktreeSetupCard.tsx b/apps/web/src/components/chat/WorktreeSetupCard.tsx index 2b6b5671f2c3c..65498cef52cb1 100644 --- a/apps/web/src/components/chat/WorktreeSetupCard.tsx +++ b/apps/web/src/components/chat/WorktreeSetupCard.tsx @@ -201,7 +201,7 @@ function StageRow({ trailing={ <> {trailing ? ( - + {trailing} ) : null}