Skip to content

fix(claude-channel): benign dedup for shared-agent reply race#1309

Open
sh1nj1 wants to merge 1 commit into
mainfrom
fix/channel-shared-agent-reply-dedup
Open

fix(claude-channel): benign dedup for shared-agent reply race#1309
sh1nj1 wants to merge 1 commit into
mainfrom
fix/channel-shared-agent-reply-dedup

Conversation

@sh1nj1

@sh1nj1 sh1nj1 commented Jun 16, 2026

Copy link
Copy Markdown
Owner

Problem

Running two claude --channels plugin:collavre@collavre sessions in the same working directory and sending a message in a Collavre topic: both sessions receive the dispatch, the first replies fine, the second fails with:

The reply tool returned 403 Not authorized

Root cause

Same-directory sessions share one Collavre agent (the default AGENT_NAME case). Both subscribe to agent:user:<id>, so a single work-topic dispatch fans out to both and each session's turn calls /reply with the same task_id. This is by design — ClaudeChannelAdapter documents that a work topic "may be handled by any session (the server's atomic task claim dedups concurrent handlers)", with the loser getting a clean 409.

But resolve_reply_agent scoped its task lookup to status: "delegated". Once the winning session's claim_delegated_task flipped the task to done, the loser's lookup returned nil and reply rendered a misleading 403 at the auth layer — one step before the intended 409 dedup. The MCP plugin throws on any non-2xx, so the 403 surfaced to the user as a hard error.

The existing test concurrent replies for same task_id only one wins; loser gets 409 even admitted this in its assertion (weakened to refute 2xx with a comment noting the real result was 403).

Fix

Server (resolve_reply_agent): drop the status: "delegated" filter. Resolve the agent for any task on the topic owned by the caller's Claude Channel agent, and let claim_delegated_task (which keeps the atomic WHERE status='delegated') decide actionability. The loser now reaches the designed 409. Nonexistent / foreign-agent / wrong-topic task_ids still 403 (ownership + topic scoping unchanged).

Plugin (collavre-client.ts + index.ts): reply() treats 409 as a benign dedup (handled: false) instead of throwing; the reply tool reports "already answered by another session" rather than a failed reply.

Verification

  • engines/collavre/test/controllers/api/v1/agents_controller_test.rb — 69 runs, 0 failures (updated the concurrent-reply test to assert :conflict).
  • Plugin: tsc clean build + npm test (28 pass).
  • Rubocop clean; pre-push full suite green.

🤖 Generated with Claude Code

Two Claude Code channel sessions in the same working directory share one
Collavre agent (default AGENT_NAME). Both stream from agent:user:<id>, so a
single work-topic dispatch fans out to both and each session's turn calls
/reply with the same task_id. The server is designed to dedup these via the
atomic task claim (claim_delegated_task), returning 409 to the loser.

But resolve_reply_agent scoped its task lookup to status: "delegated". Once
the winning session flipped the task to "done", the loser's lookup returned
nil and reply rendered a misleading 403 "Not authorized" — which the MCP
plugin threw as a hard error and surfaced to the user.

Server: resolve_reply_agent no longer gates on status. It resolves the agent
for any task on the topic owned by the caller's Claude Channel agent and lets
claim_delegated_task decide actionability — so the loser reaches the intended
409. Nonexistent/foreign task_ids still 403 (ownership + topic scope unchanged).

Plugin: reply() treats 409 as a benign dedup (handled:false) instead of
throwing; the reply tool reports "already answered by another session" rather
than a failed reply.

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 90efe44df7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +95 to +97
if (res.status === 409) {
const respBody = await res.text();
return { handled: false, reason: respBody };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not swallow every reply conflict as dedup

When the original dispatch is cancelled or failed instead of answered (for example, offline-session cancellation or stuck-task recovery flips a delegated task while Claude is still composing), /reply now also produces a 409 because the server resolves non-delegated tasks and the atomic claim fails. This branch treats every 409 as a benign sibling-session dedup, and index.ts then reports “Already answered” without posting any comment, so a valid response can be silently dropped. Please distinguish the completed/dedup case from cancelled/failed/not-delegated conflicts before suppressing the error.

Useful? React with 👍 / 👎.

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.

1 participant