fix(workflow): start planning immediately when a task is started

Pressing Start on a Coding (Ideas) card only writes a column move — there is no
dispatch call in that path — so planning did not begin until the triage
processor's next timer tick, up to pollIntervalMs (15s default) later. The
"Started planning" toast was optimistic and the card just sat in Todo.

- Wake planning discovery on the store's task:updated/task:created event when a
  task lands in todo/triage. Binding the wake to the store event rather than the
  Start button covers every move surface (board drag, context menu, task detail,
  List view, CLI, agent tools, POST /tasks/:id/move) by construction. The wake is
  advisory: it only advances WHEN the poll runs, so every pause, seed-prompt,
  dependency, and concurrency gate still applies.
- Admit a todo task whose PROMPT.md is missing instead of dropping it through a
  silent `catch {}`. The scheduler KEEPS a candidate whose prompt it cannot read,
  so such a card was invisible to planning while still visible to dispatch, with
  no log line in either lane. Unreadable (non-ENOENT) prompts now log.
- Route the scheduler's dispatch filter through the shared isUnplannedSeedPrompt
  predicate. Its open-coded strict bootstrap compare disagreed with triage on the
  refinement-seed shape, leaving hold-release as the only thing between an
  executor and a prompt containing just the operator's feedback text. The
  predicate also normalizes line endings/trailing whitespace, so a CRLF or
  trailing-newline round-trip no longer reclassifies an unplanned card as planned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-25 09:05:02 -07:00
parent 1c38c6e93b
commit b5578448fa
6 changed files with 524 additions and 9 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Starting a task now begins planning immediately instead of waiting for the next engine poll.
category: fix
dev: TriageProcessor gains `requestImmediatePoll()` plus a store-event wake (`task:updated`/`task:created`) that fires when a task lands in `todo`/`triage`, debounced 150ms with a mid-poll replay — so every move surface (board drag, context menu, CLI, tools, `POST /tasks/:id/move`) wakes planning rather than waiting out `pollIntervalMs` (15s default). Planning discovery now admits a `todo` task whose `PROMPT.md` is missing (ENOENT) instead of dropping it via a silent `catch {}`, and logs unreadable prompts. `isUnplannedSeedPrompt` normalizes line endings/trailing whitespace before comparing, and `scheduler.ts`'s dispatch filter now uses that shared predicate instead of an open-coded strict bootstrap compare that disagreed with triage on the refinement-seed shape.

View File

@@ -7,7 +7,11 @@ database) and those functions were removed from mesh-task-replication.ts.
Only buildBootstrapPrompt survives (task/comment PROMPT.md stub builder).
*/
import { describe, expect, it } from "vitest";
import { buildBootstrapPrompt, isUnplannedSeedPrompt } from "../mesh-task-replication.js";
import {
buildBootstrapPrompt,
buildRefinementSeedPrompt,
isUnplannedSeedPrompt,
} from "../mesh-task-replication.js";
import { applyOriginalDescription } from "../original-description-policy.js";
describe("mesh-task-replication", () => {
@@ -32,4 +36,56 @@ describe("mesh-task-replication", () => {
expect(isUnplannedSeedPrompt(planned, "FN-1", "Title", "desc")).toBe(false);
expect(planned).toContain("## Original Description");
});
/*
FNXC:WorkflowScheduling 2026-07-25-11:20:
Regression for the "started card never plans" symptom. Seed detection was raw byte-equality, so
any benign whitespace/line-ending drift in PROMPT.md reclassified an unplanned card as "already
planned" and triage's todo-discovery silently skipped it forever.
Surface enumeration (invariant: a seed is recognized as unplanned regardless of line-ending or
trailing-whitespace drift, for BOTH seed shapes, while a real spec is never mistaken for one):
- Both seed builders (bootstrap stub and refinement seed).
- Both drift sources (CRLF round-trip, added/stripped trailing newline, trailing spaces).
- Both titled and untitled bootstrap shapes.
- Negative: a real spec, and a seed whose heading/body text genuinely differs, stay "planned".
*/
describe("isUnplannedSeedPrompt tolerates benign PROMPT.md drift", () => {
const drift = (s: string) => [
s.replace(/\n/g, "\r\n"), // CRLF checkout / Windows editor
s.trimEnd(), // editor stripped the trailing newline
`${s}\n\n`, // editor added trailing newlines
s.replace(/\n/g, " \n"), // trailing spaces on each line
];
it("recognizes a drifted bootstrap stub (titled and untitled)", () => {
for (const title of ["Title", undefined]) {
const seed = buildBootstrapPrompt("FN-1", title, "desc");
expect(isUnplannedSeedPrompt(seed, "FN-1", title, "desc")).toBe(true);
for (const variant of drift(seed)) {
expect(isUnplannedSeedPrompt(variant, "FN-1", title, "desc")).toBe(true);
}
}
});
it("recognizes a drifted refinement seed", () => {
const seed = buildRefinementSeedPrompt("Title", "desc");
expect(isUnplannedSeedPrompt(seed, "FN-1", "Title", "desc")).toBe(true);
for (const variant of drift(seed)) {
expect(isUnplannedSeedPrompt(variant, "FN-1", "Title", "desc")).toBe(true);
}
});
it("still rejects a real spec and genuinely different text", () => {
expect(
isUnplannedSeedPrompt("# FN-1: Title\n\n## Mission\n\nReal spec.\n", "FN-1", "Title", "desc"),
).toBe(false);
// Body text differs by more than whitespace — not this task's seed.
expect(isUnplannedSeedPrompt("# FN-1: Title\n\nother\n", "FN-1", "Title", "desc")).toBe(false);
// Heading belongs to a different task.
expect(
isUnplannedSeedPrompt(buildBootstrapPrompt("FN-2", "Title", "desc"), "FN-1", "Title", "desc"),
).toBe(false);
});
});
});

View File

@@ -23,6 +23,21 @@ export function buildRefinementSeedPrompt(title: string, description: string): s
return `# ${title}\n\n${description}\n`;
}
/*
FNXC:WorkflowScheduling 2026-07-25-11:20:
Seed detection compares NORMALIZED text, not raw bytes. Symptom it fixes: a Coding (Ideas) card
promoted to Todo was never planned — triage's todo-discovery only admits a card whose PROMPT.md
still reads as a seed, and any byte-level drift from the builder output silently reclassified the
card as "already planned", so it sat in Todo forever with no log line. Drift sources are all
benign and outside the writer's control: a CRLF checkout/editor round-trip, an editor that adds or
strips the trailing newline, or trailing spaces. Normalization is line-ending + trailing-whitespace
only — the heading and body text still must match exactly, so a REAL spec can never normalize into
a seed (it carries Mission/Steps/File Scope sections the seed does not have).
*/
function normalizeSeedText(content: string): string {
return content.replace(/\r\n/g, "\n").replace(/[ \t]+$/gm, "").trimEnd();
}
/*
FNXC:WorkflowScheduling 2026-07-12-22:55:
"Unplanned" detection must recognize BOTH seed-prompt shapes or unplanned cards slip into
@@ -32,8 +47,15 @@ execution with a non-spec prompt:
failed the strict stub-equality check, so a refinement promoted out of a manual intake
column (Coding (Ideas)) was treated as already planned and released straight into
execution carrying only the operator's feedback text.
Callers: triage todo-discovery (plan-in-place workflows) and hold-release's
isUnplannedForExecution guard.
FNXC:WorkflowScheduling 2026-07-25-11:20:
This is the SINGLE seed predicate for every scheduling decision. The dispatch filter in
scheduler.ts used to open-code strict `content === buildBootstrapPrompt(...)`, which disagreed
with this function on the refinement seed: triage called such a card unplanned (so it planned it)
while the scheduler called it planned (so it was also a dispatch candidate), leaving hold-release
as the only thing between a refinement seed and an executor running on the operator's feedback
text. Callers: triage todo-discovery (plan-in-place workflows), the scheduler dispatch filter, and
hold-release's isUnplannedForExecution guard. Do not re-open-code either equality check.
*/
export function isUnplannedSeedPrompt(
content: string,
@@ -41,6 +63,8 @@ export function isUnplannedSeedPrompt(
title: string | undefined,
description: string,
): boolean {
if (content === buildBootstrapPrompt(taskId, title, description)) return true;
return title !== undefined && content === buildRefinementSeedPrompt(title, description);
const normalized = normalizeSeedText(content);
if (normalized === normalizeSeedText(buildBootstrapPrompt(taskId, title, description))) return true;
return title !== undefined
&& normalized === normalizeSeedText(buildRefinementSeedPrompt(title, description));
}

View File

@@ -0,0 +1,300 @@
import "./executor-test-helpers.js";
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import type { Task } from "@fusion/core";
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { TriageProcessor } from "../triage.js";
import { resetExecutorMocks } from "./executor-test-helpers.js";
/*
FNXC:CodingIdeasWorkflow 2026-07-25-11:20:
Covers the two halves of "a started Ideas card does not begin planning":
1. WAKE — Start performs a bare column move (TaskCard.handleStartClick -> onMoveTask) with no
dispatch call, so planning previously waited out the poll timer (pollIntervalMs, 15s default).
The wake is bound to the STORE EVENT rather than the button, so every move surface (board drag,
context menu, task detail, List view, CLI, agent tools, POST /tasks/:id/move) is covered by
construction — they all funnel through store.moveTask, which emits task:updated.
2. MISSING PROMPT.md — previously a silent `catch {}` dropped the card from planning discovery
while the scheduler still kept it as a dispatch candidate, so it was invisible to planning with
no log line in either lane.
Surface enumeration for the wake guard: both planning-eligible columns (todo, triage), a
non-eligible column, paused/userPaused rows, an already-processing row, the created-vs-updated
event, the stopped processor, and the nudge-during-poll replay path.
*/
type Listener = (...args: any[]) => void;
function createEventedStore(overrides: Record<string, any> = {}) {
const listeners = new Map<string, Set<Listener>>();
const store = {
// A long interval guarantees any poll observed in these tests came from the wake, not a tick.
getSettings: vi.fn().mockResolvedValue({
pollIntervalMs: 600_000,
maxConcurrent: 4,
maxWorktrees: 4,
autoMerge: true,
}),
listTasks: vi.fn().mockResolvedValue([]),
updateTask: vi.fn().mockResolvedValue(undefined),
moveTask: vi.fn().mockResolvedValue(undefined),
on: vi.fn((event: string, listener: Listener) => {
const set = listeners.get(event) ?? new Set<Listener>();
set.add(listener);
listeners.set(event, set);
}),
off: vi.fn((event: string, listener: Listener) => {
listeners.get(event)?.delete(listener);
}),
...overrides,
} as any;
return {
store,
emit(event: string, ...args: any[]) {
for (const listener of listeners.get(event) ?? []) listener(...args);
},
listenerCount(event: string) {
return listeners.get(event)?.size ?? 0;
},
};
}
function createTask(overrides: Partial<Task> = {}): Task {
return {
id: "FN-WAKE-1",
title: "Idea card",
description: "desc",
column: "todo",
status: null,
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
} as Task;
}
/*
FNXC:EngineTests 2026-07-25-11:20:
Fake timers, not real waits — the wake is debounced (NUDGE_DEBOUNCE_MS) and the poll interval is
600s, so a real-time sleep would either be flaky or slow. advanceTimersByTimeAsync also flushes the
microtasks between timers, which is what lets the start()-triggered poll settle before the assert.
*/
async function settleWake(): Promise<void> {
await vi.advanceTimersByTimeAsync(500);
}
describe("TriageProcessor planning wake (immediate poll on move)", () => {
beforeEach(() => {
resetExecutorMocks();
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("polls immediately when a task lands in todo instead of waiting for the interval", async () => {
const { store, emit } = createEventedStore();
const processor = new TriageProcessor(store, "/tmp/root");
processor.start();
const poll = vi.spyOn(processor as any, "poll").mockResolvedValue(undefined);
emit("task:updated", createTask({ column: "todo" }));
expect(poll).not.toHaveBeenCalled(); // debounced, not synchronous
await settleWake();
expect(poll).toHaveBeenCalledTimes(1);
processor.stop();
});
it("also wakes for the triage column and for task:created", async () => {
for (const [event, column] of [
["task:updated", "triage"],
["task:created", "todo"],
["task:created", "triage"],
] as const) {
const { store, emit } = createEventedStore();
const processor = new TriageProcessor(store, "/tmp/root");
processor.start();
const poll = vi.spyOn(processor as any, "poll").mockResolvedValue(undefined);
emit(event, createTask({ column }));
await settleWake();
expect(poll, `${event} in ${column}`).toHaveBeenCalledTimes(1);
processor.stop();
}
});
it("coalesces a burst of moves into a single poll", async () => {
const { store, emit } = createEventedStore();
const processor = new TriageProcessor(store, "/tmp/root");
processor.start();
const poll = vi.spyOn(processor as any, "poll").mockResolvedValue(undefined);
for (let i = 0; i < 5; i++) emit("task:updated", createTask({ id: `FN-WAKE-${i}`, column: "todo" }));
await settleWake();
expect(poll).toHaveBeenCalledTimes(1);
processor.stop();
});
it("ignores moves that cannot start planning", async () => {
const cases: Array<[string, Partial<Task>]> = [
["non-planning column", { column: "in-progress" }],
["paused", { column: "todo", paused: true }],
["userPaused", { column: "todo", userPaused: true }],
];
for (const [label, overrides] of cases) {
const { store, emit } = createEventedStore();
const processor = new TriageProcessor(store, "/tmp/root");
processor.start();
const poll = vi.spyOn(processor as any, "poll").mockResolvedValue(undefined);
emit("task:updated", createTask(overrides));
await settleWake();
expect(poll, label).not.toHaveBeenCalled();
processor.stop();
}
});
it("ignores a task already being planned", async () => {
const { store, emit } = createEventedStore();
const processor = new TriageProcessor(store, "/tmp/root");
processor.start();
(processor as any).processing.add("FN-WAKE-1");
const poll = vi.spyOn(processor as any, "poll").mockResolvedValue(undefined);
emit("task:updated", createTask({ column: "todo" }));
await settleWake();
expect(poll).not.toHaveBeenCalled();
processor.stop();
});
it("is a no-op before start and after stop, and unsubscribes on stop", async () => {
const { store, emit, listenerCount } = createEventedStore();
const processor = new TriageProcessor(store, "/tmp/root");
expect(processor.requestImmediatePoll()).toBe(false); // not running yet
processor.start();
expect(listenerCount("task:updated")).toBeGreaterThan(0);
expect(processor.requestImmediatePoll()).toBe(true);
processor.stop();
expect(processor.requestImmediatePoll()).toBe(false);
const poll = vi.spyOn(processor as any, "poll").mockResolvedValue(undefined);
emit("task:updated", createTask({ column: "todo" }));
await settleWake();
expect(poll).not.toHaveBeenCalled();
});
it("replays a wake that arrives while a poll is already in flight", async () => {
const { store } = createEventedStore();
const processor = new TriageProcessor(store, "/tmp/root");
processor.start();
// Simulate the re-entry guard: a nudge lands mid-poll and must not be swallowed.
(processor as any).polling = true;
expect(processor.requestImmediatePoll()).toBe(true);
expect((processor as any).nudgeDuringPoll).toBe(true);
const poll = vi.spyOn(processor as any, "poll").mockResolvedValue(undefined);
// Drain the finally-block replay the same way poll() does.
(processor as any).polling = false;
(processor as any).nudgeDuringPoll = false;
processor.requestImmediatePoll();
await settleWake();
expect(poll).toHaveBeenCalledTimes(1);
processor.stop();
});
});
describe("TriageProcessor planning discovery: missing PROMPT.md", () => {
let rootDir: string;
beforeEach(async () => {
resetExecutorMocks();
vi.clearAllMocks();
rootDir = await mkdtemp(join(tmpdir(), "fusion-wake-test-"));
});
afterEach(async () => {
await rm(rootDir, { recursive: true, force: true });
});
async function discover(store: any, tasks: Task[]): Promise<string[]> {
const processor = new TriageProcessor(store, rootDir);
const found = await (processor as any).discoverReadyPlanningTasks(tasks, Date.now());
return (found as Task[]).map((t) => t.id);
}
it("admits a todo task whose PROMPT.md is missing rather than silently skipping it", async () => {
const { store } = createEventedStore();
const task = createTask({ id: "FN-NOPROMPT", column: "todo" });
// Task dir exists but PROMPT.md was never written / was deleted.
await mkdir(join(rootDir, ".fusion", "tasks", task.id), { recursive: true });
expect(await discover(store, [task])).toEqual(["FN-NOPROMPT"]);
});
it("admits a todo task whose PROMPT.md is still the seed stub, drift and all", async () => {
for (const [label, content] of [
["exact", "# FN-SEED: Idea card\n\ndesc\n"],
// CRLF + stripped trailing newline: the drift that used to read as "already planned".
["crlf, no trailing newline", "# FN-SEED: Idea card\r\n\r\ndesc"],
["trailing spaces", "# FN-SEED: Idea card \n\ndesc \n"],
] as const) {
const { store } = createEventedStore();
const task = createTask({ id: "FN-SEED", column: "todo", title: "Idea card", description: "desc" });
const dir = join(rootDir, ".fusion", "tasks", task.id);
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "PROMPT.md"), content, "utf-8");
expect(await discover(store, [task]), label).toEqual(["FN-SEED"]);
}
});
/*
FNXC:CodingIdeasWorkflow 2026-07-25-11:20:
The refinement seed shape (no task-id prefix) must be admitted for planning too. This is the
shape the scheduler's dispatch filter used to disagree about — it open-coded a strict bootstrap
compare, so it treated a refinement seed as a real spec and kept it as a dispatch candidate while
triage was planning it. Both lanes now share isUnplannedSeedPrompt.
*/
it("admits a todo task carrying the refinement seed shape", async () => {
const { store } = createEventedStore();
const task = createTask({ id: "FN-REFINE", column: "todo", title: "Idea card", description: "desc" });
const dir = join(rootDir, ".fusion", "tasks", task.id);
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "PROMPT.md"), "# Idea card\n\ndesc\n", "utf-8");
expect(await discover(store, [task])).toEqual(["FN-REFINE"]);
});
it("does not admit a todo task that already has a real spec", async () => {
const { store } = createEventedStore();
const task = createTask({ id: "FN-PLANNED", column: "todo", title: "Idea card", description: "desc" });
const dir = join(rootDir, ".fusion", "tasks", task.id);
await mkdir(dir, { recursive: true });
await writeFile(
join(dir, "PROMPT.md"),
"# FN-PLANNED: Idea card\n\n## Mission\n\nReal spec.\n\n## Steps\n\n1. Do it\n",
"utf-8",
);
expect(await discover(store, [task])).toEqual([]);
});
});

View File

@@ -15,7 +15,7 @@ import {
type AgentStore,
type Settings,
TransitionRejectionError,
buildBootstrapPrompt,
isUnplannedSeedPrompt,
} from "@fusion/core";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
@@ -1536,13 +1536,21 @@ export class Scheduler {
/*
FNXC:CodingIdeasWorkflow 2026-07-04-10:46:
Exclude unplanned todo tasks whose PROMPT.md is still the bootstrap stub. In a merged planner/capacity column a freshly promoted card has no real spec yet; dispatching it would execute the stub. Normal-workflow todo tasks always carry a real spec (triage writes it before moving them to todo), so this filter is a no-op for them. This closes the gap between the operator promoting a card and the triage service picking it up.
FNXC:CodingIdeasWorkflow 2026-07-25-11:20:
Use the shared isUnplannedSeedPrompt predicate instead of an open-coded strict stub compare.
The two disagreed on the refineTask seed shape: triage's todo-discovery treated a refinement
seed as unplanned (planning it) while this filter treated it as a real spec (keeping it as a
dispatch candidate), so the two lanes could race for the same card and only hold-release stood
between an executor and a prompt containing nothing but the operator's feedback text. One
predicate, one answer — and it also absorbs CRLF/trailing-newline drift.
*/
todo = (
await Promise.all(
todo.map(async (t) => {
try {
const content = await readFile(getPromptPath(this.store.getTasksDir(), t.id), "utf-8");
if (content === buildBootstrapPrompt(t.id, t.title, t.description)) return null;
if (isUnplannedSeedPrompt(content, t.id, t.title, t.description)) return null;
} catch {
// Missing prompt is handled by filesystem validation below; keep the candidate.
}

View File

@@ -227,6 +227,17 @@ export class TriageProcessor {
private pollInterval: ReturnType<typeof setInterval> | null = null;
/** The interval (ms) of the currently active `setInterval` timer. */
private activePollMs: number | null = null;
/*
FNXC:CodingIdeasWorkflow 2026-07-25-11:20:
Event-wake state for requestImmediatePoll(). Planning discovery is timer-driven, so pressing
Start on an Ideas card (which only writes a column change) used to wait out the remainder of the
poll interval — up to pollIntervalMs, 15s by default — before anything even looked at the card.
`nudgeTimer` debounces a burst of moves into one poll; `nudgeDuringPoll` remembers a nudge that
arrived while a poll was already in flight, since that poll may have snapshotted the task list
before the move landed and would otherwise drop the wake entirely.
*/
private nudgeTimer: ReturnType<typeof setTimeout> | null = null;
private nudgeDuringPoll = false;
private processing = new Set<string>();
/** Synchronous ownership fence shared with advanced-triage self-healing. */
private advancedRecoveryReservations = new Set<string>();
@@ -266,6 +277,8 @@ export class TriageProcessor {
private stuckAborted = new Set<string>();
private taskDeletedHandler?: (task: Task) => void;
private taskPausedHandler?: (task: Task) => void;
/** FNXC:CodingIdeasWorkflow 2026-07-25-11:20: store-event wake for planning-eligible columns. */
private taskColumnWakeHandler?: (task: Task) => void;
private _approvalRequestStore?: ApprovalRequestStore;
/**
@@ -500,6 +513,32 @@ export class TriageProcessor {
this.activeSessions.delete(task.id);
}
};
/*
FNXC:CodingIdeasWorkflow 2026-07-25-11:20:
Wake planning discovery the moment a task lands in a planning-eligible column, instead of
waiting out the poll timer. Symptom: pressing Start on a Coding (Ideas) card appeared to do
nothing for up to pollIntervalMs (15s default) — the Start affordance performs a bare column
move (TaskCard.handleStartClick -> onMoveTask) with no dispatch call, so the engine did not
learn about the card until its next tick.
Surface enumeration — the wake is bound to the STORE EVENT, not to the Start button, so every
move surface is covered by construction: board drag, card context menu, task detail, List view,
the CLI, agent tools, and POST /tasks/:id/move all funnel through store.moveTask, which emits
task:updated. Both move sources (user and engine) and both intake shapes (Ideas -> Todo
promotion and a plain triage-column create) go through the same emit.
The handler is deliberately dumb: it filters on column only and delegates every real decision
to the poll, so it cannot bypass a pause, dependency, seed-prompt, or concurrency gate.
*/
this.taskColumnWakeHandler = (task: Task) => {
if (!task?.id) return;
if (task.column !== "todo" && task.column !== "triage") return;
if (task.paused === true || task.userPaused === true) return;
// Already being planned (or mid-plan) — the running poll/session owns it.
if (this.processing.has(task.id) || this.hasLivePlanningWork(task.id)) return;
this.requestImmediatePoll();
};
}
start(): void {
@@ -511,6 +550,10 @@ export class TriageProcessor {
if (this.taskPausedHandler && typeof this.store.on === "function") {
this.store.on("task:updated", this.taskPausedHandler);
}
if (this.taskColumnWakeHandler && typeof this.store.on === "function") {
this.store.on("task:updated", this.taskColumnWakeHandler);
this.store.on("task:created", this.taskColumnWakeHandler);
}
// Clear stale "planning" statuses left by a prior crash/restart.
// No triage agent is actually running at startup, so any task still
@@ -556,12 +599,22 @@ export class TriageProcessor {
this.pollInterval = null;
this.activePollMs = null;
}
// FNXC:CodingIdeasWorkflow 2026-07-25-11:20: a debounced wake must not fire past shutdown.
if (this.nudgeTimer) {
clearTimeout(this.nudgeTimer);
this.nudgeTimer = null;
}
this.nudgeDuringPoll = false;
if (this.taskDeletedHandler && typeof this.store.off === "function") {
this.store.off("task:deleted", this.taskDeletedHandler);
}
if (this.taskPausedHandler && typeof this.store.off === "function") {
this.store.off("task:updated", this.taskPausedHandler);
}
if (this.taskColumnWakeHandler && typeof this.store.off === "function") {
this.store.off("task:updated", this.taskColumnWakeHandler);
this.store.off("task:created", this.taskColumnWakeHandler);
}
// Tear down any in-flight specify sessions and reviewer subagents so they
// don't keep streaming LLM tokens / tool calls past engine shutdown.
this.abortAndDisposeActiveSessions("engine stop");
@@ -1125,14 +1178,35 @@ export class TriageProcessor {
eligibleTodoTasks.push(todoTask);
continue;
}
/*
FNXC:CodingIdeasWorkflow 2026-07-25-11:20:
A MISSING PROMPT.md means unplanned, so the card is admitted for planning rather than
dropped. Previously any read failure hit a silent `catch {}` that deferred to "scheduler
filesystem validation" — but the scheduler's filter KEEPS a candidate whose prompt it cannot
read, so a card with no PROMPT.md was invisible to planning while still visible to dispatch,
and produced no log line in either lane. Planning regenerates the spec, which is the correct
recovery for both plan-in-place (Ideas) cards and a normal-workflow card whose spec vanished.
Only ENOENT is treated as unplanned; a genuine read fault (permissions, a directory in the
file's place) still skips the card, but now says so in the log instead of vanishing.
*/
try {
const promptPath = join(this.rootDir, ".fusion", "tasks", todoTask.id, "PROMPT.md");
const content = await readFile(promptPath, "utf-8");
if (isUnplannedSeedPrompt(content, todoTask.id, todoTask.title, todoTask.description)) {
eligibleTodoTasks.push(todoTask);
}
} catch {
// Missing/unreadable prompt — scheduler filesystem validation owns it.
} catch (err) {
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
planLog.warn(
`${todoTask.id}: PROMPT.md is missing — treating as unplanned and admitting it for planning`,
);
eligibleTodoTasks.push(todoTask);
} else {
planLog.warn(
`${todoTask.id}: PROMPT.md unreadable (${(err as NodeJS.ErrnoException)?.code ?? "unknown"}) — ` +
"skipping planning discovery for this poll",
);
}
}
}
return [...eligibleTriageTasks, ...eligibleTodoTasks].sort((a, b) => {
@@ -1147,10 +1221,44 @@ export class TriageProcessor {
});
}
/**
* Run a planning-discovery poll now instead of waiting for the next timer tick.
*
* FNXC:CodingIdeasWorkflow 2026-07-25-11:20:
* Requirement: starting a task must begin planning immediately, not "within 15 seconds".
* The Start affordance on an intake (Ideas) card performs a bare column move — there is no
* dispatch call in that path — so planning only began when the next `setInterval` tick happened
* to fire. The store-event wake (taskColumnWakeHandler) is the primary caller.
*
* Contract: advisory and idempotent. It never plans a task by itself, it only advances WHEN the
* existing poll runs — every pause, seed-prompt, dependency, and concurrency gate still applies,
* so a nudge on a capacity-blocked card is a no-op rather than an admission bypass. Returns false
* when the processor is not running.
*/
requestImmediatePoll(): boolean {
if (!this.running) return false;
// A poll is mid-flight: it may already have read the task list, so remember to re-poll after.
if (this.polling) {
this.nudgeDuringPoll = true;
return true;
}
if (this.nudgeTimer) return true; // Already coalescing a burst of moves.
this.nudgeTimer = setTimeout(() => {
this.nudgeTimer = null;
void this.poll();
}, TriageProcessor.NUDGE_DEBOUNCE_MS);
this.nudgeTimer.unref?.();
return true;
}
/** Coalescing window for requestImmediatePoll, so a multi-card drag causes one poll, not N. */
private static readonly NUDGE_DEBOUNCE_MS = 150;
private async poll(): Promise<void> {
if (!this.running) return;
if (this.polling) return;
this.polling = true;
this.nudgeDuringPoll = false;
try {
const settings = await this.store.getSettings();
@@ -1286,6 +1394,18 @@ export class TriageProcessor {
planLog.error("Poll error:", err);
} finally {
this.polling = false;
/*
FNXC:CodingIdeasWorkflow 2026-07-25-11:20:
Replay a nudge that arrived mid-poll. Without this, a move that lands microseconds after the
poll's listTasks() snapshot is swallowed by the `if (this.polling) return` re-entry guard and
the operator waits a full interval anyway — exactly the symptom the wake exists to remove.
Re-entry is bounded: the flag is cleared when the replay poll starts, so a nudge storm during
a slow poll produces at most one extra poll.
*/
if (this.nudgeDuringPoll && this.running) {
this.nudgeDuringPoll = false;
this.requestImmediatePoll();
}
}
}