perf(test): slim subagent lifecycle imports

This commit is contained in:
Peter Steinberger
2026-04-20 12:26:57 +01:00
parent 69c78fbef0
commit d0c756e8ab
12 changed files with 147 additions and 56 deletions

View File

@@ -10,6 +10,16 @@ vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
vi.mock("../tasks/task-executor.js", () => ({
completeTaskRunByRunId: vi.fn(),
createQueuedTaskRun: vi.fn(),
createRunningTaskRun: vi.fn(),
failTaskRunByRunId: vi.fn(),
recordTaskRunProgressByRunId: vi.fn(),
setDetachedTaskDeliveryStatusByRunId: vi.fn(),
startTaskRunByRunId: vi.fn(),
}));
let storeTemplatePath = "";
let configOverride: Record<string, unknown> = {
session: createPerSenderSessionConfig(),
@@ -20,13 +30,9 @@ let subagentRegistryTesting: typeof import("./subagent-registry.js").__testing;
let setSubagentSpawnDepsForTest: typeof import("./subagent-spawn.js").__testing.setDepsForTest;
let createSessionsSpawnTool: typeof import("./tools/sessions-spawn-tool.js").createSessionsSpawnTool;
vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
return {
...actual,
loadConfig: () => configOverride,
};
});
vi.mock("../config/config.js", () => ({
loadConfig: () => configOverride,
}));
function writeStore(agentId: string, store: Record<string, unknown>) {
const storePath = storeTemplatePath.replaceAll("{agentId}", agentId);

View File

@@ -11,6 +11,7 @@ import {
setSessionsSpawnHookRunnerOverride,
setupSessionsSpawnGatewayMock,
setSessionsSpawnConfigOverride,
waitForSessionsSpawnEvent,
} from "./openclaw-tools.subagents.sessions-spawn.test-harness.js";
import {
getLatestSubagentRunByChildSessionKey,
@@ -61,15 +62,6 @@ function buildDiscordCleanupHooks(onDelete: (key: string | undefined) => void) {
};
}
const waitFor = async (label: string, predicate: () => boolean, timeoutMs = 30_000) => {
await vi.waitFor(
() => {
expect(predicate(), label).toBe(true);
},
{ timeout: timeoutMs, interval: 1 },
);
};
async function getDiscordGroupSpawnTool() {
return await getSessionsSpawnTool({
agentSessionKey: "discord:group:req",
@@ -152,7 +144,7 @@ async function emitLifecycleEndAndFlush(params: {
}
async function waitForRunCleanup(childSessionKey: string) {
await waitFor("run cleanup bookkeeping", () => {
await waitForSessionsSpawnEvent("run cleanup bookkeeping", () => {
const run = getLatestSubagentRunByChildSessionKey(childSessionKey);
return run?.cleanupCompletedAt != null;
});
@@ -232,7 +224,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
if (!child.runId) {
throw new Error("missing child runId");
}
await waitFor(
await waitForSessionsSpawnEvent(
"subagent wait, label patch, and main agent trigger",
() =>
ctx.waitCalls.some((call) => call.runId === child.runId) &&
@@ -295,7 +287,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
endedAt: 2345,
});
await waitFor(
await waitForSessionsSpawnEvent(
"lifecycle cleanup",
() => ctx.calls.filter((call) => call.method === "agent").length >= 2 && Boolean(deletedKey),
);
@@ -358,14 +350,14 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
if (!child.runId) {
throw new Error("missing child runId");
}
await waitFor("agent.wait called for child run", () =>
await waitForSessionsSpawnEvent("agent.wait called for child run", () =>
ctx.waitCalls.some((call) => call.runId === child.runId),
);
await waitFor(
await waitForSessionsSpawnEvent(
"main agent cleanup trigger",
() => ctx.calls.filter((call) => call.method === "agent").length >= 2,
);
await waitFor("delete cleanup", () => Boolean(deletedKey));
await waitForSessionsSpawnEvent("delete cleanup", () => Boolean(deletedKey));
const childWait = ctx.waitCalls.find((call) => call.runId === child.runId);
expect(childWait?.timeoutMs).toBe(1000);
@@ -416,12 +408,11 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
}
const childSessionKey = child.sessionKey;
await waitFor(
await waitForSessionsSpawnEvent(
"timeout outcome",
() =>
ctx.waitCalls.some((call) => call.runId === child.runId) &&
getLatestSubagentRunByChildSessionKey(childSessionKey)?.outcome?.status === "timeout",
20_000,
);
await waitForRunCleanup(childSessionKey);
@@ -488,7 +479,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
endedAt: 2000,
});
await waitFor(
await waitForSessionsSpawnEvent(
"account-aware lifecycle announce",
() => ctx.calls.filter((call) => call.method === "agent").length >= 2,
);

View File

@@ -23,6 +23,13 @@ type SessionsSpawnGatewayMockOptions = {
onSessionsDelete?: (params: unknown) => void;
agentWaitResult?: { status: "ok" | "timeout"; startedAt: number; endedAt: number };
};
type EventWaiter = {
label: string;
predicate: () => boolean;
resolve: () => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
};
const hoisted = vi.hoisted(() => {
const callGatewayMock = vi.fn();
@@ -90,9 +97,23 @@ const hoisted = vi.hoisted(() => {
defaultRunSubagentAnnounceFlow,
runSubagentAnnounceFlowOverride: defaultRunSubagentAnnounceFlow,
};
const eventWaiters: EventWaiter[] = [];
const notifyEventWaiters = () => {
for (let index = eventWaiters.length - 1; index >= 0; index -= 1) {
const waiter = eventWaiters[index];
if (!waiter?.predicate()) {
continue;
}
clearTimeout(waiter.timer);
eventWaiters.splice(index, 1);
waiter.resolve();
}
};
return {
callGatewayMock,
defaultConfigOverride,
eventWaiters,
notifyEventWaiters,
nextRunId: () => {
nextRunId += 1;
return `run-${nextRunId}`;
@@ -122,6 +143,26 @@ export function findGatewayRequest(method: string): GatewayRequest | undefined {
return getGatewayRequests().find((request) => request.method === method);
}
export async function waitForSessionsSpawnEvent(
label: string,
predicate: () => boolean,
timeoutMs = 5_000,
): Promise<void> {
if (predicate()) {
return;
}
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
const index = hoisted.eventWaiters.findIndex((waiter) => waiter.timer === timer);
if (index >= 0) {
hoisted.eventWaiters.splice(index, 1);
}
reject(new Error(`Timed out waiting for ${label}`));
}, timeoutMs);
hoisted.eventWaiters.push({ label, predicate, resolve, reject, timer });
});
}
export function resetSessionsSpawnConfigOverride(): void {
hoisted.state.configOverride = hoisted.defaultConfigOverride;
}
@@ -165,6 +206,10 @@ export async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) {
cleanupBrowserSessionsForLifecycleEnd: async () => {},
ensureContextEnginesInitialized: () => {},
ensureRuntimePluginsLoaded: () => {},
persistSubagentRunsToDisk: () => {
hoisted.notifyEventWaiters();
},
restoreSubagentRunsFromDisk: () => 0,
resolveContextEngine: async () => ({
info: { id: "test", name: "Test" },
assemble: async ({ messages }) => ({ messages, estimatedTokens: 0 }),
@@ -195,6 +240,7 @@ export function setupSessionsSpawnGatewayMock(setupOpts: SessionsSpawnGatewayMoc
getCallGatewayMock().mockImplementation(async (optsUnknown: unknown) => {
const request = optsUnknown as GatewayRequest;
calls.push(request);
hoisted.notifyEventWaiters();
if (request.method === "sessions.list" && setupOpts.includeSessionsList) {
return {
@@ -233,6 +279,7 @@ export function setupSessionsSpawnGatewayMock(setupOpts: SessionsSpawnGatewayMoc
if (request.method === "agent.wait") {
const params = request.params as AgentWaitCall | undefined;
waitCalls.push(params ?? {});
hoisted.notifyEventWaiters();
const waitResult = setupOpts.agentWaitResult ?? {
status: "ok",
startedAt: 1000,
@@ -246,11 +293,13 @@ export function setupSessionsSpawnGatewayMock(setupOpts: SessionsSpawnGatewayMoc
if (request.method === "sessions.patch") {
setupOpts.onSessionsPatch?.(request.params);
hoisted.notifyEventWaiters();
return { ok: true };
}
if (request.method === "sessions.delete") {
setupOpts.onSessionsDelete?.(request.params);
hoisted.notifyEventWaiters();
return { ok: true };
}

View File

@@ -1,7 +1,7 @@
import type { ChatType } from "../channels/chat-type.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveFirstBoundAccountId } from "../routing/bound-account-read.js";
import { normalizeDeliveryContext } from "../utils/delivery-context.js";
import { normalizeDeliveryContext } from "../utils/delivery-context.shared.js";
// Delivery targets often carry a transport wrapper (e.g. Matrix `room:<id>` or
// LINE `line:group:<id>`), while route bindings commonly store raw peer ids on

View File

@@ -1,10 +1,7 @@
import { type QueueDropPolicy, type QueueMode } from "../auto-reply/reply/queue.js";
import { defaultRuntime } from "../runtime.js";
import {
type DeliveryContext,
deliveryContextKey,
normalizeDeliveryContext,
} from "../utils/delivery-context.js";
import { deliveryContextKey, normalizeDeliveryContext } from "../utils/delivery-context.shared.js";
import type { DeliveryContext } from "../utils/delivery-context.types.js";
import {
applyQueueRuntimeSettings,
applyQueueDropPolicy,

View File

@@ -159,6 +159,9 @@ beforeEach(() => {
cleanupBrowserSessionsForLifecycleEnd: async () => {},
ensureContextEnginesInitialized: () => {},
ensureRuntimePluginsLoaded: () => {},
getSubagentRunsSnapshotForRead: (runs) => new Map(runs),
persistSubagentRunsToDisk: () => {},
restoreSubagentRunsFromDisk: () => 0,
resolveContextEngine: async () => ({
info: { id: "test", name: "Test" },
assemble: async ({ messages }) => ({ messages, estimatedTokens: 0 }),

View File

@@ -1,4 +1,5 @@
import { type DeliveryContext, normalizeDeliveryContext } from "../utils/delivery-context.js";
import { normalizeDeliveryContext } from "../utils/delivery-context.shared.js";
import type { DeliveryContext } from "../utils/delivery-context.types.js";
import { subagentRuns } from "./subagent-registry-memory.js";
import {
countPendingDescendantRunsExcludingRunFromRuns,

View File

@@ -1,5 +1,5 @@
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import { cleanupBrowserSessionsForLifecycleEnd } from "../browser-lifecycle-cleanup.js";
import type { cleanupBrowserSessionsForLifecycleEnd } from "../browser-lifecycle-cleanup.js";
import { formatErrorMessage, readErrorName } from "../infra/errors.js";
import { defaultRuntime } from "../runtime.js";
import { emitSessionLifecycleEvent } from "../sessions/session-lifecycle-events.js";
@@ -8,13 +8,8 @@ import {
failTaskRunByRunId,
setDetachedTaskDeliveryStatusByRunId,
} from "../tasks/detached-task-runtime.js";
import { normalizeDeliveryContext } from "../utils/delivery-context.js";
import { withSubagentOutcomeTiming } from "./subagent-announce-output.js";
import {
captureSubagentCompletionReply,
runSubagentAnnounceFlow,
type SubagentRunOutcome,
} from "./subagent-announce.js";
import { normalizeDeliveryContext } from "../utils/delivery-context.shared.js";
import { type SubagentRunOutcome, withSubagentOutcomeTiming } from "./subagent-announce-output.js";
import {
SUBAGENT_ENDED_REASON_COMPLETE,
type SubagentLifecycleEndedReason,
@@ -37,6 +32,23 @@ import {
} from "./subagent-registry-helpers.js";
import type { SubagentRunRecord } from "./subagent-registry.types.js";
type CaptureSubagentCompletionReply =
(typeof import("./subagent-announce.js"))["captureSubagentCompletionReply"];
type RunSubagentAnnounceFlow = (typeof import("./subagent-announce.js"))["runSubagentAnnounceFlow"];
type BrowserCleanupModule = Pick<
typeof import("../browser-lifecycle-cleanup.js"),
"cleanupBrowserSessionsForLifecycleEnd"
>;
let browserCleanupPromise: Promise<BrowserCleanupModule> | null = null;
async function loadCleanupBrowserSessionsForLifecycleEnd(): Promise<
BrowserCleanupModule["cleanupBrowserSessionsForLifecycleEnd"]
> {
browserCleanupPromise ??= import("../browser-lifecycle-cleanup.js");
return (await browserCleanupPromise).cleanupBrowserSessionsForLifecycleEnd;
}
export function createSubagentRegistryLifecycleController(params: {
runs: Map<string, SubagentRunRecord>;
resumedRuns: Set<string>;
@@ -61,9 +73,9 @@ export function createSubagentRegistryLifecycleController(params: {
workspaceDir?: string;
}): Promise<void>;
resumeSubagentRun(runId: string): void;
captureSubagentCompletionReply: typeof captureSubagentCompletionReply;
captureSubagentCompletionReply: CaptureSubagentCompletionReply;
cleanupBrowserSessionsForLifecycleEnd?: typeof cleanupBrowserSessionsForLifecycleEnd;
runSubagentAnnounceFlow: typeof runSubagentAnnounceFlow;
runSubagentAnnounceFlow: RunSubagentAnnounceFlow;
warn(message: string, meta?: Record<string, unknown>): void;
}) {
const scheduledResumeTimers = new Set<ReturnType<typeof setTimeout>>();
@@ -223,7 +235,7 @@ export function createSubagentRegistryLifecycleController(params: {
let captured: string | undefined;
try {
captured = await captureSubagentCompletionReply(sessionKey);
captured = await params.captureSubagentCompletionReply(sessionKey);
} catch {
return false;
}
@@ -658,7 +670,10 @@ export function createSubagentRegistryLifecycleController(params: {
return;
}
await (params.cleanupBrowserSessionsForLifecycleEnd ?? cleanupBrowserSessionsForLifecycleEnd)({
const cleanupBrowserSessions =
params.cleanupBrowserSessionsForLifecycleEnd ??
(await loadCleanupBrowserSessionsForLifecycleEnd());
await cleanupBrowserSessions({
sessionKeys: [entry.childSessionKey],
onWarn: (msg) => params.warn(msg, { runId: entry.runId }),
});

View File

@@ -4,7 +4,8 @@ import { callGateway } from "../gateway/call.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import { createRunningTaskRun } from "../tasks/detached-task-runtime.js";
import { type DeliveryContext, normalizeDeliveryContext } from "../utils/delivery-context.js";
import { normalizeDeliveryContext } from "../utils/delivery-context.shared.js";
import type { DeliveryContext } from "../utils/delivery-context.types.js";
import { waitForAgentRun } from "./run-wait.js";
import type { ensureRuntimePluginsLoaded as ensureRuntimePluginsLoadedFn } from "./runtime-plugins.js";
import { type SubagentRunOutcome, withSubagentOutcomeTiming } from "./subagent-announce-output.js";

View File

@@ -3,7 +3,7 @@ import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
import { loadJsonFile, saveJsonFile } from "../infra/json-file.js";
import { readStringValue } from "../shared/string-coerce.js";
import { normalizeDeliveryContext } from "../utils/delivery-context.js";
import { normalizeDeliveryContext } from "../utils/delivery-context.shared.js";
import type { SubagentRunRecord } from "./subagent-registry.types.js";
export type PersistedSubagentRegistryVersion = 1 | 2;

View File

@@ -1,4 +1,4 @@
import { cleanupBrowserSessionsForLifecycleEnd } from "../browser-lifecycle-cleanup.js";
import type { cleanupBrowserSessionsForLifecycleEnd } from "../browser-lifecycle-cleanup.js";
import { loadConfig } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { ContextEngine, SubagentEndReason } from "../context-engine/types.js";
@@ -6,11 +6,11 @@ import { callGateway } from "../gateway/call.js";
import { onAgentEvent } from "../infra/agent-events.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { importRuntimeModule } from "../shared/runtime-import.js";
import { type DeliveryContext, normalizeDeliveryContext } from "../utils/delivery-context.js";
import { normalizeDeliveryContext } from "../utils/delivery-context.shared.js";
import type { DeliveryContext } from "../utils/delivery-context.types.js";
import type { ensureRuntimePluginsLoaded as ensureRuntimePluginsLoadedFn } from "./runtime-plugins.js";
import type { SubagentRunOutcome } from "./subagent-announce-output.js";
import { resetAnnounceQueuesForTests } from "./subagent-announce-queue.js";
import * as subagentAnnounceModule from "./subagent-announce.js";
import {
SUBAGENT_ENDED_REASON_COMPLETE,
SUBAGENT_ENDED_REASON_ERROR,
@@ -67,9 +67,18 @@ export {
} from "./subagent-registry-helpers.js";
const log = createSubsystemLogger("agents/subagent-registry");
type SubagentAnnounceModule = Pick<
typeof import("./subagent-announce.js"),
"captureSubagentCompletionReply" | "runSubagentAnnounceFlow"
>;
type BrowserCleanupModule = Pick<
typeof import("../browser-lifecycle-cleanup.js"),
"cleanupBrowserSessionsForLifecycleEnd"
>;
type SubagentRegistryDeps = {
callGateway: typeof callGateway;
captureSubagentCompletionReply: typeof subagentAnnounceModule.captureSubagentCompletionReply;
captureSubagentCompletionReply: SubagentAnnounceModule["captureSubagentCompletionReply"];
cleanupBrowserSessionsForLifecycleEnd: typeof cleanupBrowserSessionsForLifecycleEnd;
getSubagentRunsSnapshotForRead: typeof getSubagentRunsSnapshotForRead;
loadConfig: typeof loadConfig;
@@ -77,24 +86,41 @@ type SubagentRegistryDeps = {
persistSubagentRunsToDisk: typeof persistSubagentRunsToDisk;
resolveAgentTimeoutMs: typeof resolveAgentTimeoutMs;
restoreSubagentRunsFromDisk: typeof restoreSubagentRunsFromDisk;
runSubagentAnnounceFlow: typeof subagentAnnounceModule.runSubagentAnnounceFlow;
runSubagentAnnounceFlow: SubagentAnnounceModule["runSubagentAnnounceFlow"];
ensureContextEnginesInitialized?: () => void;
ensureRuntimePluginsLoaded?: typeof ensureRuntimePluginsLoadedFn;
resolveContextEngine?: (cfg: OpenClawConfig) => Promise<ContextEngine>;
};
let subagentAnnouncePromise: Promise<SubagentAnnounceModule> | null = null;
let browserCleanupPromise: Promise<BrowserCleanupModule> | null = null;
async function loadSubagentAnnounceModule(): Promise<SubagentAnnounceModule> {
subagentAnnouncePromise ??= import("./subagent-announce.js");
return await subagentAnnouncePromise;
}
async function loadCleanupBrowserSessionsForLifecycleEnd(): Promise<
BrowserCleanupModule["cleanupBrowserSessionsForLifecycleEnd"]
> {
browserCleanupPromise ??= import("../browser-lifecycle-cleanup.js");
return (await browserCleanupPromise).cleanupBrowserSessionsForLifecycleEnd;
}
const defaultSubagentRegistryDeps: SubagentRegistryDeps = {
callGateway,
captureSubagentCompletionReply: (sessionKey) =>
subagentAnnounceModule.captureSubagentCompletionReply(sessionKey),
cleanupBrowserSessionsForLifecycleEnd,
captureSubagentCompletionReply: async (sessionKey) =>
(await loadSubagentAnnounceModule()).captureSubagentCompletionReply(sessionKey),
cleanupBrowserSessionsForLifecycleEnd: async (params) =>
(await loadCleanupBrowserSessionsForLifecycleEnd())(params),
getSubagentRunsSnapshotForRead,
loadConfig,
onAgentEvent,
persistSubagentRunsToDisk,
resolveAgentTimeoutMs,
restoreSubagentRunsFromDisk,
runSubagentAnnounceFlow: (params) => subagentAnnounceModule.runSubagentAnnounceFlow(params),
runSubagentAnnounceFlow: async (params) =>
(await loadSubagentAnnounceModule()).runSubagentAnnounceFlow(params),
};
let subagentRegistryDeps: SubagentRegistryDeps = defaultSubagentRegistryDeps;
@@ -744,6 +770,8 @@ export function resetSubagentRegistryForTests(opts?: { persist?: boolean }) {
contextEngineInitPromise = null;
contextEngineRegistryPromise = null;
runtimePluginsPromise = null;
subagentAnnouncePromise = null;
browserCleanupPromise = null;
resetAnnounceQueuesForTests();
stopSweeper();
sweepInProgress = false;

View File

@@ -1,7 +1,7 @@
import { Type } from "@sinclair/typebox";
import { loadConfig } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js";
import { normalizeDeliveryContext } from "../../utils/delivery-context.js";
import { normalizeDeliveryContext } from "../../utils/delivery-context.shared.js";
import type { GatewayMessageChannel } from "../../utils/message-channel.js";
import { optionalStringEnum } from "../schema/typebox.js";
import type { SpawnedToolContext } from "../spawned-context.js";