Files
multica/packages/core/agents/derive-presence.ts
YYClaw a6b83fef41 fix(agents): surface archived status for retired agents (#3608)
Retired agents (agent.archived_at set) previously read as offline across
the agent dot, hover card, detail badge, and squad member list — a
leftover online runtime row could even make them look reachable. Add a
dedicated archived presence/status that wins over every runtime/task
signal so a retired agent never reads as live or merely offline.

- Add archived to AgentAvailability and SquadMemberStatusValue unions
- Short-circuit deriveAgentPresenceDetail before runtime/task scan
- Backend deriveSquadMemberStatus returns archived instead of offline
- Render gray Archive dot/label; skip workload + reassign affordances
- en/ko/zh-Hans locale strings
2026-06-02 13:03:15 +08:00

156 lines
5.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Pure derivation of an agent's user-facing presence from raw server data.
// The back-end stores facts (which tasks exist, their statuses, the runtime
// last_seen_at); the front-end translates them into two orthogonal
// dimensions:
//
// 1. AgentAvailability — derived from runtime reachability only.
// 2. Workload — derived from the task counts only.
//
// They are computed independently and assembled into AgentPresenceDetail.
// Workload is strictly "what's on the plate right now" — no historical
// terminal state. Past failures / completions live on the detail page
// (Recent Work, failure_reason) and Inbox.
import { deriveRuntimeHealth } from "../runtimes/derive-health";
import type { Agent, AgentRuntime, AgentTask } from "../types";
import type {
AgentAvailability,
AgentPresenceDetail,
Workload,
} from "./types";
// AgentAvailability mirrors RuntimeHealth's reachability buckets but folds
// `about_to_gc` into `offline` — both mean "long unreachable" from the
// user's standpoint; the GC-warning copy belongs to the runtime card, not
// the agent dot.
export function deriveAgentAvailability(
runtime: AgentRuntime | null,
now: number,
): AgentAvailability {
if (!runtime) return "offline";
const health = deriveRuntimeHealth(runtime, now);
if (health === "online") return "online";
if (health === "recently_lost") return "unstable";
return "offline"; // offline | about_to_gc collapse here
}
// Atomic workload derivation: pure 3-way classification of running/queued
// counts. Exported so Runtime-level views (which already aggregate counts
// per-runtime in their own indices) can plug into the same vocabulary
// without re-deriving from raw task arrays.
export function deriveWorkload(counts: {
runningCount: number;
queuedCount: number;
}): Workload {
if (counts.runningCount > 0) return "working";
if (counts.queuedCount > 0) return "queued";
return "idle";
}
interface WorkloadDetail {
workload: Workload;
runningCount: number;
queuedCount: number;
}
// Aggregates a task list into running/queued counts, then classifies via
// deriveWorkload. Caller pre-filters to the relevant scope (per-agent or
// per-runtime) — we don't filter again here.
export function deriveWorkloadDetail(tasks: readonly AgentTask[]): WorkloadDetail {
let runningCount = 0;
let queuedCount = 0;
for (const t of tasks) {
if (t.status === "running") {
runningCount += 1;
} else if (
t.status === "queued" ||
t.status === "dispatched" ||
// The daemon parked this task on a busy local_directory path. It's
// still on the agent's plate (counts toward "queued" presence), but
// it hasn't reached the run phase yet.
t.status === "waiting_local_directory"
) {
queuedCount += 1;
}
// Terminal statuses (completed / failed / cancelled) intentionally
// ignored — workload is "what's on the plate right now", not history.
}
return {
workload: deriveWorkload({ runningCount, queuedCount }),
runningCount,
queuedCount,
};
}
interface DerivePresenceInput {
agent: Agent;
runtime: AgentRuntime | null;
// Tasks for THIS agent only. Callers (buildPresenceMap, hooks) pre-filter
// by agent_id — we don't re-check here.
tasks: readonly AgentTask[];
// Wall-clock millis used by deriveAgentAvailability to bucket runtime
// health. Threading it as a parameter keeps the function pure.
now: number;
}
export function deriveAgentPresenceDetail(input: DerivePresenceInput): AgentPresenceDetail {
// Archived wins over every runtime/task signal — a retired agent must
// never read as live anywhere. Short-circuit before deriving runtime
// health or workload so a leftover online runtime row or a stale snapshot
// task can't leak "Online" / "Working" into any consumer.
if (input.agent.archived_at) {
return {
availability: "archived",
workload: "idle",
runningCount: 0,
queuedCount: 0,
capacity: input.agent.max_concurrent_tasks,
};
}
const availability = deriveAgentAvailability(input.runtime, input.now);
const detail = deriveWorkloadDetail(input.tasks);
return {
availability,
workload: detail.workload,
runningCount: detail.runningCount,
queuedCount: detail.queuedCount,
capacity: input.agent.max_concurrent_tasks,
};
}
// Workspace-level batch builder. One pass over the workspace's agents
// produces a Map<agentId, AgentPresenceDetail> that every list / card /
// runtime sub-page can read without re-deriving.
export function buildPresenceMap(args: {
agents: readonly Agent[];
runtimes: readonly AgentRuntime[];
// The workspace agent task snapshot: every active task plus each agent's
// most recent terminal task. Comes straight from getAgentTaskSnapshot()
// — no pre-filtering needed. Terminal rows are silently ignored by
// deriveWorkloadDetail (workload is current-state only).
snapshot: readonly AgentTask[];
now: number;
}): Map<string, AgentPresenceDetail> {
const out = new Map<string, AgentPresenceDetail>();
const runtimesById = new Map<string, AgentRuntime>();
for (const r of args.runtimes) runtimesById.set(r.id, r);
// Group tasks by agent_id once — O(N) — so per-agent derivation is O(1)
// task scans rather than O(N×M).
const tasksByAgent = new Map<string, AgentTask[]>();
for (const t of args.snapshot) {
const list = tasksByAgent.get(t.agent_id);
if (list) list.push(t);
else tasksByAgent.set(t.agent_id, [t]);
}
for (const agent of args.agents) {
const runtime = runtimesById.get(agent.runtime_id) ?? null;
const tasks = tasksByAgent.get(agent.id) ?? [];
out.set(agent.id, deriveAgentPresenceDetail({ agent, runtime, tasks, now: args.now }));
}
return out;
}