Skip to content

[Feature]: Document and/or centralize the per-event cfg re-resolve contract for channel plugins #90842

Description

@caster-Q

⚠️ Correction (2026-06-06)

The original root-cause framing in this issue is incorrect. After tracing the live 2026.5.28 runtime more carefully, the actual mechanism is:

  1. getRuntimeConfig(options) is a literal alias for loadConfig(options). loadConfig by default returns loadPinnedRuntimeConfig(...) — the same pinned OpenClawConfig reference. Only options.pin === false bypasses the pin, but the public channel-plugin API (core.config.loadConfig) does not expose options in its type signature.
  2. The only code path that replaces the snapshot is applySnapshot in server-reload-handlers-*.js:152, which has an early return when isNoopReloadPlan(plan) && !followUp.requiresRestart. Because bindings/agents/routing are all kind: "none" in BASE_RELOAD_RULES_TAIL, any CLI binding-only edit produces a noop plan → applySnapshot returns → setRuntimeConfigSnapshot() is never called.
  3. src/routing/resolve-route.ts caches by cfg object identity (WeakMap<OpenClawConfig, ...>), so as long as the snapshot ref doesn't change, routing returns the same evaluated bindings forever.

Implication: the per-inbound getRuntimeConfig() pattern documented in #90852 (now closed) does not actually refresh routing in current runtime. Telegram/qqbot in-tree consumers are also effectively no-ops under this code path unless they explicitly pass { pin: false } (which would force a fresh disk read + parse + validate on every inbound — not a desirable hot-path cost). The qqbot fix #73567 most likely worked on the version it was written against and silently regressed when loadPinnedRuntimeConfig was introduced — worth maintainer verification.

Real fix surface — one of:

  • A. Unconditional snapshot swap in applySnapshot when changedPaths is non-empty, regardless of isNoopReloadPlan. Snapshot ref always advances; routing WeakMap always misses; no new reload actions triggered. Smallest, safest core change.
  • B. New ReloadRule kind "swap-snapshot-only" (or similar) for bindings/agents/routing. Same effect as A but more explicit — the planner classifies, applySnapshot honors a "swap but no action" plan.
  • C. (workaround, not a fix) Channel plugins watch openclaw.json mtime themselves and force loadConfig({ pin: false }) — penalizes hot path, only kicks the problem down the road.

Happy to draft a PR for direction A or B once a maintainer signals which they prefer. I won't repeat the docs-PR mistake — any next PR will be source-level and gated on maintainer ack first.

Original (now superseded) analysis below for context.


Summary

The "channel plugins must call getRuntimeConfig() per-inbound to pick up bindings[] edits without a gateway restart" contract is real, intentional, and load-bearing — but it is currently undocumented, and every channel plugin re-discovers it the hard way by shipping a regression first.

Problem to solve

src/gateway/config-reload-plan.ts:120-140 declares bindings, agents, and routing as kind: "none":

const BASE_RELOAD_RULES_TAIL: ReloadRule[] = [
  ...
  { prefix: "agents",   kind: "none" },
  { prefix: "bindings", kind: "none" },
  { prefix: "routing",  kind: "none" },
  ...
];

This is by design (PR #7747 attempted to flip them to kind: "hot" and was closed; issue #27706 proposed a binding-only hot-reload API and was closed-as-stale). The expected design is that channel plugins re-evaluate routing per inbound message using a fresh cfg reference from openclaw/plugin-sdk/runtime-config-snapshot.getRuntimeConfig(), so that src/routing/resolve-route.ts:205 WeakMap<OpenClawConfig, EvaluatedBindingsCache> misses on the new ref and re-reads bindings[].

But this contract is not written down anywhere a channel author would find it. As a result every channel plugin currently in the tree has had to re-discover it independently:

Plugin Status Discovery cost
extensions/telegram Inlined the pattern from the start (bot-handlers.runtime.ts:967 cfg: telegramDeps.getRuntimeConfig() // "Fresh config for bindings lookup") Discovered implicitly during initial author
extensions/qqbot Shipped without it, regressed in production, fixed by PR #73567 (merged 2026-05-11) — added ActiveCfgProvider (52-line helper) Shipped a regression, then ~80-line fix
A third-party channel plugin we maintain Has the same bug right now (we hit it this week) Will repeat the same ~80-line fix

This is three plugins independently re-rolling the same ActiveCfgProvider because the contract is implicit. Anyone who writes a fourth channel plugin will repeat the cycle.

Proposed solution

We see two reasonable directions and would like maintainer guidance before sending a PR. Both can ship; they're not mutually exclusive.

Option A — Docs only (lowest risk, fastest)

Add a short "Routing & live bindings" section to the channel plugin author guide (e.g. docs/plugins/sdk-runtime.md or a new docs/plugins/channel-routing.md) stating the contract:

Channel plugins must call getRuntimeConfig() from openclaw/plugin-sdk/runtime-config-snapshot on every inbound message and pass the returned cfg to resolveAgentRoute. Do not capture or memoize the cfg reference handed to start() — doing so will cause CLI binding edits (openclaw agents bind) to be ignored until the gateway is restarted, because bindings is kind: "none" in the reload plan by design (see config-reload-plan.ts).

Cross-link from config-reload-plan.ts:120-140 comments back to the doc, and from resolve-route.ts:205 WeakMap declaration ("cache key is cfg reference; stale refs return stale routes by construction").

This is 100% within the "Refactor-only PRs are not accepted" exception because it's a docs PR addressing real plugin author confusion.

Option B — Centralize the helper in plugin SDK (eliminates duplicate code)

Export a thin createActiveCfgProvider({ fallback }) from openclaw/plugin-sdk/runtime-config-snapshot (or a sibling module). Pattern is verbatim what qqbot PR #73567 added to extensions/qqbot/src/engine/gateway/active-cfg.ts:

export function createActiveCfgProvider(opts: { fallback: OpenClawConfig }) {
  return {
    getActiveCfg(): OpenClawConfig {
      try { return getRuntimeConfig(); }
      catch { return opts.fallback; }  // SDK not yet ready (early start, isolated tests)
    },
  };
}

Then deprecate the inlined copies in telegram + qqbot in follow-ups, on the plugin authors' own schedule. New channel plugins get the helper for free.

This is a small additive SDK change with one preexisting in-tree consumer (qqbot) plus matching telegram pattern — it doesn't introduce a new architecture, just names a pattern that already exists.

Alternatives considered

  1. Flip bindings / routing to kind: "hot" in the reload plan — already tried in PR Gateway: add zero-latency hot-reload for agent bindings #7747, closed-as-stale. Per-event re-resolve is the chosen direction; we are not asking to revisit this.

  2. A config.reload-bindings admin RPC — issue Feature: Hot-reload bindings without gateway restart (SIGUSR2 / config.reload-bindings) #27706, closed-as-stale. Same reason.

  3. Leave it as-is and let each channel plugin re-discover — current status. Three plugins, three independent fixes, the third still broken. New channel plugins (Mattermost, MS Teams, IRC, etc. all on the maintainer roster) will repeat.

Impact

  • Affected: every current and future channel plugin author who needs to support openclaw agents bind taking effect without a gateway restart
  • Severity: blocks workflow — until fixed, every binding edit requires openclaw gateway restart, which kills active sessions and reconnects providers (issue Gateway: account-scoped channel config changes restart the entire channel and can disrupt active traffic #43935 documents this cost)
  • Frequency: once per plugin author lifetime; reliably reproducible
  • Consequence: each affected plugin ships a regression to production, gets a bug report, then writes ~80 lines of duplicate code. With Option A this becomes "read the docs"; with Option B it becomes "import a helper"

Evidence/examples

Additional information

We're a third-party channel plugin team that just hit this. We'd like to contribute the fix upstream rather than just patching our own plugin. Per CONTRIBUTING.md ("New features / architecture → Start a GitHub Issue first"), we're opening this issue before drafting any PR.

Happy to send the PR for whichever direction you prefer (A, B, or both) once a maintainer signals which is in scope. If you'd rather we don't, that's fine too — we'll just patch around it on our side and move on. Tagging gateway-area maintainers per CONTRIBUTING.md: @frankekn (Gateway/Channels), @joshavant (Gateway), @cpojer (JS Infra) — apologies if this is not the right escalation path.

Real environment we tested in:

  • OpenClaw: latest main (commit c1ddfccf65c9cf3708771b356090b7331c543f58 at time of investigation)
  • OS: macOS 15.x (Darwin 25.3.0)
  • Channel triggering the discovery: a third-party channel plugin we maintain
  • Reproducer: openclaw agents add foo --workspace ~/.openclaw/workspaces/foo --non-interactiveopenclaw config patch --stdin writing channels.<our-channel>.accounts.<botUid>openclaw agents bind --agent foo --bind <our-channel>:<botUid> → DM the bot without restarting → routes to agent:main instead of foo. Confirmed by stepping through resolveAgentRoute and observing the WeakMap hit returning the pre-bind evaluated bindings.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Normal backlog priority with limited blast radius.clawsweeper:needs-maintainer-reviewClawSweeper marked this issue as needing maintainer review before automation.clawsweeper:needs-product-decisionClawSweeper marked this issue as needing a product or behavior decision.clawsweeper:no-new-fix-prClawSweeper does not recommend queueing a new automated fix PR for this issue.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:message-lossChannel message delivery can be lost, duplicated, or misrouted.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions