diff --git a/CHANGELOG.md b/CHANGELOG.md index d87f58cc1f9..f29f8dcff19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Build/Gateway: route restart, shutdown, respawn, diagnostics, command-queue cleanup, and runtime cleanup through one stable gateway lifecycle runtime entry so rebuilt packages do not strand long-running gateways on stale hashed chunks. Carries forward #73964. Thanks @pashpashpash. - Agents/errors: suppress malformed streaming tool-call JSON fragments before they reach chat surfaces while preserving provider request-validation diagnostics. Fixes #59076; keeps #59080 as duplicate coverage. (#59118) Thanks @singleGanghood. - CLI/models: restore provider-filtered `models list --all --provider ` rows for providers without manifest/static catalog coverage, including Anthropic and Amazon Bedrock, while keeping the compatibility fallback off expensive availability and resolver paths. Thanks @shakkernerd. - CLI/models: move the OpenAI listable catalog into the plugin manifest so `models list --all --provider openai` uses the manifest fast path instead of loading provider runtime normalization hooks. Thanks @shakkernerd. diff --git a/src/cli/gateway-cli/lifecycle.runtime.ts b/src/cli/gateway-cli/lifecycle.runtime.ts new file mode 100644 index 00000000000..88eae141101 --- /dev/null +++ b/src/cli/gateway-cli/lifecycle.runtime.ts @@ -0,0 +1,34 @@ +export { + abortEmbeddedPiRun, + getActiveEmbeddedRunCount, + waitForActiveEmbeddedRuns, +} from "../../agents/pi-embedded-runner/runs.js"; +export { getRuntimeConfig } from "../../config/config.js"; +export { + respawnGatewayProcessForUpdate, + restartGatewayProcessWithFreshPid, +} from "../../infra/process-respawn.js"; +export { + resolveGatewayRestartDeferralTimeoutMs, + consumeGatewayRestartIntentSync, + consumeGatewaySigusr1RestartAuthorization, + isGatewaySigusr1RestartExternallyAllowed, + markGatewaySigusr1RestartHandled, + peekGatewaySigusr1RestartReason, + resetGatewayRestartStateForInProcessRestart, + scheduleGatewaySigusr1Restart, +} from "../../infra/restart.js"; +export { markUpdateRestartSentinelFailure } from "../../infra/restart-sentinel.js"; +export { detectRespawnSupervisor } from "../../infra/supervisor-markers.js"; +export { writeDiagnosticStabilityBundleForFailureSync } from "../../logging/diagnostic-stability-bundle.js"; +export { + getActiveBundledRuntimeDepsInstallCount, + waitForBundledRuntimeDepsInstallIdle, +} from "../../plugins/bundled-runtime-deps-activity.js"; +export { + getActiveTaskCount, + markGatewayDraining, + resetAllLanes, + waitForActiveTasks, +} from "../../process/command-queue.js"; +export { reloadTaskRegistryFromStore } from "../../tasks/runtime-internal.js"; diff --git a/src/cli/gateway-cli/run-loop.ts b/src/cli/gateway-cli/run-loop.ts index 3eb7288cd9f..c3e8779382a 100644 --- a/src/cli/gateway-cli/run-loop.ts +++ b/src/cli/gateway-cli/run-loop.ts @@ -15,48 +15,12 @@ const UPDATE_RESPAWN_HEALTH_POLL_MS = 200; type GatewayRunSignalAction = "stop" | "restart"; type RestartDrainTimeoutMs = number | undefined; -type EmbeddedRunsModule = typeof import("../../agents/pi-embedded-runner/runs.js"); -type RuntimeConfigModule = typeof import("../../config/config.js"); -type ProcessRespawnModule = typeof import("../../infra/process-respawn.js"); -type RestartSentinelModule = typeof import("../../infra/restart-sentinel.js"); -type RestartModule = typeof import("../../infra/restart.js"); -type SupervisorMarkersModule = typeof import("../../infra/supervisor-markers.js"); -type DiagnosticStabilityBundleModule = - typeof import("../../logging/diagnostic-stability-bundle.js"); -type BundledRuntimeDepsActivityModule = - typeof import("../../plugins/bundled-runtime-deps-activity.js"); -type CommandQueueModule = typeof import("../../process/command-queue.js"); -type RuntimeInternalModule = typeof import("../../tasks/runtime-internal.js"); +type GatewayLifecycleRuntimeModule = typeof import("./lifecycle.runtime.js"); -let embeddedRunsModule: Promise | undefined; -let runtimeConfigModule: Promise | undefined; -let processRespawnModule: Promise | undefined; -let restartSentinelModule: Promise | undefined; -let restartModule: Promise | undefined; -let supervisorMarkersModule: Promise | undefined; -let diagnosticStabilityBundleModule: Promise | undefined; -let bundledRuntimeDepsActivityModule: Promise | undefined; -let commandQueueModule: Promise | undefined; -let runtimeInternalModule: Promise | undefined; +let gatewayLifecycleRuntimeModule: Promise | undefined; -const loadEmbeddedRunsModule = () => - (embeddedRunsModule ??= import("../../agents/pi-embedded-runner/runs.js")); -const loadRuntimeConfigModule = () => (runtimeConfigModule ??= import("../../config/config.js")); -const loadProcessRespawnModule = () => - (processRespawnModule ??= import("../../infra/process-respawn.js")); -const loadRestartSentinelModule = () => - (restartSentinelModule ??= import("../../infra/restart-sentinel.js")); -const loadRestartModule = () => (restartModule ??= import("../../infra/restart.js")); -const loadSupervisorMarkersModule = () => - (supervisorMarkersModule ??= import("../../infra/supervisor-markers.js")); -const loadDiagnosticStabilityBundleModule = () => - (diagnosticStabilityBundleModule ??= import("../../logging/diagnostic-stability-bundle.js")); -const loadBundledRuntimeDepsActivityModule = () => - (bundledRuntimeDepsActivityModule ??= import("../../plugins/bundled-runtime-deps-activity.js")); -const loadCommandQueueModule = () => - (commandQueueModule ??= import("../../process/command-queue.js")); -const loadRuntimeInternalModule = () => - (runtimeInternalModule ??= import("../../tasks/runtime-internal.js")); +const loadGatewayLifecycleRuntimeModule = () => + (gatewayLifecycleRuntimeModule ??= import("./lifecycle.runtime.js")); function createRestartIterationHook(onRestart: () => Promise | void): () => Promise { let isFirstIteration = true; @@ -137,7 +101,7 @@ export async function runGatewayLoop(params: { }; const writeStabilityBundle = async (reason: string, error?: unknown) => { const { writeDiagnosticStabilityBundleForFailureSync } = - await loadDiagnosticStabilityBundleModule(); + await loadGatewayLifecycleRuntimeModule(); const result = writeDiagnosticStabilityBundleForFailureSync(reason, error); if ("message" in result) { gatewayLog.warn(result.message); @@ -165,10 +129,12 @@ export async function runGatewayLoop(params: { const handleRestartAfterServerClose = async (restartReason?: string) => { const hadLock = await releaseLockIfHeld(); const isUpdateRestart = restartReason === "update.run"; - const { respawnGatewayProcessForUpdate, restartGatewayProcessWithFreshPid } = - await loadProcessRespawnModule(); - const { detectRespawnSupervisor } = await loadSupervisorMarkersModule(); - const { markUpdateRestartSentinelFailure } = await loadRestartSentinelModule(); + const { + detectRespawnSupervisor, + markUpdateRestartSentinelFailure, + respawnGatewayProcessForUpdate, + restartGatewayProcessWithFreshPid, + } = await loadGatewayLifecycleRuntimeModule(); if (isUpdateRestart) { const respawn = respawnGatewayProcessForUpdate(); @@ -279,10 +245,8 @@ export async function runGatewayLoop(params: { const SHUTDOWN_TIMEOUT_MS = SUPERVISOR_STOP_TIMEOUT_MS - 5_000; const resolveRestartDrainTimeoutMs = async (): Promise => { try { - const [{ getRuntimeConfig }, { resolveGatewayRestartDeferralTimeoutMs }] = await Promise.all([ - loadRuntimeConfigModule(), - loadRestartModule(), - ]); + const { getRuntimeConfig, resolveGatewayRestartDeferralTimeoutMs } = + await loadGatewayLifecycleRuntimeModule(); const timeoutMs = getRuntimeConfig().gateway?.reload?.deferralTimeoutMs; return resolveGatewayRestartDeferralTimeoutMs(timeoutMs); } catch { @@ -351,15 +315,16 @@ export async function runGatewayLoop(params: { // On restart, wait for in-flight agent turns to finish before // tearing down the server so buffered messages are delivered. if (isRestart) { - const [ - { abortEmbeddedPiRun, getActiveEmbeddedRunCount, waitForActiveEmbeddedRuns }, - { getActiveBundledRuntimeDepsInstallCount, waitForBundledRuntimeDepsInstallIdle }, - { getActiveTaskCount, markGatewayDraining, waitForActiveTasks }, - ] = await Promise.all([ - loadEmbeddedRunsModule(), - loadBundledRuntimeDepsActivityModule(), - loadCommandQueueModule(), - ]); + const { + abortEmbeddedPiRun, + getActiveBundledRuntimeDepsInstallCount, + getActiveEmbeddedRunCount, + getActiveTaskCount, + markGatewayDraining, + waitForActiveEmbeddedRuns, + waitForActiveTasks, + waitForBundledRuntimeDepsInstallIdle, + } = await loadGatewayLifecycleRuntimeModule(); const createStillPendingDrainLogger = () => setInterval(() => { gatewayLog.warn( @@ -429,7 +394,7 @@ export async function runGatewayLoop(params: { const onSigterm = () => { gatewayLog.info("signal SIGTERM received"); void (async () => { - const { consumeGatewayRestartIntentSync } = await loadRestartModule(); + const { consumeGatewayRestartIntentSync } = await loadGatewayLifecycleRuntimeModule(); request(consumeGatewayRestartIntentSync() ? "restart" : "stop", "SIGTERM"); })(); }; @@ -446,7 +411,7 @@ export async function runGatewayLoop(params: { markGatewaySigusr1RestartHandled, peekGatewaySigusr1RestartReason, scheduleGatewaySigusr1Restart, - } = await loadRestartModule(); + } = await loadGatewayLifecycleRuntimeModule(); const authorized = consumeGatewaySigusr1RestartAuthorization(); if (!authorized) { if (!isGatewaySigusr1RestartExternallyAllowed()) { @@ -482,15 +447,11 @@ export async function runGatewayLoop(params: { // new work from draining. The same boundary also discards stale restart // deferral timers and reloads the task registry from durable state so // cancelled/completed work is not kept alive by old in-memory maps. - const [ - { resetAllLanes }, - { resetGatewayRestartStateForInProcessRestart }, - { reloadTaskRegistryFromStore }, - ] = await Promise.all([ - loadCommandQueueModule(), - loadRestartModule(), - loadRuntimeInternalModule(), - ]); + const { + reloadTaskRegistryFromStore, + resetAllLanes, + resetGatewayRestartStateForInProcessRestart, + } = await loadGatewayLifecycleRuntimeModule(); resetAllLanes(); resetGatewayRestartStateForInProcessRestart(); reloadTaskRegistryFromStore(); diff --git a/src/infra/tsdown-config.test.ts b/src/infra/tsdown-config.test.ts index 031af3a623f..9e9418c7139 100644 --- a/src/infra/tsdown-config.test.ts +++ b/src/infra/tsdown-config.test.ts @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import { bundledPluginRoot } from "openclaw/plugin-sdk/test-fixtures"; import { describe, expect, it } from "vitest"; import tsdownConfig from "../../tsdown.config.ts"; @@ -41,6 +42,13 @@ function entryKeys(config: TsdownConfigEntry): string[] { return Object.keys(config.entry); } +function entrySources(config: TsdownConfigEntry): Record { + if (!config.entry || Array.isArray(config.entry)) { + return {}; + } + return config.entry; +} + function hasBundledPluginRuntimeEntry(config: TsdownConfigEntry): boolean { const keys = entryKeys(config); return keys.includes("index") || keys.includes("runtime-api"); @@ -56,6 +64,10 @@ function unifiedDistGraph(): TsdownConfigEntry | undefined { ); } +function readGatewayRunLoopSource(): string { + return readFileSync(new URL("../cli/gateway-cli/run-loop.ts", import.meta.url), "utf8"); +} + describe("tsdown config", () => { it("keeps core, plugin runtime, plugin-sdk, bundled root plugins, and bundled hooks in one dist graph", () => { const distGraph = unifiedDistGraph(); @@ -66,6 +78,7 @@ describe("tsdown config", () => { "agents/auth-profiles.runtime", "agents/model-catalog.runtime", "agents/models-config.runtime", + "cli/gateway-lifecycle.runtime", "plugins/memory-state", "subagent-registry.runtime", "task-registry-control.runtime", @@ -85,6 +98,24 @@ describe("tsdown config", () => { ); }); + it("keeps gateway lifecycle lazy runtime behind one stable dist entry", () => { + const distGraph = unifiedDistGraph(); + + expect(entrySources(distGraph as TsdownConfigEntry)).toEqual( + expect.objectContaining({ + "cli/gateway-lifecycle.runtime": "src/cli/gateway-cli/lifecycle.runtime.ts", + }), + ); + }); + + it("routes gateway run-loop lifecycle imports through the stable runtime boundary", () => { + const importSpecifiers = [ + ...readGatewayRunLoopSource().matchAll(/import\(["']([^"']+)["']\)/gu), + ].map((match) => match[1]); + + expect(new Set(importSpecifiers)).toEqual(new Set(["./lifecycle.runtime.js"])); + }); + it("emits staged bundled plugins as separate extension graphs", () => { const stagedGraphs = asConfigArray(tsdownConfig).filter( (config) => typeof config.outDir === "string" && config.outDir.startsWith("dist/extensions/"), diff --git a/tsdown.config.ts b/tsdown.config.ts index c371663de99..ebd894782be 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -212,6 +212,7 @@ function buildCoreDistEntries(): Record { "agents/auth-profiles.runtime": "src/agents/auth-profiles.runtime.ts", "agents/model-catalog.runtime": "src/agents/model-catalog.runtime.ts", "agents/models-config.runtime": "src/agents/models-config.runtime.ts", + "cli/gateway-lifecycle.runtime": "src/cli/gateway-cli/lifecycle.runtime.ts", "plugins/memory-state": "src/plugins/memory-state.ts", "subagent-registry.runtime": "src/agents/subagent-registry.runtime.ts", "task-registry-control.runtime": "src/tasks/task-registry-control.runtime.ts",