fix(engine): dispatch as soon as planning finishes

Closes the second half of the "started card does nothing" gap. Plan-in-place
workflows (Coding (Ideas)) finalize by clearing `status` in place — finalize
deliberately skips the triage->todo move — so a card that just became executable
emits only a bare task:updated. None of the scheduler's existing event wakes
(task:created, globalPause/enginePaused unpause, per-task unpause) fire for that
transition, so the operator paid one poll interval for planning to start and
another for execution to start.

Track ids seen with status "planning" and trigger a scheduling pass when they
return to a dispatchable state, mirroring the pausedTaskIds unpause tracker.
Guarded on !status, not paused/userPaused, and a schedulable column, so a
planning -> failed/awaiting-approval park does not trigger a pointless pass.
schedule()'s re-entrance guard drops the call if a pass is already in flight,
and the id is cleared on task:deleted alongside the other per-task sets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-25 09:27:26 -07:00
parent 9cbed745f9
commit e712b6faea
3 changed files with 219 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Execution now starts as soon as planning finishes, instead of waiting for the next engine poll.
category: fix
dev: Scheduler tracks task ids seen with `status: "planning"` and triggers a scheduling pass on the planning -> dispatchable transition. Plan-in-place workflows (Coding (Ideas)) clear `status` in place without a `task:moved`, so none of the pre-existing event wakes (task:created, globalPause/enginePaused unpause, per-task unpause) fired for a card that had just become executable — it waited out `pollIntervalMs`. The wake is guarded on `!task.status`, not paused/userPaused, and a schedulable column, so a planning -> failed/awaiting-approval park does not trigger a pass; `schedule()`'s re-entrance guard drops it if a pass is already running.

View File

@@ -0,0 +1,170 @@
import { describe, expect, it, vi } from "vitest";
import type { TaskStore } from "@fusion/core";
import { Scheduler } from "../scheduler.js";
/*
FNXC:CodingIdeasWorkflow 2026-07-25-13:10:
Covers the dispatch half of the "started card does nothing" gap. Triage's finalize clears `status`
in place for plan-in-place workflows (it deliberately skips the triage->todo move), so a card that
just finished planning becomes dispatchable via a bare task:updated — no task:moved, no pause
transition — and none of the scheduler's pre-existing event wakes (task:created, globalPause
unpause, enginePaused unpause, per-task unpause) fire for it. The operator paid one poll interval
for planning to start and another for execution to start.
Surface enumeration (invariant: the planning -> dispatchable transition schedules exactly once, and
only when the card is genuinely dispatchable):
- planning -> null in todo and in triage: schedules.
- planning -> failed / awaiting-approval: does NOT schedule (a park is not a dispatch).
- planning -> null but paused / userPaused: does NOT schedule.
- planning -> null in a non-schedulable column: does NOT schedule.
- A task never seen planning: does NOT schedule (no spurious wake on unrelated updates).
- Fires once per transition, not on every subsequent update.
- Deletion mid-planning clears the tracking so a reused id cannot fire a stale wake.
*/
function createStore() {
const listeners = new Map<string, ((payload: unknown) => void)[]>();
const store = {
on: vi.fn((event: string, listener: (payload: unknown) => void) => {
const existing = listeners.get(event) ?? [];
existing.push(listener);
listeners.set(event, existing);
}),
off: vi.fn(),
getRootDir: vi.fn().mockReturnValue("/test/project"),
getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false }),
listTasks: vi.fn().mockResolvedValue([]),
updateTask: vi.fn().mockResolvedValue(undefined),
} as unknown as TaskStore;
return {
store,
emit: (event: string, payload: unknown) => {
for (const listener of listeners.get(event) ?? []) listener(payload);
},
};
}
function createTask(overrides: Record<string, unknown> = {}) {
return {
id: "FN-1",
column: "todo",
status: null,
paused: false,
userPaused: false,
assignedAgentId: null,
checkedOutBy: null,
deletedAt: null,
dependencies: [],
columnMovedAt: "2026-01-01T00:00:00.000Z",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
/** Build a running scheduler with schedule() stubbed, so we count wakes not real passes. */
function createScheduler() {
const { store, emit } = createStore();
const scheduler = new Scheduler(store, {});
const schedule = vi.spyOn(scheduler, "schedule").mockResolvedValue(undefined);
(scheduler as unknown as { running: boolean }).running = true;
return { scheduler, emit, schedule };
}
describe("Scheduler wakes on the planning -> dispatchable transition", () => {
it("schedules when planning clears in todo", () => {
const { emit, schedule } = createScheduler();
emit("task:updated", createTask({ status: "planning" }));
expect(schedule).not.toHaveBeenCalled(); // still planning — nothing to dispatch yet
emit("task:updated", createTask({ status: null }));
expect(schedule).toHaveBeenCalledTimes(1);
});
it("schedules when planning clears in triage", () => {
const { emit, schedule } = createScheduler();
emit("task:updated", createTask({ column: "triage", status: "planning" }));
emit("task:updated", createTask({ column: "triage", status: null }));
expect(schedule).toHaveBeenCalledTimes(1);
});
it("fires once per transition, not on every later update", () => {
const { emit, schedule } = createScheduler();
emit("task:updated", createTask({ status: "planning" }));
emit("task:updated", createTask({ status: null }));
emit("task:updated", createTask({ status: null }));
emit("task:updated", createTask({ status: null }));
expect(schedule).toHaveBeenCalledTimes(1);
});
it("does not schedule when planning ends in a park rather than a dispatchable state", () => {
for (const status of ["failed", "awaiting-approval", "stuck-killed"]) {
const { emit, schedule } = createScheduler();
emit("task:updated", createTask({ status: "planning" }));
emit("task:updated", createTask({ status }));
expect(schedule, status).not.toHaveBeenCalled();
}
});
it("does not schedule when the card is paused", () => {
for (const pauseFlag of ["paused", "userPaused"]) {
const { emit, schedule } = createScheduler();
emit("task:updated", createTask({ status: "planning" }));
emit("task:updated", createTask({ status: null, [pauseFlag]: true }));
expect(schedule, pauseFlag).not.toHaveBeenCalled();
}
});
it("does not schedule when the card lands in a non-schedulable column", () => {
for (const column of ["in-progress", "in-review", "done", "archived"]) {
const { emit, schedule } = createScheduler();
emit("task:updated", createTask({ status: "planning" }));
emit("task:updated", createTask({ column, status: null }));
expect(schedule, column).not.toHaveBeenCalled();
}
});
it("does not schedule for a task never seen planning", () => {
const { emit, schedule } = createScheduler();
emit("task:updated", createTask({ status: null }));
emit("task:updated", createTask({ id: "FN-OTHER", status: null }));
expect(schedule).not.toHaveBeenCalled();
});
it("clears planning tracking on delete so a reused id cannot fire a stale wake", () => {
const { scheduler, emit, schedule } = createScheduler();
emit("task:updated", createTask({ status: "planning" }));
emit("task:deleted", { id: "FN-1" });
expect(
(scheduler as unknown as { planningTaskIds: Set<string> }).planningTaskIds.has("FN-1"),
).toBe(false);
emit("task:updated", createTask({ status: null }));
expect(schedule).not.toHaveBeenCalled();
});
it("does not schedule while the scheduler is stopped", () => {
const { scheduler, emit, schedule } = createScheduler();
(scheduler as unknown as { running: boolean }).running = false;
emit("task:updated", createTask({ status: "planning" }));
emit("task:updated", createTask({ status: null }));
expect(schedule).not.toHaveBeenCalled();
});
});

View File

@@ -611,6 +611,13 @@ export class Scheduler {
private activePollMs: number | null = null;
/** Tracks which task IDs are currently paused, to detect unpause transitions. */
private pausedTaskIds = new Set<string>();
/**
* FNXC:CodingIdeasWorkflow 2026-07-25-13:10:
* Tracks task IDs last seen with status "planning", so the planning -> dispatchable transition
* can trigger an immediate scheduling pass. Plan-in-place workflows clear status without a
* task:moved, so this is the only signal that the card just became executable.
*/
private planningTaskIds = new Set<string>();
/** Tracks mission-linked tasks observed with status=failed before moveTask clears status/error. */
private failedTaskIds = new Set<string>();
/** Tracks tasks blocked by unavailable-node policy to deduplicate block log entries. */
@@ -929,6 +936,38 @@ export class Scheduler {
}
}
/*
FNXC:CodingIdeasWorkflow 2026-07-25-13:10:
Dispatch the moment planning finishes, instead of waiting out the poll timer.
This closes the second half of the "started card does nothing" gap. Triage's finalize clears
`status` in place (it deliberately skips the triage->todo move for plan-in-place workflows), so
the card becomes dispatchable via a bare task:updated with no task:moved and no pause
transition — none of the existing event wakes above fire for it. The operator therefore paid
pollIntervalMs (15s default) for planning to start AND another one for execution to start.
Same shape as the pausedTaskIds tracker above: remember ids seen mid-planning, then fire once
on the transition back to a dispatchable state. Guarded on `!task.status` so a planning ->
failed/awaiting-approval park does not trigger a pointless pass, and on column so a card
finishing planning somewhere unschedulable is ignored. schedule()'s re-entrance guard drops
the call harmlessly if a poll-based pass is already running.
*/
if (task.status === "planning") {
this.planningTaskIds.add(task.id);
} else if (this.planningTaskIds.has(task.id)) {
this.planningTaskIds.delete(task.id);
if (
this.running
&& !task.status
&& !task.paused
&& !task.userPaused
&& (task.column === "todo" || task.column === "triage")
) {
schedulerLog.log(`Task ${task.id} finished planning — triggering scheduling`);
this.schedule();
}
}
if (!this.options.prMonitor) return;
if (task.column !== "in-review") return;
if (!task.prInfo) return;
@@ -950,6 +989,9 @@ export class Scheduler {
this.lastAutoClaimFingerprint.delete(task.id);
this.options.snapshotManager?.invalidate("task:deleted");
this.pausedTaskIds.delete(task.id);
// FNXC:CodingIdeasWorkflow 2026-07-25-13:10: drop planning tracking with the other per-task
// sets so a deleted-mid-planning id cannot leak or fire a stale wake if the id is reused.
this.planningTaskIds.delete(task.id);
this.failedTaskIds.delete(task.id);
this.recentEngineTodoRequeues.delete(task.id);
this.wasNodeDispatchValidationBlocked.delete(task.id);