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:
7
.changeset/fn-8728-planner-retry-before-review.md
Normal file
7
.changeset/fn-8728-planner-retry-before-review.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user