feat(KB-157): add soft-pause (enginePaused) alongside hard-stop (globalPause)
- Add enginePaused field to Settings type with soft-pause semantics (drain queue, don't kill agents) - Gate scheduler, triage, auto-merge, and periodic merge retry on enginePaused - Add Pause and Stop buttons to dashboard Header replacing single toggle - Wire enginePaused state through App with optimistic toggle and unpause resume logic - Add tests for scheduler, triage, dashboard CLI, Header, and App covering pause behavior
This commit is contained in:
5
.changeset/pause-stop-engine.md
Normal file
5
.changeset/pause-stop-engine.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@dustinbyrne/kb": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Split engine control into Pause (soft) and Stop (hard). The dashboard Header now shows two buttons: "Pause AI engine" stops new work from being dispatched while letting in-flight agents finish gracefully, and "Stop AI engine" (previously the only Pause button) immediately kills all active agent sessions. A new `enginePaused` setting field controls the soft-pause state alongside the existing `globalPause` hard-stop.
|
||||||
@@ -76,6 +76,8 @@ vi.mock("@kb/engine", async (importOriginal) => {
|
|||||||
stop: vi.fn(),
|
stop: vi.fn(),
|
||||||
})),
|
})),
|
||||||
aiMergeTask: vi.fn().mockResolvedValue({ merged: true }),
|
aiMergeTask: vi.fn().mockResolvedValue({ merged: true }),
|
||||||
|
scanIdleWorktrees: vi.fn().mockResolvedValue([]),
|
||||||
|
cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ vi.mock("@kb/engine", async (importOriginal) => {
|
|||||||
stop: vi.fn(),
|
stop: vi.fn(),
|
||||||
})),
|
})),
|
||||||
aiMergeTask: vi.fn().mockImplementation(() => Promise.resolve({ merged: true })),
|
aiMergeTask: vi.fn().mockImplementation(() => Promise.resolve({ merged: true })),
|
||||||
|
scanIdleWorktrees: vi.fn().mockResolvedValue([]),
|
||||||
|
cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -456,3 +458,116 @@ describe("runDashboard — port fallback on EADDRINUSE", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("runDashboard — enginePaused (soft pause)", () => {
|
||||||
|
let mockStore: ReturnType<typeof makeMockStore>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
capturedExecutorOpts = undefined;
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockStore = makeMockStore();
|
||||||
|
const { TaskStore } = await import("@kb/core");
|
||||||
|
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
|
||||||
|
const engine = await import("@kb/engine");
|
||||||
|
(engine.aiMergeTask as ReturnType<typeof vi.fn>).mockImplementation(() =>
|
||||||
|
Promise.resolve({ merged: true }),
|
||||||
|
);
|
||||||
|
(engine.TaskExecutor as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||||||
|
(_store: unknown, _cwd: unknown, opts: unknown) => {
|
||||||
|
capturedExecutorOpts = opts as Record<string, unknown>;
|
||||||
|
return { resumeOrphaned: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not enqueue tasks for auto-merge when enginePaused on task:moved", async () => {
|
||||||
|
mockStore.getSettings.mockResolvedValue({
|
||||||
|
maxConcurrent: 1,
|
||||||
|
maxWorktrees: 2,
|
||||||
|
autoMerge: true,
|
||||||
|
pollIntervalMs: 60_000,
|
||||||
|
enginePaused: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await runDashboard(0, { open: false });
|
||||||
|
|
||||||
|
const { aiMergeTask } = await import("@kb/engine");
|
||||||
|
|
||||||
|
// Emit task:moved
|
||||||
|
mockStore.emit("task:moved", {
|
||||||
|
task: { id: "KB-EP1", column: "in-review", paused: false },
|
||||||
|
from: "in-progress",
|
||||||
|
to: "in-review",
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
|
||||||
|
expect(aiMergeTask).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls executor.resumeOrphaned() when enginePaused transitions true → false", async () => {
|
||||||
|
const { TaskExecutor } = await import("@kb/engine");
|
||||||
|
const resumeOrphaned = vi.fn().mockResolvedValue(undefined);
|
||||||
|
(TaskExecutor as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||||||
|
(_store: unknown, _cwd: unknown, opts: unknown) => {
|
||||||
|
capturedExecutorOpts = opts as Record<string, unknown>;
|
||||||
|
return { resumeOrphaned };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await runDashboard(0, { open: false });
|
||||||
|
|
||||||
|
// Clear the startup call
|
||||||
|
resumeOrphaned.mockClear();
|
||||||
|
|
||||||
|
// Trigger engine unpause event
|
||||||
|
mockStore.emit("settings:updated", {
|
||||||
|
settings: { enginePaused: false, maxConcurrent: 1, autoMerge: false },
|
||||||
|
previous: { enginePaused: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
|
||||||
|
expect(resumeOrphaned).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sweeps merge queue on engine unpause when autoMerge is enabled", async () => {
|
||||||
|
mockStore.getSettings.mockResolvedValue({
|
||||||
|
maxConcurrent: 1,
|
||||||
|
maxWorktrees: 2,
|
||||||
|
autoMerge: true,
|
||||||
|
pollIntervalMs: 60_000,
|
||||||
|
enginePaused: false,
|
||||||
|
globalPause: false,
|
||||||
|
});
|
||||||
|
mockStore.listTasks.mockResolvedValue([
|
||||||
|
{ id: "KB-EP2", column: "in-review", paused: false },
|
||||||
|
]);
|
||||||
|
mockStore.getTask = vi.fn().mockImplementation(async (id: string) => ({
|
||||||
|
id,
|
||||||
|
column: "in-review",
|
||||||
|
paused: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { aiMergeTask } = await import("@kb/engine");
|
||||||
|
(aiMergeTask as ReturnType<typeof vi.fn>).mockImplementation(() =>
|
||||||
|
Promise.resolve({ merged: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await runDashboard(0, { open: false });
|
||||||
|
|
||||||
|
(aiMergeTask as ReturnType<typeof vi.fn>).mockClear();
|
||||||
|
|
||||||
|
mockStore.emit("settings:updated", {
|
||||||
|
settings: { enginePaused: false, maxConcurrent: 1, autoMerge: true },
|
||||||
|
previous: { enginePaused: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
|
||||||
|
const mergedIds = (aiMergeTask as ReturnType<typeof vi.fn>).mock.calls.map(
|
||||||
|
(call: any[]) => call[2],
|
||||||
|
);
|
||||||
|
expect(mergedIds).toContain("KB-EP2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -142,8 +142,8 @@ export async function runDashboard(port: number, opts: { open?: boolean } = {})
|
|||||||
try {
|
try {
|
||||||
// Re-check autoMerge and globalPause before each merge (setting may have been toggled)
|
// Re-check autoMerge and globalPause before each merge (setting may have been toggled)
|
||||||
const settings = await store.getSettings();
|
const settings = await store.getSettings();
|
||||||
if (settings.globalPause) {
|
if (settings.globalPause || settings.enginePaused) {
|
||||||
console.log(`[auto-merge] Skipping ${taskId} — global pause active`);
|
console.log(`[auto-merge] Skipping ${taskId} — ${settings.globalPause ? "global pause" : "engine paused"} active`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!settings.autoMerge) {
|
if (!settings.autoMerge) {
|
||||||
@@ -180,7 +180,7 @@ export async function runDashboard(port: number, opts: { open?: boolean } = {})
|
|||||||
if (task.paused) return;
|
if (task.paused) return;
|
||||||
try {
|
try {
|
||||||
const settings = await store.getSettings();
|
const settings = await store.getSettings();
|
||||||
if (settings.globalPause) return;
|
if (settings.globalPause || settings.enginePaused) return;
|
||||||
if (!settings.autoMerge) return;
|
if (!settings.autoMerge) return;
|
||||||
enqueueMerge(task.id);
|
enqueueMerge(task.id);
|
||||||
} catch { /* ignore settings read errors */ }
|
} catch { /* ignore settings read errors */ }
|
||||||
@@ -273,6 +273,31 @@ export async function runDashboard(port: number, opts: { open?: boolean } = {})
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Immediate engine-unpause: resume orphans + merge sweep ────────
|
||||||
|
// When enginePaused transitions from true → false, same resume logic
|
||||||
|
// as globalPause unpause: pick up orphaned tasks and sweep merge queue.
|
||||||
|
store.on("settings:updated", async ({ settings: s, previous: prev }) => {
|
||||||
|
if (prev.enginePaused && !s.enginePaused) {
|
||||||
|
console.log("[engine] Engine unpaused — resuming agentic activity");
|
||||||
|
cachedMaxConcurrent = s.maxConcurrent ?? cachedMaxConcurrent;
|
||||||
|
|
||||||
|
executor.resumeOrphaned().catch((err) =>
|
||||||
|
console.error("[engine] Failed to resume orphaned tasks on engine unpause:", err),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (s.autoMerge) {
|
||||||
|
try {
|
||||||
|
const tasks = await store.listTasks();
|
||||||
|
for (const t of tasks) {
|
||||||
|
if (t.column === "in-review" && !t.paused) {
|
||||||
|
enqueueMerge(t.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore errors in unpause sweep */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Periodic retry: catch failed merges on each poll cycle ────────
|
// ── Periodic retry: catch failed merges on each poll cycle ────────
|
||||||
// Uses a setTimeout chain so the interval dynamically follows
|
// Uses a setTimeout chain so the interval dynamically follows
|
||||||
// settings.pollIntervalMs without requiring an engine restart.
|
// settings.pollIntervalMs without requiring an engine restart.
|
||||||
@@ -285,7 +310,7 @@ export async function runDashboard(port: number, opts: { open?: boolean } = {})
|
|||||||
const s = await store.getSettings();
|
const s = await store.getSettings();
|
||||||
// Refresh the cached limit so the semaphore picks up live changes
|
// Refresh the cached limit so the semaphore picks up live changes
|
||||||
cachedMaxConcurrent = s.maxConcurrent;
|
cachedMaxConcurrent = s.maxConcurrent;
|
||||||
if (!s.globalPause && s.autoMerge) {
|
if (!s.globalPause && !s.enginePaused && s.autoMerge) {
|
||||||
const tasks = await store.listTasks();
|
const tasks = await store.listTasks();
|
||||||
for (const t of tasks) {
|
for (const t of tasks) {
|
||||||
if (t.column === "in-review" && !t.paused) {
|
if (t.column === "in-review" && !t.paused) {
|
||||||
|
|||||||
@@ -95,10 +95,19 @@ export interface TaskCreateInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
/** When true, all automated agent activity is halted — triage specification,
|
/** Hard stop: when true, all automated agent activity is **immediately**
|
||||||
* task scheduling, execution, and auto-merge. Acts as a global emergency stop
|
* terminated — active triage, execution, and merge agent sessions are
|
||||||
* for the entire AI engine. Individual per-task pause flags are unaffected. */
|
* killed, and the scheduler stops dispatching new work. Acts as a
|
||||||
|
* global emergency stop for the entire AI engine.
|
||||||
|
* Individual per-task pause flags are unaffected. */
|
||||||
globalPause?: boolean;
|
globalPause?: boolean;
|
||||||
|
/** Soft pause: when true, the scheduler and triage processor stop
|
||||||
|
* dispatching **new** work (scheduling, triage specification, and
|
||||||
|
* auto-merge), but in-flight agent sessions are allowed to finish
|
||||||
|
* gracefully. Use this to drain the queue without killing active work.
|
||||||
|
* Has no effect when {@link globalPause} is also true (hard stop
|
||||||
|
* takes precedence). */
|
||||||
|
enginePaused?: boolean;
|
||||||
/** Maximum number of concurrent AI agents across all activity types
|
/** Maximum number of concurrent AI agents across all activity types
|
||||||
* (triage specification, task execution, and merge operations). */
|
* (triage specification, task execution, and merge operations). */
|
||||||
maxConcurrent: number;
|
maxConcurrent: number;
|
||||||
@@ -142,6 +151,7 @@ export interface Settings {
|
|||||||
|
|
||||||
export const DEFAULT_SETTINGS: Settings = {
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
globalPause: false,
|
globalPause: false,
|
||||||
|
enginePaused: false,
|
||||||
maxConcurrent: 2,
|
maxConcurrent: 2,
|
||||||
maxWorktrees: 4,
|
maxWorktrees: 4,
|
||||||
pollIntervalMs: 15000,
|
pollIntervalMs: 15000,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ function AppInner() {
|
|||||||
const [maxConcurrent, setMaxConcurrent] = useState(2);
|
const [maxConcurrent, setMaxConcurrent] = useState(2);
|
||||||
const [autoMerge, setAutoMerge] = useState(false);
|
const [autoMerge, setAutoMerge] = useState(false);
|
||||||
const [globalPaused, setGlobalPaused] = useState(false);
|
const [globalPaused, setGlobalPaused] = useState(false);
|
||||||
|
const [enginePaused, setEnginePaused] = useState(false);
|
||||||
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask } = useTasks();
|
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask } = useTasks();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -28,6 +29,7 @@ function AppInner() {
|
|||||||
.then((s) => {
|
.then((s) => {
|
||||||
setAutoMerge(!!s.autoMerge);
|
setAutoMerge(!!s.autoMerge);
|
||||||
setGlobalPaused(!!s.globalPause);
|
setGlobalPaused(!!s.globalPause);
|
||||||
|
setEnginePaused(!!s.enginePaused);
|
||||||
})
|
})
|
||||||
.catch(() => {/* keep default */});
|
.catch(() => {/* keep default */});
|
||||||
fetchAuthStatus()
|
fetchAuthStatus()
|
||||||
@@ -73,6 +75,16 @@ function AppInner() {
|
|||||||
}
|
}
|
||||||
}, [globalPaused]);
|
}, [globalPaused]);
|
||||||
|
|
||||||
|
const handleToggleEnginePause = useCallback(async () => {
|
||||||
|
const next = !enginePaused;
|
||||||
|
setEnginePaused(next);
|
||||||
|
try {
|
||||||
|
await updateSettings({ enginePaused: next });
|
||||||
|
} catch {
|
||||||
|
setEnginePaused(!next); // revert on failure
|
||||||
|
}
|
||||||
|
}, [enginePaused]);
|
||||||
|
|
||||||
const handleDetailOpen = useCallback((task: TaskDetail) => {
|
const handleDetailOpen = useCallback((task: TaskDetail) => {
|
||||||
setDetailTask(task);
|
setDetailTask(task);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -84,7 +96,9 @@ function AppInner() {
|
|||||||
<Header
|
<Header
|
||||||
onOpenSettings={() => setSettingsOpen(true)}
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
globalPaused={globalPaused}
|
globalPaused={globalPaused}
|
||||||
|
enginePaused={enginePaused}
|
||||||
onToggleGlobalPause={handleToggleGlobalPause}
|
onToggleGlobalPause={handleToggleGlobalPause}
|
||||||
|
onToggleEnginePause={handleToggleEnginePause}
|
||||||
/>
|
/>
|
||||||
<Board
|
<Board
|
||||||
tasks={tasks}
|
tasks={tasks}
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
import { Settings, Pause, Play } from "lucide-react";
|
import { Settings, Pause, Play, Square } from "lucide-react";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
onOpenSettings?: () => void;
|
onOpenSettings?: () => void;
|
||||||
globalPaused?: boolean;
|
globalPaused?: boolean;
|
||||||
|
enginePaused?: boolean;
|
||||||
onToggleGlobalPause?: () => void;
|
onToggleGlobalPause?: () => void;
|
||||||
|
onToggleEnginePause?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Header({ onOpenSettings, globalPaused, onToggleGlobalPause }: HeaderProps) {
|
export function Header({
|
||||||
|
onOpenSettings,
|
||||||
|
globalPaused,
|
||||||
|
enginePaused,
|
||||||
|
onToggleGlobalPause,
|
||||||
|
onToggleEnginePause,
|
||||||
|
}: HeaderProps) {
|
||||||
return (
|
return (
|
||||||
<header className="header">
|
<header className="header">
|
||||||
<div className="header-left">
|
<div className="header-left">
|
||||||
@@ -15,12 +23,22 @@ export function Header({ onOpenSettings, globalPaused, onToggleGlobalPause }: He
|
|||||||
<span className="logo-sub">board</span>
|
<span className="logo-sub">board</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
|
{/* Pause button (soft pause): stops new work, lets agents finish */}
|
||||||
<button
|
<button
|
||||||
className={`btn-icon${globalPaused ? " btn-icon--paused" : ""}`}
|
className={`btn-icon${enginePaused ? " btn-icon--paused" : ""}`}
|
||||||
onClick={onToggleGlobalPause}
|
onClick={onToggleEnginePause}
|
||||||
title={globalPaused ? "Resume AI engine" : "Pause AI engine"}
|
title={enginePaused ? "Resume AI engine" : "Pause AI engine"}
|
||||||
|
disabled={!!globalPaused}
|
||||||
>
|
>
|
||||||
{globalPaused ? <Play size={16} /> : <Pause size={16} />}
|
{enginePaused ? <Play size={16} /> : <Pause size={16} />}
|
||||||
|
</button>
|
||||||
|
{/* Stop button (hard stop): kills all agents immediately */}
|
||||||
|
<button
|
||||||
|
className={`btn-icon${globalPaused ? " btn-icon--stopped" : ""}`}
|
||||||
|
onClick={onToggleGlobalPause}
|
||||||
|
title={globalPaused ? "Start AI engine" : "Stop AI engine"}
|
||||||
|
>
|
||||||
|
{globalPaused ? <Play size={16} /> : <Square size={16} />}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn-icon" onClick={onOpenSettings} title="Settings">
|
<button className="btn-icon" onClick={onOpenSettings} title="Settings">
|
||||||
<Settings size={16} />
|
<Settings size={16} />
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ describe("App auto-open Settings on unauthenticated", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("App global pause", () => {
|
describe("App global pause (hard stop)", () => {
|
||||||
it("initializes global pause state from fetchSettings", async () => {
|
it("initializes global pause state from fetchSettings", async () => {
|
||||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
...defaultSettings,
|
...defaultSettings,
|
||||||
@@ -142,13 +142,13 @@ describe("App global pause", () => {
|
|||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
// When globally paused, the button should show "Resume AI engine"
|
// When globally paused, the stop button should show "Start AI engine"
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTitle("Resume AI engine")).toBeTruthy();
|
expect(screen.getByTitle("Start AI engine")).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows Pause button when globalPause is false", async () => {
|
it("shows Stop button when globalPause is false", async () => {
|
||||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
...defaultSettings,
|
...defaultSettings,
|
||||||
globalPause: false,
|
globalPause: false,
|
||||||
@@ -157,7 +157,7 @@ describe("App global pause", () => {
|
|||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTitle("Pause AI engine")).toBeTruthy();
|
expect(screen.getByTitle("Stop AI engine")).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -171,15 +171,15 @@ describe("App global pause", () => {
|
|||||||
|
|
||||||
// Wait for initial render
|
// Wait for initial render
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTitle("Pause AI engine")).toBeTruthy();
|
expect(screen.getByTitle("Stop AI engine")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Click the pause button
|
// Click the stop button
|
||||||
fireEvent.click(screen.getByTitle("Pause AI engine"));
|
fireEvent.click(screen.getByTitle("Stop AI engine"));
|
||||||
|
|
||||||
// Should optimistically switch to "Resume" state
|
// Should optimistically switch to "Start" state
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTitle("Resume AI engine")).toBeTruthy();
|
expect(screen.getByTitle("Start AI engine")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Should call updateSettings with globalPause: true
|
// Should call updateSettings with globalPause: true
|
||||||
@@ -196,15 +196,68 @@ describe("App global pause", () => {
|
|||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTitle("Pause AI engine")).toBeTruthy();
|
expect(screen.getByTitle("Stop AI engine")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Click the pause button — will fail
|
// Click the stop button — will fail
|
||||||
fireEvent.click(screen.getByTitle("Pause AI engine"));
|
fireEvent.click(screen.getByTitle("Stop AI engine"));
|
||||||
|
|
||||||
|
// Should revert back to "Stop" state after failure
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTitle("Stop AI engine")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("App engine pause (soft pause)", () => {
|
||||||
|
it("initializes engine pause state from fetchSettings", async () => {
|
||||||
|
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
...defaultSettings,
|
||||||
|
enginePaused: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
// When engine is paused, the pause button should show "Resume AI engine"
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTitle("Resume AI engine")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Pause button when enginePaused is false", async () => {
|
||||||
|
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
...defaultSettings,
|
||||||
|
enginePaused: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
// Should revert back to "Pause" state after failure
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTitle("Pause AI engine")).toBeTruthy();
|
expect(screen.getByTitle("Pause AI engine")).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("toggles engine pause state and calls updateSettings", async () => {
|
||||||
|
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
...defaultSettings,
|
||||||
|
enginePaused: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTitle("Pause AI engine")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click the pause button
|
||||||
|
fireEvent.click(screen.getByTitle("Pause AI engine"));
|
||||||
|
|
||||||
|
// Should optimistically switch to "Resume" state
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTitle("Resume AI engine")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should call updateSettings with enginePaused: true
|
||||||
|
expect(updateSettings).toHaveBeenCalledWith({ enginePaused: true });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,35 +26,91 @@ describe("Header", () => {
|
|||||||
expect(btn).toBeDefined();
|
expect(btn).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Pause button (soft pause) ────────────────────────────────────
|
||||||
|
|
||||||
it("renders pause button with 'Pause AI engine' title when not paused", () => {
|
it("renders pause button with 'Pause AI engine' title when not paused", () => {
|
||||||
render(<Header globalPaused={false} />);
|
render(<Header enginePaused={false} />);
|
||||||
const btn = screen.getByTitle("Pause AI engine");
|
const btn = screen.getByTitle("Pause AI engine");
|
||||||
expect(btn).toBeDefined();
|
expect(btn).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders play button with 'Resume AI engine' title when paused", () => {
|
it("renders play button with 'Resume AI engine' title when engine is paused", () => {
|
||||||
render(<Header globalPaused={true} />);
|
render(<Header enginePaused={true} />);
|
||||||
const btn = screen.getByTitle("Resume AI engine");
|
const btn = screen.getByTitle("Resume AI engine");
|
||||||
expect(btn).toBeDefined();
|
expect(btn).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onToggleGlobalPause when pause button is clicked", () => {
|
it("calls onToggleEnginePause when pause button is clicked", () => {
|
||||||
const onToggle = vi.fn();
|
const onToggle = vi.fn();
|
||||||
render(<Header globalPaused={false} onToggleGlobalPause={onToggle} />);
|
render(<Header enginePaused={false} onToggleEnginePause={onToggle} />);
|
||||||
const btn = screen.getByTitle("Pause AI engine");
|
const btn = screen.getByTitle("Pause AI engine");
|
||||||
fireEvent.click(btn);
|
fireEvent.click(btn);
|
||||||
expect(onToggle).toHaveBeenCalledOnce();
|
expect(onToggle).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies btn-icon--paused class when paused", () => {
|
it("applies btn-icon--paused class when engine is paused", () => {
|
||||||
render(<Header globalPaused={true} />);
|
render(<Header enginePaused={true} />);
|
||||||
const btn = screen.getByTitle("Resume AI engine");
|
const btn = screen.getByTitle("Resume AI engine");
|
||||||
expect(btn.className).toContain("btn-icon--paused");
|
expect(btn.className).toContain("btn-icon--paused");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not apply btn-icon--paused class when not paused", () => {
|
it("does not apply btn-icon--paused class when engine is not paused", () => {
|
||||||
render(<Header globalPaused={false} />);
|
render(<Header enginePaused={false} />);
|
||||||
const btn = screen.getByTitle("Pause AI engine");
|
const btn = screen.getByTitle("Pause AI engine");
|
||||||
expect(btn.className).not.toContain("btn-icon--paused");
|
expect(btn.className).not.toContain("btn-icon--paused");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("pause button is disabled when globalPaused is true", () => {
|
||||||
|
render(<Header globalPaused={true} enginePaused={false} />);
|
||||||
|
const btn = screen.getByTitle("Pause AI engine");
|
||||||
|
expect((btn as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pause button is enabled when globalPaused is false", () => {
|
||||||
|
render(<Header globalPaused={false} enginePaused={false} />);
|
||||||
|
const btn = screen.getByTitle("Pause AI engine");
|
||||||
|
expect((btn as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Stop button (hard stop) ──────────────────────────────────────
|
||||||
|
|
||||||
|
it("renders stop button with 'Stop AI engine' title when not stopped", () => {
|
||||||
|
render(<Header globalPaused={false} />);
|
||||||
|
const btn = screen.getByTitle("Stop AI engine");
|
||||||
|
expect(btn).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders play button with 'Start AI engine' title when stopped", () => {
|
||||||
|
render(<Header globalPaused={true} />);
|
||||||
|
const btn = screen.getByTitle("Start AI engine");
|
||||||
|
expect(btn).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onToggleGlobalPause when stop button is clicked", () => {
|
||||||
|
const onToggle = vi.fn();
|
||||||
|
render(<Header globalPaused={false} onToggleGlobalPause={onToggle} />);
|
||||||
|
const btn = screen.getByTitle("Stop AI engine");
|
||||||
|
fireEvent.click(btn);
|
||||||
|
expect(onToggle).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies btn-icon--stopped class when globally paused", () => {
|
||||||
|
render(<Header globalPaused={true} />);
|
||||||
|
const btn = screen.getByTitle("Start AI engine");
|
||||||
|
expect(btn.className).toContain("btn-icon--stopped");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not apply btn-icon--stopped class when not globally paused", () => {
|
||||||
|
render(<Header globalPaused={false} />);
|
||||||
|
const btn = screen.getByTitle("Stop AI engine");
|
||||||
|
expect(btn.className).not.toContain("btn-icon--stopped");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stop button shows Play icon when globalPaused is true", () => {
|
||||||
|
render(<Header globalPaused={true} />);
|
||||||
|
const btn = screen.getByTitle("Start AI engine");
|
||||||
|
// The Play icon from lucide-react renders an SVG
|
||||||
|
const svg = btn.querySelector("svg");
|
||||||
|
expect(svg).toBeDefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -64,6 +64,18 @@ html, body {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
}
|
}
|
||||||
|
.btn-icon--paused {
|
||||||
|
color: var(--triage);
|
||||||
|
}
|
||||||
|
.btn-icon--paused:hover {
|
||||||
|
color: var(--triage);
|
||||||
|
}
|
||||||
|
.btn-icon--stopped {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
.btn-icon--stopped:hover {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
.logo { font-size: 20px; font-weight: 700; letter-spacing: -0.5px; }
|
.logo { font-size: 20px; font-weight: 700; letter-spacing: -0.5px; }
|
||||||
.logo-sub { font-size: 13px; color: var(--text-muted); font-weight: 400; }
|
.logo-sub { font-size: 13px; color: var(--text-muted); font-weight: 400; }
|
||||||
|
|
||||||
|
|||||||
@@ -1069,6 +1069,142 @@ describe("Scheduler in-review worktrees do not count against maxWorktrees", () =
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Scheduler enginePaused (soft pause)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function runSchedule(scheduler: Scheduler): Promise<void> {
|
||||||
|
(scheduler as any).running = true;
|
||||||
|
await scheduler.schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
it("does not move any tasks when enginePaused is true", async () => {
|
||||||
|
const tasks = [
|
||||||
|
makeTask({ id: "KB-001", column: "todo" }),
|
||||||
|
makeTask({ id: "KB-002", column: "todo" }),
|
||||||
|
];
|
||||||
|
const store = createMockStore(tasks);
|
||||||
|
store.getSettings.mockResolvedValue({
|
||||||
|
maxConcurrent: 2,
|
||||||
|
maxWorktrees: 4,
|
||||||
|
pollIntervalMs: 15000,
|
||||||
|
groupOverlappingFiles: false,
|
||||||
|
autoMerge: false,
|
||||||
|
enginePaused: true,
|
||||||
|
});
|
||||||
|
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
|
||||||
|
|
||||||
|
await runSchedule(scheduler);
|
||||||
|
|
||||||
|
expect(store.moveTask).not.toHaveBeenCalled();
|
||||||
|
expect(store.updateTask).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resumes scheduling when enginePaused is toggled back to false", async () => {
|
||||||
|
const tasks = [
|
||||||
|
makeTask({ id: "KB-001", column: "todo" }),
|
||||||
|
];
|
||||||
|
const store = createMockStore(tasks);
|
||||||
|
store.getSettings.mockResolvedValue({
|
||||||
|
maxConcurrent: 2,
|
||||||
|
maxWorktrees: 4,
|
||||||
|
pollIntervalMs: 15000,
|
||||||
|
groupOverlappingFiles: false,
|
||||||
|
autoMerge: false,
|
||||||
|
enginePaused: true,
|
||||||
|
});
|
||||||
|
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
|
||||||
|
|
||||||
|
await runSchedule(scheduler);
|
||||||
|
expect(store.moveTask).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Toggle enginePaused off
|
||||||
|
store.getSettings.mockResolvedValue({
|
||||||
|
maxConcurrent: 2,
|
||||||
|
maxWorktrees: 4,
|
||||||
|
pollIntervalMs: 15000,
|
||||||
|
groupOverlappingFiles: false,
|
||||||
|
autoMerge: false,
|
||||||
|
enginePaused: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await runSchedule(scheduler);
|
||||||
|
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "in-progress");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs once when entering engine paused state", async () => {
|
||||||
|
const tasks = [makeTask({ id: "KB-001", column: "todo" })];
|
||||||
|
const store = createMockStore(tasks);
|
||||||
|
store.getSettings.mockResolvedValue({
|
||||||
|
maxConcurrent: 2,
|
||||||
|
maxWorktrees: 4,
|
||||||
|
pollIntervalMs: 15000,
|
||||||
|
groupOverlappingFiles: false,
|
||||||
|
autoMerge: false,
|
||||||
|
enginePaused: true,
|
||||||
|
});
|
||||||
|
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
|
||||||
|
|
||||||
|
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await runSchedule(scheduler);
|
||||||
|
await runSchedule(scheduler);
|
||||||
|
await runSchedule(scheduler);
|
||||||
|
|
||||||
|
const pauseMessages = logSpy.mock.calls.filter(
|
||||||
|
(args) =>
|
||||||
|
typeof args[0] === "string" &&
|
||||||
|
args[0].includes("Engine paused"),
|
||||||
|
);
|
||||||
|
expect(pauseMessages).toHaveLength(1);
|
||||||
|
logSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls schedule() immediately when enginePaused transitions from true to false", async () => {
|
||||||
|
const tasks = [
|
||||||
|
makeTask({ id: "KB-001", column: "todo" }),
|
||||||
|
];
|
||||||
|
const store = createMockStore(tasks);
|
||||||
|
store.getSettings.mockResolvedValue({
|
||||||
|
maxConcurrent: 2,
|
||||||
|
maxWorktrees: 4,
|
||||||
|
pollIntervalMs: 15000,
|
||||||
|
groupOverlappingFiles: false,
|
||||||
|
autoMerge: false,
|
||||||
|
enginePaused: false,
|
||||||
|
});
|
||||||
|
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
|
||||||
|
(scheduler as any).running = true;
|
||||||
|
|
||||||
|
// Fire the settings:updated event: enginePaused true → false
|
||||||
|
store._trigger("settings:updated", {
|
||||||
|
settings: { enginePaused: false },
|
||||||
|
previous: { enginePaused: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
|
||||||
|
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "in-progress");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT call schedule() when enginePaused stays false (false → false)", async () => {
|
||||||
|
const store = createMockStore();
|
||||||
|
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
|
||||||
|
(scheduler as any).running = true;
|
||||||
|
|
||||||
|
store._trigger("settings:updated", {
|
||||||
|
settings: { enginePaused: false },
|
||||||
|
previous: { enginePaused: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
|
||||||
|
expect(store.listTasks).not.toHaveBeenCalled();
|
||||||
|
expect(store.moveTask).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Scheduler semaphore-aware slot counting", () => {
|
describe("Scheduler semaphore-aware slot counting", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export class Scheduler {
|
|||||||
private scheduling = false;
|
private scheduling = false;
|
||||||
private wasWorktreeLimited = false;
|
private wasWorktreeLimited = false;
|
||||||
private wasGlobalPaused = false;
|
private wasGlobalPaused = false;
|
||||||
|
private wasEnginePaused = false;
|
||||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
/** The interval (ms) of the currently active `setInterval` timer. */
|
/** The interval (ms) of the currently active `setInterval` timer. */
|
||||||
private activePollMs: number | null = null;
|
private activePollMs: number | null = null;
|
||||||
@@ -98,6 +99,18 @@ export class Scheduler {
|
|||||||
this.schedule();
|
this.schedule();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immediate soft-unpause resume: when `enginePaused` transitions from
|
||||||
|
* `true` to `false`, trigger a scheduling pass right away instead of
|
||||||
|
* waiting for the next poll interval. Same pattern as the globalPause
|
||||||
|
* unpause handler above.
|
||||||
|
*/
|
||||||
|
this.store.on("settings:updated", ({ settings, previous }) => {
|
||||||
|
if (previous.enginePaused && !settings.enginePaused && this.running) {
|
||||||
|
this.schedule();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
@@ -197,7 +210,7 @@ export class Scheduler {
|
|||||||
// Refresh the poll interval if the persisted setting has changed
|
// Refresh the poll interval if the persisted setting has changed
|
||||||
this.refreshPollInterval(settings.pollIntervalMs);
|
this.refreshPollInterval(settings.pollIntervalMs);
|
||||||
|
|
||||||
// Global pause: halt all scheduling activity
|
// Global pause (hard stop): halt all scheduling activity
|
||||||
if (settings.globalPause) {
|
if (settings.globalPause) {
|
||||||
if (!this.wasGlobalPaused) {
|
if (!this.wasGlobalPaused) {
|
||||||
schedulerLog.log("Global pause active — scheduling halted");
|
schedulerLog.log("Global pause active — scheduling halted");
|
||||||
@@ -207,6 +220,16 @@ export class Scheduler {
|
|||||||
}
|
}
|
||||||
this.wasGlobalPaused = false;
|
this.wasGlobalPaused = false;
|
||||||
|
|
||||||
|
// Engine paused (soft pause): halt new work dispatch, but let agents finish
|
||||||
|
if (settings.enginePaused) {
|
||||||
|
if (!this.wasEnginePaused) {
|
||||||
|
schedulerLog.log("Engine paused — scheduling halted (in-flight agents continue)");
|
||||||
|
this.wasEnginePaused = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.wasEnginePaused = false;
|
||||||
|
|
||||||
// Count only in-progress tasks toward the worktree limit.
|
// Count only in-progress tasks toward the worktree limit.
|
||||||
// In-review tasks with worktrees are idle (waiting to merge) and
|
// In-review tasks with worktrees are idle (waiting to merge) and
|
||||||
// should not block new tasks from starting.
|
// should not block new tasks from starting.
|
||||||
|
|||||||
@@ -823,6 +823,164 @@ describe("TriageProcessor immediate resume on unpause via settings:updated", ()
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("TriageProcessor enginePaused (soft pause)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not specify any tasks when enginePaused is true", async () => {
|
||||||
|
const triageTask = {
|
||||||
|
id: "KB-001",
|
||||||
|
title: "Test",
|
||||||
|
description: "Test task",
|
||||||
|
column: "triage" as const,
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const store = createMockStore([triageTask]);
|
||||||
|
store.getSettings.mockResolvedValue({
|
||||||
|
maxConcurrent: 2,
|
||||||
|
maxWorktrees: 4,
|
||||||
|
pollIntervalMs: 15000,
|
||||||
|
groupOverlappingFiles: false,
|
||||||
|
autoMerge: false,
|
||||||
|
enginePaused: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockedCreateHaiAgent.mockResolvedValue({
|
||||||
|
session: {
|
||||||
|
prompt: vi.fn().mockResolvedValue(undefined),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const triage = new TriageProcessor(store, "/tmp/test");
|
||||||
|
(triage as any).running = true;
|
||||||
|
await (triage as any).poll();
|
||||||
|
|
||||||
|
// Agent should never be created when engine is soft-paused
|
||||||
|
expect(mockedCreateHaiAgent).not.toHaveBeenCalled();
|
||||||
|
expect(store.updateTask).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resumes triage when enginePaused is toggled back to false", async () => {
|
||||||
|
const triageTask = {
|
||||||
|
id: "KB-002",
|
||||||
|
title: "Normal",
|
||||||
|
description: "Normal task",
|
||||||
|
column: "triage" as const,
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const store = createMockStore([triageTask]);
|
||||||
|
store.getSettings.mockResolvedValue({
|
||||||
|
maxConcurrent: 2,
|
||||||
|
maxWorktrees: 4,
|
||||||
|
pollIntervalMs: 15000,
|
||||||
|
groupOverlappingFiles: false,
|
||||||
|
autoMerge: false,
|
||||||
|
enginePaused: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockedCreateHaiAgent.mockResolvedValue({
|
||||||
|
session: {
|
||||||
|
prompt: vi.fn().mockResolvedValue(undefined),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const triage = new TriageProcessor(store, "/tmp/test");
|
||||||
|
(triage as any).running = true;
|
||||||
|
|
||||||
|
// First poll — engine paused, nothing happens
|
||||||
|
await (triage as any).poll();
|
||||||
|
expect(mockedCreateHaiAgent).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Toggle enginePaused off
|
||||||
|
store.getSettings.mockResolvedValue({
|
||||||
|
maxConcurrent: 2,
|
||||||
|
maxWorktrees: 4,
|
||||||
|
pollIntervalMs: 15000,
|
||||||
|
groupOverlappingFiles: false,
|
||||||
|
autoMerge: false,
|
||||||
|
enginePaused: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Second poll — should process tasks
|
||||||
|
await (triage as any).poll();
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
expect(store.updateTask).toHaveBeenCalledWith("KB-002", { status: "specifying" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls poll() immediately when enginePaused transitions from true to false", async () => {
|
||||||
|
const triageTask = {
|
||||||
|
id: "KB-001",
|
||||||
|
title: "Test",
|
||||||
|
description: "Test task",
|
||||||
|
column: "triage" as const,
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const store = createMockStore([triageTask]);
|
||||||
|
store.getSettings.mockResolvedValue({
|
||||||
|
maxConcurrent: 2,
|
||||||
|
maxWorktrees: 4,
|
||||||
|
pollIntervalMs: 15000,
|
||||||
|
groupOverlappingFiles: false,
|
||||||
|
autoMerge: false,
|
||||||
|
enginePaused: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockedCreateHaiAgent.mockResolvedValue({
|
||||||
|
session: {
|
||||||
|
prompt: vi.fn().mockResolvedValue(undefined),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const triage = new TriageProcessor(store, "/tmp/test");
|
||||||
|
(triage as any).running = true;
|
||||||
|
|
||||||
|
// Fire the settings:updated event: enginePaused true → false
|
||||||
|
store._trigger("settings:updated", {
|
||||||
|
settings: { enginePaused: false },
|
||||||
|
previous: { enginePaused: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
|
||||||
|
// poll() should have been called → triage task processed
|
||||||
|
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { status: "specifying" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT call poll() when enginePaused stays false (false → false)", async () => {
|
||||||
|
const store = createMockStore([]);
|
||||||
|
const triage = new TriageProcessor(store, "/tmp/test");
|
||||||
|
(triage as any).running = true;
|
||||||
|
|
||||||
|
store._trigger("settings:updated", {
|
||||||
|
settings: { enginePaused: false },
|
||||||
|
previous: { enginePaused: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
|
||||||
|
expect(store.listTasks).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("buildSpecificationPrompt", () => {
|
describe("buildSpecificationPrompt", () => {
|
||||||
it("includes project commands when testCommand is set", () => {
|
it("includes project commands when testCommand is set", () => {
|
||||||
const task = createMockTaskDetail();
|
const task = createMockTaskDetail();
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ export class TriageProcessor {
|
|||||||
private activePollMs: number | null = null;
|
private activePollMs: number | null = null;
|
||||||
private processing = new Set<string>();
|
private processing = new Set<string>();
|
||||||
private wasGlobalPaused = false;
|
private wasGlobalPaused = false;
|
||||||
|
private wasEnginePaused = false;
|
||||||
/** Active agent sessions per task, used to terminate on global pause. */
|
/** Active agent sessions per task, used to terminate on global pause. */
|
||||||
private activeSessions = new Map<string, { dispose: () => void }>();
|
private activeSessions = new Map<string, { dispose: () => void }>();
|
||||||
/** Tasks that were aborted due to global pause (to avoid reporting as errors). */
|
/** Tasks that were aborted due to global pause (to avoid reporting as errors). */
|
||||||
@@ -228,6 +229,22 @@ export class TriageProcessor {
|
|||||||
this.poll();
|
this.poll();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immediate soft-unpause resume: when `enginePaused` transitions from
|
||||||
|
* `true` to `false`, trigger a triage poll right away instead of
|
||||||
|
* waiting for the next poll interval. Same pattern as the globalPause
|
||||||
|
* unpause handler above.
|
||||||
|
*
|
||||||
|
* Note: the agent-kill listener above only fires on `globalPause`
|
||||||
|
* transitions (hard stop). `enginePaused` (soft pause) lets in-flight
|
||||||
|
* agents finish gracefully.
|
||||||
|
*/
|
||||||
|
store.on("settings:updated", ({ settings, previous }) => {
|
||||||
|
if (previous.enginePaused && !settings.enginePaused && this.running) {
|
||||||
|
this.poll();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
@@ -286,7 +303,7 @@ export class TriageProcessor {
|
|||||||
const settings = await this.store.getSettings();
|
const settings = await this.store.getSettings();
|
||||||
this.refreshPollInterval(settings.pollIntervalMs);
|
this.refreshPollInterval(settings.pollIntervalMs);
|
||||||
|
|
||||||
// Global pause: halt all triage activity
|
// Global pause (hard stop): halt all triage activity
|
||||||
if (settings.globalPause) {
|
if (settings.globalPause) {
|
||||||
if (!this.wasGlobalPaused) {
|
if (!this.wasGlobalPaused) {
|
||||||
triageLog.log("Global pause active — triage halted");
|
triageLog.log("Global pause active — triage halted");
|
||||||
@@ -296,6 +313,16 @@ export class TriageProcessor {
|
|||||||
}
|
}
|
||||||
this.wasGlobalPaused = false;
|
this.wasGlobalPaused = false;
|
||||||
|
|
||||||
|
// Engine paused (soft pause): halt new triage work, but let agents finish
|
||||||
|
if (settings.enginePaused) {
|
||||||
|
if (!this.wasEnginePaused) {
|
||||||
|
triageLog.log("Engine paused — triage halted (in-flight agents continue)");
|
||||||
|
this.wasEnginePaused = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.wasEnginePaused = false;
|
||||||
|
|
||||||
const tasks = await this.store.listTasks();
|
const tasks = await this.store.listTasks();
|
||||||
const triageTasks = tasks.filter(
|
const triageTasks = tasks.filter(
|
||||||
(t) => t.column === "triage" && !this.processing.has(t.id) && !t.paused,
|
(t) => t.column === "triage" && !this.processing.has(t.id) && !t.paused,
|
||||||
|
|||||||
Reference in New Issue
Block a user