fix(squad): suppress no-action leader comments (#2583)

This commit is contained in:
LinYushen
2026-05-14 14:07:26 +08:00
committed by GitHub
parent 58cc189dcd
commit 0cb759b446
12 changed files with 337 additions and 5 deletions

View File

@@ -226,6 +226,25 @@ function formatActivity(
return t(($) => $.activity.task_completed, { count: entry.coalesced_count ?? 1 });
case "task_failed":
return t(($) => $.activity.task_failed, { count: entry.coalesced_count ?? 1 });
case "squad_leader_evaluated": {
const reason = details.reason?.trim();
switch (details.outcome) {
case "action":
return reason
? t(($) => $.activity.squad_leader_action_reason, { reason })
: t(($) => $.activity.squad_leader_action);
case "no_action":
return reason
? t(($) => $.activity.squad_leader_no_action_reason, { reason })
: t(($) => $.activity.squad_leader_no_action);
case "failed":
return reason
? t(($) => $.activity.squad_leader_failed_reason, { reason })
: t(($) => $.activity.squad_leader_failed);
default:
return t(($) => $.activity.squad_leader_evaluated);
}
}
default:
return entry.action ?? "";
}
@@ -616,13 +635,16 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
// Coalesce consecutive activities from the same actor + action.
// - task_completed / task_failed: no time limit (these repeat across runs)
// - all other actions: within a 2-minute window
// - squad_leader_evaluated: never coalesce; outcome/reason are audit data
const COALESCE_MS = 2 * 60 * 1000;
const NO_TIME_LIMIT_ACTIONS = new Set(["task_completed", "task_failed"]);
const NEVER_COALESCE_ACTIONS = new Set(["squad_leader_evaluated"]);
const coalesced: TimelineEntry[] = [];
for (const entry of topLevel) {
if (entry.type === "activity") {
const prev = coalesced[coalesced.length - 1];
if (
!NEVER_COALESCE_ACTIONS.has(entry.action!) &&
prev?.type === "activity" &&
prev.action === entry.action &&
prev.actor_type === entry.actor_type &&

View File

@@ -162,6 +162,13 @@
"task_completed_other": "completed the task ({{count}} times)",
"task_failed_one": "task failed",
"task_failed_other": "task failed ({{count}} times)",
"squad_leader_evaluated": "evaluated the squad trigger",
"squad_leader_action": "evaluated and took action",
"squad_leader_action_reason": "evaluated and took action: {{reason}}",
"squad_leader_no_action": "evaluated: no action needed",
"squad_leader_no_action_reason": "evaluated: no action needed ({{reason}})",
"squad_leader_failed": "evaluation failed",
"squad_leader_failed_reason": "evaluation failed: {{reason}}",
"coalesced_badge": "×{{count}}"
},
"comment": {

View File

@@ -161,6 +161,13 @@
"task_completed_other": "完成了 task{{count}} 次)",
"task_failed_one": "task 失败",
"task_failed_other": "task 失败({{count}} 次)",
"squad_leader_evaluated": "评估了小队触发",
"squad_leader_action": "已评估并采取了操作",
"squad_leader_action_reason": "已评估并采取了操作:{{reason}}",
"squad_leader_no_action": "已评估:无需操作",
"squad_leader_no_action_reason": "已评估:无需操作({{reason}}",
"squad_leader_failed": "评估失败",
"squad_leader_failed_reason": "评估失败:{{reason}}",
"coalesced_badge": "×{{count}}"
},
"comment": {

View File

@@ -11,6 +11,7 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/logger"
"github.com/multica-ai/multica/server/internal/mention"
"github.com/multica-ai/multica/server/internal/service"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
@@ -196,10 +197,23 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
taskUUID, parseErr := util.ParseUUID(taskIDHeader)
if parseErr == nil {
task, err := h.Queries.GetAgentTask(r.Context(), taskUUID)
if err == nil && task.TriggerCommentID.Valid && uuidToString(task.IssueID) == uuidToString(issue.ID) {
if uuidToString(parentID) != uuidToString(task.TriggerCommentID) {
writeError(w, http.StatusConflict,
"parent_id must equal this task's trigger comment id ("+uuidToString(task.TriggerCommentID)+")")
if err == nil && task.IssueID.Valid && uuidToString(task.IssueID) == uuidToString(issue.ID) {
if task.TriggerCommentID.Valid {
if uuidToString(parentID) != uuidToString(task.TriggerCommentID) {
writeError(w, http.StatusConflict,
"parent_id must equal this task's trigger comment id ("+uuidToString(task.TriggerCommentID)+")")
return
}
}
noAction, checkErr := service.HasSquadLeaderNoActionEvaluationForTask(r.Context(), h.Queries, task)
if checkErr != nil {
slog.Warn("checking squad leader no_action evaluation failed", append(logger.RequestAttrs(r),
"error", checkErr,
"task_id", taskIDHeader,
"issue_id", issueID,
)...)
} else if noAction {
writeError(w, http.StatusConflict, "squad leader recorded no_action; comments are not allowed for this task")
return
}
}

View File

@@ -546,8 +546,20 @@ func (h *Handler) RecordSquadLeaderEvaluation(w http.ResponseWriter, r *http.Req
return
}
taskID := r.Header.Get("X-Task-ID")
taskUUID, ok := parseUUIDOrBadRequest(w, taskID, "task id")
if !ok {
return
}
task, err := h.Queries.GetAgentTask(r.Context(), taskUUID)
if err != nil || !task.IssueID.Valid || uuidToString(task.IssueID) != uuidToString(issue.ID) {
writeError(w, http.StatusBadRequest, "task does not belong to issue")
return
}
details, _ := json.Marshal(map[string]string{
"squad_id": uuidToString(squad.ID),
"task_id": util.UUIDToString(taskUUID),
"outcome": req.Outcome,
"reason": req.Reason,
})

View File

@@ -0,0 +1,196 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
)
type runningSquadLeaderTaskFixture struct {
IssueID string
LeaderID string
TaskID string
TriggerCommentID string
}
func newRunningSquadLeaderTaskFixture(t *testing.T) runningSquadLeaderTaskFixture {
t.Helper()
ctx := context.Background()
fx := newSquadCommentTriggerFixture(t)
issueID := uuidToString(fx.Issue.ID)
var runtimeID string
if err := testPool.QueryRow(ctx, `
SELECT runtime_id FROM agent WHERE id = $1
`, fx.LeaderID).Scan(&runtimeID); err != nil {
t.Fatalf("load leader runtime: %v", err)
}
var triggerCommentID string
if err := testPool.QueryRow(ctx, `
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type)
VALUES ($1, $2, 'member', $3, 'LGTM', 'comment')
RETURNING id
`, issueID, testWorkspaceID, testUserID).Scan(&triggerCommentID); err != nil {
t.Fatalf("create trigger comment: %v", err)
}
var taskID string
if err := testPool.QueryRow(ctx, `
INSERT INTO agent_task_queue (
agent_id, runtime_id, issue_id, trigger_comment_id,
status, priority, started_at
)
VALUES ($1, $2, $3, $4, 'running', 0, now())
RETURNING id
`, fx.LeaderID, runtimeID, issueID, triggerCommentID).Scan(&taskID); err != nil {
t.Fatalf("create running squad leader task: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID)
})
return runningSquadLeaderTaskFixture{
IssueID: issueID,
LeaderID: fx.LeaderID,
TaskID: taskID,
TriggerCommentID: triggerCommentID,
}
}
func recordSquadLeaderEvaluationForTask(t *testing.T, fx runningSquadLeaderTaskFixture, outcome string) {
t.Helper()
recordSquadLeaderEvaluationForTaskWithHeader(t, fx, outcome, fx.TaskID)
}
func recordSquadLeaderEvaluationForTaskWithHeader(t *testing.T, fx runningSquadLeaderTaskFixture, outcome, taskIDHeader string) {
t.Helper()
w := httptest.NewRecorder()
r := newRequest("POST", "/api/issues/"+fx.IssueID+"/squad-evaluated", map[string]any{
"outcome": outcome,
"reason": "test reason",
})
r = withURLParam(r, "id", fx.IssueID)
r.Header.Set("X-Agent-ID", fx.LeaderID)
r.Header.Set("X-Task-ID", taskIDHeader)
testHandler.RecordSquadLeaderEvaluation(w, r)
if w.Code != http.StatusCreated {
t.Fatalf("RecordSquadLeaderEvaluation: expected 201, got %d: %s", w.Code, w.Body.String())
}
}
func completeRunningTask(t *testing.T, fx runningSquadLeaderTaskFixture, output string) {
t.Helper()
w := httptest.NewRecorder()
r := newDaemonTokenRequest("POST", "/api/daemon/tasks/"+fx.TaskID+"/complete",
map[string]any{"output": output},
testWorkspaceID, "legit-daemon")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("taskId", fx.TaskID)
r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
testHandler.CompleteTask(w, r)
if w.Code != http.StatusOK {
t.Fatalf("CompleteTask: expected 200, got %d: %s", w.Code, w.Body.String())
}
}
func countAgentCommentsForIssue(t *testing.T, issueID, agentID string) int {
t.Helper()
var count int
if err := testPool.QueryRow(context.Background(), `
SELECT count(*) FROM comment
WHERE issue_id = $1 AND author_type = 'agent' AND author_id = $2
`, issueID, agentID).Scan(&count); err != nil {
t.Fatalf("count agent comments: %v", err)
}
return count
}
func TestCompleteTask_SquadLeaderNoActionDoesNotSynthesizeComment(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
fx := newRunningSquadLeaderTaskFixture(t)
recordSquadLeaderEvaluationForTask(t, fx, "no_action")
completeRunningTask(t, fx, "No action needed. Exiting silently.")
if got := countAgentCommentsForIssue(t, fx.IssueID, fx.LeaderID); got != 0 {
t.Fatalf("expected no squad leader comment after no_action completion, got %d", got)
}
}
func TestCompleteTask_SquadLeaderNoActionCanonicalizesTaskID(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
fx := newRunningSquadLeaderTaskFixture(t)
recordSquadLeaderEvaluationForTaskWithHeader(t, fx, "no_action", strings.ToUpper(fx.TaskID))
completeRunningTask(t, fx, "No action needed. Exiting silently.")
if got := countAgentCommentsForIssue(t, fx.IssueID, fx.LeaderID); got != 0 {
t.Fatalf("expected no comment when no_action was recorded with uppercase task id header, got %d", got)
}
}
func TestCompleteTask_SquadLeaderActionStillSynthesizesComment(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
fx := newRunningSquadLeaderTaskFixture(t)
recordSquadLeaderEvaluationForTask(t, fx, "action")
completeRunningTask(t, fx, "Delegated the review.")
if got := countAgentCommentsForIssue(t, fx.IssueID, fx.LeaderID); got != 1 {
t.Fatalf("expected action completion to synthesize one comment, got %d", got)
}
}
func TestCreateComment_SquadLeaderNoActionRejectsComment(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
fx := newRunningSquadLeaderTaskFixture(t)
recordSquadLeaderEvaluationForTask(t, fx, "no_action")
w := httptest.NewRecorder()
r := newRequest("POST", "/api/issues/"+fx.IssueID+"/comments", map[string]any{
"content": "No action needed.",
"parent_id": fx.TriggerCommentID,
})
r = withURLParam(r, "id", fx.IssueID)
r.Header.Set("X-Agent-ID", fx.LeaderID)
r.Header.Set("X-Task-ID", fx.TaskID)
testHandler.CreateComment(w, r)
if w.Code != http.StatusConflict {
t.Fatalf("CreateComment: expected 409, got %d: %s", w.Code, w.Body.String())
}
if got := countAgentCommentsForIssue(t, fx.IssueID, fx.LeaderID); got != 0 {
t.Fatalf("expected rejected no_action comment not to be stored, got %d", got)
}
var body map[string]any
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
t.Fatalf("decode error response: %v", err)
}
if body["error"] == "" {
t.Fatalf("expected error message in response, got %v", body)
}
}

View File

@@ -0,0 +1,21 @@
package service
import (
"context"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// HasSquadLeaderNoActionEvaluationForTask reports whether this exact task
// already recorded a squad leader no_action evaluation.
func HasSquadLeaderNoActionEvaluationForTask(ctx context.Context, q *db.Queries, task db.AgentTaskQueue) (bool, error) {
if q == nil || !task.ID.Valid || !task.IssueID.Valid || !task.AgentID.Valid {
return false, nil
}
return q.HasSquadLeaderNoActionEvaluationForTask(ctx, db.HasSquadLeaderNoActionEvaluationForTaskParams{
IssueID: task.IssueID,
AgentID: task.AgentID,
TaskID: util.UUIDToString(task.ID),
})
}

View File

@@ -995,12 +995,21 @@ func (s *TaskService) CompleteTask(ctx context.Context, taskID pgtype.UUID, resu
// for assignment-triggered tasks it is NULL and the fallback is top-level.
// Chat tasks have no IssueID and are handled separately below.
if task.IssueID.Valid {
suppressNoActionComment, err := HasSquadLeaderNoActionEvaluationForTask(ctx, s.Queries, task)
if err != nil {
slog.Warn("checking squad leader no_action evaluation failed",
"task_id", util.UUIDToString(task.ID),
"issue_id", util.UUIDToString(task.IssueID),
"agent_id", util.UUIDToString(task.AgentID),
"error", err,
)
}
agentCommented, _ := s.Queries.HasAgentCommentedSince(ctx, db.HasAgentCommentedSinceParams{
IssueID: task.IssueID,
AuthorID: task.AgentID,
Since: task.StartedAt,
})
if !agentCommented {
if !suppressNoActionComment && !agentCommented {
var payload protocol.TaskCompletedPayload
if err := json.Unmarshal(result, &payload); err == nil {
if payload.Output != "" {

View File

@@ -0,0 +1 @@
DROP INDEX IF EXISTS idx_activity_log_squad_no_action_task;

View File

@@ -0,0 +1,5 @@
CREATE INDEX IF NOT EXISTS idx_activity_log_squad_no_action_task
ON activity_log (issue_id, actor_id, ((details->>'task_id')))
WHERE actor_type = 'agent'
AND action = 'squad_leader_evaluated'
AND details->>'outcome' = 'no_action';

View File

@@ -118,6 +118,32 @@ func (q *Queries) GetActivity(ctx context.Context, id pgtype.UUID) (ActivityLog,
return i, err
}
const hasSquadLeaderNoActionEvaluationForTask = `-- name: HasSquadLeaderNoActionEvaluationForTask :one
SELECT EXISTS (
SELECT 1
FROM activity_log
WHERE issue_id = $1
AND actor_type = 'agent'
AND actor_id = $2
AND action = 'squad_leader_evaluated'
AND details->>'outcome' = 'no_action'
AND details->>'task_id' = $3::text
) AS exists
`
type HasSquadLeaderNoActionEvaluationForTaskParams struct {
IssueID pgtype.UUID `json:"issue_id"`
AgentID pgtype.UUID `json:"agent_id"`
TaskID string `json:"task_id"`
}
func (q *Queries) HasSquadLeaderNoActionEvaluationForTask(ctx context.Context, arg HasSquadLeaderNoActionEvaluationForTaskParams) (bool, error) {
row := q.db.QueryRow(ctx, hasSquadLeaderNoActionEvaluationForTask, arg.IssueID, arg.AgentID, arg.TaskID)
var exists bool
err := row.Scan(&exists)
return exists, err
}
const listActivitiesForIssue = `-- name: ListActivitiesForIssue :many
SELECT id, workspace_id, issue_id, actor_type, actor_id, action, details, created_at FROM activity_log
WHERE issue_id = $1

View File

@@ -16,6 +16,18 @@ INSERT INTO activity_log (
) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *;
-- name: HasSquadLeaderNoActionEvaluationForTask :one
SELECT EXISTS (
SELECT 1
FROM activity_log
WHERE issue_id = @issue_id
AND actor_type = 'agent'
AND actor_id = @agent_id
AND action = 'squad_leader_evaluated'
AND details->>'outcome' = 'no_action'
AND details->>'task_id' = @task_id::text
) AS exists;
-- name: CountAssigneeChangesByActor :many
-- Count how many times a user assigned each target via assignee_changed activities.
SELECT