mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
fix(squad): suppress no-action leader comments (#2583)
This commit is contained in:
@@ -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 &&
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
196
server/internal/handler/squad_no_action_test.go
Normal file
196
server/internal/handler/squad_no_action_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
21
server/internal/service/squad_no_action.go
Normal file
21
server/internal/service/squad_no_action.go
Normal 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),
|
||||
})
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX IF EXISTS idx_activity_log_squad_no_action_task;
|
||||
@@ -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';
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user