mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
fix(gateway): defer restored subagent recovery until ready (#126523)
This commit is contained in:
committed by
GitHub
parent
9441e3fe6e
commit
4b55192f81
@@ -38,6 +38,7 @@ import {
|
||||
import { loadSubagentRegistryFromSqlite } from "./subagents/registry/subagent-registry.store.sqlite.js";
|
||||
import {
|
||||
addSubagentRunForTests,
|
||||
activateSubagentRegistry,
|
||||
getSubagentRunByChildSessionKey,
|
||||
initSubagentRegistry,
|
||||
listSubagentRunsForRequester,
|
||||
@@ -74,9 +75,13 @@ async function acceptRecoveryDispatch(payload: Record<string, unknown>) {
|
||||
const dispatchAgent = vi.fn(acceptRecoveryDispatch);
|
||||
const gatewayRuntime: GatewayRecoveryRuntime = {
|
||||
dispatchAgent: dispatchAgent as GatewayRecoveryRuntime["dispatchAgent"],
|
||||
waitForAgent: vi.fn(),
|
||||
waitForAgent: vi.fn(async () => ({
|
||||
status: "pending",
|
||||
})) as GatewayRecoveryRuntime["waitForAgent"],
|
||||
sendRecoveryNotice: vi.fn(),
|
||||
};
|
||||
const activateGatewayRuntime = () =>
|
||||
activateSubagentRegistry(() => ({ recoveryRuntime: gatewayRuntime }) as never);
|
||||
|
||||
vi.mock("../gateway/session-utils.fs.js", () => ({
|
||||
readSessionMessagesAsync: vi.fn(async () => []),
|
||||
@@ -114,10 +119,10 @@ describe("subagent orphan recovery — faithful restart path", () => {
|
||||
// external side effects) are recorded so completeSubagentRun runs in-process.
|
||||
testing.setDepsForTest({
|
||||
...createSubagentRegistryTestDeps(),
|
||||
getGatewayRecoveryRuntime: () => gatewayRuntime,
|
||||
runSubagentAnnounceFlow: vi.fn(async () => "delivered" as const),
|
||||
onAgentEvent: vi.fn(() => () => undefined),
|
||||
});
|
||||
activateGatewayRuntime();
|
||||
dispatchAgent.mockReset();
|
||||
dispatchAgent.mockImplementation(acceptRecoveryDispatch);
|
||||
});
|
||||
@@ -255,7 +260,6 @@ describe("subagent orphan recovery — faithful restart path", () => {
|
||||
let strictWriteCount = 0;
|
||||
testing.setDepsForTest({
|
||||
...createSubagentRegistryTestDeps(),
|
||||
getGatewayRecoveryRuntime: () => gatewayRuntime,
|
||||
runSubagentAnnounceFlow: vi.fn(async () => "delivered" as const),
|
||||
onAgentEvent: vi.fn(() => () => undefined),
|
||||
persistSubagentRunsToDiskOrThrow: (runs, changedRunIds) => {
|
||||
@@ -353,6 +357,7 @@ describe("subagent orphan recovery — faithful restart path", () => {
|
||||
acceptedAdmission?.release();
|
||||
rotateAgentEventLifecycleGeneration();
|
||||
initSubagentRegistry();
|
||||
activateGatewayRuntime();
|
||||
const restored = subagentRuns.get(runId);
|
||||
expect(restored?.execution.restartRecovery).toMatchObject({
|
||||
sessionMarker: `sess-lost-acceptance:${now}`,
|
||||
@@ -407,7 +412,6 @@ describe("subagent orphan recovery — faithful restart path", () => {
|
||||
let strictWriteCount = 0;
|
||||
testing.setDepsForTest({
|
||||
...createSubagentRegistryTestDeps(),
|
||||
getGatewayRecoveryRuntime: () => gatewayRuntime,
|
||||
runSubagentAnnounceFlow: vi.fn(async () => "delivered" as const),
|
||||
onAgentEvent: vi.fn(() => () => undefined),
|
||||
persistSubagentRunsToDiskOrThrow: (runs, changedRunIds) => {
|
||||
@@ -455,11 +459,11 @@ describe("subagent orphan recovery — faithful restart path", () => {
|
||||
testing.setDepsForTest({
|
||||
...createSubagentRegistryTestDeps(),
|
||||
callGateway,
|
||||
getGatewayRecoveryRuntime: () => gatewayRuntime,
|
||||
runSubagentAnnounceFlow: vi.fn(async () => "delivered" as const),
|
||||
onAgentEvent: vi.fn(() => () => undefined),
|
||||
});
|
||||
initSubagentRegistry();
|
||||
activateGatewayRuntime();
|
||||
await Promise.resolve();
|
||||
expect(
|
||||
callGatewayRequests.mock.calls.some(
|
||||
@@ -527,11 +531,11 @@ describe("subagent orphan recovery — faithful restart path", () => {
|
||||
rotateAgentEventLifecycleGeneration();
|
||||
testing.setDepsForTest({
|
||||
...createSubagentRegistryTestDeps(),
|
||||
getGatewayRecoveryRuntime: () => gatewayRuntime,
|
||||
runSubagentAnnounceFlow: vi.fn(async () => "delivered" as const),
|
||||
onAgentEvent: vi.fn(() => () => undefined),
|
||||
});
|
||||
initSubagentRegistry();
|
||||
activateGatewayRuntime();
|
||||
await Promise.resolve();
|
||||
await testing.sweepOnceForTests();
|
||||
|
||||
@@ -560,6 +564,7 @@ describe("subagent orphan recovery — faithful restart path", () => {
|
||||
resetSubagentRegistryForTests({ persist: false });
|
||||
rotateAgentEventLifecycleGeneration();
|
||||
initSubagentRegistry();
|
||||
activateGatewayRuntime();
|
||||
await Promise.resolve();
|
||||
await testing.sweepOnceForTests();
|
||||
|
||||
@@ -602,7 +607,6 @@ describe("subagent orphan recovery — faithful restart path", () => {
|
||||
let strictWriteCount = 0;
|
||||
testing.setDepsForTest({
|
||||
...createSubagentRegistryTestDeps(),
|
||||
getGatewayRecoveryRuntime: () => gatewayRuntime,
|
||||
runSubagentAnnounceFlow: vi.fn(async () => "delivered" as const),
|
||||
onAgentEvent: vi.fn(() => () => undefined),
|
||||
persistSubagentRunsToDiskOrThrow: (runs, changedRunIds) => {
|
||||
|
||||
@@ -4,8 +4,6 @@ import type { OpenClawConfig } from "../../../config/types.openclaw.js";
|
||||
import type { ResolveContextEngineOptions } from "../../../context-engine/registry.js";
|
||||
import type { ContextEngine } from "../../../context-engine/types.js";
|
||||
import { callGateway } from "../../../gateway/call.js";
|
||||
import type { GatewayRecoveryRuntime } from "../../../gateway/server-instance-runtime.types.js";
|
||||
import { getGatewayRecoveryRuntime } from "../../../gateway/server-recovery-runtime-context.js";
|
||||
import { onAgentEvent, type AgentEventPayload } from "../../../infra/agent-events.js";
|
||||
import type { PluginRegistry } from "../../../plugins/registry-types.js";
|
||||
import { createLazyImportLoader, createLazyPromiseLoader } from "../../../shared/lazy-promise.js";
|
||||
@@ -32,7 +30,6 @@ type BrowserCleanupModule = Pick<
|
||||
|
||||
export type SubagentRegistryDeps = {
|
||||
callGateway: typeof callGateway;
|
||||
getGatewayRecoveryRuntime: () => GatewayRecoveryRuntime | undefined;
|
||||
captureSubagentCompletionReply: SubagentAnnounceModule["captureSubagentCompletionReply"];
|
||||
cleanupBrowserSessionsForLifecycleEnd: typeof cleanupBrowserSessionsForLifecycleEnd;
|
||||
getRuntimeConfig: typeof getRuntimeConfig;
|
||||
@@ -74,7 +71,6 @@ async function loadCleanupBrowserSessionsForLifecycleEnd(): Promise<
|
||||
|
||||
const defaultSubagentRegistryDeps: SubagentRegistryDeps = {
|
||||
callGateway,
|
||||
getGatewayRecoveryRuntime,
|
||||
captureSubagentCompletionReply: async (sessionKey, options) =>
|
||||
(await loadSubagentAnnounceModule()).captureSubagentCompletionReply(sessionKey, options),
|
||||
cleanupBrowserSessionsForLifecycleEnd: async (params) =>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ADMIN_SCOPE } from "../../../gateway/method-scopes.js";
|
||||
import type { GatewayContextResolver } from "../../../gateway/server-methods/types.js";
|
||||
import {
|
||||
getAgentEventLifecycleGeneration,
|
||||
isAgentEventLifecycleGenerationCurrent,
|
||||
@@ -55,6 +56,7 @@ export function createSubagentRegistryRestorer(config: {
|
||||
runs: Map<string, SubagentRunRecord>;
|
||||
resumedRuns: Set<string>;
|
||||
deps: () => SubagentRegistryDeps;
|
||||
getGatewayContextResolver: () => GatewayContextResolver | undefined;
|
||||
persist: (...runIds: string[]) => void;
|
||||
persistOrThrow: (...runIds: string[]) => void;
|
||||
settleRequesterTurn: SubagentLifecycleController["settleRequesterTurnAfterSessionSpawns"];
|
||||
@@ -84,13 +86,13 @@ export function createSubagentRegistryRestorer(config: {
|
||||
) => Promise<boolean>;
|
||||
settleFailedQueuedSubagentLaunch: (runId: string, error: string) => boolean;
|
||||
completeCollectorLaunchCleanup: (runId: string) => void;
|
||||
scheduleSweep: (params?: { delayMs?: number }) => void;
|
||||
warn: (message: string, meta?: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const {
|
||||
runs,
|
||||
resumedRuns,
|
||||
deps,
|
||||
getGatewayContextResolver,
|
||||
persist,
|
||||
persistOrThrow,
|
||||
settleRequesterTurn,
|
||||
@@ -103,10 +105,11 @@ export function createSubagentRegistryRestorer(config: {
|
||||
cleanupCollectorLaunchResources,
|
||||
settleFailedQueuedSubagentLaunch,
|
||||
completeCollectorLaunchCleanup,
|
||||
scheduleSweep,
|
||||
warn,
|
||||
} = config;
|
||||
let restoreState: "idle" | "in-progress" | "succeeded" = "idle";
|
||||
let activationRequested = false;
|
||||
let activated = false;
|
||||
// A dependency can merge rows before throwing. Keep their reconciliation
|
||||
// pending because mergeOnly correctly reports them as existing on retry.
|
||||
let restoredRowsPending = false;
|
||||
@@ -138,6 +141,170 @@ export function createSubagentRegistryRestorer(config: {
|
||||
restoredRowsPending = false;
|
||||
restoreState = "succeeded";
|
||||
clearRestoreRetryTimer();
|
||||
if (activationRequested) {
|
||||
activateRestoredRuns();
|
||||
}
|
||||
}
|
||||
|
||||
function activateRestoredRuns() {
|
||||
activationRequested = true;
|
||||
if (restoreState !== "succeeded" || activated) {
|
||||
return;
|
||||
}
|
||||
const cfg = deps().getRuntimeConfig();
|
||||
const requesterTurns = new Map<string, Map<string, SubagentRunRecord[]>>();
|
||||
const resolveRequesterAgentId = (entry: SubagentRunRecord) =>
|
||||
resolveSubagentRequesterAgentId(cfg, entry);
|
||||
for (const entry of runs.values()) {
|
||||
const requesterTurnRunId = entry.requesterTurnRunId?.trim();
|
||||
if (!requesterTurnRunId) {
|
||||
continue;
|
||||
}
|
||||
const requesterIdentity = `${resolveRequesterAgentId(entry) ?? "unknown"}\0${entry.requesterSessionKey}`;
|
||||
let turns = requesterTurns.get(requesterIdentity);
|
||||
if (!turns) {
|
||||
turns = new Map();
|
||||
requesterTurns.set(requesterIdentity, turns);
|
||||
}
|
||||
const entries = turns.get(requesterTurnRunId) ?? [];
|
||||
entries.push(entry);
|
||||
turns.set(requesterTurnRunId, entries);
|
||||
}
|
||||
for (const [, turns] of requesterTurns) {
|
||||
for (const [requesterTurnRunId, entries] of turns) {
|
||||
const firstEntry = entries[0];
|
||||
if (!firstEntry) {
|
||||
continue;
|
||||
}
|
||||
settleRequesterTurn({
|
||||
requesterSessionKey: firstEntry.requesterSessionKey,
|
||||
requesterAgentId: resolveRequesterAgentId(firstEntry),
|
||||
requesterTurnRunId,
|
||||
requesterYielded: entries.every((entry) => entry.requesterTurnYielded === true),
|
||||
acceptedSessionSpawns: entries.map((entry) => ({
|
||||
runId: entry.taskRunId ?? entry.runId,
|
||||
childSessionKey: entry.childSessionKey,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (runs.size === 0) {
|
||||
activated = true;
|
||||
return;
|
||||
}
|
||||
|
||||
ensureListener();
|
||||
// Session-mode runs have no archive deadline but still need TTL cleanup.
|
||||
startSweeper();
|
||||
const restoredSessionCache: SubagentSessionStoreCache = new Map();
|
||||
for (const [runId, entry] of runs) {
|
||||
// Restart recovery exclusively owns receipt-bearing source rows until it
|
||||
// remaps or terminalizes them. Generic resume would wait on an obsolete run.
|
||||
if (entry.execution.restartRecovery || entry.killIntent || entry.killReconciliation) {
|
||||
continue;
|
||||
}
|
||||
if (entry.collect && entry.execution.status === "queued") {
|
||||
const cleanupSessionEntry = loadSubagentSessionEntry({
|
||||
childSessionKey: entry.childSessionKey,
|
||||
storeCache: restoredSessionCache,
|
||||
});
|
||||
const launch = entry.queuedLaunch;
|
||||
if (!launch) {
|
||||
const cleanupLifecycleGeneration = getAgentEventLifecycleGeneration();
|
||||
void failAndCleanupRestoredQueuedRun(
|
||||
runId,
|
||||
entry,
|
||||
"queued collector launch state was unavailable after restart",
|
||||
false,
|
||||
cleanupLifecycleGeneration,
|
||||
cleanupSessionEntry?.sessionId,
|
||||
cleanupSessionEntry?.lifecycleRevision,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const groupRuns = listSwarmRunsForGroup(
|
||||
entry.groupId ?? "",
|
||||
entry.swarmRequesterSessionKey ?? entry.requesterSessionKey,
|
||||
entry.requesterAgentId,
|
||||
);
|
||||
const currentSwarmConfig = resolveSwarmConfig(cfg, entry.requesterAgentId);
|
||||
let launchTerminationConfirmed = false;
|
||||
let launchLifecycleGeneration: string | undefined;
|
||||
enqueueSwarmRun({
|
||||
groupId: launch.schedulerGroupKey,
|
||||
runId,
|
||||
maxConcurrent: currentSwarmConfig.maxConcurrent,
|
||||
activeRunIds: groupRuns
|
||||
.filter((candidate) => candidate.execution.status === "running")
|
||||
.map((candidate) => candidate.schedulerSlotId ?? candidate.runId),
|
||||
start: async () => {
|
||||
await runWithGatewayIndependentRootWorkAdmission(async () => {
|
||||
launchLifecycleGeneration = getAgentEventLifecycleGeneration();
|
||||
const request = {
|
||||
params: applySubagentLaunchAuthorization(launch.request, launch.authorization),
|
||||
timeoutMs: launch.timeoutMs,
|
||||
};
|
||||
const gatewayRuntime = getGatewayContextResolver()?.()?.recoveryRuntime;
|
||||
if (!gatewayRuntime) {
|
||||
throw new GatewayDrainingError();
|
||||
}
|
||||
const response = await gatewayRuntime.dispatchAgent(
|
||||
request.params as Parameters<typeof gatewayRuntime.dispatchAgent>[0],
|
||||
request.timeoutMs,
|
||||
launch.authorization
|
||||
? { allowModelOverride: true, scopes: [ADMIN_SCOPE] }
|
||||
: undefined,
|
||||
);
|
||||
const gatewayRunId = readGatewayRunId(response) ?? runId;
|
||||
try {
|
||||
if (!startQueuedSubagentRun(runId, gatewayRunId, launchLifecycleGeneration)) {
|
||||
throw new Error(
|
||||
"collector registry row could not transition from queued to running",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
await terminateAcceptedRestoredCollectorRun({
|
||||
entry,
|
||||
gatewayRunId,
|
||||
timeoutMs: launch.timeoutMs,
|
||||
expectedSessionId: cleanupSessionEntry?.sessionId,
|
||||
expectedLifecycleRevision: cleanupSessionEntry?.lifecycleRevision,
|
||||
});
|
||||
launchTerminationConfirmed = true;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
},
|
||||
onStartFailure: (error) => {
|
||||
if (error instanceof GatewayDrainingError) {
|
||||
return false;
|
||||
}
|
||||
return failAndCleanupRestoredQueuedRun(
|
||||
runId,
|
||||
entry,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
launchTerminationConfirmed,
|
||||
launchLifecycleGeneration ?? getAgentEventLifecycleGeneration(),
|
||||
cleanupSessionEntry?.sessionId,
|
||||
cleanupSessionEntry?.lifecycleRevision,
|
||||
);
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// An aborted persisted session belongs to orphan recovery. Waiting on its
|
||||
// pre-restart run can terminalize it before the replacement turn starts.
|
||||
if (
|
||||
loadSubagentSessionEntry({
|
||||
childSessionKey: entry.childSessionKey,
|
||||
storeCache: restoredSessionCache,
|
||||
})?.abortedLastRun === true
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
resumeRun(runId);
|
||||
}
|
||||
activated = true;
|
||||
}
|
||||
|
||||
function restoreSubagentRunsOnce(retryDelayMs = RESTORE_RETRY_DELAY_MS) {
|
||||
@@ -172,168 +339,6 @@ export function createSubagentRegistryRestorer(config: {
|
||||
if (restoredStateChanged) {
|
||||
persist();
|
||||
}
|
||||
const requesterTurns = new Map<string, Map<string, SubagentRunRecord[]>>();
|
||||
const resolveRequesterAgentId = (entry: SubagentRunRecord) =>
|
||||
resolveSubagentRequesterAgentId(cfg, entry);
|
||||
for (const entry of runs.values()) {
|
||||
const requesterTurnRunId = entry.requesterTurnRunId?.trim();
|
||||
if (!requesterTurnRunId) {
|
||||
continue;
|
||||
}
|
||||
const requesterIdentity = `${resolveRequesterAgentId(entry) ?? "unknown"}\0${entry.requesterSessionKey}`;
|
||||
let turns = requesterTurns.get(requesterIdentity);
|
||||
if (!turns) {
|
||||
turns = new Map();
|
||||
requesterTurns.set(requesterIdentity, turns);
|
||||
}
|
||||
const entries = turns.get(requesterTurnRunId) ?? [];
|
||||
entries.push(entry);
|
||||
turns.set(requesterTurnRunId, entries);
|
||||
}
|
||||
for (const [, turns] of requesterTurns) {
|
||||
for (const [requesterTurnRunId, entries] of turns) {
|
||||
const firstEntry = entries[0];
|
||||
if (!firstEntry) {
|
||||
continue;
|
||||
}
|
||||
settleRequesterTurn({
|
||||
requesterSessionKey: firstEntry.requesterSessionKey,
|
||||
requesterAgentId: resolveRequesterAgentId(firstEntry),
|
||||
requesterTurnRunId,
|
||||
requesterYielded: entries.every((entry) => entry.requesterTurnYielded === true),
|
||||
acceptedSessionSpawns: entries.map((entry) => ({
|
||||
runId: entry.taskRunId ?? entry.runId,
|
||||
childSessionKey: entry.childSessionKey,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (runs.size === 0) {
|
||||
completeRestore();
|
||||
return;
|
||||
}
|
||||
// Resume pending work.
|
||||
ensureListener();
|
||||
// Always start sweeper — session-mode runs (no archiveAtMs) also need TTL cleanup.
|
||||
startSweeper();
|
||||
const restoredSessionCache: SubagentSessionStoreCache = new Map();
|
||||
for (const [runId, entry] of runs) {
|
||||
// Restart recovery exclusively owns receipt-bearing source rows until it
|
||||
// remaps or terminalizes them. Generic resume would wait on an obsolete run.
|
||||
if (entry.execution.restartRecovery || entry.killIntent || entry.killReconciliation) {
|
||||
continue;
|
||||
}
|
||||
if (entry.collect && entry.execution.status === "queued") {
|
||||
const cleanupSessionEntry = loadSubagentSessionEntry({
|
||||
childSessionKey: entry.childSessionKey,
|
||||
storeCache: restoredSessionCache,
|
||||
});
|
||||
const launch = entry.queuedLaunch;
|
||||
if (!launch) {
|
||||
const cleanupLifecycleGeneration = getAgentEventLifecycleGeneration();
|
||||
void failAndCleanupRestoredQueuedRun(
|
||||
runId,
|
||||
entry,
|
||||
"queued collector launch state was unavailable after restart",
|
||||
false,
|
||||
cleanupLifecycleGeneration,
|
||||
cleanupSessionEntry?.sessionId,
|
||||
cleanupSessionEntry?.lifecycleRevision,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const groupRuns = listSwarmRunsForGroup(
|
||||
entry.groupId ?? "",
|
||||
entry.swarmRequesterSessionKey ?? entry.requesterSessionKey,
|
||||
entry.requesterAgentId,
|
||||
);
|
||||
const currentSwarmConfig = resolveSwarmConfig(
|
||||
deps().getRuntimeConfig(),
|
||||
entry.requesterAgentId,
|
||||
);
|
||||
let launchTerminationConfirmed = false;
|
||||
let launchLifecycleGeneration: string | undefined;
|
||||
enqueueSwarmRun({
|
||||
groupId: launch.schedulerGroupKey,
|
||||
runId,
|
||||
maxConcurrent: currentSwarmConfig.maxConcurrent,
|
||||
activeRunIds: groupRuns
|
||||
.filter((candidate) => candidate.execution.status === "running")
|
||||
.map((candidate) => candidate.schedulerSlotId ?? candidate.runId),
|
||||
start: async () => {
|
||||
await runWithGatewayIndependentRootWorkAdmission(async () => {
|
||||
launchLifecycleGeneration = getAgentEventLifecycleGeneration();
|
||||
const request = {
|
||||
method: "agent",
|
||||
params: applySubagentLaunchAuthorization(launch.request, launch.authorization),
|
||||
// Restart replay must restore the trusted launch capability; otherwise
|
||||
// the queued child silently falls back to its session/default route.
|
||||
...(launch.authorization ? { scopes: [ADMIN_SCOPE] } : {}),
|
||||
timeoutMs: launch.timeoutMs,
|
||||
};
|
||||
const gatewayRuntime = deps().getGatewayRecoveryRuntime();
|
||||
const response = gatewayRuntime
|
||||
? await gatewayRuntime.dispatchAgent(
|
||||
request.params as Parameters<typeof gatewayRuntime.dispatchAgent>[0],
|
||||
request.timeoutMs,
|
||||
launch.authorization
|
||||
? { allowModelOverride: true, scopes: [ADMIN_SCOPE] }
|
||||
: undefined,
|
||||
)
|
||||
: await deps().callGateway(request);
|
||||
const gatewayRunId = readGatewayRunId(response) ?? runId;
|
||||
try {
|
||||
if (!startQueuedSubagentRun(runId, gatewayRunId, launchLifecycleGeneration)) {
|
||||
throw new Error(
|
||||
"collector registry row could not transition from queued to running",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
await terminateAcceptedRestoredCollectorRun({
|
||||
entry,
|
||||
gatewayRunId,
|
||||
timeoutMs: launch.timeoutMs,
|
||||
expectedSessionId: cleanupSessionEntry?.sessionId,
|
||||
expectedLifecycleRevision: cleanupSessionEntry?.lifecycleRevision,
|
||||
});
|
||||
launchTerminationConfirmed = true;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
},
|
||||
onStartFailure: (error) => {
|
||||
if (error instanceof GatewayDrainingError) {
|
||||
return false;
|
||||
}
|
||||
return failAndCleanupRestoredQueuedRun(
|
||||
runId,
|
||||
entry,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
launchTerminationConfirmed,
|
||||
launchLifecycleGeneration ?? getAgentEventLifecycleGeneration(),
|
||||
cleanupSessionEntry?.sessionId,
|
||||
cleanupSessionEntry?.lifecycleRevision,
|
||||
);
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// An aborted persisted session belongs to orphan recovery. Waiting on its
|
||||
// pre-restart run can terminalize it before the replacement turn starts.
|
||||
if (
|
||||
loadSubagentSessionEntry({
|
||||
childSessionKey: entry.childSessionKey,
|
||||
storeCache: restoredSessionCache,
|
||||
})?.abortedLastRun === true
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
resumeRun(runId);
|
||||
}
|
||||
|
||||
// Cold-start restore can precede instance-runtime registration. The post-attach
|
||||
// startup pass retries this seam once the lifecycle-bound principal exists.
|
||||
scheduleSweep();
|
||||
completeRestore();
|
||||
} catch (err) {
|
||||
restoredRowsPending ||= runs.size > runCountBeforeRestore;
|
||||
@@ -517,10 +522,13 @@ export function createSubagentRegistryRestorer(config: {
|
||||
|
||||
return {
|
||||
restoreOnce: restoreSubagentRunsOnce,
|
||||
activate: activateRestoredRuns,
|
||||
reset: () => {
|
||||
clearRestoreRetryTimer();
|
||||
restoreState = "idle";
|
||||
restoredRowsPending = false;
|
||||
activationRequested = false;
|
||||
activated = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,6 +79,16 @@ vi.mock("../../timeout.js", () => ({
|
||||
describe("announce loop guard (#18264)", () => {
|
||||
let registry: typeof import("./subagent-registry.test-helpers.js");
|
||||
|
||||
function hydrateAndActivateRegistry() {
|
||||
registry.initSubagentRegistry();
|
||||
const recoveryRuntime = {
|
||||
dispatchAgent: vi.fn(),
|
||||
waitForAgent: vi.fn(async () => ({ status: "pending" })),
|
||||
sendRecoveryNotice: vi.fn(),
|
||||
};
|
||||
registry.activateSubagentRegistry(() => ({ recoveryRuntime }) as never);
|
||||
}
|
||||
|
||||
function requireRunById(runs: SubagentRunRecord[], runId: string): SubagentRunRecord {
|
||||
const entry = runs.find((run) => run.runId === runId);
|
||||
if (!entry) {
|
||||
@@ -198,7 +208,7 @@ describe("announce loop guard (#18264)", () => {
|
||||
|
||||
// Initialization finalizes expired pending rows without another recipient-visible attempt.
|
||||
const beforeInit = Date.now();
|
||||
registry.initSubagentRegistry();
|
||||
hydrateAndActivateRegistry();
|
||||
await flushAsync();
|
||||
|
||||
expect(mocks.runSubagentAnnounceFlow).not.toHaveBeenCalled();
|
||||
@@ -231,7 +241,7 @@ describe("announce loop guard (#18264)", () => {
|
||||
};
|
||||
mocks.loadSubagentRegistryFromSqlite.mockReturnValue(new Map([[entry.runId, entry]]));
|
||||
|
||||
registry.initSubagentRegistry();
|
||||
hydrateAndActivateRegistry();
|
||||
const resumed = await waitForRun(
|
||||
entry.runId,
|
||||
(run) => run.delivery?.attemptCount === 4 && typeof run.delivery.nextAttemptAt === "number",
|
||||
@@ -279,7 +289,7 @@ describe("announce loop guard (#18264)", () => {
|
||||
]),
|
||||
);
|
||||
|
||||
registry.initSubagentRegistry();
|
||||
hydrateAndActivateRegistry();
|
||||
await flushAsync();
|
||||
|
||||
expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
|
||||
@@ -315,7 +325,7 @@ describe("announce loop guard (#18264)", () => {
|
||||
]),
|
||||
);
|
||||
|
||||
registry.initSubagentRegistry();
|
||||
hydrateAndActivateRegistry();
|
||||
await flushAsync();
|
||||
|
||||
const stored = await waitForRun(
|
||||
|
||||
@@ -28,6 +28,17 @@ let callGatewayModule: typeof import("../../../gateway/call.js");
|
||||
let agentEventsModule: typeof import("../../../infra/agent-events.js");
|
||||
let registryStateDbModule: typeof import("../../../state/openclaw-state-db.js");
|
||||
|
||||
function activateRegistry() {
|
||||
const recoveryRuntime = {
|
||||
dispatchAgent: (params: Record<string, unknown>, timeoutMs?: number) =>
|
||||
callGatewayModule.callGateway({ method: "agent", params, timeoutMs }),
|
||||
waitForAgent: (params: Record<string, unknown>, timeoutMs?: number) =>
|
||||
callGatewayModule.callGateway({ method: "agent.wait", params, timeoutMs }),
|
||||
sendRecoveryNotice: vi.fn(),
|
||||
};
|
||||
mod.activateSubagentRegistry(() => ({ recoveryRuntime }) as never);
|
||||
}
|
||||
|
||||
describe("subagent registry persistence resume", () => {
|
||||
let tempStateDir: string | null = null;
|
||||
|
||||
@@ -97,6 +108,7 @@ describe("subagent registry persistence resume", () => {
|
||||
});
|
||||
|
||||
mod.initSubagentRegistry();
|
||||
activateRegistry();
|
||||
|
||||
await vi.waitFor(() => expect(announceSpy).toHaveBeenCalled(), {
|
||||
timeout: 1_000,
|
||||
@@ -169,6 +181,7 @@ describe("subagent registry persistence resume", () => {
|
||||
});
|
||||
|
||||
mod.initSubagentRegistry();
|
||||
activateRegistry();
|
||||
|
||||
await vi.waitFor(() => expect(announceSpy).toHaveBeenCalled(), {
|
||||
timeout: 1_000,
|
||||
@@ -180,6 +193,170 @@ describe("subagent registry persistence resume", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps restored recovery dormant until the Gateway lifecycle activates it", async () => {
|
||||
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-subagent-"));
|
||||
const stateDir = tempStateDir;
|
||||
const wakeRequester = vi.fn(async () => false);
|
||||
mod.testing.setDepsForTest({
|
||||
...createSubagentRegistryTestDeps({
|
||||
callGateway: vi.mocked(callGatewayModule.callGateway),
|
||||
maybeWakeRequesterAfterAllChildrenSettled: wakeRequester,
|
||||
}),
|
||||
});
|
||||
|
||||
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
|
||||
const endedAt = Date.now();
|
||||
const yieldedRun: SubagentRunRecord = {
|
||||
runId: "run-hydrated-yield",
|
||||
taskRunId: "run-hydrated-yield",
|
||||
requesterTurnRunId: "run-requester",
|
||||
requesterTurnYielded: true,
|
||||
childSessionKey: "agent:main:subagent:hydrated-yield",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "wake only after lifecycle activation",
|
||||
cleanup: "keep",
|
||||
createdAt: endedAt - 1_000,
|
||||
endedReason: "subagent-complete",
|
||||
execution: {
|
||||
status: "terminal",
|
||||
startedAt: endedAt - 500,
|
||||
endedAt,
|
||||
outcome: { status: "ok" },
|
||||
},
|
||||
expectsCompletionMessage: true,
|
||||
completion: { required: true, resultText: "done", capturedAt: endedAt },
|
||||
delivery: { status: "delivered", deliveredAt: endedAt },
|
||||
cleanupHandled: true,
|
||||
cleanupCompletedAt: endedAt,
|
||||
};
|
||||
const queuedCollector: SubagentRunRecord = {
|
||||
runId: "run-hydrated-collector",
|
||||
childSessionKey: "agent:main:subagent:hydrated-collector",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "clean only after lifecycle activation",
|
||||
cleanup: "keep",
|
||||
createdAt: endedAt - 500,
|
||||
collect: true,
|
||||
swarmRequesterSessionKey: "agent:main:main",
|
||||
groupId: "hydrated-group",
|
||||
archiveAtMs: endedAt - 1,
|
||||
execution: {
|
||||
status: "terminal",
|
||||
startedAt: endedAt - 400,
|
||||
endedAt,
|
||||
outcome: { status: "error", error: "launch failed" },
|
||||
},
|
||||
completion: { required: true },
|
||||
delivery: { status: "pending" },
|
||||
collectorCompletion: { status: "failed" },
|
||||
collectorLaunchCleanupPending: true,
|
||||
};
|
||||
const runningRun: SubagentRunRecord = {
|
||||
runId: "run-hydrated-running",
|
||||
childSessionKey: "agent:main:subagent:hydrated-running",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "wait through the activated instance",
|
||||
cleanup: "keep",
|
||||
createdAt: endedAt,
|
||||
execution: { status: "running", startedAt: endedAt },
|
||||
completion: { required: false },
|
||||
delivery: { status: "not_required" },
|
||||
};
|
||||
saveSubagentRegistryToSqlite(
|
||||
new Map([
|
||||
[yieldedRun.runId, yieldedRun],
|
||||
[queuedCollector.runId, queuedCollector],
|
||||
[runningRun.runId, runningRun],
|
||||
]),
|
||||
);
|
||||
await writeSubagentSessionEntry({
|
||||
stateDir,
|
||||
agentId: "main",
|
||||
sessionKey: yieldedRun.childSessionKey,
|
||||
sessionId: "sess-hydrated-yield",
|
||||
defaultSessionId: "sess-hydrated-yield",
|
||||
});
|
||||
await writeSubagentSessionEntry({
|
||||
stateDir,
|
||||
agentId: "main",
|
||||
sessionKey: queuedCollector.childSessionKey,
|
||||
sessionId: "sess-hydrated-collector",
|
||||
defaultSessionId: "sess-hydrated-collector",
|
||||
lifecycleRevision: "revision-hydrated-collector",
|
||||
});
|
||||
await writeSubagentSessionEntry({
|
||||
stateDir,
|
||||
agentId: "main",
|
||||
sessionKey: runningRun.childSessionKey,
|
||||
sessionId: "sess-hydrated-running",
|
||||
defaultSessionId: "sess-hydrated-running",
|
||||
});
|
||||
|
||||
mod.initSubagentRegistry();
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
|
||||
expect(mod.getSubagentRunByRunId(yieldedRun.runId)).toBeDefined();
|
||||
expect(mod.getSubagentRunByRunId(queuedCollector.runId)).toBeDefined();
|
||||
expect(mod.getSubagentRunByRunId(runningRun.runId)).toBeDefined();
|
||||
expect(wakeRequester).not.toHaveBeenCalled();
|
||||
expect(callGatewayModule.callGateway).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: "sessions.delete" }),
|
||||
);
|
||||
|
||||
const recoveryRuntime = {
|
||||
dispatchAgent: vi.fn(),
|
||||
waitForAgent: vi.fn(async () => ({ status: "pending" })),
|
||||
sendRecoveryNotice: vi.fn(),
|
||||
};
|
||||
let firstLifecycleOpen = true;
|
||||
const resolveGatewayContext = vi.fn(() =>
|
||||
firstLifecycleOpen ? ({ recoveryRuntime } as never) : undefined,
|
||||
);
|
||||
mod.activateSubagentRegistry(resolveGatewayContext);
|
||||
mod.activateSubagentRegistry(resolveGatewayContext);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wakeRequester).toHaveBeenCalledOnce();
|
||||
expect(recoveryRuntime.waitForAgent).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(recoveryRuntime.dispatchAgent).not.toHaveBeenCalled();
|
||||
expect(callGatewayModule.callGateway).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: "agent.wait" }),
|
||||
);
|
||||
|
||||
firstLifecycleOpen = false;
|
||||
expect(resolveGatewayContext()).toBeUndefined();
|
||||
const replacementRuntime = {
|
||||
dispatchAgent: vi.fn(),
|
||||
waitForAgent: vi.fn(async () => ({ status: "pending" })),
|
||||
sendRecoveryNotice: vi.fn(),
|
||||
};
|
||||
const resolveReplacementContext = () => ({ recoveryRuntime: replacementRuntime }) as never;
|
||||
mod.activateSubagentRegistry(resolveReplacementContext);
|
||||
mod.activateSubagentRegistry(resolveReplacementContext);
|
||||
expect(wakeRequester).toHaveBeenCalledOnce();
|
||||
expect(recoveryRuntime.waitForAgent).toHaveBeenCalledOnce();
|
||||
expect(replacementRuntime.waitForAgent).not.toHaveBeenCalled();
|
||||
|
||||
await mod.testing.runSweeperTickForTests();
|
||||
expect(callGatewayModule.callGateway).toHaveBeenCalledTimes(1);
|
||||
expect(callGatewayModule.callGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "sessions.delete",
|
||||
params: expect.objectContaining({
|
||||
expectedSessionId: "sess-hydrated-collector",
|
||||
expectedLifecycleRevision: "revision-hydrated-collector",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps dismissed terminal delivery dormant and TTL-eligible after restore", async () => {
|
||||
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-subagent-"));
|
||||
const stateDir = tempStateDir;
|
||||
@@ -269,6 +446,7 @@ describe("subagent registry persistence resume", () => {
|
||||
});
|
||||
|
||||
mod.initSubagentRegistry();
|
||||
activateRegistry();
|
||||
|
||||
const restored = mod.getSubagentRunByRunId(run.runId);
|
||||
expect(restored).toMatchObject({ runId: run.runId, taskRunId: run.taskRunId });
|
||||
|
||||
@@ -69,6 +69,7 @@ export async function writeSubagentSessionEntry(params: {
|
||||
sessionId?: string;
|
||||
updatedAt?: number;
|
||||
abortedLastRun?: boolean;
|
||||
lifecycleRevision?: string;
|
||||
agentId: string;
|
||||
defaultSessionId: string;
|
||||
}): Promise<string> {
|
||||
@@ -81,6 +82,7 @@ export async function writeSubagentSessionEntry(params: {
|
||||
...(typeof params.abortedLastRun === "boolean"
|
||||
? { abortedLastRun: params.abortedLastRun }
|
||||
: {}),
|
||||
...(params.lifecycleRevision ? { lifecycleRevision: params.lifecycleRevision } : {}),
|
||||
};
|
||||
await replaceSessionEntry({ storePath, sessionKey: params.sessionKey }, entry);
|
||||
return storePath;
|
||||
@@ -111,11 +113,6 @@ export function createSubagentRegistryTestDeps(
|
||||
ensureContextEnginesInitialized: vi.fn(),
|
||||
loadAgentRuntimePluginRegistryHandle: vi.fn(),
|
||||
getRuntimeConfig: vi.fn(() => ({})),
|
||||
getGatewayRecoveryRuntime: vi.fn(() => ({
|
||||
dispatchAgent: vi.fn(),
|
||||
waitForAgent: vi.fn(),
|
||||
sendRecoveryNotice: vi.fn(),
|
||||
})),
|
||||
resolveAgentTimeoutMs: vi.fn(() => 100),
|
||||
resolveContextEngine: vi.fn(async () => ({
|
||||
info: { id: "test", name: "Test", version: "0.0.1" },
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from "./subagent-registry.store.sqlite.js";
|
||||
import {
|
||||
testing,
|
||||
activateSubagentRegistry,
|
||||
addSubagentRunForTests,
|
||||
clearSubagentRunSteerRestart,
|
||||
getSubagentRunByChildSessionKey,
|
||||
@@ -187,6 +188,14 @@ describe("subagent registry persistence", () => {
|
||||
const restartRegistry = () => {
|
||||
resetSubagentRegistryForTests({ persist: false });
|
||||
initSubagentRegistry();
|
||||
const recoveryRuntime = {
|
||||
dispatchAgent: (params: Record<string, unknown>, timeoutMs?: number) =>
|
||||
callGateway({ method: "agent", params, timeoutMs }),
|
||||
waitForAgent: (params: Record<string, unknown>, timeoutMs?: number) =>
|
||||
callGateway({ method: "agent.wait", params, timeoutMs }),
|
||||
sendRecoveryNotice: vi.fn(),
|
||||
};
|
||||
activateSubagentRegistry(() => ({ recoveryRuntime }) as never);
|
||||
};
|
||||
|
||||
const fastPersistSubagentRunsToDisk = (runs: Map<string, SubagentRunRecord>) =>
|
||||
|
||||
@@ -50,9 +50,6 @@ type RegistryTestApi = {
|
||||
|
||||
type RegistryDeps = {
|
||||
callGateway: typeof import("../../../gateway/call.js").callGateway;
|
||||
getGatewayRecoveryRuntime: () =>
|
||||
| import("../../../gateway/server-instance-runtime.types.js").GatewayRecoveryRuntime
|
||||
| undefined;
|
||||
captureSubagentCompletionReply: typeof import("../announce/subagent-announce.js").captureSubagentCompletionReply;
|
||||
cleanupBrowserSessionsForLifecycleEnd: typeof import("../../../browser-lifecycle-cleanup.js").cleanupBrowserSessionsForLifecycleEnd;
|
||||
getRuntimeConfig: typeof import("../../../config/config.js").getRuntimeConfig;
|
||||
|
||||
@@ -423,6 +423,21 @@ describe("subagent registry seam flow", () => {
|
||||
},
|
||||
});
|
||||
let mod: RegistryHarness;
|
||||
const recoveryRuntime: GatewayRecoveryRuntime = {
|
||||
dispatchAgent: mocks.dispatchRecoveryAgent as GatewayRecoveryRuntime["dispatchAgent"],
|
||||
waitForAgent: (params, timeoutMs) =>
|
||||
mocks.callGateway({
|
||||
method: "agent.wait",
|
||||
params: params as unknown as Record<string, unknown>,
|
||||
timeoutMs,
|
||||
}) as never,
|
||||
sendRecoveryNotice: vi.fn(),
|
||||
};
|
||||
const activateRegistry = () => mod.activateSubagentRegistry(() => ({ recoveryRuntime }) as never);
|
||||
const hydrateAndActivateRegistry = () => {
|
||||
mod.initSubagentRegistry();
|
||||
activateRegistry();
|
||||
};
|
||||
const findRequesterRun = (runId: string) =>
|
||||
mod.listSubagentRunsForRequester("agent:main:main").find((entry) => entry.runId === runId);
|
||||
const mockPendingAgentWait = () =>
|
||||
@@ -530,7 +545,6 @@ describe("subagent registry seam flow", () => {
|
||||
callGateway: mocks.callGateway as typeof import("../../../gateway/call.js").callGateway,
|
||||
captureSubagentCompletionReply: mocks.captureSubagentCompletionReply,
|
||||
cleanupBrowserSessionsForLifecycleEnd: mocks.cleanupBrowserSessionsForLifecycleEnd,
|
||||
getGatewayRecoveryRuntime: mocks.getGatewayRecoveryRuntime,
|
||||
onAgentEvent: mocks.onAgentEvent,
|
||||
persistSubagentRunsToDisk: mocks.persistSubagentRunsToDisk,
|
||||
persistSubagentRunsToDiskOrThrow: mocks.persistSubagentRunsToDiskOrThrow,
|
||||
@@ -1251,7 +1265,7 @@ describe("subagent registry seam flow", () => {
|
||||
}) as never)
|
||||
.mockReturnValue(0);
|
||||
|
||||
mod.initSubagentRegistry();
|
||||
hydrateAndActivateRegistry();
|
||||
expect(mocks.restoreSubagentRunsFromDisk).toHaveBeenCalledOnce();
|
||||
expect(mocks.onAgentEvent).not.toHaveBeenCalled();
|
||||
|
||||
@@ -1314,7 +1328,7 @@ describe("subagent registry seam flow", () => {
|
||||
return 1;
|
||||
}) as never);
|
||||
|
||||
mod.initSubagentRegistry();
|
||||
hydrateAndActivateRegistry();
|
||||
|
||||
await waitForFast(() => {
|
||||
expect(mocks.maybeWakeRequesterAfterAllChildrenSettled).toHaveBeenCalledTimes(1);
|
||||
@@ -1353,7 +1367,7 @@ describe("subagent registry seam flow", () => {
|
||||
return 1;
|
||||
}) as never);
|
||||
|
||||
mod.initSubagentRegistry();
|
||||
hydrateAndActivateRegistry();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
@@ -1402,7 +1416,7 @@ describe("subagent registry seam flow", () => {
|
||||
|
||||
const suspension = tryBeginGatewaySuspendAdmission(() => {});
|
||||
expect(suspension?.commit()).toBe(true);
|
||||
mod.initSubagentRegistry();
|
||||
hydrateAndActivateRegistry();
|
||||
await Promise.resolve();
|
||||
expect(mocks.callGateway.mock.calls.filter(([request]) => request.method === "agent")).toEqual(
|
||||
[],
|
||||
@@ -1619,7 +1633,7 @@ describe("subagent registry seam flow", () => {
|
||||
return request.method === "agent.wait" ? { status: "pending" } : {};
|
||||
});
|
||||
|
||||
mod.initSubagentRegistry();
|
||||
hydrateAndActivateRegistry();
|
||||
await waitForFast(() => expect(releaseAbort).toBeTypeOf("function"));
|
||||
expect(agentCalls).toBe(1);
|
||||
|
||||
@@ -1700,7 +1714,7 @@ describe("subagent registry seam flow", () => {
|
||||
return request.method === "agent.wait" ? { status: "pending" } : {};
|
||||
});
|
||||
|
||||
mod.initSubagentRegistry();
|
||||
hydrateAndActivateRegistry();
|
||||
await waitForFast(() => expect(releaseDelete).toBeTypeOf("function"));
|
||||
expect(agentCalls).toBe(1);
|
||||
|
||||
@@ -1767,7 +1781,7 @@ describe("subagent registry seam flow", () => {
|
||||
return request.method === "agent.wait" ? { status: "pending" } : {};
|
||||
});
|
||||
|
||||
mod.initSubagentRegistry();
|
||||
hydrateAndActivateRegistry();
|
||||
|
||||
await waitForFast(() => expect(persistenceCalls).toBeGreaterThanOrEqual(3));
|
||||
await concurrentSweep;
|
||||
@@ -1818,7 +1832,7 @@ describe("subagent registry seam flow", () => {
|
||||
agent: new Error("launch failed"),
|
||||
});
|
||||
|
||||
mod.initSubagentRegistry();
|
||||
hydrateAndActivateRegistry();
|
||||
|
||||
await waitForFast(() =>
|
||||
expect(mod.getSubagentRunByRunId("run-queued-failure")).toMatchObject({
|
||||
@@ -1905,7 +1919,7 @@ describe("subagent registry seam flow", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
mod.initSubagentRegistry();
|
||||
hydrateAndActivateRegistry();
|
||||
|
||||
await waitForFast(() =>
|
||||
expect(mod.getSubagentRunByRunId("run-queued-cleanup-retry")).toMatchObject({
|
||||
@@ -2079,9 +2093,7 @@ describe("subagent registry seam flow", () => {
|
||||
});
|
||||
|
||||
it("does not fall back to network recovery without an instance-bound runtime", async () => {
|
||||
mod.testing.setDepsForTest({
|
||||
getGatewayRecoveryRuntime: () => undefined,
|
||||
});
|
||||
mod.activateSubagentRegistry(() => undefined);
|
||||
|
||||
mod.scheduleSubagentRegistrySweep({ delayMs: 1 });
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
@@ -2789,7 +2801,7 @@ describe("subagent registry seam flow", () => {
|
||||
},
|
||||
});
|
||||
|
||||
mod.initSubagentRegistry();
|
||||
hydrateAndActivateRegistry();
|
||||
|
||||
await waitForFast(() => {
|
||||
const completedRun = findRequesterRun(runId);
|
||||
@@ -3088,7 +3100,7 @@ describe("subagent registry seam flow", () => {
|
||||
},
|
||||
);
|
||||
|
||||
mod.initSubagentRegistry();
|
||||
hydrateAndActivateRegistry();
|
||||
|
||||
await waitForFast(() => {
|
||||
expect(waitTimeouts).toEqual([1_000]);
|
||||
@@ -5774,7 +5786,7 @@ describe("subagent registry seam flow", () => {
|
||||
return 1;
|
||||
}) as never);
|
||||
|
||||
mod.initSubagentRegistry();
|
||||
hydrateAndActivateRegistry();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
@@ -5817,7 +5829,7 @@ describe("subagent registry seam flow", () => {
|
||||
return 1;
|
||||
}) as never);
|
||||
|
||||
mod.initSubagentRegistry();
|
||||
hydrateAndActivateRegistry();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import type { AgentWaitParams } from "../../../../packages/gateway-protocol/src/
|
||||
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
|
||||
import { callGateway } from "../../../gateway/call.js";
|
||||
import type { GatewayContextResolver } from "../../../gateway/server-methods/types.js";
|
||||
import { getGatewayRecoveryRuntime } from "../../../gateway/server-recovery-runtime-context.js";
|
||||
import { createSubsystemLogger } from "../../../logging/subsystem.js";
|
||||
import { bindGatewayContextResolver } from "../../../plugins/runtime/gateway-request-scope.js";
|
||||
import {
|
||||
isGatewayRestartDraining,
|
||||
runWithGatewayIndependentRootWorkAdmission,
|
||||
@@ -63,6 +63,7 @@ const subagentRegistryBootstrapState: {
|
||||
} = {};
|
||||
|
||||
const resumeRetryTimers = new Set<ReturnType<typeof setTimeout>>();
|
||||
let activeGatewayContextResolver: GatewayContextResolver | undefined;
|
||||
const SUBAGENT_ANNOUNCE_TIMEOUT_MS = 120_000;
|
||||
const GATEWAY_ADMISSION_RETRY_DELAY_MS = 1_000;
|
||||
/** Admission pressure for recoverable completion deliveries; rows are never pruned for capacity. */
|
||||
@@ -322,6 +323,7 @@ const subagentRestorer = createSubagentRegistryRestorer({
|
||||
runs: subagentRuns,
|
||||
resumedRuns,
|
||||
deps: () => subagentRegistryDeps,
|
||||
getGatewayContextResolver: () => activeGatewayContextResolver,
|
||||
persist: persistSubagentRuns,
|
||||
persistOrThrow: persistSubagentRunsOrThrow,
|
||||
settleRequesterTurn: settleRequesterTurnAfterSessionSpawns,
|
||||
@@ -351,7 +353,6 @@ const subagentRestorer = createSubagentRegistryRestorer({
|
||||
settleFailedQueuedSubagentLaunch: (runId, error) =>
|
||||
subagentRunManager.settleFailedQueuedSubagentLaunch(runId, error),
|
||||
completeCollectorLaunchCleanup: (runId) => publicApi.completeCollectorLaunchCleanup(runId),
|
||||
scheduleSweep: scheduleSubagentRegistrySweep,
|
||||
warn: (message, meta) => log.warn(message, meta),
|
||||
});
|
||||
|
||||
@@ -380,7 +381,7 @@ const subagentSweeper = createSubagentRegistrySweeper({
|
||||
clearPendingLifecycleTimeout,
|
||||
sweepPendingLifecycle: (now) => pendingLifecycle.sweepExpired(now),
|
||||
completeSubagentRunWithRecovery: completionRuntime.completeSubagentRunWithRecovery,
|
||||
getGatewayRecoveryRuntime: () => subagentRegistryDeps.getGatewayRecoveryRuntime(),
|
||||
getGatewayRecoveryRuntime: () => activeGatewayContextResolver?.()?.recoveryRuntime,
|
||||
abandonSubagentRestartRecoveryLaunch: (params) =>
|
||||
subagentRunManager.abandonSubagentRestartRecoveryLaunch(params),
|
||||
clearAcceptedSubagentRestartRecovery: (params) =>
|
||||
@@ -433,7 +434,7 @@ const subagentRunManager = createSubagentRunManager({
|
||||
persistOrThrow: persistSubagentRunsOrThrow,
|
||||
callGateway: async <T>(request: Parameters<typeof callGateway>[0]) => {
|
||||
if (request.method === "agent.wait") {
|
||||
const gatewayRuntime = getGatewayRecoveryRuntime();
|
||||
const gatewayRuntime = activeGatewayContextResolver?.()?.recoveryRuntime;
|
||||
if (gatewayRuntime) {
|
||||
// Registry waits are Gateway-owned lifecycle work. Keep them on the
|
||||
// owning instance when one exists; standalone processes authenticate normally.
|
||||
@@ -544,6 +545,7 @@ function resetSubagentRegistryForTests(opts?: { persist?: boolean }) {
|
||||
clearSubagentRunsReadCacheForTest();
|
||||
subagentSweeper.reset();
|
||||
subagentRestorer.reset();
|
||||
activeGatewayContextResolver = undefined;
|
||||
subagentListener.reset();
|
||||
if (opts?.persist !== false) {
|
||||
persistSubagentRuns();
|
||||
@@ -599,6 +601,15 @@ export function initSubagentRegistry() {
|
||||
}
|
||||
state.restorer.restoreOnce();
|
||||
}
|
||||
export function activateSubagentRegistry(resolveGatewayContext: GatewayContextResolver) {
|
||||
activeGatewayContextResolver = resolveGatewayContext;
|
||||
for (const entry of subagentRuns.values()) {
|
||||
bindGatewayContextResolver(entry, resolveGatewayContext);
|
||||
}
|
||||
subagentRestorer.activate();
|
||||
// Post-ready only: collector cleanup retains the canonical sessions.delete RPC owner.
|
||||
scheduleSubagentRegistrySweep();
|
||||
}
|
||||
export const settleRequesterAfterSessionSpawns = publicApi.settleRequesterAfterSessionSpawns;
|
||||
export const markRequesterTurnYielded = publicApi.markRequesterTurnYielded;
|
||||
|
||||
|
||||
@@ -121,7 +121,6 @@ describe("swarm tools integration", () => {
|
||||
return resultTextBySession.get(sessionKey) ?? "";
|
||||
}) as never,
|
||||
cleanupBrowserSessionsForLifecycleEnd: vi.fn(async () => undefined),
|
||||
getGatewayRecoveryRuntime: () => undefined,
|
||||
getRuntimeConfig: () => config,
|
||||
maybeWakeRequesterAfterAllChildrenSettled: vi.fn(async () => false),
|
||||
onAgentEvent: vi.fn(() => () => undefined) as never,
|
||||
|
||||
@@ -266,6 +266,7 @@ export async function finishGatewayStartup(params: {
|
||||
deps,
|
||||
startChannels,
|
||||
recoveryRuntime: gatewayInstanceRuntime.recovery,
|
||||
resolveGatewayContext: gatewayRequestContext.resolveGatewayContext!,
|
||||
logHooks,
|
||||
logChannels,
|
||||
unlockStartupMethods: kernel.unlockStartupMethods,
|
||||
|
||||
@@ -326,6 +326,15 @@ describe("prepareGatewayPluginBootstrap startup plugins", () => {
|
||||
expect(migrateLegacyNodePairingStore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hydrates the subagent registry before plugin bootstrap", async () => {
|
||||
await prepareBootstrapWithRuntimeConfig({});
|
||||
|
||||
expect(initSubagentRegistry).toHaveBeenCalledOnce();
|
||||
expect(initSubagentRegistry.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
loadPluginLookUpTable.mock.invocationCallOrder[0]!,
|
||||
);
|
||||
});
|
||||
|
||||
it("derives startup activation from source config instead of runtime plugin defaults", async () => {
|
||||
const sourceConfig = {
|
||||
channels: {
|
||||
|
||||
@@ -39,7 +39,7 @@ const hoisted = vi.hoisted(() => {
|
||||
}));
|
||||
const scheduleGatewayUpdateCheck = vi.fn(() => () => {});
|
||||
const logGatewayStartup = vi.fn();
|
||||
const scheduleSubagentRegistrySweep = vi.fn();
|
||||
const activateSubagentRegistry = vi.fn();
|
||||
const markStartupOrphanedMainSessionsForRecovery = vi.fn(async () => ({
|
||||
marked: 0,
|
||||
skipped: 0,
|
||||
@@ -102,7 +102,7 @@ const hoisted = vi.hoisted(() => {
|
||||
initializeGatewayUpdateStatus,
|
||||
scheduleGatewayUpdateCheck,
|
||||
logGatewayStartup,
|
||||
scheduleSubagentRegistrySweep,
|
||||
activateSubagentRegistry,
|
||||
markStartupOrphanedMainSessionsForRecovery,
|
||||
scheduleRestartAbortedMainSessionRecovery,
|
||||
scheduleRestartSentinelWake,
|
||||
@@ -133,7 +133,7 @@ vi.mock("../agents/session-dirs.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../agents/subagents/registry/subagent-registry.js", () => ({
|
||||
scheduleSubagentRegistrySweep: hoisted.scheduleSubagentRegistrySweep,
|
||||
activateSubagentRegistry: hoisted.activateSubagentRegistry,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/main-session-recovery/main-session-restart-recovery-marking.js", () => ({
|
||||
@@ -479,7 +479,7 @@ describe("startGatewayPostAttachRuntime", () => {
|
||||
hoisted.initializeGatewayUpdateStatus.mockClear();
|
||||
hoisted.scheduleGatewayUpdateCheck.mockClear();
|
||||
hoisted.logGatewayStartup.mockClear();
|
||||
hoisted.scheduleSubagentRegistrySweep.mockClear();
|
||||
hoisted.activateSubagentRegistry.mockClear();
|
||||
hoisted.markStartupOrphanedMainSessionsForRecovery.mockReset();
|
||||
hoisted.markStartupOrphanedMainSessionsForRecovery.mockResolvedValue({
|
||||
marked: 0,
|
||||
@@ -569,6 +569,7 @@ describe("startGatewayPostAttachRuntime", () => {
|
||||
|
||||
it("re-enables startup-gated methods after post-attach sidecars start", async () => {
|
||||
const unavailableGatewayMethods = new Set<string>(["chat.history", "models.list"]);
|
||||
const startupOrder: string[] = [];
|
||||
const methodsAtRecoveryRegistration: string[][] = [];
|
||||
const currentConfig = { agents: { list: [{ id: "main" }, { id: "work" }] } };
|
||||
hoisted.scheduleRestartAbortedMainSessionRecovery.mockImplementationOnce(
|
||||
@@ -577,14 +578,20 @@ describe("startGatewayPostAttachRuntime", () => {
|
||||
expect(params.getConfig()).toBe(currentConfig);
|
||||
},
|
||||
);
|
||||
const onSidecarsReady = vi.fn();
|
||||
const onSidecarsReady = vi.fn(() => startupOrder.push("ready"));
|
||||
hoisted.activateSubagentRegistry.mockImplementationOnce(() => {
|
||||
startupOrder.push("registry");
|
||||
});
|
||||
const log = { info: vi.fn(), warn: vi.fn() };
|
||||
|
||||
await startGatewayPostAttachRuntime({
|
||||
...createPostAttachParams(),
|
||||
getConfig: () => currentConfig,
|
||||
log,
|
||||
unlockStartupMethods: createStartupMethodUnlocker(unavailableGatewayMethods),
|
||||
unlockStartupMethods: () => {
|
||||
startupOrder.push("unlock");
|
||||
createStartupMethodUnlocker(unavailableGatewayMethods)();
|
||||
},
|
||||
onSidecarsReady,
|
||||
});
|
||||
|
||||
@@ -610,7 +617,8 @@ describe("startGatewayPostAttachRuntime", () => {
|
||||
waitForStart: undefined,
|
||||
gatewayRuntime: expect.any(Object),
|
||||
});
|
||||
expect(hoisted.scheduleSubagentRegistrySweep).toHaveBeenCalledWith();
|
||||
expect(hoisted.activateSubagentRegistry).toHaveBeenCalledWith(expect.any(Function));
|
||||
expect(startupOrder).toEqual(["unlock", "ready", "registry"]);
|
||||
expect(methodsAtRecoveryRegistration).toStrictEqual([["chat.history", "models.list"]]);
|
||||
});
|
||||
|
||||
@@ -3052,7 +3060,7 @@ describe("startGatewayPostAttachRuntime", () => {
|
||||
expect(startWorkerEnvironmentRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps startup methods fenced when close begins during late recovery loading", async () => {
|
||||
it("does not activate restored recovery when close begins during activation loading", async () => {
|
||||
let closeStarted = false;
|
||||
let releaseRecoveryLoad: (() => void) | undefined;
|
||||
const recoveryLoadReady = new Promise<void>((resolve) => {
|
||||
@@ -3067,7 +3075,7 @@ describe("startGatewayPostAttachRuntime", () => {
|
||||
const workerSidecar = { stop: vi.fn(async () => {}) };
|
||||
let ownedWorkerSidecar: typeof workerSidecar | undefined;
|
||||
const unlockStartupMethods = vi.fn();
|
||||
const scheduleSubagentRegistrySweep = vi.fn();
|
||||
const activateSubagentRegistry = vi.fn();
|
||||
const onPluginServices = vi.fn();
|
||||
const onGatewayLifetimeSidecars = vi.fn();
|
||||
const runtime = await startGatewayPostAttachRuntime(
|
||||
@@ -3091,10 +3099,10 @@ describe("startGatewayPostAttachRuntime", () => {
|
||||
return { pluginServices, postReadySidecars: [postReadySidecar] };
|
||||
},
|
||||
),
|
||||
loadSubagentRegistrySweep: vi.fn(async () => {
|
||||
loadSubagentRegistryActivation: vi.fn(async () => {
|
||||
markRecoveryLoadStarted?.();
|
||||
await recoveryLoadReady;
|
||||
return scheduleSubagentRegistrySweep;
|
||||
return activateSubagentRegistry;
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -3104,8 +3112,8 @@ describe("startGatewayPostAttachRuntime", () => {
|
||||
releaseRecoveryLoad?.();
|
||||
await expect(runtime.startupSettled).resolves.toBeUndefined();
|
||||
|
||||
expect(scheduleSubagentRegistrySweep).not.toHaveBeenCalled();
|
||||
expect(unlockStartupMethods).not.toHaveBeenCalled();
|
||||
expect(activateSubagentRegistry).not.toHaveBeenCalled();
|
||||
expect(unlockStartupMethods).toHaveBeenCalledOnce();
|
||||
expect(workerSidecar.stop).not.toHaveBeenCalled();
|
||||
expect(pluginServices.stop).toHaveBeenCalledOnce();
|
||||
expect(postReadySidecar.stop).toHaveBeenCalledOnce();
|
||||
@@ -3457,7 +3465,7 @@ function createPostAttachRuntimeDeps(
|
||||
scheduleGatewayUpdateCheck: hoisted.scheduleGatewayUpdateCheck,
|
||||
startGatewaySidecars: vi.fn(async () => ({ pluginServices: null, postReadySidecars: [] })),
|
||||
warmSystemCa: vi.fn(async () => {}),
|
||||
loadSubagentRegistrySweep: vi.fn(async () => hoisted.scheduleSubagentRegistrySweep),
|
||||
loadSubagentRegistryActivation: vi.fn(async () => hoisted.activateSubagentRegistry),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -3496,6 +3504,7 @@ function createPostAttachParams(overrides: Partial<PostAttachParams> = {}): Post
|
||||
waitForAgent: vi.fn(),
|
||||
sendRecoveryNotice: vi.fn(),
|
||||
},
|
||||
resolveGatewayContext: vi.fn(() => ({ recoveryRuntime: {} }) as never),
|
||||
logHooks: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
import type { GatewayBroadcastToConnIdsFn } from "./server-broadcast-types.js";
|
||||
import type { GatewayControlUiRootLifecycle } from "./server-control-ui-root.js";
|
||||
import type { GatewayRecoveryRuntime } from "./server-instance-runtime.types.js";
|
||||
import type { GatewayClient } from "./server-methods/shared-types.js";
|
||||
import type { GatewayClient, GatewayContextResolver } from "./server-methods/shared-types.js";
|
||||
import type { GatewayResidentRegistry } from "./server-resident-registry.js";
|
||||
import type { refreshLatestUpdateRestartSentinel } from "./server-restart-sentinel.js";
|
||||
import type { GatewaySidecarStartupMode } from "./server-sidecar-startup-mode.js";
|
||||
@@ -943,7 +943,9 @@ type GatewayPostAttachRuntimeDeps = {
|
||||
) => Awaitable<ReturnType<typeof scheduleGatewayUpdateCheck>>;
|
||||
startGatewaySidecars: typeof startGatewaySidecars;
|
||||
warmSystemCa: typeof warmMacOSSystemCaOffMainThread;
|
||||
loadSubagentRegistrySweep: () => Awaitable<() => void>;
|
||||
loadSubagentRegistryActivation: () => Awaitable<
|
||||
(resolveGatewayContext: GatewayContextResolver) => void
|
||||
>;
|
||||
};
|
||||
|
||||
const defaultGatewayPostAttachRuntimeDeps: GatewayPostAttachRuntimeDeps = {
|
||||
@@ -958,9 +960,8 @@ const defaultGatewayPostAttachRuntimeDeps: GatewayPostAttachRuntimeDeps = {
|
||||
(await import("../infra/update-startup.js")).scheduleGatewayUpdateCheck(...args),
|
||||
startGatewaySidecars,
|
||||
warmSystemCa: warmMacOSSystemCaOffMainThread,
|
||||
loadSubagentRegistrySweep: async () =>
|
||||
(await import("../agents/subagents/registry/subagent-registry.js"))
|
||||
.scheduleSubagentRegistrySweep,
|
||||
loadSubagentRegistryActivation: async () =>
|
||||
(await import("../agents/subagents/registry/subagent-registry.js")).activateSubagentRegistry,
|
||||
};
|
||||
|
||||
function createDeferredGatewayUpdateCheck(params: {
|
||||
@@ -1116,6 +1117,7 @@ export async function startGatewayPostAttachRuntime(
|
||||
startChannels: () => Promise<void>;
|
||||
refreshChatMetadata?: () => Promise<void>;
|
||||
recoveryRuntime: GatewayRecoveryRuntime;
|
||||
resolveGatewayContext: GatewayContextResolver;
|
||||
logHooks: {
|
||||
info: (msg: string) => void;
|
||||
warn: (msg: string) => void;
|
||||
@@ -1434,17 +1436,6 @@ export async function startGatewayPostAttachRuntime(
|
||||
],
|
||||
]);
|
||||
let mainSessionRecoverySidecar: GatewayPostReadySidecarHandle | undefined;
|
||||
try {
|
||||
const scheduleSubagentRegistrySweep = await runtimeDeps.loadSubagentRegistrySweep();
|
||||
if (params.isClosing?.() !== true) {
|
||||
scheduleSubagentRegistrySweep();
|
||||
}
|
||||
} catch (err) {
|
||||
params.log.warn(`subagent restart recovery failed to schedule: ${String(err)}`);
|
||||
}
|
||||
if (params.isClosing?.()) {
|
||||
return await stopStartupSidecars(mainSessionRecoverySidecar);
|
||||
}
|
||||
try {
|
||||
await startupLog;
|
||||
} catch (error) {
|
||||
@@ -1518,6 +1509,17 @@ export async function startGatewayPostAttachRuntime(
|
||||
];
|
||||
params.log.info(formatGatewayStartupOutcomes(startupOutcomes.snapshot()));
|
||||
params.onSidecarsReady?.();
|
||||
try {
|
||||
const activateSubagentRegistry = await runtimeDeps.loadSubagentRegistryActivation();
|
||||
if (params.isClosing?.() !== true) {
|
||||
activateSubagentRegistry(params.resolveGatewayContext);
|
||||
}
|
||||
} catch (err) {
|
||||
params.log.warn(`subagent restart recovery failed to activate: ${String(err)}`);
|
||||
}
|
||||
if (params.isClosing?.()) {
|
||||
return await stopStartupSidecars(mainSessionRecoverySidecar);
|
||||
}
|
||||
params.startupTrace?.detail("sidecars.ready", [
|
||||
[
|
||||
"loadedPluginCount",
|
||||
|
||||
Reference in New Issue
Block a user