FN-8728: retry incomplete planner runs before review

Retry planner attempts until a clean, changed artifact is ready for Plan Review.

- Track each planning attempt's prompt baseline and fallback provenance.
- Require a runtime-owned fallback-dispatch boundary before review handoff.
- Back off and surface actionable errors for empty, unchanged, or fallback-engaged attempts.

Files changed:
 .changeset/fn-8728-planner-retry-before-review.md |   7 +
 docs/architecture.md                              |   2 +-
 packages/engine/src/__tests__/triage.test.ts      | 492 ++++++++++++++++++++++
 packages/engine/src/agent-runtime.ts              |   8 +
 packages/engine/src/agent-session-helpers.ts      |   3 +
 packages/engine/src/pi.ts                         |  14 +-
 packages/engine/src/runtime-resolution.ts         |  14 +-
 packages/engine/src/triage.ts                     | 135 +++++-
 8 files changed, 656 insertions(+), 19 deletions(-)

Fusion-Task-Id: FN-8728

Fusion-Task-Lineage: e7581d29-5bd6-43dc-aa0b-6594b9dd9c0d

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-02 18:26:18 -07:00
parent 4d933b19f6
commit 6cc687d3a3
8 changed files with 656 additions and 19 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Retry fallback or unchanged planner output before Plan Review.
category: fix
dev: Planning finalization now requires a changed, settled, fallback-free attempt artifact.

View File

@@ -604,7 +604,7 @@ See [Memory Plugin Contract](./memory-plugin-contract.md) for the full plan.
`@fusion/engine` executes the autonomous workflow.
### Agent roles
- **Planning**: the planning processor generates task plans (`PROMPT.md`) and selects eligible planning tasks by priority first, then FIFO (`createdAt` ascending) within each priority tier. If the stuck-task detector kills a not-yet-approved planning session after a non-empty `PROMPT.md` draft exists, the retry is requeued as `needs-replan` and seeds the next prompt in revision mode from that draft instead of cold-starting. When `PROMPT.md` is absent, a non-empty `plan` task document written through `fn_task_document_write` is the fallback seed; missing or whitespace-only drafts still cold-start.
- **Planning**: the planning processor generates task plans (`PROMPT.md`) and selects eligible planning tasks by priority first, then FIFO (`createdAt` ascending) within each priority tier. Each attempt captures the authoritative artifact baseline and owns its fallback callback provenance. Only a settled, fallback-free attempt that changed that exact baseline and passes deterministic validation may hand off to workflow Plan Review. Empty, unchanged, or fallback-engaged attempts use the shared bounded `recoveryRetryCount`/`nextRecoveryAt` backoff; exhaustion persists an actionable planning error and never signals successful handoff. After a prompt settles, triage awaits the originating runtime's finite `settleFallbackDispatch` lifecycle signal, then awaits every observer callback admitted by that signal before deciding. A configured runtime that cannot supply this signal fails closed through the same bounded planning recovery rather than handing a potentially fallback-authored plan to review. This deliberately never inspects arbitrary Node timers: clean planner housekeeping can schedule unrelated one-shot or recurring timers without delaying admission. A callback from an obsolete attempt remains scoped to that attempt. Explicit duplicate-marker closure runs only after this same clean-attempt admission. If the stuck-task detector kills a not-yet-approved planning session after a non-empty `PROMPT.md` draft exists, the retry is requeued as `needs-replan` and seeds the next prompt in revision mode from that draft instead of cold-starting. When `PROMPT.md` is absent, a non-empty `plan` task document written through `fn_task_document_write` is the fallback seed; missing or whitespace-only drafts still cold-start.
- **Executor**: `TaskExecutor` (`executor.ts`) implements tasks in worktrees
- **Execution-only reused-base refresh (FN-8693):** planning creates isolated worktrees but does not refresh them; immediately before a graph `code` node, normal executor dispatch, or durable-agent heartbeat session, refresh-enabled reuse resolves the current integration target C1 and compares it with durable `task.baseCommitSha`. A clean no-own-commit checkout resets to C1; a clean own-commit checkout rebases and retains its resulting C2 `HEAD`, while storing C1—not C2—as the baseline. A durable C0/C1 mismatch is rechecked from git and durable metadata on every acquisition, so restart reconciliation needs no in-memory marker. Dirty, unresolved, unsupported worktrunk, git, conflict, persistence, and unprovable-reconciliation cases are typed non-execution outcomes that park before session start. If baseline persistence fails after git moves `HEAD`, the engine compensates to the original clean checkout and emits `worktree:base-refresh-persistence-failed-compensated`; otherwise it requires later proof-based reconciliation. Audit events are `worktree:base-refreshed`, `worktree:base-refresh-blocked`, `worktree:base-refresh-conflict`, `worktree:base-refresh-persistence-failed-compensated`, and `worktree:base-refresh-reconciled`. Plan/review/gate acquisition and merger acquisition remain excluded; merger owns its separate auto-prerebase policy.
- **Reviewer**: `reviewStep()` (`reviewer.ts`) performs plan/code/spec reviews

View File

@@ -67,6 +67,10 @@ vi.mock("../pi.js", () => {
return suffixes.length ? `${model} ${suffixes.map((suffix) => `(${suffix})`).join(" ")}` : model;
}),
promptWithFallback: vi.fn().mockReturnValue("mock-prompt"),
wrapToolsWithRtkRewrite: vi.fn((tools: unknown) => tools),
wrapToolsWithActionGate: vi.fn((tools: unknown) => tools),
wrapToolsWithPermanentAgentGating: vi.fn((tools: unknown) => tools),
wrapToolsWithOutputBudget: vi.fn((tools: unknown) => tools),
};
});
@@ -4287,6 +4291,494 @@ describe("taskCreate tool model inheritance", () => {
);
});
/*
FNXC:TriagePlanningRetry 2026-08-03-00:02:
A syntactically valid PROMPT.md is not proof that the current planner attempt succeeded. These
fixtures hold an existing artifact or write one through a fallback, then assert that Plan Review
handoff remains unavailable until a clean, attempt-local primary write occurs.
*/
it("retries a valid fallback-written plan instead of handing it to Plan Review", async () => {
const task = createTriageTask({ id: "FN-FALLBACK-VALID" });
const root = await createTriageFixtureRoot("fusion-triage-fallback-valid-");
const promptPath = join(root, ".fusion", "tasks", task.id, "PROMPT.md");
await mkdir(join(root, ".fusion", "tasks", task.id), { recursive: true });
const onSpecifyComplete = vi.fn();
let onFallbackModelUsed: ((payload: {
primaryModel: string;
fallbackModel: string;
triggerPoint: "prompt-time";
}) => Promise<void>) | undefined;
const store = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...task, attachments: [], comments: [] }),
});
mockCreateFnAgent.mockImplementation(async (options: { onFallbackModelUsed?: typeof onFallbackModelUsed }) => {
onFallbackModelUsed = options.onFallbackModelUsed;
return {
session: { prompt: vi.fn(), dispose: vi.fn(), sessionManager: {}, navigateTree: vi.fn() },
};
});
const { promptWithFallback } = await import("../pi.js");
(promptWithFallback as ReturnType<typeof vi.fn>).mockImplementationOnce(async () => {
await onFallbackModelUsed?.({
primaryModel: "openai/gpt-4o",
fallbackModel: "anthropic/claude-haiku",
triggerPoint: "prompt-time",
});
await writeFile(promptPath, "## Mission\n\nFallback-authored plan\n", "utf8");
});
try {
await new TriageProcessor(store, root, { onSpecifyComplete }).specifyTask(task);
expect(store.updateTask).toHaveBeenCalledWith(task.id, expect.objectContaining({
status: null,
recoveryRetryCount: 1,
nextRecoveryAt: expect.any(String),
}));
expect(onSpecifyComplete).not.toHaveBeenCalled();
} finally {
await cleanupTriageFixtureRoot(root);
}
});
it("retries a fallback-written duplicate marker instead of closing the task", async () => {
const task = createTriageTask({ id: "FN-FALLBACK-DUPLICATE" });
const root = await createTriageFixtureRoot("fusion-triage-fallback-duplicate-");
const promptPath = join(root, ".fusion", "tasks", task.id, "PROMPT.md");
await mkdir(join(root, ".fusion", "tasks", task.id), { recursive: true });
const onSpecifyComplete = vi.fn();
let onFallbackModelUsed: ((payload: {
primaryModel: string;
fallbackModel: string;
triggerPoint: "prompt-time";
}) => Promise<void>) | undefined;
const store = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...task, attachments: [], comments: [] }),
});
mockCreateFnAgent.mockImplementation(async (options: { onFallbackModelUsed?: typeof onFallbackModelUsed }) => {
onFallbackModelUsed = options.onFallbackModelUsed;
return {
session: { prompt: vi.fn(), dispose: vi.fn(), sessionManager: {}, navigateTree: vi.fn() },
};
});
const { promptWithFallback } = await import("../pi.js");
(promptWithFallback as ReturnType<typeof vi.fn>).mockImplementationOnce(async () => {
await onFallbackModelUsed?.({
primaryModel: "openai/gpt-4o",
fallbackModel: "anthropic/claude-haiku",
triggerPoint: "prompt-time",
});
await writeFile(promptPath, "DUPLICATE: FN-CANONICAL\n", "utf8");
});
try {
await new TriageProcessor(store, root, { onSpecifyComplete }).specifyTask(task);
expect(store.updateTask).toHaveBeenCalledWith(task.id, expect.objectContaining({
status: null,
recoveryRetryCount: 1,
}));
expect(onSpecifyComplete).not.toHaveBeenCalled();
} finally {
await cleanupTriageFixtureRoot(root);
}
});
it("retries an unchanged valid plan instead of treating inherited text as output", async () => {
const task = createTriageTask({ id: "FN-UNCHANGED-VALID" });
const root = await createTriageFixtureRoot("fusion-triage-unchanged-valid-");
const promptPath = join(root, ".fusion", "tasks", task.id, "PROMPT.md");
await mkdir(join(root, ".fusion", "tasks", task.id), { recursive: true });
await writeFile(promptPath, "## Mission\n\nPrior valid plan\n", "utf8");
const onSpecifyComplete = vi.fn();
const store = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...task, attachments: [], comments: [] }),
});
mockCreateFnAgent.mockResolvedValue({
session: { prompt: vi.fn(), dispose: vi.fn(), sessionManager: {}, navigateTree: vi.fn() },
});
try {
await new TriageProcessor(store, root, { onSpecifyComplete }).specifyTask(task);
expect(store.updateTask).toHaveBeenCalledWith(task.id, expect.objectContaining({
status: null,
recoveryRetryCount: 1,
nextRecoveryAt: expect.any(String),
}));
expect(onSpecifyComplete).not.toHaveBeenCalled();
} finally {
await cleanupTriageFixtureRoot(root);
}
});
it("awaits the runtime fallback-dispatch boundary before handoff", async () => {
const task = createTriageTask({ id: "FN-DEFERRED-FALLBACK" });
const root = await createTriageFixtureRoot("fusion-triage-deferred-fallback-");
const promptPath = join(root, ".fusion", "tasks", task.id, "PROMPT.md");
await mkdir(join(root, ".fusion", "tasks", task.id), { recursive: true });
const onSpecifyComplete = vi.fn();
let onFallbackModelUsed: ((payload: {
primaryModel: string;
fallbackModel: string;
triggerPoint: "prompt-time";
}) => Promise<void>) | undefined;
let resolveFallbackDispatch!: () => void;
const fallbackDispatchSettled = new Promise<void>((resolve) => {
resolveFallbackDispatch = resolve;
});
const store = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...task, attachments: [], comments: [] }),
});
mockCreateFnAgent.mockImplementation(async (options: { onFallbackModelUsed?: typeof onFallbackModelUsed }) => {
onFallbackModelUsed = options.onFallbackModelUsed;
return {
session: { prompt: vi.fn(), dispose: vi.fn(), sessionManager: {}, navigateTree: vi.fn() },
settleFallbackDispatch: () => fallbackDispatchSettled,
};
});
const { promptWithFallback } = await import("../pi.js");
(promptWithFallback as ReturnType<typeof vi.fn>).mockImplementationOnce(async () => {
await writeFile(promptPath, "## Mission\n\nDeferred fallback-authored plan\n", "utf8");
// FNXC:TriagePlanningRetry 2026-08-03-01:01: Runtime dispatch settlement, rather than
// unrelated timer tracking, admits this nested callback before triage decides handoff.
setTimeout(() => {
setTimeout(() => {
setImmediate(() => {
void (async () => {
await onFallbackModelUsed?.({
primaryModel: "openai/gpt-4o",
fallbackModel: "anthropic/claude-haiku",
triggerPoint: "prompt-time",
});
resolveFallbackDispatch();
})();
});
}, 0);
}, 40);
});
try {
await new TriageProcessor(store, root, { onSpecifyComplete }).specifyTask(task);
expect(store.updateTask).toHaveBeenCalledWith(task.id, expect.objectContaining({
status: null,
recoveryRetryCount: 1,
}));
expect(onSpecifyComplete).not.toHaveBeenCalled();
expect(store.appendAgentLog).toHaveBeenCalledWith(task.id, expect.stringContaining("[fallback] triage"), "status", undefined, "triage");
} finally {
await cleanupTriageFixtureRoot(root);
}
});
it("fails closed when a plugin runtime omits deferred fallback settlement", async () => {
const task = createTriageTask({
id: "FN-PLUGIN-NO-FALLBACK-BOUNDARY",
assignedAgentId: "agent-plugin",
planningModelProvider: "openai",
planningModelId: "gpt-4o",
});
const root = await createTriageFixtureRoot("fusion-triage-plugin-no-fallback-boundary-");
const promptPath = join(root, ".fusion", "tasks", task.id, "PROMPT.md");
await mkdir(join(root, ".fusion", "tasks", task.id), { recursive: true });
const onSpecifyComplete = vi.fn();
let onFallbackModelUsed: ((payload: {
primaryModel: string;
fallbackModel: string;
triggerPoint: "prompt-time";
}) => Promise<void>) | undefined;
const store = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...task, attachments: [], comments: [] }),
});
const pluginRuntime = {
id: "deferred-planner",
name: "Deferred planner",
createSession: vi.fn(async (options: { onFallbackModelUsed?: typeof onFallbackModelUsed }) => {
onFallbackModelUsed = options.onFallbackModelUsed;
return { session: { prompt: vi.fn(), dispose: vi.fn(), sessionManager: {}, navigateTree: vi.fn() } };
}),
promptWithFallback: vi.fn(async () => {
await writeFile(promptPath, "## Mission\n\nPlugin-written plan\n", "utf8");
setTimeout(() => {
void onFallbackModelUsed?.({
primaryModel: "openai/gpt-4o",
fallbackModel: "anthropic/claude-haiku",
triggerPoint: "prompt-time",
});
}, 0);
}),
describeModel: vi.fn(() => "plugin/planner"),
};
const pluginRunner = {
getRuntimeById: vi.fn().mockReturnValue({
pluginId: "planner-plugin",
runtime: {
metadata: { runtimeId: "deferred-planner", name: "Deferred planner", description: "test", version: "1.0.0" },
factory: vi.fn().mockResolvedValue(pluginRuntime),
},
}),
createRuntimeContext: vi.fn().mockResolvedValue({}),
getPromptContributionsForSurface: vi.fn().mockReturnValue([]),
getPluginSkills: vi.fn().mockReturnValue([]),
};
const agentStore = {
getAgent: vi.fn().mockResolvedValue({
id: "agent-plugin",
name: "Plugin planner",
role: "triage",
runtimeConfig: { runtimeHint: "deferred-planner" },
}),
};
const { promptWithFallback } = await import("../pi.js");
(promptWithFallback as ReturnType<typeof vi.fn>).mockImplementationOnce(
(session: { promptWithFallback: (prompt: string, options?: unknown) => Promise<void> }, prompt: string, options?: unknown) =>
session.promptWithFallback(prompt, options),
);
try {
await new TriageProcessor(store, root, {
onSpecifyComplete,
pluginRunner: pluginRunner as any,
agentStore: agentStore as any,
}).specifyTask(task);
expect(agentStore.getAgent).toHaveBeenCalledWith("agent-plugin");
expect(pluginRunner.getRuntimeById).toHaveBeenCalledWith("deferred-planner");
expect(pluginRuntime.createSession).toHaveBeenCalledTimes(1);
expect(store.updateTask).toHaveBeenCalledWith(task.id, expect.objectContaining({
status: null,
recoveryRetryCount: 1,
nextRecoveryAt: expect.any(String),
}));
expect(store.logEntry).toHaveBeenCalledWith(task.id, expect.stringContaining("did not provide a fallback-dispatch settlement boundary"));
expect(onSpecifyComplete).not.toHaveBeenCalled();
await delay(0);
expect(store.appendAgentLog).toHaveBeenCalledWith(task.id, expect.stringContaining("[fallback] triage"), "status", undefined, "triage");
} finally {
await cleanupTriageFixtureRoot(root);
}
});
it("does not let an unrelated one-shot runtime timer delay clean planning admission", async () => {
const task = createTriageTask({ id: "FN-FALLBACK-ONE-SHOT" });
const root = await createTriageFixtureRoot("fusion-triage-fallback-one-shot-");
const promptPath = join(root, ".fusion", "tasks", task.id, "PROMPT.md");
await mkdir(join(root, ".fusion", "tasks", task.id), { recursive: true });
const onSpecifyComplete = vi.fn();
const store = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...task, attachments: [], comments: [] }),
});
let housekeepingTimer: ReturnType<typeof setTimeout> | undefined;
mockCreateFnAgent.mockResolvedValue({
session: { prompt: vi.fn(), dispose: vi.fn(), sessionManager: {}, navigateTree: vi.fn() },
});
const { promptWithFallback } = await import("../pi.js");
(promptWithFallback as ReturnType<typeof vi.fn>).mockImplementationOnce(async () => {
housekeepingTimer = setTimeout(() => undefined, 60_000);
await writeFile(promptPath, "## Mission\n\nClean plan beside runtime housekeeping\n", "utf8");
});
try {
await expect(Promise.race([
new TriageProcessor(store, root, { onSpecifyComplete }).specifyTask(task),
delay(250).then(() => { throw new Error("planning waited for an unrelated one-shot timer"); }),
])).resolves.toBeUndefined();
expect(onSpecifyComplete).toHaveBeenCalledTimes(1);
} finally {
if (housekeepingTimer) clearTimeout(housekeepingTimer);
await cleanupTriageFixtureRoot(root);
}
});
it("allows a later clean changed attempt after fallback recovery", async () => {
const task = createTriageTask({ id: "FN-CLEAN-AFTER-FALLBACK" });
const retryTask = { ...task, recoveryRetryCount: 1 };
const root = await createTriageFixtureRoot("fusion-triage-clean-after-fallback-");
const promptPath = join(root, ".fusion", "tasks", task.id, "PROMPT.md");
await mkdir(join(root, ".fusion", "tasks", task.id), { recursive: true });
const onSpecifyComplete = vi.fn();
let onFallbackModelUsed: ((payload: {
primaryModel: string;
fallbackModel: string;
triggerPoint: "prompt-time";
}) => Promise<void>) | undefined;
const store = createMockStore({
getTask: vi.fn()
.mockResolvedValueOnce({ ...task, attachments: [], comments: [] })
.mockResolvedValue({ ...retryTask, attachments: [], comments: [] }),
});
mockCreateFnAgent.mockImplementation(async (options: { onFallbackModelUsed?: typeof onFallbackModelUsed }) => {
onFallbackModelUsed = options.onFallbackModelUsed;
return {
session: { prompt: vi.fn(), dispose: vi.fn(), sessionManager: {}, navigateTree: vi.fn() },
};
});
const { promptWithFallback } = await import("../pi.js");
(promptWithFallback as ReturnType<typeof vi.fn>)
.mockImplementationOnce(async () => {
await onFallbackModelUsed?.({
primaryModel: "openai/gpt-4o",
fallbackModel: "anthropic/claude-haiku",
triggerPoint: "prompt-time",
});
await writeFile(promptPath, "## Mission\n\nFallback plan\n", "utf8");
})
.mockImplementationOnce(async () => {
await writeFile(promptPath, "## Mission\n\nClean primary plan\n", "utf8");
});
try {
const processor = new TriageProcessor(store, root, { onSpecifyComplete });
await processor.specifyTask(task);
expect(onSpecifyComplete).not.toHaveBeenCalled();
await processor.specifyTask(retryTask);
expect(onSpecifyComplete).toHaveBeenCalledTimes(1);
expect(store.updateTask).toHaveBeenCalledWith(task.id, expect.objectContaining({
recoveryRetryCount: null,
nextRecoveryAt: null,
}));
} finally {
await cleanupTriageFixtureRoot(root);
}
});
it("keeps an obsolete fallback callback from contaminating a newer clean attempt", async () => {
const task = createTriageTask({ id: "FN-OBSOLETE-FALLBACK" });
const root = await createTriageFixtureRoot("fusion-triage-obsolete-fallback-");
const promptPath = join(root, ".fusion", "tasks", task.id, "PROMPT.md");
await mkdir(join(root, ".fusion", "tasks", task.id), { recursive: true });
const onSpecifyComplete = vi.fn();
const callbacks: Array<((payload: {
primaryModel: string;
fallbackModel: string;
triggerPoint: "prompt-time";
}) => Promise<void>) | undefined> = [];
const store = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...task, attachments: [], comments: [] }),
});
mockCreateFnAgent.mockImplementation(async (options: { onFallbackModelUsed?: (payload: never) => Promise<void> }) => {
callbacks.push(options.onFallbackModelUsed as never);
return {
session: { prompt: vi.fn(), dispose: vi.fn(), sessionManager: {}, navigateTree: vi.fn() },
};
});
const { promptWithFallback } = await import("../pi.js");
(promptWithFallback as ReturnType<typeof vi.fn>)
.mockImplementationOnce(async () => {
await writeFile(promptPath, "## Mission\n\nFirst clean plan\n", "utf8");
})
.mockImplementationOnce(async () => {
await writeFile(promptPath, "## Mission\n\nSecond clean plan\n", "utf8");
queueMicrotask(() => {
void callbacks[0]?.({
primaryModel: "openai/gpt-4o",
fallbackModel: "anthropic/claude-haiku",
triggerPoint: "prompt-time",
});
});
});
try {
const processor = new TriageProcessor(store, root, { onSpecifyComplete });
await processor.specifyTask(task);
await processor.specifyTask(task);
expect(onSpecifyComplete).toHaveBeenCalledTimes(2);
expect(store.appendAgentLog).toHaveBeenCalledWith(task.id, expect.stringContaining("[fallback] triage"), "status", undefined, "triage");
} finally {
await cleanupTriageFixtureRoot(root);
}
});
it("increments the shared fallback retry budget once per failed attempt", async () => {
const root = await createTriageFixtureRoot("fusion-triage-fallback-budget-");
const taskId = "FN-FALLBACK-BUDGET";
const promptPath = join(root, ".fusion", "tasks", taskId, "PROMPT.md");
await mkdir(join(root, ".fusion", "tasks", taskId), { recursive: true });
let onFallbackModelUsed: ((payload: {
primaryModel: string;
fallbackModel: string;
triggerPoint: "prompt-time";
}) => Promise<void>) | undefined;
const store = createMockStore({
getTask: vi.fn().mockImplementation(async () => ({
...createTriageTask({ id: taskId }), attachments: [], comments: [],
})),
});
mockCreateFnAgent.mockImplementation(async (options: { onFallbackModelUsed?: typeof onFallbackModelUsed }) => {
onFallbackModelUsed = options.onFallbackModelUsed;
return {
session: { prompt: vi.fn(), dispose: vi.fn(), sessionManager: {}, navigateTree: vi.fn() },
};
});
const { promptWithFallback } = await import("../pi.js");
(promptWithFallback as ReturnType<typeof vi.fn>).mockImplementation(async () => {
await onFallbackModelUsed?.({
primaryModel: "openai/gpt-4o",
fallbackModel: "anthropic/claude-haiku",
triggerPoint: "prompt-time",
});
await writeFile(promptPath, `## Mission\n\nFallback plan ${Date.now()}\n`, "utf8");
});
try {
const processor = new TriageProcessor(store, root);
for (const recoveryRetryCount of [0, 1, 2]) {
const task = createTriageTask({ id: taskId, recoveryRetryCount });
await processor.specifyTask(task);
expect(store.updateTask).toHaveBeenCalledWith(taskId, expect.objectContaining({
recoveryRetryCount: recoveryRetryCount + 1,
nextRecoveryAt: expect.any(String),
}));
}
expect(store.updateTask).not.toHaveBeenCalledWith(taskId, expect.objectContaining({
recoveryRetryCount: 0,
}));
} finally {
await cleanupTriageFixtureRoot(root);
}
});
it("terminalizes a fallback-written plan when its shared retry budget is exhausted", async () => {
const task = createTriageTask({ id: "FN-FALLBACK-EXHAUSTED", recoveryRetryCount: 3 });
const root = await createTriageFixtureRoot("fusion-triage-fallback-exhausted-");
const promptPath = join(root, ".fusion", "tasks", task.id, "PROMPT.md");
await mkdir(join(root, ".fusion", "tasks", task.id), { recursive: true });
const onSpecifyComplete = vi.fn();
let onFallbackModelUsed: ((payload: {
primaryModel: string;
fallbackModel: string;
triggerPoint: "prompt-time";
}) => Promise<void>) | undefined;
const store = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...task, attachments: [], comments: [] }),
});
mockCreateFnAgent.mockImplementation(async (options: { onFallbackModelUsed?: typeof onFallbackModelUsed }) => {
onFallbackModelUsed = options.onFallbackModelUsed;
return {
session: { prompt: vi.fn(), dispose: vi.fn(), sessionManager: {}, navigateTree: vi.fn() },
};
});
const { promptWithFallback } = await import("../pi.js");
(promptWithFallback as ReturnType<typeof vi.fn>).mockImplementationOnce(async () => {
await onFallbackModelUsed?.({
primaryModel: "openai/gpt-4o",
fallbackModel: "anthropic/claude-haiku",
triggerPoint: "prompt-time",
});
await writeFile(promptPath, "## Mission\n\nFallback plan\n", "utf8");
});
try {
await new TriageProcessor(store, root, { onSpecifyComplete }).specifyTask(task);
expect(store.updateTask).toHaveBeenCalledWith(task.id, expect.objectContaining({
status: "failed",
error: expect.stringContaining("Planner fallback engaged"),
recoveryRetryCount: null,
nextRecoveryAt: null,
}));
expect(onSpecifyComplete).not.toHaveBeenCalled();
} finally {
await cleanupTriageFixtureRoot(root);
}
});
it("sets recoveryRetryCount and nextRecoveryAt on first transient error via specifyTask", async () => {
const task = {
id: "FN-200",

View File

@@ -187,6 +187,14 @@ export interface AgentSessionResult {
session: AgentSession;
/** Path to the persisted session file (undefined for in-memory sessions) */
sessionFile?: string;
/*
FNXC:TriagePlanningRetry 2026-08-03-01:01:
A runtime that can dispatch fallback notifications after its prompt promise resolves must expose
this finite, per-prompt lifecycle boundary. Triage awaits it before Plan Review; it must resolve
after all callbacks attributable to that prompt have started, without waiting for unrelated
runtime housekeeping timers. Runtimes without deferred fallback dispatch may omit it.
*/
settleFallbackDispatch?: () => Promise<void>;
}
/**

View File

@@ -158,6 +158,8 @@ export interface ResolvedSessionResult {
session: AgentSession;
/** Path to the persisted session file (undefined for in-memory sessions) */
sessionFile?: string;
/** Optional runtime-owned boundary for deferred fallback callback dispatch. */
settleFallbackDispatch?: () => Promise<void>;
/** The runtime ID that was used */
runtimeId: string;
/** Whether the runtime was explicitly configured */
@@ -1178,6 +1180,7 @@ export async function createResolvedAgentSession(
return {
session: result.session,
sessionFile: result.sessionFile,
settleFallbackDispatch: result.settleFallbackDispatch,
runtimeId: resolved.runtimeId,
wasConfigured: resolved.wasConfigured,
};

View File

@@ -171,6 +171,8 @@ export interface AgentResult {
session: AgentSession;
/** Path to the persisted session file (undefined for in-memory sessions). */
sessionFile?: string;
/** Optional runtime-owned boundary for deferred fallback callback dispatch. */
settleFallbackDispatch?: () => Promise<void>;
}
/**
@@ -3113,5 +3115,15 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
*/
attachSessionIdentity(promptableSession as PromptableSession & { dispose?: () => void | Promise<void> });
return { session: promptableSession, sessionFile: promptableSession.sessionFile };
/*
FNXC:TriagePlanningRetry 2026-08-03-01:01:
Pi awaits `emitFallbackUsed` inside its prompt dispatcher, so prompt settlement already closes
fallback dispatch. Expose that explicit finite boundary rather than asking triage to inspect
unrelated Node timer resources created by tools or plugin housekeeping.
*/
return {
session: promptableSession,
sessionFile: promptableSession.sessionFile,
settleFallbackDispatch: async () => undefined,
};
}

View File

@@ -108,11 +108,22 @@ export class DefaultPiRuntime implements AgentRuntime {
Forward the resolved session budget explicitly across the default-pi bridge. A project setting of 0 becomes null before this point and must reach createFnAgent so the pi wrapper skips clamping just like plugin runtimes.
*/
const { toolOutputMaxChars, mcpServers, ...agentOptions } = options;
return createFnAgent({
const result = await createFnAgent({
...agentOptions,
toolOutputMaxChars,
mcpServers: normalizeAgentRuntimeMcpServers(mcpServers),
});
/*
FNXC:TriagePlanningRetry 2026-08-03-01:10:
Pi dispatches fallback notification work before its prompt resolves, so it has a known finite
boundary even when older createFnAgent adapters do not return one. Preserve that explicit
guarantee here; configured plugin runtimes must provide their own boundary for triage instead
of being mistaken for the synchronous pi lifecycle.
*/
return {
...result,
settleFallbackDispatch: result.settleFallbackDispatch ?? (async () => undefined),
};
}
async promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise<void> {
@@ -233,6 +244,7 @@ function wrapPluginRuntime(
return {
session: (result as AgentSessionResult).session ?? (result as AgentSession),
sessionFile: (result as AgentSessionResult).sessionFile,
settleFallbackDispatch: (result as AgentSessionResult).settleFallbackDispatch,
};
}
throw new Error(`Plugin runtime "${runtimeId}" does not implement createSession`);

View File

@@ -190,7 +190,10 @@ in-process `processing` set this engine cannot see. A genuinely stranded card wa
long instead of until the next engine restart.
*/
const STALE_PLANNING_STATUS_GRACE_MS = 20 * 60_000;
import { exec } from "node:child_process";
import { existsSync } from "node:fs";
import { readFile, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
@@ -429,6 +432,8 @@ export class TriageProcessor {
private unregisterAdmissionProvider: (() => void) | null = null;
/** Timestamps when tasks entered the `processing` set, for staleness detection. */
private processingSince = new Map<string, number>();
/** Monotonic provenance token for attempt-local planner fallback observation. */
private planningAttemptSequence = 0;
private wasGlobalPaused = false;
private wasEnginePaused = false;
private idleSemaphoreLeakCandidateSince: number | null = null;
@@ -2804,7 +2809,42 @@ export class TriageProcessor {
}
await this.store.logEntry(task.id, `Planning session running in task worktree ${planningCwd}`).catch(() => undefined);
}
const { session } = await createResolvedAgentSession({
/*
FNXC:TriagePlanningRetry 2026-08-03-00:02:
Plan Review may only receive evidence from a clean planner attempt. Capture the root
PROMPT.md before this attempt starts, and close the fallback callback over this record so a
delayed runtime callback cannot authorize the artifact it helped write or contaminate a
later retry. The post-prompt microtask checkpoint admits runtime/plugin callbacks already
scheduled by the originating attempt; their observer promises settle before the handoff
decision while the shared observer keeps its normal logs and notifications.
*/
const authoritativePromptPath = join(this.rootDir, promptPath);
// FNXC:TriagePlanningRetry 2026-08-03-00:02: absent artifacts have an explicit baseline;
// avoid an asynchronous ENOENT probe so rate-limit retry scheduling remains synchronous.
const planningAttemptBaseline = existsSync(authoritativePromptPath)
? await readFile(authoritativePromptPath, "utf-8").catch(() => undefined)
: undefined;
const planningAttempt = {
id: `${task.id}:${++this.planningAttemptSequence}`,
baseline: planningAttemptBaseline,
fallbackEngaged: false,
fallbackSettlements: [] as Array<Promise<void>>,
};
const fallbackObserver = createFallbackModelObserver({
agent: "triage",
label: "triage",
store: this.store,
taskId: task.id,
taskTitle: task.title,
});
const onFallbackModelUsed = (payload: Parameters<typeof fallbackObserver>[0]): Promise<void> => {
planningAttempt.fallbackEngaged = true;
const settlement = Promise.resolve(fallbackObserver(payload));
planningAttempt.fallbackSettlements.push(settlement);
return settlement;
};
const { session, settleFallbackDispatch, runtimeId } = await createResolvedAgentSession({
sessionPurpose: "triage",
runtimeHint: triageRuntimeHint,
pluginRunner: this.options.pluginRunner,
@@ -2853,13 +2893,7 @@ export class TriageProcessor {
taskTitle: task.title,
actionGateContext: this.buildActionGateContext(task.id, triageRunContext.runId, assignedAgent, settings.defaultAgentPermissionPolicy),
permanentAgentGating: this.buildPermanentAgentGatingContext(task.id, triageRunContext.runId, assignedAgent, settings.defaultAgentPermissionPolicy),
onFallbackModelUsed: createFallbackModelObserver({
agent: "triage",
label: "triage",
store: this.store,
taskId: task.id,
taskTitle: task.title,
}),
onFallbackModelUsed,
});
const modelDesc = formatModelMarkerDetails(describeModel(session), resolvePlanningThinkingLevel(settings, task.planningThinkingLevel ?? task.thinkingLevel));
@@ -3000,6 +3034,18 @@ export class TriageProcessor {
agentPrompt,
imageContents.length > 0 ? { images: imageContents } : undefined,
);
/*
FNXC:TriagePlanningRetry 2026-08-03-01:01:
Plan Review needs a finite runtime-owned admission-close signal, not a global async-hooks
timer drain. Ordinary planner housekeeping can schedule arbitrary one-shot timers and must
not delay a clean plan. The originating runtime explicitly settles its fallback-dispatch
lifecycle; a runtime with no boundary fails closed into bounded planning recovery, then
triage observes every callback it admitted before accepting this attempt.
*/
const fallbackDispatchBoundaryMissing = typeof settleFallbackDispatch !== "function";
if (!fallbackDispatchBoundaryMissing) {
await settleFallbackDispatch();
}
// Re-raise errors that pi-coding-agent swallowed after exhausting retries.
checkSessionError(session);
@@ -3147,14 +3193,50 @@ export class TriageProcessor {
}
}
// FN-5220: planning agents that emit a `DUPLICATE: FN-NNNN` redirect
// short-circuit normal spec finalization.
const duplicateReport: PlanningHandoffReport = { outcome: "parked" };
if (await this.tryFinalizeExplicitDuplicateMarker(task, written, settings, {
isReplan,
feedback,
}, duplicateReport)) {
this.options.onSpecifyComplete?.(task, duplicateReport);
// The runtime-owned dispatch lifecycle above has closed this attempt's callback admission;
// now await observer side effects registered by callbacks before the handoff decision.
let settledFallbackCount = 0;
while (settledFallbackCount < planningAttempt.fallbackSettlements.length) {
const pendingSettlements = planningAttempt.fallbackSettlements.slice(settledFallbackCount);
settledFallbackCount = planningAttempt.fallbackSettlements.length;
await Promise.all(pendingSettlements);
}
const artifactChangedByAttempt = planningAttempt.baseline !== written;
if (fallbackDispatchBoundaryMissing || planningAttempt.fallbackEngaged || !artifactChangedByAttempt) {
const failure = fallbackDispatchBoundaryMissing
? `Planner runtime ${runtimeId} did not provide a fallback-dispatch settlement boundary for attempt ${planningAttempt.id}`
: planningAttempt.fallbackEngaged
? `Planner fallback engaged during attempt ${planningAttempt.id}`
: `Planner did not update the authoritative PROMPT.md during attempt ${planningAttempt.id}`;
const decision = computeRecoveryDecision({
recoveryRetryCount: task.recoveryRetryCount,
nextRecoveryAt: task.nextRecoveryAt,
});
if (decision.shouldRetry) {
const retryMessage = `${failure} — retry ${decision.nextState.recoveryRetryCount}/${MAX_RECOVERY_RETRIES} in ${formatDelay(decision.delayMs)}.`;
planLog.warn(`${task.id} ${retryMessage}`);
await this.store.logEntry(task.id, retryMessage);
await this.updatePlanningStateIfStillCurrent(task, {
status: this.restoreStatusAfterInterruptedTriageWork(task),
error: null,
recoveryRetryCount: decision.nextState.recoveryRetryCount,
nextRecoveryAt: decision.nextState.nextRecoveryAt,
});
return;
}
const failureMessage = `${failure} after ${MAX_RECOVERY_RETRIES} retries. Retry after adjusting the task prompt or model.`;
planLog.error(`${task.id} clean planning attempt retry budget exhausted`);
await this.store.logEntry(task.id, failureMessage);
if (await this.updatePlanningStateIfStillCurrent(task, {
status: "failed",
error: failureMessage,
recoveryRetryCount: null,
nextRecoveryAt: null,
})) {
await this.backfillBlankTitleAfterTerminalTriageFailure(task);
}
return;
}
@@ -3203,6 +3285,27 @@ export class TriageProcessor {
return;
}
// FNXC:TriagePlanningRetry 2026-08-03-00:20: A duplicate remains a separate closure
// path, but fallback-authored or inherited markers cannot bypass clean-attempt admission.
const duplicateReport: PlanningHandoffReport = { outcome: "parked" };
if (await this.tryFinalizeExplicitDuplicateMarker(task, written, settings, {
isReplan,
feedback,
}, duplicateReport)) {
this.options.onSpecifyComplete?.(task, duplicateReport);
return;
}
// FNXC:TriagePlanningRetry 2026-08-03-00:02: a clean replacement consumed no
// recovery budget; clear it only after the finalization preconditions have passed.
if (task.recoveryRetryCount != null || task.nextRecoveryAt != null) {
await this.updatePlanningStateIfStillCurrent(task, {
error: null,
recoveryRetryCount: null,
nextRecoveryAt: null,
});
}
const finalizeReport = await this.finalizeApprovedTask(task, written, settings, {
isReplan,
feedback,