openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
321 lines (320 loc) • 12.5 kB
JavaScript
import { i as formatErrorMessage } from "./errors-BXgSefBE.js";
import { t as createSubsystemLogger } from "./subsystem-BzXSmsuh.js";
import { f as resolveAgentIdFromSessionKey } from "./session-key-B_NoIfpX.js";
import { i as getRuntimeConfig } from "./io-Gi7-pyU-.js";
import "./config-C9RxTsn1.js";
import { o as callGateway } from "./call-B5-GYOlf.js";
import { u as resolveStorePath } from "./paths-NEwU8m3X.js";
import { t as loadSessionStore } from "./store-load-C4uq6Lrq.js";
import { d as updateSessionStore } from "./store-Qsgtu-0y.js";
import "./sessions-D6zZvoxX.js";
import { d as readSessionMessagesAsync } from "./session-utils.fs-D_8-X4jl.js";
import { a as markSubagentRecoveryAttempt, n as evaluateSubagentRecoveryGate, o as markSubagentRecoveryWedged } from "./subagent-recovery-state-B4lLUF84.js";
import { r as resolveInternalSessionEffectsTranscriptPath } from "./internal-session-effects-D37ZLYJ5.js";
import { n as finalizeInterruptedSubagentRun, r as replaceSubagentRunAfterSteer } from "./subagent-registry-steer-runtime-B8p8-E6G.js";
import crypto from "node:crypto";
//#region src/agents/subagent-orphan-recovery.ts
/**
* Post-restart interrupted-run resume for subagent sessions.
*
* After a SIGUSR1 gateway reload aborts in-flight subagent LLM calls,
* this module scans for interrupted sessions (those with `abortedLastRun: true`
* that are still tracked as active in the subagent registry) and sends a
* synthetic resume message to restart their work. Parent notification is handled
* separately by completion delivery after the child reaches a terminal result.
*
* @see https://github.com/openclaw/openclaw/issues/47711
*/
const log = createSubsystemLogger("subagent-interrupted-resume");
/** Delay before attempting recovery to let the gateway finish bootstrapping. */
const DEFAULT_RECOVERY_DELAY_MS = 5e3;
function isLegacyRestartInterruptedTimeout(runRecord, entry) {
return entry?.abortedLastRun === true && runRecord.outcome?.status === "timeout" && typeof runRecord.endedAt === "number" && runRecord.endedAt > 0;
}
function reclassifyLegacyRestartInterruptedRun(runRecord) {
const interruptedAt = runRecord.endedAt;
runRecord.execution = {
...runRecord.execution,
status: "interrupted",
interruptedAt,
interruptionReason: "gateway-restart",
endedAt: void 0,
outcome: void 0
};
runRecord.endedAt = void 0;
runRecord.endedReason = void 0;
runRecord.outcome = void 0;
}
/**
* Build the resume message for an orphaned subagent.
*/
function buildResumeMessage(task, lastHumanMessage) {
const maxTaskLen = 2e3;
let message = `[System] Your previous turn was interrupted by a gateway reload. Your original task was:\n\n${task.length > maxTaskLen ? `${task.slice(0, maxTaskLen)}...` : task}\n\n`;
if (lastHumanMessage) message += `The last message from the user before the interruption was:\n\n${lastHumanMessage}\n\n`;
message += `Please continue where you left off.`;
return message;
}
function extractMessageText(msg) {
if (!msg || typeof msg !== "object") return;
const m = msg;
if (typeof m.content === "string") return m.content;
if (Array.isArray(m.content)) return m.content.filter((c) => typeof c === "object" && c !== null && c.type === "text" && typeof c.text === "string").map((c) => c.text).filter(Boolean).join("\n") || void 0;
}
/**
* Send a resume message to an orphaned subagent session via the gateway agent method.
*/
async function resumeOrphanedSession(params) {
let resumeMessage = buildResumeMessage(params.task, params.lastHumanMessage);
if (params.configChangeHint) resumeMessage += params.configChangeHint;
try {
const idempotencyKey = crypto.randomUUID();
const result = await callGateway({
method: "agent",
params: {
message: resumeMessage,
sessionKey: params.sessionKey,
idempotencyKey,
deliver: false,
lane: "subagent",
inputProvenance: {
kind: "inter_session",
sourceSessionKey: params.originalRun.requesterSessionKey,
sourceChannel: "internal",
sourceTool: "subagent_interrupted_resume"
},
sessionEffects: "internal",
suppressPromptPersistence: true
},
timeoutMs: 1e4
});
if (!replaceSubagentRunAfterSteer({
previousRunId: params.originalRunId,
nextRunId: result.runId,
fallback: params.originalRun,
transcriptFile: resolveInternalSessionEffectsTranscriptPath(result.runId)
})) {
log.warn(`resumed orphaned session ${params.sessionKey} but remap failed (old run already removed); treating resume as accepted to avoid duplicate restarts`);
return { resumed: true };
}
log.info(`resumed orphaned session: ${params.sessionKey}`);
return { resumed: true };
} catch (err) {
const error = formatErrorMessage(err);
log.warn(`failed to resume orphaned session ${params.sessionKey}: ${error}`);
return {
resumed: false,
error
};
}
}
/**
* Scan for and resume orphaned subagent sessions after a gateway restart.
*
* An orphaned session is one where:
* 1. It has an active (not ended) entry in the subagent run registry
* 2. Its session store entry has `abortedLastRun: true`
*
* For each orphaned session found, we:
* 1. Clear the `abortedLastRun` flag
* 2. Send a synthetic resume message to trigger a new LLM turn
*/
async function recoverOrphanedSubagentSessions(params) {
const result = {
recovered: 0,
failed: 0,
skipped: 0,
failedRuns: []
};
const resumedSessionKeys = params.resumedSessionKeys ?? /* @__PURE__ */ new Set();
const configChangePattern = /openclaw\.json|openclaw gateway restart|config\.patch/i;
try {
const activeRuns = params.getActiveRuns();
if (activeRuns.size === 0) return result;
const cfg = getRuntimeConfig();
const storeCache = /* @__PURE__ */ new Map();
for (const [runId, runRecord] of activeRuns.entries()) {
const childSessionKey = runRecord.childSessionKey?.trim();
if (!childSessionKey) continue;
const now = Date.now();
if (resumedSessionKeys.has(childSessionKey)) {
result.skipped++;
continue;
}
try {
const agentId = resolveAgentIdFromSessionKey(childSessionKey);
const storePath = resolveStorePath(cfg.session?.store, { agentId });
let store = storeCache.get(storePath);
if (!store) {
store = loadSessionStore(storePath);
storeCache.set(storePath, store);
}
const entry = store[childSessionKey];
if (!entry) {
result.skipped++;
continue;
}
if (isLegacyRestartInterruptedTimeout(runRecord, entry)) reclassifyLegacyRestartInterruptedRun(runRecord);
if (typeof runRecord.endedAt === "number" && runRecord.endedAt > 0) {
result.skipped++;
continue;
}
if (!entry.abortedLastRun) {
result.skipped++;
continue;
}
const recoveryGate = evaluateSubagentRecoveryGate(entry, now);
if (!recoveryGate.allowed) {
if (recoveryGate.shouldMarkWedged) try {
await updateSessionStore(storePath, (currentStore) => {
const current = currentStore[childSessionKey];
if (current) {
markSubagentRecoveryWedged({
entry: current,
now,
runId,
reason: recoveryGate.reason
});
currentStore[childSessionKey] = current;
}
});
markSubagentRecoveryWedged({
entry,
now,
runId,
reason: recoveryGate.reason
});
} catch (err) {
log.warn(`failed to persist wedged subagent recovery marker for ${childSessionKey}: ${String(err)}`);
}
log.warn(`skipping orphan recovery for ${childSessionKey}: ${recoveryGate.reason}`);
result.skipped++;
result.failedRuns.push({
runId,
childSessionKey,
error: recoveryGate.reason
});
continue;
}
log.info(`found orphaned subagent session: ${childSessionKey} (run=${runId})`);
const messages = await readSessionMessagesAsync(entry.sessionId, storePath, entry.sessionFile, {
mode: "recent",
maxMessages: 200,
maxBytes: 1024 * 1024
});
const lastHumanMessage = [...messages].toReversed().find((msg) => msg?.role === "user");
const configChangeDetected = messages.some((msg) => {
if (msg?.role !== "assistant") return false;
const text = extractMessageText(msg);
return typeof text === "string" && configChangePattern.test(text);
});
const resumeResult = await resumeOrphanedSession({
sessionKey: childSessionKey,
task: runRecord.task,
lastHumanMessage: extractMessageText(lastHumanMessage),
configChangeHint: configChangeDetected ? "\n\n[config changes from your previous run were already applied — do not re-modify openclaw.json or restart the gateway]" : void 0,
originalRunId: runId,
originalRun: runRecord
});
if (resumeResult.resumed) {
resumedSessionKeys.add(childSessionKey);
try {
await updateSessionStore(storePath, (currentStore) => {
const current = currentStore[childSessionKey];
if (current) {
current.abortedLastRun = false;
markSubagentRecoveryAttempt({
entry: current,
now: Date.now(),
runId,
attempt: recoveryGate.nextAttempt
});
current.updatedAt = Date.now();
currentStore[childSessionKey] = current;
}
});
} catch (err) {
log.warn(`resume succeeded but failed to update session store for ${childSessionKey}: ${String(err)}`);
}
result.recovered++;
} else {
log.warn(`resume failed for ${childSessionKey}; abortedLastRun flag preserved for retry on next restart`);
result.failed++;
result.failedRuns.push({
runId,
childSessionKey,
error: resumeResult.error
});
}
} catch (err) {
const error = formatErrorMessage(err);
log.warn(`error processing orphaned session ${childSessionKey}: ${error}`);
result.failed++;
result.failedRuns.push({
runId,
childSessionKey,
error
});
}
}
} catch (err) {
log.warn(`orphan recovery scan failed: ${String(err)}`);
if (result.failed === 0) result.failed = 1;
}
if (result.recovered > 0 || result.failed > 0) log.info(`orphan recovery complete: recovered=${result.recovered} failed=${result.failed} skipped=${result.skipped}`);
return result;
}
/** Maximum number of retry attempts for orphan recovery. */
const MAX_RECOVERY_RETRIES = 3;
/** Backoff multiplier between retries (exponential). */
const RETRY_BACKOFF_MULTIPLIER = 2;
function buildRecoveryFailureMessage(params) {
const base = `Subagent run was interrupted by a gateway restart or connection loss. Automatic recovery failed after ${params.attempts} attempt${params.attempts === 1 ? "" : "s"}. Please retry.`;
const detail = params.error?.trim();
if (!detail) return base;
return `${base} (${detail})`;
}
/**
* Schedule orphan recovery after a delay, with retry logic.
* The delay gives the gateway time to fully bootstrap after restart.
* If recovery fails (e.g. gateway not yet ready), retries with exponential backoff.
*/
function scheduleOrphanRecovery(params) {
const initialDelay = params.delayMs ?? DEFAULT_RECOVERY_DELAY_MS;
const maxRetries = params.maxRetries ?? MAX_RECOVERY_RETRIES;
const resumedSessionKeys = /* @__PURE__ */ new Set();
const attemptRecovery = (attempt, delay) => {
setTimeout(() => {
recoverOrphanedSubagentSessions({
...params,
resumedSessionKeys
}).then((result) => {
if (result.failed > 0 && attempt < maxRetries) {
const nextDelay = delay * RETRY_BACKOFF_MULTIPLIER;
log.info(`orphan recovery had ${result.failed} failure(s); retrying in ${nextDelay}ms (attempt ${attempt + 1}/${maxRetries})`);
attemptRecovery(attempt + 1, nextDelay);
return;
}
if (result.failedRuns.length === 0) return;
const attempts = attempt + 1;
Promise.allSettled(result.failedRuns.map((run) => finalizeInterruptedSubagentRun({
runId: run.runId,
childSessionKey: run.childSessionKey,
error: buildRecoveryFailureMessage({
attempts,
error: run.error
})
})));
}).catch((err) => {
if (attempt < maxRetries) {
const nextDelay = delay * RETRY_BACKOFF_MULTIPLIER;
log.warn(`scheduled orphan recovery failed: ${String(err)}; retrying in ${nextDelay}ms (attempt ${attempt + 1}/${maxRetries})`);
attemptRecovery(attempt + 1, nextDelay);
} else log.warn(`scheduled orphan recovery failed after ${maxRetries} retries: ${String(err)}`);
});
}, delay).unref?.();
};
attemptRecovery(0, initialDelay);
}
//#endregion
export { scheduleOrphanRecovery };