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:
@@ -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", () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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()", () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user