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:
gsxdsm
2026-08-03 19:31:03 -07:00
parent 9dc7b94c17
commit bb17baaacf
24 changed files with 713 additions and 37 deletions

View File

@@ -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();
});

View File

@@ -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 };
}

View File

@@ -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 {

View File

@@ -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;
}
/*