From 23ee50e1329eb23e09e1a1fc59ead2fd788cd181 Mon Sep 17 00:00:00 2001 From: pallaoro Date: Wed, 16 Sep 2026 20:00:21 +0200 Subject: [PATCH] Serve the scheduler and flow port only from the gateway process OpenClaw loads plugins in every process that touches config: the gateway, but also `openclaw --help`, `openclaw cron list`, plugin discovery, setup. Each of those armed a TriggerScheduler and tried to bind the flow port. A CLI that exits took its timers with it; one that hung (a diagnostic `openclaw --help` left running for 13 days on a customer box) became a second scheduler for the same trigger records and squatted on 18793 with stale code, which the real gateway then silently yielded to. shouldServe() keys on two host signals: api.registrationMode must be "full" (discovery, cli-metadata and setup loads never serve) and the process must be the one started as `openclaw gateway run`. CLAWFLOW_SERVE=1 forces it on for tests and unusual hosts; CLAWFLOW_NO_SERVE keeps its meaning for spawned child agents and operator CLIs and always wins. The decision is logged at register time so a rollout can be verified from the journal. --- package-lock.json | 4 ++-- package.json | 2 +- src/plugin/index.ts | 40 ++++++++++++++++++++++++++++----- tests/plugin-reregister.test.ts | 1 + tests/should-serve.test.ts | 33 +++++++++++++++++++++++++++ 5 files changed, 72 insertions(+), 8 deletions(-) create mode 100644 tests/should-serve.test.ts diff --git a/package-lock.json b/package-lock.json index 83d2d62..924c847 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@clawnify/clawflow", - "version": "1.6.3", + "version": "1.6.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@clawnify/clawflow", - "version": "1.6.3", + "version": "1.6.4", "license": "MIT", "dependencies": { "croner": "^10.0.1" diff --git a/package.json b/package.json index dbdbb18..5c41693 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@clawnify/clawflow", - "version": "1.6.3", + "version": "1.6.4", "description": "The n8n for agents. A declarative, AI-native workflow format that agents can read, write, and run.", "type": "module", "main": "./dist/index.js", diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 3aab8ea..c479cfa 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -30,6 +30,32 @@ export function activeTriggerScheduler(): TriggerScheduler | null { return activeScheduler; } +/** + * Whether this process should own the box's scheduler and flow server. + * + * OpenClaw loads plugins in every process that touches config: the gateway, + * but also `openclaw --help`, `openclaw cron list`, plugin discovery, setup. + * Before this guard each of those armed a scheduler and tried to bind the + * flow port. A CLI that exits took its timers with it; one that hung (a + * diagnostic `openclaw --help` left running for 13 days on a customer box) + * became a second scheduler for the same trigger records and squatted on + * 18793 with stale code, which the real gateway then silently yielded to. + * + * Two signals, both from the host: `api.registrationMode` is "full" only + * when the host wants a complete registration (discovery / cli-metadata / + * setup-only loads never should serve), and the gateway process is the one + * started as `openclaw gateway run`. CLAWFLOW_SERVE=1 forces it on for + * tests and unusual hosts; CLAWFLOW_NO_SERVE keeps its meaning for spawned + * child agents and operator CLIs. + */ +export function shouldServe(api: { registrationMode?: string }, argv: readonly string[] = process.argv, env: NodeJS.ProcessEnv = process.env): boolean { + if (env.CLAWFLOW_NO_SERVE) return false; + if (env.CLAWFLOW_SERVE === "1") return true; + if (api.registrationMode && api.registrationMode !== "full") return false; + const args = argv.slice(2); + return args.includes("gateway") && (args.includes("run") || args.length === 1); +} + // ---- OpenClaw Plugin: clawflow --------------------------------------------------- // Registers eleven tools: // @@ -125,7 +151,11 @@ function register(api: PluginApi) { flowsDir, logger: api.logger, }); - if (!process.env.CLAWFLOW_NO_SERVE) { + const serving = shouldServe(api as { registrationMode?: string }); + api.logger?.info( + `clawflow: registrationMode=${(api as { registrationMode?: string }).registrationMode ?? "unknown"} argv=${process.argv.slice(2, 4).join(" ") || "-"} → ${serving ? "serving (scheduler + flow server)" : "not serving (not the gateway)"}`, + ); + if (serving) { activeScheduler?.stop(); activeScheduler = scheduler; activeTriggerStore = triggerStore; @@ -133,10 +163,10 @@ function register(api: PluginApi) { } // ---- Flow server (optional) --------------------------------------------------- - // Skip when spawned as a child agent (CLAWFLOW_NO_SERVE) to avoid port conflicts. - // Started after the scheduler so its trigger routes can reach it; the - // accessor keeps them on the currently armed scheduler across reloads. - if (pluginCfg.serve && !process.env.CLAWFLOW_NO_SERVE) { + // Only the gateway process serves (see shouldServe). Started after the + // scheduler so its trigger routes can reach it; the accessor keeps them on + // the currently armed scheduler across reloads. + if (pluginCfg.serve && serving) { startFlowServer({ runner, serve: pluginCfg.serve, diff --git a/tests/plugin-reregister.test.ts b/tests/plugin-reregister.test.ts index 66824a5..aeba35b 100644 --- a/tests/plugin-reregister.test.ts +++ b/tests/plugin-reregister.test.ts @@ -4,6 +4,7 @@ import * as fs from "fs"; import * as path from "path"; import * as os from "os"; +process.env.CLAWFLOW_SERVE = "1"; // the test process is not `openclaw gateway run` import plugin, { activeTriggerScheduler } from "../src/plugin/index.js"; import type { FlowDefinition } from "../src/index.js"; diff --git a/tests/should-serve.test.ts b/tests/should-serve.test.ts new file mode 100644 index 0000000..c18bc2e --- /dev/null +++ b/tests/should-serve.test.ts @@ -0,0 +1,33 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { shouldServe } from "../src/plugin/index.js"; + +// The scheduler and flow server belong to exactly one process per box: the +// gateway. Every other load of the plugin must stay passive. +describe("shouldServe", () => { + const gateway = ["node", "/usr/bin/openclaw", "gateway", "run"]; + const help = ["node", "/usr/bin/openclaw", "--help"]; + const cronList = ["node", "/usr/bin/openclaw", "cron", "list", "--agent", "main"]; + + it("serves in the gateway process with a full registration", () => { + assert.equal(shouldServe({ registrationMode: "full" }, gateway, {}), true); + assert.equal(shouldServe({}, gateway, {}), true, "older hosts without registrationMode"); + assert.equal(shouldServe({ registrationMode: "full" }, ["node", "openclaw", "gateway"], {}), true, "bare `openclaw gateway`"); + }); + + it("stays passive in CLI, discovery and setup loads", () => { + assert.equal(shouldServe({ registrationMode: "full" }, help, {}), false, "openclaw --help"); + assert.equal(shouldServe({ registrationMode: "full" }, cronList, {}), false, "openclaw cron list"); + assert.equal(shouldServe({ registrationMode: "cli-metadata" }, gateway, {}), false); + assert.equal(shouldServe({ registrationMode: "discovery" }, gateway, {}), false); + assert.equal(shouldServe({ registrationMode: "setup-only" }, gateway, {}), false); + assert.equal(shouldServe({ registrationMode: "full" }, ["node", "openclaw", "gateway", "status"], {}), false, "gateway status is a client, not the daemon"); + }); + + it("honours the explicit env switches either way", () => { + assert.equal(shouldServe({ registrationMode: "full" }, gateway, { CLAWFLOW_NO_SERVE: "1" }), false); + assert.equal(shouldServe({ registrationMode: "cli-metadata" }, help, { CLAWFLOW_SERVE: "1" }), true); + assert.equal(shouldServe({ registrationMode: "full" }, gateway, { CLAWFLOW_NO_SERVE: "1", CLAWFLOW_SERVE: "1" }), false, "NO_SERVE wins: a spawned child must never bind"); + }); +});