UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

636 lines (635 loc) 29.6 kB
import { t as drainGlobalSingletonLifecycleState } from "./global-singleton-Dc_stLtU.js"; import { t as createSubsystemLogger } from "./subsystem-Dy2tqXOS.js"; import { r as logVerbose } from "./globals-CTaGxEqj.js"; import { r as clearActivePluginRegistry } from "./runtime-BL4wZfTq.js"; import { i as disposeAllSessionMcpRuntimes } from "./agent-bundle-mcp-manager-api-DCHQm4w1.js"; import { t as getGlobalHookRunner } from "./hook-runner-global-0kfmMG4T.js"; import { m as closePluginStateDatabase } from "./plugin-state-store-C6hmUuuk.js"; import { t as disposeRegisteredAgentHarnesses } from "./registry-D1_C7waJ.js"; import { i as listChannelPlugins } from "./registry-Cz6cv4VC.js"; import "./plugins-Cozj1Enf.js"; import { c as WEBSOCKET_CLOSE_GRACE_MS } from "./server-constants-BrVEC7RW.js"; import { a as disposeAcpSessionManagerInstance, t as getAcpSessionManager } from "./manager-Bbp2Ogkb.js"; import { c as createAgentRunRestartAbortError } from "./run-termination-jabvTOp5.js"; import { r as captureGatewayReplyRunRestartAbort } from "./reply-run-registry.registry-BMo0d96L.js"; import "./reply-run-registry-BlmBKV23.js"; import { n as fenceSessionSuspensionWritesForGatewayShutdown } from "./session-suspension-DlBl5K-b.js"; import { n as createInternalHookEvent, u as triggerInternalHook } from "./internal-hooks-Dgwjgwuj.js"; import "./agent-bundle-mcp-tools-CejVZWe3.js"; import { s as resolveStableSessionEndTranscript } from "./session-transcript-files.fs-D0B5TSty.js"; import { a as markGatewayRestartTrace, n as collectGatewayProcessMemoryUsageMb, o as measureGatewayRestartTrace, s as recordGatewayRestartTrace } from "./restart-trace-DlhJ9XxP.js"; import { r as clearSessionTypingState } from "./session-typing-state-C5klAPZ6.js"; import { c as removeChatAbortControllerEntry, i as isChatAbortControllerEntryAbortable, t as abortChatRunById } from "./chat-abort-qLn3eFOg.js"; import { n as createChatAbortMarker } from "./server-chat-state-CwKZZaYd.js"; import { n as abortQueuedChatTurns } from "./chat-queued-turns-hwiIcr_T.js"; import { i as resolveGatewayShutdownNotice, n as createGatewayShutdownTimeout, r as recordGatewayShutdownWarning } from "./server-shutdown-Djoi94QR.js"; import { i as buildSessionEndHookPayload, n as listActiveSessionsForShutdown, t as forgetActiveSessionForShutdown } from "./active-sessions-shutdown-tracker-NctYi_BN.js"; import { cleanupSessionResources } from "@openclaw/ai/internal/runtime"; //#region src/gateway/server-run-shutdown.ts const shutdownLog$1 = createSubsystemLogger("gateway/shutdown"); const RESTART_REPLY_DRAIN_POLL_MS = 100; const RESTART_TERMINAL_PERSISTENCE_WAIT_TIMEOUT_MS = 1e3; const RESTART_MARKER_SLOW_WARNING_MS = 1e3; function getRestartReplyDrainCounts(params) { const pendingReplyCount = params.getPendingReplyCount(); const activeRuns = listRestartDrainRuns(params.chatAbortControllers).length; const queuedTurns = Array.from(params.chatQueuedTurns.values(), (entry) => entry.controller.signal.aborted).filter((aborted) => !aborted).length; return { pendingReplies: Number.isFinite(pendingReplyCount) && pendingReplyCount > 0 ? Math.floor(pendingReplyCount) : 0, activeRuns, queuedTurns }; } function listUnabortedRuns(chatAbortControllers) { return Array.from(chatAbortControllers.entries()).filter(([, entry]) => !entry.controller.signal.aborted); } function listRestartDrainRuns(chatAbortControllers) { return listUnabortedRuns(chatAbortControllers).filter(([, entry]) => entry.registrationCleanupRequested !== true); } function listRestartRecoveryRuns(chatAbortControllers) { return listUnabortedRuns(chatAbortControllers).filter(([, entry]) => entry.controlUiVisible !== false && (entry.registrationCleanupRequested !== true || entry.projectSessionTerminalPersisted !== true)); } function formatRestartReplyDrainDetails(counts) { const details = []; if (counts.pendingReplies > 0) details.push(`${counts.pendingReplies} pending reply(ies)`); if (counts.activeRuns > 0) details.push(`${counts.activeRuns} active run(s)`); if (counts.queuedTurns > 0) details.push(`${counts.queuedTurns} queued turn(s)`); return details.length > 0 ? details.join(", ") : "no pending reply work"; } async function sleepForRestartReplyDrain(delayMs) { await new Promise((resolve) => { setTimeout(resolve, delayMs).unref?.(); }); } async function waitForRestartReplyDrain(params) { const timeoutMs = Math.max(0, Math.floor(params.timeoutMs)); let counts = getRestartReplyDrainCounts(params); if (counts.pendingReplies <= 0 && counts.activeRuns <= 0 && counts.queuedTurns <= 0) return { drained: true, elapsedMs: 0, counts }; if (timeoutMs <= 0) return { drained: false, elapsedMs: 0, counts }; const startedAt = Date.now(); for (;;) { const elapsedMs = Date.now() - startedAt; if (elapsedMs >= timeoutMs) return { drained: false, elapsedMs, counts }; await sleepForRestartReplyDrain(Math.min(RESTART_REPLY_DRAIN_POLL_MS, timeoutMs - elapsedMs)); counts = getRestartReplyDrainCounts(params); if (counts.pendingReplies <= 0 && counts.activeRuns <= 0 && counts.queuedTurns <= 0) return { drained: true, elapsedMs: Date.now() - startedAt, counts }; } } function collectActiveRestartSessionRefs(params) { const activeRuns = /* @__PURE__ */ new Map(); const observedAt = Date.now(); const addRun = (run) => { activeRuns.set(`${run.runId}\u0000${run.lifecycleGeneration}`, { ...run, observedAt: run.observedAt ?? observedAt }); }; for (const [runId, entry] of listRestartRecoveryRuns(params.chatAbortControllers)) { const sessionKey = entry.sessionKey.trim(); const sessionId = (entry.kind === "agent" || !sessionKey ? void 0 : params.resolveActiveSessionIdForKey?.(sessionKey)) || entry.sessionId.trim(); if (runId && entry.lifecycleGeneration && sessionKey && sessionId) addRun({ runId, lifecycleGeneration: entry.lifecycleGeneration, sessionKey, sessionId, observedAt: entry.projectSessionTerminalObservedAt }); } for (const candidate of params.restartRecoveryCandidates?.values() ?? []) { const resolvedSessionId = params.resolveActiveSessionIdForKey?.(candidate.sessionKey); addRun({ ...candidate, sessionId: resolvedSessionId || candidate.sessionId }); } return [...activeRuns.values()]; } async function settleTerminalSessionPersistenceForRestart(chatAbortControllers) { const pending = listUnabortedRuns(chatAbortControllers).flatMap(([, entry]) => { const persistence = entry.projectSessionTerminalPersistence; if (entry.projectSessionActive !== false || !persistence) return []; return [{ entry, persistence }]; }); if (pending.length === 0) return; const timeout = createGatewayShutdownTimeout(RESTART_TERMINAL_PERSISTENCE_WAIT_TIMEOUT_MS, () => null); const results = await Promise.race([Promise.allSettled(pending.map(({ persistence }) => persistence)), timeout.promise]); timeout.clear(); if (!results) { shutdownLog$1.warn(`terminal session persistence did not settle within ${RESTART_TERMINAL_PERSISTENCE_WAIT_TIMEOUT_MS}ms; preserving restart recovery`); return; } for (const [index, result] of results.entries()) { const tracked = pending[index]; if (!tracked || tracked.entry.projectSessionTerminalPersistence !== tracked.persistence) continue; tracked.entry.projectSessionTerminalPending = false; tracked.entry.projectSessionTerminalPersistence = void 0; if (result.status === "fulfilled") tracked.entry.projectSessionTerminalPersisted = true; } } async function markActiveRunsForRestartRecovery(params) { if (!params.markMainSessionsAbortedForRestart) return 0; await settleTerminalSessionPersistenceForRestart(params.chatAbortControllers); const activeRuns = collectActiveRestartSessionRefs(params); const activeEntries = new Map(params.chatAbortControllers); const recoveryCandidates = new Map(params.restartRecoveryCandidates); const abortReplyRuns = captureGatewayReplyRunRestartAbort(params.resolveGatewayContext); try { const markerTimeout = createGatewayShutdownTimeout(RESTART_MARKER_SLOW_WARNING_MS, () => "timeout"); const markerOutcome = Promise.resolve(params.markMainSessionsAbortedForRestart({ resolveGatewayContext: params.resolveGatewayContext, activeRuns, reason: params.reason, isActiveRun: (run) => { const entry = params.chatAbortControllers.get(run.runId); const candidate = params.restartRecoveryCandidates?.get(run.runId); return entry && entry === activeEntries.get(run.runId) && !entry.controller.signal.aborted && (entry.registrationCleanupRequested !== true || entry.projectSessionTerminalPersisted !== true) && entry.lifecycleGeneration === run.lifecycleGeneration || candidate !== void 0 && candidate === recoveryCandidates.get(run.runId) && candidate.lifecycleGeneration === run.lifecycleGeneration; } })).then(() => ({ status: "completed" }), (error) => ({ status: "failed", error })); const firstOutcome = await Promise.race([markerOutcome, markerTimeout.promise]); markerTimeout.clear(); if (firstOutcome === "timeout") { shutdownLog$1.warn(`restart session marker did not settle within ${RESTART_MARKER_SLOW_WARNING_MS}ms; waiting before shutdown`); recordGatewayShutdownWarning(params.warnings, "restart-main-session-marker"); const delayedOutcome = await markerOutcome; if (delayedOutcome.status === "failed") throw delayedOutcome.error; } else if (firstOutcome.status === "failed") throw firstOutcome.error; for (const run of activeRuns) if (params.restartRecoveryCandidates?.get(run.runId) === recoveryCandidates.get(run.runId)) params.restartRecoveryCandidates?.delete(run.runId); } catch (err) { shutdownLog$1.warn(`failed to mark active main session(s) for restart recovery: ${String(err)}`); recordGatewayShutdownWarning(params.warnings, "restart-main-session-marker"); } return abortReplyRuns((sessionId, error) => { shutdownLog$1.warn(`failed to cancel reply for restart: sessionId=${sessionId} error=${String(error)}`); recordGatewayShutdownWarning(params.warnings, "restart-reply-abort"); }); } /** Cancels only this Gateway's exact controller registrations. */ function abortActiveRuns(params, restart) { let aborted = 0; for (const [runId, entry] of listUnabortedRuns(params.chatAbortControllers)) { if (!isChatAbortControllerEntryAbortable(entry)) continue; if (entry.projectSessionActive === false) { entry.abortStopReason = restart ? "restart" : "rpc"; entry.controller.abort(restart ? createAgentRunRestartAbortError() : void 0); removeChatAbortControllerEntry(params.chatAbortControllers, runId, entry); params.chatRunState.getOrCreate(runId).abortMarker = createChatAbortMarker(); params.chatRunState.clearRun(runId); const removed = params.removeChatRun(runId, runId, entry.sessionKey); params.agentRunSeq.delete(runId); if (removed?.clientRunId) params.agentRunSeq.delete(removed.clientRunId); aborted += 1; continue; } if (abortChatRunById(params, { runId, sessionKey: entry.sessionKey, stopReason: restart ? "restart" : "rpc" }).aborted) aborted += 1; } return aborted; } /** Abort queued owners before active teardown can promote them into the closing runtime. */ function abortQueuedTurns(params, restart) { const matches = Array.from(params.chatQueuedTurns, ([runId, entry]) => ({ runId, entry })); return abortQueuedChatTurns(params.chatQueuedTurns, matches, restart ? "restart" : void 0).length; } /** Completes grace and requests cancellation before execution joining begins. */ async function prepareGatewayRunShutdown(params) { if (!params.restart) { abortQueuedTurns(params, false); abortActiveRuns(params, false); return; } const initialCounts = getRestartReplyDrainCounts(params); let drainResult; if (initialCounts.pendingReplies > 0 || initialCounts.activeRuns > 0 || initialCounts.queuedTurns > 0) { const timeoutMs = Math.max(0, Math.floor(params.timeoutMs)); if (timeoutMs > 0) shutdownLog$1.info(`waiting for ${formatRestartReplyDrainDetails(initialCounts)} before restart shutdown (timeout ${timeoutMs}ms)`); drainResult = await waitForRestartReplyDrain({ getPendingReplyCount: params.getPendingReplyCount, chatAbortControllers: params.chatAbortControllers, chatQueuedTurns: params.chatQueuedTurns, timeoutMs }); if (!drainResult.drained) { shutdownLog$1.warn(`restart reply drain timed out after ${drainResult.elapsedMs}ms with ${formatRestartReplyDrainDetails(drainResult.counts)} still active; continuing shutdown`); recordGatewayShutdownWarning(params.warnings, "restart-reply-drain"); } } const abortedQueuedTurns = abortQueuedTurns(params, true); if (drainResult?.drained === false && abortedQueuedTurns > 0) shutdownLog$1.warn(`aborted ${abortedQueuedTurns} queued turn(s) during restart shutdown`); const abortedReplies = await markActiveRunsForRestartRecovery({ ...params, reason: "gateway restart shutdown" }); const abortedRuns = abortActiveRuns(params, true) + abortedReplies; if (drainResult?.drained) shutdownLog$1.info(`restart reply drain completed after ${drainResult.elapsedMs}ms`); else if (drainResult && abortedRuns > 0) shutdownLog$1.warn(`aborted ${abortedRuns} active run(s) during restart shutdown`); } //#endregion //#region src/gateway/server-close.ts const shutdownLog = createSubsystemLogger("gateway/shutdown"); const GATEWAY_SHUTDOWN_HOOK_TIMEOUT_MS = 5e3; const GATEWAY_PRE_RESTART_HOOK_TIMEOUT_MS = 1e4; const ACTIVE_SESSIONS_SHUTDOWN_DRAIN_TIMEOUT_MS = 2e3; const WEBSOCKET_CLOSE_FORCE_CONTINUE_MS = 250; const HTTP_CLOSE_GRACE_MS = 1e3; const HTTP_CLOSE_FORCE_WAIT_MS = 5e3; const MCP_RUNTIME_CLOSE_GRACE_MS = 5e3; const LSP_RUNTIME_CLOSE_GRACE_MS = 5e3; const EMBEDDING_PROVIDER_CLOSE_GRACE_MS = 5e3; const AGENT_HARNESS_CLOSE_GRACE_MS = 5e3; function createCloseStepTimer(reason) { return (name, run) => { markGatewayRestartTrace(`restart.close.${name}.begin`); return measureGatewayRestartTrace(`restart.close.${name}`, run, [["reason", reason]]); }; } /** Run one shutdown step and record a warning instead of aborting the whole close. */ async function shutdownStep(name, fn, warnings) { try { await fn(); return true; } catch (err) { const detail = err instanceof Error ? err.message : String(err); shutdownLog.warn(`${name}: ${detail}`); recordGatewayShutdownWarning(warnings, name); return false; } } async function triggerGatewayLifecycleHookWithTimeout(params) { let timeout; const hookPromise = triggerInternalHook(params.event); hookPromise.catch(() => void 0); try { const result = await Promise.race([hookPromise.then(() => "completed"), new Promise((resolve) => { timeout = setTimeout(() => resolve("timeout"), params.timeoutMs); timeout.unref?.(); })]); if (result === "timeout") shutdownLog.warn(`${params.hookName} hook timed out after ${params.timeoutMs}ms; continuing shutdown`); return result; } finally { if (timeout) clearTimeout(timeout); } } async function disposeRuntimeWithShutdownGrace(params) { const disposePromise = Promise.resolve().then(params.dispose).catch((err) => { shutdownLog.warn(`${params.label} runtime disposal failed during shutdown: ${String(err)}`); recordGatewayShutdownWarning(params.warnings, params.label); }); const disposeTimeout = createGatewayShutdownTimeout(params.graceMs, () => { shutdownLog.warn(`${params.label} runtime disposal exceeded ${params.graceMs}ms; continuing shutdown`); recordGatewayShutdownWarning(params.warnings, params.label); }); await Promise.race([disposePromise, disposeTimeout.promise]); disposeTimeout.clear(); } async function runGatewayClosePrelude(params) { params.stopDiagnostics?.(); params.clearSkillsRefreshTimer?.(); await params.skillsChangeUnsub?.(); params.disposeAuthRateLimiter?.(); params.disposeBrowserAuthRateLimiter(); await params.stopChannelHealthMonitor?.(); params.stopReadinessEventLoopHealth?.(); await params.closeMcpServer?.().catch(() => {}); } function isServerNotRunningError(err) { return Boolean(err && typeof err === "object" && "code" in err && err.code === "ERR_SERVER_NOT_RUNNING"); } async function waitForHttpClose(params) { const timeout = createGatewayShutdownTimeout(params.timeoutMs, () => false); try { return await Promise.race([params.closePromise.then(() => true, (err) => { throw err; }), timeout.promise]).catch((err) => { const detail = err instanceof Error ? err.message : String(err); shutdownLog.warn(`${params.label}: ${detail}`); recordGatewayShutdownWarning(params.warnings, params.label); return true; }); } finally { timeout.clear(); } } async function closeHttpListener(params) { const { server, label, warnings } = params; server.closeIdleConnections?.(); const closePromise = new Promise((resolve, reject) => { server.close((err) => { if (!err || isServerNotRunningError(err)) { resolve(); return; } reject(err); }); }); closePromise.catch(() => void 0); if (await waitForHttpClose({ closePromise, timeoutMs: HTTP_CLOSE_GRACE_MS, label, warnings })) return; shutdownLog.warn(`${label} close exceeded ${HTTP_CLOSE_GRACE_MS}ms; forcing connection shutdown and waiting for close`); recordGatewayShutdownWarning(warnings, label); server.closeAllConnections?.(); if (!await waitForHttpClose({ closePromise, timeoutMs: HTTP_CLOSE_FORCE_WAIT_MS, label, warnings })) throw new Error(`${label} close still pending after forced connection shutdown (${HTTP_CLOSE_FORCE_WAIT_MS}ms)`); } async function prepareGatewayClose(params, opts) { const start = Date.now(); const warnings = []; const notice = resolveGatewayShutdownNotice(opts); const { reason } = notice; const restartExpectedMs = notice.restartExpectedMs ?? null; const measureCloseStep = createCloseStepTimer(reason); fenceSessionSuspensionWritesForGatewayShutdown(); shutdownLog.debug(`shutdown started: ${reason}`); await shutdownStep("update-check", () => params.updateCheckStop?.(), warnings); await measureCloseStep("config-reloader", () => shutdownStep("config-reloader", () => params.configReloader.stop(), warnings)); await measureCloseStep("gateway-shutdown-hook", () => shutdownStep("gateway:shutdown", async () => { if (await triggerGatewayLifecycleHookWithTimeout({ event: createInternalHookEvent("gateway", "shutdown", "gateway:shutdown", { reason, restartExpectedMs }), hookName: "gateway:shutdown", timeoutMs: GATEWAY_SHUTDOWN_HOOK_TIMEOUT_MS }) === "timeout") recordGatewayShutdownWarning(warnings, "gateway:shutdown"); }, warnings)); if (restartExpectedMs !== null) await measureCloseStep("gateway-pre-restart-hook", () => shutdownStep("gateway:pre-restart", async () => { if (await triggerGatewayLifecycleHookWithTimeout({ event: createInternalHookEvent("gateway", "pre-restart", "gateway:pre-restart", { reason, restartExpectedMs }), hookName: "gateway:pre-restart", timeoutMs: GATEWAY_PRE_RESTART_HOOK_TIMEOUT_MS }) === "timeout") recordGatewayShutdownWarning(warnings, "gateway:pre-restart"); }, warnings)); const drainTimeoutMs = typeof opts?.drainTimeoutMs === "number" && Number.isFinite(opts.drainTimeoutMs) ? Math.max(0, Math.floor(opts.drainTimeoutMs)) : 0; await measureCloseStep("reply-drain", () => prepareGatewayRunShutdown({ ...params, restart: restartExpectedMs !== null, timeoutMs: drainTimeoutMs, warnings })); return { start, notice, warnings }; } async function completeGatewayClose(params, preparation) { const { start, notice, warnings } = preparation; const { reason } = notice; const restartExpectedMs = notice.restartExpectedMs ?? null; let pluginServicesCleanup; let mediaCleanupStopResult = "timed-out"; const measureCloseStep = createCloseStepTimer(reason); try { if (params.drainActiveSessionsForShutdown) await measureCloseStep("session-end-drain", () => shutdownStep("session-end-drain", async () => { const drainReason = restartExpectedMs !== null ? "restart" : "shutdown"; const result = await params.drainActiveSessionsForShutdown({ reason: drainReason, totalTimeoutMs: ACTIVE_SESSIONS_SHUTDOWN_DRAIN_TIMEOUT_MS }); if (result.timedOut) { shutdownLog.warn(`session-end-drain timed out after ${ACTIVE_SESSIONS_SHUTDOWN_DRAIN_TIMEOUT_MS}ms after ${result.emittedSessionIds.length} sessions; continuing shutdown`); recordGatewayShutdownWarning(warnings, "session-end-drain"); } }, warnings)); if (params.bonjourStop) await shutdownStep("bonjour", () => params.bonjourStop(), warnings); await measureCloseStep("acp-session-manager", () => shutdownStep("acp-session-manager", () => disposeAcpSessionManagerInstance(getAcpSessionManager(), "gateway-shutdown"), warnings)); if (params.pluginServices) { const cleanup = Promise.resolve().then(() => params.pluginServices.stop()); pluginServicesCleanup = cleanup; await measureCloseStep("plugin-services", () => disposeRuntimeWithShutdownGrace({ label: "plugin-services", dispose: () => cleanup, graceMs: MCP_RUNTIME_CLOSE_GRACE_MS, warnings })); } await measureCloseStep("channels", async () => { const channelIds = params.channelIds ?? listChannelPlugins().map((plugin) => plugin.id); for (const channelId of channelIds) await shutdownStep(`channel/${channelId}`, () => params.stopChannel(channelId), warnings); }); await shutdownStep("code-mode-runs", () => params.disposeAllCodeModeRuns(), warnings); await disposeRuntimeWithShutdownGrace({ label: "agent-harnesses", dispose: disposeRegisteredAgentHarnesses, graceMs: AGENT_HARNESS_CLOSE_GRACE_MS, warnings }); await shutdownStep("ai-session-resources", () => cleanupSessionResources(), warnings); await shutdownStep("provider-transport-dispatchers", () => params.closeProviderTransportDispatcherPool(), warnings); await measureCloseStep("bundle-runtimes", async () => { await Promise.all([disposeRuntimeWithShutdownGrace({ label: "bundle-mcp", dispose: params.disposeSessionMcpRuntimes ?? disposeAllSessionMcpRuntimes, graceMs: MCP_RUNTIME_CLOSE_GRACE_MS, warnings }), disposeRuntimeWithShutdownGrace({ label: "bundle-lsp", dispose: params.disposeBundleLspRuntimes ?? params.disposeAllBundleLspRuntimes, graceMs: LSP_RUNTIME_CLOSE_GRACE_MS, warnings })]); }); try { mediaCleanupStopResult = await params.stopMediaCleanup(); } catch (err) { shutdownLog.warn(`media-cleanup: ${err instanceof Error ? err.message : String(err)}`); recordGatewayShutdownWarning(warnings, "media-cleanup"); } if (mediaCleanupStopResult !== "drained") recordGatewayShutdownWarning(warnings, "media-cleanup"); await measureCloseStep("gmail-watcher", () => shutdownStep("gmail-watcher", () => params.stopGmailWatcher(), warnings)); await shutdownStep("cron", () => params.cron.stopAndDrain ? params.cron.stopAndDrain() : params.cron.stop(), warnings); await shutdownStep("heartbeat-runner", () => params.heartbeatRunner.stop(), warnings); await shutdownStep("task-registry-maintenance", () => params.stopTaskRegistryMaintenance?.(), warnings); for (const timer of params.nodePresenceTimers.values()) clearInterval(timer); params.nodePresenceTimers.clear(); if (params.maintenance) { clearInterval(params.maintenance.tickInterval); clearInterval(params.maintenance.healthInterval); clearInterval(params.maintenance.dedupeCleanup); clearInterval(params.maintenance.worktreeCleanup); params.maintenance.skillUsageCleanup(); } if (params.agentUnsub) await shutdownStep("agent-unsub", () => params.agentUnsub(), warnings); if (params.heartbeatUnsub) await shutdownStep("heartbeat-unsub", () => params.heartbeatUnsub(), warnings); if (params.transcriptUnsub) await shutdownStep("transcript-unsub", () => params.transcriptUnsub(), warnings); if (params.lifecycleUnsub) await shutdownStep("lifecycle-unsub", () => params.lifecycleUnsub(), warnings); if (params.taskUnsub) await shutdownStep("task-unsub", () => params.taskUnsub(), warnings); params.chatRunState.clear(); let clientCloseFailures = 0; for (const c of params.clients) try { c.socket.close(1012, c.connectionKind === "worker" ? "gateway-shutdown" : "service restart"); } catch { clientCloseFailures++; } if (clientCloseFailures > 0) { shutdownLog.warn(`failed to close ${clientCloseFailures} WebSocket client(s)`); recordGatewayShutdownWarning(warnings, "ws-clients"); } params.clients.clear(); if (params.wss) await measureCloseStep("websocket-server", async () => { const wsClients = params.wss?.clients ?? /* @__PURE__ */ new Set(); const closePromise = new Promise((resolve) => { params.wss?.close(() => resolve()); }); const websocketGraceTimeout = createGatewayShutdownTimeout(WEBSOCKET_CLOSE_GRACE_MS, () => false); const closedWithinGrace = await Promise.race([closePromise.then(() => true), websocketGraceTimeout.promise]); websocketGraceTimeout.clear(); if (!closedWithinGrace) { shutdownLog.warn(`websocket server close exceeded ${WEBSOCKET_CLOSE_GRACE_MS}ms; forcing shutdown continuation with ${wsClients.size} tracked client(s)`); recordGatewayShutdownWarning(warnings, "websocket-server"); for (const client of wsClients) try { client.terminate(); } catch {} const websocketForceTimeout = createGatewayShutdownTimeout(WEBSOCKET_CLOSE_FORCE_CONTINUE_MS, () => { shutdownLog.warn(`websocket server close still pending after ${WEBSOCKET_CLOSE_FORCE_CONTINUE_MS}ms force window; continuing shutdown`); }); await Promise.race([closePromise, websocketForceTimeout.promise]); websocketForceTimeout.clear(); } }); await params.finishRequestEntries?.(); clearSessionTypingState(); const transportServers = params.httpServers && params.httpServers.length > 0 ? params.httpServers : params.httpServer ? [params.httpServer] : []; try { if (transportServers.length > 0) await measureCloseStep("http-server", async () => { const failure = (await Promise.allSettled(transportServers.map((server, index) => closeHttpListener({ server, label: transportServers.length > 1 ? `http-server[${index}]` : "http-server", warnings })))).find((result) => result.status === "rejected"); if (failure) throw failure.reason; }); } finally { if (params.tailscaleCleanup) await shutdownStep("tailscale", () => params.tailscaleCleanup(), warnings); } await disposeRuntimeWithShutdownGrace({ label: "embedding-providers", dispose: params.drainRetainedOpenAiEmbeddingProviders, graceMs: EMBEDDING_PROVIDER_CLOSE_GRACE_MS, warnings }); } finally { await pluginServicesCleanup?.catch(() => {}); await params.finishRequestEntries?.(); if (mediaCleanupStopResult === "drained") await shutdownStep("plugin-state-store", () => closePluginStateDatabase(), warnings); await shutdownStep("plugin-host-registry", clearActivePluginRegistry, warnings); try { await drainGlobalSingletonLifecycleState(restartExpectedMs === null ? "close" : "restart"); } finally { try { params.clearSecretsRuntimeSnapshot?.(); } catch {} } } const durationMs = Date.now() - start; if (warnings.length > 0) shutdownLog.warn(`shutdown completed in ${durationMs}ms with warnings: ${warnings.join(", ")}`); else shutdownLog.info(`shutdown completed cleanly in ${durationMs}ms`); recordGatewayRestartTrace("restart.close.total", durationMs, [ ["reason", reason], ["restartExpectedMs", restartExpectedMs ?? "none"], ...collectGatewayProcessMemoryUsageMb() ]); return { durationMs, warnings }; } //#endregion //#region src/gateway/active-sessions-shutdown-drain.ts async function drainActiveSessionsForShutdown(params) { const tracked = listActiveSessionsForShutdown(); if (tracked.length === 0) return { emittedSessionIds: [], timedOut: false }; const totalTimeoutMs = Math.max(100, Math.floor(params.totalTimeoutMs ?? 2e3)); const emittedSessionIds = []; const hookRunner = getGlobalHookRunner(); let settledEmissions = 0; const drain = Promise.allSettled(tracked.map(async (entry) => { try { forgetActiveSessionForShutdown(entry.sessionId); emittedSessionIds.push(entry.sessionId); if (!hookRunner?.hasHooks("session_end")) return; const transcript = resolveStableSessionEndTranscript({ sessionId: entry.sessionId, storePath: entry.storePath, sessionFile: entry.sessionFile, agentId: entry.agentId }); const payload = buildSessionEndHookPayload({ sessionId: entry.sessionId, sessionKey: entry.sessionKey, agentId: entry.agentId, reason: params.reason, sessionFile: transcript.sessionFile, transcriptArchived: transcript.transcriptArchived }); await hookRunner.runSessionEnd(payload.event, payload.context); } catch (err) { logVerbose(`session_end hook failed during shutdown drain: ${String(err)}`); } finally { settledEmissions++; } })); let timer; const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve("timeout"), totalTimeoutMs); timer.unref?.(); }); try { if (await Promise.race([drain.then(() => "ok"), timeout]) === "timeout") { logVerbose(`shutdown session-end drain timed out after ${totalTimeoutMs}ms with ${tracked.length - settledEmissions} session_end handler(s) still pending`); return { emittedSessionIds, timedOut: true }; } return { emittedSessionIds, timedOut: false }; } finally { if (timer) clearTimeout(timer); } } //#endregion export { completeGatewayClose, drainActiveSessionsForShutdown, prepareGatewayClose, runGatewayClosePrelude };