UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

143 lines (142 loc) 5.81 kB
import { D as resolveExpiresAtMsFromDurationMs, F as resolveTimerTimeoutMs } from "./number-coercion-CLj0HTDM.js"; import { i as resolveGlobalSingleton } from "./global-singleton-Dc_stLtU.js"; import { i as resolveRegisteredAgentIdForDir } from "./agent-dir-registry-Dkh28921.js"; import { t as createSubsystemLogger } from "./subsystem-Dy2tqXOS.js"; import "./session-accessor-YsytfDtG.js"; import { d as patchSessionEntryCore } from "./session-accessor.sqlite-entry-CWk3jL7s.js"; import { o as resolveStoredSessionKeyForSessionId } from "./session-Ck6yDW4J.js"; import { AsyncLocalStorage } from "node:async_hooks"; //#region src/agents/session-suspension.ts /** * Session suspension persistence and lifecycle helpers. * * Records quota/manual/circuit suspensions for diagnostics and recovery flows. */ const log = createSubsystemLogger("session-suspension"); const DEFAULT_QUOTA_SUSPENSION_RESUME_MS = 18e5; /** * Bundled gateway chunks share one write queue and shutdown fence so one * module copy cannot persist a suspension after another copy cleaned up. */ const SESSION_SUSPENSION_STATE_KEY = Symbol.for("openclaw.sessionSuspensionRuntimeState"); function getSessionSuspensionState() { return resolveGlobalSingleton(SESSION_SUSPENSION_STATE_KEY, () => ({ suspensionWriteChain: Promise.resolve(), cleanupGeneration: 0, cleanupActive: false })); } const deferredSessionSuspension = new AsyncLocalStorage(); function resolveSessionSuspensionReason(reason) { if (reason === "billing") return "manual"; if (reason === "rate_limit") return "quota_exhausted"; return "circuit_open"; } function runWithDeferredSessionSuspension(run, onDeferred) { return deferredSessionSuspension.run({ claimed: false, onDeferred }, run); } function resolveSessionSuspensionTarget() { const scope = deferredSessionSuspension.getStore(); if (!scope || scope.claimed) return { mode: "suspend" }; scope.claimed = true; return { mode: "defer", defer: (params) => scope.onDeferred?.(params) }; } function fenceSessionSuspensionWritesForGatewayShutdown() { const state = getSessionSuspensionState(); state.cleanupGeneration += 1; state.cleanupActive = true; } function enableSessionSuspensionWritesForGatewayStart() { const state = getSessionSuspensionState(); state.cleanupGeneration += 1; state.cleanupActive = false; } async function suspendSession(params) { const state = getSessionSuspensionState(); const queuedGeneration = state.cleanupGeneration; const run = state.suspensionWriteChain.catch(() => void 0).then(() => suspendSessionQueued(params, queuedGeneration)); state.suspensionWriteChain = run.then(() => void 0, () => void 0); await run; } async function suspendSessionQueued(params, queuedGeneration) { if (!params.cfg) return; const agentIdFromDir = params.agentDir ? resolveRegisteredAgentIdForDir(params.agentDir) : void 0; const { sessionKey, storePath } = resolveStoredSessionKeyForSessionId({ cfg: params.cfg, sessionId: params.sessionId, agentId: params.agentId ?? agentIdFromDir }); if (!sessionKey) return; const ttlMs = resolveTimerTimeoutMs(params.ttlMs, DEFAULT_QUOTA_SUSPENSION_RESUME_MS, 0); const now = Date.now(); const expectedResumeBy = resolveExpiresAtMsFromDurationMs(ttlMs, { nowMs: now }) ?? now; const state = getSessionSuspensionState(); if (state.cleanupActive || state.cleanupGeneration !== queuedGeneration) return; const suspensionGeneration = state.cleanupGeneration; let previousQuotaSuspension; let persistedSuspension; try { persistedSuspension = await patchSessionEntryCore({ storePath, sessionKey }, (entry) => { if (getSessionSuspensionState().cleanupGeneration !== suspensionGeneration) return null; previousQuotaSuspension = entry.quotaSuspension; return { quotaSuspension: { schemaVersion: 1, suspendedAt: now, reason: params.reason, failedProvider: params.failedProvider, failedModel: params.failedModel, summary: params.summary, expectedResumeBy, state: "suspended" } }; }, { skipMaintenance: true, takeCacheOwnership: true }) !== null; } catch (err) { log.warn("failed to persist quota suspension", { sessionId: params.sessionId, error: err instanceof Error ? err.message : String(err) }); return; } const postPatchState = getSessionSuspensionState(); if (persistedSuspension && (postPatchState.cleanupActive || suspensionGeneration !== postPatchState.cleanupGeneration)) try { await patchSessionEntryCore({ storePath, sessionKey }, (entry) => entry.quotaSuspension?.suspendedAt === now && entry.quotaSuspension.reason === params.reason && entry.quotaSuspension.failedProvider === params.failedProvider && entry.quotaSuspension.failedModel === params.failedModel ? { quotaSuspension: previousQuotaSuspension } : null, { skipMaintenance: true, takeCacheOwnership: true }); } catch (err) { log.warn("failed to clear quota suspension after shutdown cleanup", { sessionId: params.sessionId, error: err instanceof Error ? err.message : String(err) }); } } function resetSessionSuspensionStateForTest() { const state = getSessionSuspensionState(); state.cleanupGeneration += 1; state.suspensionWriteChain = Promise.resolve(); state.cleanupActive = false; } function isSessionSuspensionWriteCleanupActiveForTest() { return getSessionSuspensionState().cleanupActive; } if (process.env.VITEST || false) globalThis[Symbol.for("openclaw.sessionSuspensionTestApi")] = { isSessionSuspensionWriteCleanupActiveForTest, resetSessionSuspensionStateForTest }; //#endregion export { runWithDeferredSessionSuspension as a, resolveSessionSuspensionTarget as i, fenceSessionSuspensionWritesForGatewayShutdown as n, suspendSession as o, resolveSessionSuspensionReason as r, enableSessionSuspensionWritesForGatewayStart as t };