improve(swarm): close out wait, phase, and worker follow-ups (#111605)

* fix(codex): support full swarm wait budgets

* feat(ui): bucket swarm dots by phase

* docs(swarm): document flat worker patterns

* fix(codex): configured per-tool timeout still wins over swarm wait default

* fix(codex): outer swarm-wait watchdog outlives the 600s budget (cap + grace)

* fix(ui): order swarm phase buckets by observation rank

* fix(ui): repair rank-order widget test — proper lit render container, untangle sibling assertions

* fix(ui): implicit swarm phase assignment only on child creation events

* test(ui): split swarm activity integration coverage
This commit is contained in:
Peter Steinberger
2026-07-19 20:11:54 -07:00
committed by GitHub
parent 19314aa31a
commit 7d4dccf959
11 changed files with 568 additions and 27 deletions
+1
View File
@@ -10435,6 +10435,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H3: Loop on a decision gate
- H3: Process the first child that finishes
- H2: How collector children behave
- H3: Children are leaves
- H2: Observe a Swarm
- H2: Use Swarm from other harnesses
- H2: Limits and roadmap
+43 -1
View File
@@ -239,6 +239,24 @@ The target agent resolves in this order:
A dedicated, lean worker agent is useful when swarm children need a smaller
tool surface, cheaper model, or tighter sandbox policy. OpenClaw does not ship
a built-in `worker` agent id; configure one before naming it as the default.
Harden that worker with `tools.swarm: false` in its per-agent configuration so
it can be spawned but cannot start swarms from its own top-level sessions:
```json5
{
tools: { swarm: { enabled: true, defaultAgentId: "worker" } },
agents: {
list: [
{
id: "main",
default: true,
subagents: { allowAgents: ["worker"] },
},
{ id: "worker", tools: { swarm: false } },
],
},
}
```
Collector approvals fail closed. A child never opens an operator approval
prompt. A tool action that would require approval is denied, and the child can
@@ -251,6 +269,29 @@ not validate, the collector completion keeps the child's raw text, leaves
`structured` unset, and includes `schemaError`. The low-level `agents_wait`
result exposes those fields for explicit recovery logic.
### Children are leaves
Swarm children are leaves by default. The universal
`agents.defaults.subagents.maxSpawnDepth` guard prevents a child from spawning
its own children at the default depth of `1`. The usual orchestration idiom is
to return work to the parent, not spawn more work from a child:
```javascript
const plan = await agents.run("Plan this job as independent tasks.", {
schema: {
type: "object",
properties: { tasks: { type: "array", items: { type: "string" } } },
required: ["tasks"],
additionalProperties: false,
},
});
return await Promise.all(plan.tasks.map((task) => agents.run(task)));
```
Nested sub-agents are an operator opt-in through
`agents.defaults.subagents.maxSpawnDepth` and are discouraged for Swarm.
Group caps, budgets, and observability all assume flat collector groups.
Every child has one admission owner. Announce and interactive children use
`agents.defaults.subagents.maxChildrenPerAgent` (default `5`) and do not count
collector children. Collector children use only `maxChildrenPerGroup` and
@@ -286,7 +327,8 @@ calls.
Codex Code Mode automatically exposes eligible dynamic OpenClaw tools under
`tools.*`. It does not use OpenClaw's QuickJS guest API or require
`tools.codeMode`, but `tools.swarm` must still be enabled. Use this pattern:
`tools.codeMode`, but `tools.swarm` must still be enabled. Codex harness
`agents_wait` calls support the full 600-second timeout. Use this pattern:
```javascript
const tasks = [
@@ -290,6 +290,35 @@ describe("dynamic tool execution helpers", () => {
).toBe(90_000);
});
it("gives agents_wait the long-running cap while preserving its inner timeout budget", () => {
const call = {
threadId: "thread-1",
turnId: "turn-1",
callId: "call-agents-wait",
namespace: null,
tool: "agents_wait",
};
expect(
resolveDynamicToolCallTimeoutMs({
call: { ...call, arguments: { ids: ["run-1"] } },
config: undefined,
}),
).toBe(630_000);
expect(
resolveDynamicToolCallTimeoutMs({
call: { ...call, arguments: { ids: ["run-1"], timeoutSeconds: 120 } },
config: undefined,
}),
).toBe(150_000);
expect(
resolveDynamicToolCallTimeoutMs({
call: { ...call, arguments: { ids: ["run-1"], timeoutSeconds: 600 } },
config: undefined,
}),
).toBe(630_000);
});
it("returns a failed dynamic tool response when an app-server tool call exceeds the deadline", async () => {
vi.useFakeTimers();
let capturedSignal: AbortSignal | undefined;
@@ -48,6 +48,9 @@ const CODEX_DYNAMIC_COMPUTER_COMPLETION_GRACE_MS = 30_000;
const CODEX_DYNAMIC_IMAGE_TOOL_TIMEOUT_MS = 60_000;
/** Timeout for message-delivery dynamic tool calls. */
const CODEX_DYNAMIC_MESSAGE_TOOL_TIMEOUT_MS = CODEX_DYNAMIC_TOOL_MAX_TIMEOUT_MS;
/** Outer default for collector waits: full swarm budget plus completion grace. */
const CODEX_DYNAMIC_AGENTS_WAIT_TOOL_TIMEOUT_MS =
CODEX_DYNAMIC_TOOL_MAX_TIMEOUT_MS + CODEX_DYNAMIC_TOOL_TIMEOUT_SECONDS_GRACE_MS;
const LOG_FIELD_MAX_LENGTH = 160;
type DynamicToolTimeoutDetails = {
@@ -488,6 +491,24 @@ export function resolveDynamicToolCallTimeoutMs(params: {
if (params.call.tool === "message") {
return CODEX_DYNAMIC_MESSAGE_TOOL_TIMEOUT_MS;
}
if (params.call.tool === "agents_wait") {
// Collector waits default to the full swarm budget, but an operator's
// configured per-tool timeout still wins over that default. The outer
// watchdog must outlive the tool's own 600s deadline (cap + grace) so a
// full-budget wait returns its structured timeout result instead of
// being aborted by the harness first.
const requestedMs =
readDynamicToolCallTimeoutMs(params.call.arguments) ??
readConfiguredDynamicToolTimeoutMs(params.call.tool, params.config) ??
CODEX_DYNAMIC_AGENTS_WAIT_TOOL_TIMEOUT_MS;
return Math.max(
1,
Math.min(
CODEX_DYNAMIC_TOOL_MAX_TIMEOUT_MS + CODEX_DYNAMIC_TOOL_TIMEOUT_SECONDS_GRACE_MS,
Math.floor(requestedMs),
),
);
}
return clampDynamicToolTimeoutMs(
readDynamicToolCallTimeoutMs(params.call.arguments) ??
readConfiguredDynamicToolTimeoutMs(params.call.tool, params.config) ??
+1
View File
@@ -2130,6 +2130,7 @@ export const en: TranslationMap = {
title: "Swarm",
description: "Let Code Mode orchestrate groups of subagents in parallel.",
empty: "No active swarms.",
defaultPhase: "Unphased",
},
},
aboutPage: {
+78 -1
View File
@@ -7,7 +7,13 @@ import { renderSwarmWidget } from "./swarm.ts";
const parentSessionKey = "agent:main:parent";
function session(overrides: Partial<GatewaySessionRow>): GatewaySessionRow {
type SwarmTestSession = GatewaySessionRow & {
swarmLog?: string;
swarmPhase?: string;
swarmPhaseRank?: number;
};
function session(overrides: Partial<SwarmTestSession>): SwarmTestSession {
return {
key: "agent:main:child",
kind: "direct",
@@ -79,4 +85,75 @@ describe("swarm board widget", () => {
"No active swarms.",
);
});
it("buckets children by phase, labels the default bucket, and shows the latest log", () => {
const container = document.createElement("div");
document.body.append(container);
render(
renderSwarmWidget({
sessionKey: parentSessionKey,
sessions: [
session({ key: "unphased", label: "Older child", status: "running" }),
session({ key: "planning", label: "Planner", status: "done", swarmPhase: "Plan" }),
session({
key: "building",
label: "Builder",
subagentRunState: "active",
swarmPhase: "Build",
swarmLog: "Implementing the selected plan.",
}),
],
}),
container,
);
expect(
[...container.querySelectorAll(".swarm-widget__phase")].map((phase) =>
phase.textContent?.trim(),
),
).toEqual(["Unphased", "Plan", "Build"]);
expect(
[...container.querySelectorAll(".swarm-widget__phase-row")].map(
(row) => row.querySelectorAll(".swarm-widget__dot").length,
),
).toEqual([1, 1, 1]);
expect(container.querySelector(".swarm-widget__narrator")?.textContent).toContain(
"Implementing the selected plan.",
);
});
it("orders phase buckets by observation rank, not canonical row order", () => {
const container = document.createElement("div");
document.body.append(container);
render(
renderSwarmWidget({
sessionKey: parentSessionKey,
sessions: [
// Canonical list order is reversed vs phase announcement order.
session({
key: "builder",
label: "Builder",
status: "running",
swarmPhase: "Build",
swarmPhaseRank: 1,
}),
session({ key: "late-unphased", label: "Late child", status: "running" }),
session({
key: "planner",
label: "Planner",
status: "done",
swarmPhase: "Plan",
swarmPhaseRank: 0,
}),
],
}),
container,
);
expect(
[...container.querySelectorAll(".swarm-widget__phase")].map((phase) =>
phase.textContent?.trim(),
),
).toEqual(["Plan", "Build", "Unphased"]);
});
});
+48 -22
View File
@@ -22,11 +22,14 @@ type SwarmGroup = {
running: number;
done: number;
failed: number;
narrator?: string;
phases: SwarmPhase[];
};
type SwarmPhaseCarrier = {
swarmLog?: unknown;
swarmPhase?: unknown;
swarmPhaseRank?: unknown;
};
function swarmStatusLabel(status: SwarmDotStatus): string {
@@ -59,13 +62,22 @@ function groupTail(groupId: string): string {
return groupId.split(":").findLast(Boolean) ?? groupId;
}
function swarmPhaseRank(row: GatewaySessionRow): number {
const rank = (row as GatewaySessionRow & SwarmPhaseCarrier).swarmPhaseRank;
// Unranked (unphased or pre-rank) buckets sort after announced phases.
return typeof rank === "number" && Number.isFinite(rank) ? rank : Number.MAX_SAFE_INTEGER;
}
function swarmPhase(row: GatewaySessionRow): string | undefined {
// Phase events will attach this optional display bucket. Until then every
// collector child belongs to the single unlabelled phase below.
const phase = (row as GatewaySessionRow & SwarmPhaseCarrier).swarmPhase;
return typeof phase === "string" && phase.trim() ? phase.trim() : undefined;
}
function swarmLog(row: GatewaySessionRow): string | undefined {
const log = (row as GatewaySessionRow & SwarmPhaseCarrier).swarmLog;
return typeof log === "string" && log.trim() ? log.trim() : undefined;
}
function isSwarmChildForSession(row: GatewaySessionRow, sessionKey: string): boolean {
if (
(row.parentSessionKey && areUiSessionKeysEquivalent(row.parentSessionKey, sessionKey)) ||
@@ -82,7 +94,10 @@ function collectActiveSwarmGroups(
sessions: readonly GatewaySessionRow[],
sessionKey: string,
): SwarmGroup[] {
const byGroup = new Map<string, Array<{ phase?: string; dot: SwarmDot }>>();
const byGroup = new Map<
string,
Array<{ phase?: string; phaseRank: number; log?: string; dot: SwarmDot }>
>();
for (const row of sessions) {
const groupId = row.swarmGroupId?.trim();
if (!groupId || !isSwarmChildForSession(row, sessionKey)) {
@@ -95,6 +110,8 @@ function collectActiveSwarmGroups(
}
dots.push({
phase: swarmPhase(row),
phaseRank: swarmPhaseRank(row),
log: swarmLog(row),
dot: {
key: row.key,
label: row.label?.trim() || row.displayName?.trim() || row.derivedTitle?.trim() || row.key,
@@ -107,11 +124,12 @@ function collectActiveSwarmGroups(
return [...byGroup.entries()]
.map(([groupId, entries]) => {
const dots = entries.map((entry) => entry.dot);
const phases = new Map<string | undefined, SwarmDot[]>();
const phases = new Map<string | undefined, { rank: number; dots: SwarmDot[] }>();
for (const entry of entries) {
const phaseDots = phases.get(entry.phase) ?? [];
phaseDots.push(entry.dot);
phases.set(entry.phase, phaseDots);
const bucket = phases.get(entry.phase) ?? { rank: entry.phaseRank, dots: [] };
bucket.rank = Math.min(bucket.rank, entry.phaseRank);
bucket.dots.push(entry.dot);
phases.set(entry.phase, bucket);
}
return {
groupId,
@@ -119,7 +137,10 @@ function collectActiveSwarmGroups(
running: dots.filter((dot) => dot.status === "running").length,
done: dots.filter((dot) => dot.status === "done").length,
failed: dots.filter((dot) => dot.status === "failed").length,
phases: [...phases.entries()].map(([title, phaseDots]) => ({ title, dots: phaseDots })),
narrator: entries.map((entry) => entry.log).find(Boolean),
phases: [...phases.entries()]
.toSorted((left, right) => left[1].rank - right[1].rank)
.map(([title, bucket]) => ({ title, dots: bucket.dots })),
} satisfies SwarmGroup;
})
.filter((group) =>
@@ -155,22 +176,27 @@ export function renderSwarmWidget({
${swarmStatusLabel("done")} · ${group.failed} ${swarmStatusLabel("failed")}</span
>
</header>
${group.narrator
? html`<div class="swarm-widget__narrator">${group.narrator}</div>`
: nothing}
${group.phases.map(
(phase) => html`
${phase.title
? html`<div class="swarm-widget__phase">${phase.title}</div>`
: nothing}
<div class="swarm-widget__dots" role="list">
${phase.dots.map(
(dot) => html`
<span
class=${`swarm-widget__dot swarm-widget__dot--${dot.status}`}
role="listitem"
title=${`${dot.label}: ${swarmStatusLabel(dot.status)}`}
aria-label=${`${dot.label}: ${swarmStatusLabel(dot.status)}`}
></span>
`,
)}
<div class="swarm-widget__phase-row">
<div class="swarm-widget__phase">
${phase.title ?? t("labsPage.swarm.defaultPhase")}
</div>
<div class="swarm-widget__dots" role="list">
${phase.dots.map(
(dot) => html`
<span
class=${`swarm-widget__dot swarm-widget__dot--${dot.status}`}
role="listitem"
title=${`${dot.label}: ${swarmStatusLabel(dot.status)}`}
aria-label=${`${dot.label}: ${swarmStatusLabel(dot.status)}`}
></span>
`,
)}
</div>
</div>
`,
)}
+26 -2
View File
@@ -48,6 +48,7 @@ import {
resolveUiSelectedGlobalAgentId,
uiSessionRowMatchesSelectedChat,
} from "./session-key.ts";
import { SwarmActivityTracker } from "./swarm-activity.ts";
export {
buildSessionUsageDateParams,
requestSessionUsage,
@@ -723,6 +724,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
string,
{ token: symbol; previous: string | null | undefined }
>();
const swarmActivity = new SwarmActivityTracker();
let subscribedClient: GatewayBrowserClient | null = null;
let lastListOptions: SessionListOptions = {};
let hasForegroundListOptions = false;
@@ -853,6 +855,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
}
}
}
nextResult = swarmActivity.decorate(nextResult);
canonicalListRevision += 1;
publish({
result: nextResult,
@@ -1220,7 +1223,9 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
defaults?: SessionsListResult["defaults"],
options?: SessionReconcileOptions,
): boolean => {
const result = reconcileSessionHistory(state.result, row, defaults, options);
const result = swarmActivity.decorate(
reconcileSessionHistory(state.result, row, defaults, options),
);
if (result === state.result) {
return false;
}
@@ -1238,7 +1243,17 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
payload: unknown,
options?: SessionReconcileOptions,
): SessionChangedResult => {
const reconciled = reconcileSessionChanged(state.result, payload, options);
swarmActivity.observe(payload);
const base = reconcileSessionChanged(state.result, payload, options);
const result = swarmActivity.decorate(base.result);
const reconciled =
result === base.result
? base
: {
...base,
result,
row: base.row ? result?.sessions.find((row) => row.key === base.row?.key) : undefined,
};
if (reconciled.applied && (reconciled.result !== state.result || reconciled.deletedKey)) {
publish({
...state,
@@ -1577,6 +1592,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
if (connectionChanged) {
connectionEpoch += 1;
invalidateGroupsLoad();
swarmActivity.clear();
inFlight = null;
queuedRefresh = null;
rollbackPendingModelPatches();
@@ -1625,6 +1641,13 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
});
const stopEvents = gateway.subscribeEvents((event) => {
if (isSessionStateEvent(event)) {
swarmActivity.observe(event.payload);
// Preserve canonical list filtering: decorate rows already admitted here,
// then let the refresh below admit new children before applying cached notes.
const decoratedResult = swarmActivity.decorate(state.result);
if (decoratedResult !== state.result) {
publish({ ...state, result: decoratedResult });
}
const reconciled = reconcileSessionChanged(state.result, event.payload, {
resultAgentId: state.agentId,
showArchived: lastListOptions.showArchived,
@@ -1716,6 +1739,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
queuedRefresh = null;
subscribedClient = null;
pendingModelPatches.clear();
swarmActivity.clear();
stopGateway();
stopEvents();
createdListeners.clear();
@@ -0,0 +1,172 @@
import { describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient, GatewayEventFrame, GatewayHelloOk } from "../../api/gateway.ts";
import type { GatewaySessionRow, SessionsListResult } from "../../api/types.ts";
import { waitForFast } from "../../test-helpers/wait-for.ts";
import { createSessionCapability } from "./index.ts";
function sessionsResult(sessions: SessionsListResult["sessions"], ts: number): SessionsListResult {
return {
ts,
path: "(multiple)",
count: sessions.length,
defaults: { modelProvider: null, model: null, contextTokens: null },
sessions,
};
}
function createGatewayHarness(client: GatewayBrowserClient) {
const snapshot: {
client: GatewayBrowserClient | null;
connected: boolean;
sessionKey: string;
assistantAgentId: string | null;
hello: GatewayHelloOk | null;
} = {
client,
connected: true,
sessionKey: "agent:main:main",
assistantAgentId: "main",
hello: null,
};
const eventListeners = new Set<(event: GatewayEventFrame) => void>();
return {
gateway: {
get snapshot() {
return snapshot;
},
subscribe: () => () => undefined,
subscribeEvents(listener: (event: GatewayEventFrame) => void) {
eventListeners.add(listener);
return () => eventListeners.delete(listener);
},
},
emitEvent: (event: GatewayEventFrame) => {
for (const listener of eventListeners) {
listener(event);
}
},
};
}
describe("session swarm activity", () => {
it("keeps chronological phase and log annotations across canonical refreshes", async () => {
const parentKey = "agent:main:main";
const groupId = "swarm:agent:main:main:turn-42";
let rows: SessionsListResult["sessions"] = [
{ key: parentKey, kind: "direct", updatedAt: 1 },
{
key: "agent:main:subagent:older",
kind: "direct",
parentSessionKey: parentKey,
swarmGroupId: groupId,
status: "running",
updatedAt: 2,
},
];
let ts = 1;
const request = vi.fn(async (method: string) => {
if (method !== "sessions.list") {
throw new Error(`Unexpected request: ${method}`);
}
return sessionsResult(rows, ts++);
});
const client = { request } as unknown as GatewayBrowserClient;
const { gateway, emitEvent } = createGatewayHarness(client);
const sessions = createSessionCapability(gateway);
const note = (kind: "phase" | "log", text: string) => ({
sessionKey: parentKey,
reason: "swarm-note",
swarmGroupId: groupId,
kind,
text,
key: parentKey,
updatedAt: 1,
});
const child = (key: string, updatedAt: number) => ({
sessionKey: key,
reason: "create",
key,
kind: "direct",
parentSessionKey: parentKey,
swarmGroupId: groupId,
status: "running",
updatedAt,
});
const emitChanged = (payload: Record<string, unknown>) =>
emitEvent({ type: "event", event: "sessions.changed", payload });
await sessions.refresh({ force: true });
emitChanged(note("phase", "Plan"));
await waitForFast(() => expect(request).toHaveBeenCalledTimes(2));
rows = [
...rows,
{
key: "agent:main:subagent:planner",
kind: "direct",
parentSessionKey: parentKey,
swarmGroupId: groupId,
status: "running",
updatedAt: 3,
},
];
emitChanged(child("agent:main:subagent:planner", 3));
await waitForFast(() =>
expect(sessions.state.result?.sessions.some((row) => row.key.endsWith(":planner"))).toBe(
true,
),
);
type SwarmDisplayRow = GatewaySessionRow & { swarmLog?: string; swarmPhase?: string };
const displayRows = () => sessions.state.result?.sessions as SwarmDisplayRow[] | undefined;
expect(displayRows()?.find((row) => row.key.endsWith(":older"))?.swarmPhase).toBeUndefined();
expect(displayRows()?.find((row) => row.key.endsWith(":planner"))?.swarmPhase).toBe("Plan");
emitChanged(note("log", "Planning is complete."));
await waitForFast(() =>
expect(
displayRows()
?.filter((row) => row.swarmGroupId === groupId)
.map((row) => row.swarmLog),
).toEqual(["Planning is complete.", "Planning is complete."]),
);
emitChanged(note("phase", "Build"));
rows = [
...rows,
{
key: "agent:main:subagent:builder",
kind: "direct",
parentSessionKey: parentKey,
swarmGroupId: groupId,
status: "running",
updatedAt: 4,
},
];
emitChanged(child("agent:main:subagent:builder", 4));
await waitForFast(() =>
expect(sessions.state.result?.sessions.some((row) => row.key.endsWith(":builder"))).toBe(
true,
),
);
expect(displayRows()?.find((row) => row.key.endsWith(":planner"))?.swarmPhase).toBe("Plan");
expect(displayRows()?.find((row) => row.key.endsWith(":builder"))?.swarmPhase).toBe("Build");
// Only creation events assign an implicit phase; a later status update
// must leave a child that predates all phase notes unphased.
emitChanged({
sessionKey: "agent:main:subagent:older",
reason: "status",
key: "agent:main:subagent:older",
kind: "direct",
parentSessionKey: parentKey,
swarmGroupId: groupId,
status: "done",
updatedAt: 5,
});
await waitForFast(() =>
expect(displayRows()?.find((row) => row.key.endsWith(":older"))?.swarmPhase).toBeUndefined(),
);
sessions.dispose();
});
});
+134
View File
@@ -0,0 +1,134 @@
import { asNullableRecord } from "@openclaw/normalization-core/record-coerce";
import type { GatewaySessionRow, SessionsListResult } from "../../api/types.ts";
// Lifecycle notes are transient UI state, so bound them for long-lived board tabs.
const MAX_TRACKED_SWARM_GROUPS = 128;
const MAX_TRACKED_SWARM_CHILDREN = 2_048;
type SwarmDisplayCarrier = {
swarmPhaseRank?: number;
swarmLog?: string;
swarmPhase?: string;
};
function normalizedString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function setBounded<K, V>(map: Map<K, V>, key: K, value: V, limit: number): void {
map.delete(key);
map.set(key, value);
while (map.size > limit) {
const oldest = map.keys().next().value;
if (oldest === undefined) {
return;
}
map.delete(oldest);
}
}
/** Tracks transient, group-scoped Swarm notes across canonical session-list refreshes. */
export class SwarmActivityTracker {
private readonly currentPhaseByGroup = new Map<string, string>();
// First-observation rank per "<groupId>\u0000<phase>": buckets render in the
// order phases were announced, not in canonical session-list row order.
private readonly phaseRankByGroupPhase = new Map<string, number>();
private nextPhaseRank = 0;
private readonly latestLogByGroup = new Map<string, string>();
private readonly phaseByChild = new Map<string, string>();
clear(): void {
this.currentPhaseByGroup.clear();
this.phaseRankByGroupPhase.clear();
this.nextPhaseRank = 0;
this.latestLogByGroup.clear();
this.phaseByChild.clear();
}
observe(payload: unknown): void {
const event = asNullableRecord(payload);
if (!event) {
return;
}
const source = asNullableRecord(event.session) ?? event;
const groupId = normalizedString(event.swarmGroupId) ?? normalizedString(source.swarmGroupId);
if (!groupId) {
return;
}
const kind = normalizedString(event.kind);
const text = normalizedString(event.text);
if ((kind === "phase" || kind === "log") && text) {
if (kind === "phase") {
const rankKey = `${groupId}\u0000${text}`;
if (!this.phaseRankByGroupPhase.has(rankKey)) {
setBounded(
this.phaseRankByGroupPhase,
rankKey,
this.nextPhaseRank,
MAX_TRACKED_SWARM_CHILDREN,
);
this.nextPhaseRank += 1;
}
}
setBounded(
kind === "phase" ? this.currentPhaseByGroup : this.latestLogByGroup,
groupId,
text,
MAX_TRACKED_SWARM_GROUPS,
);
return;
}
const childKey = normalizedString(source.key) ?? normalizedString(event.sessionKey);
if (!childKey) {
return;
}
const explicitPhase = normalizedString(source.swarmPhase) ?? normalizedString(event.swarmPhase);
if (explicitPhase) {
setBounded(this.phaseByChild, childKey, explicitPhase, MAX_TRACKED_SWARM_CHILDREN);
return;
}
// Implicit phase assignment is a creation-time fact: only a child ADMITTED
// after phase('X') belongs to X. Status/completion updates for a child that
// predates any phase note must never retro-stamp it out of Unphased.
if (normalizedString(event.reason) === "create" && !this.phaseByChild.has(childKey)) {
const currentPhase = this.currentPhaseByGroup.get(groupId);
if (currentPhase) {
setBounded(this.phaseByChild, childKey, currentPhase, MAX_TRACKED_SWARM_CHILDREN);
}
}
}
decorate(result: SessionsListResult | null): SessionsListResult | null {
if (!result) {
return result;
}
let changed = false;
const sessions = result.sessions.map((row): GatewaySessionRow => {
const carrier = row as GatewaySessionRow & SwarmDisplayCarrier;
const phase = this.phaseByChild.get(row.key) ?? carrier.swarmPhase;
const groupId = row.swarmGroupId?.trim();
const log = (groupId ? this.latestLogByGroup.get(groupId) : undefined) ?? carrier.swarmLog;
const phaseRank =
(phase && groupId
? this.phaseRankByGroupPhase.get(`${groupId}\u0000${phase}`)
: undefined) ?? carrier.swarmPhaseRank;
if (
phase === carrier.swarmPhase &&
log === carrier.swarmLog &&
phaseRank === carrier.swarmPhaseRank
) {
return row;
}
changed = true;
return {
...row,
...(phase ? { swarmPhase: phase } : {}),
...(phaseRank !== undefined ? { swarmPhaseRank: phaseRank } : {}),
...(log ? { swarmLog: log } : {}),
};
});
return changed ? { ...result, sessions } : result;
}
}
+15 -1
View File
@@ -341,13 +341,27 @@ openclaw-board-widget-cell {
}
.swarm-widget__group-header span,
.swarm-widget__narrator,
.swarm-widget__phase {
color: var(--muted, #8a919e);
font-size: 10px;
}
.swarm-widget__narrator {
line-height: 1.35;
}
.swarm-widget__phase-row {
align-items: center;
display: grid;
gap: 8px;
grid-template-columns: minmax(56px, auto) 1fr;
}
.swarm-widget__phase {
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.swarm-widget__dots {