Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,17 @@ def reply
# before touching the comments table.
claimed_task = claim_delegated_task(agent, topic, params[:task_id])
if params[:task_id].present? && claimed_task.nil?
render json: { error: "Task already completed or not delegated" }, status: :conflict
# `reason` is the machine-readable half of this 409: only
# "already_completed" means the dispatch was answered and the refused
# reply is a duplicate. A client that suppresses every conflict as
# dedup silently drops a valid answer when the task was cancelled,
# failed, or recovered out of `delegated` mid-turn instead.
render json: {
error: "Task already completed or not delegated",
reason: task_claim_service.conflict_reason(
agent: agent, topic: topic, requested_task_id: params[:task_id]
)
}, status: :conflict
return
end

Expand Down Expand Up @@ -435,17 +445,29 @@ def resolve_notify_agent(topic, requested_task_id)
# When task_id is absent, fall back to topic.primary_agent for
# back-compat with older plugin builds that don't echo task_id.
#
# If task_id IS supplied but the delegated task lookup fails (late
# reply after cancel/timeout, stale id, wrong topic), do NOT fall
# through to primary_agent — a present-but-unresolved task_id means
# the client believes it is answering a specific dispatch that no
# longer exists. Saving the reply against primary_agent would create
# an unlinked comment while complete_delegated_task finds nothing,
# potentially duplicating a reply after the task was already
# completed/cancelled. Return nil so reply renders 403.
# If task_id IS supplied but no task with that id exists on this topic
# (stale id, wrong topic) — or it belongs to a different/non-Claude
# agent — do NOT fall through to primary_agent. A present-but-foreign
# task_id means the client believes it is answering a dispatch it does
# not actually own; saving against primary_agent would create an
# unlinked comment. Return nil → reply renders 403.
#
# BUT do NOT gate this lookup on status == "delegated". Multiple Claude
# Channel sessions sharing one agent (the default AGENT_NAME case: two
# `claude --channels` sessions in the same working directory) all stream
# from agent:user:<id>, so a single work-topic dispatch fans out to every
# session and each calls /reply with the same task_id. The intended dedup
# is the atomic claim in claim_delegated_task (WHERE status='delegated'),
# which yields a clean 409 "already completed" for the loser — a signal
# the MCP plugin treats as benign. Filtering on status HERE made the loser
# fail one layer too early: the sibling already flipped the task to "done",
# this lookup returned nil, and reply rendered a misleading 403 "Not
# authorized" that the plugin surfaced as a hard error. Resolve the agent
# for any task on THIS topic owned by the caller's Claude Channel agent,
# regardless of status, and let claim_delegated_task decide actionability.
def resolve_reply_agent(topic, requested_task_id)
if requested_task_id.present?
task = Task.where(topic_id: topic.id, status: "delegated").find_by(id: requested_task_id)
task = Task.where(topic_id: topic.id).find_by(id: requested_task_id)
agent = task&.agent
return nil unless agent && agent.claude_channel_agent? && agent.created_by_id == current_user.id

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,44 @@ def claim(agent:, topic:, requested_task_id:)
claimed
end

# Why #claim refused, so the caller can tell the one benign conflict from
# the ones that lose a real reply.
#
# ALREADY_COMPLETED is the sibling-session dedup this whole claim exists
# for: the dispatch WAS answered (by another session sharing this agent, or
# by the agent's own job), so the reply being refused is a duplicate of one
# that already landed. Dropping it is correct.
#
# CLAIMED_WITHOUT_REPLY is the same `done` status with no answer behind it.
# #claim flips the task to done BEFORE the controller saves the comment, so
# between those two statements the row says "completed" while nothing has
# been posted — and that window can end in a rollback (blank/invalid text
# restores the task to `delegated`) or never end at all (the worker dies).
# A concurrent reply landing in it holds the only copy of the answer, so it
# must surface. Status alone cannot tell this from the case above; the
# linked reply comment can.
#
# NOT_DELEGATED is everything else: a task moved out of `delegated` without
# a reply — cancelled by an offline session, failed, or recovered by the
# stuck-task sweeper while the agent was still composing. Nothing answered
# that dispatch, so the text the caller is holding is the only copy of that
# answer and the conflict has to surface rather than be swallowed. A task
# that cannot be found under this agent+topic reads the same way; the reply
# path resolves the agent from the task first, so it should not reach here,
# and the safe default if it ever does is "surface it".
CONFLICT_ALREADY_COMPLETED = "already_completed"
CONFLICT_CLAIMED_WITHOUT_REPLY = "claimed_without_reply"
CONFLICT_NOT_DELEGATED = "not_delegated"

def conflict_reason(agent:, topic:, requested_task_id:)
task = Task.find_by(id: requested_task_id, agent_id: agent.id, topic_id: topic.id)
return CONFLICT_NOT_DELEGATED unless task&.status == "done"

# `reply_comment` is what #finalize links (comment.task_id = task.id), so
# its presence is the only proof the dispatch was actually answered.
task.reply_comment.present? ? CONFLICT_ALREADY_COMPLETED : CONFLICT_CLAIMED_WITHOUT_REPLY

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 Wait for finalization before hard-failing duplicate replies

When two same-directory Claude sessions reply at nearly the same time, the loser can reach this check after the winner's claim has committed status = done but before finalize has linked the reply comment. In that normal sibling-race window reply_comment.present? is still false, so the server returns claimed_without_reply; the client then throws instead of treating the race as benign, leaving the original 409-as-user-visible-error behavior timing-dependent even though the winning reply is about to land. Consider waiting/reloading for finalization (or otherwise making claim+link atomic) before deciding this is a hard conflict.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 97e3bda. You are right that the previous round made the loser's outcome timing-dependent, and the direction of that dependence is the bad one: the sibling race is the common case, and it lands in the done-without-reply window every single time. #claim commits done, then the controller runs comment.save — validations, callbacks, broadcasts — and only then does #finalize reach comment.update_column(:task_id, ...). Every winning claim passes through claimed-without-reply on its way to answered, so deciding on the first read was reporting "your answer may be the only copy" for what is almost always a duplicate that is milliseconds from landing.

The predicate is unchanged — only an actually linked comment is ever called benign, and nothing is inferred from status or elapsed time. What changed is that finalization gets a bounded chance to produce the proof before the answer is fixed:

FINALIZE_GRACE_SECONDS = 0.5
FINALIZE_GRACE_INTERVAL = 0.05

deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + FINALIZE_GRACE_SECONDS
loop do
  return true if reply_linked?(task)
  return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline

  sleep FINALIZE_GRACE_INTERVAL
end

Monotonic clock so a wall-clock adjustment cannot stretch or collapse the deadline mid-wait. The reads are uncached — that one is load-bearing rather than defensive: ActiveRecord::QueryCache wraps the request, so the identical SELECT 1 FROM comments WHERE task_id = ? would be served from the first (empty) result for the entire wait and the loop could never observe the winner's commit.

Only the window pays for it. A non-done task returns not_delegated before the wait is reached, and a reply that was already linked when the request arrived answers on the first read — both pinned by tests that assert the elapsed time stays under the grace period, so a later edit cannot quietly put the common paths behind a sleep.

I did not take the make-claim-and-link-atomic option, though it is the tighter one on paper. Doing it means holding the SELECT FOR UPDATE across comment.save, which is exactly what the current shape avoids: the long comment above #claim exists because that save fires after_update_commit work — TriggerLoopCheckJob, the stop-button broadcast — and running it inside the claim transaction puts those side effects before the commit they depend on. update_all is there specifically to keep them out. Moving the claim after the save instead reopens the duplicate-comment race the claim was written to close. Waiting buys the same observable behaviour for the loser (it blocks either way, on a lock or on a poll) without moving side effects inside a transaction.

What survives past the deadline is the case you and I both want surfaced: a claim whose worker died mid-save. It reports claimed_without_reply 500ms later than before, and nothing suppresses it.

Four tests in a new task_claim_service_test.rb, and reverting the wait to a single read fails two of them: a link that lands between the first and second read now reads as already_completed (with an assertion that it re-read at all, so a first-read decision fails even if it guesses right), and the never-finalized claim asserts both that the window was actually given and that it is bounded.

task_claim_service_test 4 runs / 22 assertions / 0 failures; agents_controller_test 75 runs / 344 assertions / 0 failures; full pre-push suite 2946 runs / 9267 assertions / 0 failures; rubocop clean on both files.

end

# Post-claim side effects, run only after the reply comment is saved. Links
# the comment to the claimed task, releases the ResourceTracker slot the
# AiAgentJob held under task.id, advances the parent workflow (if any), and
Expand Down
117 changes: 109 additions & 8 deletions engines/collavre/test/controllers/api/v1/agents_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1021,18 +1021,20 @@ class AgentsControllerTest < ActionDispatch::IntegrationTest
assert_equal "done", task.reload.status

# Second reply (same task_id) arrives after the first has completed.
# The task is no longer in delegated state — resolve_reply_agent
# cannot find it, returns nil, and reply renders 403. Either way,
# no duplicate comment is created.
# This is the multi-session dedup case: two Claude Channel sessions
# sharing one agent both /reply to the same fanned-out dispatch. The
# loser must reach the atomic-claim layer and get a clean 409 "already
# completed" — NOT a 403. resolve_reply_agent no longer gates on
# status: "delegated", so the already-done task still resolves the
# agent, and claim_delegated_task returns the 409. (Before the fix the
# loser failed at resolve_reply_agent with a misleading 403 that the
# MCP plugin surfaced as a hard error.) No duplicate comment either way.
post "/api/v1/agent/reply",
params: { topic_id: topic_id, text: "Duplicate reply", task_id: task.id },
headers: auth_headers,
as: :json
# The post-completion lookup falls through resolve_reply_agent (which
# scopes to status: "delegated") → 403. Either way, the second
# request must not produce a 2xx.
refute_includes 200..299, response.status,
"second reply for an already-completed task must not return 2xx"
assert_response :conflict,
"loser of the shared-agent dispatch race must get 409 (benign dedup), not 403"

# Count only the agent's own reply comments. An agent reply in an inbox
# creative now also creates a system-authored (user: nil) notification in
Expand Down Expand Up @@ -1094,6 +1096,105 @@ class AgentsControllerTest < ActionDispatch::IntegrationTest
"loser of the atomic claim must not save a comment"
end

test "409 for an already-answered dispatch is marked already_completed" do
# The dedup case: a sibling session sharing this agent won the claim and
# posted. The reply being refused is a duplicate, so the client is right
# to suppress it — and this reason is the only thing that says so.
reg = register_agent("conflict-reason-dedup")
topic_id = reg["topic_id"]
ai_user = User.find(reg["agent_id"])
creative = Topic.find(topic_id).creative.effective_origin

task = Collavre::Task.create!(
name: "Answered dispatch",
status: "delegated",
trigger_event_name: "comment_created",
agent: ai_user,
topic_id: topic_id,
creative_id: creative.id
)

post "/api/v1/agent/reply",
params: { topic_id: topic_id, text: "Sibling's answer", task_id: task.id },
headers: auth_headers,
as: :json
assert_response :created

post "/api/v1/agent/reply",
params: { topic_id: topic_id, text: "Duplicate answer", task_id: task.id },
headers: auth_headers,
as: :json
assert_response :conflict
assert_equal "already_completed", JSON.parse(response.body)["reason"]
end

test "409 for a cancelled dispatch is NOT marked already_completed" do
# Codex P2: an offline-session cancellation or stuck-task recovery flips
# the task out of `delegated` while the agent is still composing. The
# claim fails exactly as it does for dedup, but NOTHING answered this
# dispatch — a client that reads every 409 as dedup drops the only copy
# of the reply. The reason is what lets it tell the two apart.
reg = register_agent("conflict-reason-cancelled")
topic_id = reg["topic_id"]
ai_user = User.find(reg["agent_id"])
creative = Topic.find(topic_id).creative.effective_origin

%w[cancelled failed pending].each do |status|
task = Collavre::Task.create!(
name: "Dispatch that went #{status}",
status: status,
trigger_event_name: "comment_created",
agent: ai_user,
topic_id: topic_id,
creative_id: creative.id
)

post "/api/v1/agent/reply",
params: { topic_id: topic_id, text: "Answer nobody else posted", task_id: task.id },
headers: auth_headers,
as: :json

assert_response :conflict, "#{status} task must still be refused"
assert_equal "not_delegated", JSON.parse(response.body)["reason"],
"a #{status} dispatch was never answered — suppressing this 409 loses the reply"
end
end

test "409 for a task claimed but not yet answered is NOT marked already_completed" do
# Codex P2 follow-up: #claim flips the task to done BEFORE the comment
# is saved. A reply arriving inside that window sees `done` with nothing
# posted behind it — and that window can end in a rollback (invalid text
# restores the task to `delegated`) or never end (the worker dies). The
# linked reply comment, not the status, is the proof of an answer.
reg = register_agent("conflict-reason-claimed-unanswered")
topic_id = reg["topic_id"]
ai_user = User.find(reg["agent_id"])
creative = Topic.find(topic_id).creative.effective_origin

task = Collavre::Task.create!(
name: "Claimed but unanswered",
status: "delegated",
trigger_event_name: "comment_created",
agent: ai_user,
topic_id: topic_id,
creative_id: creative.id
)

# Exactly what #claim leaves behind, without the comment #finalize links.
Collavre::Task.where(id: task.id).update_all(status: "done")
assert_nil task.reload.reply_comment, "precondition: claimed, nothing posted"

post "/api/v1/agent/reply",
params: { topic_id: topic_id, text: "The only copy of this answer", task_id: task.id },
headers: auth_headers,
as: :json

assert_response :conflict
assert_equal "claimed_without_reply", JSON.parse(response.body)["reason"],
"a claim with no reply behind it must not be suppressed as dedup"
end


test "Claude Channel reply enqueues TriggerLoopCheckJob only AFTER the reply comment is saved" do
# Regression for Codex P2: claim_delegated_task previously used
# update! which fires after_update_commit synchronously — but the
Expand Down
53 changes: 53 additions & 0 deletions tools/collavre-claude-plugin/src/collavre-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { isBenignReplyDedup } from "./collavre-client.ts";

test("a sibling session's completed-task conflict is benign", () => {
// The dispatch fanned out to two sessions sharing one agent; the other won the
// atomic claim and posted. Nothing of ours is lost by staying quiet.
assert.equal(
isBenignReplyDedup(
JSON.stringify({ error: "Task already completed or not delegated", reason: "already_completed" }),
),
true,
);
});

test("a cancelled/failed/recovered task conflict is NOT benign", () => {
// Nobody answered this dispatch — the reply in hand is the only copy of it.
assert.equal(
isBenignReplyDedup(
JSON.stringify({ error: "Task already completed or not delegated", reason: "not_delegated" }),
),
false,
);
});

test("a task claimed but not yet answered is NOT benign", () => {
// The server flips the task to done before the reply comment is saved. A 409
// from inside that window has no answer behind it — and the window can end in
// a rollback — so this reply is still the only copy.
assert.equal(
isBenignReplyDedup(
JSON.stringify({
error: "Task already completed or not delegated",
reason: "claimed_without_reply",
}),
),
false,
);
});

test("a legacy server that sends no reason is NOT benign", () => {
// Back-compat runs the safe way: surface the conflict, as this client did
// before dedup existed, rather than silently dropping a possibly-valid reply.
assert.equal(
isBenignReplyDedup(JSON.stringify({ error: "Task already completed or not delegated" })),
false,
);
});

test("a non-JSON body is NOT benign", () => {
assert.equal(isBenignReplyDedup("<html>502 Bad Gateway</html>"), false);
assert.equal(isBenignReplyDedup(""), false);
});
48 changes: 46 additions & 2 deletions tools/collavre-claude-plugin/src/collavre-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,28 @@ export interface RegisterBody {
name: string;
}

// The server's machine-readable marker for the ONE benign reply conflict: the
// delegated task was already completed, so the dispatch this reply answers has
// an answer. See TaskClaimService::CONFLICT_ALREADY_COMPLETED.
const BENIGN_REPLY_CONFLICT_REASON = "already_completed";

/**
* Whether a 409 body from /agent/reply is the sibling-session dedup and may be
* suppressed. Anything else — a cancelled/failed/recovered task, an id that is
* not ours, an unparseable body, or an older server that sends no reason at all
* — is NOT: the reply in hand is the only copy of that answer, and suppressing
* it drops it with no trace. Unmarked conflicts therefore fall back to being
* raised, which is what this client did before dedup existed.
*/
export function isBenignReplyDedup(body: string): boolean {
try {
const parsed = JSON.parse(body) as { reason?: unknown };
return parsed?.reason === BENIGN_REPLY_CONFLICT_REASON;
} catch {
return false;
}
}

export function buildRegisterBody(params: RegisterParams): RegisterBody {
const body: RegisterBody = {
agent_name: params.agentName,
Expand Down Expand Up @@ -73,7 +95,7 @@ export class CollavreClient {
topicId: number,
text: string,
taskId: number,
): Promise<{ comment_id: number }> {
): Promise<{ handled: true; comment_id: number } | { handled: false; reason: string }> {
const body: Record<string, unknown> = { topic_id: topicId, text, task_id: taskId };

const res = await fetch(`${this.baseUrl}/api/v1/agent/reply`, {
Expand All @@ -85,12 +107,34 @@ export class CollavreClient {
body: JSON.stringify(body),
});

// 409 Conflict has two causes and only one of them is benign.
//
// Benign: multiple Claude Code sessions in the SAME working directory share
// one Collavre agent; a work-topic dispatch fans out to all of them and each
// session's turn calls reply with the same task_id. The server's atomic task
// claim lets exactly one win — the others are refused. The message was
// already delivered by a sibling, so this is success-with-nothing-to-do.
//
// Not benign: the dispatch left `delegated` WITHOUT being answered — an
// offline session cancelled it, it failed, or stuck-task recovery flipped it
// while this turn was still composing. The claim fails the same way, but no
// sibling posted anything, so treating it as dedup drops the user's answer
// silently. Only the server's explicit already_completed marker suppresses.
if (res.status === 409) {
const respBody = await res.text();
if (!isBenignReplyDedup(respBody)) {
throw new Error(`Reply failed (409): ${respBody}`);
}
return { handled: false, reason: respBody };
Comment on lines +123 to +128

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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 96bb8ac. The claim fails identically in both cases, which is exactly why the client could not tell them apart: claim_delegated_task returns nil for any task that is not delegated, so a dispatch cancelled by an offline session, failed, or flipped by stuck-task recovery while Claude was composing produced the same bare 409 as the sibling-session dedup. The dedup case has an answer already posted; the others have none, and the text in hand is the only copy of it — so index.ts reporting "Already answered" and posting nothing lost a real reply, silently.

The server now names the cause rather than leaving the client to guess:

  • TaskClaimService#conflict_reasonalready_completed when the task is done (a sibling won the claim, or the agent's own job completed it — either way the dispatch was answered and this reply is a duplicate), not_delegated for every other status.
  • reply renders it beside the existing message: { error: ..., reason: ... }, still 409.

The client suppresses on the marker only. isBenignReplyDedup parses the body and requires reason === "already_completed"; anything else — not_delegated, an unparseable body, or a response with no reason at all — is thrown as Reply failed (409). That last one is the back-compat call: an older server sending the unmarked body gets the pre-dedup behaviour (a visible error) rather than the silent drop. Surfacing a conflict the model can act on is recoverable; dropping the user's answer is not.

CONFLICT_UNKNOWN_TASK is deliberately not a third reason. resolve_reply_agent resolves the agent from the task and 403s if it is not this caller's on this topic, so by the time the conflict branch runs the task always exists — a find_by miss in conflict_reason would be unreachable, and it folds into not_delegated (surface it) as the safe default.

Tests pin both directions. Ruby: an answered dispatch replied to twice returns already_completed; cancelled, failed and pending tasks each return not_delegated with an explicit "suppressing this 409 loses the reply" assertion. Making conflict_reason unconditionally return already_completed fails that second test. TypeScript: isBenignReplyDedup over the dedup body, the cancelled body, a legacy no-reason body, and non-JSON.

agents_controller_test 74 runs / 340 assertions / 0 failures; plugin suite 32/32; tsc --noEmit and rubocop clean.

}

if (!res.ok) {
const respBody = await res.text();
throw new Error(`Reply failed (${res.status}): ${respBody}`);
}

return res.json() as Promise<{ comment_id: number }>;
const json = (await res.json()) as { comment_id: number };
return { handled: true, comment_id: json.comment_id };
}

// Post an out-of-band informational comment to a topic WITHOUT completing a
Expand Down
18 changes: 17 additions & 1 deletion tools/collavre-claude-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,8 @@ function buildServer(

const result = await client.reply(topicId, text, taskId);

// The dispatched turn is concluding (Claude has replied). Reset the active
// The dispatched turn is concluding (Claude has replied, or a sibling
// session already answered this fanned-out dispatch). Reset the active
// context to the registration inbox default so a subsequent locally-
// initiated turn's permission prompt surfaces in the inbox rather than
// leaking into this just-finished work topic.
Expand All @@ -140,6 +141,21 @@ function buildServer(
// comment must not be claimed and forwarded to a turn that is already over.
coordinator.clear();

// Benign dedup: another session sharing this agent already claimed and
// answered this dispatch (server returned 409). Nothing was posted by us,
// and that is correct — report it as a non-error so the model doesn't treat
// a normal multi-session race as a failed reply.
if (!result.handled) {
return {
content: [
{
type: "text" as const,
text: "Already answered by another session sharing this agent — nothing to send.",
},
],
};
}

return {
content: [
{ type: "text" as const, text: `Sent (comment #${result.comment_id})` },
Expand Down