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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
Reference in New Issue
Block a user