mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-19 00:52:10 -06:00
fix(agents): use canonical subagent terminal status across views (#116991)
Co-authored-by: Peter Steinberger <steipete@macos.shared>
This commit is contained in:
committed by
GitHub
parent
47c39b185c
commit
f4f990a91b
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user