feat(FN-5624): suppress transient task.json ENOENT with guard, logging, and

Implements graceful suppression of transient `task.json` ENOENT errors in the executor, logging a suppression signal and surfacing a banner in the UI, with test coverage for both the executor behavior and notification service. Documentation in `docs/architecture.md` and a changeset for `@runfusion/f

Fusion-Task-Id: FN-5624

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Fusion-Task-Id: FN-5624
This commit is contained in:
gsxdsm
2026-05-28 12:09:22 -07:00
parent 2df891fd53
commit 200dda95dc
6 changed files with 117 additions and 3 deletions

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { TaskDeletedError } from "@fusion/core";
import { isTransientMissingTaskJsonError } from "../executor.js";
describe("isTransientMissingTaskJsonError", () => {
const task = {
id: "FN-5624",
worktree: "/tmp/worktrees/fn-5624",
};
it("matches ENOENT on worktree-scoped .fusion/tasks/<id>/task.json", () => {
const err = `ENOENT: no such file or directory, open '/tmp/worktrees/fn-5624/.fusion/tasks/FN-5624/task.json'`;
expect(isTransientMissingTaskJsonError(err, task)).toBe(true);
});
it("does not match task.json parse failures", () => {
const err = "Failed to parse task.json at /tmp/worktrees/fn-5624/.fusion/tasks/FN-5624/task.json: Unexpected token";
expect(isTransientMissingTaskJsonError(err, task)).toBe(false);
});
it("does not match TaskDeletedError", () => {
expect(isTransientMissingTaskJsonError(new TaskDeletedError("FN-5624", new Date().toISOString()), task)).toBe(false);
});
it("does not match ENOENT for non-task.json paths", () => {
const err = `ENOENT: no such file or directory, open '/tmp/worktrees/fn-5624/.fusion/tasks/FN-5624/PROMPT.md'`;
expect(isTransientMissingTaskJsonError(err, task)).toBe(false);
});
});

View File

@@ -9,7 +9,7 @@ import { delimiter, isAbsolute, join, relative, resolve as resolvePath } from "n
import { existsSync, realpathSync } from "node:fs";
import { readFile, rm, writeFile } from "node:fs/promises";
import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent, AgentMemoryInclusionMode, ProjectSettings } from "@fusion/core";
import { RetryStormError, serializeRetryStormError } from "@fusion/core";
import { RetryStormError, TaskDeletedError, serializeRetryStormError } from "@fusion/core";
import {
ApprovalRequestStore,
buildExecutionMemoryInstructions,
@@ -222,6 +222,33 @@ const WORKFLOW_RERUN_WATCHDOG_MS = 15_000;
const TASK_DONE_REFUSAL_SUFFIX = "Either finish the work and resubmit, or do not call fn_task_done — exit the session and the engine will requeue.";
const TRANSIENT_WORKTREE_TASK_JSON_ENOENT_PATTERN = /ENOENT:\s+no such file or directory,\s+open\s+'([^']+\/\.fusion\/tasks\/([^/]+)\/task\.json)'/;
export function isTransientMissingTaskJsonError(error: unknown, task: Pick<Task, "id" | "worktree">): boolean {
if (error instanceof TaskDeletedError) {
return false;
}
const message = typeof error === "string"
? error
: error instanceof Error
? error.message
: "";
const match = TRANSIENT_WORKTREE_TASK_JSON_ENOENT_PATTERN.exec(message);
if (!match) {
return false;
}
const [, filePath, taskIdFromPath] = match;
if (taskIdFromPath !== task.id) {
return false;
}
if (typeof task.worktree !== "string" || task.worktree.length === 0) {
return false;
}
const normalizedWorktree = resolvePath(task.worktree);
const normalizedTaskJsonPath = resolvePath(filePath);
return normalizedTaskJsonPath.startsWith(`${normalizedWorktree}/`);
}
export const DISSENT_PATTERNS: RegExp[] = [
/\btask (is|was)(?: not|n[']?t) complete\b/i,
/\b(?:i (?:could|can)(?:not|n[']?t)|unable to|failed to) (?:complete|finish|implement)\b/i,
@@ -9027,10 +9054,19 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
audit: RunAuditor,
): Promise<boolean> {
const errorText = error instanceof Error ? error.message : String(error);
if (!isMissingWorktreeSessionStartFailure(errorText)) return false;
const missingWorktreeFailure = isMissingWorktreeSessionStartFailure(errorText);
const missingTaskJsonFailure = isTransientMissingTaskJsonError(error, task);
if (!missingWorktreeFailure && !missingTaskJsonFailure) return false;
const classification = classifyMissingWorktreeSessionStartFailure(errorText);
const staleWorktreePath = extractMissingWorktreePathFromSessionStartFailure(errorText) ?? worktreePath;
const missingTaskJsonPath = errorText.match(TRANSIENT_WORKTREE_TASK_JSON_ENOENT_PATTERN)?.[1] ?? null;
const staleWorktreePath = extractMissingWorktreePathFromSessionStartFailure(errorText)
?? (missingTaskJsonPath ? resolvePath(missingTaskJsonPath, "..", "..", "..") : null)
?? worktreePath;
if (missingTaskJsonFailure) {
executorLog.log(`[transient-task-json-suppressed] taskId=${task.id} elapsedMs=0 reason=missing-task-json-under-worktree path=${missingTaskJsonPath ?? "unknown"}`);
}
await audit.git({
type: "worktree:incomplete-detected",

View File

@@ -113,6 +113,32 @@ describe("NotificationService deferred failure notifications", () => {
await service.stop();
});
it("suppresses transient missing task.json failure after Auto-recovered clear", async () => {
const { store, service, sendNotification } = await setup();
store.setTask(task({
id: "FN-1",
status: "failed",
error: "ENOENT: no such file or directory, open '/tmp/worktrees/fn-1/.fusion/tasks/FN-1/task.json'",
}));
store.emit("task:updated", task({ id: "FN-1", status: "failed" }));
const recoveredTask = task({
id: "FN-1",
status: undefined,
error: undefined,
column: "todo",
log: [{ timestamp: new Date().toISOString(), action: "Auto-recovered: retry/verification session targeted unusable worktree" }],
});
store.setTask(recoveredTask);
store.emit("task:moved", { task: recoveredTask, from: "in-progress", to: "todo" });
await vi.advanceTimersByTimeAsync(100);
expect(sendNotification).not.toHaveBeenCalledWith("failed", expect.anything());
expect((await store.getTask("FN-1"))?.status).not.toBe("failed");
expect(service.getMetrics().failureNotificationSuppressedCount).toBe(1);
await service.stop();
});
it("Recovery via task:moved to done suppresses failed notification", async () => {
const { store, service, sendNotification } = await setup();
store.setTask(task({ id: "FN-1", status: "failed", column: "in-review" }));