feat(HAI-002): add task status tracking and display

- Add status field to Task type and store with persistence support
- Set status at engine lifecycle points (triage, scheduler, executor, merger)
- Render status badge on dashboard TaskCard component
This commit is contained in:
Dustin Byrne
2026-03-25 19:30:48 -04:00
7 changed files with 66 additions and 3 deletions

View File

@@ -168,7 +168,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async updateTask(
id: string,
updates: { title?: string; description?: string; prompt?: string; worktree?: string },
updates: { title?: string; description?: string; prompt?: string; worktree?: string; status?: string | null },
): Promise<Task> {
const dir = this.taskDir(id);
const data = await readFile(join(dir, "task.json"), "utf-8");
@@ -177,6 +177,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
if (updates.title !== undefined) task.title = updates.title;
if (updates.description !== undefined) task.description = updates.description;
if (updates.worktree !== undefined) task.worktree = updates.worktree;
if (updates.status === null) {
task.status = undefined;
} else if (updates.status !== undefined) {
task.status = updates.status;
}
task.updatedAt = new Date().toISOString();
const taskJsonPath = join(dir, "task.json");
@@ -318,6 +323,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
private async moveToDone(task: Task, dir: string): Promise<void> {
task.column = "done";
task.worktree = undefined;
task.status = undefined;
task.updatedAt = new Date().toISOString();
const taskJsonPath = join(dir, "task.json");

View File

@@ -8,6 +8,7 @@ export interface Task {
column: Column;
dependencies: string[];
worktree?: string;
status?: string;
createdAt: string;
updatedAt: string;
}

View File

@@ -1,8 +1,26 @@
import { useCallback, useState } from "react";
import type { Task, TaskDetail } from "@hai/core";
import type { Task, TaskDetail, Column } from "@hai/core";
import { fetchTaskDetail } from "../api";
import type { ToastType } from "../hooks/useToast";
const COLUMN_COLOR_MAP: Record<Column, string> = {
triage: "rgba(210,153,34,0.15)",
todo: "rgba(88,166,255,0.15)",
"in-progress": "rgba(188,140,255,0.15)",
"in-review": "rgba(63,185,80,0.15)",
done: "rgba(139,148,158,0.15)",
};
const COLUMN_TEXT_COLOR_MAP: Record<Column, string> = {
triage: "var(--triage)",
todo: "var(--todo)",
"in-progress": "var(--in-progress)",
"in-review": "var(--in-review)",
done: "var(--done)",
};
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging"]);
interface TaskCardProps {
task: Task;
queued?: boolean;
@@ -43,6 +61,17 @@ export function TaskCard({ task, queued, onOpenDetail, addToast }: TaskCardProps
onDragEnd={queued ? undefined : handleDragEnd}
onClick={handleClick}
>
{task.status && (
<span
className={`card-status-badge${ACTIVE_STATUSES.has(task.status) ? " pulsing" : ""}`}
style={{
background: COLUMN_COLOR_MAP[task.column],
color: COLUMN_TEXT_COLOR_MAP[task.column],
}}
>
{task.status}
</span>
)}
<span className="card-id">{task.id}</span>
<div className="card-title">
{task.title || (task.description ? task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "") : task.id)}

View File

@@ -73,6 +73,7 @@ export class TaskExecutor {
this.executing.add(task.id);
console.log(`[executor] Starting ${task.id}: ${task.title || task.id}`);
await this.store.updateTask(task.id, { status: "starting" });
try {
// Check dependencies
@@ -100,10 +101,13 @@ export class TaskExecutor {
this.options.onStart?.(task, worktreePath);
await this.store.updateTask(task.id, { status: "researching" });
// Read the task's PROMPT.md
const detail = await this.store.getTask(task.id);
// Create a pi agent session in the worktree
let hasStartedExecuting = false;
const { session } = await createHaiAgent({
cwd: worktreePath,
systemPrompt: EXECUTOR_SYSTEM_PROMPT,
@@ -111,6 +115,10 @@ export class TaskExecutor {
onText: (delta) => this.options.onAgentText?.(task.id, delta),
onToolStart: (name) => {
this.options.onAgentTool?.(task.id, name);
if (!hasStartedExecuting && /^(write|edit|bash)/i.test(name)) {
hasStartedExecuting = true;
this.store.updateTask(task.id, { status: "executing" }).catch(() => {});
}
},
});
@@ -128,14 +136,18 @@ export class TaskExecutor {
);
const doneCwd = join(worktreePath, ".DONE");
await this.store.updateTask(task.id, { status: "finalizing" });
if (existsSync(doneFile) || existsSync(doneCwd)) {
await this.store.moveTask(task.id, "in-review");
await this.store.updateTask(task.id, { status: "ready" });
console.log(`[executor] ✓ ${task.id} completed → in-review`);
this.options.onComplete?.(task);
} else {
// Agent finished but didn't create .DONE — still move to review
// so a human can inspect
await this.store.moveTask(task.id, "in-review");
await this.store.updateTask(task.id, { status: "ready" });
console.log(
`[executor] ⚠ ${task.id} agent finished without .DONE → in-review for inspection`,
);

View File

@@ -144,6 +144,8 @@ export async function aiMergeTask(
}
// 5. Spawn pi agent to resolve conflicts (if any) and write commit message
await store.updateTask(taskId, { status: "merging" });
console.log(
`[merger] ${taskId}: ${hasConflicts ? "resolving conflicts + " : ""}writing commit message`,
);

View File

@@ -78,6 +78,7 @@ export class Scheduler {
});
if (unmetDeps.length > 0) {
await this.store.updateTask(task.id, { status: "queued" });
this.options.onBlocked?.(task, unmetDeps);
continue;
}
@@ -87,10 +88,11 @@ export class Scheduler {
continue;
}
// Dependencies met — move to in-progress
// Dependencies met — clear status and move to in-progress
console.log(
`[scheduler] Starting ${task.id}: ${task.title || task.id} (deps satisfied)`,
);
await this.store.updateTask(task.id, { status: null });
await this.store.moveTask(task.id, "in-progress");
this.options.onSchedule?.(task);
started++;

View File

@@ -83,6 +83,13 @@ export class TriageProcessor {
(t) => t.column === "triage" && !this.processing.has(t.id),
);
for (const task of triageTasks) {
// Mark waiting tasks as queued
if (triageTasks.indexOf(task) > 0) {
await this.store.updateTask(task.id, { status: "queued" });
}
}
for (const task of triageTasks) {
// Process one at a time to avoid overwhelming the API
await this.specifyTask(task);
@@ -99,6 +106,7 @@ export class TriageProcessor {
console.log(`[triage] Specifying ${task.id}: ${task.title || task.id}`);
this.options.onSpecifyStart?.(task);
await this.store.updateTask(task.id, { status: "planning" });
try {
// Get the full task detail including current prompt
@@ -122,6 +130,9 @@ export class TriageProcessor {
// Run the agent
await session.prompt(agentPrompt);
// Clear status before moving to todo
await this.store.updateTask(task.id, { status: null });
// Move to todo
await this.store.moveTask(task.id, "todo");
console.log(`[triage] ✓ ${task.id} specified and moved to todo`);