import { coerceErrorMessage } from "@openclaw/normalization-core/error-coercion"; import { getReplyPayloadMetadata } from "../../auto-reply/reply-payload.js"; import { recordAgentRunTerminalOutcome } from "../../channels/turn/agent-run-terminal-outcome.js"; import type { CliDeps } from "../../cli/deps.types.js"; import { buildRestartRecoveryClaimCleanupPatch } from "../../config/sessions/restart-recovery-state.js"; import type { RestartRecoveryTerminalDeliveryEvidenceResult } from "../../config/sessions/restart-recovery-types.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import { assertAgentRunLifecycleGenerationCurrent } from "../../infra/agent-events.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { isSubagentSessionKey } from "../../routing/session-key.js"; import type { RuntimeEnv } from "../../runtime.js"; import type { DeliveryContext } from "../../utils/delivery-context.shared.js"; import { buildRestartRecoveryTerminalDeliveryEvidence, constrainRestartRecoveryDeliveryPayloads, shouldPersistCurrentRunSessionCleanup, shouldPersistRestartRecoveryCleanup, } from "../agent-command-restart-recovery.js"; import { normalizeAgentRunTerminalDeliverySnapshot } from "../agent-run-terminal-delivery.js"; import { buildAgentRunTerminalOutcomeFromLifecycleEvent, classifyAgentRunTerminalOutcome, mergeAgentRunTerminalOutcome, } from "../agent-run-terminal-outcome.js"; import { OPENCLAW_AGENT_RUNTIME_ID } from "../agent-runtime-id.js"; import { isHeartbeatLifecycleRunKind } from "../bootstrap-mode.js"; import type { AcceptedCompactionSuccessor } from "../embedded-agent-runner/compaction-successor.js"; import { buildMainSessionRecoveryClearPatch } from "../main-session-recovery/main-session-recovery-clear.js"; import { persistPendingFinalDeliveryMarker } from "../pending-final-delivery-marker.js"; import type { AgentRunSessionTarget } from "../run-session-target.js"; import { throwAgentRunRestartAbortReason } from "../run-termination.js"; import type { SessionMaintenanceRequest } from "../session-maintenance/run.js"; import { persistAssistantTranscriptRepairRecord } from "./assistant-transcript-repair.js"; import { persistAgentSession } from "./attempt-execution.shared.js"; import type { deliverAgentCommandResult } from "./delivery.js"; import { createCommandBudget } from "./maintenance-budget.js"; import { createCommandMaintenanceFollowup } from "./maintenance.js"; import type { PreparedAgentCommandExecution } from "./prepare.js"; import type { runEmbeddedAgentAttempt } from "./run-embedded-attempt.js"; import { loadCliCompactionRuntime, loadDeliveryRuntime, loadSessionStoreRuntime, } from "./runtime-loaders.js"; import { clearPendingFinalDelivery } from "./session-helpers.js"; import type { EmbeddedSessionState } from "./session-preparation.js"; import type { AgentCommandOpts } from "./types.js"; type EmbeddedAgentAttempt = Awaited>; const log = createSubsystemLogger("agents/agent-command"); export async function clearCommandRecoveryClaim(params: { prepared: PreparedAgentCommandExecution; sessionEntry?: SessionEntry; runOwnedSessionId: string; sessionReboundDuringRun: boolean; trackedRestartRecoveryDeliveryClaim: boolean; terminalDeliveryEvidence?: RestartRecoveryTerminalDeliveryEvidenceResult; }): Promise { const { sessionStore, sessionKey, storePath, runId } = params.prepared; if ( params.sessionReboundDuringRun || !params.trackedRestartRecoveryDeliveryClaim || !sessionStore || !sessionKey ) { return; } try { const entry = sessionStore[sessionKey] ?? params.sessionEntry; if (entry?.restartRecoveryDeliveryRunId === runId) { await persistAgentSession({ sessionStore, sessionKey, storePath, initialEntry: entry, entry: { ...entry, ...buildRestartRecoveryClaimCleanupPatch({ entry, recordTerminalSource: true, terminalRunId: runId, terminalDeliveryEvidence: params.terminalDeliveryEvidence, }), ...buildMainSessionRecoveryClearPatch(entry), updatedAt: Date.now(), }, shouldPersist: (current) => shouldPersistRestartRecoveryCleanup(current, params.runOwnedSessionId, runId), }); } } catch (error) { log.warn( `failed to clear restart recovery delivery context for ${sessionKey}: ${coerceErrorMessage(error)}`, ); } } export function createCompactionSessionIdReporter( sessionId: string, onSessionIdChanged: AgentCommandOpts["onSessionIdChanged"], ) { let notifiedSessionId = sessionId; let pendingCompactionSessionId: string | undefined; const notifySessionIdChanged = (nextSessionId: string) => { notifiedSessionId = nextSessionId; pendingCompactionSessionId = undefined; onSessionIdChanged?.(nextSessionId); }; return { onSessionIdChanged: notifySessionIdChanged, onCompactionCommitted: (nextSessionId: string | undefined) => { if (nextSessionId !== undefined) { pendingCompactionSessionId = nextSessionId; } }, reportCommitted: () => { // Compaction can commit before abort or maintenance fails. The command's // finally reports it outside commit bookkeeping so cleanup targets its row. if ( pendingCompactionSessionId !== undefined && pendingCompactionSessionId !== notifiedSessionId ) { try { notifySessionIdChanged(pendingCompactionSessionId); } catch (error) { log.warn(`failed to report settled session identity: ${coerceErrorMessage(error)}`); } } }, }; } export async function finalizeEmbeddedAgentCommand(params: { prepared: PreparedAgentCommandExecution; opts: AgentCommandOpts; deps: CliDeps; runtime: RuntimeEnv; sessionEntry?: SessionEntry; attempt: EmbeddedAgentAttempt; embeddedSessionState: EmbeddedSessionState; suppressVisibleSessionEffects: boolean; preserveUserFacingSessionModelState: boolean; currentRunDeliveryContext?: DeliveryContext; sessionOwnership: { runOwnedSessionId: string; sessionReboundDuringRun: boolean; }; trackInternalModelRunTarget: (target: AgentRunSessionTarget | undefined) => void; onSessionOwnershipChanged: ( ownership: { runOwnedSessionId: string; sessionReboundDuringRun: boolean }, committedCompactionSessionId?: string, ) => void; onTerminalDeliveryEvidenceChanged: ( evidence: RestartRecoveryTerminalDeliveryEvidenceResult, ) => void; }) { const { cfg, body, transcriptBody, sessionId, sessionKey, sessionStore, storePath, sessionAgentId, workspaceDir, cwd, agentDir, timeoutMs, outboundSession, runId, } = params.prepared; const { fallbackProvider, fallbackModel, fallbackExhausted, provider, model, effectiveTurnThinkLevel, internalSessionTarget, attemptExecutionRuntime, messageChannel, suppressUserTurnPersistence, userTurnTranscriptRecorder, fallbackTrajectoryRecorder, deferredLifecycle, lifecycle, terminal, lifecycleGeneration, } = params.attempt; const { skillsSnapshot, runContext } = params.embeddedSessionState; const effectiveCwd = cwd ?? workspaceDir; const isHeartbeatLifecycleRun = isHeartbeatLifecycleRunKind(params.opts.bootstrapContextRunKind); let sessionEntry = params.sessionEntry; let result = params.attempt.result; let deliveryResult: Awaited>; let hasResultError: boolean; let terminalError: string | undefined; let maintenanceRequest: SessionMaintenanceRequest | undefined; let { runOwnedSessionId, sessionReboundDuringRun } = params.sessionOwnership; const publishSessionOwnership = (committedCompactionSessionId?: string) => { // Outer restart-recovery cleanup runs even after later delivery failures. params.onSessionOwnershipChanged( { runOwnedSessionId, sessionReboundDuringRun }, committedCompactionSessionId, ); }; try { await fallbackTrajectoryRecorder?.flush(); const finalVisiblePayload = result.payloads ?.toReversed() .find((payload) => !payload.isError && !payload.isReasoning && payload.text?.trim()); const assistantTranscriptOwned = finalVisiblePayload !== undefined && getReplyPayloadMetadata(finalVisiblePayload)?.assistantTranscriptOwned === true; if (params.opts.internalDeliveryMediaUrls !== undefined) { result = { ...result, payloads: constrainRestartRecoveryDeliveryPayloads( result.payloads, params.opts.internalDeliveryMediaUrls, params.opts.internalDeliverySuppressText === true, ), }; } const resultErrorPayload = result.payloads?.find((payload) => payload.isError === true); if (resultErrorPayload) { const message = typeof resultErrorPayload.text === "string" && resultErrorPayload.text.trim() ? resultErrorPayload.text : undefined; params.opts.onResultErrorPayload?.(message); } params.onTerminalDeliveryEvidenceChanged(buildRestartRecoveryTerminalDeliveryEvidence(result)); const compactionFact = params.attempt.compactionAccounting; const effectiveSessionId = compactionFact?.target.sessionId ?? internalSessionTarget?.sessionId ?? sessionId; if (sessionStore && sessionKey && !params.suppressVisibleSessionEffects) { const { updateSessionStoreAfterAgentRun } = await loadSessionStoreRuntime(); await updateSessionStoreAfterAgentRun({ cfg, agentDir, sessionId: effectiveSessionId, sessionKey, storePath, sessionStore, defaultProvider: provider, defaultModel: model, fallbackProvider, fallbackModel, result, compactionAccounting: compactionFact, touchInteraction: params.opts.bootstrapContextRunKind !== "cron" && !isHeartbeatLifecycleRun && !params.opts.internalEvents?.length, // Cron output counts as unread-worthy activity; heartbeat and // internal-event turns must not re-flag the session unread. touchActivity: !isHeartbeatLifecycleRun && !params.opts.internalEvents?.length, preserveRuntimeModel: fallbackExhausted || fallbackProvider !== provider || fallbackModel !== model || isHeartbeatLifecycleRun || params.preserveUserFacingSessionModelState, preserveUserFacingSessionModelState: params.preserveUserFacingSessionModelState, clearRestartRecoveryForceSafeTools: params.opts.forceRestartSafeTools === true && params.opts.deliver !== true, }); sessionEntry = sessionStore[sessionKey] ?? sessionEntry; } runOwnedSessionId = effectiveSessionId; publishSessionOwnership(); const transcriptPersistenceRunner = result.meta.executionTrace?.runner; let persistedCliTurnTranscript = false; if (!sessionReboundDuringRun && transcriptPersistenceRunner === "cli") { try { const transcriptResult = await attemptExecutionRuntime.persistCliTurnTranscript({ body, transcriptBody, result, sessionId: effectiveSessionId, sessionKey: internalSessionTarget?.sessionKey ?? sessionKey ?? effectiveSessionId, sessionEntry: internalSessionTarget?.sessionEntry ?? sessionEntry, sessionStore: params.suppressVisibleSessionEffects ? undefined : sessionStore, storePath: internalSessionTarget?.storePath ?? storePath, sessionAgentId: internalSessionTarget?.agentId ?? sessionAgentId, threadId: params.opts.threadId, sessionCwd: effectiveCwd, config: cfg, skipAssistantTurn: assistantTranscriptOwned, skipUserTurn: suppressUserTurnPersistence || // A supplied recorder owns input admission; the terminal mirror must // not synthesize an unkeyed copy when that input never reached execution. params.opts.userTurnTranscriptRecorder !== undefined || userTurnTranscriptRecorder.hasPersisted() || userTurnTranscriptRecorder.isBlocked(), }); sessionReboundDuringRun = transcriptResult.kind === "session-rebound"; publishSessionOwnership(); if (!internalSessionTarget) { sessionEntry = transcriptResult.sessionEntry; } persistedCliTurnTranscript = transcriptResult.kind === "persisted"; } catch (error) { log.warn( `Turn transcript persistence failed for ${sessionKey ?? sessionId}: ${error instanceof Error ? error.message : String(error)}`, ); if ( sessionStore && sessionKey && !params.suppressVisibleSessionEffects && !sessionReboundDuringRun && !assistantTranscriptOwned ) { await persistAssistantTranscriptRepairRecord({ context: { sessionKey: internalSessionTarget?.sessionKey ?? sessionKey ?? effectiveSessionId, sessionEntry: internalSessionTarget?.sessionEntry ?? sessionEntry, sessionStore, storePath: internalSessionTarget?.storePath ?? storePath, sessionAgentId: internalSessionTarget?.agentId ?? sessionAgentId, config: cfg, }, replyText: attemptExecutionRuntime.resolveCliTranscriptReplyText(result), provider: result.meta.agentMeta?.provider, model: result.meta.agentMeta?.model, runOwnedSessionId, }); } } } const payloads = result.payloads ?? []; const pendingFinalDeliveryMarker = await persistPendingFinalDeliveryMarker({ deliver: params.opts.deliver === true, sessionStore, sessionKey, sessionEntry, storePath, suppressVisibleSessionEffects: params.suppressVisibleSessionEffects, sessionReboundDuringRun, payloads, deliveryContext: params.currentRunDeliveryContext, runOwnedSessionId, }); sessionEntry = pendingFinalDeliveryMarker.sessionEntry; const resolveFreshSessionEntryForDelivery = sessionStore && sessionKey && !params.suppressVisibleSessionEffects ? async (): Promise => { const { loadSessionEntryReadOnly } = await loadSessionStoreRuntime(); const freshEntry = loadSessionEntryReadOnly({ storePath, sessionKey, readConsistency: "latest", clone: false, }); if (!freshEntry || freshEntry.sessionId !== runOwnedSessionId) { return undefined; } sessionStore[sessionKey] = freshEntry; return freshEntry; } : undefined; const agentMeta = result.meta.agentMeta; const embeddedMaintenance = transcriptPersistenceRunner === "embedded" && agentMeta?.agentHarnessId === OPENCLAW_AGENT_RUNTIME_ID && params.attempt.maintenanceAuthProfile !== undefined && !fallbackExhausted && terminal.outcome.status === "ok" && !resultErrorPayload && !result.meta.yielded && !result.meta.aborted && (compactionFact?.count ?? 0) === 0 && !params.preserveUserFacingSessionModelState && params.opts.modelRun !== true && params.opts.promptMode !== "none"; maintenanceRequest = sessionEntry && sessionKey && sessionStore && !params.suppressVisibleSessionEffects && !sessionReboundDuringRun && !isHeartbeatLifecycleRun && cfg.agents?.defaults?.compaction?.enabled !== false && embeddedMaintenance ? { prepared: { cfg, sessionKey, storePath, timeoutMs }, followupRun: createCommandMaintenanceFollowup({ prepared: params.prepared, sessionEntry, embeddedSessionState: params.embeddedSessionState, provider: agentMeta?.provider ?? fallbackProvider, model: agentMeta?.model ?? fallbackModel, thinkLevel: effectiveTurnThinkLevel, auth: params.attempt.maintenanceAuthProfile, }), sessionId: runOwnedSessionId, lifecycleRevision: sessionEntry.lifecycleRevision, lifecycleGeneration, startedAt: params.attempt.startedAt, oneShotCliRun: params.opts.oneShotCliRun, agentHarnessId: agentMeta?.agentHarnessId, compactionRequestBudget: params.attempt.compactionRequestBudget, } : undefined; // Generic CLI backends may rely on this sole host-compaction path. Keep its // existing foreground custody until runtime preparation can own preflight. if ( persistedCliTurnTranscript && !params.suppressVisibleSessionEffects && (params.opts.deliver !== true || !pendingFinalDeliveryMarker.hasSendableFinalPayload || pendingFinalDeliveryMarker.pendingFinalDeliveryMarkerPersisted) ) { const maintenance = createCommandBudget( params.attempt.startedAt, timeoutMs, params.opts.abortSignal, ); let maintenanceLifecycleRevision = sessionEntry?.lifecycleRevision; const authorize = () => { throwAgentRunRestartAbortReason(params.opts.abortSignal?.reason); assertAgentRunLifecycleGenerationCurrent(lifecycleGeneration); return ( maintenance.remainingMs() > 0 && !maintenance.signal.aborted && !sessionReboundDuringRun ); }; const onCommitted = (accepted: AcceptedCompactionSuccessor) => { sessionEntry = accepted.entry; maintenanceLifecycleRevision = accepted.entry.lifecycleRevision; runOwnedSessionId = accepted.sessionId; publishSessionOwnership( accepted.previousSessionId === undefined ? undefined : accepted.sessionId, ); }; try { if (maintenance.remainingMs() > 0) { const { runCliTurnCompactionLifecycle } = await loadCliCompactionRuntime(); sessionEntry = await runCliTurnCompactionLifecycle( { cfg, sessionId: sessionEntry?.sessionId ?? effectiveSessionId, sessionKey: sessionKey ?? effectiveSessionId, sessionEntry, sessionStore, storePath, sessionAgentId, workspaceDir, cwd: effectiveCwd, agentDir, provider: agentMeta?.provider ?? provider, model: agentMeta?.model ?? model, skillsSnapshot, messageChannel, agentAccountId: runContext.accountId, senderIsOwner: params.opts.senderIsOwner, thinkLevel: effectiveTurnThinkLevel, extraSystemPrompt: params.opts.extraSystemPrompt, pluginGeneration: params.prepared.commandRuntimeContext?.pluginGeneration, abortSignal: maintenance.signal, }, { assertActive: () => { if (!authorize()) { throw new Error("Command compaction is no longer active"); } }, onCommitted, }, ); throwAgentRunRestartAbortReason(params.opts.abortSignal?.reason); assertAgentRunLifecycleGenerationCurrent(lifecycleGeneration); runOwnedSessionId = sessionEntry?.sessionId ?? runOwnedSessionId; publishSessionOwnership(); } } catch (error) { throwAgentRunRestartAbortReason(params.opts.abortSignal?.reason); throwAgentRunRestartAbortReason(error); assertAgentRunLifecycleGenerationCurrent(lifecycleGeneration); if (maintenance.signal.aborted) { params.opts.abortSignal?.throwIfAborted(); const currentEntry = await resolveFreshSessionEntryForDelivery?.(); params.opts.abortSignal?.throwIfAborted(); assertAgentRunLifecycleGenerationCurrent(lifecycleGeneration); if (!currentEntry || currentEntry.lifecycleRevision !== maintenanceLifecycleRevision) { throw error; } sessionEntry = currentEntry; } else if ( params.opts.deliver !== true || !pendingFinalDeliveryMarker.pendingFinalDeliveryMarkerPersisted || !pendingFinalDeliveryMarker.hasSendableFinalPayload ) { throw error; } log.warn( `Post-turn transcript compaction failed for ${sessionKey ?? sessionId}; continuing final delivery: ${formatErrorMessage(error)}`, ); } finally { maintenance.dispose(); } } const { deliverAgentCommandResult } = await loadDeliveryRuntime(); const deliveryParams = { cfg, deps: params.deps, runtime: params.runtime, opts: params.opts, outboundSession, sessionEntry, result, payloads, assertDeliveryCurrent: () => { params.opts.abortSignal?.throwIfAborted(); assertAgentRunLifecycleGenerationCurrent(lifecycleGeneration); }, onDeliveryResult: ( delivered: Parameters< NonNullable[0]["onDeliveryResult"]> >[0], ) => { const deliveryStatus = delivered.deliveryStatus; const terminalDelivery = normalizeAgentRunTerminalDeliverySnapshot( deliveryStatus && { status: deliveryStatus.status, resultCount: deliveryStatus.resultCount ?? 0, }, ); if (terminalDelivery) { terminal.metadata.terminalDelivery = terminalDelivery; } params.onTerminalDeliveryEvidenceChanged( buildRestartRecoveryTerminalDeliveryEvidence(delivered), ); }, }; deliveryResult = await deliverAgentCommandResult( resolveFreshSessionEntryForDelivery ? { ...deliveryParams, expectedSessionIdForFreshDelivery: runOwnedSessionId, resolveFreshSessionEntryForDelivery, } : deliveryParams, ); if ( sessionStore && sessionKey && !isSubagentSessionKey(sessionKey) && !params.suppressVisibleSessionEffects && !sessionReboundDuringRun ) { const entry = (await resolveFreshSessionEntryForDelivery?.()) ?? sessionStore[sessionKey] ?? sessionEntry; if (!entry) { throw new Error("Cannot clear pending delivery without a session entry"); } // This command only creates replayable markers, so transport-only is stale from an earlier run. const clearStaleTransportOnly = params.opts.deliver === true && !pendingFinalDeliveryMarker.hasSendableFinalPayload && entry.pendingFinalDelivery?.kind === "transport-only"; const clearOwnedPendingFinal = deliveryResult?.deliverySucceeded === true && pendingFinalDeliveryMarker.pendingFinalDeliveryIntentId !== undefined; // Preserve the exact claim snapshot through sibling session writes, then // revalidate its durable owner immediately before committing cleanup. const recoveryClaimEntry = entry.restartRecoveryDeliveryRunId === runId ? entry : sessionEntry?.restartRecoveryDeliveryRunId === runId ? sessionEntry : params.sessionEntry?.restartRecoveryDeliveryRunId === runId ? params.sessionEntry : undefined; const clearsRecoveryCycle = entry.restartRecoveryDeliveryRunId === runId; if (clearOwnedPendingFinal || clearStaleTransportOnly || recoveryClaimEntry) { const now = Date.now(); sessionEntry = await persistAgentSession({ sessionStore, sessionKey, storePath, initialEntry: entry, entry: { ...(clearOwnedPendingFinal || clearStaleTransportOnly ? clearPendingFinalDelivery(entry, now) : { ...entry, updatedAt: now }), ...(recoveryClaimEntry ? buildRestartRecoveryClaimCleanupPatch({ entry: { ...recoveryClaimEntry, restartRecoveryTerminalDeliveryEvidence: entry.restartRecoveryTerminalDeliveryEvidence, restartRecoveryTerminalRunIds: entry.restartRecoveryTerminalRunIds, }, recordTerminalSource: true, terminalDeliveryEvidence: buildRestartRecoveryTerminalDeliveryEvidence( deliveryResult ?? result, ), terminalRunId: runId, }) : {}), ...(clearsRecoveryCycle ? buildMainSessionRecoveryClearPatch(entry) : {}), }, shouldPersist: (current) => shouldPersistCurrentRunSessionCleanup(current, runOwnedSessionId) && (!recoveryClaimEntry || current?.restartRecoveryDeliveryRunId === runId || (!clearsRecoveryCycle && current?.restartRecoveryDeliveryRunId === undefined)) && (!clearOwnedPendingFinal || current?.pendingFinalDelivery?.intentId === pendingFinalDeliveryMarker.pendingFinalDeliveryIntentId) && (!clearStaleTransportOnly || current?.pendingFinalDelivery?.kind === "transport-only"), }); } } hasResultError = Boolean(fallbackExhausted || lifecycle.resolveResultError(result, false)); terminalError = hasResultError ? lifecycle.resolveTerminalError(result, fallbackExhausted, terminal) : terminal.outcome.error; if (hasResultError) { lifecycle.emitResultError(result, fallbackExhausted, terminal); } else { lifecycle.emitEnd(terminal); } } catch (error) { lifecycle.emitPostTurnError(error, terminal); throw error; } finally { await deferredLifecycle.complete(); } // Cancellation can arrive while delivery or deferred cleanup still owns the run. // Record the final fact on the projected reply without changing its JSON output. const outcome = deferredLifecycle.signal.aborted ? mergeAgentRunTerminalOutcome( terminal.outcome, buildAgentRunTerminalOutcomeFromLifecycleEvent({ phase: "end", abortSignal: deferredLifecycle.signal, }), ) : terminal.outcome; return { maintenance: classifyAgentRunTerminalOutcome(outcome) === "success" && !hasResultError && (params.opts.deliver !== true || deliveryResult?.deliverySucceeded === true) ? maintenanceRequest : undefined, deliveryResult: recordAgentRunTerminalOutcome( deliveryResult, hasResultError || classifyAgentRunTerminalOutcome(outcome) !== "success" ? "failed" : "completed", terminalError ? formatErrorMessage(terminalError) : undefined, ), sessionEntry, runOwnedSessionId, sessionReboundDuringRun, }; }