diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index a0a438bf97301..64acad6ecf794 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -2460,6 +2460,79 @@ it.effect("tells the thread when its worktree branch could not be renamed", () = }), ); +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/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( + Stream.filter((snapshot) => snapshot?.phase !== "running"), + Stream.runHead, + ); + assert.deepInclude(yield* stage("submodules"), { + status: "done", + detail: "vendor/o'hare", + }); + }).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 }>(); @@ -2509,6 +2582,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 c65754dece315..f94a4e859053a 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 DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -41,6 +42,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"; @@ -437,7 +439,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) { @@ -552,6 +554,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( { @@ -570,6 +573,42 @@ 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) => { + // 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 }); + } + 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"), + ), }, }, ) @@ -578,7 +617,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/project/ProjectCloneTracker.test.ts b/apps/server/src/project/ProjectCloneTracker.test.ts index 0d0a614e467ab..77abcf87a81d6 100644 --- a/apps/server/src/project/ProjectCloneTracker.test.ts +++ b/apps/server/src/project/ProjectCloneTracker.test.ts @@ -343,6 +343,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..b74ecf6da84e1 100644 --- a/apps/server/src/project/gitCloneProgress.ts +++ b/apps/server/src/project/gitCloneProgress.ts @@ -16,7 +16,10 @@ const STAGE_PREFIXES: ReadonlyArray = [ [/^Checking out files/, "checkout"], ]; -const PERCENT = /:\s+(\d+)%\s+\((\d+)\/(\d+)\)(?:,\s*(.*?))?\s*(?:,\s*done\.)?\s*$/; +// 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. */ export function parseGitCloneProgressLine(line: string): GitCloneProgressLine | null { @@ -26,8 +29,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, diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 542057e60c3ec..eadefb4d79ab1 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -5392,7 +5392,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"); @@ -5467,7 +5468,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 a0b99e6d6d7f5..b0a4e732fa4bc 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -4700,9 +4700,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, @@ -4710,6 +4715,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, } : {}), }, diff --git a/apps/web/src/components/chat/WorktreeSetupCard.tsx b/apps/web/src/components/chat/WorktreeSetupCard.tsx index 4e4e7c0e3a943..7b33b3a39e023 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}