Skip to content

[OPIK-8663] fix: support OpenClaw 2026.9 - #147

Merged
awkoy merged 5 commits into
mainfrom
awkoy/openclaw-2026-9-compat
Oct 5, 2026
Merged

awkoy merged 5 commits into
mainfrom
awkoy/openclaw-2026-9-compat

Conversation

@awkoy

@awkoy awkoy commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Details

The published plugin (0.2.17) exports nothing on OpenClaw 2026.9.7, the current release. I ran it against a real gateway, reproduced each problem below, fixed it, and checked the fix the same way.

  • The plugin doesn't load. OpenClaw removed the root openclaw/plugin-sdk module in its July SDK cleanup, so loading fails with ERR_PACKAGE_PATH_NOT_EXPORTED. The two runtime imports now come from openclaw/plugin-sdk/core and openclaw/plugin-sdk/diagnostic-runtime.
  • Hooks run without an Opik client. OpenClaw now registers the plugin again for every agent turn, in a separate module instance, and sends typed hooks to that copy. Services only start on the gateway's copy, so every hook saw client=null and dropped its data. A process-wide router (globalThis[Symbol.for("opik-openclaw.hook-router")]) now sends hooks to the copy whose service started, and lets go on stop().
  • LLM output, tokens and cost are lost. llm_output now arrives after agent_end, in a later tick, after the trace was already closed. If the LLM span is still open at agent_end, the trace now waits for llm_output, or for one second if it never comes. A new turn in the same session during that wait closes the previous trace first.
  • OpenClaw removed the subagent_spawning hook, so the plugin no longer registers it. subagent_spawned creates the span and now records the label, requester and threadRequested.
  • OpenClaw removed loadConfig and writeConfigFile. openclaw opik status and openclaw opik configure use config.current() and mutateConfigFile, and write back only the Opik plugin entry.
  • Failed provider calls show up as successful. When the provider rejects a call (an HTTP 401 for a bad key, for example), agent_end still says success: true with no error. The failure is only in llm_output's last assistant message (stopReason: "error"). The LLM span and the trace now get errorInfo with the provider's message, and the trace is marked success: false.
  • Gemini spans have no cost, on older hosts too. OpenClaw reports the provider as google or google-vertex, which Opik's price table doesn't know. They now map to google_ai and google_vertexai.
  • The minimum OpenClaw version is now 2026.9.7 (peer dependency, compat.pluginApi, minGatewayVersion). Older hosts stay on 0.2.17. The README says so.
  • The E2E workflow runs on Node 24, which OpenClaw 2026.9 needs; it has been failing at the install step on main since 2026-09-28. It also passes --force --accept-capabilities to plugins install, which the new host asks for when installing a local tarball.

This replaces #146. On 2026.9.7 OpenClaw already passes the full context to bundle-MCP tools, so MCP tool spans show up without that change once the plugin loads and its hooks have a client.

One thing this doesn't change: llm_output updates whichever LLM span is open. If call N's llm_output arrived after call N+1's llm_input, it would land on N+1. On 2026.9.7 both hooks fire once per turn, so I didn't see it happen.

Change checklist

  • User facing
  • Documentation updated (if needed)
  • Tests added/updated (if needed)
  • Breaking changes documented (if any)

Issues

Testing

  • npm test: 115 passed, 1 skipped (the same skip as on main). npm run typecheck, npm run build and npm run pack:check are clean.
  • The new tests cover: hooks of a copy whose service never started export through the started one, and stop once it stops; a reload that starts the new service before stopping the old one keeps hooks on the new one; a late llm_output still lands on the span and the trace; the one-second fallback; a new turn during the wait gets its own trace; a provider error reported only in llm_output; the Gemini provider mapping; createConfigDeps writes back only the Opik entry.
  • I ran the E2E workflow's scenario locally (mock Opik and mock LLM, plugin installed from npm pack, OpenClaw 2026.9.7, Node 24). It passes with this branch and fails with the published 0.2.17, which exports 0 traces.
  • Live runs on OpenClaw 2026.9.7 with a stdio MCP server and a local Opik:
check 0.2.17 this branch
plugin loads no (ERR_PACKAGE_PATH_NOT_EXPORTED) yes
Gemini 2.5 Flash: trace with output, tokens, cost nothing exported yes, provider google_ai, cost set
Anthropic claude-haiku-4-5: trace with output, tokens, cost nothing exported yes
built-in tool span (read) nothing exported yes
MCP tool span (echo-mcp__mcp_echo) nothing exported yes, with the tool's result
subagent span and child trace nothing exported yes
two turns in one session nothing exported two complete traces
OpenAI call rejected with HTTP 401 nothing exported trace marked failed, provider message in errorInfo (key masked)
openclaw opik status not checked works
  • OpenClaw 2026.4.29 with this build: traces, tools and cost work. MCP tool spans are missing there because that version strips their hook context, and it's below the new minimum.

Documentation

The README prerequisites now say OpenClaw >=2026.9.7 and Node >=24.16, and the hooks table no longer lists subagent_spawning.

🤖 Generated with Claude Code

The published plugin exports nothing on OpenClaw 2026.9.7:

- The root `openclaw/plugin-sdk` barrel is gone, so the plugin failed to load.
  Runtime imports now use `openclaw/plugin-sdk/core` and
  `openclaw/plugin-sdk/diagnostic-runtime`.
- OpenClaw registers the plugin again for every agent turn and dispatches
  typed hooks to that registration, while services only start on the
  gateway's registration, so every hook ran without an Opik client. Hooks now
  go through a process-wide router to the registration whose service started.
- llm_output arrives after agent_end in a later tick, and the trace was
  already finalized, so LLM output, usage and cost were lost. agent_end now
  waits for llm_output (or a 1 s grace) when the LLM span is still open, and a
  new turn in that window finalizes the previous trace first.
- subagent_spawning was removed; subagent_spawned creates the span.
- loadConfig/writeConfigFile were removed; the CLI uses config.current() and
  mutateConfigFile, writing back only the Opik plugin entry.

Also map OpenClaw's Gemini providers (google, google-vertex) to Opik's
canonical names so those spans get a cost, raise the compat floor to
2026.9.7, and run the E2E job on Node 24 with the install consent flags the
new host requires.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@baz-reviewer

baz-reviewer Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Review this PR on Baz

Baz Summary

Update the OpenClaw plugin runtime and service lifecycle to support OpenClaw 2026.9.7, including new SDK imports, configuration APIs, hook routing, delayed LLM completion, provider errors, and Gemini cost mapping. Raise Node/OpenClaw requirements, update CLI configuration handling and subagent hooks, and align CI/E2E workflows and documentation with the new host.

graph LR
register_("register"):::modified
createConfigDeps_("createConfigDeps"):::added
OPENCLAW_CONFIG_API_("OPENCLAW_CONFIG_API"):::modified
createOpikService_("createOpikService"):::modified
registerHooks_("registerHooks"):::modified
registerLlmHooks_("registerLlmHooks"):::modified
registerSubagentHooks_("registerSubagentHooks"):::modified
normalizeProvider_("normalizeProvider"):::modified
finalizeTrace_("finalizeTrace"):::modified
OPIK_("OPIK"):::modified
register_ -- "CLI registration now uses runtime config adapter for configuration persistence." --> createConfigDeps_
createConfigDeps_ -- "Replaces removed load/write APIs with current and plugin-entry mutation." --> OPENCLAW_CONFIG_API_
register_ -- "Service creation now supports routed hook registration across agent-turn instances." --> createOpikService_
createOpikService_ -- "Hook registration routes handlers globally and adds late-output trace finalization." --> registerHooks_
registerHooks_ -- "LLM handlers now defer finalization and record provider-reported errors." --> registerLlmHooks_
registerHooks_ -- "Uses subagent_spawned exclusively, capturing label, requester, and threading metadata." --> registerSubagentHooks_
registerLlmHooks_ -- "Maps Gemini IDs to Opik pricing identifiers for cost attribution." --> normalizeProvider_
registerLlmHooks_ -- "Finalizes traces after late LLM output or before a new turn." --> finalizeTrace_
finalizeTrace_ -- "Exports LLM failures as trace errors and marks unsuccessful execution." --> OPIK_
classDef added stroke:#15AA7A
classDef removed stroke:#CD5270
classDef modified stroke:#EDAC4C
linkStyle default stroke:#CBD5E1,font-size:13px
Loading

Topics

TopicDetails
Runtime and tooling Require OpenClaw 2026.9.7 and Node.js 24.16, migrate CLI configuration writes to config.current() and mutateConfigFile, and update README prerequisites, hook documentation, packaging, release, and E2E installation workflows.
Modified files (7)
  • .github/workflows/ci.yml
  • .github/workflows/clawhub-package-publish.yml
  • .github/workflows/e2e.yml
  • .github/workflows/release.yml
  • README.md
  • package-lock.json
  • package.json
Latest Contributors(2)
UserCommitDate
yaroslavb@comet.comfix: keep finished tur...October 02, 2026
vincentkoc@ieee.orgfix(ci): keep ClawHub ...May 22, 2026
OpenClaw compatibility Update plugin registration and tracing to work across OpenClaw 2026.9.7’s module copies and changed hook lifecycle, routing hooks to the active service and waiting for late llm_output events. Capture provider failures, normalize Gemini providers for pricing, and migrate subagent handling to subagent_spawned.
Modified files (12)
  • index.ts
  • src/openclaw-plugin-sdk.d.ts
  • src/plugin.smoke.test.ts
  • src/service.e2e.test.ts
  • src/service.test.ts
  • src/service.ts
  • src/service/constants.ts
  • src/service/helpers.ts
  • src/service/hooks/llm.ts
  • src/service/hooks/subagent.ts
  • src/types.ts
  • vitest.config.mjs
Latest Contributors(2)
UserCommitDate
yaroslavb@comet.comfix: support OpenClaw ...October 02, 2026
vincentkoc@ieee.orgfix(plugin): declare s...May 06, 2026

Merger  Activate to get a short verdict whether this PR is good to go or not

Skills  Activate Skill Maintainer to keep your skills up to date

Planner  This PR would have been improved with Baz Planner - Try it now

Comment thread src/service.ts
Comment on lines +333 to 338
// Clear deferred finalization state so stale microtasks and timers no-op.
clearTimeout(active.pendingFinalize);
active.pendingFinalize = undefined;
active.agentEnd = undefined;
active.output = undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shutdown discards completed trace data

When shutdown occurs during the grace window, stop() routes active traces through closeActiveTrace, which clears agentEnd/output and ends the trace without trace.update, so collected completion/output/usage data is omitted before the final flush — should we use finalizeTrace’s metadata/output path, retain a fallback for truly incomplete traces, and add a regression test?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In src/service.ts
around lines 333-338, review the `closeActiveTrace`/`stop()` shutdown logic: stopping
during the agent_end LLM-output grace window clears `agentEnd` and `output` before
`trace.update`, permanently dropping collected metadata and output. Refactor `stop()` to
finalize each eligible active trace through `finalizeTrace` before ending it, while
retaining `closeActiveTrace` as a safe fallback for genuinely incomplete traces, and add
a regression test covering shutdown during the grace window.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One of these commits addressed this comment: 02ae374, b2e3f90. stop() now finalizes traces that reached agent_end, preserving metadata and output via finalizeTrace, while retaining closeActiveTrace for incomplete traces. A regression test covers shutdown during the grace window.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b2e3f90: stop() now finalizes a turn that already reached agent_end, so its output and metadata are written; only turns without agent_end are just closed.

Comment thread src/service.ts
Comment on lines +698 to +702
if (active.llmSpan && agentEndLlmOutputGraceMs > 0) {
// llm_output hasn't arrived yet; it lands after agent_end on OpenClaw 2026.9+.
clearTimeout(active.pendingFinalize);
active.pendingFinalize = setTimeout(finalizeIfCurrent, agentEndLlmOutputGraceMs);
active.pendingFinalize.unref?.();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale sweep drops late LLM output

Arming the grace timer leaves lastActivityAt stale, so stale cleanup can delete the pending trace before the one-second grace ends when agent_end arrives after the timeout boundary, causing the subsequent llm_output to discard its output/usage — should we refresh or protect pending traces until grace finalization, with a boundary regression test?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In `src/service.ts`
around lines 698-702, the `registerHooks` `agent_end` grace-finalization logic leaves
`lastActivityAt` unchanged, so the stale-trace sweep can delete the trace before late
`llm_output` is processed. Refactor this flow to refresh activity or explicitly protect
traces with a pending finalization timer, ensuring stale cleanup cannot remove them
until the grace period completes and late output/usage is retained. Add a regression
test covering an `agent_end` near the stale timeout boundary followed by late
`llm_output`.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b2e3f90: agent_end refreshes lastActivityAt, so a trace waiting for llm_output isn't swept as stale.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One of these commits addressed this comment: 02ae374, b2e3f90. The change refreshes lastActivityAt when agent_end arms the grace timer, preventing immediate stale cleanup of the pending trace. It also adds tests for late llm_output retention during the grace period.

Comment thread src/service/hooks/llm.ts Outdated
Comment on lines 64 to 68
// A new turn inside the grace window after agent_end belongs to a new trace.
if (deps.activeTraces.get(sessionKey)?.pendingFinalize) {
deps.finalizeEndedTurn(sessionKey);
}
const existing = deps.activeTraces.get(sessionKey);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New turn is closed by prior finalizer

After llm_output clears pendingFinalize and queues its microtask, a same-session llm_input bypasses the guard and reuses the old trace, so the unchanged traceRef check finalizes it and closes/deletes the new turn. Could we track deferred-finalization generation/trace state separately from the timer marker and add a regression test for late llm_output followed immediately by llm_input?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`src/service/hooks/llm.ts` around lines 64-68, update `registerLlmHooks` so deferred
finalization tracks its generation or trace identity independently of `pendingFinalize`;
a same-session `llm_input` arriving after a late `llm_output` must invalidate the queued
callback or force a new trace rather than letting it finalize the new turn. Ensure the
callback verifies the captured generation/trace state before closing or deleting
anything, and add a regression test for late `llm_output` followed immediately by
`llm_input`.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b2e3f90: a late llm_output now finalizes synchronously, and llm_input finalizes any trace whose agent_end already arrived before starting the new turn. Regression test added.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One of these commits addressed this comment: 02ae374, b2e3f90. The late llm_output now synchronously finalizes the pending trace, preventing a same-tick llm_input from reusing it. A regression test covers a new turn immediately following late output.

Comment thread vitest.config.mjs
Comment on lines +7 to +13
alias: [
{
find: /^openclaw\/plugin-sdk(\/.*)?$/,
replacement: fileURLToPath(
new URL("./.scripts/vitest-openclaw-plugin-sdk.mjs", import.meta.url),
),
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests mask SDK import regressions

The alias maps openclaw/plugin-sdk and every subpath to one local stub, so Vitest accepts unsupported imports that later fail in the published plugin with ERR_PACKAGE_PATH_NOT_EXPORTED — should we limit it to the subpaths the tests need or add a resolution test against the real package exports?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In `vitest.config.mjs`
around lines 7-13, the `resolve.alias` configuration maps every `openclaw/plugin-sdk`
subpath to the same local stub, hiding unsupported production imports that would fail
when published. Narrow the alias to only the exact SDK subpaths required by the tests,
and add or update a resolution test that checks production imports against the installed
OpenClaw package exports so `ERR_PACKAGE_PATH_NOT_EXPORTED` regressions remain
detectable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point. The unit tests can't see export maps; the load path is covered by the E2E job, which installs the packed tarball into the latest OpenClaw (it fails with ERR_PACKAGE_PATH_NOT_EXPORTED on the published 0.2.17 and passes here). Leaving the alias as is.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the clarification—I'll remember that the E2E packed-tarball job covers export-map compatibility here once the PR is merged.

Comment thread package.json
},
"peerDependencies": {
"openclaw": ">=2026.3.2"
"openclaw": ">=2026.9.7"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compatibility fix reuses published version

Keeping the package at 0.2.17 lets the v0.2.17 tag pass but makes the release workflow treat the npm version as already published, so the new OpenClaw compatibility floor is not delivered — should we bump the package version, since the README reserves 0.2.17 for older hosts?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In `package.json`
around lines 33-33, update the package `version` from `0.2.17` to the next release
version appropriate for this compatibility-floor change. Ensure the new version aligns
with the release workflow and preserves `0.2.17` as the documented version for older
OpenClaw hosts.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Version bumps go in a separate chore(release) commit in this repo, so the bump comes with the release, not this PR.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it—version bumps are handled in the separate chore(release) commit here. I’ll save this to memory once the PR is merged.

Comment thread package.json
Comment on lines +77 to +78
"pluginApi": ">=2026.9.7",
"minGatewayVersion": "2026.9.7"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Advertised Node floor contradicts host requirement

The new OpenClaw 2026.9.7 metadata still advertises Node >=22.12.0 and Node-22 CI/release gates even though the branch requires Node >=24.16, so the published and validated runtime contracts diverge — should we update the package/lockfile engine and validation/publish workflows together?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In package.json around
lines 77-78, update the package and lockfile engine requirements from Node >=22.12.0 to
the documented Node >=24.16 floor, keeping the OpenClaw compatibility metadata
consistent. Also update all validation and publish/release workflows and their Node
setup or version gates to use and enforce Node 24.16, so the advertised runtime contract
matches what CI and releases actually validate.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b2e3f90: engines and the CI, release and ClawHub publish workflows now use Node 24.16.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One of these commits addressed this comment: 02ae374, b2e3f90. The package and lockfile engine requirements now require Node >=24.16.0. CI, E2E, publish, and release workflows were also updated to use Node 24.16 or newer.

Address review:
- service stop during the grace window finalizes a turn that already reached
  agent_end, so its output and metadata are written instead of dropped;
- agent_end refreshes lastActivityAt, so stale cleanup can't take a trace
  that is only waiting for llm_output;
- a late llm_output finalizes synchronously, and llm_input finalizes any
  trace whose agent_end already arrived, so a next turn in the same tick gets
  its own trace instead of being closed with the previous one;
- engines and the CI, release and ClawHub publish workflows move to Node
  24.16, matching what OpenClaw 2026.9 requires.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment thread src/service.ts
Comment on lines +575 to +577
finalizeEndedTurn: (sessionKey) => {
const active = activeTraces.get(sessionKey);
if (active?.pendingFinalize || active?.agentEnd) finalizeTrace(sessionKey);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Preserve correlation across trace rollover

The new active.agentEnd branch erases the mapping that llm_input just recorded, so context-poor llm_output and after_tool_call hooks for the replacement turn are dropped or misrouted when another trace is active — should we finalize the ended trace before recording the new correlation, or re-register it afterward?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In src/service.ts
around lines 575-577, update the `finalizeEndedTurn` callback in `registerHooks` so
finalizing an ended trace does not erase the replacement turn’s session correlation.
Finalize the ended trace before recording the new turn’s correlation, or explicitly
re-register the correlation after `finalizeTrace(sessionKey)`, ensuring context-poor
`llm_output` and `after_tool_call` hooks are still routed to the replacement trace.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 18a9024: llm_input now finalizes the ended turn before recording the new turn's correlation. Regression test added.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit 18a9024 addressed this comment by llm_input now finalizes an ended turn before recording the replacement turn’s session correlation. A regression test verifies that context-poor llm_output hooks reach the new trace.

Finalizing the previous trace forgot the session's agentId mapping that
llm_input had just recorded, so the new turn's context-poor hooks could
not find it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@awkoy awkoy changed the title [issue-5934] fix: support OpenClaw 2026.9 [OPIK-8663] fix: support OpenClaw 2026.9 Oct 2, 2026
On OpenClaw 2026.9 a failed provider call (for example an HTTP 401 from a bad
API key) comes back as agent_end success=true with no error; the failure is
only in llm_output's lastAssistant (stopReason "error", errorMessage). Such
turns showed up in Opik as successful. The LLM span and the trace now get
errorInfo with the provider message, and the trace is marked success=false.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment thread src/service.ts
Comment on lines +179 to +185
const routed: HookHandler = (...args) => {
const router = getHookRouter();
const ownerHandler =
router.owner && router.owner !== registrationId
? router.handlers.get(hookName)
: undefined;
return (ownerHandler ?? handler)(...args);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ question | Architecture

The router assumes each hook event reaches exactly one copy of the plugin. Every copy forwards to the copy whose service started, and nothing checks for repeats. So if the host ever sends the same event to two copies, the started copy handles it twice. That would happen if the host sends to both the gateway's copy and the per-turn copy, or if copies from earlier turns stay registered. The result would be duplicate LLM/tool spans, or a second trace.

Your two-turn live run suggests this doesn't happen on 2026.9.7. Is "one copy per event" a guarantee from OpenClaw, or just how it behaves today? If it's only today's behaviour, a cheap guard would be a WeakSet per hook name in the router. Skip an event object the started copy has already handled. Or at least document the assumption next to the comment above HOOK_ROUTER_KEY.

🤖 Review posted via /review-github-pr

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. It comes from how the host is built, not from a documented contract. In 2026.9.7 the hook runner merges registries per pluginId + hookName (overlayHookRegistries in hook-runner-global). The per-turn registration's hook replaces the gateway's hook for the same plugin instead of running next to it. A generation registry is used on its own, and retired registries are skipped.

I also checked it live: OpenClaw 2026.9.7, Gemini 2.5 Flash and a stdio MCP server, with a debug log on every routed dispatch. That was 40 dispatches over 5 turns, with built-in read calls, MCP echo-mcp__mcp_echo calls and a config hot reload in between. Every event went through exactly one registration. No event object and no (hook, runId, toolCallId) key repeated. After the reload the old registration got nothing. Opik had one trace per turn and no duplicate spans.

So I didn't add the WeakSet, since it would guard against something the host rules out. In e3f0c4e I documented the assumption and where it comes from above HOOK_ROUTER_KEY. In the same commit, registerHooks now throws if a hook name is registered twice on one service. Routing looks handlers up by name, so a second handler would otherwise quietly replace the first for routed events.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit e3f0c4e addressed this comment by The change documents the one-registration-per-event assumption and explains the OpenClaw behavior supporting it, including the duplicate-span risk if it changes. It does not add a WeakSet deduplication guard, but documentation was explicitly offered as an alternative.

Comment thread src/service.ts
Comment on lines +861 to +863
if (router.owner === registrationId) {
router.owner = null;
router.handlers = new Map();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 suggestion | Testing

The router.owner === registrationId check covers a config reload where the new service starts before the old one stops. The new tests only cover a single start/stop. Please add a test for start A → start B → stop A, checking that hooks from a third copy still reach B. This check is the only thing that keeps telemetry flowing after that reload, and it would be easy to "simplify" away later.

🤖 Review posted via /review-github-pr

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in e3f0c4e. The test does start old → start new → stop old, then checks that a hook from a third registration still creates a trace. Each service is loaded from a fresh module instance (vi.resetModules()), because that's what the host does on reload. I confirmed it live: the module instance changes across a config hot reload. The test fails if the owner check in stop() is removed.

For context, 2026.9.7 stops the old service before starting the new one on hot reload, so this order doesn't happen today. The test is there so the check doesn't get simplified away.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit e3f0c4e addressed this comment by A regression test now covers starting the old service, starting the new service, then stopping the old one. It also registers a third module instance and verifies its hook reaches the new service.

@JetoPistola

Copy link
Copy Markdown

👋 Review summary

What looks good

  • The PR description is excellent. Each break is reproduced against a real 2026.9.7 gateway, and the 0.2.17-vs-branch table makes the impact clear.
  • The late-llm_output handling is careful. It covers the grace timer, stopping the service during the wait, a new turn during the wait, and keeping the session correlation across turns, and each case has a regression test.
  • createConfigDeps writes back only the Opik entry, which is safer than the old write of the whole config, and there's a test for it.
  • Moving a failure that only shows up in llm_output (stopReason: "error") onto the span and trace, with the message sanitized, fixes a real gap where failed calls showed as successful.

Overall
The approach makes sense for a host that now registers the plugin once per turn, and the scope is right for a compatibility fix. Since the minimum OpenClaw and Node versions both go up, the release should probably be a minor bump (0.3.0) rather than 0.2.18, so people on older hosts don't pick it up from a ^0.2 range.

Inline comments: 1 question, 1 suggestion. Nothing blocking.

🤖 Review posted via /review-github-pr

… reloads in tests

- Note above HOOK_ROUTER_KEY that the router relies on OpenClaw delivering each hook event
  to exactly one registration of a plugin, and why that holds on 2026.9.7.
- Throw when a hook name is registered twice on one service: routing looks handlers up by
  name, so the second handler would replace the first for routed events.
- Test a reload that starts the new service before stopping the old one, with each service
  in its own module instance, so the owner check in stop() can't be dropped unnoticed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@JetoPistola JetoPistola left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All review feedback is addressed, and CI and E2E pass on e3f0c4e. LGTM.

@awkoy
awkoy merged commit 947763e into main Oct 5, 2026
5 checks passed
@awkoy awkoy mentioned this pull request Oct 5, 2026
4 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: MCP tool spans exported via the OpenClaw Opik plugin are present in trace data but missing from the Opik UI trace view

2 participants