From 6cc687d3a37613245577ff4b27ddf70e1dfd0ed1 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 2 Aug 2026 18:26:18 -0700 Subject: [PATCH] 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) --- .../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(-) create mode 100644 .changeset/fn-8728-planner-retry-before-review.md diff --git a/.changeset/fn-8728-planner-retry-before-review.md b/.changeset/fn-8728-planner-retry-before-review.md new file mode 100644 index 0000000000..39ea8af4d5 --- /dev/null +++ b/.changeset/fn-8728-planner-retry-before-review.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index 2bb4d587b2..172219c107 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/packages/engine/src/__tests__/triage.test.ts b/packages/engine/src/__tests__/triage.test.ts index 055d4dfdaf..32758c4b0f 100644 --- a/packages/engine/src/__tests__/triage.test.ts +++ b/packages/engine/src/__tests__/triage.test.ts @@ -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) | 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).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) | 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).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) | undefined; + let resolveFallbackDispatch!: () => void; + const fallbackDispatchSettled = new Promise((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).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) | 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).mockImplementationOnce( + (session: { promptWithFallback: (prompt: string, options?: unknown) => Promise }, 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 | undefined; + mockCreateFnAgent.mockResolvedValue({ + session: { prompt: vi.fn(), dispose: vi.fn(), sessionManager: {}, navigateTree: vi.fn() }, + }); + const { promptWithFallback } = await import("../pi.js"); + (promptWithFallback as ReturnType).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) | 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) + .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) | undefined> = []; + const store = createMockStore({ + getTask: vi.fn().mockResolvedValue({ ...task, attachments: [], comments: [] }), + }); + mockCreateFnAgent.mockImplementation(async (options: { onFallbackModelUsed?: (payload: never) => Promise }) => { + 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) + .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) | 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).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) | 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).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", diff --git a/packages/engine/src/agent-runtime.ts b/packages/engine/src/agent-runtime.ts index d6f17ad5e4..2d256806e6 100644 --- a/packages/engine/src/agent-runtime.ts +++ b/packages/engine/src/agent-runtime.ts @@ -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; } /** diff --git a/packages/engine/src/agent-session-helpers.ts b/packages/engine/src/agent-session-helpers.ts index 94531a9b54..e1c1afbf37 100644 --- a/packages/engine/src/agent-session-helpers.ts +++ b/packages/engine/src/agent-session-helpers.ts @@ -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; /** 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, }; diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index b6e569c372..6cb62c3ee0 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -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; } /** @@ -3113,5 +3115,15 @@ export async function createFnAgent(options: AgentOptions): Promise */ attachSessionIdentity(promptableSession as PromptableSession & { dispose?: () => void | Promise }); - 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, + }; } diff --git a/packages/engine/src/runtime-resolution.ts b/packages/engine/src/runtime-resolution.ts index e6803412ea..96ce43b4fe 100644 --- a/packages/engine/src/runtime-resolution.ts +++ b/packages/engine/src/runtime-resolution.ts @@ -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 { @@ -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`); diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index daf165fa95..b3fef62e8e 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -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(); + /** 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>, + }; + const fallbackObserver = createFallbackModelObserver({ + agent: "triage", + label: "triage", + store: this.store, + taskId: task.id, + taskTitle: task.title, + }); + const onFallbackModelUsed = (payload: Parameters[0]): Promise => { + 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,