FN-8768: recover planning handoffs after dependency reseeds
Prevent dependency reseeds from leaving completed planning work without a dispatchable continuation. - Serialize dependency invalidation with workflow claims and retire only pending continuations. - Persist dispatch-deduplication state and recover legacy reseeded planning handoffs safely. - Add PostgreSQL migration, integration coverage, architecture guidance, and a patch changeset. Files changed: .changeset/fn-8768-planning-reseed.md | 7 ++ docs/architecture.md | 10 +- .../core/src/__test-utils__/pg-test-harness.ts | 8 ++ .../__tests__/postgres/backend-resolver.test.ts | 5 + .../src/__tests__/postgres/schema-applier.test.ts | 17 ++- .../postgres/task-dependency-mutation.pg.test.ts | 42 ++++++- .../__tests__/task-update-lanes-resolved.test.ts | 20 +++- packages/core/src/postgres/advisory-locks.ts | 122 +++++++++++++++++++++ packages/core/src/postgres/backend-resolver.ts | 13 ++- packages/core/src/postgres/data-layer.ts | 4 + packages/core/src/postgres/embedded-lifecycle.ts | 6 + .../migrations/0043_fn8768_dispatch_dedupe.sql | 17 +++ packages/core/src/postgres/schema-applier.ts | 13 ++- packages/core/src/postgres/schema/project.ts | 15 +++ packages/core/src/store.ts | 56 +++++++++- packages/core/src/task-store/audit-ops.ts | 49 +++++++++ .../core/src/task-store/branch-and-pr-entities.ts | 30 +++++ packages/core/src/task-store/project-store-ops.ts | 55 ++++++++-- packages/core/src/task-store/task-update.ts | 30 ++++- packages/core/src/task-store/update-task-deps.ts | 30 ++++- packages/engine/src/__tests__/triage.test.ts | 66 ++++++++++- packages/engine/src/execution/hold-release.ts | 45 ++++++++ packages/engine/src/scheduler.ts | 3 +- packages/engine/src/triage.ts | 87 ++++++++++++++- 24 files changed, 713 insertions(+), 37 deletions(-) Fusion-Task-Id: FN-8768 Fusion-Task-Lineage: 539ef649-5a13-4eaa-a695-bc68370fed22 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -3022,6 +3022,28 @@ describe("specified triage recovery", () => {
|
||||
await cleanupTriageFixtureRoot(rootDir);
|
||||
});
|
||||
|
||||
it("claims a dependency-reseeded real specification with null status for approval", async () => {
|
||||
const store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 10_000,
|
||||
groupOverlappingFiles: false, autoMerge: true, requirePlanApproval: true,
|
||||
} as Settings),
|
||||
parseDependenciesFromPrompt: vi.fn().mockResolvedValue([]),
|
||||
});
|
||||
const processor = new TriageProcessor(store, rootDir);
|
||||
const recovered = await processor.recoverApprovedTask({
|
||||
id: "FN-001", description: "Recovered triage task", column: "triage", status: null,
|
||||
dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:02:00.000Z",
|
||||
});
|
||||
|
||||
expect(recovered).toBe(true);
|
||||
expect(store.updateTask).toHaveBeenCalledWith(
|
||||
"FN-001", expect.objectContaining({ status: "awaiting-approval" }),
|
||||
);
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves approved planning task to todo during recovery", async () => {
|
||||
const store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
@@ -3187,7 +3209,7 @@ describe("specified triage recovery", () => {
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
|
||||
});
|
||||
|
||||
it("does not recover a null-status triage draft that never passed Plan Review", async () => {
|
||||
it("recovers the legacy null-status real specification with no handoff evidence", async () => {
|
||||
const store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
@@ -3213,7 +3235,47 @@ describe("specified triage recovery", () => {
|
||||
updatedAt: "2026-01-01T00:02:00.000Z",
|
||||
});
|
||||
|
||||
expect(recovered).toBe(false);
|
||||
expect(recovered).toBe(true);
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
|
||||
});
|
||||
|
||||
it("defers legacy null-status recovery when graph step instances already exist", async () => {
|
||||
const store = createMockStore({
|
||||
listWorkflowWorkItemsForTask: vi.fn().mockResolvedValue([]),
|
||||
hasWorkflowRunStepInstancesForTask: vi.fn().mockResolvedValue(true),
|
||||
});
|
||||
const processor = new TriageProcessor(store, rootDir);
|
||||
|
||||
await expect(processor.recoverApprovedTask({
|
||||
id: "FN-INSTANCE",
|
||||
description: "Graph-owned plan",
|
||||
column: "triage",
|
||||
status: null,
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:02:00.000Z",
|
||||
} as any)).resolves.toBe(false);
|
||||
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
expect(store.hasWorkflowRunStepInstancesForTask).toHaveBeenCalledWith("FN-INSTANCE");
|
||||
});
|
||||
|
||||
it("fences a stale finalizer after dependency re-seed commits before lifecycle-lock acquisition", async () => {
|
||||
const stalePlannerSnapshot = createTriageTask({ status: "planning" });
|
||||
const store = createMockStore({
|
||||
withPlanningLifecycleLock: vi.fn(async (_id, callback) => callback()),
|
||||
getTask: vi.fn().mockResolvedValue({ ...stalePlannerSnapshot, status: "needs-replan" }),
|
||||
});
|
||||
const processor = new TriageProcessor(store, rootDir);
|
||||
|
||||
await (processor as unknown as {
|
||||
finalizeApprovedTask(task: Task, prompt: string, settings: Settings): Promise<unknown>;
|
||||
}).finalizeApprovedTask(stalePlannerSnapshot, "# already persisted", { requirePlanApproval: true } as Settings);
|
||||
|
||||
expect(store.updateTask).not.toHaveBeenCalled();
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
type WorkflowIrV2,
|
||||
type WorkflowIrColumn,
|
||||
} from "@fusion/core";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { schedulerLog } from "../logger.js";
|
||||
import { getPromptPath } from "./spec-staleness.js";
|
||||
@@ -174,6 +175,48 @@ export function resolvePreReleasePlanReviewNode(ir: WorkflowIr): WorkflowIrNode
|
||||
return planReviewNode;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PlanningDependencyReseed 2026-08-04-02:14:
|
||||
A release refusal is otherwise invisible after scheduler dispatch returns early.
|
||||
Hash only durable state that changes the planning/Plan-Review episode; the core
|
||||
store atomically claims this project/task episode and appends one task-log entry.
|
||||
*/
|
||||
export async function checkAndRecordUnplannedExecutionBlock(
|
||||
store: TaskStore,
|
||||
task: Task,
|
||||
ir: WorkflowIr,
|
||||
): Promise<void> {
|
||||
const recorder = (store as Partial<Pick<TaskStore, "checkAndRecordUnplannedExecutionBlock">>).checkAndRecordUnplannedExecutionBlock;
|
||||
if (!recorder) return;
|
||||
const planReviewNode = resolvePreReleasePlanReviewNode(ir)?.id ?? "none";
|
||||
let promptContent = typeof task.prompt === "string" ? task.prompt : "";
|
||||
const tasksDir = typeof store.getTasksDir === "function" ? store.getTasksDir() : undefined;
|
||||
if (tasksDir) {
|
||||
try {
|
||||
promptContent = await readFile(getPromptPath(tasksDir, task.id), "utf8");
|
||||
} catch {
|
||||
promptContent = "";
|
||||
}
|
||||
}
|
||||
const promptMarker = promptContent.length > 0
|
||||
? createHash("sha256").update(promptContent).digest("hex")
|
||||
: "missing";
|
||||
const dependencies = [...(task.dependencies ?? [])].sort();
|
||||
const episode = createHash("sha256").update(JSON.stringify({
|
||||
planReviewNode,
|
||||
promptMarker,
|
||||
dependencies,
|
||||
status: task.status ?? null,
|
||||
handoffFingerprint: task.approvedPlanFingerprint ?? null,
|
||||
})).digest("hex");
|
||||
try {
|
||||
await recorder.call(store, task.id, episode);
|
||||
} catch (error) {
|
||||
// The gate is safety-critical; its diagnostic must not turn an otherwise-safe refusal into a dispatch failure.
|
||||
schedulerLog.warn(`Could not persist unplanned dispatch refusal for ${task.id}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function isUnplannedForExecution(store: TaskStore, task: Task, ir: WorkflowIr): Promise<boolean> {
|
||||
/*
|
||||
FNXC:PlanReview 2026-07-19-00:40 (U3):
|
||||
@@ -722,6 +765,7 @@ async function issueRelease(
|
||||
}
|
||||
|
||||
if (targetIsProcessing && !options.allowUnplanned && (await isUnplannedForExecution(store, task, ir))) {
|
||||
await checkAndRecordUnplannedExecutionBlock(store, task, ir);
|
||||
/*
|
||||
FNXC:StrandedHoldContinuation 2026-07-26-14:15:
|
||||
Before FN-8592 this was an undeduplicated `schedulerLog.log`, not debug.
|
||||
@@ -883,6 +927,7 @@ export async function promoteHeldTask(
|
||||
: false;
|
||||
const unplanned = targetIsProcessing && (await isUnplannedForExecution(store, task, ir));
|
||||
if (unplanned && options.force !== true) {
|
||||
await checkAndRecordUnplannedExecutionBlock(store, task, ir);
|
||||
return { released: false, rejection: "unplanned-for-execution", toColumn: target };
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ import type { TaskMoveLanes } from "@fusion/core";
|
||||
import { resolveProjectColumnsForRoles, resolveWorkflowIrForTask, resolveWorkflowIrById, resolveColumnFlags, resolveWorktreeCapacityLimit, resolveLifecycleColumns, isWipColumnRole, isReviewColumnRole, isCompleteColumnRole, columnsWithFlag } from "@fusion/core";
|
||||
import type { ColumnRoleTraitFlags } from "@fusion/core";
|
||||
import type { WorkflowIr, WorkflowIrV2 } from "@fusion/core";
|
||||
import { runHoldReleaseSweep, isUnplannedForExecution, type SlotReservation } from "./execution/hold-release.js";
|
||||
import { checkAndRecordUnplannedExecutionBlock, runHoldReleaseSweep, isUnplannedForExecution, type SlotReservation } from "./execution/hold-release.js";
|
||||
import { moveTaskToReplanColumn } from "./execution/replan-target.js";
|
||||
import { evaluateParkedAgentTaskLink } from "./agents/task-agent-sync.js";
|
||||
import { decideMissionSymbolAdmission, resolveMissionFeatureForTask } from "./missions/mission-symbol-admission.js";
|
||||
@@ -2388,6 +2388,7 @@ export class Scheduler {
|
||||
try {
|
||||
const ir = await resolveWorkflowIrForTask(this.store, task.id);
|
||||
if (await isUnplannedForExecution(this.store, task, ir)) {
|
||||
await checkAndRecordUnplannedExecutionBlock(this.store, task, ir);
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -1280,9 +1280,47 @@ export class TriageProcessor {
|
||||
* Do not recover `needs-replan` / `plan-review-unavailable`.
|
||||
*/
|
||||
async recoverApprovedTask(task: Task): Promise<boolean> {
|
||||
/*
|
||||
FNXC:PlanningDependencyReseed 2026-08-04-00:30:
|
||||
A dependency reseed from older writers could clear status after the planner
|
||||
persisted a valid PROMPT.md but before this handoff ran. It has neither a
|
||||
fingerprint nor graph evidence, so ordinary discovery considers it planned
|
||||
while release considers it unplanned. Claim this narrow legacy shape here;
|
||||
the validation below still rejects seeds/partial plans and finalization
|
||||
evaluates manual approval before graph continuation.
|
||||
*/
|
||||
const hasNoPlanningHandoffEvidence = task.approvedPlanFingerprint == null
|
||||
&& !(task.workflowStepResults?.length);
|
||||
/*
|
||||
FNXC:PlanningDependencyReseed 2026-08-04-01:04:
|
||||
Null status alone is not a planning handoff. Claim the legacy reseed hole
|
||||
only after its original planner is gone, its row has aged past the normal
|
||||
stuck-processing grace, and no approval or graph continuation evidence
|
||||
exists. This keeps ordinary null-status cards from being re-finalized.
|
||||
*/
|
||||
const continuationReader = (this.store as Partial<Pick<TaskStore, "listWorkflowWorkItemsForTask">>).listWorkflowWorkItemsForTask;
|
||||
const stepInstanceReader = (this.store as Partial<Pick<TaskStore, "hasWorkflowRunStepInstancesForTask">>).hasWorkflowRunStepInstancesForTask;
|
||||
const legacyNullStatusCandidate = task.status == null
|
||||
&& hasNoPlanningHandoffEvidence
|
||||
&& !task.awaitingApprovalReason
|
||||
&& !this.hasLivePlanningWork(task.id)
|
||||
// FNXC:PlanningDependencyReseed 2026-08-04-01:04: Legacy unit fixtures
|
||||
// have no graph-work-item reader; production always applies this fence.
|
||||
&& (!continuationReader || (
|
||||
Date.now() - new Date(task.updatedAt).getTime() >= TriageProcessor.STALE_PROCESSING_THRESHOLD_MS
|
||||
&& (await continuationReader.call(this.store, task.id)).length === 0
|
||||
/*
|
||||
FNXC:PlanningDependencyReseed 2026-08-04-02:10:
|
||||
A graph run can persist foreach step-instance rows before it creates a
|
||||
result or continuation. That is still graph handoff evidence, so a
|
||||
legacy null-status repair must defer instead of duplicating finalization.
|
||||
Older narrow unit-store adapters lack this reader; production requires it.
|
||||
*/
|
||||
&& (!stepInstanceReader || !(await stepInstanceReader.call(this.store, task.id)))
|
||||
));
|
||||
const recoverableStatus =
|
||||
task.status === "planning"
|
||||
|| (task.status == null && this.hasSatisfiedPlanReview(task));
|
||||
|| (task.status == null && (this.hasSatisfiedPlanReview(task) || legacyNullStatusCandidate));
|
||||
/* FNXC:WorkflowLifecycleColumns 2026-07-29-09:05 (U11): the INTAKE lane, not
|
||||
the literal. Converting only the `todo` sites left this one rejecting every
|
||||
card whose workflow renames its planner column, so the release below was
|
||||
@@ -4054,13 +4092,54 @@ export class TriageProcessor {
|
||||
so this plumbing is inert everywhere except the two sites explicitly marked
|
||||
below. Adding a state to an exit is then a deliberate, reviewable act.
|
||||
*/
|
||||
const report: PlanningHandoffReport = { outcome: "parked" };
|
||||
const finalizeUnderLock = async () => {
|
||||
/*
|
||||
FNXC:PlanningDependencyReseed 2026-08-04-00:43:
|
||||
Finalization publishes approval and graph-continuation handoff state under
|
||||
the same cross-process advisory lock as dependency invalidation. A stale
|
||||
planner therefore cannot recreate approval evidence after a reseed.
|
||||
*/
|
||||
const report: PlanningHandoffReport = { outcome: "parked" };
|
||||
this.finalizing.add(task.id);
|
||||
try {
|
||||
/*
|
||||
FNXC:PlanningDependencyReseed 2026-08-04-00:54:
|
||||
The snapshot held by a planner predates the outer lifecycle lock. Re-read
|
||||
after acquiring it so a dependency invalidation committed first fences this
|
||||
stale finalizer before it can restore approval or continuation handoff data.
|
||||
*/
|
||||
const reRead = await Promise.resolve(this.store.getTask(task.id)).catch(() => null);
|
||||
// Older pure unit-test adapters expose a no-op getTask; production returns
|
||||
// a Task or rejects. Preserve that fixture seam without treating a failed
|
||||
// production read as permission to publish a stale handoff.
|
||||
if (reRead === null) return report;
|
||||
const live = reRead ?? task;
|
||||
if (live.status === "needs-replan") return report;
|
||||
await this.finalizeApprovedTaskBody(live, writtenInput, settings, options, report);
|
||||
} finally {
|
||||
this.finalizing.delete(task.id);
|
||||
}
|
||||
return report;
|
||||
};
|
||||
// Minimal fixture stores predate the lifecycle-lock surface. Production
|
||||
// TaskStore always supplies it; retaining this compatibility seam keeps
|
||||
// pure triage unit tests from impersonating a PostgreSQL process.
|
||||
const lifecycleLock = (this.store as Partial<TaskStore>).withPlanningLifecycleLock as
|
||||
| (<T>(id: string, callback: () => Promise<T>) => Promise<T>)
|
||||
| undefined;
|
||||
/*
|
||||
FNXC:PlanningDependencyReseed 2026-08-04-01:18:
|
||||
A fail-closed direct-session transport can reject before it invokes the
|
||||
callback. Keep the outer finalizing marker exception-safe so that rejection
|
||||
remains diagnosable and retryable rather than permanently owning the task.
|
||||
*/
|
||||
try {
|
||||
await this.finalizeApprovedTaskBody(task, writtenInput, settings, options, report);
|
||||
return lifecycleLock
|
||||
? await lifecycleLock(task.id, finalizeUnderLock)
|
||||
: await finalizeUnderLock();
|
||||
} finally {
|
||||
this.finalizing.delete(task.id);
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user