mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
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:
@@ -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
@@ -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) ??
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
)}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user