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

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep dependency-reseeded plans recoverable instead of silently stranding them.
category: fix
dev: Dependency changes now retain the durable `needs-replan` lifecycle signal.

View File

@@ -12,6 +12,14 @@ Actionable terminal task updates are classified into bounded reasons such as a n
Each task also stores `lastNotifiedAtByReason`, an independent timestamp map keyed by bounded reason. `WEDGE_RENOTIFY_COOLDOWN_MS` defaults to six hours: resolving an episode does not clear its reason's live stamp, so a scheduler/self-healing resolve→re-wedge flap sends neither a provider push nor a mailbox message until the window expires. A different reason notifies immediately, including X→Y→X while X remains within its own cooldown; expired or invalid entries are pruned during the atomic claim, and legacy rows without the map notify normally before initializing it. The no-durable-store fallback applies the same per-reason window in memory. Provider and mailbox delivery are independently best-effort after sharing this single claim decision, while run-audit metadata remains ids/counts/outcomes-only.
## Planning dependency lifecycle lock
Dependency changes and planning finalization share one outer lifecycle lock keyed by the canonical project ID and task ID. In PostgreSQL mode this is a dedicated, single-connection session advisory lock: it is acquired before the normal task lock and released before the mutation/finalization Promise settles. The operational runtime pool is never borrowed for this purpose.
`ResolvedBackend.directSessionUrl` carries the executable lock endpoint. Embedded lifecycle startup supplies its lifecycle-created local URL for runtime, migration, and direct-session use with `embedded-lifecycle` provenance. External deployments must supply an explicit `DATABASE_MIGRATION_URL`; it is the only external direct-session endpoint and has `migration-override` provenance. Missing, mismatched, unavailable, or pooler-like endpoints fail closed rather than falling back to `DATABASE_URL`.
A newly added dependency atomically records `needs-replan` and clears superseded approval fingerprint/approval-park evidence before a new graph continuation can be eligible. Pending pre-execution continuation work is cancelled with that superseded handoff; running and historical graph work remain durable evidence. Finalizers take the same lock and therefore cannot restore stale handoff state after a re-seed. Manual approval is restored before graph continuation; Plan Review verdicts and results remain graph-owned. The conservative stranded-shape recovery claims only a complete non-seed prompt after planner staleness grace, with no live/finalizing session, approval park, handoff, graph continuation/work item evidence, or any persisted workflow-run step instance; those step instances are graph-owned evidence even before a result or continuation exists. Automatic unplanned release refusals atomically claim a project/task episode and append one durable task-log diagnostic; changed prompt, dependency, status, fingerprint, or Plan Review node state starts a new episode. Direct-session validation compares PostgreSQL server identity as well as database name, preventing a same-named database on another cluster from becoming the lifecycle lock namespace.
## 1) Overview
Fusion is an AI-orchestrated task board. It takes tasks through a structured lifecycle (`planning → todo → in-progress → in-review → done → archived`) and automates planning, execution, review, merge, and operational recovery.
@@ -604,7 +612,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. 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.
- **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. A newly added dependency in a hold lane follows the same durable `needs-replan` path; it never clears status, so a planner interrupted after prompt persistence remains claimable and cannot silently bypass the approval/release handoff. 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

View File

@@ -760,6 +760,14 @@ export async function createTaskStoreForTest(options?: {
runtimeUrl: testUrl,
migrationUrl: testUrl,
migrationUrlOverridden: false,
/*
FNXC:PlanningDependencyReseed 2026-08-04-00:54:
The harness creates this local postmaster endpoint itself, making it the
test equivalent of an embedded lifecycle-proven direct session transport.
Dependency mutation tests must exercise the real advisory-lock path.
*/
directSessionUrl: testUrl,
directSessionProvenance: "migration-override",
};
const connections = await createConnectionSetFromUrl(schemaBackend, {
poolMax,

View File

@@ -17,6 +17,8 @@ describe("backend-resolver: resolveBackend (env-based)", () => {
expect(backend.runtimeUrl).toBeNull();
expect(backend.migrationUrl).toBeNull();
expect(backend.migrationUrlOverridden).toBe(false);
expect(backend.directSessionUrl).toBeNull();
expect(backend.directSessionProvenance).toBeNull();
});
it("resolves to embedded mode when DATABASE_URL is empty", () => {
@@ -64,6 +66,8 @@ describe("backend-resolver: resolveBackendWithOptions", () => {
expect(backend.runtimeUrl).toBe(runtimeUrl);
expect(backend.migrationUrl).toBe(migrationUrl);
expect(backend.migrationUrlOverridden).toBe(true);
expect(backend.directSessionUrl).toBe(migrationUrl);
expect(backend.directSessionProvenance).toBe("migration-override");
});
it("migrationUrl falls back to runtimeUrl when DATABASE_MIGRATION_URL is not set", () => {
@@ -74,6 +78,7 @@ describe("backend-resolver: resolveBackendWithOptions", () => {
});
expect(backend.migrationUrl).toBe(url);
expect(backend.migrationUrlOverridden).toBe(false);
expect(backend.directSessionUrl).toBeNull();
});
it("DATABASE_MIGRATION_URL without DATABASE_URL still resolves to embedded mode", () => {

View File

@@ -85,6 +85,7 @@ import {
TASK_LIFECYCLE_OUTBOX_VERSION,
TASK_LIFECYCLE_CONSUMERS_VERSION,
VALIDATOR_INPUT_FINGERPRINT_VERSION,
UNPLANNED_EXECUTION_BLOCK_DEDUPE_VERSION,
} from "../../postgres/schema-applier.js";
import { ProjectPartitionRekeyError, rekeyFallbackProjectPartition } from "../../postgres/migration-stamping.js";
import type { PluginSchemaInitHook } from "../../postgres/plugin-schema-hook.js";
@@ -103,7 +104,8 @@ describe("schema-applier: immutable migration identities", () => {
expect(TASK_LIFECYCLE_OUTBOX_VERSION).toBe("0040");
expect(TASK_LIFECYCLE_CONSUMERS_VERSION).toBe("0041");
expect(VALIDATOR_INPUT_FINGERPRINT_VERSION).toBe("0042");
expect(SCHEMA_BASELINE_VERSION).toBe("0042");
expect(UNPLANNED_EXECUTION_BLOCK_DEDUPE_VERSION).toBe("0043");
expect(SCHEMA_BASELINE_VERSION).toBe("0043");
});
it("keeps monitor and approval isolation assigned to version 0003", () => {
@@ -718,7 +720,7 @@ pgDescribe("schema-applier: VAL-SCHEMA-001 final-schema parity (table counts)",
ctx = null;
});
it("creates all 104 project tables, 17 central tables, 1 archive table", async () => {
it("creates all 105 project tables, 17 central tables, 1 archive table", async () => {
ctx = await setupFreshDb();
// FNXC:PostgresCutover 2026-07-05-15:55: apply the BASELINE only.
// applySchemaBaseline now runs the plugin schema-init hooks by default,
@@ -736,10 +738,10 @@ pgDescribe("schema-applier: VAL-SCHEMA-001 final-schema parity (table counts)",
/*
FNXC:PgSchemaApplier 2026-08-03-02:16:
Project table count = historical core baseline plus later migrations. 0040 adds 2 lifecycle
outbox tables; 0041 adds 4 lifecycle consumer tables (100 → 104). Plugin tables are added
separately by the schema-init hook and are excluded here.
outbox tables; 0041 adds 4 lifecycle consumer tables; 0043 adds the durable unplanned-dispatch
refusal marker (100 → 105). Plugin tables are added separately by the schema-init hook and are excluded here.
*/
expect(bySchema.project).toBe(104);
expect(bySchema.project).toBe(105);
/*
FNXC:CapacityModel 2026-07-29-08:10 (drop the cross-project cap — table half):
17, not 18: `central.global_concurrency` is dropped by migration 0037. A fresh
@@ -1758,6 +1760,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
TASK_LIFECYCLE_OUTBOX_VERSION,
TASK_LIFECYCLE_CONSUMERS_VERSION,
VALIDATOR_INPUT_FINGERPRINT_VERSION,
UNPLANNED_EXECUTION_BLOCK_DEDUPE_VERSION,
]);
expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false);
});
@@ -1826,6 +1829,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
TASK_LIFECYCLE_OUTBOX_VERSION,
TASK_LIFECYCLE_CONSUMERS_VERSION,
VALIDATOR_INPUT_FINGERPRINT_VERSION,
UNPLANNED_EXECUTION_BLOCK_DEDUPE_VERSION,
]);
});
@@ -2027,6 +2031,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
TASK_LIFECYCLE_OUTBOX_VERSION,
TASK_LIFECYCLE_CONSUMERS_VERSION,
VALIDATOR_INPUT_FINGERPRINT_VERSION,
UNPLANNED_EXECUTION_BLOCK_DEDUPE_VERSION,
]);
});
@@ -2109,6 +2114,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
TASK_LIFECYCLE_OUTBOX_VERSION,
TASK_LIFECYCLE_CONSUMERS_VERSION,
VALIDATOR_INPUT_FINGERPRINT_VERSION,
UNPLANNED_EXECUTION_BLOCK_DEDUPE_VERSION,
]);
});
@@ -2191,6 +2197,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
TASK_LIFECYCLE_OUTBOX_VERSION,
TASK_LIFECYCLE_CONSUMERS_VERSION,
VALIDATOR_INPUT_FINGERPRINT_VERSION,
UNPLANNED_EXECUTION_BLOCK_DEDUPE_VERSION,
]);
});
});

View File

@@ -54,7 +54,8 @@ pgTest("TaskStore dependency mutations (PostgreSQL)", () => {
expect(updated.dependencies).toEqual([canonical.id]);
expect(updated.blockedBy).toBeUndefined();
expect(updated.status).toBeUndefined();
// A newly introduced prerequisite invalidates any in-flight planning handoff.
expect(updated.status).toBe("needs-replan");
/*
FNXC:WorkflowLifecycleColumns 2026-08-02-03:20 (fleet — this assertion pinned a live bug):
THE RE-SPECIFICATION TARGET IS THE BOARD'S INTAKE COLUMN, and on today's default lineage that is
@@ -83,10 +84,48 @@ pgTest("TaskStore dependency mutations (PostgreSQL)", () => {
) as { dependencies: string[]; blockedBy?: string; column: string; status?: string };
expect(taskJson.dependencies).toEqual([canonical.id]);
expect(taskJson.blockedBy).toBeUndefined();
expect(taskJson.status).toBe("needs-replan");
// Same reasoning as above: the intake column of the default lineage is `todo` post-U11.
expect(taskJson.column).toBe("todo");
});
/*
FNXC:PlanningDependencyReseed 2026-08-04-01:57:
A dependency re-seed commits its task fence and pending continuation retirement
in one transaction for both public dependency APIs. A list-then-transition
implementation could otherwise cancel a worker after it claimed the
continuation between those independent writes.
*/
it("atomically fences the task and cancels only its pending continuation", async () => {
const prerequisite = await store.createTask({ description: "new prerequisite", column: "done" });
const dependent = await store.createTask({ description: "dependent", column: "todo" });
const pending = await store.replaceActiveTaskWorkflowContinuation({
runId: `${dependent.id}:continuation:0`, taskId: dependent.id, nodeId: "plan-review",
kind: "task", state: "runnable", stableWorkflowRunId: `${dependent.id}:workflow`,
continuationSequence: 0, waitReason: "planning", sourceColumn: "todo", targetColumn: "todo", irHash: "ir-v1",
});
await store.updateTaskDependencies(dependent.id, { operation: "add", dependency: prerequisite.id });
expect((await store.getTask(dependent.id)).status).toBe("needs-replan");
expect((await store.getWorkflowWorkItem(pending.id))?.state).toBe("cancelled");
});
it("uses the same atomic invalidation for updateTask dependency patches", async () => {
const prerequisite = await store.createTask({ description: "patch prerequisite", column: "done" });
const dependent = await store.createTask({ description: "patch dependent", column: "todo" });
const pending = await store.replaceActiveTaskWorkflowContinuation({
runId: `${dependent.id}:continuation:0`, taskId: dependent.id, nodeId: "plan-review",
kind: "task", state: "held", stableWorkflowRunId: `${dependent.id}:workflow`,
continuationSequence: 0, waitReason: "planning", sourceColumn: "todo", targetColumn: "todo", irHash: "ir-v1",
});
await store.updateTask(dependent.id, { dependencies: [prerequisite.id] });
expect((await store.getTask(dependent.id)).status).toBe("needs-replan");
expect((await store.getWorkflowWorkItem(pending.id))?.state).toBe("cancelled");
});
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-02:05 (PR #2720 review — greptile):
DISTINCT HOLD AND INTAKE LANES, the configuration the default lineage does not exercise.
@@ -131,6 +170,7 @@ pgTest("TaskStore dependency mutations (PostgreSQL)", () => {
} as never);
expect(updated.column).toBe("inbox");
expect(updated.status).toBe("needs-replan");
// A real move, so the move timestamp advances.
expect(updated.columnMovedAt).not.toBe(before.columnMovedAt);
});

View File

@@ -118,15 +118,31 @@ describe("adding a dependency never parks a card in a deleted column", () => {
});
it("still records the re-specification when the card does not move", async () => {
// The status reset and log entry are the operator-visible signal; only the teleport is dropped.
// A durable replan claim and log entry make an interrupted planner rediscoverable; only the teleport is dropped.
const { store, row } = harness({ column: "todo", dependencies: [], status: "queued" }, DEFAULT_IR);
await run(store, { dependencies: ["FN-2"] });
expect(row.status).toBeUndefined();
expect(row.status).toBe("needs-replan");
expect(row.log.some((e) => e.action.includes("re-specification"))).toBe(true);
});
it("clears approval evidence when a new dependency supersedes the plan", async () => {
const { store, row } = harness({
column: "todo",
dependencies: [],
status: "awaiting-approval",
approvedPlanFingerprint: "sha256:stale",
awaitingApprovalReason: "plan-review-replan-cap",
}, DEFAULT_IR);
await run(store, { dependencies: ["FN-2"] });
expect(row.status).toBe("needs-replan");
expect(row.approvedPlanFingerprint).toBeUndefined();
expect(row.awaitingApprovalReason).toBeUndefined();
});
it("moves a RENAMED board's hold card to its own intake lane", async () => {
// Here intake and hold ARE different columns, so the move is real — and it goes to `inbox`,
// a column this board actually declares.

View File

@@ -19,3 +19,125 @@ export async function acquireSchemaMutationLocks(tx: AdvisoryLockTransaction): P
await acquireSqliteMigrationStateLock(tx);
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('fusion:schema-applier'))`);
}
import postgres from "postgres";
import { looksLikePoolerUrl } from "./backend-resolver.js";
export class PlanningLifecycleLockTransportError extends Error {
constructor(message: string) {
super(message);
this.name = "PlanningLifecycleLockTransportError";
}
}
/**
* FNXC:PlanningDependencyReseed 2026-08-04-00:43:
* Planning handoff and dependency re-seed cross processes, so their outer lock
* is a dedicated PostgreSQL session rather than the TaskStore's local mutex.
* Never use the runtime pool: a transaction pooler can change sessions between
* lock and unlock and silently defeat session advisory locking.
*/
export async function withPlanningLifecycleAdvisoryLock<T>(
input: {
projectId: string;
taskId: string;
directSessionUrl: string | null;
provenance: "embedded-lifecycle" | "migration-override" | null;
runtimeUrl?: string | null;
migrationUrl?: string | null;
},
callback: () => Promise<T>,
): Promise<T> {
const directUrl = input.directSessionUrl;
if (!directUrl || !input.provenance || looksLikePoolerUrl(directUrl)) {
throw new PlanningLifecycleLockTransportError("Planning lifecycle lock requires a direct PostgreSQL session endpoint");
}
let directDatabase: string;
try {
directDatabase = decodeURIComponent(new URL(directUrl).pathname.replace(/^\//, ""));
} catch {
throw new PlanningLifecycleLockTransportError("Planning lifecycle lock has an invalid direct PostgreSQL session endpoint");
}
if (!directDatabase) {
throw new PlanningLifecycleLockTransportError("Planning lifecycle lock direct endpoint must select a database");
}
const descriptorEndpoint = input.provenance === "embedded-lifecycle"
? input.runtimeUrl
: input.migrationUrl;
if (!descriptorEndpoint || descriptorEndpoint !== directUrl) {
throw new PlanningLifecycleLockTransportError("Planning lifecycle lock endpoint does not match the resolved backend descriptor");
}
const operationalUrl = input.runtimeUrl ?? input.migrationUrl;
if (operationalUrl) {
try {
const operationalDatabase = decodeURIComponent(new URL(operationalUrl).pathname.replace(/^\//, ""));
if (!operationalDatabase || operationalDatabase !== directDatabase) {
throw new PlanningLifecycleLockTransportError("Planning lifecycle lock endpoint selects a different database than the resolved backend");
}
} catch (error) {
if (error instanceof PlanningLifecycleLockTransportError) throw error;
throw new PlanningLifecycleLockTransportError("Resolved backend has an invalid PostgreSQL endpoint");
}
}
const client = postgres(directUrl, { max: 1, prepare: false, onnotice: () => {} });
const key = `fusion:planning-lifecycle:${input.projectId}:${input.taskId}`;
let acquired = false;
try {
/*
FNXC:PlanningDependencyReseed 2026-08-04-00:54:
URL shape is not proof of the connected target. Verify the session-selected
database before locking so an accidental migration endpoint cannot serialize
one database while task writes target another.
*/
const identity = await client<{ database: string; host: string | null; port: number | null; cluster: string | null }[]>`
SELECT current_database() AS database,
inet_server_addr()::text AS host,
inet_server_port() AS port,
current_setting('cluster_name', true) AS cluster
`;
if (identity[0]?.database !== directDatabase) {
throw new PlanningLifecycleLockTransportError("Planning lifecycle lock session selected an unexpected database");
}
/*
FNXC:PlanningDependencyReseed 2026-08-04-01:04:
A database name identifies neither a PostgreSQL server nor an advisory-lock
namespace. Compare the resolved operational connection's server identity
with the dedicated direct session so a migration URL aimed at another
cluster with the same database cannot serialize the wrong task lifecycle.
*/
if (operationalUrl && operationalUrl !== directUrl) {
const operationalClient = postgres(operationalUrl, { max: 1, prepare: false, onnotice: () => {} });
try {
const operationalIdentity = await operationalClient<{ database: string; host: string | null; port: number | null; cluster: string | null }[]>`
SELECT current_database() AS database,
inet_server_addr()::text AS host,
inet_server_port() AS port,
current_setting('cluster_name', true) AS cluster
`;
const expected = operationalIdentity[0];
const actual = identity[0];
if (!expected || !actual
|| expected.database !== actual.database
|| expected.host !== actual.host
|| expected.port !== actual.port
|| (expected.cluster && actual.cluster && expected.cluster !== actual.cluster)) {
throw new PlanningLifecycleLockTransportError("Planning lifecycle lock endpoint targets a different PostgreSQL server than the resolved backend");
}
} catch (error) {
if (error instanceof PlanningLifecycleLockTransportError) throw error;
throw new PlanningLifecycleLockTransportError("Planning lifecycle lock could not verify the resolved backend server identity");
} finally {
await operationalClient.end({ timeout: 5 }).catch(() => undefined);
}
}
await client`SELECT pg_advisory_lock(hashtext(${key}))`;
acquired = true;
return await callback();
} finally {
if (acquired) await client`SELECT pg_advisory_unlock(hashtext(${key}))`.catch(() => undefined);
await client.end({ timeout: 5 }).catch(() => undefined);
}
}

View File

@@ -44,6 +44,9 @@ export interface ResolvedBackend {
readonly runtimeUrl: string | null;
readonly migrationUrl: string | null;
readonly migrationUrlOverridden: boolean;
/** A proven direct endpoint for session-scoped lifecycle advisory locks. */
readonly directSessionUrl?: string | null;
readonly directSessionProvenance?: "embedded-lifecycle" | "migration-override" | null;
}
/**
@@ -99,7 +102,15 @@ export function resolveBackendWithOptions(
? databaseMigrationUrl
: runtimeUrl;
return { mode, runtimeUrl, migrationUrl, migrationUrlOverridden };
// FNXC:PlanningDependencyReseed 2026-08-04-00:43:
// Advisory locks require one backend session. External runtime URLs can be
// transaction poolers, so only an explicit migration endpoint is eligible.
const directSessionUrl = migrationUrlOverridden ? databaseMigrationUrl : null;
return {
mode, runtimeUrl, migrationUrl, migrationUrlOverridden,
directSessionUrl,
directSessionProvenance: directSessionUrl ? "migration-override" : null,
};
}
// ── Pooler detection ─────────────────────────────────────────────────

View File

@@ -55,6 +55,7 @@ import { sql, eq, type SQL } from "drizzle-orm";
import type { PostgresJsDatabase, PostgresJsTransaction } from "drizzle-orm/postgres-js";
import { randomUUID } from "node:crypto";
import type { PostgresConnections } from "./connection.js";
import type { ResolvedBackend } from "./backend-resolver.js";
import * as schema from "./schema/index.js";
import { PROJECT_SCHEMA } from "./schema/_shared.js";
@@ -153,6 +154,8 @@ export interface AsyncDataLayer {
* unaffected).
*/
readonly projectId?: string;
/** Backend descriptor retained for store-owned session advisory locks. */
readonly backend?: ResolvedBackend;
/**
* Run an async callback inside a PostgreSQL transaction. All writes inside
* the callback commit atomically; a thrown error rolls back every write
@@ -235,6 +238,7 @@ export function createAsyncDataLayer(
return {
db,
projectId: options?.projectId,
backend: connections.backend,
async transaction<T>(fn: (tx: DbTransaction) => Promise<T>, options?: TransactionOptions): Promise<T> {
return runInTransaction(db, fn, options);
},

View File

@@ -1438,6 +1438,8 @@ export class EmbeddedPostgresLifecycle {
runtimeUrl: url,
migrationUrl: url,
migrationUrlOverridden: false,
directSessionUrl: url,
directSessionProvenance: "embedded-lifecycle",
};
}
return this.startBounded();
@@ -1631,6 +1633,8 @@ export class EmbeddedPostgresLifecycle {
runtimeUrl,
migrationUrl: runtimeUrl,
migrationUrlOverridden: false,
directSessionUrl: runtimeUrl,
directSessionProvenance: "embedded-lifecycle",
};
}
/*
@@ -1689,6 +1693,8 @@ export class EmbeddedPostgresLifecycle {
runtimeUrl,
migrationUrl: runtimeUrl,
migrationUrlOverridden: false,
directSessionUrl: runtimeUrl,
directSessionProvenance: "embedded-lifecycle",
};
}

View File

@@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS project.unplanned_execution_blocks (
project_id text NOT NULL DEFAULT current_setting('fusion.project_id', true),
task_id text NOT NULL,
episode text NOT NULL,
created_at text NOT NULL,
PRIMARY KEY (project_id, task_id, episode)
);
ALTER TABLE project.unplanned_execution_blocks ENABLE ROW LEVEL SECURITY;
ALTER TABLE project.unplanned_execution_blocks FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS fusion_project_isolation ON project.unplanned_execution_blocks;
CREATE POLICY fusion_project_isolation ON project.unplanned_execution_blocks
USING (current_setting('fusion.project_bypass', true) = 'on' OR project_id = current_setting('fusion.project_id', true))
WITH CHECK (current_setting('fusion.project_bypass', true) = 'on' OR project_id = current_setting('fusion.project_id', true));
DROP TRIGGER IF EXISTS fusion_assign_project_id ON project.unplanned_execution_blocks;
CREATE TRIGGER fusion_assign_project_id BEFORE INSERT OR UPDATE OF project_id ON project.unplanned_execution_blocks
FOR EACH ROW EXECUTE FUNCTION project.fusion_assign_project_id();

View File

@@ -56,7 +56,7 @@ capacity-model table drop that landed while this PR was open.
*/
/* FNXC:CrossProcessDeleteObservation 2026-08-01-11:39: advance the schema ceiling so durable consumer state exists before observers begin polling FN-8684's outbox. */
/* FNXC:MissionValidation 2026-08-01-16:21: advance the schema ceiling before validator admission reads durable content fingerprints. */
export const SCHEMA_BASELINE_VERSION = "0042";
export const SCHEMA_BASELINE_VERSION = "0043";
/** FNXC:SymbolLock 2026-07-20-10:00: upgrades need durable task declarations before admission resolves symbols. */
export const TASK_DECLARED_SYMBOLS_VERSION = "0028";
const INITIAL_SCHEMA_VERSION = "0000";
@@ -183,6 +183,8 @@ export const TASK_LIFECYCLE_OUTBOX_VERSION = "0040";
export const TASK_LIFECYCLE_CONSUMERS_VERSION = "0041";
/** FNXC:MissionValidation 2026-08-01-16:21: durable input fingerprints and budget-block provenance. */
export const VALIDATOR_INPUT_FINGERPRINT_VERSION = "0042";
/** FNXC:PlanningDependencyReseed 2026-08-04-02:14: durable per-episode unplanned-dispatch diagnostics. */
export const UNPLANNED_EXECUTION_BLOCK_DEDUPE_VERSION = "0043";
/** SECURITY DEFINER helper that only inserts LEGACY_ADOPTION_DRAINED_MARKER. */
export const LEGACY_ADOPTION_DRAINED_MARKER_FUNCTION = "fusion_mark_legacy_adoption_drained";
@@ -399,6 +401,7 @@ const CREDENTIAL_INSTANCE_SELECTION_MIGRATION_PATH = join(MIGRATIONS_DIR, "0039_
const TASK_LIFECYCLE_OUTBOX_MIGRATION_PATH = join(MIGRATIONS_DIR, "0040_fn_8684_task_lifecycle_outbox.sql");
const TASK_LIFECYCLE_CONSUMERS_MIGRATION_PATH = join(MIGRATIONS_DIR, "0041_fn_8685_task_lifecycle_consumers.sql");
const VALIDATOR_INPUT_FINGERPRINT_MIGRATION_PATH = join(MIGRATIONS_DIR, "0042_fn_8694_validator_input_fingerprint.sql");
const UNPLANNED_EXECUTION_BLOCK_DEDUPE_MIGRATION_PATH = join(MIGRATIONS_DIR, "0043_fn8768_dispatch_dedupe.sql");
/**
* Ensure the migration bookkeeping table exists. Lives in the public schema so
@@ -511,6 +514,7 @@ export async function applySchemaBaseline(
const taskLifecycleOutboxAlreadyApplied = applied.includes(TASK_LIFECYCLE_OUTBOX_VERSION);
const taskLifecycleConsumersAlreadyApplied = applied.includes(TASK_LIFECYCLE_CONSUMERS_VERSION);
const validatorInputFingerprintAlreadyApplied = applied.includes(VALIDATOR_INPUT_FINGERPRINT_VERSION);
const unplannedExecutionBlockDedupeAlreadyApplied = applied.includes(UNPLANNED_EXECUTION_BLOCK_DEDUPE_VERSION);
assertBinaryNotOlderThanDatabase(applied);
let schemaChanged = false;
@@ -1090,6 +1094,13 @@ export async function applySchemaBaseline(
schemaChanged = true;
}
if (!unplannedExecutionBlockDedupeAlreadyApplied) {
const migrationSql = await readFile(UNPLANNED_EXECUTION_BLOCK_DEDUPE_MIGRATION_PATH, "utf8");
await tx.execute(sql.raw(migrationSql));
await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${UNPLANNED_EXECUTION_BLOCK_DEDUPE_VERSION}) ON CONFLICT (version) DO NOTHING`);
schemaChanged = true;
}
return { applied: schemaChanged, pluginHooksRun: pluginHooks.length };
});
}

View File

@@ -972,6 +972,21 @@ export const workflowWorkItems = projectSchema.table("workflow_work_items", {
.where(sql`${t.kind} = 'task' AND ${t.state} IN ('runnable', 'running', 'held', 'retrying')`),
]);
/*
FNXC:PlanningDependencyReseed 2026-08-04-02:10:
An automated release refusal must be visible once per persisted episode across
scheduler processes. The composite key keeps same task IDs in separate projects
independent and atomically claims the accompanying task-log append.
*/
export const unplannedExecutionBlocks = projectSchema.table("unplanned_execution_blocks", {
projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`),
taskId: text("task_id").notNull(),
episode: text("episode").notNull(),
createdAt: text("created_at").notNull(),
}, (t) => [
primaryKey({ columns: [t.projectId, t.taskId, t.episode] }),
]);
export const workflowRunBranches = projectSchema.table("workflow_run_branches", {
projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`),
taskId: text("task_id").notNull(),

View File

@@ -60,6 +60,7 @@ import { GlobalSettingsStore } from "./config/global-settings.js";
import { Database } from "./db/db.js";
import { ArchiveDatabase } from "./db/archive-db.js";
import type { AsyncDataLayer, DbTransaction } from "./postgres/data-layer.js";
import { withPlanningLifecycleAdvisoryLock } from "./postgres/advisory-locks.js";
import { MissionStore } from "./missions/mission-store.js";
import { AsyncMissionStore } from "./async-stores/async-mission-store.js";
import { AsyncIdeationStore } from "./async-stores/async-ideation-store.js";
@@ -93,7 +94,7 @@ import { pgRowToTaskRow as pgRowToTaskRowExternal, rowToTask as rowToTaskExterna
import { moveTaskImpl, moveTaskIfImpl, handoffToReviewImpl, moveTaskInternalImpl, type MoveTaskIfResult } from "./task-store/moves.js";
import { recordGoalCitationsImpl, insertTaskWithFtsRecoveryImpl2, assertTaskIdAvailableImpl, atomicWriteTaskJsonImpl2, createTaskWithDistributedReservationImpl, toStoredWorkflowStepImpl, ensureWorkflowStepForTemplateImpl, resolveEnabledWorkflowStepsImpl, setTaskBranchGroupImpl, getTaskColumnsImpl, prepareWorkflowMovePolicyPreflightImpl, updateTaskCustomFieldsImpl, listWorkflowPromptOverridesForProjectImpl, listWorkflowWorkItemsForTaskImpl, listDueWorkflowWorkItemsImpl, rewriteBlockedByResidueDependentsForRemovalImpl, getAllDocumentsImpl, deleteWorkflowStepImpl, toWorkflowDefinitionImpl, materializeDefaultWorkflowStepsImpl, reconcileTaskCustomFieldsForSchemaImpl, getTaskMovedCountsByDayImpl, getGoalStoreImpl, upsertTaskCommitAssociationImpl } from "./task-store/workflow-task-create-ops.js";
import { applyLegacyWorkflowStepOverridesImpl, archiveDbImpl, assertNoDependencyCycleImpl, atomicCreateTaskJsonImpl, buildActiveTaskDependencyLookupImpl, buildArchivedAgentLogFieldsImpl, buildTaskIdIntegrityFallbackReportImpl, createBranchGroupImpl, dbImpl, detectAndCacheTaskIdIntegrityReportImpl, findLiveDependentsImpl, findLiveLineageChildrenImpl, getLegacyWorkflowStepSnapshotImpl, getMalformedTaskMetadataReasonImpl, getMergeQueuedTaskIdsAsyncImpl, insertRunAuditEventRowImpl, insertTaskImpl, invokeTaskCreatedHookImpl, isTaskArchivedAsyncImpl, isTaskArchivedImpl, isTaskIdPresentInArchivedTasksTableAsyncImpl, isTaskIdPresentInArchivedTasksTableImpl, logTaskCreateConflictImpl, maybeResolveTombstonedTaskIdImpl, mergeTaskIdIntegrityReportsImpl, optionalGroupIdSetImpl, patchTaskRowInTransactionImpl, readConfigFastImpl, readConfigImpl, readPromptForArchiveImpl, readTaskFromDbImpl, reconcileDistributedTaskIdStateOnOpenImpl, recordActivityFromListenerImpl, recordDependencyCycleRejectedAuditImpl, refreshTaskIdIntegrityReportImpl, resolveLocalNodeIdForTaskAllocationImpl, runTaskFtsWriteWithRecoveryImpl, scanAndRecordCitationsImpl, taskIdExistsAnywhereImpl, throwSoftDeletedWriteBlockedImpl, toBuiltInWorkflowStepImpl, trackDeferredTaskCreatedWorkImpl, upsertTaskImpl, withConfigLockImpl, withTaskLockImpl, withWorktreeAllocationLockImpl } from "./task-store/task-id-integrity.js";
import { claimNextToolFailureRetryImpl, createTaskVerificationRequestImpl, claimTaskVerificationRequestImpl, finishTaskVerificationRequestImpl, clearNearDuplicateReferencesToFailSoftImpl, clearWorkflowRunStepInstancesAsyncImpl, clearWorkflowRunStepInstancesImpl, computeMovedSettingsTargetWorkflowIdsImpl, ensureBranchGroupForSourceImpl, ensurePrEntityForSourceImpl, findRecentTasksByContentFingerprintImpl, getActiveMergingTaskImpl, getActivePrEntityBySourceImpl, getBranchGroupByBranchNameImpl, getBranchGroupBySourceImpl, getBranchGroupImpl, getBranchProgressByTaskImpl, getMutationsForRunImpl, getPrEntityByNumberImpl, getPrEntityImpl, getPrThreadStateImpl, getTasksByAssignedAgentImpl, getWorkflowPromptOverridesAsyncImpl, getWorkflowSettingValuesAsyncImpl, getWorkflowSettingValuesImpl, getWorkflowSettingsProjectIdImpl, getWorkflowWorkItemImpl, insertCompletionHandoffWorkflowWorkAuditImpl, listActivePrEntitiesImpl, listBranchGroupsImpl, listPrThreadStatesImpl, listTasksByBranchGroupImpl, listWorkflowSettingValuesForProjectImpl, loadWorkflowRunBranchesImpl, loadWorkflowRunStepInstancesAsyncImpl, loadWorkflowRunStepInstancesImpl, markToolFailureRetryExhaustedAuditImpl, mergeCustomFieldPatchImpl, normalizeMergeRequestStateImpl, normalizeWorkflowWorkItemKindImpl, normalizeWorkflowWorkItemStateImpl, parseWorkflowPromptOverrideJsonImpl, recordPrThreadOutcomeImpl, resetAllStepsToPendingImpl, resetPromptCheckboxesImpl, resolveWorkflowMoveActorImpl, resolveWorkflowSettingDeclarationsImpl, saveWorkflowRunStepInstanceAsyncImpl, saveWorkflowRunStepInstanceImpl, transitionMergeRequestStateImpl, transitionWorkflowWorkItemSyncImpl, updateTaskImpl, updateWorkflowPromptOverridesImpl, upsertMergeRequestRecordImpl, workflowStateForMergeRequestStateImpl } from "./task-store/branch-and-pr-entities.js";
import { claimNextToolFailureRetryImpl, createTaskVerificationRequestImpl, claimTaskVerificationRequestImpl, finishTaskVerificationRequestImpl, clearNearDuplicateReferencesToFailSoftImpl, clearWorkflowRunStepInstancesAsyncImpl, clearWorkflowRunStepInstancesImpl, computeMovedSettingsTargetWorkflowIdsImpl, ensureBranchGroupForSourceImpl, ensurePrEntityForSourceImpl, findRecentTasksByContentFingerprintImpl, getActiveMergingTaskImpl, getActivePrEntityBySourceImpl, getBranchGroupByBranchNameImpl, getBranchGroupBySourceImpl, getBranchGroupImpl, getBranchProgressByTaskImpl, getMutationsForRunImpl, getPrEntityByNumberImpl, getPrEntityImpl, getPrThreadStateImpl, getTasksByAssignedAgentImpl, getWorkflowPromptOverridesAsyncImpl, getWorkflowSettingValuesAsyncImpl, getWorkflowSettingValuesImpl, getWorkflowSettingsProjectIdImpl, getWorkflowWorkItemImpl, insertCompletionHandoffWorkflowWorkAuditImpl, listActivePrEntitiesImpl, listBranchGroupsImpl, listPrThreadStatesImpl, listTasksByBranchGroupImpl, listWorkflowSettingValuesForProjectImpl, loadWorkflowRunBranchesImpl, hasWorkflowRunStepInstancesForTaskImpl, loadWorkflowRunStepInstancesAsyncImpl, loadWorkflowRunStepInstancesImpl, markToolFailureRetryExhaustedAuditImpl, mergeCustomFieldPatchImpl, normalizeMergeRequestStateImpl, normalizeWorkflowWorkItemKindImpl, normalizeWorkflowWorkItemStateImpl, parseWorkflowPromptOverrideJsonImpl, recordPrThreadOutcomeImpl, resetAllStepsToPendingImpl, resetPromptCheckboxesImpl, resolveWorkflowMoveActorImpl, resolveWorkflowSettingDeclarationsImpl, saveWorkflowRunStepInstanceAsyncImpl, saveWorkflowRunStepInstanceImpl, transitionMergeRequestStateImpl, transitionWorkflowWorkItemSyncImpl, updateTaskImpl, updateWorkflowPromptOverridesImpl, upsertMergeRequestRecordImpl, workflowStateForMergeRequestStateImpl } from "./task-store/branch-and-pr-entities.js";
import { addPrInfoImpl, addSteeringCommentImpl, archiveAllDoneImpl, cleanupStaleMergeQueueRowsImpl, clearCompletionHandoffAcceptedMarkerImpl, clearDoneTransientFieldsImpl, clearStaleExecutionStartBranchReferencesImpl, deleteTaskCommentImpl, deleteTaskDocumentImpl, emitUsageEventImpl, enqueueMergeQueueImpl, getAgentLogCountImpl, getAgentLogsImpl, getArtifactImpl, getArtifactsImpl, getAttachmentImpl, getCompletionHandoffAcceptedMarkerImpl, getTaskDocumentImpl, getTaskDocumentRevisionsImpl, getTaskDocumentsImpl, insertArtifactRowImpl, linkGithubIssueImpl, listWorkflowWorkItemsForTaskSyncImpl, moveToDoneImpl, parseDependenciesFromPromptImpl, parseFileScopeFromPromptImpl, parseStepsFromPromptImpl, peekMergeQueueHeadImpl, peekMergeQueueImpl, readPreArchiveColumnFromTaskFileImpl, recordPluginActivationImpl, recordRunAuditEventBackendImpl, removePrInfoByNumberImpl, resolvePrimaryPrInfoImpl, resolveUnarchiveTargetColumnImpl, rewriteLineageChildrenForRemovalImpl, runGitCommandImpl, stopWatchingImpl, syncAgentTaskLinkOnReassignmentImpl, updateArtifactImpl, updateGithubTrackingImpl, updatePrInfoByNumberImpl, updateTaskCommentImpl, upsertPrInfoByNumberImpl, writeArtifactDataImpl } from "./task-store/task-artifacts-ops.js";
import { approveCliAutonomyImpl, approveWorkflowCliCommandImpl, cleanupOrphanedMaterializedStepsImpl, consumePluginGateVerdictsImpl, getAgentLogsByTimeRangeImpl, getDatabaseHealthImpl, getDistributedTaskIdAllocatorImpl, getExperimentSessionStoreImpl, getInReviewDurationEventsImpl, getMissionStoreImpl, getIdeationStoreImpl, getPluginStoreImpl, getSecretsStoreImpl, getSettingsSyncImpl, getTaskMergedTaskIdsImpl, getTaskWorkflowSelectionImpl, getImportTranslationImpl, recordImportTranslationImpl, pruneImportTranslationsImpl, type ImportTranslationCacheKey, type ImportTranslationCacheEntry, getVerificationCacheHitImpl, getWorkflowDefinitionImpl, healthCheckImpl, importLegacyAgentLogsOnceImpl, insertWorkflowDefinitionSyncImpl, isCliAutonomyApprovedImpl, isPluginInstalledImpl, isWorkflowCliCommandApprovedImpl, listWorkflowDefinitionsImpl, materializeExplicitWorkflowStepsImpl, materializeWorkflowStepsImpl, migrateActiveArchivedTasksToArchiveDbImpl, migrateLegacyArchiveEntriesToArchiveDbImpl, nextWorkflowDefinitionIdImpl, occupantsByColumnForWorkflowImpl, parseWorkflowLayoutImpl, pruneAgentLogFilesImpl, purgeTaskWorkflowSelectionRowsImpl, readAllWorkflowDefinitionsImpl, readRawProjectSettingsImpl, recordPluginGateVerdictImpl, recordVerificationCachePassImpl, removeMaterializedSelectionImpl, resolvePluginWorkflowStepImpl, resolveTaskWorkflowIrSyncImpl, revokeCliAutonomyImpl, selectTaskWorkflowAndReconcileImpl, writeTaskWorkflowSelectionImpl, getTaskWorkflowSelectionAsyncImpl, } from "./task-store/workflow-definitions.js";
import { getTaskCommitAssociationsByLineageIdImpl, replaceLegacyTaskCommitAssociationsImpl } from "./task-store/task-commit-associations.js";
@@ -101,7 +102,7 @@ import { findRecentTasksBySourceParentTaskIdImpl } from "./task-store/branch-and
import { addTaskCommentImpl, applyBuiltInPromptOverridesAsyncImpl, applyBuiltInPromptOverridesSyncImpl, areAllDependenciesDoneImpl, artifactStoredNameImpl, assertWorkflowIrTraitsValidImpl, clearActivityLogImpl, clearTaskWorkflowSelectionImpl, deleteTaskByIdImpl, getDefaultWorkflowIdImpl, resolveOriginWorkflowOverrideIdImpl, type TaskOriginWorkflowKind, getInsightStoreImpl, getMergeQueuedTaskIdsImpl, getMergeRequestRecordImpl, getMergeRequestRecordAsyncImpl, getResearchStoreImpl, getTaskIdFromDirImpl, getTodoStoreImpl, getWorkflowWorkItemByIdentityImpl, hasActiveTaskImpl, invalidateConfigCacheAfterMigrationImpl, isTaskIdConflictErrorImpl, listLegacyAutoMergeStampCandidatesImpl, readTaskRowFromDbImpl, recordBranchGroupMemberLandedImpl, refreshDatabaseHealthAsyncImpl, refreshDatabaseHealthImpl, resolveTaskCustomFieldDefsSyncImpl, resolveWorkflowBypassGuardsImpl, serializeConfigForDiskImpl, setPluginWorkflowStepTemplatesImpl, shouldSkipWorkflowMovePoliciesImpl, suppressWatcherImpl, upsertTaskWithFtsRecoveryImpl } from "./task-store/task-store-helpers.js";
import { getTaskSelectClauseImpl2, createTaskPersistSerializationContextImpl, getTaskPersistValuesImpl, getTaskPatchDescriptorsImpl, normalizeTaskFromDiskImpl, writeTaskJsonFileImpl, rowToPrEntityImpl, generatePrEntityIdImpl, readTaskForMoveImpl, rowToMergeQueueEntryImpl, rowToMergeRequestRecordImpl, rowToCompletionHandoffMarkerImpl, rowToWorkflowWorkItemImpl, rowToRunAuditEventImpl } from "./task-store/task-row-mappers.js";
import { getTaskSelectClauseWithActivityLogLimitImpl, getChangedTaskColumnsImpl, getSoftDeletedWriteConflictImpl, readTaskJsonImpl, writeConfigImpl, _maybeAutoArchiveSameAgentDuplicateBackendImpl, updateBranchGroupImpl, updatePrEntityImpl, listTasksForGithubTrackingReconcileImpl, listTasksForGitlabTrackingReconcileImpl, renewCheckoutLeaseImpl, updateTaskAtomicImpl, resolveTaskWedgeNotificationEpisodeImpl, getWorkflowPromptOverridesImpl, updateWorkflowSettingValuesImpl, rollbackConfigurationImpl, cancelActiveWorkflowWorkItemsForTaskImpl, setCompletionHandoffAcceptedMarkerImpl, reconcileLegacyAutoMergeStampsImpl, recoverExpiredMergeQueueLeasesImpl, rewriteDependentsForRemovalImpl, cleanupBranchForTaskImpl, addAttachmentImpl, deleteAttachmentImpl, registerArtifactImpl, updatePrInfoImpl, unlinkGithubIssueImpl, cleanupArchivedTasksImpl, generatePromptFromArchiveEntryImpl, listWorkflowOccupantTaskIdsImpl, listApprovedCliAutonomyAdaptersImpl, closeImpl, getActivityLogImpl } from "./task-store/task-mutation-ops.js";
import { getOrCreateForProjectImpl, listGoalCitationsImpl, atomicWriteTaskJsonWithAuditImpl, duplicateTaskImpl, listStrandedRefinementsImpl, tryClaimCheckoutImpl, evaluateWorkflowMovePoliciesImpl, recordRunAuditEventImpl, getRunAuditEventsImpl, dequeueMergeQueueOnColumnExitImpl, updateIssueInfoImpl, listWorkflowStepsImpl, getWorkflowStepImpl, createWorkflowDefinitionImpl, countActiveInCapacitySlotSyncImpl, countActiveInCapacitySlotAsyncImpl, generateSpecifiedPromptImpl, recordActivityImpl, getEvalStoreImpl } from "./task-store/project-store-ops.js";
import { getOrCreateForProjectImpl, listGoalCitationsImpl, atomicWriteTaskJsonWithAuditImpl, type PlanningDependencyInvalidation, duplicateTaskImpl, listStrandedRefinementsImpl, tryClaimCheckoutImpl, evaluateWorkflowMovePoliciesImpl, recordRunAuditEventImpl, getRunAuditEventsImpl, dequeueMergeQueueOnColumnExitImpl, updateIssueInfoImpl, listWorkflowStepsImpl, getWorkflowStepImpl, createWorkflowDefinitionImpl, countActiveInCapacitySlotSyncImpl, countActiveInCapacitySlotAsyncImpl, generateSpecifiedPromptImpl, recordActivityImpl, getEvalStoreImpl } from "./task-store/project-store-ops.js";
import { markLegacyAutoMergeStampsOnceImpl, appendAgentLogImpl, importLegacyAgentLogsImpl, cleanupNoOpTaskMovedActivityRowsOnceImpl, backfillCommitAssociationDiffStatsImpl } from "./task-store/workflow-integrity.js";
import { saveWorkflowRunBranchImpl, clearNearDuplicateReferencesToImpl, selectNextTaskForAgentImpl, pauseTaskImpl, clearLinkedAgentTaskIdsImpl, listArtifactsImpl, rehomeOccupantImpl, type RehomeOccupantResult } from "./task-store/branch-group-ops.js";
import { taskToArchiveEntryImpl, deleteTaskBackendImpl, deleteTaskIfBackendImpl, archiveTaskBackendImpl, unarchiveTaskImpl, restoreFromArchiveImpl, listArchivedTasksImpl } from "./task-store/archive-lifecycle-2.js";
@@ -114,7 +115,7 @@ import { queryRunAuditEvents } from "./task-store/async/async-audit.js";
import { isValidMergeRequestTransitionImpl, releaseMergeQueueLeaseImpl, collectMergeDetailsImpl, applyPrMergedTransitionImpl } from "./task-store/merge-queue-ops-2.js";
import { upsertWorkflowWorkItemImpl, replaceActiveTaskWorkflowContinuationImpl, seedStrandedPlanReviewContinuationImpl, transitionWorkflowWorkItemImpl, acquireWorkflowWorkItemLeaseImpl } from "./task-store/workflow-workitems-ops-2.js";
import { getSettingsImpl, getSettingsFastImpl, getSettingsByScopeImpl, getSettingsByScopeFastImpl } from "./task-store/settings-ops-2.js";
import { runPluginColumnTransitionHooksImpl, logEntryImpl } from "./task-store/audit-ops.js";
import { runPluginColumnTransitionHooksImpl, checkAndRecordUnplannedExecutionBlockImpl, logEntryImpl } from "./task-store/audit-ops.js";
import { clearWorkflowRunBranchesImpl, projectMergeRequestToWorkflowWorkItemImpl, createCompletionHandoffWorkflowWorkImpl } from "./task-store/workflow-workitems-ops.js";
import { flushAgentLogBufferImpl, appendAgentLogBatchImpl } from "./task-store/agent-logs.js";
import { refineTaskImpl, updateTaskDependenciesImpl } from "./task-store/update-task-deps.js";
@@ -210,6 +211,9 @@ export const storeLog = createLogger("task-store");
export const coreLog = createLogger("core");
export const TASK_BRANCH_CONTEXT_METADATA_KEY = "fusionBranchContext";
/** Per-project/task FIFO tails used only where an in-memory store has no PostgreSQL session lock. */
const planningLifecycleLocks = new Map<string, Promise<void>>();
export type TaskDependencyMutation =
| { operation: "add"; dependency: string }
| { operation: "remove"; dependency: string }
@@ -813,6 +817,40 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
public withTaskLock<T>(id: string, fn: () => Promise<T>): Promise<T> {
return withTaskLockImpl(this, id, fn);
}
/**
* FNXC:PlanningDependencyReseed 2026-08-04-00:30:
* Dependency re-specification must serialize ahead of its task lock so the
* planner finalization and mutation cannot both publish contradictory
* handoffs. PostgreSQL persistence already makes the inner writes atomic;
* this FIFO is the compatibility fallback used by in-process/test stores.
*/
public async withPlanningLifecycleLock<T>(id: string, fn: () => Promise<T>): Promise<T> {
const backend = this.asyncLayer?.backend;
if (this.asyncLayer && backend) {
return await withPlanningLifecycleAdvisoryLock({
projectId: this.asyncLayer.projectId ?? this.rootDir,
taskId: id,
directSessionUrl: backend.directSessionUrl ?? null,
provenance: backend.directSessionProvenance ?? null,
runtimeUrl: backend.runtimeUrl,
migrationUrl: backend.migrationUrl,
}, fn);
}
const projectId = this.asyncLayer?.projectId ?? this.rootDir;
const key = `${projectId}:${id}`;
const prior = planningLifecycleLocks.get(key) ?? Promise.resolve();
let release!: () => void;
const current = new Promise<void>((resolve) => { release = resolve; });
planningLifecycleLocks.set(key, current);
await prior;
try {
return await fn();
} finally {
release();
if (planningLifecycleLocks.get(key) === current) planningLifecycleLocks.delete(key);
}
}
public getTaskIdFromDir(dir: string): string {
return getTaskIdFromDirImpl(this, dir);
}
@@ -899,8 +937,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
public async atomicWriteTaskJson(dir: string, task: Task): Promise<void> {
return atomicWriteTaskJsonImpl2(this, dir, task);
}
public async atomicWriteTaskJsonWithAudit( dir: string, task: Task, auditInput?: RunAuditEventInput, ): Promise<void> {
return atomicWriteTaskJsonWithAuditImpl(this, dir, task, auditInput);
public async atomicWriteTaskJsonWithAudit( dir: string, task: Task, auditInput?: RunAuditEventInput, planningInvalidation?: PlanningDependencyInvalidation, ): Promise<void> {
return atomicWriteTaskJsonWithAuditImpl(this, dir, task, auditInput, planningInvalidation);
}
/*
FNXC:TaskTiming 2026-07-15-00:00:
@@ -1244,6 +1282,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return saveWorkflowRunStepInstanceImpl(this, state);
}
/** True when any graph run has persisted step-instance evidence for this task. */
async hasWorkflowRunStepInstancesForTask(taskId: string): Promise<boolean> {
return hasWorkflowRunStepInstancesForTaskImpl(this, taskId);
}
/** Load persisted step-instance run-state for a run (crash-resume; KTD-6). */
async loadWorkflowRunStepInstances( taskId: string, runId: string, ): Promise<import("./types.js").WorkflowRunStepInstance[]> {
return loadWorkflowRunStepInstancesImpl(this, taskId, runId);
@@ -1666,6 +1709,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async startStep( id: string, stepIndex: number, options?: { source?: "graph" }, ): Promise<import("./task-store/merge-queue-ops.js").StepStartResult> {
return startStepImpl(this, id, stepIndex, options);
}
async checkAndRecordUnplannedExecutionBlock(id: string, episode: string): Promise<boolean> {
return checkAndRecordUnplannedExecutionBlockImpl(this, id, episode);
}
async logEntry(id: string, action: string, outcome?: string, runContext?: RunMutationContext): Promise<Task> {
return logEntryImpl(this, id, action, outcome, runContext);
}

View File

@@ -6,6 +6,7 @@
* behavior-preserving refactor. Each function receives the TaskStore
* instance as its first parameter and performs byte-identical work.
*/
import { and, eq, isNull } from "drizzle-orm";
import {TaskStore} from "../store.js";
import type { Task, TaskDetail, TaskLogEntry, RunMutationContext } from "../types.js";
import {findWorkflowColumn} from "../plugins/plugin-gate-verdict.js";
@@ -18,6 +19,7 @@ import {__setTaskActivityLogLimitsForTesting, truncateTaskLogOutcome, getTaskAct
import {readTaskRow, updateTaskColumns} from "../task-store/async/async-persistence.js";
import { getLiveTaskColumn } from "./async/async-comments-attachments.js";
import { resolveArchivedLanes } from "../project-lane-vocabulary.js";
import * as schema from "../postgres/schema/index.js";
export async function runPluginColumnTransitionHooksImpl(store: TaskStore, taskId: string, workflowIr: WorkflowIr, fromColumn: string, toColumn: string,): Promise<void> {
const registry = getTraitRegistry();
@@ -114,6 +116,53 @@ export async function runPluginColumnTransitionHooksImpl(store: TaskStore, taskI
}
}
/*
FNXC:PlanningDependencyReseed 2026-08-04-02:10:
Release gates can be evaluated by multiple schedulers. Claim the project/task
episode and append its diagnostic in one transaction so a crash cannot leave a
suppression marker without the operator-visible task-log entry.
*/
export async function checkAndRecordUnplannedExecutionBlockImpl(
store: TaskStore,
id: string,
episode: string,
): Promise<boolean> {
const layer = store.asyncLayer!;
const projectId = layer.projectId ?? "__legacy_unscoped__";
const entry: TaskLogEntry = {
timestamp: new Date().toISOString(),
action: "Execution dispatch refused — task is still unplanned",
outcome: "Waiting for planning lifecycle handoff or Plan Review continuation",
};
const recorded = await layer.transactionImmediate(async (tx) => {
const claimed = await tx
.insert(schema.project.unplannedExecutionBlocks)
.values({ projectId, taskId: id, episode, createdAt: entry.timestamp })
.onConflictDoNothing()
.returning({ taskId: schema.project.unplannedExecutionBlocks.taskId });
if (claimed.length === 0) return false;
const rows = await tx.select({ log: schema.project.tasks.log, deletedAt: schema.project.tasks.deletedAt })
.from(schema.project.tasks)
.where(and(
eq(schema.project.tasks.projectId, projectId),
eq(schema.project.tasks.id, id),
isNull(schema.project.tasks.deletedAt),
));
const task = rows[0];
if (!task) throw new Error(`Task ${id} not found or archived while recording unplanned dispatch refusal`);
const log = Array.isArray(task.log) ? [...task.log as TaskLogEntry[]] : [];
log.push(entry);
const limit = getTaskActivityLogEntryLimit();
if (log.length > limit) log.splice(0, log.length - limit);
await tx.update(schema.project.tasks)
.set({ log, updatedAt: entry.timestamp })
.where(and(eq(schema.project.tasks.projectId, projectId), eq(schema.project.tasks.id, id)));
return true;
});
return recorded;
}
export async function logEntryImpl(store: TaskStore, id: string, action: string, outcome?: string, runContext?: RunMutationContext): Promise<Task> {
return store.withTaskLock(id, async () => {
const entry: TaskLogEntry = {

View File

@@ -225,6 +225,25 @@ export async function saveWorkflowRunStepInstanceImpl(store: TaskStore,
return saveWorkflowRunStepInstanceAsyncImpl(store, state);
}
/*
FNXC:PlanningDependencyReseed 2026-08-04-02:10:
Legacy planning recovery must treat any persisted graph step instance as graph-owned
handoff evidence. The query intentionally spans run IDs because an abandoned run
may use a workflow-derived ID that recovery cannot safely reconstruct.
*/
export async function hasWorkflowRunStepInstancesForTaskImpl(
store: TaskStore,
taskId: string,
): Promise<boolean> {
const layer = store.asyncLayer!;
const rows = await layer.db
.select({ taskId: schema.project.workflowRunStepInstances.taskId })
.from(schema.project.workflowRunStepInstances)
.where(eq(schema.project.workflowRunStepInstances.taskId, taskId))
.limit(1);
return rows.length > 0;
}
export async function loadWorkflowRunStepInstancesImpl(store: TaskStore,
taskId: string,
runId: string,
@@ -597,6 +616,17 @@ export async function resetPromptCheckboxesImpl(store: TaskStore, dir: string):
}
export async function updateTaskImpl(store: TaskStore,
id: string,
updates: Parameters<TaskStore["updateTask"]>[1],
runContext?: RunMutationContext,
): Promise<Task> {
if (updates.dependencies !== undefined) {
return store.withPlanningLifecycleLock(id, () => updateTaskWithTaskLockImpl(store, id, updates, runContext));
}
return updateTaskWithTaskLockImpl(store, id, updates, runContext);
}
async function updateTaskWithTaskLockImpl(store: TaskStore,
id: string,
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("../types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("../types.js").TaskStep[]; customFields?: Record<string, unknown>; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("../types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("../types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("../types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; executeRequeueLoopCount?: number | null; graphResumeRetryCount?: number | null; consecutiveToolFailureRetryCount?: number | null; executorEscalationAttempted?: boolean | null; toolFailureDetectorLogCursor?: number | null; toolFailureRetryExhaustedAuditEmitted?: boolean | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; executeRequeueLoopSignature?: string | null; postReviewFixCount?: number | null; planReviewReplanCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; bulkCompletionRefusalAt?: string | null; workflowIrPin?: string | null; workflowIrPinNodeId?: string | null; workflowIrPinColumnId?: string | null; legacyAdoptedAt?: string | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; credentialInstanceId?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorCredentialInstanceId?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningCredentialInstanceId?: string | null; planningModelId?: string | null; mergerModelProvider?: string | null; mergerCredentialInstanceId?: string | null; mergerModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; mergerThinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("../types.js").TaskReview | null; reviewState?: import("../types.js").TaskReviewState | null; workflowStepResults?: import("../types.js").WorkflowStepResult[] | null; mergeDetails?: import("../types.js").MergeDetails | null; sourceIssue?: import("../types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record<string, unknown> | null; githubTracking?: import("../types.js").TaskGithubTracking | null; tokenUsage?: import("../types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("../types.js").WorkflowTransitionNotificationMarker | undefined; sessionAdvisorEnabled?: boolean | null }, runContext?: RunMutationContext,
): Promise<Task> {

View File

@@ -13,7 +13,7 @@ import { resolveCapacityPoolId } from "../workflows/workflow-capacity.js";
import {resolveWorkflowIntakeFacts} from "./task-creation.js";
import {TransitionRejectionError} from "./errors.js";
import * as schema from "../postgres/schema/index.js";
import {and, eq, isNull, ne, or, sql} from "drizzle-orm";
import {and, eq, inArray, isNull, ne, or, sql} from "drizzle-orm";
import {mkdir, writeFile} from "node:fs/promises";
import {join} from "node:path";
import type {Task, ColumnId, CheckoutClaimPrecondition, ActivityLogEntry, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, GoalCitation, GoalCitationFilter} from "../types.js";
@@ -104,7 +104,17 @@ export async function listGoalCitationsImpl(store: TaskStore, filter: GoalCitati
return listGoalCitationsAsync(layer.db, filter);
}
export async function atomicWriteTaskJsonWithAuditImpl(store: TaskStore, dir: string, task: Task, auditInput?: RunAuditEventInput,): Promise<void> {
export type PlanningDependencyInvalidation = {
/** Dependencies observed before this mutation; protects against stale snapshots. */
expectedCurrentDependencies: readonly string[];
};
function sameDependencySet(actual: readonly string[], expected: readonly string[]): boolean {
return actual.length === expected.length
&& actual.every((dependency, index) => dependency === expected[index]);
}
export async function atomicWriteTaskJsonWithAuditImpl(store: TaskStore, dir: string, task: Task, auditInput?: RunAuditEventInput, planningInvalidation?: PlanningDependencyInvalidation,): Promise<void> {
const id = store.getTaskIdFromDir(dir);
// FNXC:RuntimeTaskOrchestrationAsync 2026-06-24-14:10:
// Backend mode: upsert the task row + audit event in one async Drizzle
@@ -138,6 +148,12 @@ export async function atomicWriteTaskJsonWithAuditImpl(store: TaskStore, dir: st
*/
if (row) {
const existing = store.pgRowToTaskRow(row);
if (planningInvalidation && !sameDependencySet(
store.rowToTask(existing).dependencies ?? [],
planningInvalidation.expectedCurrentDependencies,
)) {
throw new Error(`Planning dependency invalidation conflict for ${id}: dependencies changed before the lifecycle mutation committed`);
}
preserveResolvedTaskWedgeEpisode(existing, task);
const changedColumns = store.getChangedTaskColumns(existing, task);
if (changedColumns.size > 0) {
@@ -164,19 +180,42 @@ export async function atomicWriteTaskJsonWithAuditImpl(store: TaskStore, dir: st
const context = store.createTaskPersistSerializationContext(task);
await upsertTaskRowInTransaction(tx, task as unknown as Record<string, unknown>, context, layer.projectId);
}
if (planningInvalidation) {
/*
FNXC:PlanningDependencyReseed 2026-08-04-01:57:
A dependency mutation supersedes a plan handoff only while its graph
continuation is still pending. Persist the new dependencies, replan
fence, and retirement together under the workflow serialization lock:
a worker that wins the lock first becomes `running` and is preserved;
a pending item cannot be claimed between this query and cancellation.
*/
const workItemConditions = [
eq(schema.project.workflowWorkItems.taskId, id),
eq(schema.project.workflowWorkItems.kind, "task"),
inArray(schema.project.workflowWorkItems.state, ["runnable", "held", "retrying"]),
];
if (layer.projectId) workItemConditions.push(eq(schema.project.workflowWorkItems.projectId, layer.projectId));
await tx.update(schema.project.workflowWorkItems).set({
state: "cancelled",
leaseOwner: null,
leaseExpiresAt: null,
lastError: "cancelled-by-planning-dependency-reseed",
updatedAt: task.updatedAt,
}).where(and(...workItemConditions));
}
if (auditInput) {
await recordRunAuditEventWithinTransaction(tx, auditInput);
}
return undefined;
};
/*
FNXC:WorkflowSerialization 2026-07-26-15:30:
FN-8592 makes the persisted plan-review passed edge share the exact
per-task advisory transaction lock used by conditional continuation
seeding. This prevents a pass from committing between that repair's
locked predicate reads and its insert.
FNXC:PlanningDependencyReseed 2026-08-04-01:57:
Planning invalidation shares this task-scoped transaction lock with
claim/continuation writers. The former plan-review-result condition is
retained because that persisted graph edge has the same serialization
requirement.
*/
if (task.workflowStepResults?.some(isPlanReviewSatisfied)) {
if (planningInvalidation || task.workflowStepResults?.some(isPlanReviewSatisfied)) {
return withTaskWorkflowSerialization(tx, layer.projectId, id, persist);
}
return persist();

View File

@@ -166,8 +166,10 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat
let movedToTriage = false;
let respecifyFromColumn: string | undefined;
let respecifyMoveLanes: TaskMoveLanes | undefined;
let previousDependencies: string[] | undefined;
if (updates.dependencies !== undefined) {
const oldDeps = new Set((task.dependencies ?? []).map((dependency) => dependency.trim()).filter(Boolean));
previousDependencies = (task.dependencies ?? []).map((dependency) => dependency.trim()).filter(Boolean);
const oldDeps = new Set(previousDependencies);
const normalizedDependencies = updates.dependencies.map((dependency) => dependency.trim()).filter(Boolean);
const hasNewDeps = normalizedDependencies.some((d) => !oldDeps.has(d));
task.dependencies = normalizedDependencies;
@@ -218,7 +220,24 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat
task.column = intakeLane;
task.columnMovedAt = new Date().toISOString();
}
task.status = undefined;
/*
FNXC:PlanningDependencyReseed 2026-08-04-00:30:
A new dependency invalidates a plan that is still in the hold lane. The
prior null reset raced an in-flight planner after it wrote PROMPT.md but
before its final handoff, leaving a real specification that neither
planning discovery nor release could claim. `needs-replan` is the
graph-owned durable re-entry signal: it preserves prompt authority and
makes the interrupted planner's stale finalizer harmless.
*/
task.status = "needs-replan";
/*
FNXC:PlanningDependencyReseed 2026-08-04-00:54:
Both dependency mutation APIs invalidate the same pre-execution plan
handoff. Clearing manual-approval evidence here prevents a newly added
blocker from inheriting approval for the superseded specification.
*/
task.approvedPlanFingerprint = undefined;
task.awaitingApprovalReason = undefined;
const depLogEntry: TaskLogEntry = {
timestamp: new Date().toISOString(),
action: relocating
@@ -929,6 +948,9 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat
}
// When runContext is provided, record audit event atomically with task mutation
const planningInvalidation = movedToTriage
? {expectedCurrentDependencies: previousDependencies ?? []}
: undefined;
if (runContext) {
await store.atomicWriteTaskJsonWithAudit(dir, task, {
taskId: task.id,
@@ -941,9 +963,9 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat
updatedFields: Object.keys(updates).filter((k) => (updates as Record<string, unknown>)[k] !== undefined),
...(titleNormalized ? { titleNormalized: true } : {}),
},
});
}, planningInvalidation);
} else {
await store.atomicWriteTaskJson(dir, task);
await store.atomicWriteTaskJsonWithAudit(dir, task, undefined, planningInvalidation);
}
/*

View File

@@ -218,6 +218,10 @@ export async function refineTaskImpl(store: TaskStore, id: string, feedback: str
}
export async function updateTaskDependenciesImpl(store: TaskStore, id: string, mutation: TaskDependencyMutation, runContext?: RunMutationContext,): Promise<Task> {
return store.withPlanningLifecycleLock(id, () => updateTaskDependenciesWithTaskLockImpl(store, id, mutation, runContext));
}
async function updateTaskDependenciesWithTaskLockImpl(store: TaskStore, id: string, mutation: TaskDependencyMutation, runContext?: RunMutationContext,): Promise<Task> {
return store.withTaskLock(id, async () => {
const dir = store.taskDir(id);
const task = await store.readTaskJson(dir);
@@ -449,10 +453,28 @@ export async function updateTaskDependenciesImpl(store: TaskStore, id: string, m
const holdColumn = respecifyLifecycle?.hold ?? "todo";
const intakeColumn = respecifyLifecycle?.intake;
const respecifyFromColumn = task.column;
/*
FNXC:PlanningDependencyReseed 2026-08-04-00:43:
A new dependency invalidates every pre-execution approval artifact even
when merged intake/hold lanes make this a same-column transition. Leaving
the old fingerprint would let an unchanged prompt bypass manual approval.
*/
if (hasNewDependencies && task.column === holdColumn) {
task.status = "needs-replan";
task.approvedPlanFingerprint = undefined;
task.awaitingApprovalReason = undefined;
}
if (hasNewDependencies && task.column === holdColumn && intakeColumn !== undefined) {
task.column = intakeColumn;
movedToTriage = true;
task.status = undefined;
/*
FNXC:PlanningDependencyReseed 2026-08-04-00:30:
Dependency mutation shares updateTask's re-specification invariant. A
real new dependency must leave a durable `needs-replan` claim, never a
clean status that can strand a persisted plan between planning and the
pre-release graph gate.
*/
task.status = "needs-replan";
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-02:05 (PR #2720 review — greptile):
`columnMovedAt` IS THE MOVE TIMESTAMP, so it may only move when the column does. On the default
@@ -495,7 +517,11 @@ export async function updateTaskDependenciesImpl(store: TaskStore, id: string, m
blockedBy: task.blockedBy ?? null,
},
};
await store.atomicWriteTaskJsonWithAudit(dir, task, auditEvent);
await store.atomicWriteTaskJsonWithAudit(dir, task, auditEvent,
hasNewDependencies && task.status === "needs-replan"
? {expectedCurrentDependencies: normalizedCurrent}
: undefined,
);
// FNXC:BoardConsistency 2026-06-21-08:31: updateTaskDependencies' todo→triage re-spec move can also carry title/blocker changes, and leaving taskCache on the pre-move row made watch/SSE/board consumers surface one task ID in two columns (FN-6851/FN-6812). Sync the cache after the authoritative write like sibling mutation paths.
if (store.isWatching) store.taskCache.set(id, { ...task });
/*

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