From 749167cbed7524845c3342e4a51aa0608ffe9d11 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 24 Jul 2026 18:49:19 -0700 Subject: [PATCH] fix(engine-controls): surface a paused engine instead of showing it as idle A paused engine with nothing running derived executorState "idle", so the footer badge was indistinguishable from a healthy engine waiting for work. That is exactly the state a pause settles into once in-flight tasks drain: triage and planning stall while the board keeps moving, with the pause only visible by opening the Engine Control menu. Pause state now dominates run state; the adjacent counters still report throughput. In the CLI TUI, the global `t` (Git view) branch returned before the Utilities dispatch in the same key handler, so the advertised "[t] Toggle Engine Pause" was unreachable dead UI. It now yields when Utilities owns input, and because the shortcut can stop the board, pausing takes a second `t` within 5s while resuming stays single-press. Tests assert the invariant across the whole state matrix (both pause flags x 0/1/5 running), not just the zero-running repro, plus the TUI routing, the two-press pause, single-press resume, and re-arm behavior. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/paused-engine-visibility.md | 7 + .../__tests__/engine-pause-shortcut.test.tsx | 167 ++++++++++++++++++ .../cli/src/commands/dashboard-tui/app.tsx | 11 +- .../src/commands/dashboard-tui/controller.ts | 41 +++++ .../executor-state-derivation.test.ts | 47 +++++ packages/dashboard/app/api/projects.ts | 8 +- .../dashboard/app/hooks/useExecutorStats.ts | 18 +- 7 files changed, 287 insertions(+), 12 deletions(-) create mode 100644 .changeset/paused-engine-visibility.md create mode 100644 packages/cli/src/commands/dashboard-tui/__tests__/engine-pause-shortcut.test.tsx create mode 100644 packages/dashboard/app/__tests__/executor-state-derivation.test.ts diff --git a/.changeset/paused-engine-visibility.md b/.changeset/paused-engine-visibility.md new file mode 100644 index 0000000000..bd924c28ba --- /dev/null +++ b/.changeset/paused-engine-visibility.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: A paused engine now reads "Paused" in the footer instead of "Idle", and pausing from the terminal takes two presses. +category: fix +dev: `deriveExecutorState` (dashboard `app/hooks/useExecutorStats.ts`) now returns "paused" for any `enginePaused` value regardless of `runningTaskCount`; the previous matrix mapped paused-with-zero-running to "idle". In the CLI TUI, the global `t` (Git view) branch now yields when the Utilities section owns input, making the advertised "[t] Toggle Engine Pause" reachable, and pausing requires a second `t` within `PAUSE_CONFIRM_WINDOW_MS` (5s); resuming stays single-press. diff --git a/packages/cli/src/commands/dashboard-tui/__tests__/engine-pause-shortcut.test.tsx b/packages/cli/src/commands/dashboard-tui/__tests__/engine-pause-shortcut.test.tsx new file mode 100644 index 0000000000..a290e0fcc3 --- /dev/null +++ b/packages/cli/src/commands/dashboard-tui/__tests__/engine-pause-shortcut.test.tsx @@ -0,0 +1,167 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { render } from "ink-testing-library"; +import { I18nextProvider } from "react-i18next"; +import { DashboardApp } from "../app.js"; +import { DashboardTUI } from "../controller.js"; +import { initCliI18n } from "../../../i18n/index.js"; +import type { SettingsValues, SystemInfo, TUICallbacks } from "../state.js"; + +const testI18n = initCliI18n("en"); + +function renderNode(controller: DashboardTUI) { + return React.createElement( + I18nextProvider, + { i18n: testI18n }, + React.createElement(DashboardApp, { controller }), + ); +} + +function makeSystemInfo(engineMode: "active" | "paused" = "active"): SystemInfo { + return { + host: "localhost", + port: 4040, + baseUrl: "http://localhost:4040", + authEnabled: false, + engineMode, + fileWatcher: true, + startTimeMs: Date.now(), + } as SystemInfo; +} + +function makeSettings(enginePaused: boolean): SettingsValues { + return { + maxConcurrent: 1, + maxWorktrees: 2, + autoMerge: false, + mergeStrategy: "direct", + pollIntervalMs: 60000, + enginePaused, + globalPause: false, + remoteActiveProvider: null, + remoteShortLivedEnabled: false, + remoteShortLivedTtlMs: 900000, + }; +} + +/** + * Wire the minimum callback surface `handleUtilityAction` needs, echoing the + * requested pause back through settings the way dashboard.ts's onTogglePause does. + */ +function attachCallbacks(controller: DashboardTUI) { + const onTogglePause = vi.fn(async (paused: boolean) => makeSettings(paused)); + controller.setCallbacks({ + onTogglePause, + onRefreshStats: async () => {}, + onClearLogs: () => {}, + } as unknown as TUICallbacks); + return onTogglePause; +} + +async function settle() { + await new Promise((resolve) => setTimeout(resolve, 25)); +} + +/* +FNXC:DashboardTui 2026-07-24-18:40: +Two defects covered here, both found while diagnosing an engine that sat paused +with no visible cause: + +1. Routing — the global `t` (Git view) branch returned before the Utilities + dispatch in the same key handler, so the Utilities panel's advertised + "[t] Toggle Engine Pause" was unreachable dead UI. +2. Safety — once reachable, one unmodified keystroke would silently stop triage, + planning, and dispatch. Pausing now takes two presses inside a short window; + resuming is not destructive and stays single-press. +*/ +describe("Utilities [t] engine pause shortcut", () => { + it("still opens the Git view when Utilities does not own input", async () => { + const controller = new DashboardTUI(); + controller.setSystemInfo(makeSystemInfo()); + const onTogglePause = attachCallbacks(controller); + controller.setActiveSection("system"); + + const { stdin, unmount } = render(renderNode(controller)); + stdin.write("t"); + await settle(); + + const snapshot = controller.getSnapshot(); + expect(snapshot.mode).toBe("interactive"); + expect(snapshot.interactiveView).toBe("git"); + expect(onTogglePause).not.toHaveBeenCalled(); + unmount(); + }); + + it("routes to the pause action instead of the Git view when Utilities is focused", async () => { + const controller = new DashboardTUI(); + controller.setSystemInfo(makeSystemInfo()); + attachCallbacks(controller); + controller.setActiveSection("utilities"); + + const { stdin, unmount } = render(renderNode(controller)); + stdin.write("t"); + await settle(); + + // First press arms the confirmation; it must not have escaped to Git view. + expect(controller.getSnapshot().mode).toBe("status"); + expect(controller.getSnapshot().interactiveView).not.toBe("git"); + unmount(); + }); + + it("requires a second press to pause, and does not pause on the first", async () => { + const controller = new DashboardTUI(); + controller.setSystemInfo(makeSystemInfo()); + const onTogglePause = attachCallbacks(controller); + controller.setActiveSection("utilities"); + + const { stdin, unmount } = render(renderNode(controller)); + + stdin.write("t"); + await settle(); + expect(onTogglePause).not.toHaveBeenCalled(); + + stdin.write("t"); + await settle(); + expect(onTogglePause).toHaveBeenCalledTimes(1); + expect(onTogglePause).toHaveBeenCalledWith(true); + expect(controller.getSnapshot().systemInfo?.engineMode).toBe("paused"); + unmount(); + }); + + it("resumes on a single press without confirmation", async () => { + const controller = new DashboardTUI(); + controller.setSystemInfo(makeSystemInfo("paused")); + const onTogglePause = attachCallbacks(controller); + controller.setActiveSection("utilities"); + + const { stdin, unmount } = render(renderNode(controller)); + + stdin.write("t"); + await settle(); + expect(onTogglePause).toHaveBeenCalledTimes(1); + expect(onTogglePause).toHaveBeenCalledWith(false); + expect(controller.getSnapshot().systemInfo?.engineMode).toBe("active"); + unmount(); + }); + + it("re-arms rather than pausing twice when pressed a third time", async () => { + const controller = new DashboardTUI(); + controller.setSystemInfo(makeSystemInfo()); + const onTogglePause = attachCallbacks(controller); + controller.setActiveSection("utilities"); + + const { stdin, unmount } = render(renderNode(controller)); + + stdin.write("t"); // arm + await settle(); + stdin.write("t"); // confirm -> paused + await settle(); + stdin.write("t"); // engine is paused now: resume is single-press + await settle(); + + expect(onTogglePause).toHaveBeenCalledTimes(2); + expect(onTogglePause).toHaveBeenNthCalledWith(1, true); + expect(onTogglePause).toHaveBeenNthCalledWith(2, false); + unmount(); + }); +}); diff --git a/packages/cli/src/commands/dashboard-tui/app.tsx b/packages/cli/src/commands/dashboard-tui/app.tsx index 11e647b89c..2513e1c3c8 100644 --- a/packages/cli/src/commands/dashboard-tui/app.tsx +++ b/packages/cli/src/commands/dashboard-tui/app.tsx @@ -4360,7 +4360,16 @@ export function DashboardApp({ controller }: DashboardAppProps) { return; } - if (input === "t" || input === "T") { + /* + FNXC:DashboardTui 2026-07-24-18:40: + `t` is overloaded: global → Git view, Utilities section → Toggle Engine Pause. + The global branch runs first in this same handler and returned unconditionally, + so `handleUtilityAction("t")` below was unreachable and the Utilities panel + advertised a shortcut that did nothing. Yield to the Utilities dispatch when + that section owns focus (matching the `f` / logs-focus precedent above). + */ + const utilitiesOwnsInput = state.mode === "status" && state.activeSection === "utilities" && !logsFocused; + if ((input === "t" || input === "T") && !utilitiesOwnsInput) { controller.setMode("interactive"); controller.setInteractiveView("git"); return; diff --git a/packages/cli/src/commands/dashboard-tui/controller.ts b/packages/cli/src/commands/dashboard-tui/controller.ts index bb85aa9d00..768cd41e01 100644 --- a/packages/cli/src/commands/dashboard-tui/controller.ts +++ b/packages/cli/src/commands/dashboard-tui/controller.ts @@ -30,6 +30,14 @@ import type { } from "./state.js"; import { SECTION_ORDER } from "./state.js"; +/* +FNXC:DashboardTui 2026-07-24-18:40: +Window in which the second Utilities [t] press confirms an engine pause. Long +enough to read the warning line in the log pane, short enough that an unrelated +`t` minutes later cannot land on an armed confirmation. +*/ +export const PAUSE_CONFIRM_WINDOW_MS = 5_000; + // ── DashboardTUI ───────────────────────────────────────────────────────────── // // Public API is identical to the old imperative class so dashboard.ts requires @@ -80,6 +88,15 @@ export class DashboardTUI { private lastAutoKillAt = 0; clipboardFlash: { ok: boolean; at: number } | null = null; private clipboardFlashTimer: ReturnType | null = null; + /* + FNXC:DashboardTui 2026-07-24-18:40: + Pausing the engine from Utilities [t] is a silent, board-wide stop: triage and + planning stall while in-flight tasks drain, so the board keeps moving for + minutes and the cause is invisible from the terminal. One unmodified keystroke + must not do that — the first `t` arms this deadline and only a second `t` + inside the window pauses. Resuming is not destructive and stays single-press. + */ + private pausePendingConfirmUntil = 0; interactiveData: InteractiveData | null = null; interactiveView: InteractiveView = "board"; interactiveInputLocked = false; @@ -587,6 +604,23 @@ export class DashboardTUI { : all.filter((e) => e.level === this.logsSeverityFilter); } + /** + * Two-press confirmation for an engine pause initiated from Utilities [t]. + * + * Returns true when the caller may pause: either a prior press armed the + * window and it has not expired, or this call arms it and returns false so the + * caller can prompt. Any successful confirmation disarms, so a third press + * re-arms rather than toggling twice. + */ + private consumePauseConfirmation(now: number = Date.now()): boolean { + if (this.pausePendingConfirmUntil > now) { + this.pausePendingConfirmUntil = 0; + return true; + } + this.pausePendingConfirmUntil = now + PAUSE_CONFIRM_WINDOW_MS; + return false; + } + async handleUtilityAction(key: string): Promise { if (!this.callbacks) return; @@ -601,6 +635,13 @@ export class DashboardTUI { case "t": if (this.systemInfo) { const newPaused = this.systemInfo.engineMode !== "paused"; + if (newPaused && !this.consumePauseConfirmation()) { + this.warn( + `Press [t] again within ${Math.round(PAUSE_CONFIRM_WINDOW_MS / 1000)}s to pause the engine (stops triage, planning, and new dispatch).`, + "engine", + ); + break; + } const newSettings = await this.callbacks.onTogglePause(newPaused); const newEngineMode = newSettings.enginePaused ? "paused" : "active"; this.setSystemInfo({ ...this.systemInfo, engineMode: newEngineMode }); diff --git a/packages/dashboard/app/__tests__/executor-state-derivation.test.ts b/packages/dashboard/app/__tests__/executor-state-derivation.test.ts new file mode 100644 index 0000000000..fda715e73b --- /dev/null +++ b/packages/dashboard/app/__tests__/executor-state-derivation.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; +import { deriveExecutorState } from "../hooks/useExecutorStats"; + +/* +FNXC:EngineControls 2026-07-24-18:35: +Regression cover for the pause-reads-as-idle bug: a paused engine with nothing +running rendered the same "Idle" footer badge as a healthy engine waiting for +work, so operators could not tell why triage/planning had stopped. The invariant +under test is pause-dominates-run-state across the whole matrix, not just the +reported zero-running repro — asserted at every running count and against both +pause flags, since globalPause must keep out-ranking enginePaused. +*/ +describe("deriveExecutorState", () => { + it("reports paused at every running count, including zero", () => { + for (const runningTaskCount of [0, 1, 5]) { + expect(deriveExecutorState(false, true, runningTaskCount)).toBe("paused"); + } + }); + + it("reports stopped whenever globalPause is set, outranking enginePaused", () => { + for (const enginePaused of [false, true]) { + for (const runningTaskCount of [0, 1, 5]) { + expect(deriveExecutorState(true, enginePaused, runningTaskCount)).toBe("stopped"); + } + } + }); + + it("reports idle only when unpaused with nothing running", () => { + expect(deriveExecutorState(false, false, 0)).toBe("idle"); + }); + + it("reports running when unpaused with work in flight", () => { + expect(deriveExecutorState(false, false, 1)).toBe("running"); + expect(deriveExecutorState(false, false, 5)).toBe("running"); + }); + + it("never reports idle while either pause flag is set", () => { + for (const globalPause of [false, true]) { + for (const enginePaused of [false, true]) { + if (!globalPause && !enginePaused) continue; + for (const runningTaskCount of [0, 1, 5]) { + expect(deriveExecutorState(globalPause, enginePaused, runningTaskCount)).not.toBe("idle"); + } + } + } + }); +}); diff --git a/packages/dashboard/app/api/projects.ts b/packages/dashboard/app/api/projects.ts index 6fb910fc48..0967c10a42 100644 --- a/packages/dashboard/app/api/projects.ts +++ b/packages/dashboard/app/api/projects.ts @@ -84,9 +84,13 @@ export type ExecutorState = "idle" | "running" | "paused" | "stopped"; * * The executorState is derived from: * - "stopped": globalPause is true - * - "idle": (enginePaused is true AND runningTaskCount is 0) OR not paused with nothing running - * - "paused": enginePaused is true AND runningTaskCount > 0 + * - "paused": enginePaused is true (regardless of runningTaskCount) * - "running": globalPause is false AND enginePaused is false AND runningTaskCount > 0 + * - "idle": nothing paused and nothing running + * + * FNXC:EngineControls 2026-07-24-18:35: pause state dominates run state so a paused + * engine never reads "Idle" once in-flight work drains. See deriveExecutorState in + * app/hooks/useExecutorStats.ts for the rationale. */ export interface ExecutorStats { /** Number of tasks currently in "in-progress" column */ diff --git a/packages/dashboard/app/hooks/useExecutorStats.ts b/packages/dashboard/app/hooks/useExecutorStats.ts index af49598dde..8ffa021c79 100644 --- a/packages/dashboard/app/hooks/useExecutorStats.ts +++ b/packages/dashboard/app/hooks/useExecutorStats.ts @@ -26,16 +26,19 @@ export interface UseExecutorStatsResult { /** * Derive the executor state from globalPause, enginePaused, and runningTaskCount. - * + * * - "stopped": globalPause is true - * - "idle": (enginePaused is true AND runningTaskCount is 0) OR not paused with nothing running - * - "paused": enginePaused is true AND runningTaskCount > 0 + * - "paused": enginePaused is true (regardless of runningTaskCount) * - "running": globalPause is false AND enginePaused is false AND runningTaskCount > 0 + * - "idle": nothing paused and nothing running * * FNXC:EngineControls 2026-06-22-00:00: * `globalPause` dominates the footer state matrix so an operator-stopped engine is distinct from idle even if in-progress tasks still exist. + * + * FNXC:EngineControls 2026-07-24-18:35: + * A paused engine must read "Paused" even when nothing is running. The prior matrix mapped (enginePaused && runningTaskCount === 0) to "idle", so the footer was indistinguishable from a healthy engine waiting for work — and that is precisely the state a pause produces once in-flight tasks drain. Operators saw triage tasks sitting untouched with an "Idle" badge and no visible cause; the pause is only otherwise surfaced by the Engine Control menu item label, which requires opening the menu. Pause state now dominates run state: the badge reports the operator-set condition, and the separate running/queued counters already report throughput. */ -function deriveExecutorState( +export function deriveExecutorState( globalPause: boolean, enginePaused: boolean, runningTaskCount: number @@ -43,10 +46,7 @@ function deriveExecutorState( if (globalPause) { return "stopped"; } - if (enginePaused && runningTaskCount === 0) { - return "idle"; - } - if (enginePaused && runningTaskCount > 0) { + if (enginePaused) { return "paused"; } // globalPause is false and enginePaused is false @@ -121,7 +121,7 @@ function hasActionableBlockedBy(blockedBy: Task["blockedBy"] | string[] | null): * - Derives blockedTaskCount from tasks with blockedBy field set * - Derives stuckTaskCount using the project's `taskStuckTimeoutMs` setting; * returns 0 when the setting is undefined/disabled - * - Derives executorState from globalPause and enginePaused flags, with globalPause mapping to "stopped" + * - Derives executorState from globalPause and enginePaused flags, with globalPause mapping to "stopped" and enginePaused to "paused" at any running count * - Returns ExecutorStats object with reactive updates */ const DEFAULT_API_DATA: Pick & {