From e712b6faea72077db9532036ccbd2f2aca105777 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 25 Jul 2026 09:27:26 -0700 Subject: [PATCH] fix(engine): dispatch as soon as planning finishes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .changeset/dispatch-wake-after-planning.md | 7 + .../scheduler-planning-finished-wake.test.ts | 170 ++++++++++++++++++ packages/engine/src/scheduler.ts | 42 +++++ 3 files changed, 219 insertions(+) create mode 100644 .changeset/dispatch-wake-after-planning.md create mode 100644 packages/engine/src/__tests__/scheduler-planning-finished-wake.test.ts 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);