refactor(agents): split main session recovery (#113687)

This commit is contained in:
Peter Steinberger
2026-07-25 06:39:15 -07:00
committed by GitHub
parent 46a1338918
commit 1dcb35b204
13 changed files with 2495 additions and 2390 deletions
-1
View File
@@ -462,7 +462,6 @@ src/agents/harness/native-hook-relay.test.ts
src/agents/harness/selection.test.ts
src/agents/harness/selection.ts
src/agents/main-session-restart-recovery.test.ts
src/agents/main-session-restart-recovery.ts
src/agents/model-auth-availability.ts
src/agents/model-auth.profiles.test.ts
src/agents/model-auth.test.ts
+3 -1
View File
@@ -186,7 +186,9 @@ export const migratedSessionAccessorWriteFiles = new Set([
"src/agents/embedded-agent-subscribe.handlers.compaction.runtime.ts",
"src/agents/embedded-agent-runner/run/attempt.ts",
"src/agents/live-model-switch.ts",
"src/agents/main-session-restart-recovery.ts",
"src/agents/main-session-restart-recovery-checkpoint.ts",
"src/agents/main-session-restart-recovery-marking.ts",
"src/agents/main-session-restart-recovery-store.ts",
"src/agents/session-suspension.ts",
"src/auto-reply/reply/abort.ts",
"src/agents/subagent-control.ts",
@@ -70,7 +70,7 @@ const gatewaySessionServerMethodFiles = [
];
export const migratedSessionTranscriptReaderFiles = new Set([
"src/agents/main-session-restart-recovery.ts",
"src/agents/main-session-restart-recovery-store.ts",
"src/agents/subagent-announce-output.test.ts",
"src/agents/subagent-announce-output.ts",
"src/agents/subagent-announce.runtime.ts",
@@ -0,0 +1,521 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { InternalSessionEntry as SessionEntry } from "../config/sessions.js";
import { buildRestartRecoveryClaimCleanupPatch } from "../config/sessions/restart-recovery-state.js";
import {
applySessionEntryReplacements,
persistSessionTranscriptTurn,
type SessionTranscriptTurnExpectedState,
type SessionTranscriptTurnLifecyclePatch,
updateSessionEntry,
} from "../config/sessions/session-accessor.js";
import {
hasInterSessionUserProvenance,
isCompletionReportInputProvenance,
} from "../sessions/input-provenance.js";
import { buildRunUserTurnIdempotencyKey } from "../sessions/user-turn-transcript.js";
import { isAnnounceRunId } from "./announce-idempotency.js";
import {
getTranscriptMessageRole as getMessageRole,
isTerminalSilentAssistantMessage,
readTerminalSourceReplyDeliveryMirror,
} from "./embedded-agent-runner/message-visibility.js";
import { buildMainSessionRecoveryClearPatch } from "./main-session-recovery-clear.js";
import { isRestartAbortTailArtifact } from "./main-session-restart-recovery-resume-policy.js";
import { log } from "./main-session-restart-recovery-shared.js";
export function hasOnlyAnnounceRecoveryRuns(entry: SessionEntry): boolean {
const runs = entry.restartRecoveryRuns;
return Boolean(runs?.length && runs.every((run) => isAnnounceRunId(run.runId)));
}
export function hasCompletionReportUserTail(messages: readonly unknown[]): boolean {
const message = messages.findLast((candidate) => getMessageRole(candidate) === "user");
if (!message || typeof message !== "object") {
return false;
}
const userMessage = message as { role?: unknown; provenance?: unknown };
return (
hasInterSessionUserProvenance(userMessage) &&
isCompletionReportInputProvenance(userMessage.provenance)
);
}
export async function reconcileInterruptedCompletionReport(params: {
entry: SessionEntry;
source: "announce_runs" | "transcript";
storePath: string;
sessionKey: string;
}): Promise<{ outcome: "reconciled" } | { outcome: "changed"; entry: SessionEntry | null }> {
let didReconcile = false;
const current = await updateSessionEntry(
{ sessionKey: params.sessionKey, storePath: params.storePath },
(entry) => {
const hasRecoveryRuns = Boolean(entry.restartRecoveryRuns?.length);
const stillMatchesSource =
params.source === "announce_runs" ? hasOnlyAnnounceRecoveryRuns(entry) : !hasRecoveryRuns;
if (
entry.sessionId !== params.entry.sessionId ||
entry.status !== "running" ||
entry.abortedLastRun !== true ||
!stillMatchesSource
) {
return null;
}
didReconcile = true;
const endedAt = Date.now();
return {
...buildRestartRecoveryClaimCleanupPatch({ entry, recordTerminalSource: false }),
...buildMainSessionRecoveryClearPatch(entry),
status: "killed",
abortedLastRun: false,
endedAt,
lastRunError: undefined,
runtimeMs:
typeof entry.startedAt === "number" ? Math.max(0, endedAt - entry.startedAt) : undefined,
updatedAt: endedAt,
};
},
{ requireWriteSuccess: true },
);
if (didReconcile) {
log.info(`reconciled interrupted completion report to non-running: ${params.sessionKey}`);
return { outcome: "reconciled" };
}
return { outcome: "changed", entry: current };
}
function findSourceTurnRange(params: {
continuationRunId?: string;
messages: readonly unknown[];
sourceTurnId: string;
}): { startIndex: number; endIndex: number } | undefined {
const sourceUserTurnId = buildRunUserTurnIdempotencyKey(params.sourceTurnId);
const sourceTurnIds = new Set([params.sourceTurnId, sourceUserTurnId]);
const continuationTurnId = params.continuationRunId
? buildRunUserTurnIdempotencyKey(params.continuationRunId)
: undefined;
for (let index = params.messages.length - 1; index >= 0; index -= 1) {
const message = params.messages[index];
if (
getMessageRole(message) === "user" &&
message &&
typeof message === "object" &&
sourceTurnIds.has(
normalizeOptionalString((message as { idempotencyKey?: unknown }).idempotencyKey) ?? "",
)
) {
let endIndex = params.messages.length;
for (let nextIndex = index + 1; nextIndex < params.messages.length; nextIndex += 1) {
const nextMessage = params.messages[nextIndex];
if (getMessageRole(nextMessage) !== "user") {
continue;
}
const nextIdempotencyKey =
nextMessage && typeof nextMessage === "object"
? normalizeOptionalString((nextMessage as { idempotencyKey?: unknown }).idempotencyKey)
: undefined;
// Late media and the exact restart continuation extend the same logical source turn.
if (
nextIdempotencyKey === `${params.sourceTurnId}:late-media` ||
nextIdempotencyKey === continuationTurnId ||
(continuationTurnId !== undefined &&
nextIdempotencyKey === `${continuationTurnId}:late-media`)
) {
continue;
}
endIndex = nextIndex;
break;
}
return { startIndex: index, endIndex };
}
}
return undefined;
}
function readToolCallId(message: Record<string, unknown>): string | undefined {
return [
message.toolCallId,
message.toolUseId,
message.tool_call_id,
message.tool_use_id,
message.callId,
message.call_id,
]
.map(normalizeOptionalString)
.find(Boolean);
}
function findMessageToolCallIndexInSourceTurn(params: {
messages: readonly unknown[];
sourceTurnRange: { startIndex: number; endIndex: number };
toolCallId: string;
}): number | undefined {
for (
let index = params.sourceTurnRange.endIndex - 1;
index > params.sourceTurnRange.startIndex;
index -= 1
) {
const message = params.messages[index];
if (!message || typeof message !== "object" || getMessageRole(message) !== "assistant") {
continue;
}
const content = (message as { content?: unknown }).content;
if (!Array.isArray(content)) {
continue;
}
const matched = content.some((block) => {
if (!block || typeof block !== "object") {
return false;
}
const record = block as Record<string, unknown>;
const type = normalizeOptionalString(record.type);
return (
(type === "toolCall" || type === "toolUse" || type === "tool_use") &&
normalizeOptionalString(record.id) === params.toolCallId &&
normalizeOptionalString(record.name) === "message"
);
});
if (matched) {
return index;
}
}
return undefined;
}
function hasSiblingAssistantToolCalls(message: unknown): boolean {
if (!message || typeof message !== "object" || getMessageRole(message) !== "assistant") {
return true;
}
const content = (message as { content?: unknown }).content;
if (!Array.isArray(content)) {
return true;
}
let toolCallCount = 0;
for (const block of content) {
if (!block || typeof block !== "object") {
continue;
}
const type = normalizeOptionalString((block as { type?: unknown }).type);
if (type === "toolCall" || type === "toolUse" || type === "tool_use") {
toolCallCount += 1;
}
}
return toolCallCount !== 1;
}
function isSuccessfulMessageToolResult(message: unknown, toolCallId: string): boolean {
const role = getMessageRole(message);
if (!message || typeof message !== "object" || (role !== "tool" && role !== "toolResult")) {
return false;
}
const record = message as Record<string, unknown>;
return (
readToolCallId(record) === toolCallId &&
normalizeOptionalString(record.toolName) === "message" &&
record.isError !== true
);
}
function findSuccessfulMessageToolResultIndex(params: {
messages: readonly unknown[];
sourceTurnRange: { startIndex: number; endIndex: number };
toolCallId: string;
toolCallIndex: number;
}): number | undefined {
for (let index = params.toolCallIndex + 1; index < params.sourceTurnRange.endIndex; index += 1) {
if (isSuccessfulMessageToolResult(params.messages[index], params.toolCallId)) {
return index;
}
}
return undefined;
}
function isSafeTerminalDeliveryTailMessage(params: {
message: unknown;
sourceTurnId: string;
toolCallId: string;
}): boolean {
const mirror = readTerminalSourceReplyDeliveryMirror(params.message);
if (mirror?.sourceTurnId === params.sourceTurnId && mirror.toolCallId === params.toolCallId) {
return true;
}
// An empty provider abort is restart lifecycle noise. Partial output remains unsafe.
return isRestartAbortTailArtifact(params.message);
}
function canReconcileTerminalDeliveryAtSourceTurnTail(params: {
messages: readonly unknown[];
sourceTurnId: string;
sourceTurnRange: { startIndex: number; endIndex: number };
toolCallId: string;
toolCallIndex: number;
successfulToolResultIndex?: number;
}): boolean {
if (params.sourceTurnRange.endIndex !== params.messages.length) {
return false;
}
for (
let messageIndex = params.toolCallIndex + 1;
messageIndex < params.sourceTurnRange.endIndex;
messageIndex += 1
) {
if (messageIndex === params.successfulToolResultIndex) {
continue;
}
const message = params.messages[messageIndex];
if (
params.successfulToolResultIndex !== undefined &&
messageIndex > params.successfulToolResultIndex &&
messageIndex === params.sourceTurnRange.endIndex - 1 &&
isTerminalSilentAssistantMessage(message)
) {
continue;
}
if (
isSafeTerminalDeliveryTailMessage({
message,
sourceTurnId: params.sourceTurnId,
toolCallId: params.toolCallId,
})
) {
continue;
}
return false;
}
return true;
}
function buildRecoveryToolResultIdempotencyKey(sourceTurnId: string, toolCallId: string): string {
return `restart-recovery:message-tool-result:${sourceTurnId}:${toolCallId}`;
}
type RecoveryCheckpointCompletion =
| { outcome: "completed" }
| { outcome: "changed" }
| { outcome: "unsafe-transcript"; reason: string };
export async function markSessionCompletedAfterRecoveryCheckpoint(params: {
agentId: string;
entry: SessionEntry;
messages: readonly unknown[];
reason: "delivered-terminal" | "delivered-terminal-receipt" | "handled-silent";
storePath: string;
sessionKey: string;
sourceTurnId?: string;
toolCallId?: string;
}): Promise<RecoveryCheckpointCompletion> {
const expectedRecoveryRunId = normalizeOptionalString(params.entry.restartRecoveryDeliveryRunId);
const expectedRecoverySourceRunId = normalizeOptionalString(
params.entry.restartRecoveryDeliverySourceRunId,
);
const endedAt = Date.now();
const lifecyclePatch: SessionTranscriptTurnLifecyclePatch = {
...buildRestartRecoveryClaimCleanupPatch({
entry: params.entry,
recordTerminalSource: expectedRecoverySourceRunId !== undefined,
terminalSourceRunId: expectedRecoverySourceRunId,
}),
abortedLastRun: false,
endedAt,
pendingFinalDelivery: undefined,
pendingFinalDeliveryText: undefined,
pendingFinalDeliveryCreatedAt: undefined,
pendingFinalDeliveryLastAttemptAt: undefined,
pendingFinalDeliveryAttemptCount: undefined,
pendingFinalDeliveryLastError: undefined,
pendingFinalDeliveryContext: undefined,
pendingFinalDeliveryIntentId: undefined,
restartRecoveryForceSafeTools: undefined,
restartRecoveryRuns: undefined,
runtimeMs:
typeof params.entry.startedAt === "number"
? Math.max(0, endedAt - params.entry.startedAt)
: undefined,
status: "done",
updatedAt: endedAt,
};
const sourceTurnId = normalizeOptionalString(params.sourceTurnId);
if (params.reason === "handled-silent" && !sourceTurnId) {
return {
outcome: "unsafe-transcript",
reason: "handled silent checkpoint lacks its durable source turn",
};
}
const sourceTurnRange = sourceTurnId
? findSourceTurnRange({
continuationRunId: expectedRecoveryRunId,
messages: params.messages,
sourceTurnId,
})
: undefined;
const toolCallId = normalizeOptionalString(params.toolCallId);
if (sourceTurnId && sourceTurnRange === undefined) {
return {
outcome: "unsafe-transcript",
reason: "recovery checkpoint cannot be matched to its durable source turn",
};
}
if (sourceTurnRange && sourceTurnRange.endIndex !== params.messages.length) {
return {
outcome: "unsafe-transcript",
reason: "recovery checkpoint belongs to an earlier transcript turn",
};
}
if (toolCallId && !sourceTurnId) {
return {
outcome: "unsafe-transcript",
reason: "terminal delivery lacks its durable source turn",
};
}
const messageToolCallIndex =
toolCallId && sourceTurnRange
? findMessageToolCallIndexInSourceTurn({
messages: params.messages,
sourceTurnRange,
toolCallId,
})
: undefined;
if (toolCallId && messageToolCallIndex === undefined) {
return {
outcome: "unsafe-transcript",
reason: "terminal delivery cannot be matched to its message tool call",
};
}
if (
messageToolCallIndex !== undefined &&
hasSiblingAssistantToolCalls(params.messages[messageToolCallIndex])
) {
return {
outcome: "unsafe-transcript",
reason: "terminal message tool call has sibling tool work",
};
}
const recoveryToolResultIdempotencyKey =
toolCallId && sourceTurnId
? buildRecoveryToolResultIdempotencyKey(sourceTurnId, toolCallId)
: undefined;
const successfulToolResultIndex =
toolCallId && sourceTurnRange && messageToolCallIndex !== undefined
? findSuccessfulMessageToolResultIndex({
messages: params.messages,
sourceTurnRange,
toolCallId,
toolCallIndex: messageToolCallIndex,
})
: undefined;
if (
toolCallId &&
sourceTurnId &&
sourceTurnRange !== undefined &&
messageToolCallIndex !== undefined &&
!canReconcileTerminalDeliveryAtSourceTurnTail({
messages: params.messages,
sourceTurnId,
sourceTurnRange,
toolCallId,
toolCallIndex: messageToolCallIndex,
successfulToolResultIndex,
})
) {
return {
outcome: "unsafe-transcript",
reason:
successfulToolResultIndex === undefined
? "terminal delivery would require an out-of-order transcript repair"
: "terminal delivery result is followed by unfinished transcript work",
};
}
if (
toolCallId &&
sourceTurnId &&
sourceTurnRange !== undefined &&
messageToolCallIndex !== undefined &&
recoveryToolResultIdempotencyKey &&
successfulToolResultIndex === undefined
) {
const expectedSessionState: SessionTranscriptTurnExpectedState = {
abortedLastRun: params.entry.abortedLastRun,
restartRecoveryBeforeAgentReplyState: params.entry.restartRecoveryBeforeAgentReplyState,
restartRecoveryDeliveryReceiptState: params.entry.restartRecoveryDeliveryReceiptState,
restartRecoveryDeliveryToolCallId: params.entry.restartRecoveryDeliveryToolCallId,
restartRecoveryDeliveryRequestFingerprint:
params.entry.restartRecoveryDeliveryRequestFingerprint,
restartRecoveryDeliveryRunId: params.entry.restartRecoveryDeliveryRunId,
restartRecoveryDeliverySourceRunId: params.entry.restartRecoveryDeliverySourceRunId,
restartRecoveryRequesterAccountId: params.entry.restartRecoveryRequesterAccountId,
restartRecoveryRequesterSenderId: params.entry.restartRecoveryRequesterSenderId,
restartRecoverySameChannelThreadRequired:
params.entry.restartRecoverySameChannelThreadRequired,
restartRecoverySourceIngress: params.entry.restartRecoverySourceIngress,
restartRecoverySourceReplyDeliveryMode: params.entry.restartRecoverySourceReplyDeliveryMode,
restartRecoveryTerminalRunIds: params.entry.restartRecoveryTerminalRunIds,
status: params.entry.status,
updatedAt: params.entry.updatedAt,
};
const persisted = await persistSessionTranscriptTurn(
{
agentId: params.agentId,
sessionId: params.entry.sessionId,
sessionKey: params.sessionKey,
storePath: params.storePath,
},
{
expectedSessionId: params.entry.sessionId,
expectedSessionState,
messages: [
{
idempotencyLookup: "scan",
message: {
role: "toolResult",
toolCallId,
toolName: "message",
content: [{ type: "text", text: "Message delivered before gateway restart." }],
idempotencyKey: recoveryToolResultIdempotencyKey,
isError: false,
timestamp: endedAt,
},
},
],
sessionLifecyclePatch: lifecyclePatch,
updateMode: "none",
},
);
const completed = persisted.sessionEntry?.status === "done";
if (completed) {
log.info(`reconciled delivered terminal reply after restart: ${params.sessionKey}`);
}
return { outcome: completed ? "completed" : "changed" };
}
const marked = await applySessionEntryReplacements({
sessionKeys: [params.sessionKey],
storePath: params.storePath,
update: (entries) => {
const current = entries.find((candidate) => candidate.sessionKey === params.sessionKey);
const entry = current?.entry;
if (
!entry ||
entry.sessionId !== params.entry.sessionId ||
entry.status !== "running" ||
entry.abortedLastRun !== true ||
normalizeOptionalString(entry.restartRecoveryDeliveryRunId) !== expectedRecoveryRunId ||
normalizeOptionalString(entry.restartRecoveryDeliverySourceRunId) !==
expectedRecoverySourceRunId
) {
return { result: false };
}
Object.assign(entry, lifecyclePatch);
return {
result: true,
replacements: [{ sessionKey: params.sessionKey, entry }],
};
},
});
if (marked) {
log.info(
params.reason === "delivered-terminal" || params.reason === "delivered-terminal-receipt"
? `reconciled delivered terminal reply after restart: ${params.sessionKey}`
: `reconciled handled silent reply after restart: ${params.sessionKey}`,
);
}
return { outcome: marked ? "completed" : "changed" };
}
@@ -0,0 +1,376 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
import {
type InternalSessionEntry as SessionEntry,
type RestartRecoveryRun,
resolveAllAgentSessionStoreTargetsSync,
resolveSessionFilePath,
resolveSessionTranscriptPathInDir,
} from "../config/sessions.js";
import { applySessionEntryReplacements } from "../config/sessions/session-accessor.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveGatewaySessionStoreTarget } from "../gateway/session-utils.js";
import {
getAgentEventLifecycleGeneration,
listAgentRunsForSession,
} from "../infra/agent-events.js";
import {
listActiveEmbeddedRunSessionIds,
listActiveEmbeddedRunSessionKeys,
} from "./embedded-agent-runner/run-state.js";
import { transitionMainSessionRecovery } from "./main-session-recovery-state.js";
import {
hasCurrentProcessOwner,
log,
normalizeFiniteTimestamp,
normalizeStringSet,
resolveRestartRecoveryStorePaths,
shouldSkipMainRecovery,
} from "./main-session-restart-recovery-shared.js";
import { resolveAgentSessionDirs } from "./session-dirs.js";
import type { SessionLockInspection } from "./session-write-lock.js";
function normalizeTranscriptLockPath(lockPath: string): string | undefined {
const trimmed = lockPath.trim();
if (!path.basename(trimmed).endsWith(".jsonl.lock")) {
return undefined;
}
const resolved = path.resolve(trimmed);
try {
return path.join(fs.realpathSync(path.dirname(resolved)), path.basename(resolved));
} catch {
return resolved;
}
}
function resolveEntryTranscriptLockPaths(params: {
entry: SessionEntry;
sessionsDir: string;
}): string[] {
const paths = new Set<string>();
const push = (resolvePath: () => string) => {
try {
paths.add(path.resolve(`${resolvePath()}.lock`));
} catch {
// Keep restart recovery best-effort when session metadata is stale.
}
};
push(() =>
resolveSessionFilePath(params.entry.sessionId, params.entry, {
sessionsDir: params.sessionsDir,
}),
);
push(() => resolveSessionTranscriptPathInDir(params.entry.sessionId, params.sessionsDir));
return [...paths];
}
export async function markRestartAbortedMainSessions(params: {
cfg?: OpenClawConfig;
additionalCfgs?: Iterable<OpenClawConfig | undefined>;
stateDir?: string;
sessionKeys?: Iterable<string>;
sessionIds?: Iterable<string>;
activeRuns?: Iterable<
RestartRecoveryRun & {
sessionKey: string;
sessionId: string;
observedAt?: number;
}
>;
isActiveRun?: (
run: RestartRecoveryRun & {
sessionKey: string;
sessionId: string;
observedAt?: number;
},
) => boolean;
reason?: string;
}): Promise<{ marked: number; skipped: number }> {
const sessionKeys = normalizeStringSet(params.sessionKeys);
const sessionIds = normalizeStringSet(params.sessionIds);
const preferSessionIdMatch = sessionIds.size > 0;
const activeRuns = [...(params.activeRuns ?? [])]
.map((run) => ({
runId: run.runId.trim(),
lifecycleGeneration: run.lifecycleGeneration.trim(),
sessionKey: run.sessionKey.trim(),
sessionId: run.sessionId.trim(),
observedAt: normalizeFiniteTimestamp(run.observedAt),
}))
.filter((run) => run.runId && run.lifecycleGeneration && (run.sessionKey || run.sessionId));
const currentLifecycleGeneration = getAgentEventLifecycleGeneration();
const result = { marked: 0, skipped: 0 };
if (sessionKeys.size === 0 && sessionIds.size === 0) {
return result;
}
const storePaths = new Set<string>();
const env =
params.stateDir === undefined
? process.env
: { ...process.env, OPENCLAW_STATE_DIR: params.stateDir };
const stateDir = resolveStateDir(env);
const configs = [params.cfg, ...(params.additionalCfgs ?? [])].filter(
(cfg): cfg is OpenClawConfig => Boolean(cfg),
);
for (const cfg of configs) {
try {
for (const target of resolveAllAgentSessionStoreTargetsSync(cfg, { env })) {
storePaths.add(path.resolve(target.storePath));
}
} catch (err) {
log.warn(`failed to resolve configured session stores for restart marker: ${String(err)}`);
}
for (const sessionKey of sessionKeys) {
try {
const target = resolveGatewaySessionStoreTarget({
cfg,
key: sessionKey,
});
storePaths.add(path.resolve(target.storePath));
for (const storeKey of target.storeKeys) {
const trimmed = storeKey.trim();
if (trimmed) {
sessionKeys.add(trimmed);
}
}
} catch (err) {
log.warn(
`failed to resolve session store for restart marker ${sessionKey}: ${String(err)}`,
);
}
}
}
for (const sessionsDir of await resolveAgentSessionDirs(stateDir)) {
storePaths.add(path.join(sessionsDir, "sessions.json"));
}
for (const storePath of storePaths) {
const storeResult = await applySessionEntryReplacements({
storePath,
requireWriteSuccess: true,
update: (entries) => {
const replacements: Array<{ sessionKey: string; entry: SessionEntry }> = [];
const counts = { marked: 0, skipped: 0 };
for (const { sessionKey, entry } of entries) {
const registeredActiveRuns = listAgentRunsForSession({
sessionKey,
sessionId: entry.sessionId,
});
const matchingActiveRuns = activeRuns.filter(
(run) =>
(run.sessionId ? run.sessionId === entry.sessionId : run.sessionKey === sessionKey) &&
(entry.status === "running" ||
run.observedAt === undefined ||
normalizeFiniteTimestamp(entry.updatedAt) === undefined ||
(entry.updatedAt < run.observedAt &&
run.lifecycleGeneration !== currentLifecycleGeneration)) &&
params.isActiveRun?.(run) !== false,
);
if (
entry.status !== "running" &&
matchingActiveRuns.length === 0 &&
registeredActiveRuns.length === 0
) {
continue;
}
const matches =
typeof entry.sessionId === "string" && sessionIds.has(entry.sessionId)
? true
: !preferSessionIdMatch && sessionKeys.has(sessionKey);
if (!matches) {
continue;
}
if (shouldSkipMainRecovery(entry, sessionKey)) {
counts.skipped++;
continue;
}
const wasRunning = entry.status === "running";
const recoveryRuns = new Map<string, RestartRecoveryRun>();
for (const run of entry.restartRecoveryRuns ?? []) {
if (run.lifecycleGeneration === currentLifecycleGeneration) {
recoveryRuns.set(`${run.runId}\u0000${run.lifecycleGeneration}`, run);
}
}
const replaceActiveRunMarker = (run: RestartRecoveryRun) => {
for (const [key, existingRun] of recoveryRuns) {
if (existingRun.runId === run.runId) {
recoveryRuns.delete(key);
}
}
recoveryRuns.set(`${run.runId}\u0000${run.lifecycleGeneration}`, run);
};
for (const run of registeredActiveRuns) {
replaceActiveRunMarker(run);
}
for (const run of matchingActiveRuns) {
replaceActiveRunMarker({
runId: run.runId,
lifecycleGeneration: run.lifecycleGeneration,
});
}
entry.restartRecoveryRuns = [...recoveryRuns.values()].toSorted((a, b) =>
a.runId === b.runId
? a.lifecycleGeneration.localeCompare(b.lifecycleGeneration)
: a.runId.localeCompare(b.runId),
);
transitionMainSessionRecovery(entry, {
kind: "mark_interrupted",
cycleId: randomUUID(),
now: Date.now(),
resetRuntime: !wasRunning,
runs: entry.restartRecoveryRuns,
});
replacements.push({ sessionKey, entry });
counts.marked++;
}
return { result: counts, replacements };
},
});
result.marked += storeResult.marked;
result.skipped += storeResult.skipped;
}
if (result.marked > 0) {
log.warn(
`marked ${result.marked} interrupted main session(s) for restart recovery${
params.reason ? ` (${params.reason})` : ""
}`,
);
}
return result;
}
export async function markStartupOrphanedMainSessionsForRecovery(params: {
cfg?: OpenClawConfig;
stateDir?: string;
activeSessionIds?: Iterable<string>;
activeSessionKeys?: Iterable<string>;
updatedBeforeMs?: number;
}): Promise<{ marked: number; skipped: number }> {
const result = { marked: 0, skipped: 0 };
const providedActiveSessionIds =
params.activeSessionIds === undefined ? undefined : normalizeStringSet(params.activeSessionIds);
const providedActiveSessionKeys =
params.activeSessionKeys === undefined
? undefined
: normalizeStringSet(params.activeSessionKeys);
const updatedBeforeMs = normalizeFiniteTimestamp(params.updatedBeforeMs);
// Lifecycle rotation synchronously evicts stale owners, so this same registry
// view drives both operational routing and recovery suppression. Re-read it at
// each check so a newer owner can still fence an older async recovery scan.
const resolveActiveSessionIds = () =>
providedActiveSessionIds ?? normalizeStringSet(listActiveEmbeddedRunSessionIds());
const resolveActiveSessionKeys = () =>
providedActiveSessionKeys ?? normalizeStringSet(listActiveEmbeddedRunSessionKeys());
for (const storePath of await resolveRestartRecoveryStorePaths(params)) {
const storeResult = await applySessionEntryReplacements({
storePath,
statuses: ["running"],
update: (entries) => {
const replacements: Array<{ sessionKey: string; entry: SessionEntry }> = [];
const counts = { marked: 0, skipped: 0 };
for (const { sessionKey, entry } of entries) {
if (entry.status !== "running" || entry.abortedLastRun === true) {
continue;
}
if (shouldSkipMainRecovery(entry, sessionKey)) {
counts.skipped++;
continue;
}
const updatedAt = normalizeFiniteTimestamp(entry.updatedAt);
if (
updatedBeforeMs !== undefined &&
updatedAt !== undefined &&
updatedAt > updatedBeforeMs
) {
continue;
}
if (
hasCurrentProcessOwner({
activeSessionIds: resolveActiveSessionIds(),
activeSessionKeys: resolveActiveSessionKeys(),
entry,
sessionKey,
})
) {
continue;
}
transitionMainSessionRecovery(entry, {
kind: "mark_interrupted",
cycleId: randomUUID(),
now: Date.now(),
});
replacements.push({ sessionKey, entry });
counts.marked++;
}
return { result: counts, replacements };
},
});
result.marked += storeResult.marked;
result.skipped += storeResult.skipped;
}
if (result.marked > 0) {
log.warn(`marked ${result.marked} startup-orphaned main session(s) for restart recovery`);
}
return result;
}
export async function markRestartAbortedMainSessionsFromLocks(params: {
sessionsDir: string;
cleanedLocks: SessionLockInspection[];
}): Promise<{ marked: number; skipped: number }> {
const result = { marked: 0, skipped: 0 };
const sessionsDir = path.resolve(params.sessionsDir);
const interruptedLockPaths = new Set(
params.cleanedLocks
.map((lock) => normalizeTranscriptLockPath(lock.lockPath))
.filter((lockPath): lockPath is string => Boolean(lockPath)),
);
if (interruptedLockPaths.size === 0) {
return result;
}
const storePath = path.join(sessionsDir, "sessions.json");
const storeResult = await applySessionEntryReplacements({
storePath,
statuses: ["running"],
update: (entries) => {
const replacements: Array<{ sessionKey: string; entry: SessionEntry }> = [];
const counts = { marked: 0, skipped: 0 };
for (const { sessionKey, entry } of entries) {
if (entry.status !== "running") {
continue;
}
if (shouldSkipMainRecovery(entry, sessionKey)) {
counts.skipped++;
continue;
}
const entryLockPaths = resolveEntryTranscriptLockPaths({ entry, sessionsDir });
if (!entryLockPaths.some((lockPath) => interruptedLockPaths.has(lockPath))) {
continue;
}
transitionMainSessionRecovery(entry, {
kind: "mark_interrupted",
cycleId: randomUUID(),
now: Date.now(),
});
replacements.push({ sessionKey, entry });
counts.marked++;
}
return { result: counts, replacements };
},
});
result.marked += storeResult.marked;
result.skipped += storeResult.skipped;
if (result.marked > 0) {
log.warn(`marked ${result.marked} interrupted main session(s) from stale transcript locks`);
}
return result;
}
@@ -0,0 +1,167 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { InternalSessionEntry as SessionEntry } from "../config/sessions.js";
import { appendAssistantMessageToSessionTranscript } from "../config/sessions/transcript.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { GatewayRecoveryRuntime } from "../gateway/server-instance-runtime.types.js";
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
import type { DeliveryContext } from "../utils/delivery-context.shared.js";
import { resolveDefaultAgentId } from "./agent-scope-config.js";
import type { MainSessionRecoveryObservation } from "./main-session-recovery-state.js";
import { commitMainSessionRecovery } from "./main-session-recovery-store.js";
import { buildUnresumableSessionNoticeIdempotencyKey } from "./main-session-restart-claim.js";
import { resolveRestartRecoveryDeliveryContext } from "./main-session-restart-dispatch.js";
import { log, UNRESUMABLE_SESSION_NOTICE } from "./main-session-restart-recovery-shared.js";
async function markSessionFailed(params: {
observation: MainSessionRecoveryObservation;
storePath: string;
sessionKey: string;
reason: string;
}): Promise<boolean> {
const marked = await commitMainSessionRecovery({
command: {
kind: "fail_recovery",
now: Date.now(),
observation: params.observation,
},
requireWriteSuccess: true,
target: { sessionKey: params.sessionKey, storePath: params.storePath },
});
if (marked.transition.kind === "failed") {
log.warn(`marked interrupted main session failed: ${params.sessionKey} (${params.reason})`);
return true;
}
return false;
}
async function sendUnresumableSessionNotice(params: {
deliveryContext: DeliveryContext;
entry: SessionEntry;
reason: string;
sessionKey: string;
gatewayRuntime: GatewayRecoveryRuntime;
}): Promise<void> {
const messageParams: Record<string, unknown> = {
to: params.deliveryContext.to,
message: UNRESUMABLE_SESSION_NOTICE,
bestEffort: true,
};
if (params.deliveryContext.threadId != null) {
messageParams.threadId = params.deliveryContext.threadId;
}
const actionParams: Record<string, unknown> = {
channel: params.deliveryContext.channel,
action: "send",
sessionKey: params.sessionKey,
sessionId: params.entry.sessionId,
idempotencyKey: buildUnresumableSessionNoticeIdempotencyKey(params.entry),
params: messageParams,
};
const accountId = normalizeOptionalString(params.deliveryContext.accountId);
if (accountId) {
actionParams.accountId = accountId;
}
try {
await params.gatewayRuntime.sendRecoveryNotice(actionParams, 10_000);
log.info(
`sent interrupted main session recovery notice: ${params.sessionKey} (${params.reason})`,
);
} catch (err) {
log.warn(
`failed to send interrupted main session recovery notice ${params.sessionKey}: ${String(err)}`,
);
}
}
async function writeUnresumableSessionNotice(params: {
agentId: string;
entry: SessionEntry;
sessionKey: string;
storePath: string;
}): Promise<boolean> {
const result = await appendAssistantMessageToSessionTranscript({
agentId: params.agentId,
sessionKey: params.sessionKey,
expectedSessionId: params.entry.sessionId,
expectedSessionState: {
abortedLastRun: params.entry.abortedLastRun,
restartRecoveryBeforeAgentReplyState: params.entry.restartRecoveryBeforeAgentReplyState,
restartRecoveryDeliveryReceiptState: params.entry.restartRecoveryDeliveryReceiptState,
restartRecoveryDeliveryToolCallId: params.entry.restartRecoveryDeliveryToolCallId,
restartRecoveryDeliveryRequestFingerprint:
params.entry.restartRecoveryDeliveryRequestFingerprint,
restartRecoveryDeliveryRunId: params.entry.restartRecoveryDeliveryRunId,
restartRecoveryDeliverySourceRunId: params.entry.restartRecoveryDeliverySourceRunId,
restartRecoveryRequesterAccountId: params.entry.restartRecoveryRequesterAccountId,
restartRecoveryRequesterSenderId: params.entry.restartRecoveryRequesterSenderId,
restartRecoverySameChannelThreadRequired:
params.entry.restartRecoverySameChannelThreadRequired,
restartRecoverySourceIngress: params.entry.restartRecoverySourceIngress,
restartRecoverySourceReplyDeliveryMode: params.entry.restartRecoverySourceReplyDeliveryMode,
restartRecoveryTerminalRunIds: params.entry.restartRecoveryTerminalRunIds,
status: params.entry.status,
updatedAt: params.entry.updatedAt,
},
storePath: params.storePath,
text: UNRESUMABLE_SESSION_NOTICE,
idempotencyKey: buildUnresumableSessionNoticeIdempotencyKey(params.entry),
}).catch((error: unknown) => ({ ok: false as const, reason: String(error) }));
if (!result.ok) {
log.warn(
`failed to write interrupted main session notice ${params.sessionKey}: ${result.reason}`,
);
}
return result.ok;
}
export async function failUnresumableMainSession(params: {
cfg?: OpenClawConfig;
entry: SessionEntry;
gatewayRuntime: GatewayRecoveryRuntime;
observation: MainSessionRecoveryObservation;
reason: string;
sessionKey: string;
storePath: string;
}): Promise<"failed" | "skipped"> {
const deliveryContext = resolveRestartRecoveryDeliveryContext({
cfg: params.cfg,
entry: params.entry,
includeSessionDeliveryFallback: true,
sessionKey: params.sessionKey,
});
if (
!deliveryContext &&
!(await writeUnresumableSessionNotice({
agentId: resolveAgentIdFromSessionKey(
params.sessionKey,
params.cfg ? resolveDefaultAgentId(params.cfg) : undefined,
),
entry: params.entry,
sessionKey: params.sessionKey,
storePath: params.storePath,
}))
) {
// Keep ownership for another recovery attempt until its terminal notice is durable.
return "failed";
}
const marked = await markSessionFailed({
observation: params.observation,
storePath: params.storePath,
sessionKey: params.sessionKey,
reason: params.reason,
});
if (!marked) {
return "skipped";
}
if (deliveryContext) {
await sendUnresumableSessionNotice({
deliveryContext,
entry: params.entry,
gatewayRuntime: params.gatewayRuntime,
reason: params.reason,
sessionKey: params.sessionKey,
});
}
return "failed";
}
@@ -0,0 +1,435 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { InternalSessionEntry as SessionEntry } from "../config/sessions.js";
import { CODE_MODE_EXEC_TOOL_NAME, CODE_MODE_WAIT_TOOL_NAME } from "./code-mode-control-tools.js";
import {
getTranscriptMessageRole as getMessageRole,
isMeaningfulTranscriptMessage,
readTerminalSourceReplyDeliveryMirror,
} from "./embedded-agent-runner/message-visibility.js";
import { isAgentToolReplaySafe } from "./tool-replay-safety.js";
function readDeliveredTerminalSourceReplyToolCallId(
messages: readonly unknown[],
expectedSourceTurnId: string | undefined,
): string | undefined {
if (!expectedSourceTurnId) {
return undefined;
}
for (const message of messages.toReversed()) {
if (getMessageRole(message) !== "assistant") {
continue;
}
const mirror = readTerminalSourceReplyDeliveryMirror(message);
if (mirror?.sourceTurnId === expectedSourceTurnId) {
return mirror.toolCallId;
}
}
return undefined;
}
function readCodeModeWaitCall(
message: unknown,
): { runId: string; toolCallId?: string } | undefined {
if (
!message ||
typeof message !== "object" ||
getMessageRole(message) !== "assistant" ||
(message as { stopReason?: unknown }).stopReason !== "toolUse"
) {
return undefined;
}
const content = (message as { content?: unknown }).content;
if (!Array.isArray(content)) {
return undefined;
}
const supportedTypes = new Set(["text", "thinking", "toolCall", "toolUse", "tool_use"]);
if (
content.some(
(block) =>
!block ||
typeof block !== "object" ||
!supportedTypes.has(String((block as { type?: unknown }).type)) ||
((block as { type?: unknown }).type === "text" &&
Boolean(normalizeOptionalString((block as { text?: unknown }).text))),
)
) {
return undefined;
}
const toolCalls = content.filter((block) => {
const type = (block as { type?: unknown }).type;
return type === "toolCall" || type === "toolUse" || type === "tool_use";
});
if (toolCalls.length !== 1) {
return undefined;
}
const block = toolCalls[0] as Record<string, unknown>;
if (normalizeOptionalString((block as { name?: unknown }).name) !== CODE_MODE_WAIT_TOOL_NAME) {
return undefined;
}
const args = (block as { arguments?: unknown }).arguments ?? (block as { input?: unknown }).input;
const runId =
args && typeof args === "object"
? normalizeOptionalString((args as { runId?: unknown }).runId)
: undefined;
if (!runId) {
return undefined;
}
const toolCallId = normalizeOptionalString(block.id);
return { runId, ...(toolCallId ? { toolCallId } : {}) };
}
function isResumableTailMessage(message: unknown): boolean {
const role = getMessageRole(message);
return role === "user" || role === "tool" || role === "toolResult";
}
function isPendingAssistantToolCall(message: unknown): boolean {
if (!message || typeof message !== "object" || getMessageRole(message) !== "assistant") {
return false;
}
if (normalizeOptionalString((message as { stopReason?: unknown }).stopReason) !== "toolUse") {
return false;
}
const content = (message as { content?: unknown }).content;
if (!Array.isArray(content)) {
return false;
}
let hasToolCall = false;
for (const block of content) {
if (!block || typeof block !== "object") {
return false;
}
const type = normalizeOptionalString((block as { type?: unknown }).type);
if (type === "toolCall" || type === "toolUse" || type === "tool_use") {
hasToolCall = true;
continue;
}
if (type === "thinking") {
continue;
}
if (type === "text" && !normalizeOptionalString((block as { text?: unknown }).text)) {
continue;
}
return false;
}
return hasToolCall;
}
type DanglingToolCallClassification =
| { kind: "none" }
| { kind: "code-mode" }
| { kind: "resumable"; forceRestartSafeTools: boolean };
// A dangling call may have committed before its result persisted, so anything
// outside the audited replay-safe allowlist keeps the restart-safe tool
// restriction: the continuation can inspect and report, but never silently
// re-execute the ambiguous side effect. Name-only classification is enough
// here because the concrete tool instance is gone with the old process.
function classifyDanglingToolCalls(content: unknown): DanglingToolCallClassification | undefined {
if (!Array.isArray(content)) {
return undefined;
}
let allReplaySafe = true;
let hasToolCall = false;
for (const block of content) {
if (!block || typeof block !== "object") {
return undefined;
}
const type = normalizeOptionalString((block as { type?: unknown }).type);
if (type !== "toolCall" && type !== "toolUse" && type !== "tool_use") {
continue;
}
const name = normalizeOptionalString((block as { name?: unknown }).name);
if (name === CODE_MODE_EXEC_TOOL_NAME || name === CODE_MODE_WAIT_TOOL_NAME) {
return { kind: "code-mode" };
}
if (!isAgentToolReplaySafe({ name })) {
allReplaySafe = false;
}
hasToolCall = true;
}
return hasToolCall
? { kind: "resumable", forceRestartSafeTools: !allReplaySafe }
: { kind: "none" };
}
// Unlike isPendingAssistantToolCall, visible text beside the call is fine —
// the tail is not replayed, only continued past. Code Mode control calls are
// excluded so their replay-safe checkpoint gating stays authoritative.
function readResumablePendingToolCallTail(
message: unknown,
): { forceRestartSafeTools: boolean } | undefined {
if (!message || typeof message !== "object" || getMessageRole(message) !== "assistant") {
return undefined;
}
if (normalizeOptionalString((message as { stopReason?: unknown }).stopReason) !== "toolUse") {
return undefined;
}
const classified = classifyDanglingToolCalls((message as { content?: unknown }).content);
return classified?.kind === "resumable"
? { forceRestartSafeTools: classified.forceRestartSafeTools }
: undefined;
}
function readCodeModeCheckpoint(
message: unknown,
): { replaySafe: boolean; runId?: string } | undefined {
if (!message || typeof message !== "object") {
return undefined;
}
const role = getMessageRole(message);
if (role !== "tool" && role !== "toolResult") {
return undefined;
}
const toolName = normalizeOptionalString((message as { toolName?: unknown }).toolName);
if (toolName !== CODE_MODE_EXEC_TOOL_NAME && toolName !== CODE_MODE_WAIT_TOOL_NAME) {
return undefined;
}
const content = (message as { content?: unknown }).content;
if (!Array.isArray(content)) {
return undefined;
}
const textBlock = content.find(
(block) => block && typeof block === "object" && (block as { type?: unknown }).type === "text",
) as { text?: unknown } | undefined;
const text = normalizeOptionalString(textBlock?.text);
if (!text) {
return undefined;
}
try {
const result = JSON.parse(text) as {
status?: unknown;
replaySafe?: unknown;
runId?: unknown;
};
if (result.status === "completed" || result.status === "failed") {
return { replaySafe: result.replaySafe === true };
}
const runId = normalizeOptionalString(result.runId);
return result.status === "waiting" && runId
? { replaySafe: result.replaySafe === true, runId }
: undefined;
} catch {
return undefined;
}
}
export function hasReplaySafeCodeModeCheckpointInCurrentTurn(
messages: readonly unknown[],
): boolean {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (getMessageRole(message) === "user") {
return false;
}
if (readCodeModeCheckpoint(message)?.replaySafe === true) {
return true;
}
}
return false;
}
// Generic fetch/undici abort strings plus the gateway's own restart abort
// reason (run-termination.ts); which one lands depends on whether the provider
// stream throws or surfaces the abort as an error event.
const RESTART_ABORT_ERROR_MESSAGES = new Set([
"Request was aborted",
"This operation was aborted",
"agent run aborted for restart",
]);
function isRestartAbortAssistantMessage(message: unknown): boolean {
if (!message || typeof message !== "object" || getMessageRole(message) !== "assistant") {
return false;
}
const stopReason = normalizeOptionalString((message as { stopReason?: unknown }).stopReason);
if (stopReason !== "error" && stopReason !== "aborted") {
return false;
}
const errorMessage = normalizeOptionalString(
(message as { errorMessage?: unknown }).errorMessage,
);
return errorMessage !== undefined && RESTART_ABORT_ERROR_MESSAGES.has(errorMessage);
}
export function isRestartAbortTailArtifact(message: unknown): boolean {
if (!isRestartAbortAssistantMessage(message)) {
return false;
}
const content = (message as { content?: unknown }).content;
return Array.isArray(content) && content.length === 0;
}
function isRestartAbortedWaitFailure(message: unknown): boolean {
if (!message || typeof message !== "object" || getMessageRole(message) !== "toolResult") {
return false;
}
const record = message as Record<string, unknown>;
if (
normalizeOptionalString(record.toolName) !== CODE_MODE_WAIT_TOOL_NAME ||
record.isError !== true
) {
return false;
}
const details = record.details;
if (
!details ||
typeof details !== "object" ||
(details as { status?: unknown }).status !== "failed" ||
(details as { code?: unknown }).code !== "internal_error"
) {
return false;
}
const content = record.content;
const contentText = Array.isArray(content)
? content
.filter(
(block) =>
block && typeof block === "object" && (block as { type?: unknown }).type === "text",
)
.map((block) => normalizeOptionalString((block as { text?: unknown }).text) ?? "")
.join("\n")
: "";
const errorText =
normalizeOptionalString((details as { error?: unknown }).error) ??
normalizeOptionalString(contentText);
return /^(?:(?:Abort)?Error:\s*)?(?:The|This) operation was aborted\.?$/u.test(errorText ?? "");
}
function isRestartAbortedWaitResultArtifact(message: unknown, waitMessage: unknown): boolean {
if (!isRestartAbortedWaitFailure(message)) {
return false;
}
const toolCallId = normalizeOptionalString((message as Record<string, unknown>).toolCallId);
const waitCall = readCodeModeWaitCall(waitMessage);
return Boolean(toolCallId && waitCall?.toolCallId === toolCallId);
}
function isApprovalPendingToolResult(message: unknown): boolean {
if (!message || typeof message !== "object" || getMessageRole(message) !== "toolResult") {
return false;
}
const details = (message as { details?: unknown }).details;
if (!details || typeof details !== "object") {
return false;
}
return (details as { status?: unknown }).status === "approval-pending";
}
type MainSessionResumePolicy =
| {
action: "complete";
reason: "delivered-terminal" | "delivered-terminal-receipt";
toolCallId: string;
}
| { action: "complete"; reason: "handled-silent" }
| { action: "fail"; reason: string }
| { action: "resume"; forceRestartSafeTools: boolean };
export function resolveMainSessionResumePolicy(
messages: unknown[],
forceRestartSafeTools = false,
expectedSourceTurnId?: string,
beforeAgentReplyState?: SessionEntry["restartRecoveryBeforeAgentReplyState"],
deliveryReceiptState?: SessionEntry["restartRecoveryDeliveryReceiptState"],
deliveryToolCallId?: string,
): MainSessionResumePolicy {
const mirroredToolCallId = readDeliveredTerminalSourceReplyToolCallId(
messages,
expectedSourceTurnId,
);
if (mirroredToolCallId) {
return { action: "complete", reason: "delivered-terminal", toolCallId: mirroredToolCallId };
}
if (deliveryReceiptState === "delivered-terminal") {
return deliveryToolCallId
? {
action: "complete",
reason: "delivered-terminal-receipt",
toolCallId: deliveryToolCallId,
}
: { action: "fail", reason: "terminal delivery receipt lacks tool-call correlation" };
}
if (deliveryReceiptState === "terminal-pending") {
return { action: "fail", reason: "terminal source reply delivery outcome is unknown" };
}
if (beforeAgentReplyState === "handled-silent") {
return { action: "complete", reason: "handled-silent" };
}
if (beforeAgentReplyState === "pending") {
return { action: "fail", reason: "before_agent_reply hook outcome is unknown" };
}
if (beforeAgentReplyState === "handled-reply") {
return { action: "fail", reason: "before_agent_reply handled reply is not recoverable" };
}
if (beforeAgentReplyState === "handled-unrecoverable") {
return { action: "fail", reason: "before_agent_reply handled an unrecoverable reply shape" };
}
// `admitted` means no optional hook started. The dispatch boundary reloads
// the current hook set before it permits this transcript to resume.
const meaningfulMessages = messages.toReversed().filter(isMeaningfulTranscriptMessage);
// A restart abort tail without tool calls is lifecycle noise whether or not
// partial streamed text was persisted with it; the partial output stays in
// the transcript for the continuation, and the message beneath decides
// resume safety. Persisted dangling tool calls instead classify like a
// pending toolUse tail so ambiguous side effects stay restricted.
if (isRestartAbortAssistantMessage(meaningfulMessages[0])) {
const dangling = classifyDanglingToolCalls(
(meaningfulMessages[0] as { content?: unknown }).content,
);
if (dangling?.kind === "resumable") {
return { action: "resume", forceRestartSafeTools: dangling.forceRestartSafeTools };
}
if (dangling?.kind === "none") {
meaningfulMessages.shift();
}
}
if (isRestartAbortedWaitResultArtifact(meaningfulMessages[0], meaningfulMessages[1])) {
meaningfulMessages.shift();
}
const lastMeaningful = meaningfulMessages[0];
if (forceRestartSafeTools && isPendingAssistantToolCall(lastMeaningful)) {
return { action: "resume", forceRestartSafeTools: true };
}
if (isRestartAbortedWaitFailure(lastMeaningful)) {
const waitCall = readCodeModeWaitCall(meaningfulMessages[1]);
const checkpoint = readCodeModeCheckpoint(meaningfulMessages[2]);
return waitCall && checkpoint?.replaySafe === true && checkpoint.runId === waitCall.runId
? { action: "resume", forceRestartSafeTools: true }
: {
action: "fail",
reason: "failed Code Mode wait cannot be matched to a replay-safe checkpoint",
};
}
const waitCall = readCodeModeWaitCall(lastMeaningful);
if (waitCall) {
const checkpoint = readCodeModeCheckpoint(meaningfulMessages[1]);
return checkpoint?.replaySafe === true && checkpoint.runId === waitCall.runId
? { action: "resume", forceRestartSafeTools: true }
: { action: "fail", reason: "Code Mode wait checkpoint is not replay-safe" };
}
const tailCheckpoint = readCodeModeCheckpoint(lastMeaningful);
if (tailCheckpoint) {
return tailCheckpoint.replaySafe
? { action: "resume", forceRestartSafeTools: true }
: { action: "fail", reason: "Code Mode wait checkpoint is not replay-safe" };
}
// A tool call interrupted mid-execution resumes like the manual re-send the
// failure notice used to demand: the dangling call is dropped from the next
// provider payload and the continuation prompt lets the model re-decide.
// Code Mode control calls keep the stricter checkpoint gating above.
const pendingToolCallTail = readResumablePendingToolCallTail(lastMeaningful);
if (pendingToolCallTail) {
return { action: "resume", forceRestartSafeTools: pendingToolCallTail.forceRestartSafeTools };
}
if (!lastMeaningful || !isResumableTailMessage(lastMeaningful)) {
return { action: "fail", reason: "transcript tail is not resumable" };
}
if (isApprovalPendingToolResult(lastMeaningful)) {
return {
action: "fail",
reason: "transcript tail is a stale approval-pending tool result",
};
}
return { action: "resume", forceRestartSafeTools: false };
}
@@ -0,0 +1,389 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { GatewayRecoveryRuntime } from "../gateway/server-instance-runtime.types.js";
import { runWithGatewayIndependentRootWorkAdmission } from "../process/gateway-work-admission.js";
import {
beginSessionWorkAdmission,
cancelSessionWorkAdmissionHandoff,
} from "../sessions/session-lifecycle-admission.js";
import {
loadExpectedRestartRecoveryClaim,
type ExpectedRestartRecoveryClaim,
} from "./main-session-restart-claim.js";
import { markStartupOrphanedMainSessionsForRecovery } from "./main-session-restart-recovery-marking.js";
import {
DEFAULT_RECOVERY_DELAY_MS,
type ExhaustedRestartRecoveryTarget,
type ExpectedRestartRecoveryTarget,
log,
MAX_RECOVERY_RETRIES,
RETRY_BACKOFF_MULTIPLIER,
resolveRestartRecoveryStorePaths,
} from "./main-session-restart-recovery-shared.js";
import {
loadExpectedRestartRecoveryTarget,
recoverStore,
} from "./main-session-restart-recovery-store.js";
async function recoverRestartAbortedMainSessionsWithOptions(params: {
cfg?: OpenClawConfig;
onExhaustedTarget?: (target: ExhaustedRestartRecoveryTarget) => void;
stateDir?: string;
resumedSessionKeys?: Set<string>;
activeSessionIds?: Iterable<string>;
activeSessionKeys?: Iterable<string>;
gatewayRuntime: GatewayRecoveryRuntime;
}): Promise<{ recovered: number; failed: number; skipped: number }> {
const result = { recovered: 0, failed: 0, skipped: 0 };
const resumedSessionKeys = params.resumedSessionKeys ?? new Set<string>();
for (const storePath of await resolveRestartRecoveryStorePaths(params)) {
const storeResult = await recoverStore({
cfg: params.cfg,
onExhaustedTarget: params.onExhaustedTarget,
storePath,
resumedSessionKeys,
activeSessionIds: params.activeSessionIds,
activeSessionKeys: params.activeSessionKeys,
gatewayRuntime: params.gatewayRuntime,
});
result.recovered += storeResult.recovered;
result.failed += storeResult.failed;
result.skipped += storeResult.skipped;
}
if (result.recovered > 0 || result.failed > 0) {
log.info(
`main-session restart recovery complete: recovered=${result.recovered} failed=${result.failed} skipped=${result.skipped}`,
);
}
return result;
}
export async function recoverRestartAbortedMainSessions(params: {
cfg?: OpenClawConfig;
stateDir?: string;
resumedSessionKeys?: Set<string>;
activeSessionIds?: Iterable<string>;
activeSessionKeys?: Iterable<string>;
gatewayRuntime: GatewayRecoveryRuntime;
}): Promise<{ recovered: number; failed: number; skipped: number }> {
return await recoverRestartAbortedMainSessionsWithOptions(params);
}
/** Retries one exact durable Control UI row from its owning per-agent SQLite store. */
export async function retryRestartAbortedMainSessionRecovery(params: {
canonicalSessionKey?: string;
cfg?: OpenClawConfig;
expectedRecoveryRunId: string;
expectedRecoverySourceRunId: string;
expectedSessionId: string;
sessionKey: string;
storePath: string;
gatewayRuntime: GatewayRecoveryRuntime;
}): Promise<{ recovered: number; failed: number; skipped: number }> {
const expectedClaim: ExpectedRestartRecoveryClaim = {
canonicalSessionKey: params.canonicalSessionKey,
recoveryRunId: params.expectedRecoveryRunId,
recoverySourceRunId: params.expectedRecoverySourceRunId,
sessionId: params.expectedSessionId,
sessionKey: params.sessionKey,
};
if (!loadExpectedRestartRecoveryClaim({ expected: expectedClaim, storePath: params.storePath })) {
return { recovered: 0, failed: 0, skipped: 0 };
}
const assertClaimCurrent = () => {
if (
!loadExpectedRestartRecoveryClaim({ expected: expectedClaim, storePath: params.storePath })
) {
throw new Error("restart recovery session ownership changed before dispatch");
}
};
// Keep lifecycle replacement behind the accepted recovery dispatch. The agent
// RPC atomically adopts this lease, so no second admission can deadlock behind
// a mutation that already sees the accepted browser turn as active work.
const admission = await beginSessionWorkAdmission({
scope: params.storePath,
identities: [params.sessionKey, params.canonicalSessionKey, params.expectedSessionId],
assertAllowed: assertClaimCurrent,
revalidateAllowed: assertClaimCurrent,
});
const handoffId = admission.createHandoff();
try {
return await admission.run(
async () =>
await recoverStore({
cfg: params.cfg,
storePath: params.storePath,
resumedSessionKeys: new Set<string>(),
expectedClaim,
sessionWorkAdmissionHandoffId: handoffId,
gatewayRuntime: params.gatewayRuntime,
}),
);
} finally {
cancelSessionWorkAdmissionHandoff(handoffId);
admission.release();
}
}
/** Reconciles one interrupted row after its final foreground owner releases. */
export async function retryRestartAbortedMainSessionRecoveryAfterOwnerRelease(params: {
cfg?: OpenClawConfig;
expectedSessionId: string;
sessionKey: string;
storePath: string;
gatewayRuntime: GatewayRecoveryRuntime;
}): Promise<{ recovered: number; failed: number; skipped: number }> {
return await recoverExpectedRestartRecoveryTarget(params);
}
async function recoverExpectedRestartRecoveryTarget(params: {
canonicalSessionKey?: string;
cfg?: OpenClawConfig;
expectedSessionId: string;
observationOnly?: boolean;
sessionKey: string;
storePath: string;
gatewayRuntime: GatewayRecoveryRuntime;
}): Promise<{ recovered: number; failed: number; skipped: number }> {
const expectedTarget: ExpectedRestartRecoveryTarget = {
canonicalSessionKey: params.canonicalSessionKey,
sessionId: params.expectedSessionId,
sessionKey: params.sessionKey,
};
const assertTargetCurrent = () => {
if (
!loadExpectedRestartRecoveryTarget({ expected: expectedTarget, storePath: params.storePath })
) {
throw new Error("restart recovery session ownership changed before owner-release retry");
}
};
if (
!loadExpectedRestartRecoveryTarget({ expected: expectedTarget, storePath: params.storePath })
) {
return { recovered: 0, failed: 0, skipped: 0 };
}
const admission = await beginSessionWorkAdmission({
scope: params.storePath,
identities: [params.sessionKey, params.expectedSessionId],
assertAllowed: assertTargetCurrent,
revalidateAllowed: assertTargetCurrent,
});
const handoffId = admission.createHandoff();
try {
return await admission.run(
async () =>
await recoverStore({
cfg: params.cfg,
observationOnly: params.observationOnly,
storePath: params.storePath,
resumedSessionKeys: new Set<string>(),
expectedTarget,
sessionWorkAdmissionHandoffId: handoffId,
gatewayRuntime: params.gatewayRuntime,
}),
);
} finally {
cancelSessionWorkAdmissionHandoff(handoffId);
admission.release();
}
}
export function scheduleRestartAbortedMainSessionRecoveryAfterOwnerRelease(params: {
delayMs?: number;
expectedSessionId: string;
getConfig: () => OpenClawConfig;
getGatewayRuntime: () => GatewayRecoveryRuntime | undefined;
maxRetries?: number;
sessionKey: string;
storePath: string;
}): void {
const retryDelayMs = params.delayMs ?? DEFAULT_RECOVERY_DELAY_MS;
const maxRetries = params.maxRetries ?? MAX_RECOVERY_RETRIES;
const scheduleAttempt = (attempt: number, delayMs: number) => {
const run = () => {
void runWithGatewayIndependentRootWorkAdmission(async () => {
const gatewayRuntime = params.getGatewayRuntime();
if (!gatewayRuntime) {
throw new Error("Gateway recovery runtime is unavailable");
}
return await retryRestartAbortedMainSessionRecoveryAfterOwnerRelease({
cfg: params.getConfig(),
expectedSessionId: params.expectedSessionId,
sessionKey: params.sessionKey,
storePath: params.storePath,
gatewayRuntime,
});
})
.then((result) => {
const stillPending = loadExpectedRestartRecoveryTarget({
expected: {
sessionId: params.expectedSessionId,
sessionKey: params.sessionKey,
},
storePath: params.storePath,
});
if (
(result.failed > 0 || (result.recovered === 0 && stillPending)) &&
attempt < maxRetries
) {
scheduleAttempt(attempt + 1, retryDelayMs * 2 ** (attempt - 1));
} else if (
attempt === maxRetries &&
stillPending?.mainRestartRecovery?.chargedAttempts === MAX_RECOVERY_RETRIES &&
!stillPending.mainRestartRecovery.reservation
) {
// The last ambiguous dispatch consumed the final durable charge.
// One exact observation tombstones exhaustion without dispatching again.
scheduleAttempt(attempt + 1, 0);
}
})
.catch((error: unknown) => {
if (attempt < maxRetries) {
scheduleAttempt(attempt + 1, retryDelayMs * 2 ** (attempt - 1));
} else {
log.warn(`main-session owner-release recovery failed: ${String(error)}`);
}
});
};
if (delayMs <= 0) {
run();
} else {
setTimeout(run, delayMs).unref?.();
}
};
scheduleAttempt(1, 0);
}
async function recoverStartupOrphanedMainSessionsWithOptions(params: {
cfg?: OpenClawConfig;
stateDir?: string;
activeSessionIds?: Iterable<string>;
activeSessionKeys?: Iterable<string>;
updatedBeforeMs?: number;
resumedSessionKeys?: Set<string>;
onExhaustedTarget?: (target: ExhaustedRestartRecoveryTarget) => void;
gatewayRuntime: GatewayRecoveryRuntime;
}): Promise<{ marked: number; recovered: number; failed: number; skipped: number }> {
const startupRecoveryCutoffMs = params.updatedBeforeMs ?? Date.now();
const marked = await markStartupOrphanedMainSessionsForRecovery({
cfg: params.cfg,
stateDir: params.stateDir,
activeSessionIds: params.activeSessionIds,
activeSessionKeys: params.activeSessionKeys,
updatedBeforeMs: startupRecoveryCutoffMs,
});
const recovered = await recoverRestartAbortedMainSessionsWithOptions({
cfg: params.cfg,
onExhaustedTarget: params.onExhaustedTarget,
stateDir: params.stateDir,
resumedSessionKeys: params.resumedSessionKeys,
activeSessionIds: params.activeSessionIds,
activeSessionKeys: params.activeSessionKeys,
gatewayRuntime: params.gatewayRuntime,
});
return {
marked: marked.marked,
recovered: recovered.recovered,
failed: recovered.failed,
skipped: marked.skipped + recovered.skipped,
};
}
export async function recoverStartupOrphanedMainSessions(params: {
cfg?: OpenClawConfig;
stateDir?: string;
activeSessionIds?: Iterable<string>;
activeSessionKeys?: Iterable<string>;
updatedBeforeMs?: number;
resumedSessionKeys?: Set<string>;
gatewayRuntime: GatewayRecoveryRuntime;
}): Promise<{ marked: number; recovered: number; failed: number; skipped: number }> {
return await recoverStartupOrphanedMainSessionsWithOptions(params);
}
export function scheduleRestartAbortedMainSessionRecovery(params: {
cfg?: OpenClawConfig;
delayMs?: number;
maxRetries?: number;
stateDir?: string;
gatewayRuntime: GatewayRecoveryRuntime;
}): void {
const initialDelay = params.delayMs ?? DEFAULT_RECOVERY_DELAY_MS;
const maxRetries = params.maxRetries ?? MAX_RECOVERY_RETRIES;
const resumedSessionKeys = new Set<string>();
// Only reconcile rows that existed before this startup recovery was scheduled.
// Fresh runs started by this gateway are protected again by the active-run check.
const startupRecoveryCutoffMs = Date.now();
const runRecoveryAttempt = (attempt: number, delay: number) => {
const exhaustedTargets = new Map<string, ExhaustedRestartRecoveryTarget>();
const reconcileExhaustedTargets = async () => {
const outcomes = await Promise.allSettled(
[...exhaustedTargets.values()].map((target) =>
runWithGatewayIndependentRootWorkAdmission(
async () =>
await recoverExpectedRestartRecoveryTarget({
canonicalSessionKey: target.canonicalSessionKey,
cfg: params.cfg,
expectedSessionId: target.sessionId,
observationOnly: true,
sessionKey: target.sessionKey,
storePath: target.storePath,
gatewayRuntime: params.gatewayRuntime,
}),
),
),
);
for (const outcome of outcomes) {
if (outcome.status === "rejected") {
log.warn(`main-session exhaustion reconciliation failed: ${String(outcome.reason)}`);
}
}
};
// Delayed retries outlive startup; each attempt must independently block
// host suspension while it reads and rewrites recovery session state.
void runWithGatewayIndependentRootWorkAdmission(
async () =>
await recoverStartupOrphanedMainSessionsWithOptions({
cfg: params.cfg,
onExhaustedTarget: (target) => {
exhaustedTargets.set(`${target.storePath}\u0000${target.sessionKey}`, target);
},
stateDir: params.stateDir,
resumedSessionKeys,
updatedBeforeMs: startupRecoveryCutoffMs,
gatewayRuntime: params.gatewayRuntime,
}),
)
.then(async (result) => {
if (result.failed > 0 && attempt < maxRetries) {
scheduleAttempt(attempt + 1, delay * RETRY_BACKOFF_MULTIPLIER);
} else if (result.failed > 0 && attempt === maxRetries && exhaustedTargets.size > 0) {
// Reconcile only exact rows whose final dispatch retained its durable charge.
await reconcileExhaustedTargets();
}
})
.catch(async (err: unknown) => {
if (attempt < maxRetries) {
log.warn(`main-session restart recovery failed: ${String(err)}`);
scheduleAttempt(attempt + 1, delay * RETRY_BACKOFF_MULTIPLIER);
} else {
log.warn(`main-session restart recovery gave up: ${String(err)}`);
await reconcileExhaustedTargets();
}
});
};
const scheduleAttempt = (attempt: number, delay: number) => {
if (delay <= 0) {
runRecoveryAttempt(attempt, delay);
return;
}
setTimeout(() => {
runRecoveryAttempt(attempt, delay);
}, delay).unref?.();
};
scheduleAttempt(1, initialDelay);
}
@@ -0,0 +1,77 @@
import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
import {
type InternalSessionEntry as SessionEntry,
resolveAllAgentSessionStoreTargetsSync,
} from "../config/sessions.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { isMainRestartRecoveryCandidate } from "./main-session-recovery-state.js";
import { resolveAgentSessionDirs } from "./session-dirs.js";
export const log = createSubsystemLogger("main-session-restart-recovery");
export const DEFAULT_RECOVERY_DELAY_MS = 5_000;
export const MAX_RECOVERY_RETRIES = 3;
export const RETRY_BACKOFF_MULTIPLIER = 2;
export const UNRESUMABLE_SESSION_NOTICE =
"I was interrupted by a gateway restart and couldn't safely resume the previous turn. " +
"Please send that last request again and I'll pick it up cleanly.";
export type ExpectedRestartRecoveryTarget = {
canonicalSessionKey?: string;
sessionId: string;
sessionKey: string;
};
export type ExhaustedRestartRecoveryTarget = ExpectedRestartRecoveryTarget & {
storePath: string;
};
export function shouldSkipMainRecovery(entry: SessionEntry, sessionKey: string): boolean {
return !isMainRestartRecoveryCandidate(entry, sessionKey);
}
export function normalizeStringSet(values: Iterable<string> | undefined): Set<string> {
const normalized = new Set<string>();
for (const value of values ?? []) {
const trimmed = value.trim();
if (trimmed) {
normalized.add(trimmed);
}
}
return normalized;
}
export function normalizeFiniteTimestamp(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
export function hasCurrentProcessOwner(params: {
activeSessionIds: Set<string>;
activeSessionKeys: Set<string>;
entry: SessionEntry;
sessionKey: string;
}): boolean {
if (params.activeSessionIds.has(params.entry.sessionId)) {
return true;
}
return params.activeSessionIds.size === 0 && params.activeSessionKeys.has(params.sessionKey);
}
export async function resolveRestartRecoveryStorePaths(params: {
cfg?: OpenClawConfig;
stateDir?: string;
}): Promise<string[]> {
const storePaths = new Set<string>();
const stateDir = params.stateDir ?? resolveStateDir(process.env);
for (const sessionsDir of await resolveAgentSessionDirs(stateDir)) {
storePaths.add(path.join(sessionsDir, "sessions.json"));
}
if (params.cfg) {
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
for (const target of resolveAllAgentSessionStoreTargetsSync(params.cfg, { env })) {
storePaths.add(path.resolve(target.storePath));
}
}
return [...storePaths].toSorted((a, b) => a.localeCompare(b));
}
@@ -0,0 +1,509 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import {
type InternalSessionEntry as SessionEntry,
resolveSessionWorkStartError,
} from "../config/sessions.js";
import {
listSessionEntriesByStatus,
loadExactSessionEntry,
} from "../config/sessions/session-accessor.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { GatewayRecoveryRuntime } from "../gateway/server-instance-runtime.types.js";
import { readSessionMessagesAsync } from "../gateway/session-transcript-readers.js";
import { resolveGatewaySessionStoreTarget } from "../gateway/session-utils.js";
import { getAgentEventLifecycleGeneration } from "../infra/agent-events.js";
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
import { resolveDefaultAgentId } from "./agent-scope-config.js";
import {
listActiveEmbeddedRunSessionIds,
listActiveEmbeddedRunSessionKeys,
} from "./embedded-agent-runner/run-state.js";
import { isMainRestartRecoveryCandidate } from "./main-session-recovery-state.js";
import { commitMainSessionRecovery } from "./main-session-recovery-store.js";
import {
loadExpectedRestartRecoveryClaim,
type ExpectedRestartRecoveryClaim,
} from "./main-session-restart-claim.js";
import {
hasRestartRecoveryMessageActionAuthority,
requiresRestartRecoveryMessageActionAuthority,
resolveRestartRecoveryResumeBlockReason,
resumeMainSession,
} from "./main-session-restart-dispatch.js";
import {
hasCompletionReportUserTail,
hasOnlyAnnounceRecoveryRuns,
markSessionCompletedAfterRecoveryCheckpoint,
reconcileInterruptedCompletionReport,
} from "./main-session-restart-recovery-checkpoint.js";
import { tombstoneMainRestartRecoveryWithNotice } from "./main-session-restart-recovery-failure.js";
import { failUnresumableMainSession } from "./main-session-restart-recovery-notice.js";
import {
hasReplaySafeCodeModeCheckpointInCurrentTurn,
resolveMainSessionResumePolicy,
} from "./main-session-restart-recovery-resume-policy.js";
import {
type ExhaustedRestartRecoveryTarget,
type ExpectedRestartRecoveryTarget,
hasCurrentProcessOwner,
log,
MAX_RECOVERY_RETRIES,
normalizeStringSet,
shouldSkipMainRecovery,
} from "./main-session-restart-recovery-shared.js";
export function loadExpectedRestartRecoveryTarget(params: {
expected: ExpectedRestartRecoveryTarget;
storePath: string;
}): SessionEntry | undefined {
const exact = loadExactSessionEntry({
sessionKey: params.expected.sessionKey,
storePath: params.storePath,
readConsistency: "latest",
});
const entry = exact?.sessionKey === params.expected.sessionKey ? exact.entry : undefined;
return entry?.sessionId === params.expected.sessionId &&
entry.status === "running" &&
entry.abortedLastRun === true &&
isMainRestartRecoveryCandidate(entry, params.expected.sessionKey)
? entry
: undefined;
}
function resolveRecoveryDispatchSessionKey(params: {
cfg?: OpenClawConfig;
sessionKey: string;
storePath: string;
}): string | undefined {
if (!params.cfg) {
return params.sessionKey;
}
try {
const target = resolveGatewaySessionStoreTarget({
cfg: params.cfg,
key: params.sessionKey,
});
return !params.cfg.session?.store ||
path.resolve(target.storePath) === path.resolve(params.storePath)
? target.canonicalKey
: undefined;
} catch (err) {
log.warn(`failed to resolve recovery store for ${params.sessionKey}: ${String(err)}`);
return undefined;
}
}
export async function recoverStore(params: {
cfg?: OpenClawConfig;
observationOnly?: boolean;
onExhaustedTarget?: (target: ExhaustedRestartRecoveryTarget) => void;
storePath: string;
resumedSessionKeys: Set<string>;
expectedClaim?: ExpectedRestartRecoveryClaim;
expectedTarget?: ExpectedRestartRecoveryTarget;
sessionWorkAdmissionHandoffId?: string;
activeSessionIds?: Iterable<string>;
activeSessionKeys?: Iterable<string>;
gatewayRuntime: GatewayRecoveryRuntime;
}): Promise<{ recovered: number; failed: number; skipped: number }> {
const result = { recovered: 0, failed: 0, skipped: 0 };
const providedActiveSessionIds =
params.activeSessionIds === undefined ? undefined : normalizeStringSet(params.activeSessionIds);
const providedActiveSessionKeys =
params.activeSessionKeys === undefined
? undefined
: normalizeStringSet(params.activeSessionKeys);
const resolveActiveSessionIds = () =>
providedActiveSessionIds ?? normalizeStringSet(listActiveEmbeddedRunSessionIds());
const resolveActiveSessionKeys = () =>
providedActiveSessionKeys ?? normalizeStringSet(listActiveEmbeddedRunSessionKeys());
let entries: Array<{ sessionKey: string; entry: SessionEntry }>;
try {
if (params.expectedClaim) {
const entry = loadExpectedRestartRecoveryClaim({
expected: params.expectedClaim,
storePath: params.storePath,
});
entries = entry ? [{ sessionKey: params.expectedClaim.sessionKey, entry }] : [];
} else if (params.expectedTarget) {
const entry = loadExpectedRestartRecoveryTarget({
expected: params.expectedTarget,
storePath: params.storePath,
});
entries = entry ? [{ sessionKey: params.expectedTarget.sessionKey, entry }] : [];
} else {
entries = listSessionEntriesByStatus({ storePath: params.storePath }, ["running"]);
}
} catch (err) {
log.warn(`failed to load session store ${params.storePath}: ${String(err)}`);
result.failed++;
return result;
}
for (const { sessionKey, entry: loadedEntry } of entries.toSorted((a, b) =>
a.sessionKey.localeCompare(b.sessionKey),
)) {
let entry = loadedEntry;
const agentId = resolveAgentIdFromSessionKey(
sessionKey,
params.cfg ? resolveDefaultAgentId(params.cfg) : undefined,
);
if (!entry || entry.status !== "running" || entry.abortedLastRun !== true) {
continue;
}
if (shouldSkipMainRecovery(entry, sessionKey)) {
result.skipped++;
continue;
}
if (resolveSessionWorkStartError(sessionKey, entry)) {
result.skipped++;
continue;
}
const resolvedDispatchSessionKey = resolveRecoveryDispatchSessionKey({
cfg: params.cfg,
sessionKey,
storePath: params.storePath,
});
if (!resolvedDispatchSessionKey) {
result.skipped++;
continue;
}
const dispatchSessionKey =
params.expectedClaim?.canonicalSessionKey ??
params.expectedTarget?.canonicalSessionKey ??
resolvedDispatchSessionKey;
if (
hasCurrentProcessOwner({
activeSessionIds: resolveActiveSessionIds(),
activeSessionKeys: resolveActiveSessionKeys(),
entry,
sessionKey,
})
) {
result.skipped++;
continue;
}
const resumeDedupeKey = sessionKey;
if (params.resumedSessionKeys.has(resumeDedupeKey)) {
result.skipped++;
continue;
}
const observed = await commitMainSessionRecovery({
command: {
kind: "observe",
cycleId: randomUUID(),
lifecycleGeneration: getAgentEventLifecycleGeneration(),
sessionKey,
},
requireWriteSuccess: true,
target: { sessionKey, storePath: params.storePath },
});
if (!observed.entry || observed.transition.kind !== "observed") {
result.skipped++;
continue;
}
entry = observed.entry;
const recoveryView = observed.transition.view;
if (
recoveryView.status === "inactive" ||
recoveryView.status === "blocked" ||
recoveryView.status === "tombstoned"
) {
result.skipped++;
continue;
}
if (recoveryView.status === "exhausted") {
const tombstone = await tombstoneMainRestartRecoveryWithNotice({
cfg: params.cfg,
entry,
gatewayRuntime: params.gatewayRuntime,
observation: recoveryView.observation,
reason: recoveryView.reason,
sessionKey,
storePath: params.storePath,
});
if (tombstone === "notice_failed") {
result.failed++;
} else {
result.skipped++;
}
continue;
}
if (params.observationOnly) {
result.skipped++;
continue;
}
const recordResumeResult = (resumeResult: Awaited<ReturnType<typeof resumeMainSession>>) => {
if (resumeResult === "resumed") {
params.resumedSessionKeys.add(resumeDedupeKey);
result.recovered++;
} else if (resumeResult === "skipped") {
result.skipped++;
} else {
result.failed++;
const current = loadExpectedRestartRecoveryTarget({
expected: { sessionId: entry.sessionId, sessionKey },
storePath: params.storePath,
});
if (
current?.mainRestartRecovery?.chargedAttempts === MAX_RECOVERY_RETRIES &&
!current.mainRestartRecovery.reservation
) {
params.onExhaustedTarget?.({
canonicalSessionKey: dispatchSessionKey,
sessionId: entry.sessionId,
sessionKey,
storePath: params.storePath,
});
}
}
};
if (
requiresRestartRecoveryMessageActionAuthority(entry) &&
!hasRestartRecoveryMessageActionAuthority(entry)
) {
const disposition = await failUnresumableMainSession({
cfg: params.cfg,
entry,
gatewayRuntime: params.gatewayRuntime,
observation: recoveryView.observation,
reason: "message-tool-only recovery authority is unavailable",
sessionKey,
storePath: params.storePath,
});
result[disposition]++;
continue;
}
const expectedRecoverySourceRunId = normalizeOptionalString(
entry.restartRecoveryDeliverySourceRunId,
);
let resumeBlockReason: string | undefined;
let resumeSafetyResolved = false;
const failBlockedResume = async (): Promise<boolean> => {
if (!resumeSafetyResolved) {
resumeSafetyResolved = true;
resumeBlockReason = resolveRestartRecoveryResumeBlockReason({
cfg: params.cfg,
entry,
sessionKey,
});
}
if (!resumeBlockReason) {
return false;
}
const disposition = await failUnresumableMainSession({
cfg: params.cfg,
entry,
gatewayRuntime: params.gatewayRuntime,
observation: recoveryView.observation,
reason: resumeBlockReason,
sessionKey,
storePath: params.storePath,
});
result[disposition]++;
return true;
};
if (
entry.pendingFinalDelivery === true &&
entry.pendingFinalDeliveryText &&
entry.restartRecoveryForceSafeTools === true
) {
if (await failBlockedResume()) {
continue;
}
const resumed = await resumeMainSession({
canonicalSessionKey: dispatchSessionKey,
cfg: params.cfg,
entry,
observation: recoveryView.observation,
recoveryAttempt: recoveryView.nextAttempt,
storePath: params.storePath,
sessionKey,
pendingFinalDeliveryText: entry.pendingFinalDeliveryText,
forceRestartSafeTools: true,
sessionWorkAdmissionHandoffId: params.sessionWorkAdmissionHandoffId,
gatewayRuntime: params.gatewayRuntime,
});
recordResumeResult(resumed);
continue;
}
let messages: unknown[];
try {
messages = await readSessionMessagesAsync(
{
agentId,
sessionEntry: entry,
sessionId: entry.sessionId,
sessionKey,
storePath: params.storePath,
},
{
mode: "recent",
maxMessages: 20,
maxBytes: 256 * 1024,
},
);
} catch (err) {
if (entry.pendingFinalDelivery === true && entry.pendingFinalDeliveryText) {
if (await failBlockedResume()) {
continue;
}
log.warn(
`transcript unavailable for ${sessionKey}; resuming its durable pending final delivery`,
);
const resumed = await resumeMainSession({
canonicalSessionKey: dispatchSessionKey,
cfg: params.cfg,
entry,
observation: recoveryView.observation,
recoveryAttempt: recoveryView.nextAttempt,
storePath: params.storePath,
sessionKey,
pendingFinalDeliveryText: entry.pendingFinalDeliveryText,
sessionWorkAdmissionHandoffId: params.sessionWorkAdmissionHandoffId,
gatewayRuntime: params.gatewayRuntime,
});
recordResumeResult(resumed);
continue;
}
log.warn(`failed to read transcript for ${sessionKey}: ${String(err)}`);
result.failed++;
continue;
}
if (entry.pendingFinalDelivery === true && entry.pendingFinalDeliveryText) {
if (await failBlockedResume()) {
continue;
}
const resumed = await resumeMainSession({
canonicalSessionKey: dispatchSessionKey,
cfg: params.cfg,
entry,
observation: recoveryView.observation,
recoveryAttempt: recoveryView.nextAttempt,
storePath: params.storePath,
sessionKey,
pendingFinalDeliveryText: entry.pendingFinalDeliveryText,
forceRestartSafeTools: hasReplaySafeCodeModeCheckpointInCurrentTurn(messages),
sessionWorkAdmissionHandoffId: params.sessionWorkAdmissionHandoffId,
gatewayRuntime: params.gatewayRuntime,
});
recordResumeResult(resumed);
continue;
}
// Completion reports are delivery turns, not human work. Same-process
// rotation retains their announce run ids; a full restart can recover the
// same fact from the already-persisted user-message provenance.
const hasRecoveryRuns = Boolean(entry.restartRecoveryRuns?.length);
const completionSource = hasOnlyAnnounceRecoveryRuns(entry)
? "announce_runs"
: !hasRecoveryRuns && hasCompletionReportUserTail(messages)
? "transcript"
: undefined;
if (completionSource) {
const reconciliation = await reconcileInterruptedCompletionReport({
entry,
source: completionSource,
storePath: params.storePath,
sessionKey,
});
if (reconciliation.outcome === "reconciled") {
params.resumedSessionKeys.add(resumeDedupeKey);
result.skipped++;
} else if (
reconciliation.entry?.status === "running" &&
reconciliation.entry.abortedLastRun === true
) {
result.failed++;
} else {
result.skipped++;
}
continue;
}
const resumePolicy = resolveMainSessionResumePolicy(
messages,
entry.restartRecoveryForceSafeTools === true,
expectedRecoverySourceRunId,
entry.restartRecoveryBeforeAgentReplyState,
entry.restartRecoveryDeliveryReceiptState,
entry.restartRecoveryDeliveryToolCallId,
);
if (resumePolicy.action === "complete") {
const completion = await markSessionCompletedAfterRecoveryCheckpoint({
agentId,
entry,
messages,
reason: resumePolicy.reason,
storePath: params.storePath,
sessionKey,
sourceTurnId: expectedRecoverySourceRunId,
...(resumePolicy.reason === "handled-silent"
? {}
: {
toolCallId: resumePolicy.toolCallId,
}),
});
if (completion.outcome === "completed") {
params.resumedSessionKeys.add(resumeDedupeKey);
result.recovered++;
} else if (completion.outcome === "changed") {
result.skipped++;
} else {
const disposition = await failUnresumableMainSession({
cfg: params.cfg,
entry,
gatewayRuntime: params.gatewayRuntime,
observation: recoveryView.observation,
reason: completion.reason,
sessionKey,
storePath: params.storePath,
});
result[disposition]++;
}
continue;
}
if (resumePolicy.action === "fail") {
const disposition = await failUnresumableMainSession({
cfg: params.cfg,
entry,
gatewayRuntime: params.gatewayRuntime,
observation: recoveryView.observation,
reason: resumePolicy.reason,
sessionKey,
storePath: params.storePath,
});
result[disposition]++;
continue;
}
if (await failBlockedResume()) {
continue;
}
const resumed = await resumeMainSession({
canonicalSessionKey: dispatchSessionKey,
cfg: params.cfg,
entry,
observation: recoveryView.observation,
recoveryAttempt: recoveryView.nextAttempt,
storePath: params.storePath,
sessionKey,
pendingFinalDeliveryText: entry.pendingFinalDeliveryText,
forceRestartSafeTools:
entry.restartRecoveryForceSafeTools === true || resumePolicy.forceRestartSafeTools,
sessionWorkAdmissionHandoffId: params.sessionWorkAdmissionHandoffId,
gatewayRuntime: params.gatewayRuntime,
});
recordResumeResult(resumed);
}
return result;
}
File diff suppressed because it is too large Load Diff
@@ -168,7 +168,9 @@ describe("session accessor boundary guard", () => {
"src/agents/embedded-agent-runner/run/attempt.ts",
"src/agents/embedded-agent-subscribe.handlers.compaction.runtime.ts",
"src/agents/live-model-switch.ts",
"src/agents/main-session-restart-recovery.ts",
"src/agents/main-session-restart-recovery-checkpoint.ts",
"src/agents/main-session-restart-recovery-marking.ts",
"src/agents/main-session-restart-recovery-store.ts",
"src/agents/session-suspension.ts",
"src/auto-reply/reply/abort.ts",
"src/agents/subagent-control.ts",
@@ -8,7 +8,7 @@ describe("session transcript reader boundary guard", () => {
it("ratchets only the files migrated by the transcript reader slice", () => {
expect(migratedSessionTranscriptReaderFiles).toEqual(
new Set([
"src/agents/main-session-restart-recovery.ts",
"src/agents/main-session-restart-recovery-store.ts",
"src/agents/subagent-announce-output.test.ts",
"src/agents/subagent-announce-output.ts",
"src/agents/subagent-announce.runtime.ts",