FN-7648: gate hold-release on trait-based unplanned-card check, not literal todo column

Blocks planning/intake column cards from entering processing columns regardless of literal column id, so renamed custom intake/planning columns are covered by the same guard as the legacy todo column.

- Add isUnplannedForExecution() in hold-release.ts: true when task.status==="planning", or when the card sits in the legacy todo column or a column carrying the intake trait AND its PROMPT.md still equals the bootstrap stub.
- Route issueRelease() (used by the sweep, promoteHeldTask, and releaseHeldTaskByEvent) through this guard before releasing into any countsTowardWip processing column.
- Update scheduler.ts's reserveSlot guard to use the same trait-based predicate instead of a hardcoded "todo" column id check.
- Add regression tests in hold-release.test.ts and scheduler-workflow-cutover.test.ts covering renamed intake/planning columns.
- Document the invariant in docs/architecture.md and docs/workflow-steps.md.
- Add changeset (patch) describing the fix.

Files changed:
 .changeset/fn-7648-unplanned-intake-cards-never-execute.md |   7 +
 docs/architecture.md                               |   2 +
 docs/workflow-steps.md                             |   2 +
 packages/engine/src/__tests__/hold-release.test.ts | 238 +++++++++++++++++++++
 packages/engine/src/__tests__/scheduler-workflow-cutover.test.ts |  60 +++++-
 packages/engine/src/hold-release.ts                |  60 ++++++
 packages/engine/src/scheduler.ts                   |  26 +--
 7 files changed, 378 insertions(+), 17 deletions(-)

Fusion-Task-Id: FN-7648

Fusion-Task-Lineage: a4b54d30-f86d-4eb9-9cf2-6ac55b6dbe58

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-07 14:40:05 -07:00
parent 009ce26fd0
commit bec8987ce9
7 changed files with 378 additions and 17 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix tasks in planning/intake columns starting execution before they were specified.
category: fix
dev: The hold-release entry guard (reserveSlot in scheduler.ts, issueRelease in hold-release.ts) is now trait-based (isUnplannedForExecution resolves the `intake` trait plus `status:"planning"`/bootstrap-stub PROMPT.md) instead of keyed on the literal `todo` column id, so renamed custom intake columns (e.g. `ideas`, `Inbox`) are covered too. promoteHeldTask/releaseHeldTaskByEvent also route through the same guard (FN-7648).

View File

@@ -1298,6 +1298,8 @@ Tune sensitivity by adjusting the exported constants in `stalled-review-detector
**Graduation.** The flag default-flip is gated by `computeWorkflowColumnsGraduationReport()` (`workflow-parity.ts`; store method `TaskStore.computeWorkflowColumnsGraduationReport`), aggregating: five-invariant dual-observe parity, default-workflow transition parity vs `VALID_TRANSITIONS` (`checkTransitionParity`), and the U6 dual-accept marker/column disagreement count. `ready` is true only when all gates pass over a non-empty observation window. The report is the gate; it does not flip the flag.
**Execution-entry invariant: unplanned/intake-resident cards never enter a processing column (FN-7648).** The hold/release sweep (`runHoldReleaseSweep` in `packages/engine/src/hold-release.ts`, driven from `Scheduler.runHoldReleaseSweepPass`) releases a `hold`-trait card into the nearest downstream `countsTowardWip` column. A card must never be released while it is still unplanned — `status === "planning"` (specified-in-place), its PROMPT.md still equals `buildBootstrapPrompt(...)` (the bootstrap stub), or it is resident in a column carrying the `intake` trait. This is enforced by one shared, trait-based predicate, `isUnplannedForExecution(store, task, ir)` (exported from `hold-release.ts`), evaluated at TWO points so no release surface can bypass it: `issueRelease` in `hold-release.ts` (the choke point for the sweep, `promoteHeldTask`, and `releaseHeldTaskByEvent` — all three funnel through it) refuses to release into a processing target while unplanned, and the scheduler's `reserveSlot` callback (`scheduler.ts`) applies the same guard before reserving a worktree/semaphore slot. The guard is intentionally NOT keyed on the literal `"todo"` column id (kept only as an additional OR-condition for the legacy/merged-planner-capacity `todo` column, which cannot self-report via traits) — it resolves the `intake` trait on the card's OWN resolved workflow IR, so a custom workflow whose intake column is renamed (`ideas`, `Inbox`, the default workflow's renamed "Planning") is covered identically. A held-back card releases any reservation it had not yet been granted (the check runs before `reserveSlot` is called) and is left with `status`/`blockedBy` untouched, so it neither leaks a worktree/semaphore slot nor gets stranded.
### Step inversion: steps as workflow-modelable nodes (`experimentalFeatures.workflowGraphExecutor`)
The columns/traits track moved *board* policy (transitions, capacity, hold, merge orchestration) onto the substrate/policy line. The **step-inversion** track extends the same inversion to *task steps* and to the *task shape itself*, riding the existing `workflowGraphExecutor` flag (orthogonal to `workflowColumns`). With the flag off — and for the default coding workflow always — step policy stays exactly as it is today (the monolithic `execute` seam, PROMPT.md `### Step N:` parsing, in-session `fn_review_step` verdicts, RETHINK git-reset/session-rewind). The default workflow is the byte-identical parity oracle; inversion is opt-in via custom workflows and a built-in stepwise coding workflow.

View File

@@ -106,6 +106,8 @@ Pure-v1 custom graphs remain rollback-compatible by upgrading to trait-less defa
After the workflow-columns cutover, the only automatic queued-work dispatcher is the engine's hold/release sweep. It releases a task from `todo` only when that column resolves as a `hold` column and its hold config uses `release: "capacity"`; it then moves the card to the nearest downstream `wip` column with available capacity.
**Unplanned/intake cards never release into a processing column (FN-7648).** The sweep (and the explicit `promoteHeldTask`/`releaseHeldTaskByEvent` releases) refuses to move a card into a `countsTowardWip` column while it is still unplanned: `status === "planning"`, its PROMPT.md is still the bootstrap stub, or it currently rests in a column carrying the `intake` trait. This is trait-based, not keyed on the literal `"todo"` id, so a custom workflow whose intake column is renamed (`ideas`, `Inbox`, ...) is covered the same way as the default workflow's `triage`/"Planning" column. See `docs/architecture.md` § "Workflow-defined columns & traits" → "Execution-entry invariant" for the implementation detail.
Pure-v1 custom workflow definitions (`start` / `prompt` / `script` / `gate` / `end` nodes with default columns) still parse and upgrade by synthesizing the legacy column ids with empty trait sets. That shape is intentional for FN-5769 / issue #1405 rollback compatibility: it can be downgraded back to v1 for older binaries. The tradeoff is that a pure-v1 custom workflow's `todo` column is not a hold column, so tasks can sit in `todo` instead of dispatching to `in-progress`.
For capacity-dispatched custom workflows, author or migrate the workflow as IR v2 and give `todo` and `in-progress` the canonical dispatch traits (the same minimum used by the built-in coding workflow):

View File

@@ -24,6 +24,7 @@ import {
runHoldReleaseSweep,
promoteHeldTask,
releaseHeldTaskByEvent,
isUnplannedForExecution,
type HoldReleaseDeps,
type SlotReservation,
} from "../hold-release.js";
@@ -53,6 +54,23 @@ function setSelection(store: TaskStore, taskId: string, workflowId: string): voi
).run(taskId, workflowId, new Date().toISOString());
}
/**
* FNXC:WorkflowScheduling 2026-07-07-00:00:
* FN-7648's trait-based unplanned guard now applies to ANY "todo"-column card,
* not only ones released via a `reserveSlot` dep (the guard used to live only
* inside the scheduler's closure, so a card created via `store.createTask` and
* moved straight to "todo" without ever getting a real spec sailed through
* `runHoldReleaseSweep(store, noReserveDeps)` in these fixtures). Production
* `todo` cards always carry a real spec by the time triage moves them there, so
* fixtures representing an "already planned, ready to dispatch" card must write
* one too.
*/
async function writeRealPrompt(store: TaskStore, taskId: string, title: string): Promise<void> {
await store.updateTask(taskId, {
prompt: `# Task: ${taskId} - ${title}\n\n## Mission\n\nReal spec written for test fixture purposes (not the bootstrap stub).\n`,
});
}
/** Write a transitionPending marker directly (simulating a crash mid-transition). */
function setTransitionPending(store: TaskStore, taskId: string, toColumn: string): void {
const db = (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db;
@@ -138,6 +156,7 @@ describe("hold-release sweep (U6)", () => {
// (hold release: capacity), which releases into `in-progress` (wip).
async function seedTodoCard(): Promise<string> {
const task = await store.createTask({ description: "card" });
await writeRealPrompt(store, task.id, "card");
setColumn(store, task.id, "todo");
return task.id;
}
@@ -159,6 +178,7 @@ describe("hold-release sweep (U6)", () => {
// returned false here and these selected built-in tasks were silently skipped forever.
for (const workflowId of LINEAR_BUILTIN_WORKFLOW_IDS) {
const task = await store.createTask({ description: `card ${workflowId}` });
await writeRealPrompt(store, task.id, `card ${workflowId}`);
setSelection(store, task.id, workflowId);
setColumn(store, task.id, "todo");
selectedTasks.push(task.id);
@@ -178,11 +198,13 @@ describe("hold-release sweep (U6)", () => {
const v1Def = await store.createWorkflowDefinition({ name: "pure v1 custom", ir: pureV1CustomWorkflowIr() });
const v1Task = await store.createTask({ description: "pure-v1 custom card" });
await writeRealPrompt(store, v1Task.id, "pure-v1 custom card");
setSelection(store, v1Task.id, v1Def.id);
setColumn(store, v1Task.id, "todo");
const v2Def = await store.createWorkflowDefinition({ name: "authored v2 capacity", ir: authoredV2CapacityWorkflowIr() });
const v2Task = await store.createTask({ description: "authored-v2 custom card" });
await writeRealPrompt(store, v2Task.id, "authored-v2 custom card");
setSelection(store, v2Task.id, v2Def.id);
setColumn(store, v2Task.id, "todo");
@@ -688,3 +710,219 @@ describe("hold-release sweep — dependency gating (KTD-5)", () => {
expect(diffLogged).toBe(true);
});
});
// ── FN-7648: unplanned/intake-resident cards must never enter a processing
// column, even when the workflow's intake column is renamed away from the
// literal "todo" id ──────────────────────────────────────────────────────────
//
// The reserveSlot bootstrap-stub guard used to be gated on `task.column ===
// "todo"`, so a custom workflow whose intake column is renamed (e.g. `ideas`)
// bypassed the stub check entirely and could release an unplanned card
// straight into execution. The fix is trait-based (`isUnplannedForExecution`
// resolves the `intake` trait on the card's OWN column), so this suite
// exercises the renamed-column surface directly, plus a positive control that
// a genuinely planned card in the SAME custom workflow still dispatches.
function renamedIntakeCapacityWorkflowIr(): WorkflowIr {
return {
version: "v2",
name: "renamed-intake-capacity-workflow",
columns: [
{
id: "ideas",
name: "Ideas",
traits: [
{ trait: "intake" },
{ trait: "hold", config: { release: "capacity" } },
],
},
{
id: "in-progress",
name: "in-progress",
traits: [{ trait: "wip", config: { limit: "settings.maxConcurrent" } }, { trait: "abort-on-exit" }, { trait: "timing" }],
},
{ id: "in-review", name: "in-review", traits: [{ trait: "merge-blocker" }, { trait: "human-review" }] },
{ id: "done", name: "done", traits: [{ trait: "complete" }] },
],
nodes: [
{ id: "start", kind: "start", column: "ideas" },
{ id: "execute", kind: "prompt", column: "in-progress", config: { seam: "execute", prompt: "Do the work" } },
{ id: "end", kind: "end", column: "done" },
],
edges: [
{ from: "start", to: "execute", condition: "success" },
{ from: "execute", to: "end", condition: "success" },
{ from: "execute", to: "end", condition: "failure" },
],
} as WorkflowIr;
}
describe("hold-release sweep — FN-7648 unplanned/intake cards never enter execution", () => {
let rootDir = "";
let store: TaskStore;
const reserveSlotDeps: HoldReleaseDeps = {
now: () => Date.now(),
reserveSlot: () => ({ release: vi.fn() }),
};
beforeEach(async () => {
rootDir = mkdtempSync(join(tmpdir(), "fn-7648-hold-release-"));
git(rootDir, "init -b main");
git(rootDir, "config user.name 'Fusion'");
git(rootDir, "config user.email 'hi@runfusion.ai'");
writeFileSync(join(rootDir, "README.md"), "root\n");
git(rootDir, "add README.md");
git(rootDir, "commit -m init");
store = new TaskStore(rootDir, undefined, { inMemoryDb: false });
await store.init();
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
await store.updateSettings({ maxConcurrent: 10 } as Parameters<typeof store.updateSettings>[0]);
});
afterEach(() => {
try { store?.close(); } catch { /* ignore */ }
if (rootDir) rmSync(rootDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it("keeps an unplanned card (bootstrap-stub PROMPT.md) held in a renamed custom intake column instead of releasing it into in-progress", async () => {
const def = await store.createWorkflowDefinition({ name: "renamed intake", ir: renamedIntakeCapacityWorkflowIr() });
const task = await store.createTask({ description: "unplanned ideas card" });
setSelection(store, task.id, def.id);
setColumn(store, task.id, "ideas");
// Card still carries the bootstrap-stub PROMPT.md written at createTask time.
const before = await store.getTask(task.id);
expect(before?.column).toBe("ideas");
const result = await runHoldReleaseSweep(store, reserveSlotDeps);
expect(result.released).not.toContain(task.id);
const after = await store.getTask(task.id);
expect(after?.column).toBe("ideas");
expect(after?.status).not.toBe("in-progress" as unknown as typeof after.status);
});
it("keeps an unplanned card with status:'planning' held in a renamed custom intake column", async () => {
const def = await store.createWorkflowDefinition({ name: "renamed intake (status)", ir: renamedIntakeCapacityWorkflowIr() });
const task = await store.createTask({ description: "specified-in-place ideas card" });
setSelection(store, task.id, def.id);
setColumn(store, task.id, "ideas");
await store.updateTask(task.id, {
prompt: `# Task: ${task.id} - specified-in-place\n\n## Mission\n\nReal-looking spec, but status is still planning.\n`,
});
await store.updateTask(task.id, { status: "planning" } as Parameters<typeof store.updateTask>[1]);
const result = await runHoldReleaseSweep(store, reserveSlotDeps);
expect(result.released).not.toContain(task.id);
expect((await store.getTask(task.id))?.column).toBe("ideas");
});
it("does not leak a reserved worktree/semaphore slot when holding back an unplanned renamed-intake card", async () => {
const def = await store.createWorkflowDefinition({ name: "renamed intake (leak check)", ir: renamedIntakeCapacityWorkflowIr() });
const task = await store.createTask({ description: "unplanned ideas card" });
setSelection(store, task.id, def.id);
setColumn(store, task.id, "ideas");
let reserveCalls = 0;
const release = vi.fn();
const deps: HoldReleaseDeps = {
now: () => Date.now(),
reserveSlot: () => {
reserveCalls += 1;
return { release };
},
};
await runHoldReleaseSweep(store, deps);
// The unplanned-for-execution guard fires before reserveSlot is ever called
// (issueRelease checks it first), so no slot is reserved and none needs
// releasing.
expect(reserveCalls).toBe(0);
expect(release).not.toHaveBeenCalled();
expect((await store.getTask(task.id))?.column).toBe("ideas");
});
it("positive control: a genuinely planned card in the SAME custom workflow still releases into in-progress", async () => {
const def = await store.createWorkflowDefinition({ name: "renamed intake (planned)", ir: renamedIntakeCapacityWorkflowIr() });
const task = await store.createTask({ description: "planned ideas card" });
setSelection(store, task.id, def.id);
await store.updateTask(task.id, {
prompt: `# Task: ${task.id} - planned ideas card\n\n## Mission\n\nA real spec, not the bootstrap stub.\n\n## Steps\n\n### Step 0: Do the work\n`,
});
setColumn(store, task.id, "ideas");
const result = await runHoldReleaseSweep(store, reserveSlotDeps);
expect(result.released).toContain(task.id);
expect((await store.getTask(task.id))?.column).toBe("in-progress");
});
it("promoteHeldTask does not silently push an unplanned renamed-intake card into execution", async () => {
const def = await store.createWorkflowDefinition({ name: "renamed intake (promote)", ir: renamedIntakeCapacityWorkflowIr() });
const task = await store.createTask({ description: "unplanned ideas card, explicit promote" });
setSelection(store, task.id, def.id);
setColumn(store, task.id, "ideas");
// No reserveSlot dep passed — this is the shape real callers (fn_task_promote,
// webhook release) use today, which used to bypass the scheduler's
// reserveSlot guard entirely.
const outcome = await promoteHeldTask(store, task.id);
expect(outcome.released).toBe(false);
expect((await store.getTask(task.id))?.column).toBe("ideas");
});
it("releaseHeldTaskByEvent does not silently push an unplanned renamed-intake card into execution", async () => {
const def = await store.createWorkflowDefinition({ name: "renamed intake (event)", ir: {
...renamedIntakeCapacityWorkflowIr(),
columns: [
{
id: "ideas",
name: "Ideas",
traits: [
{ trait: "intake" },
{ trait: "hold", config: { release: "external-event" } },
],
},
{ id: "in-progress", name: "in-progress", traits: [{ trait: "wip", config: { limit: "settings.maxConcurrent" } }, { trait: "abort-on-exit" }, { trait: "timing" }] },
{ id: "in-review", name: "in-review", traits: [{ trait: "merge-blocker" }, { trait: "human-review" }] },
{ id: "done", name: "done", traits: [{ trait: "complete" }] },
],
} as WorkflowIr });
const task = await store.createTask({ description: "unplanned ideas card, external event" });
setSelection(store, task.id, def.id);
setColumn(store, task.id, "ideas");
const outcome = await releaseHeldTaskByEvent(store, task.id, "webhook:test");
expect(outcome.released).toBe(false);
expect((await store.getTask(task.id))?.column).toBe("ideas");
});
it("isUnplannedForExecution: true for bootstrap stub in a todo column, true for status:planning, true for intake-trait column, false for a real spec in a non-intake column", async () => {
const def = await store.createWorkflowDefinition({ name: "predicate probe", ir: renamedIntakeCapacityWorkflowIr() });
const ir = await store.getWorkflowDefinition(def.id);
if (!ir?.ir) throw new Error("missing workflow ir");
const stubTask = await store.createTask({ description: "stub" });
setSelection(store, stubTask.id, def.id);
setColumn(store, stubTask.id, "ideas");
expect(await isUnplannedForExecution(store, (await store.getTask(stubTask.id))!, ir.ir)).toBe(true);
const planningTask = await store.createTask({ description: "planning status" });
setSelection(store, planningTask.id, def.id);
setColumn(store, planningTask.id, "in-progress");
await store.updateTask(planningTask.id, { status: "planning" } as Parameters<typeof store.updateTask>[1]);
expect(await isUnplannedForExecution(store, (await store.getTask(planningTask.id))!, ir.ir)).toBe(true);
const plannedTask = await store.createTask({ description: "planned" });
setSelection(store, plannedTask.id, def.id);
await store.updateTask(plannedTask.id, {
prompt: `# Task: ${plannedTask.id} - planned\n\n## Mission\n\nReal spec.\n`,
});
setColumn(store, plannedTask.id, "in-progress");
expect(await isUnplannedForExecution(store, (await store.getTask(plannedTask.id))!, ir.ir)).toBe(false);
});
});

View File

@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { makeTransitionRejection, TransitionRejectionError, type Task, type TaskStore } from "@fusion/core";
import { makeTransitionRejection, TransitionRejectionError, buildBootstrapPrompt, type Task, type TaskStore, type WorkflowIr } from "@fusion/core";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { Scheduler } from "../scheduler.js";
@@ -31,7 +31,11 @@ function task(overrides: Partial<Task> = {}): Task {
} as Task;
}
function storeWith(tasks: Task[], settings: Record<string, unknown> = {}): TaskStore {
function storeWith(
tasks: Task[],
settings: Record<string, unknown> = {},
workflows: { selections?: Record<string, string>; definitions?: Record<string, WorkflowIr> } = {},
): TaskStore {
const byId = new Map(tasks.map((candidate) => [candidate.id, candidate]));
return {
listTasks: vi.fn(async () => [...byId.values()]),
@@ -64,6 +68,14 @@ function storeWith(tasks: Task[], settings: Record<string, unknown> = {}): TaskS
listMissions: () => [],
listGoalIdsForMission: () => [],
})),
getTaskWorkflowSelection: vi.fn((id: string) => {
const workflowId = workflows.selections?.[id];
return workflowId ? { workflowId, stepIds: [] } : undefined;
}),
getWorkflowDefinition: vi.fn(async (id: string) => {
const ir = workflows.definitions?.[id];
return ir ? { ir } : undefined;
}),
} as unknown as TaskStore;
}
@@ -123,6 +135,50 @@ describe("Scheduler workflow cutover", () => {
expect(onSchedule).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-100", column: "in-progress" }));
});
/*
FNXC:WorkflowScheduling 2026-07-07-00:00:
FN-7648 regression: a custom workflow's intake column can be renamed away from
the literal "todo" id (e.g. `ideas`). An unplanned card resting there (still
carrying the bootstrap-stub PROMPT.md) must stay held — the `reserveSlot`
guard used to be keyed on `task.column === "todo"` and silently released this
kind of card straight into `in-progress`.
*/
it("FN-7648: keeps an unplanned card in a renamed custom intake column held instead of releasing it", async () => {
const unplanned = task({ id: "FN-300", column: "ideas" });
const renamedIntakeIr: WorkflowIr = {
version: "v2",
name: "renamed-intake",
columns: [
{ id: "ideas", name: "Ideas", traits: [{ trait: "intake" }, { trait: "hold", config: { release: "capacity" } }] },
{ id: "in-progress", name: "in-progress", traits: [{ trait: "wip", config: { limit: 5 } }] },
{ id: "done", name: "done", traits: [{ trait: "complete" }] },
],
nodes: [
{ id: "start", kind: "start", column: "ideas" },
{ id: "execute", kind: "prompt", column: "in-progress", config: { seam: "execute", prompt: "Do the work" } },
{ id: "end", kind: "end", column: "done" },
],
edges: [
{ from: "start", to: "execute", condition: "success" },
{ from: "execute", to: "end", condition: "success" },
],
} as WorkflowIr;
const store = storeWith([unplanned], {}, {
selections: { "FN-300": "custom:renamed-intake" },
definitions: { "custom:renamed-intake": renamedIntakeIr },
});
vi.mocked(readFile).mockImplementation(async () => buildBootstrapPrompt("FN-300", unplanned.title, unplanned.description));
const onSchedule = vi.fn();
const scheduler = new Scheduler(store, { onSchedule });
(scheduler as unknown as { running: boolean }).running = true;
await scheduler.schedule();
expect(store.moveTask).not.toHaveBeenCalledWith("FN-300", "in-progress", expect.anything());
expect(unplanned.column).toBe("ideas");
expect(onSchedule).not.toHaveBeenCalledWith(expect.objectContaining({ id: "FN-300" }));
});
it("queues without dispatch when ephemeral agents are disabled and no agent store is available", async () => {
const ready = task({ id: "FN-101" });
const store = storeWith([ready], { ephemeralAgentsEnabled: false });

View File

@@ -43,13 +43,16 @@ import {
DEFAULT_WORKFLOW_POOL_ID,
TransitionRejectionError,
resolveWorkflowIrForTask,
buildBootstrapPrompt,
type TaskStore,
type Task,
type WorkflowIr,
type WorkflowIrV2,
type WorkflowIrColumn,
} from "@fusion/core";
import { readFile } from "node:fs/promises";
import { schedulerLog } from "./logger.js";
import { getPromptPath } from "./spec-staleness.js";
/** A reservation handle returned by {@link HoldReleaseDeps.reserveSlot}. The
* sweep calls `release()` if the subsequent move rejects on capacity. */
@@ -121,6 +124,47 @@ function isHeldTask(ir: WorkflowIr, task: Task): boolean {
return resolveColumnFlags(column).hold === true;
}
/**
* True when the card carries the `intake` trait on its CURRENT column, in its
* OWN resolved workflow IR.
*/
function columnHasIntakeTrait(ir: WorkflowIr, columnId: string): boolean {
const column = findColumn(ir, columnId);
if (!column) return false;
return resolveColumnFlags(column).intake === true;
}
/**
* FNXC:WorkflowScheduling 2026-07-07-00:00:
* A card must never be released into a processing (`countsTowardWip`) column
* while it is unplanned — regardless of which literal column id it currently
* rests in. "Unplanned" means: `status === "planning"` (specified-in-place),
* OR the card's PROMPT.md still equals the bootstrap stub AND the card is
* resident in the legacy `todo` column OR a column carrying the `intake`
* trait. Keying the stub check on the literal `"todo"` string alone misses a
* custom workflow whose intake/planning column is renamed (`ideas`, `Inbox`,
* default-workflow's renamed "Planning") — this is the general, trait-based
* predicate shared by the sweep (`issueRelease`) and the scheduler's
* `reserveSlot` guard (FN-7648) so every release surface (sweep, explicit
* `promoteHeldTask`, `releaseHeldTaskByEvent`) enforces the same invariant.
*/
export async function isUnplannedForExecution(store: TaskStore, task: Task, ir: WorkflowIr): Promise<boolean> {
if (task.status === "planning") return true;
const isLegacyTodoColumn = task.column === "todo";
const isIntakeColumn = columnHasIntakeTrait(ir, task.column);
if (!isLegacyTodoColumn && !isIntakeColumn) return false;
if (typeof store.getTasksDir !== "function") return false;
try {
const promptContent = await readFile(getPromptPath(store.getTasksDir(), task.id), "utf-8");
return promptContent === buildBootstrapPrompt(task.id, task.title, task.description);
} catch {
// Missing prompt is handled by filesystem validation elsewhere; do not block on it here.
return false;
}
}
/**
* Resolve the release target column for a held card.
*
@@ -409,6 +453,22 @@ async function issueRelease(
const targetColumn = findColumn(ir, target);
const targetIsProcessing = targetColumn ? resolveColumnFlags(targetColumn).countsTowardWip === true : false;
/*
FNXC:WorkflowScheduling 2026-07-07-00:00:
Every release surface funnels through this function (the sweep, explicit
`promoteHeldTask`, and `releaseHeldTaskByEvent`) so a single defensive check
here covers all of them — including the operator/webhook release paths that
do not pass a `reserveSlot` dep at all and would otherwise bypass the
scheduler's `reserveSlot` guard entirely. An unplanned card (bootstrap-stub
PROMPT.md, `status: "planning"`, or resident in an `intake`-trait column)
must never be moved into a processing column, no matter which surface
requested the release (FN-7648).
*/
if (targetIsProcessing && (await isUnplannedForExecution(store, task, ir))) {
schedulerLog.log(`Hold release for ${task.id} blocked — card is unplanned and cannot enter processing column ${target}`);
return false;
}
let reservation: SlotReservation | null = null;
if (targetIsProcessing && deps.reserveSlot) {
reservation = await deps.reserveSlot(task, target);

View File

@@ -35,8 +35,8 @@ import { StaleTaskReporter } from "./stale-task-reporter.js";
import { BacklogPressureReporter } from "./backlog-pressure-reporter.js";
import { UnlinkedMissionsAdvisoryReporter } from "./unlinked-missions-advisory-reporter.js";
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
import { isWorkflowColumnsEnabled, DEFAULT_WORKFLOW_POOL_ID } from "@fusion/core";
import { runHoldReleaseSweep, type SlotReservation } from "./hold-release.js";
import { isWorkflowColumnsEnabled, DEFAULT_WORKFLOW_POOL_ID, resolveWorkflowIrForTask } from "@fusion/core";
import { runHoldReleaseSweep, isUnplannedForExecution, type SlotReservation } from "./hold-release.js";
import { evaluateParkedAgentTaskLink } from "./task-agent-sync.js";
function shouldRunWorkflowColumnScheduler(_settings: Settings): boolean {
@@ -2253,21 +2253,17 @@ export class Scheduler {
let reservedScope = false;
/*
FNXC:CodingIdeasWorkflow 2026-07-04-12:10:
The workflow-column dispatch path is the only dispatcher when the flag is on, so the planning/bootstrap guards from the legacy todo filter must also apply here. A todo task being specified in place (status "planning") or still carrying the bootstrap stub PROMPT.md must not be released into an execution slot.
FNXC:WorkflowScheduling 2026-07-07-00:00:
The workflow-column dispatch path is the only dispatcher when the flag is on, so the planning/bootstrap guards from the legacy todo filter must also apply here — and they must be TRAIT-based, not keyed on the literal "todo" column id. A custom workflow's intake/planning column can be renamed (`ideas`, `Inbox`, the default workflow's renamed "Planning"), so gating this guard on `task.column === "todo"` alone let an unplanned card in a renamed intake column bypass the stub check and release straight into execution (FN-7648). `isUnplannedForExecution` resolves the intake trait on the task's OWN resolved workflow IR so every renamed variant is covered.
*/
if (task.status === "planning") {
return null;
}
if (task.column === "todo") {
try {
const promptContent = await readFile(getPromptPath(this.store.getTasksDir(), task.id), "utf-8");
if (promptContent === buildBootstrapPrompt(task.id, task.title, task.description)) {
return null;
}
} catch {
// Missing prompt handled by filesystem validation below.
try {
const ir = await resolveWorkflowIrForTask(this.store, task.id);
if (await isUnplannedForExecution(this.store, task, ir)) {
return null;
}
} catch {
// IR resolution failure: fall through to the filesystem/other guards below,
// which handle a missing/invalid workflow via their own error paths.
}
const unmetDeps = getUnmetSchedulingDependencies(task, tasks, schedulingDependencyOptions);