Files
fusion/packages/core/src/postgres/data-layer.ts
gsxdsm bb17baaacf 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>
2026-08-03 19:31:03 -07:00

458 lines
21 KiB
TypeScript

/**
* Async data-layer foundation (U4) — replaces the synchronous DatabaseSync adapter.
*
* FNXC:AsyncDataLayer 2026-06-24-09:00:
* The synchronous `DatabaseSync` surface (`db.prepare(sql).get/run/all`,
* `db.transaction(fn)`, `db.transactionImmediate(fn)`) is replaced by an async
* Drizzle-backed connection. This module defines the stable data-layer
* interface (the "AsyncDataLayer") that plugin stores and the decomposed
* task-store modules consume, and provides the core CRUD/transaction
* primitives they depend on.
*
* Why this module exists:
* R6 — the sync DatabaseSync data-access surface is replaced with an async
* data layer; no blocking/synchronous bridge to PostgreSQL remains
* (VAL-DATA-001). Every PostgreSQL client is async, so every data call site
* must be awaited. Store methods are already `async`, so the boundary exists;
* this module is the inner layer they call into.
*
* What this module provides (the foundation; U12-U15 migrate the actual stores):
* 1. `AsyncDataLayer` — the stable interface plugin stores consume. It exposes
* the runtime Drizzle instance, a `transaction()` primitive, and
* `transactionImmediate()` for write-heavy paths.
* 2. `transactionImmediate(async (tx) => ...)` — the async equivalent of the
* SQLite `BEGIN IMMEDIATE` path. In SQLite this acquired the RESERVED lock
* before user code ran so writers fail/retry before the callback executes.
* PostgreSQL uses MVCC (no BEGIN IMMEDIATE), so the closest equivalent for
* write-heavy paths is a transaction with READ WRITE access mode. All
* writes inside the callback commit atomically; a thrown error rolls back
* every write including audit rows (VAL-DATA-002, VAL-DATA-003).
* 3. `recordRunAuditEventWithinTransaction(tx, input)` — the run-audit-event
* insertion that runs *inside* a shared transaction so the audit row
* commits or rolls back atomically with the mutation it accompanies
* (the run-audit-event-within-transaction behavior).
* 4. The `getDatabase()` accessor contract changes to return this async-capable
* connection rather than the synchronous `Database` (U15 converts the
* direct-`prepare()` consumers that relied on the sync shape).
*
* Transaction isolation (VAL-DATA-004):
* Concurrent transactions do not observe each other's uncommitted writes.
* PostgreSQL's default `READ COMMITTED` isolation already guarantees this —
* a transaction never sees another transaction's uncommitted rows.
* `transactionImmediate()` defaults to `READ COMMITTED` (matching the SQLite
* behavioral contract: SQLite's default is also a read-committed-equivalent
* under WAL). Callers needing stricter guarantees can pass an isolation level.
*
* Concurrency model change:
* SQLite used WAL multi-process-over-one-file with BEGIN IMMEDIATE for
* write serialization. PostgreSQL uses a server process with MVCC, which
* structurally removes single-writer contention. The atomicity contract
* (multi-statement mutations commit/rollback together) is preserved by the
* Drizzle transaction callback wrapper.
*/
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";
/**
* FNXC:AsyncDataLayer 2026-06-24-09:00:
* The schema-aware Drizzle instance type. U3 defined the schema-as-code
* table objects; the runtime Drizzle instance is constructed schema-less at
* the connection layer (connection.ts wraps postgres.js without a schema
* binding so the same connection serves the schema-applier and the data
* layer). The `DrizzleDb` type therefore mirrors that schema-less shape.
*
* Callers reference tables via the `schema.project.<table>` namespace
* (imported from `./schema/index.js`) and pass them to the query builders.
* This keeps the data-layer foundation decoupled from the full schema type
* (which would require `ExtractTablesWithRelations` plumbing) while still
* giving compile-time table references.
*/
export type DrizzleDb = PostgresJsDatabase<Record<string, never>>;
/**
* A Drizzle transaction handle passed to a `transaction()` / `transactionImmediate()`
* callback. It supports the same query builders as the top-level `DrizzleDb`
* (select/insert/update/delete/execute) so code inside a transaction is written
* identically to code outside one.
*
* Schema-less (matching `DrizzleDb`) so the foundation does not force every
* caller through `ExtractTablesWithRelations` plumbing. Callers reference
* tables via the `schema.project.<table>` namespace.
*/
export type DbTransaction = PostgresJsTransaction<Record<string, never>, Record<string, never>>;
/**
* Transaction configuration. Maps the SQLite transaction modes onto PostgreSQL.
*
* FNXC:AsyncDataLayer 2026-06-24-09:05:
* - `immediate` (the default for `transactionImmediate()`) maps to a PostgreSQL
* transaction with `READ WRITE` access mode. There is no direct BEGIN
* IMMEDIATE in PostgreSQL; MVCC provides atomicity and the access mode
* signals write intent.
* - `isolationLevel` overrides the default (`READ COMMITTED`). Use
* `SERIALIZABLE` only when the write path genuinely requires it — most
* paths do not, and SERIALIZABLE introduces retryable serialization
* failures that callers must handle.
*/
export interface TransactionOptions {
readonly isolationLevel?: "read uncommitted" | "read committed" | "repeatable read" | "serializable";
readonly accessMode?: "read only" | "read write";
readonly deferrable?: boolean;
}
/**
* FNXC:AsyncDataLayer 2026-06-24-09:10:
* The stable data-layer interface plugin stores and the decomposed task-store
* modules consume. This is the contract that survives the SQLite→PostgreSQL
* backend swap: plugins like `fusion-plugin-roadmap` keep working because they
* program against this interface, not the underlying driver (VAL-DATA-016).
*
* Members:
* - `db` — the runtime Drizzle instance for queries outside explicit
* transactions. Schema-typed for compile-time safety.
* - `transaction(fn, options?)` — run `fn` inside a PostgreSQL transaction.
* All writes inside `fn` commit atomically on success; a thrown error
* rolls back every write (VAL-DATA-002, VAL-DATA-003). The callback
* receives a transaction handle with the same query surface as `db`.
* - `transactionImmediate(fn, options?)` — the write-heavy-path variant,
* equivalent to SQLite's `BEGIN IMMEDIATE`. Defaults to READ WRITE access
* mode. Use for multi-statement mutations and the
* run-audit-event-within-transaction pattern.
* - `ping()` — connectivity probe.
* - `close()` — release the connection pool.
*
* Stability contract:
* - Adding methods is backwards-compatible.
* - The signature of `transaction` / `transactionImmediate` is stable; do
* not change the callback shape or the return type without a major-version
* plugin contract bump.
* - The `db` member may gain schema (new tables) but its query-builder
* surface is stable.
*/
export interface AsyncDataLayer {
/** Schema-typed runtime Drizzle instance for non-transactional queries. */
readonly db: DrizzleDb;
/**
* FNXC:MultiProjectIsolation 2026-07-10:
* The central-registry project ID this data layer is bound to, or undefined
* for a project-agnostic layer (single-project / global / analytics reads).
*
* In embedded-PG mode every per-project TaskStore gets its OWN AsyncDataLayer
* instance (constructed by the startup factory per projectId) but they all
* connect to the SAME shared `fusion` database + `project` schema. This field
* lets the task-store helpers scope every read/claim/insert on the flat
* `project.tasks` / `project.archived_tasks` tables to a single project so
* per-project engines cannot poll/claim/execute each other's tasks. When
* undefined the scope filter is a no-op (back-compat: single-project stores,
* cross-project analytics, and the SQLite path — which isolates by file — are
* 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
* including audit rows. Concurrent transactions do not observe each other's
* uncommitted writes (READ COMMITTED default isolation, VAL-DATA-004).
*/
transaction<T>(fn: (tx: DbTransaction) => Promise<T>, options?: TransactionOptions): Promise<T>;
/**
* Write-heavy-path transaction, equivalent to SQLite's `transactionImmediate()`.
* Defaults to READ WRITE access mode. Use for multi-statement mutations where
* the audit row must commit/rollback with the mutation (the
* run-audit-event-within-transaction behavior).
*/
transactionImmediate<T>(fn: (tx: DbTransaction) => Promise<T>, options?: TransactionOptions): Promise<T>;
/** Connectivity probe; rejects if the backend is unreachable. */
ping(): Promise<void>;
/** Release the underlying connection pool. */
close(): Promise<void>;
}
/**
* Input for a run-audit event insertion. Mirrors the sync
* `RunAuditEventInput` but lives here so the data-layer foundation owns the
* transaction-scoped insertion helper.
*
* FNXC:AsyncDataLayer 2026-06-24-09:15:
* The `id` column is the PRIMARY KEY. Inserting a duplicate id fails the
* transaction with a primary-key constraint violation, which is one way to
* trigger the rollback behavior for VAL-DATA-003 (a failing mutation inside
* a transaction rolls back all writes including the audit row). The `domain`
* column is free-text (no CHECK constraint) in both the SQLite and PostgreSQL
* schemas.
*/
export interface RunAuditEventInput {
readonly timestamp?: string;
readonly taskId?: string;
readonly agentId: string;
readonly runId: string;
readonly domain: string;
readonly mutationType: string;
readonly target: string;
readonly metadata?: Record<string, unknown> | null;
}
/** A persisted run-audit event row. */
export interface RunAuditEvent {
readonly id: string;
readonly timestamp: string;
readonly taskId: string | null;
readonly agentId: string;
readonly runId: string;
readonly domain: string;
readonly mutationType: string;
readonly target: string;
readonly metadata: Record<string, unknown> | null;
}
/**
* Construct the stable `AsyncDataLayer` from a `PostgresConnections` set.
*
* The data layer wraps the runtime Drizzle instance and exposes the
* transaction primitives. The migration Drizzle instance is held by the
* connections object for schema work (the applier) but is not part of the
* data-layer contract plugin stores consume.
*
* @param connections The resolved PostgreSQL connection set (runtime + migration).
* @param options Optional binding: `projectId` scopes task-table reads/writes
* to a single project (embedded-PG multi-project isolation, FNXC:MultiProjectIsolation).
*/
export function createAsyncDataLayer(
connections: PostgresConnections,
options?: { projectId?: string },
): AsyncDataLayer {
// The runtime Drizzle instance is schema-less at the connection layer
// (connection.ts constructs it without a schema binding so it works for
// any caller). We cast to the schema-typed view so callers get
// compile-time table references via `layer.db`.
const db = connections.runtime as unknown as DrizzleDb;
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);
},
async transactionImmediate<T>(fn: (tx: DbTransaction) => Promise<T>, options?: TransactionOptions): Promise<T> {
return runInTransaction(db, fn, {
accessMode: "read write",
...options,
});
},
async ping(): Promise<void> {
await connections.ping();
},
async close(): Promise<void> {
await connections.close();
},
};
}
/**
* Internal: run `fn` inside a Drizzle transaction with the given options.
*
* Drizzle's `db.transaction(callback, config)` issues `BEGIN` (with the
* configured isolation/access mode), runs the callback, and commits on normal
* return or rolls back on a thrown error. This is the atomicity primitive
* that preserves the SQLite `transactionImmediate()` contract (VAL-DATA-002,
* VAL-DATA-003).
*
* The config object maps directly onto PostgreSQL's SET TRANSACTION
* TRANSACTION ISOLATION LEVEL / ACCESS MODE / DEFERRABLE clauses. When no
* options are set, `undefined` is passed so Drizzle uses a plain `BEGIN`
* (passing an empty object makes Drizzle emit a malformed `SET TRANSACTION `
* with no clauses, so we omit the config entirely in the no-options case).
*/
async function runInTransaction<T>(
db: DrizzleDb,
fn: (tx: DbTransaction) => Promise<T>,
options?: TransactionOptions,
): Promise<T> {
const config: {
isolationLevel?: TransactionOptions["isolationLevel"];
accessMode?: TransactionOptions["accessMode"];
deferrable?: boolean;
} = {};
if (options?.isolationLevel) config.isolationLevel = options.isolationLevel;
if (options?.accessMode) config.accessMode = options.accessMode;
if (typeof options?.deferrable === "boolean") config.deferrable = options.deferrable;
// Drizzle's transaction callback receives a typed transaction handle.
// The cast bridges the schema-less runtime instance to the schema-typed
// transaction surface callers program against.
const hasConfig =
config.isolationLevel !== undefined ||
config.accessMode !== undefined ||
config.deferrable !== undefined;
return db.transaction(
async (tx) => fn(tx as unknown as DbTransaction),
hasConfig ? config : undefined,
);
}
/**
* FNXC:AsyncDataLayer 2026-06-24-09:20:
* Insert a run-audit event row *inside* the given transaction handle.
*
* This is the run-audit-event-within-transaction behavior: the audit row is
* written using the same transaction handle as the mutation it accompanies,
* so it commits or rolls back atomically. Callers pass the `tx` they received
* from `transactionImmediate(async (tx) => ...)`.
*
* If the insert fails (e.g. a CHECK-constraint violation on `domain`), the
* error propagates and Drizzle rolls back the entire transaction — including
* any prior writes in the same callback. This is the rollback coverage that
* VAL-DATA-003 requires.
*
* @param tx The transaction handle from `transaction()` / `transactionImmediate()`.
* @param input The audit event input.
* @returns The persisted event (with generated id/timestamp if not provided).
*/
export async function recordRunAuditEventWithinTransaction(
tx: DbTransaction,
input: RunAuditEventInput,
): Promise<RunAuditEvent> {
const id = randomUUID();
const timestamp = input.timestamp ?? new Date().toISOString();
const event: RunAuditEvent = {
id,
timestamp,
taskId: input.taskId ?? null,
agentId: input.agentId,
runId: input.runId,
domain: input.domain,
mutationType: input.mutationType,
target: input.target,
metadata: input.metadata ?? null,
};
await tx.insert(schema.project.runAuditEvents).values({
id: event.id,
timestamp: event.timestamp,
taskId: event.taskId,
agentId: event.agentId,
runId: event.runId,
domain: event.domain,
mutationType: event.mutationType,
target: event.target,
metadata: event.metadata,
});
return event;
}
/**
* FNXC:AsyncDataLayer 2026-06-24-09:25:
* Convenience: insert a run-audit event in its own transaction. This mirrors
* the standalone `recordRunAuditEvent` path used when the audit row is not
* paired with a task mutation (e.g. a system bookkeeping event). Most callers
* should use `recordRunAuditEventWithinTransaction(tx, ...)` to pair the
* audit row with the mutation it describes.
*/
export async function recordRunAuditEvent(
layer: AsyncDataLayer,
input: RunAuditEventInput,
): Promise<RunAuditEvent> {
return layer.transactionImmediate(async (tx) =>
recordRunAuditEventWithinTransaction(tx, input),
);
}
/**
* FNXC:AsyncDataLayer 2026-06-24-09:30:
* Helper to build a qualified SQL fragment referencing a project-schema table.
* The project schema is namespaced (`project.<table>`), so raw-SQL call sites
* inside transactions need the schema qualifier. Exposed so the migrating
* stores (U12-U14) can reference tables by their PostgreSQL-qualified name
* without re-deriving the schema constant.
*
* `sql.identifier` takes a single name; a schema-qualified reference needs two
* identifiers joined as raw SQL (`schema"."table`) to avoid search_path
* ambiguity. The tableName is interpolated as a raw identifier (not a
* parameter) so it is treated as a column/table name, not a value.
*/
export function projectTable(tableName: string): SQL {
return sql.raw(`${PROJECT_SCHEMA}."${tableName}"`);
}
/**
* FNXC:MultiProjectIsolation 2026-07-10:
* The per-project scope predicate for the flat `project.tasks` table. Returns
* `project_id = <layer.projectId>` when the data layer is bound to a project,
* or `undefined` (a no-op inside Drizzle's `and(...)`) when it is not.
*
* Every backend-mode task READ / CLAIM / LIST / COUNT path folds this into its
* WHERE clause so a per-project engine only ever sees its own project's rows on
* the shared embedded-PG cluster. `undefined` preserves the pre-isolation
* behavior for project-agnostic layers (single-project stores, cross-project
* analytics) and is safe to pass through `and()` (Drizzle drops undefined
* operands).
*
* NOTE: this operand is passed to `and(...)` so it is only enforced where a
* caller actually threads it in. The load-bearing sites are the row-scan
* readers (readLiveTaskRows, readTaskRow, countLiveTasks), the merge-lease
* candidate scan, and the search scans — see the FNXC:MultiProjectIsolation
* markers in the task-store helpers.
*/
/*
FNXC:ProjectDataIsolation 2026-07-14-18:30:
Migration 0006 forces every project-owned write through fusion_assign_project_id, which rewrites NULL/empty project_id to current_setting('fusion.project_id') or the explicit __legacy_unscoped__ quarantine. Application reads that still filter project_id = '' never see those rows. Normalize empty/missing bindings to the same sentinel used by the trigger so write+read paths stay lockstep for unbound compatibility stores and bound runtimes alike.
*/
export const LEGACY_UNSCOPED_PROJECT_ID = "__legacy_unscoped__";
/**
* FNXC:ProjectDataIsolation 2026-07-14-18:30:
* Resolve the ownership partition written/read for a project-scoped row. Empty, null, and whitespace map to {@link LEGACY_UNSCOPED_PROJECT_ID} so application code matches the 0006 insert trigger instead of silently partitioning writes and reads differently.
*/
export function projectOwnershipPartition(projectId?: string | null): string {
const trimmed = projectId?.trim();
return trimmed || LEGACY_UNSCOPED_PROJECT_ID;
}
export function taskProjectScope(layer: Pick<AsyncDataLayer, "projectId">): SQL | undefined {
return layer.projectId ? eq(schema.project.tasks.projectId, layer.projectId) : undefined;
}
/** As {@link taskProjectScope} but for the `project.archived_tasks` table. */
export function archivedTaskProjectScope(
layer: Pick<AsyncDataLayer, "projectId">,
): SQL | undefined {
return layer.projectId
? eq(schema.project.archivedTasks.projectId, layer.projectId)
: undefined;
}
/**
* FNXC:MultiProjectIsolation 2026-07-15-21:40:
* As {@link taskProjectScope}, but for any project-schema column and a raw project id rather
* than a bound layer. Same contract: a bound id scopes the read, an unbound one (undefined or
* blank) is a no-op that reads across projects.
*
* Exists because helpers that take `projectId: string` are called as `layer.projectId ?? ""`,
* which turns "no scope" into a literal `''` scope. That never matches anything: the
* `fusion_assign_project_id` BEFORE INSERT trigger (migration 0006) rewrites a written `''` to
* the session's `fusion.project_id` or `__legacy_unscoped__`, so reads filtering on `''` look
* for a value the database never stores. Writes normalize; reads must not invent a scope.
*
* Blank is treated as unbound rather than as the legacy sentinel deliberately: an unbound layer
* is documented as a project-agnostic / analytics reader, so it reads everything, exactly as the
* `taskProjectScope` no-op already does. Restricting it to `__legacy_unscoped__` rows would make
* an unscoped analytics read silently partial instead.
*/
export function projectScopeFor(
column: SQL | Parameters<typeof eq>[0],
projectId: string | undefined,
): SQL | undefined {
const scope = projectId?.trim();
return scope ? eq(column, scope) : undefined;
}