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:
@@ -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).
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user