feat(KB-145): add global pause button to halt all AI engine activity

- Add globalPause setting to core Settings type with default false
- Guard scheduler, triage, and auto-merge queue to skip work when globalPause is active
- Add pause/play toggle button in dashboard Header with optimistic UI and rollback on failure
- Add tests for scheduler, triage, Header, and App global pause behavior
- Include minor changeset for the new feature
This commit is contained in:
Dustin Byrne
2026-03-28 01:01:05 -04:00
parent e0f33fb32c
commit 50821fc820
11 changed files with 399 additions and 8 deletions

View File

@@ -788,6 +788,99 @@ describe("Scheduler worktree limit logging", () => {
});
});
describe("Scheduler globalPause", () => {
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 globalPause 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,
globalPause: 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 globalPause 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,
globalPause: true,
});
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
await runSchedule(scheduler);
expect(store.moveTask).not.toHaveBeenCalled();
// Toggle globalPause off
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
globalPause: false,
});
await runSchedule(scheduler);
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "in-progress");
});
it("logs once when entering global pause 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,
globalPause: 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("Global pause active"),
);
expect(pauseMessages).toHaveLength(1);
logSpy.mockRestore();
});
});
describe("Scheduler in-review worktrees do not count against maxWorktrees", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -75,6 +75,7 @@ export class Scheduler {
private running = false;
private scheduling = false;
private wasWorktreeLimited = false;
private wasGlobalPaused = false;
private pollInterval: ReturnType<typeof setInterval> | null = null;
/** The interval (ms) of the currently active `setInterval` timer. */
private activePollMs: number | null = null;
@@ -181,6 +182,16 @@ export class Scheduler {
// Refresh the poll interval if the persisted setting has changed
this.refreshPollInterval(settings.pollIntervalMs);
// Global pause: halt all scheduling activity
if (settings.globalPause) {
if (!this.wasGlobalPaused) {
schedulerLog.log("Global pause active — scheduling halted");
this.wasGlobalPaused = true;
}
return;
}
this.wasGlobalPaused = false;
// Count only in-progress tasks toward the worktree limit.
// In-review tasks with worktrees are idle (waiting to merge) and
// should not block new tasks from starting.

View File

@@ -291,6 +291,132 @@ describe("TriageProcessor paused tasks", () => {
});
});
describe("TriageProcessor globalPause", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("does not specify any tasks when globalPause 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,
globalPause: 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 globally paused
expect(mockedCreateHaiAgent).not.toHaveBeenCalled();
expect(store.updateTask).not.toHaveBeenCalled();
});
it("resumes triage when globalPause 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,
globalPause: 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 — paused, nothing happens
await (triage as any).poll();
expect(mockedCreateHaiAgent).not.toHaveBeenCalled();
// Toggle globalPause off
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
globalPause: false,
});
// Second poll — should process tasks
await (triage as any).poll();
expect(store.updateTask).toHaveBeenCalledWith("KB-002", { status: "specifying" });
});
it("logs once when entering global pause state", async () => {
const store = createMockStore([]);
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
globalPause: true,
});
const triage = new TriageProcessor(store, "/tmp/test");
(triage as any).running = true;
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await (triage as any).poll();
await (triage as any).poll();
await (triage as any).poll();
const pauseMessages = logSpy.mock.calls.filter(
(args) =>
typeof args[0] === "string" &&
args[0].includes("Global pause active"),
);
expect(pauseMessages).toHaveLength(1);
logSpy.mockRestore();
});
});
describe("buildSpecificationPrompt", () => {
it("includes project commands when testCommand is set", () => {
const task = createMockTaskDetail();

View File

@@ -179,6 +179,7 @@ export class TriageProcessor {
/** The interval (ms) of the currently active `setInterval` timer. */
private activePollMs: number | null = null;
private processing = new Set<string>();
private wasGlobalPaused = false;
constructor(
private store: TaskStore,
@@ -230,6 +231,16 @@ export class TriageProcessor {
const settings = await this.store.getSettings();
this.refreshPollInterval(settings.pollIntervalMs);
// Global pause: halt all triage activity
if (settings.globalPause) {
if (!this.wasGlobalPaused) {
triageLog.log("Global pause active — triage halted");
this.wasGlobalPaused = true;
}
return;
}
this.wasGlobalPaused = false;
const tasks = await this.store.listTasks();
const triageTasks = tasks.filter(
(t) => t.column === "triage" && !this.processing.has(t.id) && !t.paused,