fix(thread-ownership): bound forwarder conflict handling#101968
fix(thread-ownership): bound forwarder conflict handling#101968mushuiyu886 wants to merge 2 commits into
Conversation
|
Codex review: needs maintainer review before merge. Reviewed July 7, 2026, 11:19 PM ET / 03:19 UTC. Summary PR surface: Source +25, Tests +78. Total +103 across 3 files. Reproducibility: yes. at source level: current main's 409 branch can throw from resp.json() into the outer fail-open catch, and mentionedThreads has only TTL cleanup. I did not execute the PR tests in this read-only review. Review metrics: 2 noteworthy metrics.
Stored data model Root-cause cluster Members:
Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything. Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Risk before merge
Maintainer options:
Next step before merge
Maintainer decision needed
Security Review detailsBest possible solution: Land one canonical Thread Ownership fix that bounds 409 reads and mention-cache growth while preserving 409 cancellation; this PR currently looks like the narrowest candidate, with overlapping PRs narrowed or closed after maintainer reconciliation. Do we have a high-confidence way to reproduce the issue? Yes at source level: current main's 409 branch can throw from resp.json() into the outer fail-open catch, and mentionedThreads has only TTL cleanup. I did not execute the PR tests in this read-only review. Is this the best way to solve the issue? Yes, this is the best current fix shape: it uses shared bounded-reader and Map-pruning helpers inside the Thread Ownership plugin while preserving the established 409 cancellation signal. The main alternative PRs are either missing this corrected invariant or broader/conflicting. AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against a24f15dc4d9e. Label changesLabel changes:
Label justifications:
Evidence reviewedPR surface: Source +25, Tests +78. Total +103 across 3 files. View PR surface stats
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
Review history (1 earlier review cycle)
|
|
A/B verified locally against base SHA. Ran vitest with the PR's test file against the PR's fix (B) and against the base's fix (A). Result: CLEAN A/B The new test |
Summary
409ownership-conflict cancellation when the forwarder conflict body is oversized or malformed.ownerreported asunknown.What Problem This Solves
Thread Ownership treats a forwarder
409as the ownership-conflict signal: another agent owns the Slack thread, so the outgoing thread reply must be cancelled. The first patch bounded the conflict JSON read, but an oversized or malformed409body could throw before the cancellation return and fall into the outer fail-open ownership-check catch. That made a conflict response capable of allowing a send.This update keeps the bounded read but moves parse/overflow handling inside the
409branch. The409status remains authoritative, so unreadable conflict bodies only degrade owner attribution tounknown; they no longer change cancellation into fail-open delivery.User Impact
409ownership conflict is the message-delivery guard that prevents one agent from replying into a thread owned by another. A bad conflict body should not bypass that guard. The repaired branch still bounds forwarder response reads and the mention cache, but preserves the user-visible cancellation semantics for all observed409conflicts.Origin / follow-up
Follow-up to #101743, which targets the same Thread Ownership hardening area.
This PR keeps the patch focused on the Thread Ownership extension and updates the existing #101968 repair path after ClawSweeper identified the
409cancellation regression. It does not close an issue; it is a focused follow-up PR intended to provide a maintainer-reviewable root-cause fix with fresh before/after behavior evidence.Competition / linked PR analysis
The linked PR #101743 is the known related open PR for this Thread Ownership behavior area. ClawSweeper also reported overlapping hardening PR coverage, so the canonical landing path remains a maintainer decision if another PR is preferred.
This PR's narrower advantage is the now-explicit
409conflict invariant plus proof quality: the verification drives the production Thread Ownership plugin entrypoint hooks (message_sendingandmessage_received) against a real local HTTP forwarder, compares origin/main-style behavior to this repaired branch, and records small, oversized, and malformed409conflict responses plus mention-cache eviction behavior.409cancellation while keeping the bounded read and cache cap.Scope / source-of-truth boundary
extensions/thread-ownership: the local extension API barrel, the Thread Ownership plugin entrypoint, and focused regression tests. It does not change Slack routing, channel ingestion, the ownership forwarder API path, request body, SSRF policy, timeout, config schema, or any other extension.409. The conflict body is only optional attribution for logging. The source of truth for bypassing ownership checks remains the plugin's{channel}:{thread}mention map populated bymessage_received.message_sending/message_receivedevents and before the Thread Ownership plugin posts to the configured forwarder. The proof covers that plugin hook boundary plus the HTTP forwarder boundary.409means another owner and must cancel the send; mention map state means the agent was directly mentioned in that Slack thread and can skip the ownership check. The patch only changes the parser/read boundary for optional owner attribution and the retention invariant for mention-map state.Real behavior proof
Behavior or issue addressed: Thread Ownership no longer lets unreadable forwarder
409conflict bodies convert an ownership conflict into fail-open send delivery. It also keeps the hard 64 KiB conflict-body read limit and the 2000-entry Slack @mention bypass cache cap.Canonical reachability path: User/config/input → schema/type/ingestion/normalization → runtime object → request/effect: a Slack channel event reaches OpenClaw's plugin runtime as a normalized
message_receivedormessage_sendinghook call; Thread Ownership resolvesctx.channelId,ctx.conversationId,event.replyToId,event.threadId, andevent.metadata;message_receivedrecords the canonical{channel}:{thread}key for direct agent @mentions;message_sendingchecks that runtime map and otherwise posts{"agent_id": agent.id}to/api/v1/ownership/{channelId}/{threadTs}; any forwarder409cancels the outgoing Slack thread send even if optional owner JSON is oversized or malformed.Boundary crossed: The proof registered the production Thread Ownership plugin entrypoint and drove the real
message_sendingandmessage_receivedhook handlers. The ownership check crossed a real loopback HTTP socket to a local forwarder process returning200and409responses. The changed code does not call Slack's SaaS API directly; its channel boundary is the OpenClaw Slack hook payload, and its service boundary is the Thread Ownership forwarder HTTP request.Shared helper / provider constraint check: The patch uses
readProviderJsonResponsefromopenclaw/plugin-sdk/provider-http, the shared bounded JSON response helper backed byreadResponseWithLimit. The mention cache cap usespruneMapToMaxSizefromopenclaw/plugin-sdk/collection-runtime, matching the shared Map-pruning helper used by other request/memory guard caches.Real environment tested: Local OpenClaw worktree at
/media/vdb/code/ai/aispace/openclaw-worktrees/pr-101968, PR repair head97d609c880df0196158f86a0507ed5ac82d6e4b9. The proof used Node withtsx, the production Thread Ownership plugin entrypoint, normalized Slack hook payloads, and a local HTTP forwarder bound to127.0.0.1.Exact steps or command run after this patch:
Evidence after fix:
After-patch evidence excerpt:
{ "smallConflict": { "cancel": true }, "oversizedConflict": { "cancel": true }, "malformedConflict": { "cancel": true }, "newestMentionSkippedForwarder": true, "oldestMentionTriggeredForwarder": true, "logs": { "info": [ "thread-ownership: cancelled send to C123:1000.0001 — owned by agent-b", "thread-ownership: cancelled send to C123:1000.0002 — owned by unknown", "thread-ownership: cancelled send to C123:1000.0003 — owned by unknown" ], "warn": [ "thread-ownership: conflict body unreadable (Error: thread-ownership forwarder conflict: JSON response exceeds 65536 bytes), cancelling send", "thread-ownership: conflict body unreadable (Error: thread-ownership forwarder conflict: malformed JSON response), cancelling send" ] } }Observed result after fix: A small
409conflict body cancels the outgoing Slack thread send and logs the owner. An oversized409conflict body trips the 64 KiB bounded read, logsJSON response exceeds 65536 bytes, records owner asunknown, and still returns{ cancel: true }. A malformed409conflict body logsmalformed JSON response, records owner asunknown, and still returns{ cancel: true }. After 2001 Slack @mention threads, the oldest remembered thread hits the forwarder again while the newest remembered thread still skips the forwarder, proving oldest-entry eviction without breaking the bypass for recent mentions.What was not tested: Production deployment load profile, real workspace traffic volume, and non-Thread-Ownership integrations. The changed code path itself is below Slack event ingestion and does not call Slack's external API; the proof exercises the OpenClaw hook payload boundary and the forwarder HTTP boundary that this patch changes.
Fix classification: Root cause fix.
Review findings addressed
409conflict bodies are caught inside the409branch and still return{ cancel: true }, so unreadable conflict bodies no longer fall through to the outer fail-open catch.oldestMentionTriggeredForwarder: trueandnewestMentionSkippedForwarder: true, so recent direct mentions keep the bypass while memory stays bounded.409cancellation semantics before maintainers choose the canonical landing path.409cancellation semantics while keeping the bounded conflict-body read and the mention cache cap.extensions/thread-ownership/index.ts:201-205. Owner parsing failures now degrade toowner = "unknown"; they do not change the cancellation decision.git diff --checkpassed with no whitespace errors.node scripts/run-vitest.mjs extensions/thread-ownership/index.test.tspassed with 24 tests.Regression Test Plan
extensions/thread-ownership/index.test.tsmessage_sendingcancels when the forwarder returns409with a normal owner body, an oversized owner body, or malformed JSON.message_receivedretains only the newest 2000 mentioned-thread keys, so the oldest high-cardinality mention returns to the forwarder while the newest mention still skips the forwarder.409conflicts, unbounded conflict-body reads, or unbounded mention-cache retention without a focused failure.Commands run:
Observed result:
Merge risk
merge-risk: 🚨 message-delivery,merge-risk: 🚨 availability,merge-risk: 🚨 compatibility.409conflicts now consistently cancel sends, successful forwarder responses still allow sends, and non-409forwarder/network/unexpected failures still use the existing fail-open policy. The cache cap only sends older high-cardinality mention entries back through the existing forwarder check while retaining the newest entries.Risk / Compatibility
409conflicts is preserved: the send is still cancelled.409conflicts now match the conflict status semantics instead of the generic fail-open error path.409forwarder/network/unexpected errors keep the existing fail-open behavior.Patch quality notes
./api.tsbarrel, which re-exports plugin SDK helpers instead of deep-importing core internals from the extension. No unrelated cleanup, broad refactor, dependency changes, lockfile changes, or docs-only changes are included.Maintainer-ready confidence
409reads, unreadable conflict-body cancellation, and max-size mention cache eviction.What was not changed
extensions/thread-ownership; no unrelated cleanup or refactor.Root Cause
Root cause: The root source is a parser/error-propagation invariant violation in the
409conflict branch: optional owner JSON parsing was allowed to throw into the same outer catch used for generic ownership-check failures, so a conflict status could be transformed into fail-open send delivery. Separately, the mention bypass cache relied only on time-based expiry, so high-cardinalitymessage_receivedtraffic could keep adding unique{channel}:{thread}keys until TTL cleanup.Why this is root-cause fix: The fix changes the response-consumption boundary where the incorrect propagation happened:
409is handled first as the authoritative conflict signal, and owner body parsing is contained inside that branch. Parser overflow or malformed JSON now affects only owner attribution, not the cancellation decision. The cache change fixes the retention invariant at the only write point by pruning immediately after each mention key is inserted, so the map cannot exceed the configured maximum regardless of inbound mention cardinality.