From bec8987ce9266fffc209acf3ce81e2a1071e5d84 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 7 Jul 2026 14:40:05 -0700 Subject: [PATCH] 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) --- ...48-unplanned-intake-cards-never-execute.md | 7 + docs/architecture.md | 2 + docs/workflow-steps.md | 2 + .../engine/src/__tests__/hold-release.test.ts | 238 ++++++++++++++++++ .../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(-) create mode 100644 .changeset/fn-7648-unplanned-intake-cards-never-execute.md diff --git a/.changeset/fn-7648-unplanned-intake-cards-never-execute.md b/.changeset/fn-7648-unplanned-intake-cards-never-execute.md new file mode 100644 index 0000000000..d43e8d99a8 --- /dev/null +++ b/.changeset/fn-7648-unplanned-intake-cards-never-execute.md @@ -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). diff --git a/docs/architecture.md b/docs/architecture.md index 729e520be2..f49588f7dc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index 746a4a1c05..63fc094e54 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -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): diff --git a/packages/engine/src/__tests__/hold-release.test.ts b/packages/engine/src/__tests__/hold-release.test.ts index fd565eb3a6..708d6bf0c8 100644 --- a/packages/engine/src/__tests__/hold-release.test.ts +++ b/packages/engine/src/__tests__/hold-release.test.ts @@ -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 { + 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 { 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[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[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[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); + }); +}); diff --git a/packages/engine/src/__tests__/scheduler-workflow-cutover.test.ts b/packages/engine/src/__tests__/scheduler-workflow-cutover.test.ts index 90e985b504..76ccf297a8 100644 --- a/packages/engine/src/__tests__/scheduler-workflow-cutover.test.ts +++ b/packages/engine/src/__tests__/scheduler-workflow-cutover.test.ts @@ -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 { } as Task; } -function storeWith(tasks: Task[], settings: Record = {}): TaskStore { +function storeWith( + tasks: Task[], + settings: Record = {}, + workflows: { selections?: Record; definitions?: Record } = {}, +): 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 = {}): 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 }); diff --git a/packages/engine/src/hold-release.ts b/packages/engine/src/hold-release.ts index ef18ff13c4..53a5470cf3 100644 --- a/packages/engine/src/hold-release.ts +++ b/packages/engine/src/hold-release.ts @@ -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 { + 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); diff --git a/packages/engine/src/scheduler.ts b/packages/engine/src/scheduler.ts index 283b8c594d..72c6ce0ed2 100644 --- a/packages/engine/src/scheduler.ts +++ b/packages/engine/src/scheduler.ts @@ -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);