Files
openclaw/src/agents/command/prepare.ts
xingzhou 0a868179e4 fix(session): stop repeated restart recovery after retry budget (#96230)
* fix(session): bound restart recovery across gateway restarts

Co-authored-by: Mason Huang <masonxhuang@proton.me>

* style(session): format restart recovery changes

* fix(session): fence recovery notice state

* test(session): align internal recovery types

* test(session): type recovery completion fixture

* style(session): satisfy recovery lint rules

* test(plugin-sdk): split recovery boundary coverage

* test(plugin-sdk): use tracked recovery temp dirs

* fix(session): preserve stale admission errors

* fix(session): close recovery lifecycle races

* fix(session): rotate rejected recovery dispatch ids

* fix(session): enforce recovery quarantine boundaries

* fix(session): close restart recovery exhaustion gaps

* fix(session): preserve recovery command narrowing

* test(session): type restart recovery fixtures

* fix(session): enforce restart recovery quarantine

* fix(session): close restart recovery races

* fix(session): type recovery lifecycle state

* fix(session): enforce recovery ownership fences

* fix(session): fence recovery lifecycle completion

* fix(session): quarantine cross-process recovery

* fix(session): retire stale recovery ownership

* fix(session): restore rejected recovery admission

* fix(session): preserve restored recovery target

* fix(session): retry pending recovery races

* fix(session): preserve concurrent recovery aborts

* fix(session): quarantine recovery lifecycle state

* fix(session): settle exact recovery delivery fences

* fix(session): keep recovery restoration internal

* fix(session): close recovery admission races

* fix(session): retry durable recovery rollback

* fix(session): guard cached recovery settlement

* fix(session): close recovery ownership gaps

* fix(session): resume after delayed recovery rollback

* fix(session): finalize unrecoverable restart state

* fix(session): retry admitted recovery restoration

* fix(session): preserve recovery cleanup guarantees

* fix(session): restore failed terminal settlement

* chore(plugin-sdk): refresh API baseline

* fix(session): close recovery owner retry races

* test(session): type recovery owner fixture

* refactor(session): remove obsolete owner validator

* fix(session): preserve explicit abort recovery

---------

Co-authored-by: Mason Huang <masonxhuang@proton.me>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-17 15:36:09 +01:00

403 lines
14 KiB
TypeScript

import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import {
formatThinkingLevels,
normalizeThinkLevel,
normalizeVerboseLevel,
} from "../../auto-reply/thinking.js";
import { formatCliCommand } from "../../cli/command-format.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { resolveAgentExplicitRecipientSession } from "../../infra/outbound/agent-delivery.js";
import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js";
import { parseStrictNonNegativeInteger } from "../../infra/parse-finite-number.js";
import { normalizePluginsConfig } from "../../plugins/config-state.js";
import { loadManifestMetadataSnapshot } from "../../plugins/manifest-contract-eligibility.js";
import {
classifySessionKeyShape,
isUnscopedSessionKeySentinel,
normalizeAgentId,
resolveAgentIdFromSessionKey,
scopeLegacySessionKeyToAgent,
} from "../../routing/session-key.js";
import type { RuntimeEnv } from "../../runtime.js";
import {
AGENT_HARNESS_MODEL_RUN_FORBIDDEN_MESSAGE,
resolveAgentHarnessSessionContextError,
} from "../../sessions/agent-harness-session-key.js";
import { resolveUserPath } from "../../utils.js";
import { isDeliverableMessageChannel, resolveMessageChannel } from "../../utils/message-channel.js";
import { resolveAgentRuntimeConfig } from "../agent-runtime-config.js";
import {
listAgentIds,
resolveAgentDir,
resolveDefaultAgentId,
resolveSessionAgentId,
resolveAgentWorkspaceDir,
} from "../agent-scope.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
import { AGENT_LANE_SUBAGENT } from "../lanes.js";
import type { ModelManifestNormalizationContext } from "../model-selection-normalize.js";
import { buildConfiguredModelCatalog, resolveConfiguredModelRef } from "../model-selection.js";
import { normalizeSpawnedRunMetadata } from "../spawned-context.js";
import { resolveEffectiveAgentRuntime } from "../thinking-runtime.js";
import { resolveAgentTimeoutMs } from "../timeout.js";
import { ensureAgentWorkspace } from "../workspace.js";
import { acquireWorktreeRunLease, resolveWorktreeIdForPath } from "../worktrees/run-lease.js";
import {
resolveAcpPromptBody,
prependInternalEventContext,
resolveInternalEventTranscriptBody,
} from "./attempt-execution.shared.js";
import { loadAcpManagerRuntime } from "./runtime-loaders.js";
import { createAgentCommandSessionWorkingCopy } from "./session-helpers.js";
import { resolveSession } from "./session.js";
import type { AgentCommandOpts } from "./types.js";
const OVERRIDE_VALUE_MAX_LENGTH = 256;
function containsControlCharacters(value: string): boolean {
for (const char of value) {
const code = char.codePointAt(0);
if (code === undefined) {
continue;
}
if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) {
return true;
}
}
return false;
}
export function normalizeExplicitOverrideInput(raw: string, kind: "provider" | "model"): string {
const trimmed = raw.trim();
const label = kind === "provider" ? "Provider" : "Model";
if (!trimmed) {
throw new Error(`${label} override must be non-empty.`);
}
if (trimmed.length > OVERRIDE_VALUE_MAX_LENGTH) {
throw new Error(`${label} override exceeds ${String(OVERRIDE_VALUE_MAX_LENGTH)} characters.`);
}
if (containsControlCharacters(trimmed)) {
throw new Error(`${label} override contains invalid control characters.`);
}
return trimmed;
}
export function resolveExplicitAgentCommandSessionKey(params: {
rawExplicitSessionKey?: string;
agentIdOverride?: string;
shouldScopeDefaultAgentKey?: boolean;
cfg: OpenClawConfig;
}): string | undefined {
if (
isUnscopedSessionKeySentinel(params.rawExplicitSessionKey) &&
!params.agentIdOverride &&
!params.shouldScopeDefaultAgentKey
) {
return params.rawExplicitSessionKey;
}
return scopeLegacySessionKeyToAgent({
agentId:
params.agentIdOverride ??
(params.shouldScopeDefaultAgentKey ? resolveDefaultAgentId(params.cfg) : undefined),
sessionKey: params.rawExplicitSessionKey,
mainKey: params.cfg.session?.mainKey,
});
}
export async function prepareAgentCommandExecution(opts: AgentCommandOpts, runtime: RuntimeEnv) {
const isRawModelRun = opts.modelRun === true || opts.promptMode === "none";
const message = opts.message ?? "";
if (!message.trim()) {
throw new Error("Message (--message) is required");
}
const rawExplicitSessionKey = opts.sessionKey?.trim();
const requestedSessionId = opts.sessionId?.trim() || undefined;
const rawTo = opts.to?.trim();
const toSessionKey =
!rawExplicitSessionKey && !requestedSessionId && classifySessionKeyShape(rawTo) === "agent"
? rawTo
: undefined;
const recipientChannel = resolveMessageChannel(opts.channel);
const shouldResolveExplicitRecipientSession = Boolean(
!rawExplicitSessionKey &&
!requestedSessionId &&
!toSessionKey &&
opts.agentId?.trim() &&
recipientChannel &&
isDeliverableMessageChannel(recipientChannel) &&
rawTo,
);
if (!opts.to && !requestedSessionId && !rawExplicitSessionKey && !opts.agentId) {
throw new Error(
"Pass --to <E.164>, --session-key, --session-id, or --agent to choose a session",
);
}
const { cfg } = await resolveAgentRuntimeConfig(runtime, {
runtimeTargetsChannelSecrets: opts.deliver === true,
runtimeChannelSecretScope:
opts.deliver !== true && shouldResolveExplicitRecipientSession && recipientChannel
? { channel: recipientChannel, accountId: opts.accountId }
: undefined,
});
const normalizedSpawned = normalizeSpawnedRunMetadata({
spawnedBy: opts.spawnedBy,
groupId: opts.groupId,
groupChannel: opts.groupChannel,
groupSpace: opts.groupSpace,
workspaceDir: opts.workspaceDir,
});
const agentIdOverrideRaw = opts.agentId?.trim();
const agentIdOverride = agentIdOverrideRaw ? normalizeAgentId(agentIdOverrideRaw) : undefined;
if (agentIdOverride) {
const knownAgents = listAgentIds(cfg);
if (!knownAgents.includes(agentIdOverride)) {
throw new Error(
`Unknown agent id "${agentIdOverrideRaw}". Use "${formatCliCommand("openclaw agents list")}" to see configured agents.`,
);
}
}
const shouldScopeDefaultAgentKey = Boolean(
rawExplicitSessionKey &&
!agentIdOverride &&
classifySessionKeyShape(rawExplicitSessionKey) === "legacy_or_alias" &&
!isUnscopedSessionKeySentinel(rawExplicitSessionKey),
);
const explicitSessionKey =
toSessionKey ??
resolveExplicitAgentCommandSessionKey({
rawExplicitSessionKey,
agentIdOverride,
shouldScopeDefaultAgentKey,
cfg,
});
if (explicitSessionKey && classifySessionKeyShape(explicitSessionKey) === "malformed_agent") {
throw new Error(
`Invalid --session-key "${explicitSessionKey}". Agent-prefixed session keys must use agent:<agent-id>:<session-key>.`,
);
}
if (
agentIdOverride &&
explicitSessionKey &&
classifySessionKeyShape(explicitSessionKey) === "agent"
) {
const sessionAgentId = resolveAgentIdFromSessionKey(explicitSessionKey);
if (sessionAgentId !== agentIdOverride) {
throw new Error(
`Agent id "${agentIdOverrideRaw}" does not match session key agent "${sessionAgentId}".`,
);
}
}
const agentCfg = cfg.agents?.defaults;
const verboseOverride = normalizeVerboseLevel(opts.verbose);
if (opts.verbose && !verboseOverride) {
throw new Error('Invalid verbose level. Use "on", "full", or "off".');
}
const laneRaw = normalizeOptionalString(opts.lane) ?? "";
const subagentLane: string = AGENT_LANE_SUBAGENT;
const isSubagentLane = laneRaw === subagentLane;
const hasExplicitTimeoutOption = opts.timeout !== undefined;
const timeoutSecondsRaw = hasExplicitTimeoutOption
? (parseStrictNonNegativeInteger(opts.timeout) ?? Number.NaN)
: isSubagentLane
? 0
: undefined;
if (
timeoutSecondsRaw !== undefined &&
(Number.isNaN(timeoutSecondsRaw) || timeoutSecondsRaw < 0)
) {
throw new Error("--timeout must be a non-negative integer (seconds; 0 means no timeout)");
}
const timeoutMs = resolveAgentTimeoutMs({ cfg, overrideSeconds: timeoutSecondsRaw });
const runTimeoutOverrideMs = hasExplicitTimeoutOption ? timeoutMs : undefined;
const selectedCommandOpts = toSessionKey
? { ...opts, to: undefined, sessionKey: explicitSessionKey }
: opts;
const explicitRecipientSession =
shouldResolveExplicitRecipientSession && agentIdOverride && recipientChannel && rawTo
? await resolveAgentExplicitRecipientSession({
cfg,
agentId: agentIdOverride,
channel: recipientChannel,
to: rawTo,
accountId: selectedCommandOpts.accountId,
threadId: selectedCommandOpts.threadId,
})
: undefined;
if (explicitRecipientSession?.error) {
throw explicitRecipientSession.error;
}
const commandOpts = explicitRecipientSession?.sessionKey
? {
...selectedCommandOpts,
channel: explicitRecipientSession.channel,
to: explicitRecipientSession.to,
accountId: explicitRecipientSession.accountId,
threadId: explicitRecipientSession.threadId,
}
: selectedCommandOpts;
const sessionResolution = resolveSession({
cfg,
to: commandOpts.to,
sessionId: commandOpts.sessionId,
sessionKey: explicitSessionKey ?? explicitRecipientSession?.sessionKey,
agentId: agentIdOverride,
clone: false,
});
const {
sessionId,
sessionKey,
storePath,
isNewSession,
previousSessionId,
persistedThinking,
persistedVerbose,
} = sessionResolution;
const harnessSessionError = sessionKey
? resolveAgentHarnessSessionContextError(sessionKey, sessionResolution.sessionEntry)
: undefined;
if (harnessSessionError) {
throw new Error(harnessSessionError);
}
const isOneShotModelRun = opts.modelRun === true || opts.promptMode === "none";
if (
isOneShotModelRun &&
sessionKey &&
sessionResolution.sessionEntry?.modelSelectionLocked === true
) {
throw new Error(AGENT_HARNESS_MODEL_RUN_FORBIDDEN_MESSAGE);
}
const { sessionEntry: sessionEntryRaw, sessionStore } = createAgentCommandSessionWorkingCopy({
sessionKey,
sessionEntry: sessionResolution.sessionEntry,
sessionStore: sessionResolution.sessionStore,
});
const sessionAgentId =
agentIdOverride ??
resolveSessionAgentId({ sessionKey: sessionKey ?? explicitSessionKey, config: cfg });
const outboundSession = buildOutboundSessionContext({
cfg,
agentId: sessionAgentId,
sessionKey,
});
const workspaceDirRaw =
normalizedSpawned.workspaceDir ?? resolveAgentWorkspaceDir(cfg, sessionAgentId);
const workspaceDir = resolveUserPath(workspaceDirRaw);
const cwd =
normalizeOptionalString(opts.cwd) ?? normalizeOptionalString(sessionEntryRaw?.spawnedCwd);
const agentDir = resolveAgentDir(cfg, sessionAgentId);
const pluginsEnabled = normalizePluginsConfig(cfg.plugins).enabled;
const manifestMetadataSnapshot = pluginsEnabled
? loadManifestMetadataSnapshot({ config: cfg, workspaceDir, env: process.env })
: undefined;
const modelManifestContext = {
manifestPlugins: manifestMetadataSnapshot?.plugins ?? [],
} satisfies ModelManifestNormalizationContext;
const configuredModel = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
allowPluginNormalization: pluginsEnabled,
...modelManifestContext,
});
const configuredThinkingCatalog = buildConfiguredModelCatalog({
cfg,
workspaceDir,
...modelManifestContext,
});
const configuredThinkingRuntime = resolveEffectiveAgentRuntime({
cfg,
provider: configuredModel.provider,
modelId: configuredModel.model,
agentId: sessionAgentId,
sessionKey,
sessionEntry: sessionEntryRaw,
});
const thinkingLevelsHint = formatThinkingLevels(
configuredModel.provider,
configuredModel.model,
", ",
configuredThinkingCatalog.length > 0 ? configuredThinkingCatalog : undefined,
configuredThinkingRuntime,
);
const thinkOverride = normalizeThinkLevel(opts.thinking);
const thinkOnce = normalizeThinkLevel(opts.thinkingOnce);
if (opts.thinking && !thinkOverride) {
throw new Error(`Invalid thinking level. Use one of: ${thinkingLevelsHint}.`);
}
if (opts.thinkingOnce && !thinkOnce) {
throw new Error(`Invalid one-shot thinking level. Use one of: ${thinkingLevelsHint}.`);
}
const resolvedCwd = cwd ? resolveUserPath(cwd) : undefined;
const worktreeId = await resolveWorktreeIdForPath({
sessionEntry: sessionEntryRaw,
candidatePaths: [resolvedCwd ?? workspaceDir, workspaceDir],
});
const runLease = worktreeId ? await acquireWorktreeRunLease(worktreeId) : undefined;
try {
await ensureAgentWorkspace({
dir: workspaceDirRaw,
ensureBootstrapFiles: !agentCfg?.skipBootstrap,
skipOptionalBootstrapFiles: agentCfg?.skipOptionalBootstrapFiles,
});
const runId = opts.runId?.trim() || sessionId;
const { getAcpSessionManager } = await loadAcpManagerRuntime();
const acpManager = getAcpSessionManager();
const acpResolution = sessionKey ? acpManager.resolveSession({ cfg, sessionKey }) : null;
const body =
!isRawModelRun && acpResolution?.kind === "ready"
? resolveAcpPromptBody(message, opts.internalEvents)
: prependInternalEventContext(message, opts.internalEvents);
const transcriptBody =
opts.transcriptMessage ?? resolveInternalEventTranscriptBody(message, opts.internalEvents);
const prepared = {
opts: commandOpts,
body,
transcriptBody,
cfg,
configuredThinkingCatalog,
normalizedSpawned,
agentCfg,
thinkOverride,
thinkOnce,
verboseOverride,
timeoutMs,
runTimeoutOverrideMs,
sessionId,
sessionKey,
sessionEntry: sessionEntryRaw,
sessionStore,
storePath,
isNewSession,
previousSessionId,
persistedThinking,
persistedVerbose,
sessionAgentId,
outboundSession,
workspaceDir,
cwd: resolvedCwd,
agentDir,
pluginsEnabled,
manifestMetadataSnapshot,
modelManifestContext,
runId,
isSubagentLane,
acpManager,
acpResolution,
runLease,
};
return prepared;
} catch (error) {
await runLease?.release();
throw error;
}
}
export type PreparedAgentCommandExecution = Awaited<
ReturnType<typeof prepareAgentCommandExecution>
>;