feat(KB-151): immediately resume agentic activity on global unpause
- Add settings:updated listener in Scheduler to trigger schedule() on globalPause true→false transition - Add settings:updated listener in TriageProcessor to trigger poll() on globalPause true→false transition - Add unpause handler in dashboard to resume orphaned executor tasks and sweep merge queue - Guard all resume paths against no-op transitions (false→false, true→true) and stopped state - Add tests for all three resume paths covering transition edge cases
This commit is contained in:
@@ -237,6 +237,111 @@ describe("runDashboard — auto-merge pause exclusion", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("runDashboard — immediate resume on unpause", () => {
|
||||
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("registers a settings:updated listener on the store", async () => {
|
||||
await runDashboard(0, { open: false });
|
||||
|
||||
// The store.on should have been called with "settings:updated" at least once
|
||||
const settingsUpdatedCalls = mockStore.on.mock.calls.filter(
|
||||
(call: any[]) => call[0] === "settings:updated",
|
||||
);
|
||||
// At least 2 listeners: one for pause→true (merge kill), one for unpause
|
||||
expect(settingsUpdatedCalls.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("calls executor.resumeOrphaned() when globalPause 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 to resumeOrphaned
|
||||
resumeOrphaned.mockClear();
|
||||
|
||||
// Trigger unpause event
|
||||
mockStore.emit("settings:updated", {
|
||||
settings: { globalPause: false, maxConcurrent: 1, autoMerge: false },
|
||||
previous: { globalPause: true },
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
expect(resumeOrphaned).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sweeps merge queue on unpause when autoMerge is enabled", async () => {
|
||||
// Set up settings to return autoMerge: true for the drain queue check
|
||||
mockStore.getSettings.mockResolvedValue({
|
||||
maxConcurrent: 1,
|
||||
maxWorktrees: 2,
|
||||
autoMerge: true,
|
||||
pollIntervalMs: 60_000,
|
||||
globalPause: false,
|
||||
});
|
||||
mockStore.listTasks.mockResolvedValue([
|
||||
{ id: "KB-MQ1", column: "in-review", paused: false },
|
||||
{ id: "KB-MQ2", column: "in-review", paused: false },
|
||||
]);
|
||||
// getTask is called inside drainMergeQueue to verify the task
|
||||
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 });
|
||||
|
||||
// Clear any calls from startup sweep
|
||||
(aiMergeTask as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
// Trigger unpause event with autoMerge enabled
|
||||
mockStore.emit("settings:updated", {
|
||||
settings: { globalPause: false, maxConcurrent: 1, autoMerge: true },
|
||||
previous: { globalPause: true },
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
// Both in-review tasks should be enqueued for merge
|
||||
const mergedIds = (aiMergeTask as ReturnType<typeof vi.fn>).mock.calls.map(
|
||||
(call: any[]) => call[2],
|
||||
);
|
||||
expect(mergedIds).toContain("KB-MQ1");
|
||||
expect(mergedIds).toContain("KB-MQ2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runDashboard — port fallback on EADDRINUSE", () => {
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
|
||||
@@ -224,6 +224,33 @@ export async function runDashboard(port: number, opts: { open?: boolean } = {})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Immediate unpause: resume orphans + merge sweep ─────────────
|
||||
// When globalPause transitions from true → false, immediately:
|
||||
// 1. Refresh cachedMaxConcurrent so the semaphore picks up live changes
|
||||
// 2. Resume orphaned in-progress tasks whose agents were killed by pause
|
||||
// 3. Sweep the merge queue for in-review tasks that need merging
|
||||
store.on("settings:updated", async ({ settings: s, previous: prev }) => {
|
||||
if (prev.globalPause && !s.globalPause) {
|
||||
console.log("[engine] Global unpause — resuming agentic activity");
|
||||
cachedMaxConcurrent = s.maxConcurrent ?? cachedMaxConcurrent;
|
||||
|
||||
executor.resumeOrphaned().catch((err) =>
|
||||
console.error("[engine] Failed to resume orphaned tasks on 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 ────────
|
||||
// Uses a setTimeout chain so the interval dynamically follows
|
||||
// settings.pollIntervalMs without requiring an engine restart.
|
||||
|
||||
@@ -18,7 +18,17 @@ function makeTask(overrides: Record<string, unknown> = {}) {
|
||||
}
|
||||
|
||||
function createMockStore(tasks: any[] = []) {
|
||||
const listeners = new Map<string, Function[]>();
|
||||
return {
|
||||
on: vi.fn((event: string, fn: Function) => {
|
||||
const existing = listeners.get(event) || [];
|
||||
existing.push(fn);
|
||||
listeners.set(event, existing);
|
||||
}),
|
||||
/** Trigger registered listeners for an event (test helper). */
|
||||
_trigger(event: string, ...args: any[]) {
|
||||
for (const fn of listeners.get(event) || []) fn(...args);
|
||||
},
|
||||
listTasks: vi.fn().mockResolvedValue(tasks),
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
@@ -882,6 +892,101 @@ describe("Scheduler globalPause", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scheduler immediate resume on unpause via settings:updated", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("calls schedule() immediately when globalPause 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,
|
||||
globalPause: false,
|
||||
});
|
||||
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
|
||||
// Set running state without calling start() (avoids the initial schedule() call)
|
||||
(scheduler as any).running = true;
|
||||
|
||||
// Fire the settings:updated event: true → false
|
||||
store._trigger("settings:updated", {
|
||||
settings: { globalPause: false },
|
||||
previous: { globalPause: true },
|
||||
});
|
||||
|
||||
// schedule() is async, give it time to process
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// schedule() should have been called → task moved to in-progress
|
||||
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "in-progress");
|
||||
});
|
||||
|
||||
it("does NOT call schedule() when globalPause stays false (false → false)", async () => {
|
||||
const tasks = [
|
||||
makeTask({ id: "KB-001", column: "todo" }),
|
||||
];
|
||||
const store = createMockStore(tasks);
|
||||
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
|
||||
(scheduler as any).running = true;
|
||||
|
||||
// Fire the settings:updated event: false → false
|
||||
store._trigger("settings:updated", {
|
||||
settings: { globalPause: false },
|
||||
previous: { globalPause: false },
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// schedule() should NOT have been called
|
||||
expect(store.listTasks).not.toHaveBeenCalled();
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT call schedule() when globalPause stays true (true → true)", async () => {
|
||||
const tasks = [
|
||||
makeTask({ id: "KB-001", column: "todo" }),
|
||||
];
|
||||
const store = createMockStore(tasks);
|
||||
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
|
||||
(scheduler as any).running = true;
|
||||
|
||||
// Fire the settings:updated event: true → true
|
||||
store._trigger("settings:updated", {
|
||||
settings: { globalPause: true },
|
||||
previous: { globalPause: true },
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// schedule() should NOT have been called
|
||||
expect(store.listTasks).not.toHaveBeenCalled();
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT call schedule() when scheduler is not running", async () => {
|
||||
const store = createMockStore();
|
||||
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
|
||||
// running = false (default)
|
||||
|
||||
// Fire the settings:updated event: true → false
|
||||
store._trigger("settings:updated", {
|
||||
settings: { globalPause: false },
|
||||
previous: { globalPause: true },
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// schedule() should NOT have been called since scheduler is not running
|
||||
expect(store.listTasks).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scheduler in-review worktrees do not count against maxWorktrees", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -83,7 +83,22 @@ export class Scheduler {
|
||||
constructor(
|
||||
private store: TaskStore,
|
||||
private options: SchedulerOptions = {},
|
||||
) {}
|
||||
) {
|
||||
/**
|
||||
* Immediate unpause resume: when `globalPause` transitions from `true`
|
||||
* to `false`, trigger a scheduling pass right away instead of waiting
|
||||
* for the next poll interval (up to 15 s). Only reacts to true→false
|
||||
* transitions — no-ops on false→false and true→true.
|
||||
*
|
||||
* The re-entrance guard (`this.scheduling`) inside `schedule()` safely
|
||||
* drops the call if a poll-based pass is already in flight.
|
||||
*/
|
||||
this.store.on("settings:updated", ({ settings, previous }) => {
|
||||
if (previous.globalPause && !settings.globalPause && this.running) {
|
||||
this.schedule();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.running) return;
|
||||
|
||||
@@ -551,6 +551,109 @@ describe("TriageProcessor globalPause", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TriageProcessor immediate resume on unpause via settings:updated", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("calls poll() immediately when globalPause 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,
|
||||
globalPause: 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: true → false
|
||||
store._trigger("settings:updated", {
|
||||
settings: { globalPause: false },
|
||||
previous: { globalPause: true },
|
||||
});
|
||||
|
||||
// poll() is async, give it time to process
|
||||
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 globalPause stays false (false → false)", async () => {
|
||||
const store = createMockStore([]);
|
||||
const triage = new TriageProcessor(store, "/tmp/test");
|
||||
(triage as any).running = true;
|
||||
|
||||
// Fire the settings:updated event: false → false
|
||||
store._trigger("settings:updated", {
|
||||
settings: { globalPause: false },
|
||||
previous: { globalPause: false },
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// poll() should NOT have been called
|
||||
expect(store.listTasks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT call poll() when globalPause stays true (true → true)", async () => {
|
||||
const store = createMockStore([]);
|
||||
const triage = new TriageProcessor(store, "/tmp/test");
|
||||
(triage as any).running = true;
|
||||
|
||||
// Fire the settings:updated event: true → true
|
||||
store._trigger("settings:updated", {
|
||||
settings: { globalPause: true },
|
||||
previous: { globalPause: true },
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// poll() should NOT have been called
|
||||
expect(store.listTasks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT call poll() when processor is not running", async () => {
|
||||
const store = createMockStore([]);
|
||||
const triage = new TriageProcessor(store, "/tmp/test");
|
||||
// running = false (default)
|
||||
|
||||
// Fire the settings:updated event: true → false
|
||||
store._trigger("settings:updated", {
|
||||
settings: { globalPause: false },
|
||||
previous: { globalPause: true },
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// poll() should NOT have been called since processor is not running
|
||||
expect(store.listTasks).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSpecificationPrompt", () => {
|
||||
it("includes project commands when testCommand is set", () => {
|
||||
const task = createMockTaskDetail();
|
||||
|
||||
@@ -213,6 +213,21 @@ export class TriageProcessor {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Immediate unpause resume: when `globalPause` transitions from `true`
|
||||
* to `false`, trigger a triage poll right away instead of waiting for
|
||||
* the next poll interval (up to 15 s). Only reacts to true→false
|
||||
* transitions — no-ops on false→false and true→true.
|
||||
*
|
||||
* The re-entrance guard (`this.polling`) inside `poll()` safely drops
|
||||
* the call if a poll-based pass is already in flight.
|
||||
*/
|
||||
store.on("settings:updated", ({ settings, previous }) => {
|
||||
if (previous.globalPause && !settings.globalPause && this.running) {
|
||||
this.poll();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
start(): void {
|
||||
|
||||
Reference in New Issue
Block a user