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,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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user