fix: enrich direct subagent terminal outcomes

This commit is contained in:
Gustavo Madeira Santana
2026-04-19 18:10:14 -04:00
parent 3abc92dae9
commit 55c756142f
4 changed files with 96 additions and 12 deletions

View File

@@ -0,0 +1,54 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { reconcileOrphanedRun } from "./subagent-registry-helpers.js";
import type { SubagentRunRecord } from "./subagent-registry.types.js";
function createRunEntry(overrides: Partial<SubagentRunRecord> = {}): SubagentRunRecord {
return {
runId: "run-1",
childSessionKey: "agent:main:subagent:child",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "finish the task",
cleanup: "keep",
retainAttachmentsOnKeep: true,
createdAt: 500,
startedAt: 1_000,
...overrides,
};
}
describe("reconcileOrphanedRun", () => {
afterEach(() => {
vi.useRealTimers();
});
it("preserves timing on orphaned error outcomes", () => {
vi.useFakeTimers();
vi.setSystemTime(4_000);
const entry = createRunEntry();
const runs = new Map([[entry.runId, entry]]);
const resumedRuns = new Set([entry.runId]);
expect(
reconcileOrphanedRun({
runId: entry.runId,
entry,
reason: "missing-session-id",
source: "resume",
runs,
resumedRuns,
}),
).toBe(true);
expect(entry.endedAt).toBe(4_000);
expect(entry.outcome).toEqual({
status: "error",
error: "orphaned subagent run (missing-session-id)",
startedAt: 1_000,
endedAt: 4_000,
elapsedMs: 3_000,
});
expect(runs.has(entry.runId)).toBe(false);
expect(resumedRuns.has(entry.runId)).toBe(false);
});
});

View File

@@ -11,9 +11,9 @@ import {
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { defaultRuntime } from "../runtime.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { type SubagentRunOutcome } from "./subagent-announce-output.js";
import { withSubagentOutcomeTiming } from "./subagent-announce-output.js";
import { SUBAGENT_ENDED_REASON_ERROR } from "./subagent-lifecycle-events.js";
import { runOutcomesEqual } from "./subagent-registry-completion.js";
import { shouldUpdateRunOutcome } from "./subagent-registry-completion.js";
import type { SubagentRunRecord } from "./subagent-registry.types.js";
import {
getSubagentSessionRuntimeMs,
@@ -219,11 +219,17 @@ export function reconcileOrphanedRun(params: {
params.entry.endedAt = now;
changed = true;
}
const orphanOutcome: SubagentRunOutcome = {
status: "error",
error: `orphaned subagent run (${params.reason})`,
};
if (!runOutcomesEqual(params.entry.outcome, orphanOutcome)) {
const orphanOutcome = withSubagentOutcomeTiming(
{
status: "error",
error: `orphaned subagent run (${params.reason})`,
},
{
startedAt: params.entry.startedAt,
endedAt: params.entry.endedAt,
},
);
if (shouldUpdateRunOutcome(params.entry.outcome, orphanOutcome)) {
params.entry.outcome = orphanOutcome;
changed = true;
}

View File

@@ -7,7 +7,7 @@ import { createRunningTaskRun } from "../tasks/detached-task-runtime.js";
import { type DeliveryContext, normalizeDeliveryContext } from "../utils/delivery-context.js";
import { waitForAgentRun } from "./run-wait.js";
import type { ensureRuntimePluginsLoaded as ensureRuntimePluginsLoadedFn } from "./runtime-plugins.js";
import type { SubagentRunOutcome } from "./subagent-announce-output.js";
import { type SubagentRunOutcome, withSubagentOutcomeTiming } from "./subagent-announce-output.js";
import {
SUBAGENT_ENDED_OUTCOME_KILLED,
SUBAGENT_ENDED_REASON_COMPLETE,
@@ -15,7 +15,10 @@ import {
SUBAGENT_ENDED_REASON_KILLED,
type SubagentLifecycleEndedReason,
} from "./subagent-lifecycle-events.js";
import { emitSubagentEndedHookOnce, runOutcomesEqual } from "./subagent-registry-completion.js";
import {
emitSubagentEndedHookOnce,
shouldUpdateRunOutcome,
} from "./subagent-registry-completion.js";
import {
getSubagentSessionRuntimeMs,
getSubagentSessionStartedAt,
@@ -127,13 +130,17 @@ export function createSubagentRunManager(params: {
mutated = true;
}
const waitError = typeof wait.error === "string" ? wait.error : undefined;
const outcome: SubagentRunOutcome =
const baseOutcome: SubagentRunOutcome =
wait.status === "error"
? { status: "error", error: waitError }
: wait.status === "timeout"
? { status: "timeout" }
: { status: "ok" };
if (!runOutcomesEqual(entry.outcome, outcome)) {
const outcome = withSubagentOutcomeTiming(baseOutcome, {
startedAt: entry.startedAt,
endedAt: entry.endedAt,
});
if (shouldUpdateRunOutcome(entry.outcome, outcome)) {
entry.outcome = outcome;
mutated = true;
}
@@ -417,7 +424,13 @@ export function createSubagentRunManager(params: {
continue;
}
entry.endedAt = now;
entry.outcome = { status: "error", error: reason };
entry.outcome = withSubagentOutcomeTiming(
{ status: "error", error: reason },
{
startedAt: entry.startedAt,
endedAt: now,
},
);
entry.endedReason = SUBAGENT_ENDED_REASON_KILLED;
entry.cleanupHandled = true;
entry.cleanupCompletedAt = now;

View File

@@ -402,6 +402,17 @@ describe("subagent registry seam flow", () => {
});
expect(updated).toBe(1);
const killedRun = mod
.listSubagentRunsForRequester("agent:main:main")
.find((entry) => entry.runId === "run-killed-init");
const killedAt = Date.parse("2026-03-24T12:00:00Z");
expect(killedRun?.outcome).toEqual({
status: "error",
error: "manual kill",
startedAt: killedAt,
endedAt: killedAt,
elapsedMs: 0,
});
await waitForFast(() => {
expect(mocks.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({
config: {