diff --git a/src/agents/subagent-registry-helpers.test.ts b/src/agents/subagent-registry-helpers.test.ts new file mode 100644 index 00000000000..25512b5a887 --- /dev/null +++ b/src/agents/subagent-registry-helpers.test.ts @@ -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 { + 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); + }); +}); diff --git a/src/agents/subagent-registry-helpers.ts b/src/agents/subagent-registry-helpers.ts index cd45e87c682..71aa8484749 100644 --- a/src/agents/subagent-registry-helpers.ts +++ b/src/agents/subagent-registry-helpers.ts @@ -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; } diff --git a/src/agents/subagent-registry-run-manager.ts b/src/agents/subagent-registry-run-manager.ts index 1e1c0e83773..a5e2d605cb2 100644 --- a/src/agents/subagent-registry-run-manager.ts +++ b/src/agents/subagent-registry-run-manager.ts @@ -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; diff --git a/src/agents/subagent-registry.test.ts b/src/agents/subagent-registry.test.ts index 8c872cd3b2b..ba62216c012 100644 --- a/src/agents/subagent-registry.test.ts +++ b/src/agents/subagent-registry.test.ts @@ -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: {