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:
7
.changeset/ideas-planning-starts-immediately.md
Normal file
7
.changeset/ideas-planning-starts-immediately.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
300
packages/engine/src/__tests__/triage-planning-wake.test.ts
Normal file
300
packages/engine/src/__tests__/triage-planning-wake.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user