diff --git a/.changeset/fix-planning-lock-reentry.md b/.changeset/fix-planning-lock-reentry.md new file mode 100644 index 0000000000..f82134241a --- /dev/null +++ b/.changeset/fix-planning-lock-reentry.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Prevent completed planning sessions from stalling before Plan Review or execution. +category: fix +dev: Avoids nested planning lifecycle locks and preserves recoverable written plans during orphan cleanup. diff --git a/packages/engine/src/__tests__/planning-handoff-recovery.test.ts b/packages/engine/src/__tests__/planning-handoff-recovery.test.ts index 60629ae7b2..5d4a49cdd8 100644 --- a/packages/engine/src/__tests__/planning-handoff-recovery.test.ts +++ b/packages/engine/src/__tests__/planning-handoff-recovery.test.ts @@ -54,4 +54,21 @@ describe("classifyPersistedPlanHandoff", () => { awaitingApprovalReason: "require-all", }), options)).toBeNull(); }); + + it("recovers a planning-status task with its retained planning worktree", () => { + expect(classifyPersistedPlanHandoff(approvedNullTask({ + status: "planning", + workflowStepResults: [], + worktree: "/tmp/fusion-planning-worktree", + }), options)).toBe("planning"); + }); + + it("keeps the retained-worktree fence for null-status legacy recovery", () => { + expect(classifyPersistedPlanHandoff(approvedNullTask({ + status: null, + workflowStepResults: [], + steps: [{ id: "planned-step", description: "planned", status: "pending" }], + worktree: "/tmp/fusion-planning-worktree", + }), options)).toBeNull(); + }); }); diff --git a/packages/engine/src/__tests__/reliability-interactions/planning-dependency-release.pg.test.ts b/packages/engine/src/__tests__/reliability-interactions/planning-dependency-release.pg.test.ts index 46da9a5af4..23d4761a29 100644 --- a/packages/engine/src/__tests__/reliability-interactions/planning-dependency-release.pg.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/planning-dependency-release.pg.test.ts @@ -23,6 +23,7 @@ import { import { getPromptPath } from "../../execution/spec-staleness.js"; import { promoteHeldTask, runHoldReleaseSweep } from "../../execution/hold-release.js"; import { SelfHealingManager } from "../../self-healing.js"; +import { TriageProcessor } from "../../triage.js"; const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ prefix: "fusion_planning_dependency_release", @@ -78,6 +79,58 @@ pgDescribe("FN-8768 planning dependency release interactions", () => { return store.getTask(id); } + it("finalizes a persisted plan without reacquiring its PostgreSQL lifecycle lock", async () => { + const store = h.store(); + const taskId = "FN-8998-PG"; + const prompt = [ + `# ${taskId}: Planning lifecycle lock regression`, + "", + "## Mission", + "", + "Finalize a persisted plan while the task planning lifecycle lock is held.", + "", + "## Steps", + "", + "### Step 1: Implement", + "- [ ] Preserve the single-lock finalization invariant.", + "", + ].join("\n"); + + await store.updateSettings({ planApprovalMode: "auto-approve-all" }); + await store.createTaskWithReservedId( + { description: "Planning lifecycle lock regression", column: "todo" }, + { taskId, applyDefaultWorkflowSteps: true }, + ); + const promptPath = getPromptPath(store.getTasksDir(), taskId); + mkdirSync(dirname(promptPath), { recursive: true }); + writeFileSync(promptPath, prompt, "utf8"); + await store.updateTask(taskId, { status: "planning", steps: [] }); + store.taskCache.delete(taskId); + + const task = await store.getTask(taskId); + await expect( + new TriageProcessor(store, h.rootDir()).recoverApprovedTask(task), + ).resolves.toBe(true); + + store.taskCache.delete(taskId); + const released = await store.getTask(taskId); + const currentPlan = await store.getLatestCurrentPlanEvidence(taskId); + const latestLock = await store.getLatestSpecLock(taskId); + + expect(released.column).toBe("todo"); + expect(released.status).toBeUndefined(); + expect(released.approvedPlanFingerprint).toEqual(expect.any(String)); + expect(currentPlan).toMatchObject({ + plan: { status: "available", contentHash: expect.any(String) }, + }); + expect(latestLock).toMatchObject({ + approvalFingerprint: released.approvedPlanFingerprint, + currentPlanVersion: currentPlan?.version, + currentPlanHash: currentPlan?.plan.contentHash, + }); + await expect(store.getActiveSpecLock(taskId)).resolves.toEqual(latestLock); + }); + it.each([ { label: "dependency mutation API", diff --git a/packages/engine/src/__tests__/self-healing.test.ts b/packages/engine/src/__tests__/self-healing.test.ts index 93107f802f..e6b58a8081 100644 --- a/packages/engine/src/__tests__/self-healing.test.ts +++ b/packages/engine/src/__tests__/self-healing.test.ts @@ -143,7 +143,7 @@ vi.mock("../merger.js", () => ({ import { SelfHealingManager, isBranchAheadOfBase, MAX_AUTO_MERGE_RETRIES } from "../self-healing.js"; import { HEARTBEAT_ERROR_RECOVERY_METADATA_KEY, HEARTBEAT_ERROR_RETRY_EXHAUSTED_PAUSE_REASON, HEARTBEAT_ERROR_UNRECOVERABLE_PAUSE_REASON, readHeartbeatErrorRetryCount } from "../agent-heartbeat.js"; -import { TaskDeletedError, TaskNotFoundError, type TaskStore, type Settings, type Task, type AgentStore, type Agent, type NotificationProvider } from "@fusion/core"; +import { PlanningLifecycleLockTransportError, TaskDeletedError, TaskNotFoundError, type TaskStore, type Settings, type Task, type AgentStore, type Agent, type NotificationProvider } from "@fusion/core"; import { EventEmitter } from "node:events"; import { execSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; @@ -9491,7 +9491,189 @@ describe("SelfHealingManager", () => { }); }); - describe("recoverOrphanedPlanningTasks", () => { + describe("planning handoff and orphan recovery", () => { + it("finalizes a recoverable written plan before clearing it for re-planning", async () => { + const task = { + id: "FN-PLAN-HANDOFF", + column: "todo", + status: "planning", + worktree: "/tmp/fusion-planning-worktree", + paused: false, + log: [], + updatedAt: "2026-01-01T00:00:00.000Z", + } as unknown as Task; + const recoverApprovedTriageTask = vi.fn().mockResolvedValue(true); + (store.listTasks as ReturnType).mockResolvedValue([task]); + const recovery = new SelfHealingManager(store, { + rootDir: "/tmp/test-project", + getPlanningTaskIds: () => new Set(), + recoverApprovedTriageTask, + }); + vi.setSystemTime(new Date("2026-01-01T00:05:00.000Z")); + + await expect(recovery.recoverApprovedTriageTasks()).resolves.toBe(1); + expect(recoverApprovedTriageTask).toHaveBeenCalledWith(task); + expect(store.updateTask).not.toHaveBeenCalled(); + + recovery.stop(); + }); + + it("backs off a planning-lock transport failure before retrying the retained handoff", async () => { + const task = { + id: "FN-PLAN-HANDOFF-RETRY", + column: "todo", + status: "planning", + paused: false, + log: [], + updatedAt: "2026-01-01T00:00:00.000Z", + } as unknown as Task & { recoveryRetryCount?: number | null; nextRecoveryAt?: string | null }; + const recoverApprovedTriageTask = vi.fn() + .mockRejectedValueOnce(new PlanningLifecycleLockTransportError("lock transport unavailable")) + .mockResolvedValueOnce(true); + const retryingStore = createMockStore({ + listTasks: vi.fn(async () => [task]), + getTask: vi.fn(async () => task), + updateTaskAtomic: vi.fn(async (_id: string, updater: (live: Task) => Partial | null) => { + const patch = updater(task); + if (patch) Object.assign(task, patch); + return task; + }), + }); + const recovery = new SelfHealingManager(retryingStore, { + rootDir: "/tmp/test-project", + getPlanningTaskIds: () => new Set(), + recoverApprovedTriageTask, + }); + vi.setSystemTime(new Date("2026-01-01T00:05:00.000Z")); + + await expect(recovery.recoverApprovedTriageTasks()).resolves.toBe(0); + expect(task.status).toBe("planning"); + expect(task.recoveryRetryCount).toBe(1); + expect(Date.parse(task.nextRecoveryAt!)).toBeGreaterThan(Date.now()); + expect(retryingStore.logEntry).toHaveBeenCalledWith( + task.id, + expect.stringContaining("Planning lifecycle lock transport failure during approved triage recovery — retry 1/3"), + ); + + await expect(recovery.recoverOrphanedPlanningTasks()).resolves.toBe(0); + expect(recoverApprovedTriageTask).toHaveBeenCalledTimes(1); + + vi.setSystemTime(new Date(Date.parse(task.nextRecoveryAt!) + 1)); + await expect(recovery.recoverApprovedTriageTasks()).resolves.toBe(1); + expect(recoverApprovedTriageTask).toHaveBeenCalledTimes(2); + + recovery.stop(); + }); + + it("parks a retained handoff after the planning-lock transport retry budget is exhausted", async () => { + const task = { + id: "FN-PLAN-HANDOFF-EXHAUSTED", + column: "todo", + status: "planning", + paused: false, + log: [], + recoveryRetryCount: 3, + nextRecoveryAt: "2026-01-01T00:04:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + } as unknown as Task; + const recoverApprovedTriageTask = vi.fn().mockRejectedValue( + new PlanningLifecycleLockTransportError("lock transport unavailable"), + ); + const exhaustedStore = createMockStore({ + listTasks: vi.fn(async () => [task]), + getTask: vi.fn(async () => task), + updateTaskAtomic: vi.fn(async (_id: string, updater: (live: Task) => Partial | null) => { + const patch = updater(task); + if (patch) Object.assign(task, patch); + return task; + }), + }); + const recovery = new SelfHealingManager(exhaustedStore, { + rootDir: "/tmp/test-project", + getPlanningTaskIds: () => new Set(), + recoverApprovedTriageTask, + }); + vi.setSystemTime(new Date("2026-01-01T00:05:00.000Z")); + + await expect(recovery.recoverApprovedTriageTasks()).resolves.toBe(0); + expect(task.status).toBe("failed"); + expect(task.error).toContain("PLANNING_LIFECYCLE_LOCK_RECOVERY_EXHAUSTED"); + expect(exhaustedStore.logEntry).toHaveBeenCalledWith(task.id, expect.stringContaining("PLANNING_LIFECYCLE_LOCK_RECOVERY_EXHAUSTED")); + + recovery.stop(); + }); + + it("clears and logs when canonical written-plan recovery returns false", async () => { + const task = { + id: "FN-PLAN-HANDOFF-NOT-RECOVERABLE", + column: "todo", + status: "planning", + paused: false, + log: [], + updatedAt: "2026-01-01T00:00:00.000Z", + } as unknown as Task; + const recoverApprovedTriageTask = vi.fn().mockResolvedValue(false); + const fallbackStore = createMockStore({ + listTasks: vi.fn(async () => [task]), + getTask: vi.fn(async () => task), + }); + const recovery = new SelfHealingManager(fallbackStore, { + rootDir: "/tmp/test-project", + getPlanningTaskIds: () => new Set(), + recoverApprovedTriageTask, + }); + vi.setSystemTime(new Date("2026-01-01T00:05:00.000Z")); + + await expect(recovery.recoverApprovedTriageTasks()).resolves.toBe(0); + await expect(recovery.recoverOrphanedPlanningTasks()).resolves.toBe(1); + expect(recoverApprovedTriageTask).toHaveBeenCalledWith(task); + expect(recoverApprovedTriageTask).toHaveBeenCalledTimes(1); + expect(fallbackStore.updateTask).toHaveBeenCalledWith(task.id, { status: null }); + expect(fallbackStore.logEntry).toHaveBeenCalledWith( + task.id, + "Auto-recovered orphaned planning task — agent session lost, cleared for re-planning", + ); + + recovery.stop(); + }); + + it("does not log a clear when the guarded fallback loses the planning-stage race", async () => { + const task = { + id: "FN-PLAN-HANDOFF-GUARDED", + column: "todo", + status: "planning", + paused: false, + log: [], + updatedAt: "2026-01-01T00:00:00.000Z", + } as unknown as Task; + const recoverApprovedTriageTask = vi.fn().mockResolvedValue(false); + const guardedStore = createMockStore({ + listTasks: vi.fn(async () => [task]), + updateTaskAtomic: vi.fn(async (_id: string, updater: (live: Task) => Partial | null) => updater({ + ...task, + column: "in-progress", + status: null, + firstExecutionAt: "2026-01-01T00:01:00.000Z", + } as Task)), + }); + const recovery = new SelfHealingManager(guardedStore, { + rootDir: "/tmp/test-project", + getPlanningTaskIds: () => new Set(), + recoverApprovedTriageTask, + }); + vi.setSystemTime(new Date("2026-01-01T00:05:00.000Z")); + + await expect(recovery.recoverApprovedTriageTasks()).resolves.toBe(0); + await expect(recovery.recoverOrphanedPlanningTasks()).resolves.toBe(0); + expect(recoverApprovedTriageTask).toHaveBeenCalledWith(task); + expect(guardedStore.logEntry).not.toHaveBeenCalledWith( + task.id, + "Auto-recovered orphaned planning task — agent session lost, cleared for re-planning", + ); + + recovery.stop(); + }); + it("clears status for orphaned planning tasks without a recoverable prompt", async () => { const getPlanning = vi.fn().mockReturnValue(new Set()); diff --git a/packages/engine/src/__tests__/triage-planning-handoff-outcome.test.ts b/packages/engine/src/__tests__/triage-planning-handoff-outcome.test.ts index b23b88f336..f263b9682e 100644 --- a/packages/engine/src/__tests__/triage-planning-handoff-outcome.test.ts +++ b/packages/engine/src/__tests__/triage-planning-handoff-outcome.test.ts @@ -80,6 +80,7 @@ function createStore(opts: { task: Task; settings?: Partial; moveTaskIfResult?: "moved" | "refused" | "absent"; + specLock?: boolean; } ): TaskStore { const { task } = opts; const store: Record = { @@ -112,6 +113,16 @@ function createStore(opts: { on: vi.fn(), off: vi.fn(), }; + if (opts.specLock) { + store.isBackendMode = vi.fn(() => true); + store.withPlanningLifecycleLock = vi.fn(async (_id: string, fn: () => Promise) => fn()); + store.captureCurrentPlanEvidence = vi.fn(async () => { + throw new Error("planning lifecycle lock was reacquired"); + }); + store.captureCurrentPlanEvidenceWhilePlanningLocked = vi.fn().mockResolvedValue(undefined); + store.lockCurrentPlanWhilePlanningLocked = vi.fn().mockResolvedValue(undefined); + store.reconcileSpecDriftWhilePlanningLocked = vi.fn().mockResolvedValue(undefined); + } if (opts.moveTaskIfResult !== "absent") { store.moveTaskIf = vi.fn(async (_id: string, column: string) => { if (opts.moveTaskIfResult === "refused") { @@ -150,6 +161,19 @@ describe("planning handoff outcome — recoverApprovedTask reports what finalize expect(store.moveTaskIf).toHaveBeenCalledWith("FN-001", "todo", expect.any(Function)); }); + it("captures plan evidence without reacquiring the planning lifecycle lock", async () => { + const task = createTask(); + const store = createStore({ task, moveTaskIfResult: "moved", specLock: true }); + + await expect(new TriageProcessor(store, rootDir).recoverApprovedTask(task)).resolves.toBe(true); + + expect(store.captureCurrentPlanEvidenceWhilePlanningLocked).toHaveBeenCalledWith( + "FN-001", + expect.stringContaining("## Steps"), + ); + expect(store.captureCurrentPlanEvidence).not.toHaveBeenCalled(); + }); + it("reports NOT recovered when the planning-stage guard refuses the release move (FN-8361)", async () => { // The symptom: `true` here makes handleStuckAbortRequeue stop, so the card // keeps a finished spec in the planner column with its retry budget skipped. diff --git a/packages/engine/src/planning-handoff-recovery.ts b/packages/engine/src/planning-handoff-recovery.ts index f525793369..d435e4139b 100644 --- a/packages/engine/src/planning-handoff-recovery.ts +++ b/packages/engine/src/planning-handoff-recovery.ts @@ -1,9 +1,14 @@ -import { isPlanReviewSatisfied, type Task } from "@fusion/core"; +import { isPlanReviewSatisfied, PlanningLifecycleLockTransportError, type Task } from "@fusion/core"; export const LEGACY_NULL_PLAN_HANDOFF_STALE_MS = 30 * 60 * 1000; export type PersistedPlanHandoffKind = "planning" | "approved-null" | "legacy-null"; +export function isPlanningLifecycleLockTransportError(error: unknown): error is Error { + return error instanceof PlanningLifecycleLockTransportError + || (error instanceof Error && error.name === "PlanningLifecycleLockTransportError"); +} + /** * Shared persisted-state classifier for planning handoff recovery. It deliberately * excludes graph work-item/step-instance evidence, which callers must check at @@ -36,9 +41,14 @@ export function classifyPersistedPlanHandoff( // a retained Plan Review approval must never make an operator-held or already- // executing task eligible for planning-handoff recovery. if (task.awaitingApprovalReason) return null; - if (task.worktree || task.firstExecutionAt || task.executionStartedAt) return null; + if (task.firstExecutionAt || task.executionStartedAt) return null; + // A planning worktree belongs to the planner and may legitimately survive a + // crashed session. It must not hide a written plan from canonical handoff + // recovery. Null-status compatibility recovery remains fenced below because + // at that point a retained worktree is ambiguous execution evidence. if (task.status === "planning") return "planning"; if (task.status != null) return null; + if (task.worktree) return null; if (task.workflowStepResults?.some(isPlanReviewSatisfied)) return "approved-null"; if (task.approvedPlanFingerprint != null) return null; if (task.workflowStepResults?.length) return null; diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index caa7f8e556..80df739fbb 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -78,7 +78,11 @@ import { finalizeProvenAutoMergeTask, validateWorkflowDoneMergeProof } from "./m import { AutoRecoveryDispatcher } from "./healing/auto-recovery.js"; import { activeSessionRegistry, executingTaskLock } from "./agents/active-session-registry.js"; import { isTaskStillInPlanningStage } from "./execution/replan-target.js"; -import { classifyPersistedPlanHandoff, LEGACY_NULL_PLAN_HANDOFF_STALE_MS } from "./planning-handoff-recovery.js"; +import { + classifyPersistedPlanHandoff, + isPlanningLifecycleLockTransportError, + LEGACY_NULL_PLAN_HANDOFF_STALE_MS, +} from "./planning-handoff-recovery.js"; import { getPromptPath } from "./execution/spec-staleness.js"; import { evaluateStrandedHoldContinuation, seedPreReleasePlanReviewContinuation } from "./plan-review-continuation.js"; import { evaluateStrandedContinuationReclaim, RECLAIM_RETIRED_STATE } from "./workflows/stranded-continuation-reclaim.js"; @@ -123,7 +127,7 @@ import { MAX_TERMINAL_FAILURE_AUTO_RETRIES, TERMINAL_FAILURE_CLAIM_APPLY_GRACE_MS, } from "@fusion/core"; -import { BASE_DELAY_MS, computeRecoveryDecision, MAX_DELAY_MS } from "./healing/recovery-policy.js"; +import { BASE_DELAY_MS, computeRecoveryDecision, formatDelay, MAX_DELAY_MS, MAX_RECOVERY_RETRIES } from "./healing/recovery-policy.js"; export { COMPLETED_BLOCKED_PAUSE_REASON, @@ -477,6 +481,13 @@ export interface SelfHealingOptions { } const APPROVED_TRIAGE_RECOVERY_GRACE_MS = 60_000; + +function isRecoveryRetryDue(task: Pick, now: number): boolean { + if (!task.nextRecoveryAt) return true; + const retryAt = Date.parse(task.nextRecoveryAt); + return !Number.isFinite(retryAt) || retryAt <= now; +} + const STARVED_REFINEMENT_RECOVERY_GRACE_MS = 10 * 60_000; const STARVED_PEER_PROGRESS_THRESHOLD = 3; const STARVED_REFINEMENT_ESCALATION_COOLDOWN_MS = STARVED_REFINEMENT_RECOVERY_GRACE_MS * 4; @@ -14901,6 +14912,50 @@ const movedTask = await this.store.moveTask(task.id, completeLane); * This catches the mirror-image of executor recovery: planning completed, * but the final transition to `todo` / `awaiting-approval` never happened. */ + private async recordPlanningHandoffTransportFailure(task: Task, error: Error): Promise { + const decision = computeRecoveryDecision({ + recoveryRetryCount: task.recoveryRetryCount, + nextRecoveryAt: task.nextRecoveryAt, + }); + const exhaustedMessage = + `PLANNING_LIFECYCLE_LOCK_RECOVERY_EXHAUSTED: canonical planning handoff failed after ` + + `${MAX_RECOVERY_RETRIES} retries — last error: ${error.message}`; + const patch = decision.shouldRetry + ? { + status: "planning" as const, + error: null, + recoveryRetryCount: decision.nextState.recoveryRetryCount, + nextRecoveryAt: decision.nextState.nextRecoveryAt, + } + : { + status: "failed" as const, + error: exhaustedMessage, + recoveryRetryCount: null, + nextRecoveryAt: null, + }; + let persisted = false; + + if (typeof this.store.updateTaskAtomic === "function") { + await this.store.updateTaskAtomic(task.id, (live) => { + if (!isTaskStillInPlanningStage(live)) return null; + persisted = true; + return patch; + }); + } else { + const live = await this.store.getTask(task.id); + if (live && isTaskStillInPlanningStage(live)) { + await this.store.updateTask(task.id, patch); + persisted = true; + } + } + + if (!persisted) return; + const action = decision.shouldRetry + ? `Planning lifecycle lock transport failure during approved triage recovery — retry ${decision.nextState.recoveryRetryCount}/${MAX_RECOVERY_RETRIES} in ${formatDelay(decision.delayMs)}: ${error.message}` + : exhaustedMessage; + await this.store.logEntry(task.id, action).catch(() => undefined); + } + async recoverApprovedTriageTasks(): Promise { const recoverFn = this.options.recoverApprovedTriageTask; if (!recoverFn) return 0; @@ -14934,6 +14989,7 @@ const movedTask = await this.store.moveTask(task.id, completeLane); requirePersistedSteps: true, }); return handoffKind != null + && isRecoveryRetryDue(task, now) && now - new Date(task.updatedAt).getTime() >= APPROVED_TRIAGE_RECOVERY_GRACE_MS; }); @@ -14962,8 +15018,16 @@ const movedTask = await this.store.moveTask(task.id, completeLane); // progress, so its exact classifier owns that one compatibility shape. if (handoffKind !== "legacy-null" && !isTaskStillInPlanningStage(recoveryTask)) continue; log.log(`Recovering specified triage task ${task.id}: ${task.title || task.description?.slice(0, 60) || "(untitled)"}`); - const success = await recoverFn(recoveryTask); - if (success) recovered++; + try { + const success = await recoverFn(recoveryTask); + if (success) recovered++; + } catch (error) { + if (isPlanningLifecycleLockTransportError(error)) { + await this.recordPlanningHandoffTransportFailure(recoveryTask, error); + continue; + } + log.warn(`Recoverable planning handoff for ${task.id} could not be finalized: ${error instanceof Error ? error.message : String(error)}`); + } } if (recovered > 0) { @@ -15386,16 +15450,25 @@ const movedTask = await this.store.moveTask(task.id, completeLane); t.status === "planning" && !t.paused && !planningIds.has(t.id) && + isRecoveryRetryDue(t, now) && now - new Date(t.updatedAt).getTime() >= APPROVED_TRIAGE_RECOVERY_GRACE_MS ); if (orphaned.length === 0) return 0; - log.warn(`Found ${orphaned.length} orphaned planning triage task(s) without a recoverable prompt`); + log.warn(`Found ${orphaned.length} orphaned planning triage task(s)`); let recovered = 0; for (const task of orphaned) { try { + /* + FNXC:PlanningLifecycleLockReentry 2026-08-12-03:18: + Canonical written-plan recovery runs immediately before this fallback in + both startup and steady-state pipelines. It finalizes a retained prompt + or records bounded transport backoff, which makes that task ineligible + here. A remaining candidate has no releasable prompt, so guarded clearing + for replanning is safe and does not repeat the canonical recovery call. + */ log.log(`Recovering orphaned planning task ${task.id}: ${task.title || task.description?.slice(0, 60) || "(untitled)"}`); // FNXC:Triage 2026-07-29-12:00: // FN-8361 closes the stale listTasks → updateTask race: only clear planning @@ -15949,4 +16022,3 @@ const movedTask = await this.store.moveTask(task.id, completeLane); } } } - diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 35ded21a12..2382be956f 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -98,11 +98,6 @@ function getPlanningLifecycleLockTransportFailure(task: Task): PlanningLifecycle : null; } -function isPlanningLifecycleLockTransportError(error: unknown): error is Error { - return error instanceof fusionCore.PlanningLifecycleLockTransportError - || (error instanceof Error && error.name === "PlanningLifecycleLockTransportError"); -} - /* FNXC:PlanReviewReplan 2026-08-10-18:32 (TOMBSTONE — do not re-add): `PLAN_REVIEW_GATE_REPLAN_CAP = 8` is DELETED. It belonged to the out-of-graph triage Plan Review gate @@ -159,7 +154,11 @@ import type { } from "@earendil-works/pi-coding-agent"; import { ModelFallbackExhaustedError, describeModel, formatModelMarkerDetails, promptWithFallback } from "./pi.js"; import { hasAdvancedPastPlanning, isTaskStillInPlanningStage, resolvePlannerLanesForTaskAsync } from "./execution/replan-target.js"; -import { classifyPersistedPlanHandoff, LEGACY_NULL_PLAN_HANDOFF_STALE_MS } from "./planning-handoff-recovery.js"; +import { + classifyPersistedPlanHandoff, + isPlanningLifecycleLockTransportError, + LEGACY_NULL_PLAN_HANDOFF_STALE_MS, +} from "./planning-handoff-recovery.js"; import { createResolvedAgentSession, extractRuntimeHint, @@ -4910,11 +4909,13 @@ export class TriageProcessor { FNXC:SpecLock 2026-08-09-07:36: Planning finalization writes PROMPT.md without going through updateTask({ prompt }), so it must capture the same canonical evidence before any approval path can release the task. This remains - before the release boundary: a database/parser failure leaves the planning hold intact. + before the release boundary: a database/parser failure leaves the planning hold intact. The + finalizer already owns the planning lifecycle lock, so evidence capture must use the lock-assuming + seam instead of reacquiring the non-reentrant PostgreSQL advisory lock. */ const supportsSpecLock = (this.store as unknown as { isBackendMode?: () => boolean }).isBackendMode?.() === true; if (supportsSpecLock) { - await this.store.captureCurrentPlanEvidence(task.id, written); + await this.store.captureCurrentPlanEvidenceWhilePlanningLocked(task.id, written); } let taskIntentSignature: ReturnType = {