UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

408 lines (407 loc) • 15.7 kB
import { t as runInDetachedAsyncContext } from "./detached-async-context-D4YGqfxh.mjs"; import { t as createDeferredCore } from "./deferred-D0La5CRk.mjs"; import { d as sleepWithAbort, o as racePromiseWithAbortSignal } from "./src-D1BHGNTj.mjs"; import "./abort-signal-QqAmLWTl.mjs"; import { t as formatErrorMessage } from "./errors-BvOkmQM5.mjs"; import { a as isSqliteLockError } from "./sqlite-error-diagnostics-CWRyv4lz.mjs"; import { t as createSubsystemLogger } from "./subsystem-4NzR43uz.mjs"; import { c as resolveOpenClawStateSqlitePath } from "./openclaw-state-db.paths-CmKkMYdo.mjs"; import { h as withSqliteReadOnlyWorkerScope } from "./sqlite-readonly-worker-RO6waSGe.mjs"; import { n as normalizeAgentId } from "./agent-id-D1-Q6vR4.mjs"; import "./session-key-DY3MOX4N.mjs"; import { a as sameFileMutationFingerprint } from "./file-descriptor-BWLpOgrg.mjs"; import { n as readSqliteIntegrityFileIdentity } from "./sqlite-file-generation-HEkrmgCv.mjs"; import "./openclaw-agent-db-contract-OYt2DCoK.mjs"; import { o as isSameOpenClawAgentDatabasePath, r as createOpenClawAgentDatabasePathMatcher } from "./openclaw-agent-db.paths-BbmZlEP5.mjs"; import { _ as readAgentDatabaseAdmissionRefusal, d as failPendingAgentDatabase, g as preparePendingAgentDatabase, h as listAgentDatabaseAdmissionRefusals, l as createAgentDatabaseInspectionRefusal } from "./agent-database-admission-CRwW7FsI.mjs"; import { r as cloneEnvWithPlatformSemantics } from "./config-env-vars-BzUE_dOh.mjs"; import { o as readAgentDeletionJournalStatusInWorker } from "./agent-deletion-journal.read-BRl4UyPn.mjs"; import "./backoff-BrzXG7iD.mjs"; import { n as withAgentDatabasePreparationContext } from "./agent-database-preparation-context-DK375nya.mjs"; import { t as retryAsync } from "./retry-Bgt50HOo.mjs"; import { t as createPermitPool } from "./permit-pool-DWNEROq5.mjs"; import { statSync } from "node:fs"; import { AsyncLocalStorage } from "node:async_hooks"; //#region src/state/agent-database-startup.ts const PREPARATION_PROGRESS_INTERVAL_MS = 6e4; function recoveryTiming(recovery, now = performance.now()) { return { elapsedMs: Math.round(now - recovery.startedAt), phaseDurationsMs: { ...recovery.phaseDurationsMs, [recovery.phase]: Math.round((recovery.phaseDurationsMs[recovery.phase] ?? 0) + now - recovery.phaseStartedAt) } }; } function readSchemaSourceWitness(pathname) { try { const files = [ "", "-wal", "-journal" ].map((suffix) => statSync(`${pathname}${suffix}`, { bigint: true, throwIfNoEntry: false })); return files[0] && files.every((file) => !file || file.isFile()) ? files : void 0; } catch { return; } } function matchesSchemaSourceWitness(before, after) { return Boolean(after && before.every((file, index) => { const current = after[index]; return file ? current && sameFileMutationFingerprint(file, current) : !current; })); } function matchesInspectionPath(paths, target, samePath = isSameOpenClawAgentDatabasePath) { return paths.some((pathname) => { try { return samePath(pathname, target); } catch { return false; } }); } const log = createSubsystemLogger("state/agent-admission"); const startupAdmission = new AsyncLocalStorage(); /** Startup owns readers until the Gateway adopts them; only the Gateway activates agents. */ var AgentDatabaseStartupAdmission = class { constructor(deferInspections = true) { this.deferInspections = deferInspections; this.controller = new AbortController(); this.activation = createDeferredCore(); this.work = /* @__PURE__ */ new Set(); this.pending = /* @__PURE__ */ new Map(); this.adopted = false; this.activated = false; this.stopped = false; this.publication = Promise.resolve(); this.opening = createPermitPool(2); this.migrating = createPermitPool(4); } get signal() { return this.controller.signal; } get isStopped() { return this.stopped; } startProgress() { this.progressTimer ??= setInterval(() => { const now = performance.now(); for (const [agentId, recovery] of this.pending) log.warn("agent database startup preparation still running", { agentId, phase: recovery.phase, elapsedMs: Math.round(now - recovery.startedAt), phaseElapsedMs: Math.round(now - recovery.phaseStartedAt), ...recovery.phase === "publication-wait" ? { publishingAgentId: this.publishingAgentId } : {} }); }, PREPARATION_PROGRESS_INTERVAL_MS); this.progressTimer.unref(); } clearProgress() { clearInterval(this.progressTimer); this.progressTimer = void 0; } /** Full readiness stays fresh; only unchanged compatibility headers cross into bootstrap. */ prepareSchemaHeaders(env) { const prepared = { statePath: resolveOpenClawStateSqlitePath(env), headers: /* @__PURE__ */ new Map() }; this.preparedSchemaHeaders = prepared; return (pathname) => { const before = readSchemaSourceWitness(pathname); return ({ version, writerAppVersion, agentSchemaMeta }) => { if (!this.stopped && this.preparedSchemaHeaders === prepared && before && version === 25 && matchesSchemaSourceWitness(before, readSchemaSourceWitness(pathname))) prepared.headers.set(pathname, { inspection: { version, writerAppVersion, agentSchemaMeta }, witness: before }); }; }; } takePreparedSchemaHeaders(env) { const prepared = this.preparedSchemaHeaders; this.preparedSchemaHeaders = void 0; return (pathname, supportedVersion) => { const header = prepared?.headers.get(pathname); return !this.stopped && prepared?.statePath === resolveOpenClawStateSqlitePath(env) && header?.inspection.version === supportedVersion && matchesSchemaSourceWitness(header.witness, readSchemaSourceWitness(pathname)) ? header.inspection : void 0; }; } /** Joins startup work already scheduled before a background consumer begins. */ get pendingPreparation() { return this.work.size > 0 ? Promise.allSettled(this.work) : void 0; } get hasPendingAgents() { return this.pending.size > 0; } /** Join only the current agent preparation, without holding channel startup or healthy agents. */ waitForAgentPreparation(agentId, options = {}) { const pending = this.pending.get(normalizeAgentId(agentId)); if (!pending || readAgentDatabaseAdmissionRefusal(agentId, options) !== pending.refusal) return; return racePromiseWithAbortSignal(pending.completion, options.signal ? AbortSignal.any([this.signal, options.signal]) : this.signal); } track(work) { this.work.add(work); work.then(() => this.work.delete(work), () => this.work.delete(work)); } scheduling(env, runtimePaths, runtimeAgentIds) { const samePath = createOpenClawAgentDatabasePathMatcher(); return { signal: this.signal, canDefer: (target) => this.deferInspections && target.agentId !== void 0 && runtimeAgentIds.has(target.agentId) && matchesInspectionPath(runtimePaths, target.path, samePath), track: (work) => this.track(work), defer: (inspections, reason) => this.defer({ env, inspections, reason }) }; } recordInspectionFailure(target, inspection, error) { this.signal.throwIfAborted(); if (!target.agentId) return false; (inspection.agentRefusals ??= []).push(createAgentDatabaseInspectionRefusal({ agentId: target.agentId, paths: [target.path], reason: formatErrorMessage(error), cause: error })); return true; } captureRefusals(env) { return new Map(listAgentDatabaseAdmissionRefusals({ env }).filter((refusal) => this.pending.get(refusal.agentId)?.refusal === refusal).map((refusal) => [refusal.agentId, refusal])); } reuseRefusal(target, inspection, priorRefusals) { const refusal = target.agentId && priorRefusals?.get(target.agentId); if (!refusal || !matchesInspectionPath(refusal.paths, target.path)) return false; (inspection.agentRefusals ??= []).push(refusal); return true; } defer(params) { this.signal.throwIfAborted(); const env = cloneEnvWithPlatformSemantics(params.env); const grouped = /* @__PURE__ */ new Map(); for (const inspection of params.inspections) { const agentId = inspection.target.agentId; if (!agentId) throw new Error(`Cannot defer a database without an agent owner: ${inspection.target.path}`); grouped.set(agentId, [...grouped.get(agentId) ?? [], inspection]); } const refusals = []; for (const [agentId, inspections] of grouped) { const paths = [...new Set(inspections.map(({ target }) => target.path))]; const refusal = createAgentDatabaseInspectionRefusal({ agentId, paths, pending: true, reason: `Agent ${agentId} has not completed startup inspection and preparation. ${params.reason}` }); const startedAt = performance.now(); const completion = createDeferredCore(); const recovery = { refusal, completion: completion.promise, startedAt, phase: "inspection", phaseStartedAt: startedAt, phaseDurationsMs: {} }; const phase = (name) => { const now = performance.now(); recovery.phaseDurationsMs = recoveryTiming(recovery, now).phaseDurationsMs; recovery.phase = name; recovery.phaseStartedAt = now; }; this.pending.set(agentId, recovery); this.startProgress(); refusals.push(refusal); log.info(refusal.reason, { agentId, paths }); const witnesses = paths.map((pathname) => { try { return { pathname, identity: readSqliteIntegrityFileIdentity(pathname) }; } catch (error) { return { pathname, error }; } }); const checked = Promise.allSettled(inspections.map(({ result }) => result)); const work = runInDetachedAsyncContext(async () => { const publicationComplete = createDeferredCore(); try { const results = await checked; phase("activation"); const activation = await this.activation.promise; if (!activation || this.stopped) return; const assertCurrent = () => { this.signal.throwIfAborted(); if (!activation.isCurrent() || this.pending.get(agentId) !== recovery) throw new Error(`Gateway no longer owns preparation for agent ${agentId}`); for (const witness of witnesses) { if (!witness.identity) throw witness.error; readSqliteIntegrityFileIdentity(witness.pathname, witness.identity); } }; const assertNotDeleted = async () => { assertCurrent(); const deletion = await readAgentDeletionJournalStatusInWorker(agentId, { env }, this.signal); assertCurrent(); if (deletion !== "absent") throw new Error(`Agent ${agentId} was deleted during startup inspection`); }; assertCurrent(); for (const result of results) { if (result.status === "rejected") throw result.reason; const inspection = result.value; if (inspection.incompatible.length || inspection.indeterminate.length || inspection.agentRefusals?.length || inspection.pendingMigrations?.length) throw new Error(inspection.agentRefusals?.map((entry) => entry.reason).join("; ") || inspection.indeterminate.map((entry) => entry.reason).join("; ") || `Agent ${agentId} database requires Doctor before preparation`); } phase("readiness"); await racePromiseWithAbortSignal(activation.preparationReady, this.signal); assertCurrent(); await withSqliteReadOnlyWorkerScope(async () => { await assertNotDeleted(); await preparePendingAgentDatabase(refusal, { env, assertCurrent }, async () => { const input = { agentId, paths, env, signal: this.signal, assertCurrent, phase }; const retryLockedPreparation = async (run) => { let preparationAttempts = 0; await retryAsync(async () => { if (preparationAttempts++ > 0) await assertNotDeleted(); assertCurrent(); await run(); }, { attempts: 4, minDelayMs: 500, maxDelayMs: 8e3, jitter: .2, sleep: (ms) => sleepWithAbort(ms, this.signal), shouldRetry: isSqliteLockError, onRetry: ({ attempt }) => log.warn("agent database startup locked; retrying preparation", { agentId, phase: recovery.phase, attempt }) }); }; await retryLockedPreparation(async () => { phase("open-wait"); const release = await this.opening.acquire({ signal: this.signal }); try { assertCurrent(); phase("open"); await activation.openAgent(input); } finally { release?.(); } }); await retryLockedPreparation(async () => { phase("migration-wait"); const releaseMigration = await this.migrating.acquire({ signal: this.signal }); try { assertCurrent(); phase("migration"); await activation.migrateAgent(input); } finally { releaseMigration?.(); } }); phase("publication-wait"); const previous = this.publication; this.publication = publicationComplete.promise; await previous; assertCurrent(); this.publishingAgentId = agentId; await activation.publishAgent(input); phase("publication"); await assertNotDeleted(); }); }, { signal: this.signal, deadlineOwnedByCaller: true }); log.info("agent database recovered after background inspection and preparation", { agentId, paths, ...recoveryTiming(recovery) }); } catch (error) { if (!this.stopped) { const reason = formatErrorMessage(error); failPendingAgentDatabase(refusal, error, { env }); log.warn("agent database remains degraded", { agentId, paths, reason, repairHint: readAgentDatabaseAdmissionRefusal(agentId, { env })?.repairHint, ...recoveryTiming(recovery) }); } } finally { if (this.pending.get(agentId) === recovery) this.pending.delete(agentId); if (this.pending.size === 0) this.clearProgress(); if (this.publishingAgentId === agentId) this.publishingAgentId = void 0; publicationComplete.resolve(); completion.resolve(); } }); this.track(work); } return refusals; } adopt() { this.signal.throwIfAborted(); if (this.adopted) throw new Error("Agent database startup admission already belongs to a Gateway"); this.adopted = true; return { stop: () => this.stop() }; } activate(activation) { if (!this.adopted || this.stopped || this.activated) return; this.activated = true; this.activation.resolve(activation); } async releaseStartup() { if (!this.adopted) await this.stop(); } stop() { return this.stopping ??= (async () => { this.stopped = true; this.clearProgress(); this.preparedSchemaHeaders = void 0; this.controller.abort(/* @__PURE__ */ new Error("Gateway stopped during agent database inspection")); this.activation.resolve(void 0); while (this.work.size > 0) await Promise.allSettled(this.work); })(); } }; function getAgentDatabaseStartupAdmission() { const scope = startupAdmission.getStore(); return scope?.isStopped ? void 0 : scope; } async function withAgentDatabaseStartupAdmission(run, options = {}) { const admission = getAgentDatabaseStartupAdmission() ?? new AgentDatabaseStartupAdmission(options.deferInspections); try { return await startupAdmission.run(admission, () => withAgentDatabasePreparationContext(() => admission, () => run(admission))); } finally { await admission.releaseStartup(); } } //#endregion export { withAgentDatabaseStartupAdmission as n, getAgentDatabaseStartupAdmission as t };