mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-08 07:41:08 +00:00
* fix(subagents): harden task-registry lifecycle writes * chore(changelog): note subagent task-registry hardening * fix(subagents): align lifecycle terminal timestamps * fix(subagents): sanitize lifecycle warning metadata
168 lines
5.5 KiB
TypeScript
168 lines
5.5 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { SUBAGENT_ENDED_REASON_COMPLETE } from "./subagent-lifecycle-events.js";
|
|
import type { SubagentRunRecord } from "./subagent-registry.types.js";
|
|
|
|
const taskExecutorMocks = vi.hoisted(() => ({
|
|
completeTaskRunByRunId: vi.fn(),
|
|
failTaskRunByRunId: vi.fn(),
|
|
setDetachedTaskDeliveryStatusByRunId: vi.fn(),
|
|
}));
|
|
|
|
const helperMocks = vi.hoisted(() => ({
|
|
persistSubagentSessionTiming: vi.fn(async () => {}),
|
|
safeRemoveAttachmentsDir: vi.fn(async () => {}),
|
|
}));
|
|
|
|
const lifecycleEventMocks = vi.hoisted(() => ({
|
|
emitSessionLifecycleEvent: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../tasks/task-executor.js", () => ({
|
|
completeTaskRunByRunId: taskExecutorMocks.completeTaskRunByRunId,
|
|
failTaskRunByRunId: taskExecutorMocks.failTaskRunByRunId,
|
|
setDetachedTaskDeliveryStatusByRunId: taskExecutorMocks.setDetachedTaskDeliveryStatusByRunId,
|
|
}));
|
|
|
|
vi.mock("../sessions/session-lifecycle-events.js", () => ({
|
|
emitSessionLifecycleEvent: lifecycleEventMocks.emitSessionLifecycleEvent,
|
|
}));
|
|
|
|
vi.mock("./subagent-registry-helpers.js", async () => {
|
|
const actual = await vi.importActual<typeof import("./subagent-registry-helpers.js")>(
|
|
"./subagent-registry-helpers.js",
|
|
);
|
|
return {
|
|
...actual,
|
|
persistSubagentSessionTiming: helperMocks.persistSubagentSessionTiming,
|
|
safeRemoveAttachmentsDir: helperMocks.safeRemoveAttachmentsDir,
|
|
};
|
|
});
|
|
|
|
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",
|
|
createdAt: 1_000,
|
|
startedAt: 2_000,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("subagent registry lifecycle hardening", () => {
|
|
let mod: typeof import("./subagent-registry-lifecycle.js");
|
|
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
vi.clearAllMocks();
|
|
mod = await import("./subagent-registry-lifecycle.js");
|
|
});
|
|
|
|
it("does not reject completion when task finalization throws", async () => {
|
|
const persist = vi.fn();
|
|
const warn = vi.fn();
|
|
const entry = createRunEntry();
|
|
const runs = new Map([[entry.runId, entry]]);
|
|
taskExecutorMocks.completeTaskRunByRunId.mockImplementation(() => {
|
|
throw new Error("task store boom");
|
|
});
|
|
|
|
const controller = mod.createSubagentRegistryLifecycleController({
|
|
runs,
|
|
resumedRuns: new Set(),
|
|
subagentAnnounceTimeoutMs: 1_000,
|
|
persist,
|
|
clearPendingLifecycleError: vi.fn(),
|
|
countPendingDescendantRuns: () => 0,
|
|
suppressAnnounceForSteerRestart: () => false,
|
|
shouldEmitEndedHookForRun: () => false,
|
|
emitSubagentEndedHookForRun: vi.fn(async () => {}),
|
|
notifyContextEngineSubagentEnded: vi.fn(async () => {}),
|
|
resumeSubagentRun: vi.fn(),
|
|
captureSubagentCompletionReply: vi.fn(async () => "final completion reply"),
|
|
runSubagentAnnounceFlow: vi.fn(async () => true),
|
|
warn,
|
|
});
|
|
|
|
await expect(
|
|
controller.completeSubagentRun({
|
|
runId: entry.runId,
|
|
endedAt: 4_000,
|
|
outcome: { status: "ok" },
|
|
reason: SUBAGENT_ENDED_REASON_COMPLETE,
|
|
triggerCleanup: false,
|
|
}),
|
|
).resolves.toBeUndefined();
|
|
|
|
expect(warn).toHaveBeenCalledWith(
|
|
"failed to finalize subagent background task state",
|
|
expect.objectContaining({
|
|
error: { name: "Error", message: "task store boom" },
|
|
runId: "***",
|
|
childSessionKey: "agent:main:…",
|
|
outcomeStatus: "ok",
|
|
}),
|
|
);
|
|
expect(helperMocks.persistSubagentSessionTiming).toHaveBeenCalledTimes(1);
|
|
expect(lifecycleEventMocks.emitSessionLifecycleEvent).toHaveBeenCalledWith({
|
|
sessionKey: "agent:main:subagent:child",
|
|
reason: "subagent-status",
|
|
parentSessionKey: "agent:main:main",
|
|
label: undefined,
|
|
});
|
|
});
|
|
|
|
it("does not reject cleanup give-up when task delivery status update throws", async () => {
|
|
const persist = vi.fn();
|
|
const warn = vi.fn();
|
|
const entry = createRunEntry({
|
|
endedAt: 4_000,
|
|
expectsCompletionMessage: false,
|
|
retainAttachmentsOnKeep: true,
|
|
});
|
|
taskExecutorMocks.setDetachedTaskDeliveryStatusByRunId.mockImplementation(() => {
|
|
throw new Error("delivery state boom");
|
|
});
|
|
|
|
const controller = mod.createSubagentRegistryLifecycleController({
|
|
runs: new Map([[entry.runId, entry]]),
|
|
resumedRuns: new Set(),
|
|
subagentAnnounceTimeoutMs: 1_000,
|
|
persist,
|
|
clearPendingLifecycleError: vi.fn(),
|
|
countPendingDescendantRuns: () => 0,
|
|
suppressAnnounceForSteerRestart: () => false,
|
|
shouldEmitEndedHookForRun: () => false,
|
|
emitSubagentEndedHookForRun: vi.fn(async () => {}),
|
|
notifyContextEngineSubagentEnded: vi.fn(async () => {}),
|
|
resumeSubagentRun: vi.fn(),
|
|
captureSubagentCompletionReply: vi.fn(async () => undefined),
|
|
runSubagentAnnounceFlow: vi.fn(async () => true),
|
|
warn,
|
|
});
|
|
|
|
await expect(
|
|
controller.finalizeResumedAnnounceGiveUp({
|
|
runId: entry.runId,
|
|
entry,
|
|
reason: "retry-limit",
|
|
}),
|
|
).resolves.toBeUndefined();
|
|
|
|
expect(warn).toHaveBeenCalledWith(
|
|
"failed to update subagent background task delivery state",
|
|
expect.objectContaining({
|
|
error: { name: "Error", message: "delivery state boom" },
|
|
runId: "***",
|
|
childSessionKey: "agent:main:…",
|
|
deliveryStatus: "failed",
|
|
}),
|
|
);
|
|
expect(entry.cleanupCompletedAt).toBeTypeOf("number");
|
|
expect(persist).toHaveBeenCalled();
|
|
});
|
|
});
|