swarm: list subagents from their published run state
The subagents list reads each subagent's status from the subagent-status bucket and reports its state, when it began and the exit status. A subagent with no status keeps the newest-row lookup, so an older daemon lists as before. The swarm UI uses the state when there is one and the row-age guess otherwise.
This commit is contained in:
parent
fec5461018
commit
e9bc28fe2f
4 changed files with 309 additions and 44 deletions
|
|
@ -1,8 +1,9 @@
|
|||
// <SubagentTerms> — an agent's subagents, under its own terminal preview in
|
||||
// an opened agent card on `AgentsPage`. The list is `GET /api/agents/{name}/subagents`:
|
||||
// the subagents the swarm queue holds terminal rows for, each with the
|
||||
// publish time of its newest row. Newest first, with a ticking age beside
|
||||
// the name; one quiet for `STALE_S` is dimmed. Subagents are spawned on
|
||||
// state its daemon published, or else the publish time of its newest row.
|
||||
// Newest first, with a ticking age beside the name; one that is not running
|
||||
// is dimmed (`isDimmed`, `subagent-list.ts`). Subagents are spawned on
|
||||
// demand, so `useSubagentList` refetches it. Picking one shows its
|
||||
// terminal in `AgentTermPreview`, expand-to-tab included. Read-only like the
|
||||
// agent's own: subagents take no input.
|
||||
|
|
@ -12,7 +13,13 @@ import { useEffect, useState } from "preact/hooks";
|
|||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||
import { Badge } from "@hive/shared/badge.js";
|
||||
import { AgentTermPreview } from "./AgentTermPreview.js";
|
||||
import { STALE_S, runLabel, stillListed } from "./subagent-list.js";
|
||||
import {
|
||||
isDimmed,
|
||||
lastActive,
|
||||
runLabel,
|
||||
stateLabel,
|
||||
stillListed,
|
||||
} from "./subagent-list.js";
|
||||
import { useSubagentList } from "./useSubagentList.js";
|
||||
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
|
||||
import "./SubagentTerms.css";
|
||||
|
|
@ -42,10 +49,11 @@ export function SubagentTerms({ agentName }: { agentName: string }) {
|
|||
<div class="ui-subagent-terms-list">
|
||||
<span class="ui-subagent-terms-heading">subagents</span>
|
||||
{subagents?.map((sub) => {
|
||||
const { name, last_row_unix } = sub;
|
||||
const { name } = sub;
|
||||
const runs = runLabel(sub);
|
||||
const stale =
|
||||
last_row_unix !== null && nowS - last_row_unix > STALE_S;
|
||||
const active = lastActive(sub);
|
||||
const status = stateLabel(sub);
|
||||
const stale = isDimmed(sub, nowS);
|
||||
return (
|
||||
<span
|
||||
key={name}
|
||||
|
|
@ -63,9 +71,10 @@ export function SubagentTerms({ agentName }: { agentName: string }) {
|
|||
onClick={() => setPicked(name === selected ? null : name)}
|
||||
/>
|
||||
{runs && <span class="ui-subagent-terms-age">{runs}</span>}
|
||||
{last_row_unix !== null && (
|
||||
{status && <span class="ui-subagent-terms-age">{status}</span>}
|
||||
{active !== null && (
|
||||
<span class="ui-subagent-terms-age">
|
||||
<RelativeTime epochMs={last_row_unix * 1000} />
|
||||
<RelativeTime epochMs={active * 1000} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
// its own terminal page. On a subagent's page the current one is marked and the
|
||||
// list starts with a way back to the agent's own terminal. The list is the
|
||||
// same `GET /api/agents/{name}/subagents` as the one under the preview, and
|
||||
// "running" means the same too: a row published within `STALE_S`
|
||||
// (`subagent-list.ts`). Quiet subagents are listed below, dimmed, so a finished
|
||||
// "running" means the same too: what the subagent's daemon says, or without
|
||||
// that a row published within `STALE_S` (`subagent-list.ts`). Quiet subagents are listed below, dimmed, so a finished
|
||||
// one can still be opened.
|
||||
//
|
||||
// With `onSplit` the entries are buttons that put the subagent's terminal
|
||||
|
|
@ -22,9 +22,11 @@ import { Popover } from "../../ui/popover/Popover.js";
|
|||
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
|
||||
import { termPaths } from "./term-paths.js";
|
||||
import {
|
||||
lastActive,
|
||||
runLabel,
|
||||
runningLabel,
|
||||
splitRunning,
|
||||
stateLabel,
|
||||
type Subagent,
|
||||
} from "./subagent-list.js";
|
||||
import { useSubagentList } from "./useSubagentList.js";
|
||||
|
|
@ -51,9 +53,12 @@ function Entry({
|
|||
{runLabel(sub)}
|
||||
</span>
|
||||
)}
|
||||
{sub.last_row_unix !== null && (
|
||||
{stateLabel(sub) && (
|
||||
<span class="ui-subagents-menu-age">{stateLabel(sub)}</span>
|
||||
)}
|
||||
{lastActive(sub) !== null && (
|
||||
<span class="ui-subagents-menu-age">
|
||||
<RelativeTime epochMs={sub.last_row_unix * 1000} />
|
||||
<RelativeTime epochMs={(lastActive(sub) ?? 0) * 1000} />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,21 @@ export interface Subagent {
|
|||
model: string | null;
|
||||
/** Its reasoning effort; `null` when not readable. */
|
||||
effort: string | null;
|
||||
/**
|
||||
* Whether it runs, as its daemon published it: `stale` is a running status
|
||||
* the daemon stopped refreshing. `null` (or absent) for a subagent whose
|
||||
* daemon publishes no status, which is judged by its newest row instead.
|
||||
*/
|
||||
state?: "running" | "finished" | "stale" | null;
|
||||
/** Unix seconds when it entered `state`; `null` with `state`. */
|
||||
since_unix?: number | null;
|
||||
/** The process's exit status once it finished by itself. */
|
||||
exit?: number | null;
|
||||
}
|
||||
|
||||
/** When a subagent was last heard from: its newest row, else its status. */
|
||||
export function lastActive(sub: Subagent): number | null {
|
||||
return sub.last_row_unix ?? sub.since_unix ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -23,23 +38,55 @@ export function runLabel(sub: Pick<Subagent, "model" | "effort">): string {
|
|||
return [sub.model, sub.effort].filter((v) => v).join(" · ");
|
||||
}
|
||||
|
||||
/** A subagent quiet for longer than this is no longer counted as running. */
|
||||
/**
|
||||
* A subagent whose daemon publishes no status, quiet for longer than this, is
|
||||
* no longer counted as running.
|
||||
*/
|
||||
export const STALE_S = 15 * 60;
|
||||
|
||||
/**
|
||||
* Whether a subagent counts as running: it published a row within
|
||||
* [`STALE_S`]. The controller reports only when the newest row was published,
|
||||
* so this is a guess. A subagent that is thinking quietly drops out, and one
|
||||
* that finished stays in until the window passes.
|
||||
* Whether a subagent counts as running. The daemon's own status decides when
|
||||
* there is one. Without it this is a guess from the newest row: published
|
||||
* within [`STALE_S`], so a subagent thinking quietly drops out and one that
|
||||
* finished stays in until the window passes.
|
||||
*/
|
||||
export function isRunning(sub: Subagent, nowS: number): boolean {
|
||||
const state = sub.state ?? null;
|
||||
if (state !== null) return state === "running";
|
||||
return sub.last_row_unix !== null && nowS - sub.last_row_unix <= STALE_S;
|
||||
}
|
||||
|
||||
/** Newest row first; a subagent whose time could not be read sorts last. */
|
||||
/**
|
||||
* Whether the list dims a subagent: its daemon says it is not running, or,
|
||||
* with no status, its newest row is older than [`STALE_S`]. A subagent whose
|
||||
* time could not be read is not dimmed, as nothing says it has stopped.
|
||||
*/
|
||||
export function isDimmed(sub: Subagent, nowS: number): boolean {
|
||||
const state = sub.state ?? null;
|
||||
if (state !== null) return state !== "running";
|
||||
return sub.last_row_unix !== null && nowS - sub.last_row_unix > STALE_S;
|
||||
}
|
||||
|
||||
/**
|
||||
* A word for a state that is neither running nor unknown, to show beside the
|
||||
* subagent: `finished`, or `stopped reporting` for a stale status. Empty
|
||||
* when running or when the daemon published no status.
|
||||
*/
|
||||
export function stateLabel(sub: Subagent): string {
|
||||
switch (sub.state ?? null) {
|
||||
case "finished":
|
||||
return "finished";
|
||||
case "stale":
|
||||
return "stopped reporting";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** Newest first; a subagent whose time could not be read sorts last. */
|
||||
export function byRecency(a: Subagent, b: Subagent): number {
|
||||
const ta = a.last_row_unix ?? -Infinity;
|
||||
const tb = b.last_row_unix ?? -Infinity;
|
||||
const ta = lastActive(a) ?? -Infinity;
|
||||
const tb = lastActive(b) ?? -Infinity;
|
||||
if (ta !== tb) return tb > ta ? 1 : -1;
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,12 @@ import { test } from "node:test";
|
|||
import {
|
||||
STALE_S,
|
||||
byRecency,
|
||||
isDimmed,
|
||||
isRunning,
|
||||
runLabel,
|
||||
runningLabel,
|
||||
splitRunning,
|
||||
stateLabel,
|
||||
stillListed,
|
||||
} from "../src/agent/terminal/subagent-list.ts";
|
||||
|
||||
|
|
@ -73,6 +75,58 @@ test("a pick holds only while the refreshed list still has it", () => {
|
|||
assert.equal(stillListed(null, subs), null);
|
||||
});
|
||||
|
||||
const withState = (name, state, sinceAgo, rowAgo = null) => ({
|
||||
...sub(name, rowAgo),
|
||||
state,
|
||||
since_unix: NOW - sinceAgo,
|
||||
exit: state === "finished" ? 0 : null,
|
||||
});
|
||||
|
||||
test("a published state decides, whatever the newest row's age says", () => {
|
||||
// Quiet for an hour but the daemon says running: thinking, not stopped.
|
||||
assert.equal(isRunning(withState("a", "running", 3600, 3600), NOW), true);
|
||||
// A row a second ago but the daemon says finished.
|
||||
assert.equal(isRunning(withState("a", "finished", 5, 1), NOW), false);
|
||||
assert.equal(isRunning(withState("a", "stale", 900), NOW), false);
|
||||
});
|
||||
|
||||
test("only a subagent without a status falls back to the row's age", () => {
|
||||
assert.equal(isDimmed(sub("a", STALE_S + 1), NOW), true);
|
||||
assert.equal(isDimmed(sub("a", 5), NOW), false);
|
||||
assert.equal(isDimmed(sub("a", null), NOW), false);
|
||||
assert.equal(isDimmed(withState("a", "running", 3600, 3600), NOW), false);
|
||||
assert.equal(isDimmed(withState("a", "finished", 5, 1), NOW), true);
|
||||
assert.equal(isDimmed(withState("a", "stale", 5), NOW), true);
|
||||
});
|
||||
|
||||
test("the state word names finished and stale subagents only", () => {
|
||||
assert.equal(stateLabel(withState("a", "finished", 5)), "finished");
|
||||
assert.equal(stateLabel(withState("a", "stale", 5)), "stopped reporting");
|
||||
assert.equal(stateLabel(withState("a", "running", 5)), "");
|
||||
assert.equal(stateLabel(sub("a", 5)), "");
|
||||
});
|
||||
|
||||
test("a subagent with only a status sorts by when its state began", () => {
|
||||
const list = [
|
||||
withState("old", "finished", 500),
|
||||
sub("row", 100),
|
||||
withState("new", "running", 5),
|
||||
];
|
||||
assert.deepEqual(
|
||||
[...list].sort(byRecency).map((s) => s.name),
|
||||
["new", "row", "old"],
|
||||
);
|
||||
const { running, quiet } = splitRunning(list, NOW);
|
||||
assert.deepEqual(
|
||||
running.map((s) => s.name),
|
||||
["new", "row"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
quiet.map((s) => s.name),
|
||||
["old"],
|
||||
);
|
||||
});
|
||||
|
||||
test("the run label names the model and effort, whichever are known", () => {
|
||||
assert.equal(runLabel({ model: "opus", effort: "high" }), "opus · high");
|
||||
assert.equal(runLabel({ model: "opus", effort: null }), "opus");
|
||||
|
|
|
|||
Loading…
Reference in a new issue