Compare commits

...

1 Commits

Author SHA1 Message Date
Jiang Bohan
173b07e67e fix(comments): cancel triggered tasks when comment is deleted
When a user deletes a comment that triggered an agent task, the agent
would still run with the now-deleted content baked into its prompt
(fetched at task claim time) — manifesting as "the agent still sees the
deleted comment". The FK ON DELETE SET NULL only nullified
trigger_comment_id; the queued task itself was never cancelled.

DeleteComment now cancels any queued/dispatched/running task whose
trigger is the deleted comment, before the comment row is removed.
2026-04-27 17:52:27 +08:00
5 changed files with 128 additions and 0 deletions

View File

@@ -512,6 +512,41 @@ func TestCommentTriggerThreadInheritedMention(t *testing.T) {
})
}
// TestDeleteCommentCancelsTriggeredTasks verifies that deleting a comment
// also cancels any active tasks that were triggered by it. Without this,
// the daemon would still claim the queued task after the FK SET NULL
// nullified its trigger_comment_id, and the agent would either run with a
// stale prompt (race during claim) or with a generic "you are assigned"
// prompt that has no record of the now-deleted user request — both of
// which manifest as "the agent still sees the deleted comment".
func TestDeleteCommentCancelsTriggeredTasks(t *testing.T) {
agentID := getAgentID(t)
issueID := createIssueAssignedToAgent(t, "Delete-comment cancels task test", agentID)
t.Cleanup(func() {
clearTasks(t, issueID)
resp := authRequest(t, "DELETE", "/api/issues/"+issueID, nil)
resp.Body.Close()
})
t.Run("deleting trigger comment cancels its queued task", func(t *testing.T) {
clearTasks(t, issueID)
commentID := postComment(t, issueID, "Please fix this bug", nil)
if n := countPendingTasks(t, issueID); n != 1 {
t.Fatalf("expected 1 pending task before delete, got %d", n)
}
resp := authRequest(t, "DELETE", "/api/comments/"+commentID, nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("DeleteComment: expected 204, got %d", resp.StatusCode)
}
if n := countPendingTasks(t, issueID); n != 0 {
t.Errorf("expected 0 pending tasks after deleting trigger comment, got %d", n)
}
})
}
// TestCommentTriggerCoalescing verifies that rapid-fire comments don't create
// duplicate tasks (coalescing dedup).
func TestCommentTriggerCoalescing(t *testing.T) {

View File

@@ -555,6 +555,14 @@ func (h *Handler) DeleteComment(w http.ResponseWriter, r *http.Request) {
// Collect attachment URLs before CASCADE delete removes them.
attachmentURLs, _ := h.Queries.ListAttachmentURLsByCommentID(r.Context(), parseUUID(commentId))
// Cancel any active tasks triggered by this comment so the agent does not
// run with the now-deleted content already embedded in its prompt. Must
// run before DeleteComment because the FK ON DELETE SET NULL would
// otherwise nullify trigger_comment_id and orphan those tasks in queued.
if err := h.TaskService.CancelTasksByTriggerComment(r.Context(), parseUUID(commentId)); err != nil {
slog.Warn("cancel tasks for deleted trigger comment failed", append(logger.RequestAttrs(r), "error", err, "comment_id", commentId)...)
}
if err := h.Queries.DeleteComment(r.Context(), parseUUID(commentId)); err != nil {
slog.Warn("delete comment failed", append(logger.RequestAttrs(r), "error", err, "comment_id", commentId)...)
writeError(w, http.StatusInternalServerError, "failed to delete comment")

View File

@@ -161,6 +161,24 @@ func (s *TaskService) CancelTasksForIssue(ctx context.Context, issueID pgtype.UU
return nil
}
// CancelTasksByTriggerComment cancels active tasks whose trigger is the given
// comment. Called from DeleteComment so an agent does not run with the
// now-deleted content already embedded in its prompt. Must be invoked BEFORE
// the comment row is deleted because the FK ON DELETE SET NULL would
// otherwise nullify trigger_comment_id and we'd lose the ability to find
// the affected tasks.
func (s *TaskService) CancelTasksByTriggerComment(ctx context.Context, commentID pgtype.UUID) error {
cancelled, err := s.Queries.CancelAgentTasksByTriggerComment(ctx, commentID)
if err != nil {
return err
}
for _, t := range cancelled {
s.ReconcileAgentStatus(ctx, t.AgentID)
s.broadcastTaskEvent(ctx, protocol.EventTaskCancelled, t)
}
return nil
}
// CancelTask cancels a single task by ID. It broadcasts a task:cancelled event
// so frontends can update immediately.
func (s *TaskService) CancelTask(ctx context.Context, taskID pgtype.UUID) (*db.AgentTaskQueue, error) {

View File

@@ -216,6 +216,62 @@ func (q *Queries) CancelAgentTasksByIssueAndAgent(ctx context.Context, arg Cance
return items, nil
}
const cancelAgentTasksByTriggerComment = `-- name: CancelAgentTasksByTriggerComment :many
UPDATE agent_task_queue
SET status = 'cancelled', completed_at = now()
WHERE trigger_comment_id = $1 AND status IN ('queued', 'dispatched', 'running')
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, last_heartbeat_at
`
// Cancels active tasks whose trigger is the given comment. Called when a
// comment is deleted so the agent does not run with the now-deleted content
// already embedded in its prompt. Must run BEFORE the comment row is deleted
// because the FK ON DELETE SET NULL would otherwise nullify trigger_comment_id
// and we'd lose the ability to find the affected tasks.
func (q *Queries) CancelAgentTasksByTriggerComment(ctx context.Context, triggerCommentID pgtype.UUID) ([]AgentTaskQueue, error) {
rows, err := q.db.Query(ctx, cancelAgentTasksByTriggerComment, triggerCommentID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []AgentTaskQueue{}
for rows.Next() {
var i AgentTaskQueue
if err := rows.Scan(
&i.ID,
&i.AgentID,
&i.IssueID,
&i.Status,
&i.Priority,
&i.DispatchedAt,
&i.StartedAt,
&i.CompletedAt,
&i.Result,
&i.Error,
&i.CreatedAt,
&i.Context,
&i.RuntimeID,
&i.SessionID,
&i.WorkDir,
&i.TriggerCommentID,
&i.ChatSessionID,
&i.AutopilotRunID,
&i.Attempt,
&i.MaxAttempts,
&i.ParentTaskID,
&i.FailureReason,
&i.LastHeartbeatAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const claimAgentTask = `-- name: ClaimAgentTask :one
UPDATE agent_task_queue
SET status = 'dispatched', dispatched_at = now()

View File

@@ -115,6 +115,17 @@ UPDATE agent_task_queue
SET status = 'cancelled'
WHERE agent_id = $1 AND status IN ('queued', 'dispatched', 'running');
-- name: CancelAgentTasksByTriggerComment :many
-- Cancels active tasks whose trigger is the given comment. Called when a
-- comment is deleted so the agent does not run with the now-deleted content
-- already embedded in its prompt. Must run BEFORE the comment row is deleted
-- because the FK ON DELETE SET NULL would otherwise nullify trigger_comment_id
-- and we'd lose the ability to find the affected tasks.
UPDATE agent_task_queue
SET status = 'cancelled', completed_at = now()
WHERE trigger_comment_id = $1 AND status IN ('queued', 'dispatched', 'running')
RETURNING *;
-- name: GetAgentTask :one
SELECT * FROM agent_task_queue
WHERE id = $1;