feat(HAI-096): add task pause/unpause functionality
- Add paused field to Task data model and store with pauseTask method - Update triage, scheduler, and executor to respect paused flag and terminate paused tasks - Add REST API endpoints and CLI commands for pause/unpause operations - Add dashboard UI visual indicator and toggle for paused state - Update README with pause/unpause CLI command documentation
This commit is contained in:
@@ -39,12 +39,16 @@ const mockedCreateHaiAgent = vi.mocked(createHaiAgent);
|
||||
|
||||
function createMockStore() {
|
||||
const listeners = new Map<string, Function[]>();
|
||||
return {
|
||||
const store = {
|
||||
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);
|
||||
},
|
||||
emit: vi.fn(),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
getTask: vi.fn().mockResolvedValue({
|
||||
@@ -73,7 +77,8 @@ function createMockStore() {
|
||||
worktreeInitCommand: undefined,
|
||||
}),
|
||||
updateStep: vi.fn().mockResolvedValue({}),
|
||||
} as any;
|
||||
};
|
||||
return store as any;
|
||||
}
|
||||
|
||||
describe("TaskExecutor with semaphore", () => {
|
||||
@@ -908,3 +913,102 @@ describe("summarizeToolArgs", () => {
|
||||
expect(summarizeToolArgs("unknown", { count: 42, flag: true })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskExecutor pause behavior", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("terminates agent and moves task to todo when paused during execution", async () => {
|
||||
const store = createMockStore();
|
||||
const disposeFn = vi.fn();
|
||||
|
||||
mockedCreateHaiAgent.mockImplementation(async () => {
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockImplementation(async () => {
|
||||
// Simulate pause happening during agent execution
|
||||
store._trigger("task:updated", { id: "HAI-001", paused: true, column: "in-progress" });
|
||||
// Simulate the dispose causing an error (session terminated)
|
||||
throw new Error("Session terminated");
|
||||
}),
|
||||
dispose: disposeFn,
|
||||
},
|
||||
} as any;
|
||||
});
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
await executor.execute({
|
||||
id: "HAI-001",
|
||||
title: "Test",
|
||||
description: "Test",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Should move to todo, NOT mark as failed
|
||||
expect(store.moveTask).toHaveBeenCalledWith("HAI-001", "todo");
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith("HAI-001", { status: "failed" });
|
||||
});
|
||||
|
||||
it("does not move to in-review when paused during execution (graceful session end)", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
mockedCreateHaiAgent.mockImplementation(async () => {
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockImplementation(async () => {
|
||||
// Simulate pause — session ends gracefully (no throw)
|
||||
store._trigger("task:updated", { id: "HAI-001", paused: true, column: "in-progress" });
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any;
|
||||
});
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
await executor.execute({
|
||||
id: "HAI-001",
|
||||
title: "Test",
|
||||
description: "Test",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Should NOT move to in-review (paused tasks skip that logic)
|
||||
expect(store.moveTask).not.toHaveBeenCalledWith("HAI-001", "in-review");
|
||||
});
|
||||
|
||||
it("skips paused tasks during resumeOrphaned", async () => {
|
||||
const store = createMockStore();
|
||||
store.listTasks.mockResolvedValue([
|
||||
{ id: "HAI-001", column: "in-progress", paused: true, title: "Paused task" },
|
||||
{ id: "HAI-002", column: "in-progress", paused: false, title: "Active task" },
|
||||
]);
|
||||
|
||||
mockedCreateHaiAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any);
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
await executor.resumeOrphaned();
|
||||
|
||||
// Only HAI-002 should be resumed (HAI-001 is paused)
|
||||
expect(store.logEntry).toHaveBeenCalledWith("HAI-002", "Resumed after engine restart");
|
||||
expect(store.logEntry).not.toHaveBeenCalledWith("HAI-001", expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,6 +137,10 @@ export interface TaskExecutorOptions {
|
||||
export class TaskExecutor {
|
||||
private activeWorktrees = new Map<string, string>();
|
||||
private executing = new Set<string>();
|
||||
/** Active agent sessions per task, used to terminate on pause. */
|
||||
private activeSessions = new Map<string, { dispose: () => void }>();
|
||||
/** Tasks that were paused mid-execution (to avoid marking them as "failed"). */
|
||||
private pausedAborted = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private store: TaskStore,
|
||||
@@ -150,6 +154,16 @@ export class TaskExecutor {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// When a task is paused while executing, terminate the agent session.
|
||||
store.on("task:updated", (task) => {
|
||||
if (task.paused && this.activeSessions.has(task.id)) {
|
||||
console.log(`[executor] Pausing ${task.id} — terminating agent session`);
|
||||
this.pausedAborted.add(task.id);
|
||||
const session = this.activeSessions.get(task.id);
|
||||
session?.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,7 +173,7 @@ export class TaskExecutor {
|
||||
async resumeOrphaned(): Promise<void> {
|
||||
const tasks = await this.store.listTasks();
|
||||
const inProgress = tasks.filter(
|
||||
(t) => t.column === "in-progress" && !this.executing.has(t.id),
|
||||
(t) => t.column === "in-progress" && !this.executing.has(t.id) && !t.paused,
|
||||
);
|
||||
|
||||
if (inProgress.length === 0) return;
|
||||
@@ -337,10 +351,19 @@ export class TaskExecutor {
|
||||
onToolStart: agentLogger.onToolStart,
|
||||
});
|
||||
|
||||
// Register session so the pause listener can terminate it
|
||||
this.activeSessions.set(task.id, session);
|
||||
|
||||
try {
|
||||
const agentPrompt = buildExecutionPrompt(detail, this.rootDir, settings);
|
||||
await session.prompt(agentPrompt);
|
||||
|
||||
// If paused during execution, don't move to in-review
|
||||
if (this.pausedAborted.has(task.id)) {
|
||||
this.pausedAborted.delete(task.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (taskDone) {
|
||||
await this.store.moveTask(task.id, "in-review");
|
||||
console.log(`[executor] ✓ ${task.id} completed → in-review`);
|
||||
@@ -352,6 +375,7 @@ export class TaskExecutor {
|
||||
this.options.onComplete?.(task);
|
||||
}
|
||||
} finally {
|
||||
this.activeSessions.delete(task.id);
|
||||
await agentLogger.flush();
|
||||
session.dispose();
|
||||
}
|
||||
@@ -363,10 +387,18 @@ export class TaskExecutor {
|
||||
await agentWork();
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`[executor] ✗ ${task.id} execution failed:`, err.message);
|
||||
await this.store.logEntry(task.id, `Execution failed: ${err.message}`);
|
||||
await this.store.updateTask(task.id, { status: "failed" });
|
||||
this.options.onError?.(task, err);
|
||||
if (this.pausedAborted.has(task.id)) {
|
||||
// Task was paused mid-execution — move to todo, don't mark as failed
|
||||
console.log(`[executor] ${task.id} paused — moving to todo`);
|
||||
this.pausedAborted.delete(task.id);
|
||||
await this.store.logEntry(task.id, "Execution paused — agent terminated, moved to todo");
|
||||
await this.store.moveTask(task.id, "todo");
|
||||
} else {
|
||||
console.error(`[executor] ✗ ${task.id} execution failed:`, err.message);
|
||||
await this.store.logEntry(task.id, `Execution failed: ${err.message}`);
|
||||
await this.store.updateTask(task.id, { status: "failed" });
|
||||
this.options.onError?.(task, err);
|
||||
}
|
||||
} finally {
|
||||
this.executing.delete(task.id);
|
||||
}
|
||||
|
||||
@@ -383,6 +383,62 @@ describe("Scheduler file-scope overlap", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scheduler paused tasks", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function runSchedule(scheduler: Scheduler): Promise<void> {
|
||||
(scheduler as any).running = true;
|
||||
await scheduler.schedule();
|
||||
}
|
||||
|
||||
it("does not schedule paused todo tasks", async () => {
|
||||
const tasks = [
|
||||
makeTask({ id: "HAI-001", column: "todo", paused: true }),
|
||||
];
|
||||
const store = createMockStore(tasks);
|
||||
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
|
||||
|
||||
await runSchedule(scheduler);
|
||||
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("schedules non-paused todo tasks normally", async () => {
|
||||
const tasks = [
|
||||
makeTask({ id: "HAI-001", column: "todo", paused: false }),
|
||||
];
|
||||
const store = createMockStore(tasks);
|
||||
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
|
||||
|
||||
await runSchedule(scheduler);
|
||||
|
||||
expect(store.moveTask).toHaveBeenCalledWith("HAI-001", "in-progress");
|
||||
});
|
||||
|
||||
it("does not count paused specifying tasks toward agent slots", async () => {
|
||||
const tasks = [
|
||||
makeTask({ id: "HAI-001", column: "triage", status: "specifying", paused: true }),
|
||||
makeTask({ id: "HAI-002", column: "todo" }),
|
||||
];
|
||||
const store = createMockStore(tasks);
|
||||
store.getSettings.mockResolvedValue({
|
||||
maxConcurrent: 1,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: false,
|
||||
});
|
||||
const scheduler = new Scheduler(store, { maxConcurrent: 1 });
|
||||
|
||||
await runSchedule(scheduler);
|
||||
|
||||
// The paused specifying task doesn't consume a slot, so HAI-002 should be scheduled
|
||||
expect(store.moveTask).toHaveBeenCalledWith("HAI-002", "in-progress");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scheduler worktree limit logging", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -177,8 +177,9 @@ export class Scheduler {
|
||||
// Specifying tasks (triage column, status "specifying") run full PI
|
||||
// agent sessions that consume the same resources as execution agents,
|
||||
// so they must occupy concurrency slots alongside in-progress tasks.
|
||||
// Paused specifying tasks don't count toward slots.
|
||||
const specifying = tasks.filter(
|
||||
(t) => t.column === "triage" && t.status === "specifying",
|
||||
(t) => t.column === "triage" && t.status === "specifying" && !t.paused,
|
||||
);
|
||||
const agentSlots = inProgress.length + specifying.length;
|
||||
|
||||
@@ -197,7 +198,7 @@ export class Scheduler {
|
||||
);
|
||||
if (available <= 0) return;
|
||||
|
||||
const todo = tasks.filter((t) => t.column === "todo");
|
||||
const todo = tasks.filter((t) => t.column === "todo" && !t.paused);
|
||||
if (todo.length === 0) return;
|
||||
|
||||
/**
|
||||
|
||||
@@ -216,6 +216,74 @@ describe("TriageProcessor dynamic poll interval", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TriageProcessor paused tasks", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("skips paused triage tasks in poll()", async () => {
|
||||
const pausedTask = {
|
||||
id: "HAI-001",
|
||||
title: "Paused",
|
||||
description: "Paused task",
|
||||
column: "triage" as const,
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
paused: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const store = createMockStore([pausedTask]);
|
||||
|
||||
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 for a paused task
|
||||
expect(mockedCreateHaiAgent).not.toHaveBeenCalled();
|
||||
expect(store.updateTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("processes non-paused triage tasks normally", async () => {
|
||||
const normalTask = {
|
||||
id: "HAI-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([normalTask]);
|
||||
|
||||
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 be created for a non-paused task
|
||||
expect(store.updateTask).toHaveBeenCalledWith("HAI-002", { status: "specifying" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSpecificationPrompt", () => {
|
||||
it("includes project commands when testCommand is set", () => {
|
||||
const task = createMockTaskDetail();
|
||||
|
||||
@@ -226,7 +226,7 @@ export class TriageProcessor {
|
||||
|
||||
const tasks = await this.store.listTasks();
|
||||
const triageTasks = tasks.filter(
|
||||
(t) => t.column === "triage" && !this.processing.has(t.id),
|
||||
(t) => t.column === "triage" && !this.processing.has(t.id) && !t.paused,
|
||||
);
|
||||
|
||||
for (const task of triageTasks) {
|
||||
|
||||
Reference in New Issue
Block a user