fix(dashboard): useMergeAdvanceNotice tests should waitFor toBeDefined

`notice` is `events.find(...)` which returns `undefined` (not `null`)
when no match. `waitFor(() => expect(...).not.toBeNull())` exited
immediately because `undefined !== null` — the test never actually
waited for the api mock to resolve. Sometimes the followup assertions
happened to land after the events fetched (test passed by luck);
sometimes they ran while notice was still undefined and the assertions
failed.

Switched all five waitFor sites to `.toBeDefined()` so they actually
block on the events-fetch resolution.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-23 18:39:36 -07:00
parent de67c5137f
commit 6e7f1e570e
14 changed files with 950 additions and 52 deletions

View File

@@ -970,7 +970,15 @@ describe("aiMergeTask — merge details collection", () => {
});
const result = await aiMergeTask(store, "/tmp/root", "FN-3469");
expect(result.merged).toBe(false);
expect(result.merged).toBe(true);
expect((store.emit as ReturnType<typeof vi.fn>).mock.calls).toContainEqual([
"task:merged",
expect.objectContaining({
merged: true,
mergeConfirmed: true,
commitSha: "a47b1e5d78d626f8b480f1e90d3d64be2625ff6a",
}),
]);
const mergeDetailsCall = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls.find(
(call: any[]) => call[1]?.mergeDetails !== undefined,
@@ -1100,4 +1108,3 @@ describe("aiMergeTask — merge details collection", () => {
});
});

View File

@@ -630,9 +630,65 @@ describe("aiMergeTask — build verification", () => {
expect(installCall).toBeDefined();
expect(store.logEntry).toHaveBeenCalledWith(
"FN-050",
"Syncing dependencies before merge build verification: pnpm install --frozen-lockfile",
"Syncing dependencies before merge verification: pnpm install --frozen-lockfile",
);
});
it("syncs dependencies before test verification when install state is missing", async () => {
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
mockedExistsSync.mockImplementation((path: any) => {
const pathStr = String(path);
if (pathStr.includes("node_modules") || pathStr.endsWith(".pnp.cjs")) return false;
return true;
});
let cachedQuietChecks = 0;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "2 files changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "" as any;
if (cmdStr.includes("git diff --cached --name-only")) {
return "package.json\npackages/desktop/package.json" as any;
}
if (cmdStr.includes("pnpm install --frozen-lockfile")) return "Lockfile is up to date" as any;
if (cmdStr.includes("diff --cached --quiet")) {
cachedQuietChecks += 1;
return cachedQuietChecks === 1 ? "1" as any : "0" as any;
}
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const store = createMockStore(
{ id: "FN-051", worktree: "/tmp/root/.worktrees/KB-051" },
[{ id: "FN-051", worktree: "/tmp/root/.worktrees/KB-051", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
testCommand: "pnpm test",
});
const result = await aiMergeTask(store, "/tmp/root", "FN-051");
expect(result.merged).toBe(true);
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).includes("pnpm install --frozen-lockfile")),
).toBe(true);
});
});
// ── Deterministic Merge Verification Tests ──────────────────────────────
@@ -2868,6 +2924,10 @@ describe("inferDefaultTestCommand — pnpm workspace scoping", () => {
const result = inferDefaultTestCommand("/tmp/root", undefined, undefined, "main", "fusion/fn-123");
expect(result?.command).toBe(`pnpm --filter "@fusion/dashboard...^" test`);
expect(result?.testSource).toBe("inferred-scoped");
expect(mockedExecSync).toHaveBeenCalledWith(
'git diff --name-only "main"..."fusion/fn-123"',
expect.objectContaining({ cwd: "/tmp/root", encoding: "utf-8" }),
);
});
it("returns command with 2 filters when 2 packages are changed", () => {

View File

@@ -279,6 +279,13 @@ describe("PluginRunner", () => {
pluginRunner.invokeHook("onLoad")
).rejects.toThrow("Hook failed");
});
it("should isolate hook invocation errors in invokeHookSafe", async () => {
mockPluginLoader.invokeHook = vi.fn().mockRejectedValue(new Error("Hook failed"));
await pluginRunner.init();
await expect(pluginRunner.invokeHookSafe("onLoad")).resolves.toBeUndefined();
});
});
describe("getPluginTools()", () => {

View File

@@ -44,8 +44,9 @@ describe("sandbox wiring", () => {
});
const stub = makeStub({ run });
__setSandboxBackendForTests(stub);
const controller = new AbortController();
const result = await __runConfiguredCommandForTests("echo hi", "/tmp", 1200, { A: "1" });
const result = await __runConfiguredCommandForTests("echo hi", "/tmp", 1200, { A: "1" }, undefined, controller.signal);
expect(run).toHaveBeenCalledTimes(1);
expect(run).toHaveBeenCalledWith("echo hi", {
@@ -54,6 +55,7 @@ describe("sandbox wiring", () => {
maxBuffer: 10 * 1024 * 1024,
encoding: "utf-8",
env: { A: "1" },
signal: controller.signal,
});
expect((stub.runStreaming as any)).not.toHaveBeenCalled();
expect(result).toMatchObject({
@@ -77,6 +79,7 @@ describe("sandbox wiring", () => {
bufferExceeded: false,
});
__setSandboxBackendForTests(makeStub({ run }));
const controller = new AbortController();
const result = await __executePostMergeScriptStepForTests(
{ updateTask: vi.fn() } as any,
@@ -84,6 +87,8 @@ describe("sandbox wiring", () => {
{ scriptName: "post" } as any,
"/tmp/worktree",
{ scripts: { post: "echo post" } } as any,
undefined,
controller.signal,
);
expect(result.success).toBe(true);
@@ -92,6 +97,7 @@ describe("sandbox wiring", () => {
encoding: "utf-8",
timeoutMs: 120_000,
maxBuffer: 10 * 1024 * 1024,
signal: controller.signal,
});
});

View File

@@ -594,6 +594,7 @@ async function runConfiguredCommand(
timeoutMs: number,
extraEnv?: NodeJS.ProcessEnv,
auditor?: RunAuditor,
signal?: AbortSignal,
): Promise<RunCommandResult> {
const backend = getConfiguredCommandSandboxBackend(auditor);
const result = await backend.run(command, {
@@ -602,6 +603,7 @@ async function runConfiguredCommand(
maxBuffer: 10 * 1024 * 1024,
encoding: "utf-8",
...(extraEnv !== undefined && { env: extraEnv }),
...(signal !== undefined && { signal }),
});
return {
@@ -621,8 +623,9 @@ export async function __runConfiguredCommandForTests(
timeoutMs: number,
extraEnv?: NodeJS.ProcessEnv,
auditor?: RunAuditor,
signal?: AbortSignal,
): Promise<RunCommandResult> {
return runConfiguredCommand(command, cwd, timeoutMs, extraEnv, auditor);
return runConfiguredCommand(command, cwd, timeoutMs, extraEnv, auditor, signal);
}
// ── Tool parameter schemas (module-level for reuse in ToolDefinition generics) ──
@@ -1048,6 +1051,8 @@ export class TaskExecutor {
private activeStepExecutors = new Map<string, StepSessionExecutor>();
/** Active pre-merge workflow step sessions per task. */
private activeWorkflowStepSessions = new Map<string, AgentSession>();
/** Active configured-command abort controllers keyed by task. */
private activeConfiguredCommandControllers = new Map<string, Set<AbortController>>();
private readonlyWorkflowStepAuditDone = false;
/**
* Reviewer subagent sessions per task. Reviewers (`reviewer.ts`) create their
@@ -1130,6 +1135,27 @@ export class TaskExecutor {
}
}
private registerConfiguredCommandController(taskId: string, controller: AbortController): void {
const controllers = this.activeConfiguredCommandControllers.get(taskId) ?? new Set<AbortController>();
controllers.add(controller);
this.activeConfiguredCommandControllers.set(taskId, controllers);
}
private unregisterConfiguredCommandController(taskId: string, controller: AbortController): void {
const controllers = this.activeConfiguredCommandControllers.get(taskId);
if (!controllers) return;
controllers.delete(controller);
if (controllers.size === 0) {
this.activeConfiguredCommandControllers.delete(taskId);
}
}
private createConfiguredCommandAbortError(taskId: string, command: string): Error {
const error = new Error(`Configured command aborted for ${taskId}: ${command}`);
error.name = "AbortError";
return error;
}
private getAutoRecoveryDispatcher(audit: RunAuditor): AutoRecoveryDispatcher {
if (this.options.autoRecoveryDispatcher) return this.options.autoRecoveryDispatcher;
const fileScopeHandler = createFileScopeAutoRecoveryHandler({
@@ -1274,6 +1300,18 @@ export class TaskExecutor {
return true;
}
if ((latestTask && latestTask.column !== "in-progress") || this.userCanceledTaskIds.has(taskId)) {
this.clearCompletedTaskWatchdog(taskId);
executorLog.log(`${taskId}: completion handoff deferred — task no longer active (${context})`);
await this.store.logEntry(
taskId,
`Completion handoff deferred — task no longer active (${context})`,
undefined,
this.getRunContextFor(taskId),
).catch(() => undefined);
return true;
}
return this.shouldDeferCompletionForGlobalPause(taskId, context);
}
@@ -1608,6 +1646,14 @@ export class TaskExecutor {
hadActiveSurface = true;
this.deleteActiveWorkflowStepSession(taskId);
}
const claimedConfiguredCommands = this.activeConfiguredCommandControllers.get(taskId);
if (claimedConfiguredCommands && claimedConfiguredCommands.size > 0) {
hadActiveSurface = true;
this.activeConfiguredCommandControllers.delete(taskId);
for (const controller of claimedConfiguredCommands) {
controller.abort();
}
}
const claimedSubagents = this.activeSubagentSessions.has(taskId);
if (claimedSubagents) {
hadActiveSurface = true;
@@ -1671,6 +1717,7 @@ export class TaskExecutor {
...this.activeSessions.keys(),
...this.activeStepExecutors.keys(),
...this.activeWorkflowStepSessions.keys(),
...this.activeConfiguredCommandControllers.keys(),
...this.activeSubagentSessions.keys(),
]);
@@ -1810,6 +1857,7 @@ export class TaskExecutor {
this.activeSessions.has(task.id)
|| this.activeStepExecutors.has(task.id)
|| this.activeWorkflowStepSessions.has(task.id)
|| this.activeConfiguredCommandControllers.has(task.id)
)
) {
executorLog.log(`Pausing ${task.id} — awaiting in-flight session disposal`);
@@ -1982,6 +2030,18 @@ export class TaskExecutor {
// When globalPause transitions from false → true, terminate all active agent sessions.
store.on("settings:updated", ({ settings, previous }) => {
if (settings.globalPause && !previous.globalPause) {
for (const [taskId, controllers] of this.activeConfiguredCommandControllers) {
executorLog.log(`Global pause — aborting configured command(s) for ${taskId}`);
this.pausedAborted.add(taskId);
this.options.stuckTaskDetector?.untrackTask(taskId);
for (const controller of controllers) {
controller.abort();
}
this.activeConfiguredCommandControllers.delete(taskId);
this.loopRecoveryState.delete(taskId);
this.spawnedAgents.delete(taskId);
this.stuckAborted.delete(taskId);
}
// Dispose every reviewer subagent across every task. The per-task loops
// below handle main + step sessions; reviewers live in their own map
// and would otherwise outlive the global pause.
@@ -3082,21 +3142,42 @@ export class TaskExecutor {
}
const hadAssignedWorktree = Boolean(task.worktree);
const acquisition = await acquireTaskWorktree({
task,
rootDir: this.rootDir,
store: this.store,
settings,
pool: this.options.pool,
logger: executorLog,
audit,
runContext: this.getRunContextFor(task.id),
runInitCommand: true,
createWorktree: this.createWorktree.bind(this),
runConfiguredCommand,
taskEnv,
secretsStore: this.options.secretsStore,
});
const taskCommandAbortController = new AbortController();
this.registerConfiguredCommandController(task.id, taskCommandAbortController);
const acquisition = await (async () => {
try {
return await acquireTaskWorktree({
task,
rootDir: this.rootDir,
store: this.store,
settings,
pool: this.options.pool,
logger: executorLog,
audit,
runContext: this.getRunContextFor(task.id),
runInitCommand: true,
createWorktree: this.createWorktree.bind(this),
runConfiguredCommand: (command, cwd, timeoutMs, env) =>
runConfiguredCommand(
command,
cwd,
timeoutMs,
env,
audit,
taskCommandAbortController.signal,
).then((result) => {
if (taskCommandAbortController.signal.aborted) {
throw this.createConfiguredCommandAbortError(task.id, command);
}
return result;
}),
taskEnv,
secretsStore: this.options.secretsStore,
});
} finally {
this.unregisterConfiguredCommandController(task.id, taskCommandAbortController);
}
})();
worktreePath = acquisition.worktreePath;
if (acquisition.reclaimed) {
@@ -3118,18 +3199,35 @@ export class TaskExecutor {
const scriptCommand = settings.scripts?.[settings.setupScript];
if (scriptCommand) {
const setupStartedAt = Date.now();
const setupAbortController = new AbortController();
this.registerConfiguredCommandController(task.id, setupAbortController);
try {
const setupResult = await runConfiguredCommand(scriptCommand, worktreePath, 120_000, taskEnv, audit);
const setupResult = await runConfiguredCommand(
scriptCommand,
worktreePath,
120_000,
taskEnv,
audit,
setupAbortController.signal,
);
if (setupAbortController.signal.aborted) {
throw this.createConfiguredCommandAbortError(task.id, scriptCommand);
}
if (setupResult.spawnError || setupResult.timedOut || setupResult.exitCode !== 0) {
throw new Error(configuredCommandErrorMessage(setupResult));
}
await this.store.logEntry(task.id, `[timing] Setup script '${settings.setupScript}' completed in ${Date.now() - setupStartedAt}ms`, scriptCommand, this.getRunContextFor(task.id));
} catch (err: unknown) {
if (err instanceof Error && err.name === "AbortError") {
throw err;
}
const execError = err instanceof Error ? err : new Error(String(err));
const message = "stderr" in execError && typeof (execError as Record<string, unknown>).stderr === "string"
? String((execError as Record<string, unknown>).stderr)
: execError.message;
await this.store.logEntry(task.id, `Setup script '${settings.setupScript}' failed: ${message}`, undefined, this.getRunContextFor(task.id));
} finally {
this.unregisterConfiguredCommandController(task.id, setupAbortController);
}
} else {
await this.store.logEntry(task.id, `Setup script '${settings.setupScript}' not found in scripts map — skipping`, undefined, this.getRunContextFor(task.id));
@@ -4084,8 +4182,7 @@ export class TaskExecutor {
executorLog.log(`${task.id}: session registered (model=${describeModel(session)}, stuckDetector=${!!stuckDetector})`);
// Invoke plugin onAgentRunStart hook (fire-and-forget)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
void (this.options.pluginRunner as any)?.invokeHook("onAgentRunStart", task.id);
void this.options.pluginRunner?.invokeHookSafe("onAgentRunStart", task.id);
try {
// Record activity on prompt start (heartbeat for stuck detection)
@@ -4661,8 +4758,7 @@ export class TaskExecutor {
});
}
// Invoke plugin onAgentRunEnd hook (fire-and-forget)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
void (this.options.pluginRunner as any)?.invokeHook("onAgentRunEnd", task.id);
void this.options.pluginRunner?.invokeHookSafe("onAgentRunEnd", task.id);
}
};
@@ -7715,18 +7811,33 @@ ${failureFeedback}
executorLog.log(`${task.id}: workflow step '${workflowStep.name}' executing script '${scriptName}': ${scriptCommand}`);
await this.store.logEntry(task.id, `Workflow step '${workflowStep.name}' executing script '${scriptName}': ${scriptCommand}`);
const scriptAbortController = new AbortController();
this.registerConfiguredCommandController(task.id, scriptAbortController);
try {
const scriptResult = await runConfiguredCommand(scriptCommand, worktreePath, 120_000, extraEnv, createRunAuditor(this.store, {
runId: this.getRunContextFor(task.id)?.runId ?? generateSyntheticRunId("exec-script", task.id),
agentId: this.getRunContextFor(task.id)?.agentId ?? (task.assignedAgentId ?? "executor"),
taskId: task.id,
phase: "execute",
}));
const scriptResult = await runConfiguredCommand(
scriptCommand,
worktreePath,
120_000,
extraEnv,
createRunAuditor(this.store, {
runId: this.getRunContextFor(task.id)?.runId ?? generateSyntheticRunId("exec-script", task.id),
agentId: this.getRunContextFor(task.id)?.agentId ?? (task.assignedAgentId ?? "executor"),
taskId: task.id,
phase: "execute",
}),
scriptAbortController.signal,
);
if (scriptAbortController.signal.aborted) {
throw this.createConfiguredCommandAbortError(task.id, scriptCommand);
}
if (scriptResult.spawnError || scriptResult.timedOut || scriptResult.exitCode !== 0) {
return { success: false, error: configuredCommandErrorMessage(scriptResult) };
}
return { success: true, output: `Script '${scriptName}' completed successfully` };
} catch (err: unknown) {
if (err instanceof Error && err.name === "AbortError") {
throw err;
}
const execError = err instanceof Error ? err : new Error(String(err));
const stderr = "stderr" in execError && typeof execError.stderr === "string" ? execError.stderr.trim() : "";
const stdout = "stdout" in execError && typeof execError.stdout === "string" ? execError.stdout.trim() : "";
@@ -7738,6 +7849,8 @@ ${failureFeedback}
if (!parts.length) parts.push(execError.message || "Unknown error");
const errorOutput = parts.join("\n");
return { success: false, error: errorOutput };
} finally {
this.unregisterConfiguredCommandController(task.id, scriptAbortController);
}
}

View File

@@ -567,8 +567,8 @@ async function syncDependenciesForMerge(
}
throwIfAborted(signal, taskId);
mergerLog.log(`${taskId}: syncing dependencies before merge build verification`);
await store.logEntry(taskId, `Syncing dependencies before merge build verification: ${installCommand}`);
mergerLog.log(`${taskId}: syncing dependencies before merge verification`);
await store.logEntry(taskId, `Syncing dependencies before merge verification: ${installCommand}`);
try {
await execAsync(installCommand, {
cwd: rootDir,
@@ -1044,7 +1044,7 @@ export function packageNamesForFiles(rootDir: string, files: string[]): string[]
*
* @internal Exported for testing only.
*/
export function deriveScopedPnpmTestCommand(rootDir: string, baseBranch: string, _branch: string): string | null {
export function deriveScopedPnpmTestCommand(rootDir: string, baseBranch: string, branch: string): string | null {
// 1. Read and parse pnpm-workspace.yaml
const workspacePath = join(rootDir, "pnpm-workspace.yaml");
let workspaceContent: string;
@@ -1060,11 +1060,11 @@ export function deriveScopedPnpmTestCommand(rootDir: string, baseBranch: string,
const packageRoots = resolveWorkspacePackageRoots(rootDir, globs);
if (packageRoots.length === 0) return null;
// 3. Get the changed files between base and branch tip
// 3. Get the changed files between base and the branch tip passed by caller.
let changedFilesOutput: string;
try {
changedFilesOutput = execSync(
`git diff --name-only ${quoteArg(baseBranch)}...HEAD`,
`git diff --name-only ${quoteArg(baseBranch)}...${quoteArg(branch)}`,
{ cwd: rootDir, stdio: "pipe", encoding: "utf-8" },
).toString();
} catch {
@@ -8131,6 +8131,7 @@ export async function aiMergeTask(
}
if (classification.kind === "owned-commit") {
const mergedAt = new Date().toISOString();
await store.updateTask(taskId, {
mergeDetails: {
commitSha: classification.commit.sha,
@@ -8138,16 +8139,27 @@ export async function aiMergeTask(
insertions: classification.commit.insertions,
deletions: classification.commit.deletions,
mergeCommitMessage: classification.commit.subject,
mergedAt: new Date().toISOString(),
mergedAt,
mergeConfirmed: true,
prNumber: task.prInfo?.number,
mergeTargetBranch: mergeTarget.branch,
mergeTargetSource: mergeTarget.source,
},
});
result.merged = true;
result.mergeConfirmed = true;
result.commitSha = classification.commit.sha;
result.filesChanged = classification.commit.filesChanged;
result.insertions = classification.commit.insertions;
result.deletions = classification.commit.deletions;
result.mergeCommitMessage = classification.commit.subject;
result.mergedAt = mergedAt;
result.mergeTargetBranch = mergeTarget.branch;
result.mergeTargetSource = mergeTarget.source;
mergerLog.log(`${taskId}: branch missing; recovered owned landed commit ${classification.commit.sha.slice(0, 8)}`);
} else {
const noOpReason = `branch has zero commits ahead of ${classification.baseRef}`;
const mergedAt = new Date().toISOString();
await store.updateTask(taskId, {
modifiedFiles: [],
mergeDetails: {
@@ -8156,17 +8168,25 @@ export async function aiMergeTask(
noOpMerge: true,
noOpReason,
landedFiles: [],
mergedAt: new Date().toISOString(),
mergedAt,
prNumber: task.prInfo?.number,
mergeTargetBranch: classification.baseRef,
mergeTargetSource: mergeTarget.source,
},
});
result.merged = true;
result.mergeConfirmed = true;
result.noOp = true;
result.noOpMerge = true;
result.noOpReason = noOpReason;
result.mergedAt = mergedAt;
result.mergeTargetBranch = classification.baseRef;
result.mergeTargetSource = mergeTarget.source;
await store.logEntry(taskId, `Auto-finalized no-op (proven): start point on ${classification.baseRef}; modifiedFiles cleared`);
}
// Audit trail: record merge completion (FN-1404)
await audit.database({ type: "task:move", target: taskId, metadata: { to: "done", merged: false } });
await audit.database({ type: "task:move", target: taskId, metadata: { to: "done", merged: true } });
await completeTask(store, taskId, result);
return result;
}
@@ -10615,7 +10635,7 @@ export async function executeMergeAttempt(
}
}
if (buildCommand) {
if (testCommand || buildCommand) {
throwIfAborted(options.signal, taskId);
const stagedFiles = await getStagedFiles(rootDir);
if (shouldSyncDependenciesForMerge(stagedFiles, hasInstallState(rootDir))) {
@@ -11570,7 +11590,7 @@ async function runPostMergeWorkflowSteps(
try {
const result = stepMode === "script"
? await executePostMergeScriptStep(store, taskId, ws, cwd, settings, auditor)
? await executePostMergeScriptStep(store, taskId, ws, cwd, settings, auditor, mergeOptions.signal)
: await executePostMergePromptStep(store, taskId, ws, rootDir, cwd, settings, mergeOptions);
const completedAt = new Date().toISOString();
@@ -11632,6 +11652,7 @@ async function executePostMergeScriptStep(
cwd: string,
settings: Settings,
auditor?: RunAuditor,
signal?: AbortSignal,
): Promise<{ success: boolean; output?: string; error?: string }> {
const scriptName = workflowStep.scriptName!.trim();
const scripts = settings.scripts || {};
@@ -11647,6 +11668,7 @@ async function executePostMergeScriptStep(
encoding: "utf-8",
timeoutMs: 120_000,
maxBuffer: 10 * 1024 * 1024,
...(signal !== undefined && { signal }),
});
if (result.exitCode === 0 && !result.signal && !result.timedOut && !result.bufferExceeded && !result.spawnError) {
@@ -11674,8 +11696,9 @@ export async function __executePostMergeScriptStepForTests(
cwd: string,
settings: Settings,
auditor?: RunAuditor,
signal?: AbortSignal,
): Promise<{ success: boolean; output?: string; error?: string }> {
return executePostMergeScriptStep(store, taskId, workflowStep, cwd, settings, auditor);
return executePostMergeScriptStep(store, taskId, workflowStep, cwd, settings, auditor, signal);
}
/** Execute a prompt-mode post-merge workflow step using an AI agent in the provided execution directory. */

View File

@@ -1024,7 +1024,7 @@ export class PluginRunner {
/**
* Invoke a hook with error isolation and logging.
*/
private async invokeHookSafe(hookName: keyof FusionPlugin["hooks"], ...args: unknown[]): Promise<void> {
async invokeHookSafe(hookName: keyof FusionPlugin["hooks"], ...args: unknown[]): Promise<void> {
try {
await this.withTimeout(
this.invokeHook(hookName, ...args),

View File

@@ -473,6 +473,9 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
}
await store.logEntry(task.id, `[timing] Worktree init command completed in ${Date.now() - initStartedAt}ms`, settings.worktreeInitCommand, runContext);
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
throw 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);
const outcome = formatInitFailureOutcome(initResult, err);