From ed8c8752f08de61f7e78e68b313041674be88435 Mon Sep 17 00:00:00 2001 From: "Fusion (runfusion.ai)" Date: Sat, 16 May 2026 21:29:00 -0700 Subject: [PATCH] =?UTF-8?q?feat(FN-4834):=20complete=20Step=201=20?= =?UTF-8?q?=E2=80=94=20surface=20init=20diagnostics=20in=20task=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fusion-Task-Id: FN-4834 Fusion-Task-Lineage: 714c9c73-ca66-4c3f-980c-d03e6b50316a --- .../__tests__/worktree-acquisition.test.ts | 23 +++++++++ packages/engine/src/worktree-acquisition.ts | 50 +++++++++++++++++-- 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/packages/engine/src/__tests__/worktree-acquisition.test.ts b/packages/engine/src/__tests__/worktree-acquisition.test.ts index 735da2977..7b05d24db 100644 --- a/packages/engine/src/__tests__/worktree-acquisition.test.ts +++ b/packages/engine/src/__tests__/worktree-acquisition.test.ts @@ -117,6 +117,29 @@ describe("acquireTaskWorktree", () => { }); expect(runConfiguredCommand).not.toHaveBeenCalled(); }); + + it("FN-4834: logs worktree init stderr in task log outcome", async () => { + const runConfiguredCommand = vi.fn().mockResolvedValue({ + exitCode: 1, + stderr: "ERR_PNPM_FROZEN_LOCKFILE_WITH_OUTDATED_LOCKFILE Cannot install with \"frozen-lockfile\" because pnpm-lock.yaml is not up to date", + stdout: "", + }); + + await expect(acquireTaskWorktree({ + task, + rootDir: process.cwd(), + store, + settings: { worktreeInitCommand: "pnpm install --frozen-lockfile" } as any, + createWorktree: vi.fn().mockResolvedValue({ path: "/tmp/new", branch: "fusion/fn-1" }), + runConfiguredCommand, + runInitCommand: true, + logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn() }, + })).resolves.toBeTruthy(); + + const failureCall = store.logEntry.mock.calls.find((call: unknown[]) => String(call[1]).startsWith("Worktree init command failed")); + expect(failureCall).toBeDefined(); + expect(failureCall?.[2]).toContain("ERR_PNPM_FROZEN_LOCKFILE_WITH_OUTDATED_LOCKFILE"); + }); }); describe("acquireTaskWorktree foreign start-point warning", () => { diff --git a/packages/engine/src/worktree-acquisition.ts b/packages/engine/src/worktree-acquisition.ts index 664702dc8..16816d60d 100644 --- a/packages/engine/src/worktree-acquisition.ts +++ b/packages/engine/src/worktree-acquisition.ts @@ -51,7 +51,15 @@ export interface AcquireTaskWorktreeOptions { startPoint?: string, allowSiblingBranchRename?: boolean, ) => Promise<{ path: string; branch: string }>; - runConfiguredCommand?: (command: string, cwd: string, timeoutMs: number, env?: NodeJS.ProcessEnv) => Promise<{ spawnError?: string | Error; timedOut?: boolean; exitCode?: number | null }>; + runConfiguredCommand?: (command: string, cwd: string, timeoutMs: number, env?: NodeJS.ProcessEnv) => Promise<{ + spawnError?: string | Error; + timedOut?: boolean; + exitCode?: number | null; + signal?: NodeJS.Signals | null; + stdout?: string; + stderr?: string; + bufferExceeded?: boolean; + }>; taskEnv?: NodeJS.ProcessEnv; backend?: WorktreeBackend; } @@ -68,12 +76,44 @@ export interface AcquireTaskWorktreeResult { }; } +type InitCommandResult = Awaited>>; + +const INIT_OUTCOME_MAX_CHARS = 2_000; + function configuredCommandErrorMessage(result: { spawnError?: string | Error; timedOut?: boolean; exitCode?: number | null }): string { if (result.spawnError) return `Failed to start command: ${result.spawnError}`; if (result.timedOut) return "Command timed out"; return `Command exited with code ${result.exitCode ?? "unknown"}`; } +function truncateInitCommandOutput(output: string): string { + if (output.length <= INIT_OUTCOME_MAX_CHARS) return output; + return `... output truncated to last ${INIT_OUTCOME_MAX_CHARS} chars ...\n${output.slice(-INIT_OUTCOME_MAX_CHARS)}`; +} + +function formatInitFailureOutcome(initResult: InitCommandResult | undefined, err: unknown): string { + const stderr = initResult?.stderr?.trim(); + if (stderr) return truncateInitCommandOutput(stderr); + + const stdout = initResult?.stdout?.trim(); + if (stdout) return truncateInitCommandOutput(stdout); + + if (initResult?.spawnError) { + return typeof initResult.spawnError === "string" ? initResult.spawnError : initResult.spawnError.message; + } + + const parts: string[] = []; + if (initResult?.timedOut) parts.push("Command timed out"); + if (initResult?.exitCode !== undefined && initResult.exitCode !== null) parts.push(`exit code: ${initResult.exitCode}`); + if (initResult?.signal) parts.push(`signal: ${initResult.signal}`); + if (parts.length > 0) return parts.join("; "); + + if (err instanceof Error && err.message.trim().length > 0) return err.message; + + const fallback = String(err).trim(); + return fallback.length > 0 ? fallback : "Command failed"; +} + async function maybeWarnForeignTaskStartPoint( input: { baseBranch: string | null; @@ -321,8 +361,9 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro if (runInitCommand && settings.worktreeInitCommand && runConfiguredCommand) { const initStartedAt = Date.now(); + let initResult: InitCommandResult | undefined; try { - const initResult = await runConfiguredCommand(settings.worktreeInitCommand, worktreePath, 300_000, taskEnv); + initResult = await runConfiguredCommand(settings.worktreeInitCommand, worktreePath, 300_000, taskEnv); if (initResult.spawnError || initResult.timedOut || initResult.exitCode !== 0) { throw new Error(configuredCommandErrorMessage(initResult)); } @@ -330,8 +371,9 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro } catch (err) { await store.logEntry(task.id, `[timing] Worktree init command failed after ${Date.now() - initStartedAt}ms`, undefined, runContext); const message = err instanceof Error ? err.message : String(err); - logger?.error?.(`${task.id}: worktree init command failed — first test run will likely fail: ${message}`); - await store.logEntry(task.id, `Worktree init command failed (first test run will likely fail): ${message}`, undefined, runContext); + const outcome = formatInitFailureOutcome(initResult, err); + logger?.error?.(`${task.id}: worktree init command failed — first test run will likely fail: ${message} (stderr captured in task log outcome)`); + await store.logEntry(task.id, `Worktree init command failed (first test run will likely fail): ${message}`, outcome, runContext); } }