Watch
0
0
Fork
You've already forked hyperhive
0

swarm: list an agent's bash tasks from their published status

GET /api/agents/{name}/bash-tasks reads the agent's keys of the bash-task-status
bucket and reports each task's state, command and times, with a live task whose
daemon stopped reporting as stale. The swarm UI shows them in a background tasks
section of the agent card, running ones first. The task output stays unread.
This commit is contained in:
iris 2026-11-15 18:36:44 +01:00 • committed by mara
commit 5de20f31dd
8 changed files with 625 additions and 11 deletions

View file

@ -42,6 +42,7 @@ import {
startsOpen,
type DetailSection,
} from "./sections.js";
import { BashTasks } from "../terminal/BashTasks.js";
import { SubagentTerms } from "../terminal/SubagentTerms.js";
import { WantedMenu } from "../roster/WantedMenu.js";
import "./AgentDetail.css";
@ -313,6 +314,8 @@ function SectionBody({
return <AgentTermPreview agentName={row.name} />;
case "subagents":
return <SubagentTerms key={row.name} agentName={row.name} />;
case "tasks":
return <BashTasks key={row.name} agentName={row.name} />;
}
}
@ -324,7 +327,7 @@ export function AgentDetail({
}: {
row: AgentRow;
actions: AgentActions;
/** Add the live terminal and subagents sections. */
/** Add the live terminal, subagents and background tasks sections. */
withTerminal?: boolean;
/**
* An opened card, whose header already shows the state rows. Without it

View file

@ -3,24 +3,30 @@
// agent's state, message and wanted state, so the sections hold the rest.
/** One folded block of an opened agent card. */
export type DetailSection = "status" | "terminal" | "subagents" | "settings";
export type DetailSection =
| "status"
| "terminal"
| "subagents"
| "tasks"
| "settings";
/** What each section's header says. */
export const SECTION_TITLE: Record<DetailSection, string> = {
status: "status",
terminal: "terminal",
subagents: "subagents",
tasks: "background tasks",
settings: "settings",
};
/**
* The sections a card shows, top to bottom. The terminal and subagents are
* The sections a card shows, top to bottom. The terminal, subagents and tasks are
* left out where the page is already beside a terminal (the terminal page's
* details popover).
*/
export function sectionsFor(withTerminal: boolean): DetailSection[] {
return withTerminal
? ["status", "terminal", "subagents", "settings"]
? ["status", "terminal", "subagents", "tasks", "settings"]
: ["status", "settings"];
}

View file

@ -0,0 +1,38 @@
/* <BashTasks> — the rows of an agent's background bash tasks. */
.ui-bash-tasks-count {
margin: 0 0 0.5em;
color: var(--muted);
}
.ui-bash-tasks-list {
display: flex;
flex-direction: column;
gap: 0.35em;
margin: 0;
padding: 0;
list-style: none;
}
.ui-bash-tasks-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: baseline;
gap: 0.75em;
}
.ui-bash-tasks-item-over {
opacity: 0.6;
}
.ui-bash-tasks-cmd {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ui-bash-tasks-id,
.ui-bash-tasks-age {
color: var(--muted);
font-size: 0.85em;
white-space: nowrap;
}

View file

@ -0,0 +1,60 @@
// <BashTasks> — an agent's background bash tasks, in its card's "background
// tasks" section: the running ones first, each with its command, what state it
// is in and since when. The list is `GET /api/agents/{name}/bash-tasks`
// (`useBashTasks`), which swarm-controller builds from the status each task's
// bash MCP publishes. Read-only; a task's output is not shown here.
//
// Mount it keyed by agent name: the refresh timer is armed once per mount.
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { Hint } from "../../ui/hint/Hint.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import { isLive, runningTasksLabel, stateWord } from "./bash-tasks.js";
import { useBashTasks } from "./useBashTasks.js";
import "./BashTasks.css";
export function BashTasks({ agentName }: { agentName: string }) {
const { tasks, error } = useBashTasks(agentName);
return (
<section class="ui-bash-tasks" aria-label={`${agentName}'s bash tasks`}>
{error && (
<ApiErrorPanel
problem={error}
context={`failed to list ${agentName}'s bash tasks`}
/>
)}
{tasks === null ? (
error ? null : (
<Hint as="span">…</Hint>
)
) : tasks.length === 0 ? (
<Hint as="span">no background tasks</Hint>
) : (
<>
<p class="ui-bash-tasks-count">{runningTasksLabel(tasks)}</p>
<ul class="ui-bash-tasks-list">
{tasks.map((t) => (
<li
key={t.task}
class={`ui-bash-tasks-item${isLive(t) ? "" : " ui-bash-tasks-item-over"}`}
>
<code class="ui-bash-tasks-cmd" title={t.cmd}>
{t.cmd}
</code>
<span class="ui-bash-tasks-id">{t.task}</span>
<span class="ui-bash-tasks-age">
{stateWord(t)}
{t.state === "stale" ? null : (
<>
{" "}
<RelativeTime epochMs={t.since_unix * 1000} />
</>
)}
</span>
</li>
))}
</ul>
</>
)}
</section>
);
}

View file

@ -0,0 +1,75 @@
// What an agent's bash task list means, without a DOM: the wire row, the words
// for a state, which tasks count as running, and the count's wording. The list
// is swarm-controller's `GET /api/agents/{name}/bash-tasks`, from the status
// each task's bash MCP published.
export type TaskState =
| "pending"
| "running"
| "stale"
| "done"
| "timed_out"
| "interrupted"
| "killed";
/** One row of `GET /api/agents/{name}/bash-tasks`. */
export interface BashTask {
task: string;
/** As of the request: a live task whose daemon stopped reporting is `stale`. */
state: TaskState;
/** The command, cut to 200 characters. */
cmd: string;
/** Unix seconds when the task was accepted. */
created_unix: number;
/** Unix seconds when it entered `state`. */
since_unix: number;
/** The exit status once the process exited by itself. */
exit: number | null;
}
/** Whether the task is still going: queued or running, and reporting. */
export function isLive(task: Pick<BashTask, "state">): boolean {
return task.state === "pending" || task.state === "running";
}
/**
* The word before a task's age, saying what the age counts from. `done` shows
* the exit status when there is one and it is not 0.
*/
export function stateWord(task: BashTask): string {
switch (task.state) {
case "pending":
return "queued";
case "running":
return "started";
case "stale":
return "stopped reporting";
case "done":
return task.exit === null || task.exit === 0
? "finished"
: `exited ${task.exit}`;
case "timed_out":
return "timed out";
case "interrupted":
return "interrupted";
case "killed":
return "killed";
}
}
/** Live tasks first, then the rest, each newest first; ties by id. */
export function sortTasks(tasks: BashTask[]): BashTask[] {
return [...tasks].sort((a, b) => {
const live = Number(isLive(b)) - Number(isLive(a));
if (live !== 0) return live;
if (a.created_unix !== b.created_unix)
return b.created_unix - a.created_unix;
return a.task.localeCompare(b.task);
});
}
/** The count of live tasks in words: `1 running task`, `3 running tasks`. */
export function runningTasksLabel(tasks: BashTask[]): string {
const n = tasks.filter(isLive).length;
return n === 1 ? "1 running task" : `${n} running tasks`;
}

View file

@ -0,0 +1,23 @@
// An agent's bash tasks, refetched on a timer: `GET /api/agents/{name}/bash-tasks`.
// `null` until the first answer; a failed fetch sets `error` and keeps the last
// list. Mount per agent name: the poll belongs to one agent.
import type { ProblemDetails } from "@hive/shared/api-error.js";
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
import { sortTasks, type BashTask } from "./bash-tasks.js";
const REFRESH_MS = 10_000;
export function useBashTasks(agentName: string): {
tasks: BashTask[] | null;
error: ProblemDetails | null;
} {
const { data, error } = usePolled(REFRESH_MS, async (signal) =>
sortTasks(
await fetchJson<BashTask[]>(
`/api/agents/${encodeURIComponent(agentName)}/bash-tasks`,
signal,
),
),
);
return { tasks: data, error };
}

View file

@ -0,0 +1,69 @@
// What the bash task list means: which tasks are live, the words for a state,
// the order, and the count. Run by `npm test`, which imports the `.ts` module
// as it is.
import assert from "node:assert/strict";
import { test } from "node:test";
import {
isLive,
runningTasksLabel,
sortTasks,
stateWord,
} from "../src/agent/terminal/bash-tasks.ts";
const task = (name, state, created, exit = null) => ({
task: name,
state,
cmd: "make",
created_unix: created,
since_unix: created + 1,
exit,
});
test("only a queued or running task, still reporting, is live", () => {
assert.equal(isLive(task("a", "pending", 1)), true);
assert.equal(isLive(task("a", "running", 1)), true);
for (const s of ["stale", "done", "timed_out", "interrupted", "killed"]) {
assert.equal(isLive(task("a", s, 1)), false, s);
}
});
test("the state word says what the age counts from", () => {
assert.equal(stateWord(task("a", "pending", 1)), "queued");
assert.equal(stateWord(task("a", "running", 1)), "started");
assert.equal(stateWord(task("a", "stale", 1)), "stopped reporting");
assert.equal(stateWord(task("a", "done", 1, 0)), "finished");
assert.equal(stateWord(task("a", "done", 1)), "finished");
assert.equal(stateWord(task("a", "done", 1, 2)), "exited 2");
assert.equal(stateWord(task("a", "timed_out", 1)), "timed out");
assert.equal(stateWord(task("a", "interrupted", 1)), "interrupted");
assert.equal(stateWord(task("a", "killed", 1)), "killed");
});
test("live tasks come first, each group newest first, ties by id", () => {
const list = [
task("old-done", "done", 10),
task("new-done", "done", 50),
task("old-run", "running", 20),
task("new-run", "running", 60),
task("b", "running", 40),
task("a", "running", 40),
];
assert.deepEqual(
sortTasks(list).map((t) => t.task),
["new-run", "a", "b", "old-run", "new-done", "old-done"],
);
});
test("the count names the live tasks only", () => {
assert.equal(runningTasksLabel([]), "0 running tasks");
assert.equal(runningTasksLabel([task("a", "running", 1)]), "1 running task");
assert.equal(
runningTasksLabel([
task("a", "running", 1),
task("b", "pending", 1),
task("c", "done", 1),
task("d", "stale", 1),
]),
"2 running tasks",
);
});

View file

@ -8,16 +8,17 @@ import {
startsOpen,
} from "../src/agent/detail/sections.ts";
test("a card holds status, terminal, subagents, then settings", () => {
test("a card holds status, terminal, subagents, background tasks, then settings", () => {
assert.deepEqual(sectionsFor(true), [
"status",
"terminal",
"subagents",
"tasks",
"settings",
]);
});
test("a page beside a terminal leaves the terminal and subagents out", () => {
test("a page beside a terminal leaves the terminal, subagents and tasks out", () => {
assert.deepEqual(sectionsFor(false), ["status", "settings"]);
});