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) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-24 18:49:19 -07:00
parent f3a69497dc
commit 749167cbed
7 changed files with 287 additions and 12 deletions

View File

@@ -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.

View File

@@ -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();
});
});

View File

@@ -4360,7 +4360,16 @@ export function DashboardApp({ controller }: DashboardAppProps) {
return; 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.setMode("interactive");
controller.setInteractiveView("git"); controller.setInteractiveView("git");
return; return;

View File

@@ -30,6 +30,14 @@ import type {
} from "./state.js"; } from "./state.js";
import { SECTION_ORDER } 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 ───────────────────────────────────────────────────────────── // ── DashboardTUI ─────────────────────────────────────────────────────────────
// //
// Public API is identical to the old imperative class so dashboard.ts requires // Public API is identical to the old imperative class so dashboard.ts requires
@@ -80,6 +88,15 @@ export class DashboardTUI {
private lastAutoKillAt = 0; private lastAutoKillAt = 0;
clipboardFlash: { ok: boolean; at: number } | null = null; clipboardFlash: { ok: boolean; at: number } | null = null;
private clipboardFlashTimer: ReturnType<typeof setTimeout> | null = null; private clipboardFlashTimer: ReturnType<typeof setTimeout> | 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; interactiveData: InteractiveData | null = null;
interactiveView: InteractiveView = "board"; interactiveView: InteractiveView = "board";
interactiveInputLocked = false; interactiveInputLocked = false;
@@ -587,6 +604,23 @@ export class DashboardTUI {
: all.filter((e) => e.level === this.logsSeverityFilter); : 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<void> { async handleUtilityAction(key: string): Promise<void> {
if (!this.callbacks) return; if (!this.callbacks) return;
@@ -601,6 +635,13 @@ export class DashboardTUI {
case "t": case "t":
if (this.systemInfo) { if (this.systemInfo) {
const newPaused = this.systemInfo.engineMode !== "paused"; 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 newSettings = await this.callbacks.onTogglePause(newPaused);
const newEngineMode = newSettings.enginePaused ? "paused" : "active"; const newEngineMode = newSettings.enginePaused ? "paused" : "active";
this.setSystemInfo({ ...this.systemInfo, engineMode: newEngineMode }); this.setSystemInfo({ ...this.systemInfo, engineMode: newEngineMode });

View File

@@ -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");
}
}
}
});
});

View File

@@ -84,9 +84,13 @@ export type ExecutorState = "idle" | "running" | "paused" | "stopped";
* *
* The executorState is derived from: * The executorState is derived from:
* - "stopped": globalPause is true * - "stopped": globalPause is true
* - "idle": (enginePaused is true AND runningTaskCount is 0) OR not paused with nothing running * - "paused": enginePaused is true (regardless of runningTaskCount)
* - "paused": enginePaused is true AND runningTaskCount > 0
* - "running": globalPause is false AND enginePaused is false AND runningTaskCount > 0 * - "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 { export interface ExecutorStats {
/** Number of tasks currently in "in-progress" column */ /** Number of tasks currently in "in-progress" column */

View File

@@ -28,14 +28,17 @@ export interface UseExecutorStatsResult {
* Derive the executor state from globalPause, enginePaused, and runningTaskCount. * Derive the executor state from globalPause, enginePaused, and runningTaskCount.
* *
* - "stopped": globalPause is true * - "stopped": globalPause is true
* - "idle": (enginePaused is true AND runningTaskCount is 0) OR not paused with nothing running * - "paused": enginePaused is true (regardless of runningTaskCount)
* - "paused": enginePaused is true AND runningTaskCount > 0
* - "running": globalPause is false AND enginePaused is false AND runningTaskCount > 0 * - "running": globalPause is false AND enginePaused is false AND runningTaskCount > 0
* - "idle": nothing paused and nothing running
* *
* FNXC:EngineControls 2026-06-22-00:00: * 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. * `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, globalPause: boolean,
enginePaused: boolean, enginePaused: boolean,
runningTaskCount: number runningTaskCount: number
@@ -43,10 +46,7 @@ function deriveExecutorState(
if (globalPause) { if (globalPause) {
return "stopped"; return "stopped";
} }
if (enginePaused && runningTaskCount === 0) { if (enginePaused) {
return "idle";
}
if (enginePaused && runningTaskCount > 0) {
return "paused"; return "paused";
} }
// globalPause is false and enginePaused is false // 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 blockedTaskCount from tasks with blockedBy field set
* - Derives stuckTaskCount using the project's `taskStuckTimeoutMs` setting; * - Derives stuckTaskCount using the project's `taskStuckTimeoutMs` setting;
* returns 0 when the setting is undefined/disabled * 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 * - Returns ExecutorStats object with reactive updates
*/ */
const DEFAULT_API_DATA: Pick<ExecutorStats, "maxConcurrent" | "lastActivityAt"> & { const DEFAULT_API_DATA: Pick<ExecutorStats, "maxConcurrent" | "lastActivityAt"> & {