mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 19:59:20 +02:00
Compare commits
2 Commits
fix/email-
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e47977212c | ||
|
|
b0b667b907 |
130
docs/rfcs/0001-mention-dedup-policy.md
Normal file
130
docs/rfcs/0001-mention-dedup-policy.md
Normal 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.
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user