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:
@@ -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", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -76,6 +76,7 @@ export class Scheduler {
|
||||
private scheduling = false;
|
||||
private wasWorktreeLimited = false;
|
||||
private wasGlobalPaused = false;
|
||||
private wasEnginePaused = false;
|
||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
/** The interval (ms) of the currently active `setInterval` timer. */
|
||||
private activePollMs: number | null = null;
|
||||
@@ -98,6 +99,18 @@ export class Scheduler {
|
||||
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 {
|
||||
@@ -197,7 +210,7 @@ export class Scheduler {
|
||||
// Refresh the poll interval if the persisted setting has changed
|
||||
this.refreshPollInterval(settings.pollIntervalMs);
|
||||
|
||||
// Global pause: halt all scheduling activity
|
||||
// Global pause (hard stop): halt all scheduling activity
|
||||
if (settings.globalPause) {
|
||||
if (!this.wasGlobalPaused) {
|
||||
schedulerLog.log("Global pause active — scheduling halted");
|
||||
@@ -207,6 +220,16 @@ export class Scheduler {
|
||||
}
|
||||
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.
|
||||
// In-review tasks with worktrees are idle (waiting to merge) and
|
||||
// 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", () => {
|
||||
it("includes project commands when testCommand is set", () => {
|
||||
const task = createMockTaskDetail();
|
||||
|
||||
@@ -184,6 +184,7 @@ export class TriageProcessor {
|
||||
private activePollMs: number | null = null;
|
||||
private processing = new Set<string>();
|
||||
private wasGlobalPaused = false;
|
||||
private wasEnginePaused = false;
|
||||
/** Active agent sessions per task, used to terminate on global pause. */
|
||||
private activeSessions = new Map<string, { dispose: () => void }>();
|
||||
/** Tasks that were aborted due to global pause (to avoid reporting as errors). */
|
||||
@@ -228,6 +229,22 @@ export class TriageProcessor {
|
||||
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 {
|
||||
@@ -286,7 +303,7 @@ export class TriageProcessor {
|
||||
const settings = await this.store.getSettings();
|
||||
this.refreshPollInterval(settings.pollIntervalMs);
|
||||
|
||||
// Global pause: halt all triage activity
|
||||
// Global pause (hard stop): halt all triage activity
|
||||
if (settings.globalPause) {
|
||||
if (!this.wasGlobalPaused) {
|
||||
triageLog.log("Global pause active — triage halted");
|
||||
@@ -296,6 +313,16 @@ export class TriageProcessor {
|
||||
}
|
||||
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 triageTasks = tasks.filter(
|
||||
(t) => t.column === "triage" && !this.processing.has(t.id) && !t.paused,
|
||||
|
||||
Reference in New Issue
Block a user