Files
fusion/packages/engine/src/hold-release.ts
gsxdsm a53330786e fix(FN-6082): restore workflow overlap blocking
Workflow-column hold releases now participate in active file-scope leases before moving tasks into in-progress, preserving overlapBlockedBy card badges and scheduler blocking.

Fusion-Task-Id: FN-6082
2026-06-09 08:02:56 -07:00

553 lines
23 KiB
TypeScript

/**
* Hold/release sweep — the generalized scheduler (U6, KTD-10, R3 behavior half).
*
* Flag-ON, the scheduler's poll becomes a *hold/release sweep*: for each
* workflow in use by live tasks, it finds cards resting at `hold`-trait columns
* and evaluates their release condition:
*
* - `manual` — released ONLY by an explicit {@link promoteHeldTask}
* call (U9's promote endpoint / CLI). The sweep never
* auto-releases a manual hold.
* - `external-event` — released ONLY by {@link releaseHeldTaskByEvent} (a
* webhook/API release, same shape as manual + an event
* tag).
* - `timer` — released when the injected clock passes the hold's
* deadline (`columnMovedAt + durationMs`, or an explicit
* `deadlineAt`). Fake-timer friendly (FN-5048): the clock
* is injected, never `Date.now()` baked in.
* - `capacity` — released when a downstream capacity (`wip`) column has a
* free slot (same counting rules as the in-txn check).
* - `dependency` — released when the card's dependencies are satisfied
* (KTD-5: dependency task's column has the `complete`
* trait flag in ITS resolved workflow; FN-5719 dual-accept
* also honors the legacy completion signal, logging an
* audit-diff when the two disagree).
*
* Eligible cards move via `store.moveTask(..., { moveSource: "scheduler" })`.
* A scheduler move bypasses trait guards (it is substrate-driven) but the in-txn
* capacity check is NOT a guard — it still runs (KTD-10), so two holds racing
* into one slot serialize: exactly one commits, the other rejects with
* `capacity-exhausted` and retries next sweep.
*
* Reservation ordering (KTD-10): for releases into a processing (capacity)
* column, the sweep reserves worktree + semaphore slots BEFORE issuing the move
* and releases the reservation if the move rejects on capacity — a card is never
* moved into a column it cannot actually start in, and a semaphore-exhausted
* interleaving leaves the card held with no commit.
*/
import {
isWorkflowColumnsEnabled,
resolveColumnCapacity,
resolveColumnFlags,
resolveColumnAdjacency,
DEFAULT_WORKFLOW_POOL_ID,
TransitionRejectionError,
resolveWorkflowIrForTask,
type TaskStore,
type Task,
type WorkflowIr,
type WorkflowIrV2,
type WorkflowIrColumn,
} from "@fusion/core";
import { schedulerLog } from "./logger.js";
/** A reservation handle returned by {@link HoldReleaseDeps.reserveSlot}. The
* sweep calls `release()` if the subsequent move rejects on capacity. */
export interface SlotReservation {
release(): void;
}
/** Injected dependencies so the sweep stays unit-testable with fake timers and
* without real worktree/session allocation. */
export interface HoldReleaseDeps {
/** Monotonic clock (ms). Inject a fake-timer-driven clock in tests; production
* passes `() => Date.now()`. */
now: () => number;
/**
* Reserve a worktree + semaphore slot for a card about to be released into a
* processing column (KTD-10 reservation-first). Returns `null` when no slot
* could be reserved (e.g. semaphore exhausted) — the sweep then leaves the
* card held without issuing a move. Returns a {@link SlotReservation} whose
* `release()` the sweep calls if the move rejects on capacity.
*
* Optional: when absent, releases into processing columns proceed without a
* reservation (the in-txn capacity check still arbitrates), which is the
* default-workflow legacy parity path where the scheduler dispatch loop owns
* worktree allocation via `allocateWorktree`.
*/
reserveSlot?: (task: Task, targetColumn: string) => SlotReservation | null | Promise<SlotReservation | null>;
/** Allocate a worktree path for a release into a processing column (passed
* through to `moveTask`'s `allocateWorktree`). */
allocateWorktree?: (task: Task, reservedNames: Set<string>) => string | null;
}
/** Outcome of one sweep pass (for tests + observability). */
export interface HoldReleaseResult {
released: string[];
/** taskId → reason it stayed held this pass. */
held: Array<{ taskId: string; reason: string }>;
}
// ── Workflow IR resolution (read-only) ────────────────────────────────────────
// The selection → builtin/custom → default rule lives in @fusion/core's
// resolveWorkflowIrForTask (GitHub #1402); the optional per-sweep irCache Map is
// threaded straight through.
function effectiveWorkflowId(store: TaskStore, taskId: string): string {
try {
return store.getTaskWorkflowSelection(taskId)?.workflowId ?? DEFAULT_WORKFLOW_POOL_ID;
} catch {
return DEFAULT_WORKFLOW_POOL_ID;
}
}
function findColumn(ir: WorkflowIr, columnId: string): WorkflowIrColumn | undefined {
if (ir.version !== "v2") return undefined;
return (ir as WorkflowIrV2).columns.find((c) => c.id === columnId);
}
/** The hold trait config on a column, if any. */
function resolveHoldConfig(column: WorkflowIrColumn): Record<string, unknown> | undefined {
const flags = resolveColumnFlags(column);
if (!flags.hold) return undefined;
const ct = column.traits.find((t) => t.trait === "hold");
return ct?.config ?? {};
}
/** True when the card currently rests at a hold column. */
function isHeldTask(ir: WorkflowIr, task: Task): boolean {
const column = findColumn(ir, task.column);
if (!column) return false;
return resolveColumnFlags(column).hold === true;
}
/**
* Resolve the release target column for a held card.
*
* For `capacity` holds, the target is the nearest downstream column (by the
* workflow's column adjacency, breadth-first from the hold column) that carries
* a capacity (`wip`) trait — for the default workflow this is `in-progress`.
* For other release kinds the target is the first adjacency neighbor that is not
* the hold column itself (the forward step out of the hold).
*/
function resolveReleaseTarget(ir: WorkflowIr, fromColumn: string, preferCapacity: boolean): string | undefined {
const v2 = ir as WorkflowIrV2;
const orderedIds = Array.isArray(v2.columns) ? v2.columns.map((c) => c.id) : [];
const fromIdx = orderedIds.indexOf(fromColumn);
const adjacency = resolveColumnAdjacency(ir);
const neighbors = adjacency.get(fromColumn) ?? [];
if (preferCapacity) {
// Walk FORWARD in declared order for the nearest capacity-bearing column;
// the hold releases downstream, never backward.
for (let i = fromIdx + 1; i < orderedIds.length; i++) {
const col = findColumn(ir, orderedIds[i]);
if (col && resolveColumnFlags(col).countsTowardWip && neighbors.includes(orderedIds[i])) {
return orderedIds[i];
}
}
// No directly-adjacent capacity column: fall back to the nearest forward
// capacity column reachable via adjacency BFS.
const seen = new Set<string>([fromColumn]);
const queue = [...neighbors];
while (queue.length > 0) {
const candidate = queue.shift()!;
if (seen.has(candidate)) continue;
seen.add(candidate);
const col = findColumn(ir, candidate);
if (col && resolveColumnFlags(col).countsTowardWip) return candidate;
for (const next of adjacency.get(candidate) ?? []) {
if (!seen.has(next)) queue.push(next);
}
}
}
// Forward neighbor (declared-order next) if it is adjacent; else any neighbor
// that is forward in declared order; else the first neighbor.
const forwardId = fromIdx >= 0 ? orderedIds[fromIdx + 1] : undefined;
if (forwardId && neighbors.includes(forwardId)) return forwardId;
const forwardNeighbor = neighbors.find((n) => orderedIds.indexOf(n) > fromIdx);
if (forwardNeighbor) return forwardNeighbor;
return neighbors.find((n) => n !== fromColumn);
}
// ── Dependency satisfaction (KTD-5 + FN-5719 dual-accept) ─────────────────────
/** Legacy completion signal: dependency's column is a terminal/handoff column. */
function legacyDependencySatisfied(dep: Task): boolean {
return dep.column === "done" || dep.column === "in-review" || dep.column === "archived";
}
/**
* KTD-5 dependency satisfaction: the dependency task's current column has the
* `complete` trait flag in ITS resolved workflow. Dual-accept (FN-5719): the
* legacy completion signal (done/in-review/archived column, or an accepted
* completion-handoff marker) is also honored; when the two disagree an
* audit-diff event is logged.
*/
async function dependencySatisfied(store: TaskStore, dep: Task): Promise<boolean> {
const ir = await resolveWorkflowIrForTask(store, dep.id);
const column = findColumn(ir, dep.column);
const completeFlag = column ? resolveColumnFlags(column).complete === true : false;
let markerAccepted = false;
try {
markerAccepted = store.getCompletionHandoffAcceptedMarker(dep.id) !== null;
} catch {
markerAccepted = false;
}
const legacy = legacyDependencySatisfied(dep) || markerAccepted;
if (completeFlag !== legacy) {
try {
void store.recordRunAuditEvent?.({
taskId: dep.id,
agentId: "scheduler",
runId: `hold-release:${dep.id}`,
domain: "database",
mutationType: "merge:dependency-parity-diff",
target: dep.id,
metadata: {
depId: dep.id,
completeFlagResult: completeFlag,
legacyResult: legacy,
source: "hold-release.dependency",
},
});
} catch {
// Audit is best-effort.
}
}
// Dual-accept: satisfied if EITHER signal says so (the dual-accept window
// closes at graduation per U12; until then both are accepted).
return completeFlag || legacy;
}
async function allDependenciesSatisfied(store: TaskStore, task: Task, allTasks: Task[]): Promise<boolean> {
for (const depId of task.dependencies ?? []) {
const dep = allTasks.find((t) => t.id === depId);
if (!dep) continue; // missing dep does not block (matches scheduler posture)
if (!(await dependencySatisfied(store, dep))) return false;
}
return true;
}
// ── Timer release ─────────────────────────────────────────────────────────────
/** Resolve the timer deadline (ms epoch) for a timer hold, or `undefined` if not
* resolvable. Supports an explicit `deadlineAt` (ISO or ms) or a relative
* `durationMs`/`timerMs` measured from `columnMovedAt`. */
function resolveTimerDeadline(holdConfig: Record<string, unknown>, task: Task): number | undefined {
const deadlineAt = holdConfig.deadlineAt;
if (typeof deadlineAt === "number" && Number.isFinite(deadlineAt)) return deadlineAt;
if (typeof deadlineAt === "string") {
const parsed = Date.parse(deadlineAt);
if (Number.isFinite(parsed)) return parsed;
}
const duration =
(typeof holdConfig.durationMs === "number" ? holdConfig.durationMs : undefined) ??
(typeof holdConfig.timerMs === "number" ? holdConfig.timerMs : undefined);
if (typeof duration === "number" && Number.isFinite(duration)) {
const base = Date.parse(task.columnMovedAt ?? task.createdAt);
if (Number.isFinite(base)) return base + duration;
}
return undefined;
}
// ── Capacity availability (same counting rule as the in-txn check) ────────────
/**
* Count cards occupying the (workflow, column) capacity slot from a task
* snapshot, mirroring the store's in-txn count: cards in the column now, plus
* (when countPending) cards mid-`transitionPending` targeting it, scoped to the
* SAME effective workflow. This is the sweep's *pre-check* — the authoritative
* arbitration is still the in-txn check, which rejects a losing racer.
*/
function countCapacitySlot(
allTasks: Task[],
// Pre-built taskId → effective workflowId map (one pass per sweep) so this
// counting loop avoids a per-task `effectiveWorkflowId` DB call.
effectiveWorkflowIdByTask: Map<string, string>,
targetColumn: string,
workflowId: string,
countPending: boolean,
): number {
let count = 0;
for (const t of allTasks) {
if ((effectiveWorkflowIdByTask.get(t.id) ?? DEFAULT_WORKFLOW_POOL_ID) !== workflowId) continue;
if (t.column === targetColumn) {
count += 1;
continue;
}
if (!countPending) continue;
const tp = (t as Task & { transitionPending?: { toColumn?: string } | null }).transitionPending;
if (tp && typeof tp === "object" && tp.toColumn === targetColumn) count += 1;
}
return count;
}
// ── The sweep ─────────────────────────────────────────────────────────────────
/**
* Run one hold/release sweep pass. No-op (returns empty) when the workflowColumns
* flag is OFF — flag-OFF scheduler behavior is byte-identical (the legacy
* pull-from-todo loop is untouched).
*/
export async function runHoldReleaseSweep(
store: TaskStore,
deps: HoldReleaseDeps,
): Promise<HoldReleaseResult> {
const result: HoldReleaseResult = { released: [], held: [] };
const settings = await store.getSettings();
if (!isWorkflowColumnsEnabled(settings)) return result;
const allTasks = await store.listTasks({ includeArchived: false });
// Per-sweep caches. `allTasks` is a snapshot-stable read within a sweep, so we
// resolve each workflow's IR at most once (irCache) and pre-build the
// taskId → effective-workflowId map a single time rather than per-task DB
// calls inside the capacity counting loop. The authoritative in-txn capacity
// check is unaffected — this only trims the sweep pre-check cost.
const irCache = new Map<string, WorkflowIr>();
const effectiveWorkflowIdByTask = new Map<string, string>();
for (const t of allTasks) {
effectiveWorkflowIdByTask.set(t.id, effectiveWorkflowId(store, t.id));
}
for (const task of allTasks) {
// Skip paused / recovery-backoff tasks exactly as the legacy scheduler does.
if (task.paused || task.userPaused) {
continue;
}
if (task.nextRecoveryAt && Date.parse(task.nextRecoveryAt) > deps.now()) {
continue;
}
const ir = await resolveWorkflowIrForTask(store, task.id, irCache);
if (!isHeldTask(ir, task)) continue;
const column = findColumn(ir, task.column);
const holdConfig = column ? resolveHoldConfig(column) : undefined;
if (!column || !holdConfig) continue;
const release = typeof holdConfig.release === "string" ? holdConfig.release : "manual";
// manual / external-event are NEVER auto-released by the sweep.
if (release === "manual" || release === "external-event") {
result.held.push({ taskId: task.id, reason: `${release}-only` });
continue;
}
let shouldRelease = false;
if (release === "timer") {
const deadline = resolveTimerDeadline(holdConfig, task);
shouldRelease = deadline !== undefined && deps.now() >= deadline;
if (!shouldRelease) {
result.held.push({ taskId: task.id, reason: "timer-not-elapsed" });
continue;
}
} else if (release === "dependency") {
shouldRelease = await allDependenciesSatisfied(store, task, allTasks);
if (!shouldRelease) {
result.held.push({ taskId: task.id, reason: "deps-unsatisfied" });
continue;
}
} else if (release === "capacity") {
// Capacity holds release into the nearest downstream capacity column when a
// slot is free (pre-check); the in-txn check is the authority.
const target = resolveReleaseTarget(ir, task.column, true);
if (!target) {
result.held.push({ taskId: task.id, reason: "no-downstream-capacity-column" });
continue;
}
const capacity = resolveColumnCapacity(ir, target, settings);
if (capacity.hasCapacity && Number.isFinite(capacity.limit)) {
const workflowId = effectiveWorkflowIdByTask.get(task.id) ?? DEFAULT_WORKFLOW_POOL_ID;
const occupants = countCapacitySlot(allTasks, effectiveWorkflowIdByTask, target, workflowId, capacity.countPending);
if (occupants >= capacity.limit) {
result.held.push({ taskId: task.id, reason: "downstream-full" });
continue;
}
}
shouldRelease = true;
}
if (!shouldRelease) continue;
const target = resolveReleaseTarget(ir, task.column, release === "capacity");
if (!target) {
result.held.push({ taskId: task.id, reason: "no-release-target" });
continue;
}
const released = await issueRelease(store, deps, task, target, ir);
if (released) {
result.released.push(task.id);
} else {
result.held.push({ taskId: task.id, reason: "move-rejected-or-no-slot" });
}
}
return result;
}
/**
* Issue a single release move (`moveSource: "scheduler"`). For releases into a
* processing (capacity) column the reservation-first ordering (KTD-10) reserves
* worktree + semaphore before the move and releases the reservation if the move
* rejects on capacity. Returns true on a committed move, false otherwise (the
* card stays held).
*/
async function issueRelease(
store: TaskStore,
deps: HoldReleaseDeps,
task: Task,
target: string,
ir: WorkflowIr,
): Promise<boolean> {
const targetColumn = findColumn(ir, target);
const targetIsProcessing = targetColumn ? resolveColumnFlags(targetColumn).countsTowardWip === true : false;
let reservation: SlotReservation | null = null;
if (targetIsProcessing && deps.reserveSlot) {
reservation = await deps.reserveSlot(task, target);
if (!reservation) {
// Semaphore/worktree exhausted — reservation-first means no move at all.
schedulerLog.log(`Hold release for ${task.id} deferred — no reservable slot for ${target}`);
return false;
}
}
// A concurrent sweep (or explicit promote) can win the move for this same card
// while we hold a reservation. The store serializes the move under a per-task
// lock and resolves a redundant same-column move to a silent no-op: it returns
// the card already at the target WITHOUT re-allocating a slot or emitting a
// `task:moved`. A snapshot/pre-read can't tell winner from loser (both reads
// race ahead of either commit on the per-task lock). Instead we attribute the
// transition by OBJECT IDENTITY: a real move emits `task:moved` with the very
// Task object it then returns, whereas a no-op returns a freshly-read object
// and emits nothing. So the call whose `moveTask` result IS the emitted task is
// the real mover; any other call that reserved performed a redundant no-op and
// must release the slot it grabbed (FN-1415).
const movedTaskObjects = new Set<object>();
const onMoved = (data: { task: object; to: string }): void => {
if (data.to === target) movedTaskObjects.add(data.task);
};
store.on("task:moved", onMoved);
try {
const result = await store.moveTask(task.id, target, {
moveSource: "scheduler",
allocateWorktree:
targetIsProcessing && deps.allocateWorktree
? (reservedNames) => deps.allocateWorktree!(task, reservedNames)
: undefined,
});
if (reservation && !movedTaskObjects.has(result)) {
// Same-column no-op: a racing sweep already moved this card to the target.
reservation.release();
schedulerLog.log(`Hold release for ${task.id} skipped — already at ${target} (racing sweep won)`);
return false;
}
return true;
} catch (error) {
if (error instanceof TransitionRejectionError && error.rejection.code === "capacity-exhausted") {
// Lost the in-txn race for the slot — release the reservation, stay held.
reservation?.release();
schedulerLog.log(`Hold release for ${task.id} rejected on capacity for ${target} — staying held`);
return false;
}
// Any other failure: release the reservation and let the card stay held.
reservation?.release();
schedulerLog.warn(
`Hold release for ${task.id} into ${target} failed: ${error instanceof Error ? error.message : String(error)}`,
);
return false;
} finally {
store.off("task:moved", onMoved);
}
}
// ── Explicit (manual / external-event) releases ───────────────────────────────
/**
* Manually promote a held card out of its hold column (U9's promote endpoint /
* CLI calls this). Releases regardless of the hold's release kind — a manual
* promote is the explicit operator action the `manual` release kind waits for,
* and it is also accepted for other kinds as an operator override. The move
* still serializes through the in-txn capacity check (KTD-10): a promote into a
* full column rejects with `capacity-exhausted`, surfaced to the caller.
*/
export async function promoteHeldTask(
store: TaskStore,
taskId: string,
deps: Pick<HoldReleaseDeps, "reserveSlot" | "allocateWorktree"> = {},
): Promise<{ released: boolean; toColumn?: string; rejection?: string }> {
const task = await store.getTask(taskId);
if (!task) return { released: false, rejection: "task-not-found" };
const ir = await resolveWorkflowIrForTask(store, taskId);
if (!isHeldTask(ir, task)) {
return { released: false, rejection: "not-held" };
}
const target = resolveReleaseTarget(ir, task.column, true);
if (!target) return { released: false, rejection: "no-release-target" };
const released = await issueRelease(
store,
{ now: () => Date.now(), reserveSlot: deps.reserveSlot, allocateWorktree: deps.allocateWorktree },
task,
target,
ir,
);
return released ? { released: true, toColumn: target } : { released: false, rejection: "capacity-exhausted-or-no-slot" };
}
/**
* Release a held card on an external event (webhook/API). Same shape as
* {@link promoteHeldTask} plus an `eventTag` recorded in the audit; only acts on
* `external-event` holds (a no-op otherwise so a stray webhook can't release a
* manual/timer/capacity hold).
*/
export async function releaseHeldTaskByEvent(
store: TaskStore,
taskId: string,
eventTag: string,
deps: Pick<HoldReleaseDeps, "reserveSlot" | "allocateWorktree"> = {},
): Promise<{ released: boolean; toColumn?: string; rejection?: string }> {
const task = await store.getTask(taskId);
if (!task) return { released: false, rejection: "task-not-found" };
const ir = await resolveWorkflowIrForTask(store, taskId);
const column = findColumn(ir, task.column);
const holdConfig = column ? resolveHoldConfig(column) : undefined;
if (!column || !holdConfig || holdConfig.release !== "external-event") {
return { released: false, rejection: "not-external-event-hold" };
}
try {
void store.recordRunAuditEvent?.({
taskId,
agentId: "scheduler",
runId: `hold-release:event:${taskId}`,
domain: "database",
mutationType: "task:hold-release-event",
target: taskId,
metadata: { eventTag, fromColumn: task.column },
});
} catch {
// best-effort
}
const target = resolveReleaseTarget(ir, task.column, true);
if (!target) return { released: false, rejection: "no-release-target" };
const released = await issueRelease(
store,
{ now: () => Date.now(), reserveSlot: deps.reserveSlot, allocateWorktree: deps.allocateWorktree },
task,
target,
ir,
);
return released ? { released: true, toColumn: target } : { released: false, rejection: "capacity-exhausted-or-no-slot" };
}