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:
Dustin Byrne
2026-03-26 19:57:22 -04:00
parent 7955897013
commit 615f31c777
22 changed files with 647 additions and 23 deletions

View File

@@ -39,7 +39,7 @@ if (isBunBinary) {
// Dynamic imports so the pi-coding-agent config module sees PI_PACKAGE_DIR
const { runDashboard } = await import("./commands/dashboard.js");
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskShow, runTaskAttach } = await import("./commands/task.js");
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause } = await import("./commands/task.js");
const HELP = `
hai — AI-orchestrated task board
@@ -54,6 +54,8 @@ Usage:
hai task log <id> <message> Add a log entry
hai task merge <id> Merge an in-review task and close it
hai task attach <id> <file> Attach a file to a task
hai task pause <id> Pause a task (stops all automation)
hai task unpause <id> Unpause a task (resumes automation)
Options:
--port, -p <port> Dashboard port (default: 4040)
@@ -161,6 +163,18 @@ async function main() {
await runTaskAttach(id, file);
break;
}
case "pause": {
const id = args[2];
if (!id) { console.error("Usage: hai task pause <id>"); process.exit(1); }
await runTaskPause(id);
break;
}
case "unpause": {
const id = args[2];
if (!id) { console.error("Usage: hai task unpause <id>"); process.exit(1); }
await runTaskUnpause(id);
break;
}
default:
console.error(`Unknown subcommand: task ${subcommand || ""}`);
console.log("Try: hai task create | list | move");

View File

@@ -140,3 +140,80 @@ describe("runDashboard — WorktreePool wiring", () => {
expect(executorPool).toBe(mergerPool);
});
});
describe("runDashboard — auto-merge pause exclusion", () => {
let mockStore: ReturnType<typeof makeMockStore>;
beforeEach(async () => {
capturedExecutorOpts = undefined;
vi.clearAllMocks();
mockStore = makeMockStore();
const { TaskStore } = await import("@hai/core");
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
const engine = await import("@hai/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 paused in-review tasks for auto-merge on task:moved", async () => {
mockStore.getSettings.mockResolvedValue({
maxConcurrent: 1,
maxWorktrees: 2,
autoMerge: true,
pollIntervalMs: 60_000,
});
await runDashboard(0, { engine: true, open: false });
const { aiMergeTask } = await import("@hai/engine");
// Emit task:moved with a paused task
mockStore.emit("task:moved", {
task: { id: "HAI-PAUSED", column: "in-review", paused: true },
from: "in-progress",
to: "in-review",
});
// Give async handlers time to process
await new Promise((r) => setTimeout(r, 50));
expect(aiMergeTask).not.toHaveBeenCalled();
});
it("does not enqueue paused in-review tasks during startup sweep", async () => {
mockStore.getSettings.mockResolvedValue({
maxConcurrent: 1,
maxWorktrees: 2,
autoMerge: true,
pollIntervalMs: 60_000,
});
mockStore.listTasks.mockResolvedValue([
{ id: "HAI-PAUSED", column: "in-review", paused: true },
{ id: "HAI-ACTIVE", column: "in-review", paused: false },
]);
const { aiMergeTask } = await import("@hai/engine");
// Reset after import
(aiMergeTask as ReturnType<typeof vi.fn>).mockImplementation(() =>
Promise.resolve({ merged: true }),
);
await runDashboard(0, { engine: true, open: false });
// Give async handlers time to process
await new Promise((r) => setTimeout(r, 50));
// Only the non-paused task should be enqueued
const mergedIds = (aiMergeTask as ReturnType<typeof vi.fn>).mock.calls.map(
(call: any[]) => call[2],
);
expect(mergedIds).not.toContain("HAI-PAUSED");
});
});

View File

@@ -96,9 +96,9 @@ export async function runDashboard(port: number, opts: { engine?: boolean; open?
console.log(`[auto-merge] Skipping ${taskId} — autoMerge disabled`);
continue;
}
// Verify the task is still in-review (it may have been manually moved)
// Verify the task is still in-review and not paused
const task = await store.getTask(taskId);
if (task.column !== "in-review") {
if (task.column !== "in-review" || task.paused) {
continue;
}
console.log(`[auto-merge] Merging ${taskId}...`);
@@ -123,6 +123,7 @@ export async function runDashboard(port: number, opts: { engine?: boolean; open?
// enqueue it for serialized merge processing.
store.on("task:moved", async ({ task, to }) => {
if (to !== "in-review") return;
if (task.paused) return;
try {
const settings = await store.getSettings();
if (!settings.autoMerge) return;
@@ -177,7 +178,7 @@ export async function runDashboard(port: number, opts: { engine?: boolean; open?
// ── Startup sweep: enqueue any tasks already in "in-review" ───────
if (settings.autoMerge) {
const existing = await store.listTasks();
const inReview = existing.filter((t) => t.column === "in-review");
const inReview = existing.filter((t) => t.column === "in-review" && !t.paused);
if (inReview.length > 0) {
console.log(
`[auto-merge] Startup sweep: enqueueing ${inReview.length} in-review task(s)`,
@@ -203,7 +204,7 @@ export async function runDashboard(port: number, opts: { engine?: boolean; open?
if (s.autoMerge) {
const tasks = await store.listTasks();
for (const t of tasks) {
if (t.column === "in-review") {
if (t.column === "in-review" && !t.paused) {
enqueueMerge(t.id);
}
}

View File

@@ -250,6 +250,24 @@ export async function runTaskAttach(id: string, filePath: string) {
console.log();
}
export async function runTaskPause(id: string) {
const store = await getStore();
const task = await store.pauseTask(id, true);
console.log();
console.log(` ✓ Paused ${task.id}`);
console.log();
}
export async function runTaskUnpause(id: string) {
const store = await getStore();
const task = await store.pauseTask(id, false);
console.log();
console.log(` ✓ Unpaused ${task.id}`);
console.log();
}
export async function runTaskMove(id: string, column: string) {
if (!COLUMNS.includes(column as Column)) {
console.error(`Invalid column: ${column}`);