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:
@@ -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");
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface Task {
|
||||
column: Column;
|
||||
dependencies: string[];
|
||||
worktree?: string;
|
||||
status?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user