Compare commits

...

2 Commits

Author SHA1 Message Date
Lambda
e47977212c fix(handler): make on_comment and @mention paths mutually exclusive
Without (issue, agent) coalescing, a single member comment that @mentions
the assignee was enqueueing two tasks with identical trigger_comment_id —
once via shouldEnqueueOnComment → EnqueueTaskForIssue and once via
enqueueMentionedAgentTasks → EnqueueTaskForMention. The same double-fire
hit plain replies that inherit the assignee mention from the thread root.

Add commentMentionsAssignee, which uses the shared
shouldInheritParentMentions logic to compute the same effective mention
set the @mention path will see. Extend the on_comment gate to skip when
that helper says the assignee will be triggered through the @mention
path. Net result: exactly one task per (comment, assignee), with the
trigger comment preserved.

TestOnCommentTriggerDecision updated to reflect the new contract; assignee
mention cases that used to assert the on_comment branch fires now assert
it skips (mention path covers them). New integration test
TestAssigneeMentionDoesNotDoubleEnqueue pins the end-to-end behavior.

Also softens the "ClaimAgentTask guarantees serial execution" wording in
the code and the RFC: it is a coordination-side property under the
current single-poller-per-runtime model, not a DB-level lock on
(issue, agent). FOR UPDATE SKIP LOCKED locks the row being claimed, not
the key.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-09 14:42:25 +08:00
Lambda
b0b667b907 refactor(handler): drop @mention coalescing dedup, enqueue per trigger
Each @mention or assignee comment now creates its own queued task instead
of folding into a single pending task per (issue, agent). The previous
HasPendingTaskForIssueAndAgent short-circuit in enqueueMentionedAgentTasks
and shouldEnqueueOnComment is gone; ClaimAgentTask already enforces
per-(issue, agent) serial execution at claim time, so multiple queued rows
drain one-by-one without overlap.

Why: see docs/rfcs/0001-mention-dedup-policy.md. Coalescing dropped the
trigger comment for every mention after the first, gave no UI feedback,
and collapsed distinct intents (different threads, different requests).

Drops the unused HasPendingTaskForIssue and HasPendingTaskForIssueAndAgent
queries and regenerates sqlc.

Adds TestRepeatedMentionsEnqueueSeparateTasks pinning the new contract:
two back-to-back @mentions queue two tasks with distinct trigger_comment_ids.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-09 14:29:55 +08:00
7 changed files with 352 additions and 84 deletions

View File

@@ -0,0 +1,130 @@
# RFC: Per-mention agent task enqueue (drop @mention coalescing dedup)
- Issue: [MUL-1913](mention://issue/9f54962b-e055-43eb-a649-1b16db52fea2)
- Status: Accepted
- Date: 2026-05-09
## Background
When a member @mentions an agent on an issue (or a member comments on an
issue assigned to an agent), the trigger path enqueues an `agent_task_queue`
row. Today both paths short-circuit when the same agent already has a
`queued` or `dispatched` task on the same issue:
- `server/internal/handler/comment.go` `enqueueMentionedAgentTasks`@mention
trigger
- `server/internal/handler/issue.go` `shouldEnqueueOnComment` — assignee-on-
comment trigger
Both call `Queries.HasPendingTaskForIssueAndAgent` and skip enqueue when it
returns true. The intent was a coalescing queue: rapid-fire comments fold
into a single pending task, and when that task picks up it reads all the
latest comments anyway.
## Problem
The coalescing model has three user-visible costs:
1. **No UI feedback for the merged comment.** A second @mention does not
create a task, so no queued banner appears and there is no toast saying
"merged into pending task". Users perceive the @mention as lost.
2. **Trigger comment provenance is lost.** Only the first trigger comment
is recorded on the task; subsequent triggers are not referenced by any
task. Auditing "what made this run happen" fails.
3. **Distinct intents collapse.** When two @mentions live in different
threads with different requests ("add a test" vs. "fix copy"), folding
them into one task forces the agent to disambiguate, and the user cannot
cancel one without cancelling both.
Different threads and different mention text are strong signals that the
two triggers are distinct intents — coalescing throws that signal away.
## Decision
**Adopt option C: every @mention or assignee-comment trigger creates its
own task.** No `(issue, agent)` dedup at enqueue time.
Per-(issue, agent) execution stays serial because `ClaimAgentTask`
(`server/pkg/db/queries/agent.sql`) refuses to dispatch a queued row when
the same agent has another `dispatched` or `running` row on the same
issue. Multiple queued rows pile up safely and drain in
`(priority DESC, created_at ASC)` order. This is a coordination-side
property — `FOR UPDATE SKIP LOCKED` locks the row being claimed, not the
`(issue, agent)` key — and relies on the daemon today never invoking
`ClaimAgentTask` concurrently for the same agent. Tightening that into a
real DB-level guarantee (e.g. an advisory lock keyed on `(issue, agent)`)
is out of scope for this RFC.
### Mutual exclusion between on_comment and @mention paths
Without `(issue, agent)` dedup at enqueue time, a single member comment
that @mentions the assignee would otherwise enqueue twice with identical
`trigger_comment_id`: once via the on_comment path
(`shouldEnqueueOnComment``EnqueueTaskForIssue`) and once via the
@mention path (`enqueueMentionedAgentTasks``EnqueueTaskForMention`).
Same trick applies to a plain reply that inherits the assignee mention
from the thread root.
The on_comment gate gains a `commentMentionsAssignee` clause that uses the
same effective-mention computation as the @mention path
(`shouldInheritParentMentions` for inheritance). When the @mention path
will enqueue for the assignee, on_comment skips. The two paths become
mutually exclusive on a `(comment, assignee)` pair.
### Considered alternatives
- **A. Keep coalescing, add a UI hint** ("merged into pending task"). Fixes
visibility but not provenance and not the distinct-intent case.
- **B. Allow up to N queued tasks per (issue, agent), coalesce above N.**
Combines the worst of both — still loses the Nth+1 trigger comment, and
introduces a magic number.
- **C. No dedup, every trigger creates a task. (Chosen.)**
## Out of scope
- **True rapid-fire duplicate suppression.** A user double-clicks @ within
a second; both create tasks and the agent runs twice on identical
context. Acceptable cost — the agent reads its own previous comment in
the second run and can early-exit. We may revisit by having the
scheduler skip a queued task when "no relevant comments since the
previous task for this (issue, agent) completed", but that is a
follow-up, not a blocker for this RFC.
- **Cross-agent dedup.** Different agents on the same issue continue to
run in parallel; nothing changes there.
- **Queued banner UI.** Already shipped in
[MUL-1897](mention://issue/14fdefb4-3a36-4406-a840-1f6700ac95b5).
## Implementation
1. Remove the `HasPendingTaskForIssueAndAgent` short-circuit in
`enqueueMentionedAgentTasks`.
2. Remove the `HasPendingTaskForIssueAndAgent` short-circuit in
`shouldEnqueueOnComment`. The function reduces to the
assignee-readiness check (`isAgentAssigneeReady` + non-backlog status).
3. Add the `commentMentionsAssignee` clause to the on_comment gate so
the on_comment and @mention paths are mutually exclusive on a
`(comment, assignee)` pair (see "Mutual exclusion" above).
4. Drop `HasPendingTaskForIssueAndAgent` and the unused
`HasPendingTaskForIssue` from `server/pkg/db/queries/agent.sql`. Re-run
`make sqlc`.
5. Tests:
- `TestRepeatedMentionsEnqueueSeparateTasks` — two @mentions on an
unassigned issue produce two `queued` rows with distinct
`trigger_comment_id` values.
- `TestAssigneeMentionDoesNotDoubleEnqueue` — a member comment that
@mentions the assignee on an assigned issue produces exactly one
`queued` row (the mention path), not two.
6. No migration needed. No frontend changes needed: the queued banner
already aggregates over `ListActiveTasksByIssue`, so multiple queued
rows render correctly.
## Risks
- **Cost:** users who comment frequently on agent-assigned issues will
trigger more runs than today. Mitigated by per-(issue, agent) serial
execution — no extra concurrency, just more sequential work — and by
the future "skip if no relevant comments" optimization noted above.
- **Replay:** the second queued task reads issue state that the first
task may have already addressed. Agents already need to read recent
comments and judge whether work is still required; this RFC does not
change that contract.

View File

@@ -305,9 +305,15 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
// the user is talking to someone else, not requesting work from the assignee.
// Also skip when replying in a member-started thread without mentioning the
// assignee — the user is continuing a member-to-member conversation.
// Also skip when the comment effectively @mentions the assignee (explicit
// or via parent inheritance) — enqueueMentionedAgentTasks below will
// handle that path with the same trigger_comment_id, and double-enqueueing
// the same (comment, assignee) pair would create two redundant tasks now
// that the per-(issue, agent) coalescing dedup is gone.
if authorType == "member" && h.shouldEnqueueOnComment(r.Context(), issue) &&
!h.commentMentionsOthersButNotAssignee(comment.Content, issue) &&
!h.isReplyToMemberThread(r.Context(), parentComment, comment.Content, issue) {
!h.isReplyToMemberThread(r.Context(), parentComment, comment.Content, issue) &&
!h.commentMentionsAssignee(comment.Content, parentComment, authorType, issue) {
// Always use the current comment as the trigger so the agent reads
// the actual new reply, not the thread root. Reply placement (flat
// thread grouping) is handled downstream by createAgentComment,
@@ -360,6 +366,34 @@ func (h *Handler) commentMentionsOthersButNotAssignee(content string, issue db.I
return true // Others mentioned but not assignee — suppress trigger
}
// commentMentionsAssignee returns true when the assignee agent will be
// triggered via the @mention path for this comment — either an explicit
// agent mention in the comment content, or an inherited mention from the
// thread root when shouldInheritParentMentions applies. Used to make the
// on_comment trigger and the @mention trigger mutually exclusive on the
// same (comment, assignee) pair: without this gate a member comment that
// @mentions the assignee would enqueue two tasks with identical
// trigger_comment_ids.
func (h *Handler) commentMentionsAssignee(content string, parent *db.Comment, authorType string, issue db.Issue) bool {
if !issue.AssigneeID.Valid {
return false
}
if issue.AssigneeType.String != "agent" {
return false
}
mentions := util.ParseMentions(content)
if shouldInheritParentMentions(parent, mentions, authorType) {
mentions = util.ParseMentions(parent.Content)
}
assigneeID := uuidToString(issue.AssigneeID)
for _, m := range mentions {
if m.Type == "agent" && m.ID == assigneeID {
return true
}
}
return false
}
// isReplyToMemberThread returns true if the comment is a reply in a thread
// started by a member and does NOT @mention the issue's assignee agent.
// When a member replies in a member-started thread, they are most likely
@@ -486,16 +520,16 @@ func (h *Handler) enqueueMentionedAgentTasks(ctx context.Context, issue db.Issue
}
}
}
// Dedup: skip if this agent already has a pending task for this issue.
hasPending, err := h.Queries.HasPendingTaskForIssueAndAgent(ctx, db.HasPendingTaskForIssueAndAgentParams{
IssueID: issue.ID,
AgentID: agentUUID,
})
if err != nil || hasPending {
continue
}
// Always use the current comment as the trigger so the agent reads the
// actual reply that mentioned it, not the thread root.
// Each @mention enqueues its own task. ClaimAgentTask refuses to
// dispatch a queued row when another row for the same (issue, agent)
// is already dispatched or running, so under the current
// single-poller-per-runtime model multiple queued rows drain one at
// a time. (This is a coordination-side property, not a hard DB lock
// on the (issue, agent) key — concurrent claimers could in theory
// race, but the daemon does not invoke ClaimAgentTask concurrently
// for the same agent today.) Always use the current comment as the
// trigger so the agent reads the actual reply that mentioned it,
// not the thread root.
if _, err := h.TaskService.EnqueueTaskForMention(ctx, issue, agentUUID, comment.ID); err != nil {
slog.Warn("enqueue mention agent task failed", "issue_id", uuidToString(issue.ID), "agent_id", m.ID, "error", err)
}

View File

@@ -2274,3 +2274,157 @@ func TestAgentExplicitMentionStillTriggers(t *testing.T) {
t.Fatalf("expected 0 tasks for Agent A (no self-trigger on own mention), got %d", got)
}
}
// TestRepeatedMentionsEnqueueSeparateTasks pins down the post-RFC-0001
// behavior: each @mention of the same agent on the same issue must enqueue
// its own queued task, even when an earlier task is still pending. The old
// coalescing path silently dropped the second mention; per-(issue, agent)
// serialization at claim time (ClaimAgentTask) is what keeps execution
// safe, not enqueue-time dedup.
func TestRepeatedMentionsEnqueueSeparateTasks(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
agentID := createHandlerTestAgent(t, "Repeat Mention Target", nil)
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Repeat-mention enqueue test",
"status": "todo",
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
}
var issue IssueResponse
json.NewDecoder(w.Body).Decode(&issue)
issueID := issue.ID
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
})
mentionBody := func(text string) map[string]any {
return map[string]any{
"content": fmt.Sprintf("[@Repeat](mention://agent/%s) %s", agentID, text),
}
}
// First mention queues a task.
w = httptest.NewRecorder()
r := newRequest("POST", "/api/issues/"+issueID+"/comments", mentionBody("first request"))
r = withURLParam(r, "id", issueID)
testHandler.CreateComment(w, r)
if w.Code != http.StatusCreated {
t.Fatalf("first mention: expected 201, got %d: %s", w.Code, w.Body.String())
}
// Second mention while the first task is still queued — must enqueue a
// second task, not be coalesced into the first.
w = httptest.NewRecorder()
r = newRequest("POST", "/api/issues/"+issueID+"/comments", mentionBody("second request"))
r = withURLParam(r, "id", issueID)
testHandler.CreateComment(w, r)
if w.Code != http.StatusCreated {
t.Fatalf("second mention: expected 201, got %d: %s", w.Code, w.Body.String())
}
var queued int
if err := testPool.QueryRow(ctx,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
issueID, agentID,
).Scan(&queued); err != nil {
t.Fatalf("count queued tasks: %v", err)
}
if queued != 2 {
t.Fatalf("expected 2 queued tasks (one per @mention), got %d", queued)
}
// Each task must reference its own trigger comment for provenance — the
// whole point of dropping coalescing.
var distinctTriggers int
if err := testPool.QueryRow(ctx, `
SELECT count(DISTINCT trigger_comment_id) FROM agent_task_queue
WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'
AND trigger_comment_id IS NOT NULL
`, issueID, agentID).Scan(&distinctTriggers); err != nil {
t.Fatalf("count distinct trigger comments: %v", err)
}
if distinctTriggers != 2 {
t.Fatalf("expected 2 distinct trigger_comment_id values, got %d", distinctTriggers)
}
}
// TestAssigneeMentionDoesNotDoubleEnqueue pins down the on_comment / mention
// path mutual exclusion: when a member comment on an issue assigned to an
// agent @mentions that same assignee, the post-RFC code must enqueue exactly
// one task (via the @mention path), not two. Before
// commentMentionsAssignee was added, the on_comment branch and the
// enqueueMentionedAgentTasks branch both fired with identical
// trigger_comment_ids — the old HasPendingTaskForIssueAndAgent dedup masked
// it, but dropping that dedup exposed the regression.
func TestAssigneeMentionDoesNotDoubleEnqueue(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
agentID := createHandlerTestAgent(t, "Assignee Dedup Target", nil)
// Create the issue assigned to the agent up front so the on_comment path
// can fire on the first comment.
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Assignee mention dedup test",
"status": "todo",
"assignee_type": "agent",
"assignee_id": agentID,
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
}
var issue IssueResponse
json.NewDecoder(w.Body).Decode(&issue)
issueID := issue.ID
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
})
// CreateIssue itself enqueues an initial task because the issue is
// assigned + non-backlog. Drain that so we can isolate the comment path.
if _, err := testPool.Exec(ctx,
`UPDATE agent_task_queue SET status = 'cancelled', completed_at = now() WHERE issue_id = $1`,
issueID,
); err != nil {
t.Fatalf("clear initial assignment task: %v", err)
}
w = httptest.NewRecorder()
r := newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
"content": fmt.Sprintf("[@Target](mention://agent/%s) please look", agentID),
})
r = withURLParam(r, "id", issueID)
testHandler.CreateComment(w, r)
if w.Code != http.StatusCreated {
t.Fatalf("comment with assignee mention: expected 201, got %d: %s", w.Code, w.Body.String())
}
var queued int
if err := testPool.QueryRow(ctx,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
issueID, agentID,
).Scan(&queued); err != nil {
t.Fatalf("count queued tasks: %v", err)
}
if queued != 1 {
t.Fatalf("expected 1 queued task (mention path only), got %d — on_comment and mention paths both fired for the same (comment, assignee)", queued)
}
}

View File

@@ -1625,25 +1625,16 @@ func (h *Handler) shouldEnqueueAgentTask(ctx context.Context, issue db.Issue) bo
return h.isAgentAssigneeReady(ctx, issue)
}
// shouldEnqueueOnComment returns true if a member comment on this issue should
// trigger the assigned agent. Fires for any status — comments are
// shouldEnqueueOnComment returns true if a member comment on this issue
// should trigger the assigned agent. Fires for any status — comments are
// conversational and can happen at any stage, including after completion
// (e.g. follow-up questions on a done issue).
// (e.g. follow-up questions on a done issue). Each comment enqueues its
// own task; ClaimAgentTask refuses to dispatch a queued row when another
// row for the same (issue, agent) is already dispatched or running, so
// under the current single-poller-per-runtime model multiple queued rows
// drain one at a time.
func (h *Handler) shouldEnqueueOnComment(ctx context.Context, issue db.Issue) bool {
if !h.isAgentAssigneeReady(ctx, issue) {
return false
}
// Coalescing queue: allow enqueue when a task is running (so the agent
// picks up new comments on the next cycle) but skip if this agent already
// has a pending task (natural dedup for rapid-fire comments).
hasPending, err := h.Queries.HasPendingTaskForIssueAndAgent(ctx, db.HasPendingTaskForIssueAndAgentParams{
IssueID: issue.ID,
AgentID: issue.AssigneeID,
})
if err != nil || hasPending {
return false
}
return true
return h.isAgentAssigneeReady(ctx, issue)
}
// isAgentAssigneeReady checks if an issue is assigned to an active agent

View File

@@ -278,11 +278,16 @@ func TestOnCommentTriggerDecision(t *testing.T) {
Content: fmt.Sprintf("[@Agent](mention://agent/%s) help me", agentAssigneeID),
}
// Simulates the combined check from CreateComment:
// !commentMentionsOthersButNotAssignee && !isReplyToMemberThread
// Simulates the combined on_comment gate from CreateComment. The third
// clause makes the on_comment path mutually exclusive with the @mention
// path on the same (comment, assignee) pair: when the comment
// effectively mentions the assignee (explicitly or via parent
// inheritance), enqueueMentionedAgentTasks handles it and on_comment
// must skip — otherwise we double-enqueue.
shouldTrigger := func(parent *db.Comment, content string) bool {
return !h.commentMentionsOthersButNotAssignee(content, issue) &&
!h.isReplyToMemberThread(context.Background(), parent, content, issue)
!h.isReplyToMemberThread(context.Background(), parent, content, issue) &&
!h.commentMentionsAssignee(content, parent, "member", issue)
}
tests := []struct {
@@ -292,15 +297,18 @@ func TestOnCommentTriggerDecision(t *testing.T) {
want bool
}{
{"top-level, no mention", nil, "hello agent", true},
{"top-level, mention assignee", nil, fmt.Sprintf("[@Agent](mention://agent/%s) fix this", agentAssigneeID), true},
// Mention path covers the assignee — on_comment must NOT also fire.
{"top-level, mention assignee → mention path only", nil, fmt.Sprintf("[@Agent](mention://agent/%s) fix this", agentAssigneeID), false},
{"top-level, mention other only", nil, fmt.Sprintf("[@Other](mention://agent/%s) look", otherAgentID), false},
{"reply agent thread, no mention", agentParent, "got it", true},
{"reply agent thread, mention other member", agentParent, fmt.Sprintf("[@Bob](mention://member/%s) ?", memberID), false},
{"reply agent thread, mention assignee", agentParent, fmt.Sprintf("[@Agent](mention://agent/%s) yes", agentAssigneeID), true},
{"reply agent thread, mention assignee → mention path only", agentParent, fmt.Sprintf("[@Agent](mention://agent/%s) yes", agentAssigneeID), false},
{"reply member thread, no mention", memberParent, "agreed", false},
{"reply member thread, mention other member", memberParent, fmt.Sprintf("[@Bob](mention://member/%s) ok", memberID), false},
{"reply member thread, mention assignee", memberParent, fmt.Sprintf("[@Agent](mention://agent/%s) help", agentAssigneeID), true},
{"reply member thread that @mentioned assignee, no re-mention", memberParentMentioningAssignee, "here is more info", true},
{"reply member thread, mention assignee → mention path only", memberParent, fmt.Sprintf("[@Agent](mention://agent/%s) help", agentAssigneeID), false},
// Plain reply inherits the assignee mention from the parent — mention
// path picks it up via shouldInheritParentMentions, on_comment skips.
{"reply member thread that @mentioned assignee, no re-mention → mention path only", memberParentMentioningAssignee, "here is more info", false},
{"top-level, @all broadcast", nil, "[@All](mention://all/all) heads up team", false},
{"reply agent thread, @all broadcast", agentParent, "[@All](mention://all/all) update for everyone", false},
{"reply member thread, @all broadcast", memberParent, "[@All](mention://all/all) fyi", false},

View File

@@ -1191,41 +1191,6 @@ func (q *Queries) HasActiveTaskForIssue(ctx context.Context, issueID pgtype.UUID
return has_active, err
}
const hasPendingTaskForIssue = `-- name: HasPendingTaskForIssue :one
SELECT count(*) > 0 AS has_pending FROM agent_task_queue
WHERE issue_id = $1 AND status IN ('queued', 'dispatched')
`
// Returns true if there is a queued or dispatched (but not yet running) task for the issue.
// Used by the coalescing queue: allow enqueue when a task is running (so
// the agent picks up new comments on the next cycle) but skip if a pending
// task already exists (natural dedup).
func (q *Queries) HasPendingTaskForIssue(ctx context.Context, issueID pgtype.UUID) (bool, error) {
row := q.db.QueryRow(ctx, hasPendingTaskForIssue, issueID)
var has_pending bool
err := row.Scan(&has_pending)
return has_pending, err
}
const hasPendingTaskForIssueAndAgent = `-- name: HasPendingTaskForIssueAndAgent :one
SELECT count(*) > 0 AS has_pending FROM agent_task_queue
WHERE issue_id = $1 AND agent_id = $2 AND status IN ('queued', 'dispatched')
`
type HasPendingTaskForIssueAndAgentParams struct {
IssueID pgtype.UUID `json:"issue_id"`
AgentID pgtype.UUID `json:"agent_id"`
}
// Returns true if a specific agent already has a queued or dispatched task
// for the given issue. Used by @mention trigger dedup.
func (q *Queries) HasPendingTaskForIssueAndAgent(ctx context.Context, arg HasPendingTaskForIssueAndAgentParams) (bool, error) {
row := q.db.QueryRow(ctx, hasPendingTaskForIssueAndAgent, arg.IssueID, arg.AgentID)
var has_pending bool
err := row.Scan(&has_pending)
return has_pending, err
}
const linkTaskToIssue = `-- name: LinkTaskToIssue :exec
UPDATE agent_task_queue
SET issue_id = $2

View File

@@ -320,20 +320,6 @@ WHERE agent_id = $1 AND status IN ('dispatched', 'running');
SELECT count(*) > 0 AS has_active FROM agent_task_queue
WHERE issue_id = $1 AND status IN ('queued', 'dispatched', 'running');
-- name: HasPendingTaskForIssue :one
-- Returns true if there is a queued or dispatched (but not yet running) task for the issue.
-- Used by the coalescing queue: allow enqueue when a task is running (so
-- the agent picks up new comments on the next cycle) but skip if a pending
-- task already exists (natural dedup).
SELECT count(*) > 0 AS has_pending FROM agent_task_queue
WHERE issue_id = $1 AND status IN ('queued', 'dispatched');
-- name: HasPendingTaskForIssueAndAgent :one
-- Returns true if a specific agent already has a queued or dispatched task
-- for the given issue. Used by @mention trigger dedup.
SELECT count(*) > 0 AS has_pending FROM agent_task_queue
WHERE issue_id = $1 AND agent_id = $2 AND status IN ('queued', 'dispatched');
-- name: ListPendingTasksByRuntime :many
SELECT * FROM agent_task_queue
WHERE runtime_id = $1 AND status IN ('queued', 'dispatched')