diff --git a/.changeset/dispatch-wake-after-planning.md b/.changeset/dispatch-wake-after-planning.md new file mode 100644 index 0000000000..86066e00ff --- /dev/null +++ b/.changeset/dispatch-wake-after-planning.md @@ -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. diff --git a/packages/engine/src/__tests__/scheduler-planning-finished-wake.test.ts b/packages/engine/src/__tests__/scheduler-planning-finished-wake.test.ts new file mode 100644 index 0000000000..d5f24b700c --- /dev/null +++ b/packages/engine/src/__tests__/scheduler-planning-finished-wake.test.ts @@ -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 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 = {}) { + 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 }).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(); + }); +}); diff --git a/packages/engine/src/scheduler.ts b/packages/engine/src/scheduler.ts index c0e2de4c7f..49db82a182 100644 --- a/packages/engine/src/scheduler.ts +++ b/packages/engine/src/scheduler.ts @@ -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(); + /** + * 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(); /** Tracks mission-linked tasks observed with status=failed before moveTask clears status/error. */ private failedTaskIds = new Set(); /** 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);