openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
395 lines (394 loc) • 17.2 kB
JavaScript
import { g as isFutureDateTimestampMs } from "./number-coercion-CLj0HTDM.js";
import { r as createLazyPromiseLoader } from "./lazy-promise-DGqyc4Y4.js";
import { t as pruneMapToMaxSize } from "./map-size-CNcWiFKu.js";
import { t as formatErrorMessage } from "./errors-Db3Ymjlb.js";
import { r as generateSecureInt } from "./secure-random-Ds4AFLgz.js";
import { w as sweepStaleRunContexts } from "./agent-run-registry-CKYKdfNd.js";
import { O as tryBeginGatewaySuspendAdmission, f as isGatewayWorkAdmissionClosed } from "./gateway-work-admission-R1IpuDim.js";
import { c as pruneOutboundMedia, l as prunePlaybackTranscodeCache, r as cleanOldMedia } from "./store-Ph57PZ9P.js";
import { r as HEALTH_REFRESH_INTERVAL_MS, s as TICK_INTERVAL_MS, t as DEDUPE_MAX } from "./server-constants-BrVEC7RW.js";
import "./agent-run-terminal-outcome-CigeY75d.js";
import { t as createGatewayActiveWorkSnapshot } from "./gateway-active-work-DdwRky6I.js";
import { i as tryResolveSessionCompatibilityOwnerAgentId } from "./session-request-agent-CCRSEGCB.js";
import { h as pruneExpiredDeliveryQueueTombstones } from "./delivery-queue-sqlite-Bpn2g0Bx.js";
import { n as pruneOrphanedDeliveryQueueMedia } from "./delivery-queue-media-spool-D2PN5qRE.js";
import { a as registerSkillUsageTracking } from "./curator-DE46Wqg0.js";
import { c as removeChatAbortControllerEntry, t as abortChatRunById } from "./chat-abort-qLn3eFOg.js";
import { t as chatAbortMarkerTimestampMs } from "./server-chat-state-CwKZZaYd.js";
import { l as pruneExpiredDevicePairSetupCompletions } from "./device-bootstrap-DRFMRCcr.js";
import "./server-utils-BUZaV07G.js";
import { n as hasRegisteredChatRunForSessionKey } from "./session-active-runs-CCNuJW7B.js";
import "./server-shared-C-7Ahu3n.js";
import { i as WORKTREE_GC_INTERVAL_MS, l as managedWorktrees, u as resolveWorktreeCleanupLimits } from "./service-B1SvAvxa.js";
import { r as checkTelemetryUpdate } from "./telemetry-DmzlffL1.js";
import { t as createManagedWorktreeOwnerPolicy } from "./owner-protection-DS4QvFc2.js";
import { r as pruneStaleControlPlaneBuckets } from "./control-plane-rate-limit-B1YxvgrA.js";
import { i as waitForMediaCleanupDrainsToSettle, n as registerMediaCleanupDrain, r as waitForMediaCleanupDrains, t as MEDIA_CLEANUP_STOP_TIMEOUT_MS } from "./server-media-cleanup-lifecycle-dOPPbnLL.js";
import { s as setBroadcastHealthUpdate } from "./health-state-BQ5pY3AF.js";
function createHostThawRecovery(deps) {
let lastTickAtMs = deps.nowMs();
let pendingFrozenMs;
let pendingChannelRestart = false;
let activeRecovery;
const runStep = async (label, step) => {
try {
await step();
} catch (error) {
deps.logger.error(`host thaw ${label} failed: ${String(error)}`);
}
};
const restartChannels = async (mode) => {
try {
const outcome = await deps.restartChannelsIfIdle(mode);
pendingChannelRestart = outcome.status === "retry";
if (outcome.status === "retry") {
const detail = outcome.reason === "active-work" ? "gateway still has active work" : outcome.reason === "admission-closed" ? "gateway admission is closed" : "one or more channel accounts remain pending";
deps.logger.info(`host thaw channel restart deferred: ${detail}`);
}
} catch (error) {
pendingChannelRestart = true;
deps.logger.error(`host thaw channel restart failed: ${String(error)}`);
}
};
const recover = async (frozenMs) => {
deps.logger.info(`host timing gap detected: process was frozen ~${Math.round(frozenMs)}ms; restarting channels when idle and refreshing health`);
const deferForAdmission = () => {
if (deps.isAdmissionClosed()) {
pendingFrozenMs = Math.max(pendingFrozenMs ?? 0, frozenMs);
deps.logger.info("host thaw recovery deferred: gateway suspension began mid-recovery");
return true;
}
return false;
};
if (deferForAdmission()) return;
await runStep("event-loop reset", deps.resetEventLoopHealth);
if (deferForAdmission()) return;
await restartChannels("new-thaw");
for (const [label, step] of [["health refresh", deps.refreshHealth], ["presence refresh", deps.refreshPresence]]) {
if (deferForAdmission()) return;
await runStep(label, step);
}
};
return { tick: async () => {
const nowMs = deps.nowMs();
const gapMs = nowMs - lastTickAtMs;
lastTickAtMs = nowMs;
if (gapMs >= 75e3) pendingFrozenMs = Math.max(pendingFrozenMs ?? 0, gapMs - TICK_INTERVAL_MS);
if (pendingFrozenMs === void 0 && !pendingChannelRestart || deps.isAdmissionClosed() || activeRecovery) return;
const frozenMs = pendingFrozenMs;
pendingFrozenMs = void 0;
activeRecovery = frozenMs === void 0 ? restartChannels("deferred-retry") : recover(frozenMs);
try {
await activeRecovery;
} finally {
activeRecovery = void 0;
}
} };
}
//#endregion
//#region src/gateway/server-maintenance.ts
const DELIVERY_QUEUE_MEDIA_GC_INTERVAL_MS = 36e5;
const TELEMETRY_MAINTENANCE_INTERVAL_MS = 3e5;
function startGatewayMaintenanceTimers(params) {
setBroadcastHealthUpdate((snap) => {
params.broadcast("health", snap, { stateVersion: {
presence: params.getPresenceVersion(),
health: params.getHealthVersion()
} });
params.nodeSendToAllSubscribed("health", snap);
});
const restartChannelsIfIdle = async (mode) => {
let invalidated = false;
const admission = tryBeginGatewaySuspendAdmission(() => {
invalidated = true;
});
if (!admission) return {
status: "retry",
reason: "admission-closed"
};
let snapshot;
try {
snapshot = createGatewayActiveWorkSnapshot(params.activeWorkInspectors, { ignoreTerminalSessions: true });
} catch (error) {
admission.rollback();
throw error;
}
if (!snapshot.idle) {
admission.rollback();
return {
status: "retry",
reason: "active-work"
};
}
if (!admission.commit()) return {
status: "retry",
reason: "admission-closed"
};
try {
return await params.restartRunningChannels(mode, () => !invalidated) ? { status: "completed" } : {
status: "retry",
reason: "channel-restart-incomplete"
};
} finally {
admission.release();
}
};
const hostThawRecovery = createHostThawRecovery({
nowMs: Date.now,
restartChannelsIfIdle,
refreshHealth: async () => {
await params.refreshGatewayHealthSnapshot({ probe: true });
},
refreshPresence: params.refreshPresence,
resetEventLoopHealth: params.resetEventLoopHealth,
isAdmissionClosed: isGatewayWorkAdmissionClosed,
logger: params.logHealth
});
let nextTelemetryCheckAtMs = Date.now() + generateSecureInt(TELEMETRY_MAINTENANCE_INTERVAL_MS);
const tickInterval = setInterval(() => {
hostThawRecovery.tick();
const now = Date.now();
if (!params.isNixMode && now >= nextTelemetryCheckAtMs) {
nextTelemetryCheckAtMs = now + TELEMETRY_MAINTENANCE_INTERVAL_MS + generateSecureInt(TELEMETRY_MAINTENANCE_INTERVAL_MS);
checkTelemetryUpdate(params.getRuntimeConfig(), { surface: "gateway" }).catch(() => {});
}
const payload = { ts: now };
params.broadcast("tick", payload);
params.nodeSendToAllSubscribed("tick", payload);
}, TICK_INTERVAL_MS);
const healthInterval = setInterval(() => {
params.refreshGatewayHealthSnapshot({ probe: false }).catch((err) => params.logHealth.error(`refresh failed: ${formatErrorMessage(err)}`));
}, HEALTH_REFRESH_INTERVAL_MS);
params.refreshGatewayHealthSnapshot({ probe: false }).catch((err) => params.logHealth.error(`initial refresh failed: ${formatErrorMessage(err)}`));
const runWorktreeGc = params.runWorktreeGc ?? (() => {
const cfg = params.getRuntimeConfig();
return managedWorktrees.gc({
...createManagedWorktreeOwnerPolicy(cfg),
limits: resolveWorktreeCleanupLimits()
});
});
const performWorktreeGc = () => runWorktreeGc().catch((err) => {
params.logHealth.error(`managed worktree cleanup failed: ${formatErrorMessage(err)}`);
});
const worktreeCleanup = setInterval(() => void performWorktreeGc(), WORKTREE_GC_INTERVAL_MS);
performWorktreeGc();
const runDeliveryQueueMediaGc = params.runDeliveryQueueMediaGc ?? (async () => {
try {
pruneExpiredDeliveryQueueTombstones();
} finally {
await pruneOrphanedDeliveryQueueMedia();
}
});
let deliveryQueueMediaGcStartedAtMs = 0;
const deliveryQueueMediaGcLoader = createLazyPromiseLoader(async () => {
try {
await runDeliveryQueueMediaGc();
} catch (error) {
params.logHealth.error(`delivery queue maintenance failed: ${formatErrorMessage(error)}`);
} finally {
deliveryQueueMediaGcLoader.clear();
}
});
const performDeliveryQueueMediaGc = () => {
if (!deliveryQueueMediaGcLoader.peek()) deliveryQueueMediaGcStartedAtMs = Date.now();
return deliveryQueueMediaGcLoader.load();
};
performDeliveryQueueMediaGc();
let devicePairSetupCompletionGcInFlight = null;
const performDevicePairSetupCompletionGc = (nowMs) => {
if (devicePairSetupCompletionGcInFlight) return devicePairSetupCompletionGcInFlight;
devicePairSetupCompletionGcInFlight = pruneExpiredDevicePairSetupCompletions({ nowMs }).then(() => void 0).catch((error) => {
params.logHealth.error(`device pair setup cleanup failed: ${formatErrorMessage(error)}`);
}).finally(() => {
devicePairSetupCompletionGcInFlight = null;
});
return devicePairSetupCompletionGcInFlight;
};
performDevicePairSetupCompletionGc(Date.now());
const skillUsageCleanup = registerSkillUsageTracking();
const dedupeCleanup = setInterval(() => {
const AGENT_RUN_SEQ_MAX = 1e4;
const now = Date.now();
performDevicePairSetupCompletionGc(now);
if (now - deliveryQueueMediaGcStartedAtMs >= DELIVERY_QUEUE_MEDIA_GC_INTERVAL_MS) performDeliveryQueueMediaGc();
const resolveDedupeRunId = (key, entry) => {
if (!key.startsWith("agent:") && !key.startsWith("chat:")) return;
const keyRunId = key.slice(key.indexOf(":") + 1);
if (keyRunId) {
if (params.chatAbortControllers.has(keyRunId) || params.chatQueuedTurns.has(keyRunId)) return keyRunId;
}
const payload = entry.payload;
return payload && typeof payload === "object" && !Array.isArray(payload) ? typeof payload.runId === "string" ? payload.runId.trim() || void 0 : void 0 : void 0;
};
const isPendingAcceptedRunDedupeKey = (key, dedupeEntry) => {
if (!key.startsWith("agent:") && !key.startsWith("pending-chat:")) return false;
const payload = dedupeEntry.payload;
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
if (payload.status !== "accepted") return false;
const expiresAtMs = payload.expiresAtMs;
return isFutureDateTimestampMs(expiresAtMs, { nowMs: now });
};
const isActiveRunDedupeKey = (key, dedupeEntry) => {
const isAgentKey = key.startsWith("agent:");
const isChatKey = key.startsWith("chat:");
if (!isAgentKey && !isChatKey) return false;
const runId = resolveDedupeRunId(key, dedupeEntry);
const entry = runId ? params.chatAbortControllers.get(runId) : void 0;
if (entry) return isAgentKey ? entry.kind === "agent" : entry.kind !== "agent";
return Boolean(isChatKey && runId && params.chatQueuedTurns.has(runId));
};
for (const [k, v] of params.dedupe) {
if (isActiveRunDedupeKey(k, v) || isPendingAcceptedRunDedupeKey(k, v)) continue;
if (now - v.ts > 3e5) params.dedupe.delete(k);
}
if (params.dedupe.size > 1e3) {
const excess = params.dedupe.size - DEDUPE_MAX;
const oldestKeys = [...params.dedupe.entries()].filter(([key, entry]) => !isActiveRunDedupeKey(key, entry) && !isPendingAcceptedRunDedupeKey(key, entry)).toSorted(([, left], [, right]) => left.ts - right.ts).slice(0, excess).map(([key]) => key);
for (const key of oldestKeys) params.dedupe.delete(key);
}
pruneMapToMaxSize(params.agentRunSeq, AGENT_RUN_SEQ_MAX);
for (const [runId, entry] of params.chatAbortControllers) {
const terminalClearOverdue = typeof entry.projectSessionTerminalObservedAt === "number" && now - entry.projectSessionTerminalObservedAt > 15e3;
if (entry.projectSessionTerminalPending === true && !terminalClearOverdue) continue;
if (isFutureDateTimestampMs(entry.expiresAtMs, { nowMs: now })) continue;
if (entry.projectSessionTerminalPersistence) {
const lifecycleGeneration = entry.lifecycleGeneration?.trim();
const sessionKey = entry.sessionKey.trim();
const sessionId = entry.sessionId.trim();
if (entry.controlUiVisible !== false && lifecycleGeneration && sessionKey && sessionId) params.restartRecoveryCandidates.set(runId, {
runId,
lifecycleGeneration,
sessionKey,
sessionId,
observedAt: entry.projectSessionTerminalObservedAt
});
removeChatAbortControllerEntry(params.chatAbortControllers, runId, entry);
continue;
}
if (entry.projectSessionActive === false) {
removeChatAbortControllerEntry(params.chatAbortControllers, runId, entry);
continue;
}
if (!abortChatRunById(params, {
runId,
sessionKey: entry.sessionKey,
stopReason: "timeout"
}).aborted) removeChatAbortControllerEntry(params.chatAbortControllers, runId, entry);
}
const ABORTED_RUN_TTL_MS = 36e5;
pruneStaleControlPlaneBuckets(now);
for (const [runId, record] of params.chatRunState.runs) {
if (record.abortMarker !== void 0) {
if (now - chatAbortMarkerTimestampMs(record.abortMarker) > ABORTED_RUN_TTL_MS) {
params.chatRunState.deleteAbortMarker(runId);
params.chatRunState.clearRun(runId);
}
continue;
}
if (params.chatAbortControllers.has(runId)) continue;
if ([
record.deltaSentAt,
record.bufferUpdatedAt,
record.agentText?.assistant?.lastSentAt,
record.agentText?.thinking?.lastSentAt
].some((timestamp) => timestamp !== void 0 && now - timestamp > ABORTED_RUN_TTL_MS)) params.chatRunState.clearRun(runId);
}
sweepStaleRunContexts();
}, 6e4);
const playbackTranscodeCacheCleanupLoader = createLazyPromiseLoader(async () => {
try {
await prunePlaybackTranscodeCache();
} catch (err) {
params.logHealth.error(`playback transcode cache cleanup failed: ${formatErrorMessage(err)}`);
} finally {
playbackTranscodeCacheCleanupLoader.clear();
}
});
const runManagedOutgoingMediaGc = params.runManagedOutgoingMediaGc ?? (async () => {
const { cleanupManagedOutgoingMediaRecords } = await import("./managed-image-attachments-D18sTbBJ.js");
return await cleanupManagedOutgoingMediaRecords({ hasActiveSessionRun: (sessionKey, agentId) => {
const cfg = params.getRuntimeConfig();
return hasRegisteredChatRunForSessionKey({
context: { chatAbortControllers: params.chatAbortControllers },
sessionKey,
agentId,
defaultAgentId: tryResolveSessionCompatibilityOwnerAgentId(cfg, sessionKey)
});
} });
});
const managedOutgoingCleanupLoader = createLazyPromiseLoader(async () => {
try {
await runManagedOutgoingMediaGc();
} catch (err) {
params.logHealth.error(`managed outgoing media cleanup failed: ${formatErrorMessage(err)}`);
} finally {
managedOutgoingCleanupLoader.clear();
}
});
let mediaCleanupInFlight = null;
const runMediaCleanup = () => {
if (mediaCleanupInFlight) return mediaCleanupInFlight;
const ttlHours = params.getRuntimeConfig().attachments?.ttlHours;
mediaCleanupInFlight = (ttlHours !== void 0 ? cleanOldMedia(ttlHours * 60 * 6e4, {
recursive: true,
pruneEmptyDirs: true
}) : pruneOutboundMedia()).catch((err) => {
params.logHealth.error(`media cleanup failed: ${formatErrorMessage(err)}`);
}).finally(() => {
mediaCleanupInFlight = null;
});
return mediaCleanupInFlight;
};
let mediaCleanupInterval;
let mediaCleanupStopped = false;
const runMediaMaintenance = () => {
if (mediaCleanupStopped) return;
playbackTranscodeCacheCleanupLoader.load();
managedOutgoingCleanupLoader.load();
runMediaCleanup();
};
let mediaCleanupStartPromise;
const startMediaCleanup = () => {
if (mediaCleanupStopped || mediaCleanupInterval || mediaCleanupStartPromise) return;
mediaCleanupStartPromise = waitForMediaCleanupDrainsToSettle().then(() => {
mediaCleanupStartPromise = void 0;
if (mediaCleanupStopped || mediaCleanupInterval) return;
mediaCleanupInterval = setInterval(runMediaMaintenance, 36e5);
runMediaMaintenance();
});
};
let stopMediaCleanupPromise;
const stopMediaCleanup = () => {
stopMediaCleanupPromise ??= (async () => {
mediaCleanupStopped = true;
if (mediaCleanupInterval) {
clearInterval(mediaCleanupInterval);
mediaCleanupInterval = void 0;
}
const pending = [
playbackTranscodeCacheCleanupLoader.peek(),
managedOutgoingCleanupLoader.peek(),
mediaCleanupInFlight
].filter((promise) => promise !== void 0 && promise !== null);
if (pending.length > 0) registerMediaCleanupDrain(Promise.allSettled(pending).then(() => void 0));
return await waitForMediaCleanupDrains({
timeoutMs: MEDIA_CLEANUP_STOP_TIMEOUT_MS,
onTimeout: () => {
params.logHealth.error(`media cleanup drain exceeded ${MEDIA_CLEANUP_STOP_TIMEOUT_MS}ms; retaining shared state until cleanup settles`);
}
});
})();
return stopMediaCleanupPromise;
};
return {
tickInterval,
healthInterval,
dedupeCleanup,
startMediaCleanup,
stopMediaCleanup,
worktreeCleanup,
skillUsageCleanup
};
}
//#endregion
export { startGatewayMaintenanceTimers };