From b5578448fa3bc8bad9a835e7e6edf8e1cde124dc Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 25 Jul 2026 09:05:02 -0700 Subject: [PATCH] fix(workflow): start planning immediately when a task is started MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../ideas-planning-starts-immediately.md | 7 + .../__tests__/mesh-task-replication.test.ts | 58 +++- packages/core/src/mesh-task-replication.ts | 32 +- .../__tests__/triage-planning-wake.test.ts | 300 ++++++++++++++++++ packages/engine/src/scheduler.ts | 12 +- packages/engine/src/triage.ts | 124 +++++++- 6 files changed, 524 insertions(+), 9 deletions(-) create mode 100644 .changeset/ideas-planning-starts-immediately.md create mode 100644 packages/engine/src/__tests__/triage-planning-wake.test.ts diff --git a/.changeset/ideas-planning-starts-immediately.md b/.changeset/ideas-planning-starts-immediately.md new file mode 100644 index 0000000000..71138f1476 --- /dev/null +++ b/.changeset/ideas-planning-starts-immediately.md @@ -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. diff --git a/packages/core/src/__tests__/mesh-task-replication.test.ts b/packages/core/src/__tests__/mesh-task-replication.test.ts index 2b65aca020..f97174377a 100644 --- a/packages/core/src/__tests__/mesh-task-replication.test.ts +++ b/packages/core/src/__tests__/mesh-task-replication.test.ts @@ -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); + }); + }); }); diff --git a/packages/core/src/mesh-task-replication.ts b/packages/core/src/mesh-task-replication.ts index 8303cff232..387cf8cf26 100644 --- a/packages/core/src/mesh-task-replication.ts +++ b/packages/core/src/mesh-task-replication.ts @@ -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)); } diff --git a/packages/engine/src/__tests__/triage-planning-wake.test.ts b/packages/engine/src/__tests__/triage-planning-wake.test.ts new file mode 100644 index 0000000000..fe875d8b82 --- /dev/null +++ b/packages/engine/src/__tests__/triage-planning-wake.test.ts @@ -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 = {}) { + const listeners = new Map>(); + 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(); + 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 { + 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 { + 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]> = [ + ["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 { + 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([]); + }); +}); diff --git a/packages/engine/src/scheduler.ts b/packages/engine/src/scheduler.ts index a9b5aaa40a..c0e2de4c7f 100644 --- a/packages/engine/src/scheduler.ts +++ b/packages/engine/src/scheduler.ts @@ -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. } diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 587a3e6548..092f664356 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -227,6 +227,17 @@ export class TriageProcessor { private pollInterval: ReturnType | 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 | null = null; + private nudgeDuringPoll = false; private processing = new Set(); /** Synchronous ownership fence shared with advanced-triage self-healing. */ private advancedRecoveryReservations = new Set(); @@ -266,6 +277,8 @@ export class TriageProcessor { private stuckAborted = new Set(); 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 { 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(); + } } }