fix(agents): use canonical subagent terminal status across views (#116991)

Co-authored-by: Peter Steinberger <steipete@macos.shared>
This commit is contained in:
Peter Steinberger
2026-07-31 12:22:17 -07:00
committed by GitHub
parent 47c39b185c
commit f4f990a91b
8 changed files with 183 additions and 62 deletions
+59
View File
@@ -6,6 +6,7 @@ import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { replaceSessionEntry } from "../config/sessions/session-accessor.js";
import { SUBAGENT_ENDED_REASON_KILLED } from "./subagent-lifecycle-events.js";
import { buildSubagentList } from "./subagent-list.js";
import {
addSubagentRunForTests,
@@ -108,6 +109,64 @@ describe("buildSubagentList", () => {
expect(list.active[0]?.line).toContain("review_subagents: Review worker");
});
it.each([
{
name: "a killed run with a provider failure",
endedReason: SUBAGENT_ENDED_REASON_KILLED,
outcome: { status: "error", error: "agent run aborted" } as const,
expectedStatus: "killed",
},
{
name: "a killed run with an earlier successful provider outcome",
endedReason: SUBAGENT_ENDED_REASON_KILLED,
outcome: { status: "ok" } as const,
expectedStatus: "killed",
},
{
name: "a failed run",
outcome: { status: "error", error: "provider rejected the request" } as const,
expectedStatus: "failed",
},
{
name: "a timed-out run",
outcome: { status: "timeout" } as const,
expectedStatus: "timeout",
},
{
name: "a completed run",
outcome: { status: "ok" } as const,
expectedStatus: "done",
},
])(
"projects the canonical terminal status for $name",
({ endedReason, outcome, expectedStatus }) => {
const now = Date.now();
const run = {
runId: `run-status-${expectedStatus}`,
childSessionKey: `agent:main:subagent:status-${expectedStatus}`,
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "report the actual child outcome",
cleanup: "keep",
createdAt: now - 2_000,
startedAt: now - 2_000,
endedAt: now - 1_000,
...(endedReason ? { endedReason } : {}),
outcome,
} satisfies SubagentRunRecord;
addSubagentRunForTests(run);
const list = buildSubagentList({
cfg: {} as OpenClawConfig,
runs: [run],
recentMinutes: 30,
});
expect(list.recent[0]?.status).toBe(expectedStatus);
expect(list.recent[0]?.line).toContain(` ${expectedStatus}`);
},
);
it("keeps ended orchestrators active while descendants remain pending", () => {
// Parent orchestrators can finish their own turn before child workers do;
// list output should keep them active until descendants settle.
+2 -23
View File
@@ -26,10 +26,10 @@ import {
import { getSubagentRunsSnapshotForRead } from "./subagent-registry-state.js";
import type { SubagentRunRecord } from "./subagent-registry.types.js";
import {
hasSubagentRunEnded,
isLiveUnendedSubagentRun,
shouldKeepSubagentRunChildLink,
} from "./subagent-run-liveness.js";
import { resolveSubagentDisplayStatus } from "./subagent-session-metrics.js";
type SubagentListItem = {
index: number;
@@ -144,25 +144,6 @@ function isActiveSubagentRun(
return isLiveUnendedSubagentRun(entry) || pendingDescendantCount(entry.childSessionKey) > 0;
}
function resolveRunStatus(entry: SubagentRunRecord, options?: { pendingDescendants?: number }) {
const pendingDescendants = Math.max(0, options?.pendingDescendants ?? 0);
if (pendingDescendants > 0) {
const childLabel = pendingDescendants === 1 ? "child" : "children";
return `active (waiting on ${pendingDescendants} ${childLabel})`;
}
if (!hasSubagentRunEnded(entry)) {
return "running";
}
const status = entry.outcome?.status ?? "done";
if (status === "ok") {
return "done";
}
if (status === "error") {
return "failed";
}
return status;
}
function resolveModelRef(entry?: SessionEntry, fallbackModel?: string) {
return resolveModelDisplayRef({
runtimeProvider: entry?.modelProvider,
@@ -240,9 +221,7 @@ export function buildSubagentList(params: {
const totalTokens = resolveTotalTokens(sessionEntry);
const usageText = formatTokenUsageDisplay(sessionEntry);
const pendingDescendants = pendingDescendantCount(entry.childSessionKey);
const status = resolveRunStatus(entry, {
pendingDescendants,
});
const status = resolveSubagentDisplayStatus(entry, pendingDescendants);
const childSessions = childSessionsByController.get(entry.childSessionKey) ?? [];
const runtime = formatDurationCompact(runtimeMs) ?? "n/a";
const label = truncateLine(resolveSubagentLabel(entry), 48);
+13
View File
@@ -76,3 +76,16 @@ export function resolveSubagentSessionStatus(
}
return "done";
}
/** Formats the authoritative run status while preserving unfinished descendants. */
export function resolveSubagentDisplayStatus(
entry: Pick<SubagentRunRecord, "endedAt" | "endedReason" | "outcome">,
pendingDescendants = 0,
): string {
const pending = Math.max(0, pendingDescendants);
if (pending > 0) {
const childLabel = pending === 1 ? "child" : "children";
return `active (waiting on ${pending} ${childLabel})`;
}
return resolveSubagentSessionStatus(entry) ?? "done";
}
+44
View File
@@ -2,6 +2,12 @@
import { describe, expect, it, vi } from "vitest";
import type { TaskRecord, TaskRuntime, TaskStatus } from "../../tasks/task-registry.types.js";
import { TASK_STATUS_DETAIL_MAX_CHARS } from "../../tasks/task-status.js";
import { SUBAGENT_ENDED_REASON_KILLED } from "../subagent-lifecycle-events.js";
import {
addSubagentRunForTests,
resetSubagentRegistryForTests,
} from "../subagent-registry.test-helpers.js";
import type { SubagentRunRecord } from "../subagent-registry.types.js";
import { createSubagentsTool } from "./subagents-tool.js";
function task(params: {
@@ -47,6 +53,44 @@ describe("subagents tool", () => {
);
});
it("reports a killed subagent truthfully through the actual list tool", async () => {
resetSubagentRegistryForTests();
const now = Date.now();
const run = {
runId: "run-tool-killed",
childSessionKey: "agent:main:subagent:tool-killed",
controllerSessionKey: "agent:main:main",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "report the killed child",
cleanup: "keep",
createdAt: now - 2_000,
startedAt: now - 2_000,
endedAt: now - 1_000,
endedReason: SUBAGENT_ENDED_REASON_KILLED,
outcome: { status: "error", error: "agent run aborted" },
} satisfies SubagentRunRecord;
addSubagentRunForTests(run);
try {
const tool = createSubagentsTool({
agentSessionKey: "agent:main:main",
config: {},
listTasks: () => [],
});
const result = await tool.execute("list-killed", { action: "list" });
expect(result.details).toMatchObject({
status: "ok",
recent: [expect.objectContaining({ runId: run.runId, status: "killed" })],
});
expect((result.details as { text: string }).text).toContain(" killed");
} finally {
resetSubagentRegistryForTests();
}
});
it("lists cross-runtime tasks in the caller session tree", async () => {
const tasks = [
task({
@@ -7,6 +7,7 @@
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SUBAGENT_ENDED_REASON_KILLED } from "../../agents/subagent-lifecycle-events.js";
import { subagentRuns } from "../../agents/subagent-registry-memory.js";
import {
countPendingDescendantRunsFromRuns,
@@ -26,6 +27,7 @@ import type { ReplyPayload } from "../types.js";
import { buildSubagentsStatusLine } from "./commands-status-subagents.js";
import { extractMessageText } from "./commands-subagents-text.js";
import { handleSubagentsInfoAction } from "./commands-subagents/action-info.js";
import { handleSubagentsListAction } from "./commands-subagents/action-list.js";
import { handleSubagentsLogAction } from "./commands-subagents/action-log.js";
import { resolveFocusTargetSession } from "./commands-subagents/shared.js";
import {
@@ -233,6 +235,62 @@ describe("subagents info", () => {
expect(text).toContain("Task summary: Completed the requested task");
});
it.each([
{
name: "a killed run despite its provider error",
endedReason: SUBAGENT_ENDED_REASON_KILLED,
outcome: { status: "error", error: "agent run aborted" } as const,
expectedStatus: "killed",
},
{
name: "a killed run despite its earlier successful result",
endedReason: SUBAGENT_ENDED_REASON_KILLED,
outcome: { status: "ok" } as const,
expectedStatus: "killed",
},
{
name: "a failed run",
outcome: { status: "error", error: "provider rejected the request" } as const,
expectedStatus: "failed",
},
{
name: "a timed-out run",
outcome: { status: "timeout" } as const,
expectedStatus: "timeout",
},
])(
"keeps /subagents info and list aligned for $name",
({ endedReason, outcome, expectedStatus }) => {
const now = Date.now();
const run = {
runId: `commands-subagents-status-${expectedStatus}`,
childSessionKey: `agent:main:subagent:commands-status-${expectedStatus}`,
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "report the actual child outcome",
cleanup: "keep",
createdAt: now - 2_000,
startedAt: now - 2_000,
endedAt: now - 1_000,
...(endedReason ? { endedReason } : {}),
outcome,
} satisfies SubagentRunRecord;
addSubagentRunForTests(run);
const context = buildInfoContext({
cfg: buildCommandTestConfig(),
runs: [run],
restTokens: ["1"],
});
expect(requireReplyText(handleSubagentsInfoAction(context).reply)).toContain(
`Status: ${expectedStatus}`,
);
expect(requireReplyText(handleSubagentsListAction(context).reply)).toContain(
` ${expectedStatus}`,
);
},
);
it("omits Date-invalid subagent timestamps", () => {
const runId = "commands-subagents-info-invalid-date-run";
const childSessionKey = "agent:main:subagent:commands-info-invalid-date";
@@ -3,6 +3,7 @@ import { timestampMsToIsoString } from "@openclaw/normalization-core/number-coer
import { subagentRuns } from "../../../agents/subagent-registry-memory.js";
import { countPendingDescendantRunsFromRuns } from "../../../agents/subagent-registry-queries.js";
import { getSubagentRunsSnapshotForRead } from "../../../agents/subagent-registry-state.js";
import { resolveSubagentDisplayStatus } from "../../../agents/subagent-session-metrics.js";
import { resolveStorePath } from "../../../config/sessions/paths.js";
import { loadSessionEntryReadOnly } from "../../../config/sessions/session-accessor.js";
import { formatTimeAgo } from "../../../infra/format-time/format-relative.ts";
@@ -11,7 +12,7 @@ import { formatDurationCompact } from "../../../shared/subagents-format.js";
import { findTaskByRunIdForOwner } from "../../../tasks/task-owner-access.js";
import { sanitizeTaskStatusText } from "../../../tasks/task-status.js";
import type { CommandHandlerResult } from "../commands-types.js";
import { formatRunLabel, formatRunStatus } from "../subagents-utils.js";
import { formatRunLabel } from "../subagents-utils.js";
import {
resolveSubagentEntryForToken,
stopWithText,
@@ -29,19 +30,6 @@ function formatTimestampWithAge(valueMs?: number) {
return `${timestamp} (${formatTimeAgo(Date.now() - valueMs, { fallback: "n/a" })})`;
}
function resolveDisplayStatus(
entry: SubagentsCommandContext["runs"][number],
options?: { pendingDescendants?: number },
) {
const pendingDescendants = Math.max(0, options?.pendingDescendants ?? 0);
if (pendingDescendants > 0) {
const childLabel = pendingDescendants === 1 ? "child" : "children";
return `active (waiting on ${pendingDescendants} ${childLabel})`;
}
const status = formatRunStatus(entry);
return status === "error" ? "failed" : status;
}
function loadSubagentSessionEntry(params: SubagentsCommandContext["params"], childKey: string) {
const parsed = parseAgentSessionKey(childKey);
const storePath = resolveStorePath(params.cfg.session?.store, {
@@ -91,12 +79,13 @@ export function handleSubagentsInfoAction(ctx: SubagentsCommandContext): Command
const lines = [
"️ Subagent info",
`Status: ${resolveDisplayStatus(run, {
pendingDescendants: countPendingDescendantRunsFromRuns(
`Status: ${resolveSubagentDisplayStatus(
run,
countPendingDescendantRunsFromRuns(
getSubagentRunsSnapshotForRead(subagentRuns),
run.childSessionKey,
),
})}`,
)}`,
`Label: ${formatRunLabel(run)}`,
`Task: ${taskText}`,
`Run: ${run.runId}`,
+1 -14
View File
@@ -14,12 +14,7 @@ import {
import type { TemplateContext } from "../templating.js";
import { buildThreadingToolContext } from "./agent-runner-utils.js";
import { applyReplyThreading } from "./reply-payloads.js";
import {
formatRunLabel,
formatRunStatus,
resolveSubagentLabel,
sortSubagentRuns,
} from "./subagents-utils.js";
import { formatRunLabel, resolveSubagentLabel, sortSubagentRuns } from "./subagents-utils.js";
function createSlackThreadingPlugin(): ChannelPlugin {
return {
@@ -484,14 +479,6 @@ describe("subagents utils", () => {
expect(sorted.map((run) => run.runId)).toEqual(["run-2", "run-1", "run-3"]);
});
it("formats run status from outcome and timestamps", () => {
expect(formatRunStatus({ ...baseRun })).toBe("running");
expect(formatRunStatus({ ...baseRun, endedAt: 2000, outcome: { status: "ok" } })).toBe("done");
expect(formatRunStatus({ ...baseRun, endedAt: 2000, outcome: { status: "timeout" } })).toBe(
"timeout",
);
});
it("formats duration compact for seconds and minutes", () => {
expect(formatDurationCompact(45_000)).toBe("45s");
expect(formatDurationCompact(65_000)).toBe("1m5s");
-8
View File
@@ -21,14 +21,6 @@ export function formatRunLabel(entry: SubagentRunRecord, options?: { maxLength?:
return raw.length > maxLength ? `${truncateUtf16Safe(raw, maxLength).trimEnd()}` : raw;
}
export function formatRunStatus(entry: SubagentRunRecord) {
if (!entry.endedAt) {
return "running";
}
const status = entry.outcome?.status ?? "done";
return status === "ok" ? "done" : status;
}
export function sortSubagentRuns(runs: SubagentRunRecord[]) {
return [...runs].toSorted((a, b) => {
const aTime = a.startedAt ?? a.createdAt ?? 0;