diff --git a/.changeset/planning-retry-budget-and-timeout.md b/.changeset/planning-retry-budget-and-timeout.md new file mode 100644 index 0000000000..c0812df762 --- /dev/null +++ b/.changeset/planning-retry-budget-and-timeout.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Planning failures now retry with backoff and park after 3 attempts instead of looping forever. +category: fix +dev: Two bounds on the triage planning path plus a log-level fix. (1) The unclassified-failure branch in `specifyTask` restored the card's claimable status and wrote no counter, no `nextRecoveryAt` and no park, so triage rediscovery re-admitted it every poll indefinitely; it now consumes the shared `recoveryRetryCount`/`nextRecoveryAt` budget (`MAX_RECOVERY_RETRIES` = 3, 60s/120s/300s backoff) and parks `status: "failed"` with a `PLANNING_FAILED_EXHAUSTED:` error once spent. (2) New workflow-native setting `planningTimeoutMs` (default `DEFAULT_PLANNING_TIMEOUT_MS` = 5_400_000, declared in `BUILTIN_TRIAGE_POLICY_SETTINGS`) caps a planning turn — previously nothing did, since `workflowStepTimeoutMs` covers pre-merge steps only and the provider SDK's 300s cap is time-to-first-byte and is cleared once headers arrive; a timeout aborts the session and consumes one bounded attempt. Default is generous by design (successful plans measured p99 ≈ 106 min) — it bounds hung turns, not slow ones. (3) `[event:task:moved]` executor tracing dropped from `log` to `debug`; it fired on every move and was the loudest line in engine output. diff --git a/packages/core/src/__tests__/builtin-workflow-settings-triage.test.ts b/packages/core/src/__tests__/builtin-workflow-settings-triage.test.ts index 8d213bada9..9051dd1352 100644 --- a/packages/core/src/__tests__/builtin-workflow-settings-triage.test.ts +++ b/packages/core/src/__tests__/builtin-workflow-settings-triage.test.ts @@ -5,6 +5,7 @@ import { BUILTIN_REVIEW_REVISION_SETTINGS, BUILTIN_TRIAGE_POLICY_SETTINGS, BUILTIN_WORKFLOW_SETTINGS, + DEFAULT_PLANNING_TIMEOUT_MS, renderTriagePolicyPlaceholders, } from "../workflows/builtin-workflow-settings.js"; import { MOVED_SETTINGS_KEYS } from "../config/moved-settings.js"; @@ -32,6 +33,12 @@ const expectedDefaults: Record = { triageDefaultWorkflowId: { type: "string", default: "" }, leanPlanning: { type: "boolean", default: false }, autoApproveSpec: { type: "boolean", default: false }, + /* + FNXC:TriagePlanningTimeout 2026-08-10-18:32: + Driven off the exported constant, not a literal — same anti-drift rule the maxPostReviewFixes + parity anchor documents. The planning turn previously had no Fusion-side ceiling at all. + */ + planningTimeoutMs: { type: "number", default: DEFAULT_PLANNING_TIMEOUT_MS }, }; describe("workflow-native built-in workflow settings", () => { @@ -59,12 +66,34 @@ describe("workflow-native built-in workflow settings", () => { const movedIds = new Set(BUILTIN_MOVED_WORKFLOW_SETTINGS.map((setting) => setting.id)); const movedKeyIds = new Set(MOVED_SETTINGS_KEYS); + /* + FNXC:ReviewSeverityGate 2026-08-10-18:32: + The blocking-severity pair is review-loop policy and belongs in this catalog alongside the + revision caps: the caps bound how many times a REVISE may cycle, the thresholds decide whether a + REVISE blocks at all. They are enum-typed with defaults, so the number-typed assertions below + deliberately continue to cover only the three cap settings. + */ expect(BUILTIN_REVIEW_REVISION_SETTINGS.map((setting) => setting.id)).toEqual([ "reviewerInlineFixes", "planReviewMaxRevisions", "codeReviewMaxRevisions", + "planReviewBlockingSeverity", + "codeReviewBlockingSeverity", "planReviewReplanCap", ]); + for (const id of ["planReviewBlockingSeverity", "codeReviewBlockingSeverity"]) { + const setting = revisionById.get(id); + expect(setting, `${id} should be declared`).toBeDefined(); + expect(setting?.type).toBe("enum"); + // Defaulted (unlike the caps): the gate is always active, with "any" restoring pre-gate blocking. + expect(setting).toHaveProperty("default"); + expect(setting?.options?.map((option) => option.value)).toContain("any"); + expect(fullIds.has(id), `${id} should be in the full built-in catalog`).toBe(true); + expect(movedIds.has(id), `${id} should not be in the moved-key catalog`).toBe(false); + expect(movedKeyIds.has(id), `${id} should not be in MOVED_SETTINGS_KEYS`).toBe(false); + } + expect(revisionById.get("planReviewBlockingSeverity")?.default).toBe("high"); + expect(revisionById.get("codeReviewBlockingSeverity")?.default).toBe("critical"); const inlineFixes = revisionById.get("reviewerInlineFixes"); expect(inlineFixes).toMatchObject({ type: "boolean", diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 0554e13801..d47c3d3e94 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -293,6 +293,7 @@ export { BUILTIN_TRIAGE_POLICY_SETTINGS, BUILTIN_OVERSIGHT_SETTINGS, DEFAULT_PLANNER_OVERSEER_EXECUTOR_STUCK_AFTER_MS, + DEFAULT_PLANNING_TIMEOUT_MS, PLANNER_HEARTBEAT_PATROL_ENABLED_SETTING_ID, renderTriagePolicyPlaceholders, } from "./workflows/builtin-workflow-settings.js"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c7fc2f001e..82b1e2b019 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -338,6 +338,7 @@ export { BUILTIN_TRIAGE_POLICY_SETTINGS, BUILTIN_OVERSIGHT_SETTINGS, DEFAULT_MAX_POST_REVIEW_FIXES, + DEFAULT_PLANNING_TIMEOUT_MS, DEFAULT_PLANNER_OVERSEER_EXECUTOR_STUCK_AFTER_MS, PLANNER_HEARTBEAT_PATROL_ENABLED_SETTING_ID, renderTriagePolicyPlaceholders, diff --git a/packages/core/src/types/settings/settings-scope.ts b/packages/core/src/types/settings/settings-scope.ts index 0bdf4bae3f..63034973cd 100644 --- a/packages/core/src/types/settings/settings-scope.ts +++ b/packages/core/src/types/settings/settings-scope.ts @@ -2434,6 +2434,18 @@ export interface Settings extends GlobalSettings, ProjectSettings { leanPlanning?: boolean; /** Auto-approve generated specs and skip the independent spec reviewer. */ autoApproveSpec?: boolean; + /** Wall-clock timeout (ms) for a single triage planning/specification AI turn. + * + * FNXC:TriagePlanningTimeout 2026-08-10-18:32: + * Workflow-native (like `leanPlanning`/`autoApproveSpec` above) — it never lived in project or + * global settings, so it is declared HERE rather than on `ProjectSettings`, and must never be + * added to `MOVED_SETTINGS_KEYS`. `workflowStepTimeoutMs` covers pre-merge workflow STEPS only + * and never applied to the planning session, which had no Fusion-side ceiling at all: the + * provider SDK's 300s `APIConnectionTimeoutError` caps time-to-first-byte and is cleared once + * headers arrive, so a hung stream ran unbounded (observed: 126 minutes). On timeout the session + * is aborted and the failure consumes one attempt of the bounded planning retry budget. + * Default: {@link DEFAULT_PLANNING_TIMEOUT_MS}. */ + planningTimeoutMs?: number; /** Index signature for dynamic settings access */ [key: string]: unknown; } diff --git a/packages/core/src/workflows/builtin-workflow-settings.ts b/packages/core/src/workflows/builtin-workflow-settings.ts index 254db9a468..52947c33ff 100644 --- a/packages/core/src/workflows/builtin-workflow-settings.ts +++ b/packages/core/src/workflows/builtin-workflow-settings.ts @@ -1,4 +1,14 @@ import { THINKING_LEVELS, type Settings } from "../types.js"; + +/* +FNXC:TriagePlanningTimeout 2026-08-10-18:32: +Single source for the planning-turn ceiling, mirroring DEFAULT_MAX_POST_REVIEW_FIXES: the declaration +default and the engine's `settings.planningTimeoutMs ?? N` read site must not drift into two separate +literals. 90 minutes is deliberately generous — successful planning work items measured over 7 days +ran p50 12.7 / p90 39.5 / p99 105.7 minutes, so a tighter bound would abort legitimate plans and pay +for the restart, which is the churn this bound exists to remove. +*/ +export const DEFAULT_PLANNING_TIMEOUT_MS = 5_400_000; import { DEFAULT_CODE_REVIEW_BLOCKING_SEVERITY, DEFAULT_PLAN_REVIEW_BLOCKING_SEVERITY, @@ -509,6 +519,25 @@ export const BUILTIN_TRIAGE_POLICY_SETTINGS: WorkflowSettingDefinition[] = [ default: false, description: "Auto-approve the generated PROMPT.md and skip the independent spec reviewer.", }, + { + id: "planningTimeoutMs", + name: "Planning timeout (ms)", + type: "number", + minimum: 60_000, + integer: true, + /* + * FNXC:TriagePlanningTimeout 2026-08-10-18:32: + * Workflow-native triage policy — it never lived in project/global settings, so it belongs here + * and must never be added to MOVED_SETTINGS_KEYS. The planning turn previously had NO + * Fusion-side ceiling: `workflowStepTimeoutMs` covers pre-merge workflow STEPS only, and the + * provider SDK's 300s cap is time-to-first-byte (cleared once headers arrive), so a hung stream + * ran unbounded — observed at 126 minutes, with failed-attempt durations showing a smooth + * 1-126 min spread and no clustering, the signature of nothing enforcing a bound. + */ + default: DEFAULT_PLANNING_TIMEOUT_MS, + description: + "Maximum time a single planning/specification turn may run before the session is aborted. A timeout consumes one attempt of the bounded planning retry budget. Generous by design — it bounds hung turns, not slow ones.", + }, ]; export const BUILTIN_REVIEW_REVISION_SETTINGS: WorkflowSettingDefinition[] = [ diff --git a/packages/engine/src/__tests__/triage.test.ts b/packages/engine/src/__tests__/triage.test.ts index ba6090cd4c..149fcbcdd1 100644 --- a/packages/engine/src/__tests__/triage.test.ts +++ b/packages/engine/src/__tests__/triage.test.ts @@ -5781,6 +5781,83 @@ describe("taskCreate tool model inheritance", () => { }); }); + /* + FNXC:TriagePlanningRetry 2026-08-10-18:32: + An UNCLASSIFIED planning failure used to restore the card's claimable status and write nothing + else — no counter, no nextRecoveryAt, no park — so triage rediscovery re-admitted it every poll + forever. Measured: 48 unrecognized "Request timed out." failures across 10 tasks in 30 hours with + 0-minute gaps; FN-8950 alone burned 8 attempts over ~8 hours without ever reaching implementation. + These pin the bound: retry with backoff while budget remains, then park `failed` for a human. + */ + it("schedules a bounded backoff retry for an unclassified planning failure", async () => { + const task = { + id: "FN-GENERIC-RETRY", + title: "Generic planning failure", + description: "Unclassified planning failure should retry with backoff", + column: "triage", + status: "planning", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } as unknown as Task; + const store = createMockStore({ + getTask: vi.fn().mockResolvedValue({ ...task, attachments: [] }), + }); + // Deliberately a string no classifier recognizes. + mockCreateFnAgent.mockRejectedValue(new Error("kaboom: something nobody classified")); + + const processor = new TriageProcessor(store, "/test/root", { pollIntervalMs: 100_000 }); + await processor.specifyTask(task); + + const retryWrite = store.updateTask.mock.calls.find( + ([id, patch]) => id === "FN-GENERIC-RETRY" && patch?.recoveryRetryCount === 1, + ); + expect(retryWrite, "an unclassified failure must consume a bounded retry").toBeDefined(); + expect(typeof retryWrite?.[1]?.nextRecoveryAt).toBe("string"); + // Must NOT park while budget remains. + expect(store.updateTask).not.toHaveBeenCalledWith( + "FN-GENERIC-RETRY", + expect.objectContaining({ status: "failed" }), + ); + }); + + it("parks an unclassified planning failure once the retry budget is exhausted", async () => { + const task = { + id: "FN-GENERIC-EXHAUSTED", + title: "Generic planning failure", + description: "Unclassified planning failure should park after the budget is spent", + column: "triage", + status: "planning", + recoveryRetryCount: 3, + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } as unknown as Task; + const store = createMockStore({ + getTask: vi.fn().mockResolvedValue({ ...task, attachments: [] }), + }); + mockCreateFnAgent.mockRejectedValue(new Error("kaboom: something nobody classified")); + + const processor = new TriageProcessor(store, "/test/root", { pollIntervalMs: 100_000 }); + await processor.specifyTask(task); + + const parkWrite = store.updateTask.mock.calls.find( + ([id, patch]) => id === "FN-GENERIC-EXHAUSTED" && patch?.status === "failed", + ); + // `status: "failed"` is what suppresses triage rediscovery — without it the card is re-picked. + expect(parkWrite, "an exhausted budget must park the card for a human").toBeDefined(); + expect(parkWrite?.[1]?.error).toContain("PLANNING_FAILED_EXHAUSTED:"); + expect(parkWrite?.[1]?.error).toContain("kaboom: something nobody classified"); + expect(parkWrite?.[1]?.recoveryRetryCount).toBeNull(); + expect(parkWrite?.[1]?.nextRecoveryAt).toBeNull(); + }); + it("does not overwrite an existing title during terminal fallback exhaustion", async () => { const task = { id: "FN-7961-EXISTING", diff --git a/packages/engine/src/executor/wire-executor-lifecycle.ts b/packages/engine/src/executor/wire-executor-lifecycle.ts index bf0e2ad0a2..22481ed99b 100644 --- a/packages/engine/src/executor/wire-executor-lifecycle.ts +++ b/packages/engine/src/executor/wire-executor-lifecycle.ts @@ -270,7 +270,15 @@ export function wireExecutorLifecycle(deps: WireExecutorLifecycleDeps): WireExec as a live inertness path rather than defensive dead code. */ deps.store.on("task:moved", ({ task, from, to, source, lanes }) => { - executorLog.log(`[event:task:moved] ${task.id}: ${from} → ${to}`); + /* + FNXC:Diagnostics 2026-08-10-18:32: + Per-move tracing is DEBUG. This listener fires on every task:moved event — every dispatch, + rebound, requeue, archive and self-healing move across every task — so at `log` level it was the + single loudest line in engine output and buried the events an operator actually needs to see. + The information is still available at debug level; nothing here is an operator-actionable signal + on its own. + */ + executorLog.debug(`[event:task:moved] ${task.id}: ${from} → ${to}`); /* FNXC:WorkflowResolvedColumns 2026-07-31-21:30 (fleet): Lanes come from the EMITTER (see `moves.ts`), not from a resolver called here. @@ -294,7 +302,7 @@ export function wireExecutorLifecycle(deps: WireExecutorLifecycleDeps): WireExec return; } deps.clearWorkflowRerunWatchdog(task.id); - executorLog.log(`[event:task:moved] Initiating execute() for ${task.id}`); + executorLog.debug(`[event:task:moved] Initiating execute() for ${task.id}`); void (async () => { // FN-5256: if the prior session is still being torn down (because the // task was just moved away from in-progress), wait for the worktree- @@ -303,7 +311,7 @@ export function wireExecutorLifecycle(deps: WireExecutorLifecycleDeps): WireExec // executor's own conflict cleanup against a still-live shell. const pending = deps.pendingTaskDisposals.get(task.id); if (pending) { - executorLog.log(`[event:task:moved] Awaiting pending disposal for ${task.id} before dispatch`); + executorLog.debug(`[event:task:moved] Awaiting pending disposal for ${task.id} before dispatch`); await pending; } const taskForExecution = await deps.resetMergeStateIfNeeded(task, from); @@ -364,7 +372,7 @@ export function wireExecutorLifecycle(deps: WireExecutorLifecycleDeps): WireExec ); } else if (from === wipLane) { if (deps.workflowLifecycleMovesInFlight.has(task.id) && deps.graphRouting.has(task.id)) { - executorLog.log( + executorLog.debug( `[event:task:moved] Preserving graph run for ${task.id} across its own ${from} → ${to} boundary`, ); return; diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 915dd54013..dafa2a3bd0 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -252,7 +252,7 @@ import { import { createRunAuditor, generateSyntheticRunId } from "./util/run-audit.js"; import { resolveAndEmitGoalContext } from "./goals/goal-injection-diagnostics.js"; import { accumulateSessionTokenUsage } from "./execution/session-token-usage.js"; -import { finalizePlanningSegment, startPlanningSegment } from "@fusion/core"; +import { DEFAULT_PLANNING_TIMEOUT_MS, finalizePlanningSegment, startPlanningSegment } from "@fusion/core"; import { collectPlanReviewFeedbackHistory, isPlanReviewRevisionLog } from "./plan-review-feedback-history.js"; import type { AgentActionGateContext } from "./agents/agent-action-gate.js"; import { buildAgentGatedActionSummary } from "./agents/permanent-agent-gating.js"; @@ -3291,11 +3291,55 @@ export class TriageProcessor { planReviewFeedbackHistory, }, ); - await promptWithFallback( - session, - agentPrompt, - imageContents.length > 0 ? { images: imageContents } : undefined, - ); + /* + FNXC:TriagePlanningTimeout 2026-08-10-18:32: + Hard ceiling on the planning turn. Fusion previously set NO timeout here, and the only + inherited one is the provider SDK's `APIConnectionTimeoutError` (300s) which caps + TIME-TO-FIRST-BYTE only — it is cleared as soon as response headers arrive, after which the + stream is uncapped. `configureHttpDispatcher` (which would install undici body/headers idle + timeouts) is only called from pi's own CLI entrypoints, never in the in-process engine, so + there was no idle timeout either. Measured consequence: single planning attempts ran to 126 + minutes, and failed-attempt durations showed a smooth 1-126 min spread with no clustering — + the signature of nothing enforcing a bound. + + The stuck detector does not cover this: `recordActivity` fires on every streamed token, so a + session that emits anything between provider stalls never trips its inactivity threshold. + + Default is deliberately GENEROUS (90 min) rather than tight. Successful planning work items + measured over 7 days: p50 12.7 min, p90 39.5 min, p99 105.7 min. A tight ceiling would abort + legitimate long plans and pay for the restart, which is the churn this work is removing. The + ceiling exists to make a HUNG turn terminate at all, not to discipline slow ones. A timeout + here is recoverable, not fatal: it surfaces as a transient error and consumes one attempt of + the bounded planning budget below. + */ + const planningTimeoutMs = Math.max(60_000, settings.planningTimeoutMs ?? DEFAULT_PLANNING_TIMEOUT_MS); + let planningTimeoutHandle: ReturnType | undefined; + const planningTimeoutPromise = new Promise<"timeout">((resolveTimeout) => { + planningTimeoutHandle = setTimeout(() => resolveTimeout("timeout"), planningTimeoutMs); + }); + try { + const planningOutcome = await Promise.race([ + promptWithFallback( + session, + agentPrompt, + imageContents.length > 0 ? { images: imageContents } : undefined, + ).then(() => "completed" as const), + planningTimeoutPromise, + ]); + if (planningOutcome === "timeout") { + planLog.warn(`${task.id}: planning turn exceeded ${planningTimeoutMs}ms — disposing session`); + await this.store.logEntry( + task.id, + `Planning turn timed out after ${Math.round(planningTimeoutMs / 60_000)} min — aborting session`, + ).catch(() => undefined); + try { session.dispose(); } catch { /* best-effort */ } + // Phrased to match the provider-timeout transient pattern so this routes into the + // bounded planning retry budget instead of the unclassified-failure park. + throw new Error(`Planning request timed out after ${planningTimeoutMs}ms`); + } + } finally { + if (planningTimeoutHandle) clearTimeout(planningTimeoutHandle); + } /* FNXC:TriagePlanningRetry 2026-08-03-01:01: Plan Review needs a finite runtime-owned admission-close signal, not a global async-hooks @@ -3807,12 +3851,31 @@ export class TriageProcessor { this.options.onSpecifyError?.(task, err instanceof Error ? err : new Error(errorMessage)); return; } - // For interrupted recovery states, restore the original triage-held status; - // otherwise clear to null so the next poll can re-pick ordinary tasks up. - const restoreStatus = this.restoreStatusAfterInterruptedTriageWork(task); - await this.updatePlanningStateIfStillCurrent(task, { status: restoreStatus }).catch((restoreErr: unknown) => { - const msg = restoreErr instanceof Error ? restoreErr.message : String(restoreErr); - planLog.warn(`${task.id}: failed to restore status to '${restoreStatus}' after planning error: ${msg}`); + /* + FNXC:TriagePlanningRetry 2026-08-10-18:32: + UNCLASSIFIED planning failures are bounded. This branch is the catch-all for every error the + classifiers above did not recognize, and it used to restore the card's claimable status and + write NOTHING else — no counter, no `nextRecoveryAt`, no park. Triage rediscovery therefore + re-admitted the card on the very next poll, forever, and `replaceActiveTaskWorkflowContinuation` + replaced the terminal work item with a fresh one carrying no attempt count, so nothing anywhere + recorded that the task had already failed N times. + + Measured cost of that hole: `"Request timed out."` (unrecognized until the companion fix to + `transient-error-patterns.ts`) produced 48 failures across 10 tasks in 30 hours with zero + backoff — FN-8950 burned 8 consecutive attempts over ~8 hours and never reached implementation. + Classifying that ONE string fixes that ONE symptom; this budget is what makes the NEXT + unrecognized error string fail safely instead of looping for a day. + + Deliberately reuses the `recoveryRetryCount`/`nextRecoveryAt` pair (and its 60s/120s/300s + jittered backoff) that the transient branch above already uses, rather than adding a parallel + counter: the budget answers "this task keeps failing to plan", which is true regardless of + which classifier recognized the error, and sharing it avoids a schema migration for a counter + that means the same thing. On exhaustion the card is parked `failed` for a human — unlike a + transient exhaustion, an unrecognized error has no evidence it is retryable at all. + */ + const genericDecision = computeRecoveryDecision({ + recoveryRetryCount: task.recoveryRetryCount, + nextRecoveryAt: task.nextRecoveryAt, }); planLog.error(`✗ ${task.id} planning failed:`, errorDetail); if (errorStack) { @@ -3821,6 +3884,54 @@ export class TriageProcessor { planLog.warn(`${task.id}: failed to persist specification-failure stack trace: ${msg}`); }); } + + if (genericDecision.shouldRetry) { + const attempt = genericDecision.nextState.recoveryRetryCount; + const delay = formatDelay(genericDecision.delayMs); + planLog.warn(`⚡ ${task.id} planning failed — retry ${attempt}/${MAX_RECOVERY_RETRIES} in ${delay}: ${errorMessage}`); + await this.store.logEntry( + task.id, + `Specification failed (retry ${attempt}/${MAX_RECOVERY_RETRIES} in ${delay}): ${errorMessage}`, + ).catch((logErr: unknown) => { + const msg = logErr instanceof Error ? logErr.message : String(logErr); + planLog.warn(`${task.id}: failed to log planning-failure retry entry: ${msg}`); + }); + // For interrupted recovery states, restore the original triage-held status; + // otherwise clear to null so the next poll can re-pick ordinary tasks up. + const restoreStatus = this.restoreStatusAfterInterruptedTriageWork(task); + await this.updatePlanningStateIfStillCurrent(task, { + status: restoreStatus, + recoveryRetryCount: genericDecision.nextState.recoveryRetryCount, + nextRecoveryAt: genericDecision.nextState.nextRecoveryAt, + }).catch((restoreErr: unknown) => { + const msg = restoreErr instanceof Error ? restoreErr.message : String(restoreErr); + planLog.warn(`${task.id}: failed to restore status to '${restoreStatus}' after planning error: ${msg}`); + }); + this.options.onSpecifyError?.(task, err instanceof Error ? err : new Error(errorMessage)); + return; + } + + /* + Budget exhausted — park for a human. Mirrors the in-file `maxStuckKills` park + (status `failed` + a prefixed error a human can grep) so the card stops being re-picked: + `status: "failed"` is what suppresses triage rediscovery. + */ + const exhaustedMessage = `PLANNING_FAILED_EXHAUSTED: specification failed ${MAX_RECOVERY_RETRIES} times — last error: ${errorMessage}`; + planLog.error(`✗ ${task.id} planning retries exhausted (${MAX_RECOVERY_RETRIES} attempts) — parking failed: ${errorMessage}`); + await this.store.logEntry(task.id, exhaustedMessage).catch((logErr: unknown) => { + const msg = logErr instanceof Error ? logErr.message : String(logErr); + planLog.warn(`${task.id}: failed to log planning-retries-exhausted entry: ${msg}`); + }); + await this.updatePlanningStateIfStillCurrent(task, { + status: "failed", + error: exhaustedMessage, + recoveryRetryCount: null, + nextRecoveryAt: null, + }).catch((restoreErr: unknown) => { + const msg = restoreErr instanceof Error ? restoreErr.message : String(restoreErr); + planLog.warn(`${task.id}: failed to park task after planning retries exhausted: ${msg}`); + }); + await this.backfillBlankTitleAfterTerminalTriageFailure(task); this.options.onSpecifyError?.(task, err instanceof Error ? err : new Error(errorMessage)); } } finally {