Files
multica/server/internal/handler/squad_briefing_claim_test.go
LinYushen 3692b6a862 fix(squad): inject leader briefing by task flag, not issue assignee (MUL-3730) (#4606)
* fix(squad): inject leader briefing by task flag, not issue assignee

Key squad-leader briefing injection off task.IsLeaderTask + task.SquadID
instead of issue.AssigneeType=='squad'. The old gate missed the most common
path — an @squad mention in a comment on an issue assigned to a plain agent
(MUL-3724) — so the leader booted with zero squad context and did the work
itself instead of orchestrating.

- migration 127: add agent_task_queue.squad_id (no FK) + partial index
- sqlc: CreateAgentTask stamps squad_id; CreateRetryTask inherits it
- service: thread squadID through EnqueueTaskForSquadLeader(+WithHandoff),
  enqueueMentionTask, and the rerun path; all 5 call sites pass the squad id
- daemon claim: unified injection keyed on leader-task + squad_id, with a
  defensive leader-identity re-check; quick-create block retained (it serves
  issue-less tasks and sets resp.SquadID/SquadName)
- briefing: strengthen leader Operating Protocol opening
- tests: claim-time injection (comment-mention/non-leader/null-squad),
  squad_id enqueue stamping, retry inheritance; existing fixture updated

Co-authored-by: multica-agent <github@multica.ai>

* test+docs(squad): dangling squad_id regression + clarify quick-create path

Address review nits on #4606:
- Add TestClaim_LeaderTaskWithDanglingSquadID_NoBriefing: squad hard-deleted
  after enqueue leaves task.squad_id dangling (no FK); claim still 200 and
  skips injection via the err!=nil guard. This is the load-bearing contract
  for dropping the FK.
- Rewrite the daemon.go injection comment to state quick-create does NOT use
  the is_leader_task/squad_id columns — it routes squad via the context JSON
  branch (qc.SquadID) and must not be folded into the column-based path.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: 魏和尚 <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 16:01:33 +08:00

217 lines
8.8 KiB
Go

package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// claimAgentInstructionsForTest claims the next queued task for runtimeID and
// returns the claimed task id plus the agent Instructions carried on the claim
// response (the field the squad-leader briefing is injected into). Empty task
// id means no task was claimed.
func claimAgentInstructionsForTest(t *testing.T, runtimeID string) (taskID string, instructions string, raw string) {
t.Helper()
w := httptest.NewRecorder()
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/tasks/claim", nil,
testWorkspaceID, "squad-briefing-claim")
req = withURLParam(req, "runtimeId", runtimeID)
testHandler.ClaimTaskByRuntime(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ClaimTaskByRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
Task *struct {
ID string `json:"id"`
Agent *struct {
Instructions string `json:"instructions"`
} `json:"agent"`
} `json:"task"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode claim response: %v", err)
}
if resp.Task == nil {
return "", "", w.Body.String()
}
var instr string
if resp.Task.Agent != nil {
instr = resp.Task.Agent.Instructions
}
return resp.Task.ID, instr, w.Body.String()
}
// squadBriefingClaimFixture wires a runtime + leader agent + squad and returns
// the IDs needed to enqueue leader tasks against that runtime.
type squadBriefingClaimFixture struct {
RuntimeID string
AgentID string // squad leader, has the runtime and empty instructions
SquadID string
IssueID string // assignee_type='agent' (NOT squad) — reproduces MUL-3724
}
func newSquadBriefingClaimFixture(t *testing.T, ctx context.Context, name string) squadBriefingClaimFixture {
t.Helper()
runtimeID := createClaimReclaimRuntime(t, ctx, name+" runtime")
// Leader agent + an issue assigned to that agent (assignee_type='agent').
agentID, issueID := createClaimReclaimAgentAndIssue(t, ctx, runtimeID, name+" leader")
// Force empty instructions so the test asserts the briefing alone — this
// mirrors MUL-3724 where the leader's own instructions were blank.
if _, err := testPool.Exec(ctx, `UPDATE agent SET instructions = '' WHERE id = $1`, agentID); err != nil {
t.Fatalf("clear leader instructions: %v", err)
}
// Make the issue assignee an agent (NOT the squad). The pre-fix code only
// injected the briefing when issue.assignee_type='squad', so this is the
// exact gap the fix closes: a comment @squad-mention leader task running on
// an agent-assigned issue.
if _, err := testPool.Exec(ctx, `UPDATE issue SET assignee_type = 'agent', assignee_id = $2 WHERE id = $1`, issueID, agentID); err != nil {
t.Fatalf("set issue agent assignee: %v", err)
}
var squadID string
if err := testPool.QueryRow(ctx, `
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
VALUES ($1, $2, '', $3, $4)
RETURNING id
`, testWorkspaceID, name+" squad", agentID, testUserID).Scan(&squadID); err != nil {
t.Fatalf("create squad: %v", err)
}
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, squadID) })
return squadBriefingClaimFixture{
RuntimeID: runtimeID,
AgentID: agentID,
SquadID: squadID,
IssueID: issueID,
}
}
func enqueueClaimTask(t *testing.T, ctx context.Context, fx squadBriefingClaimFixture, isLeader bool, withSquadID bool) string {
t.Helper()
var taskID string
var squadArg any
if withSquadID {
squadArg = fx.SquadID
} else {
squadArg = nil
}
if err := testPool.QueryRow(ctx, `
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, is_leader_task, squad_id)
VALUES ($1, $2, $3, 'queued', 0, $4, $5)
RETURNING id
`, fx.AgentID, fx.RuntimeID, fx.IssueID, isLeader, squadArg).Scan(&taskID); err != nil {
t.Fatalf("enqueue claim task: %v", err)
}
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
return taskID
}
// TestClaim_LeaderTaskFromCommentMention_InjectsBriefing is the MUL-3724
// reproduction: a leader task (is_leader_task=true) carrying a squad_id, on an
// issue assigned to a plain AGENT (not the squad). The pre-fix gate
// (issue.assignee_type='squad') would NOT inject the briefing here, so the
// leader booted with no squad context and degraded into doing the work itself.
// After the fix the briefing is keyed off the task flag + squad_id, so it is
// injected regardless of issue assignee.
func TestClaim_LeaderTaskFromCommentMention_InjectsBriefing(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx := context.Background()
fx := newSquadBriefingClaimFixture(t, ctx, "Briefing inject")
want := enqueueClaimTask(t, ctx, fx, true /*isLeader*/, true /*withSquadID*/)
got, instr, raw := claimAgentInstructionsForTest(t, fx.RuntimeID)
if got != want {
t.Fatalf("claimed task id = %q, want %q: %s", got, want, raw)
}
if !strings.Contains(instr, "## Squad Operating Protocol") || !strings.Contains(instr, "## Squad Roster") {
t.Fatalf("expected squad-leader briefing in agent instructions, got:\n%s", instr)
}
}
// TestClaim_NonLeaderTask_NoBriefing guards the negative: a task that is NOT a
// leader task (is_leader_task=false), even with a squad_id present, must not
// receive the briefing. This keeps worker/mention runs free of leader framing.
func TestClaim_NonLeaderTask_NoBriefing(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx := context.Background()
fx := newSquadBriefingClaimFixture(t, ctx, "Briefing nonleader")
enqueueClaimTask(t, ctx, fx, false /*isLeader*/, true /*withSquadID*/)
_, instr, _ := claimAgentInstructionsForTest(t, fx.RuntimeID)
if strings.Contains(instr, "## Squad Operating Protocol") || strings.Contains(instr, "## Squad Roster") {
t.Fatalf("non-leader task must NOT get squad briefing, got:\n%s", instr)
}
}
// TestClaim_LeaderTaskWithDanglingSquadID_NoBriefing is the load-bearing
// contract for dropping the FK on agent_task_queue.squad_id (migration 127):
// when a squad is hard-deleted AFTER a leader task was enqueued, the task row
// keeps a now-dangling squad_id. The claim must still succeed (HTTP 200, task
// delivered) and simply skip briefing injection — GetSquadInWorkspace returns
// no row, so the err != nil guard makes this identical to "condition not
// matched". Never a 500, never a stale/empty briefing. Without the FK nothing
// in the DB prevents the dangling row, so this guard lives entirely in the
// claim handler and must stay tested.
func TestClaim_LeaderTaskWithDanglingSquadID_NoBriefing(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx := context.Background()
fx := newSquadBriefingClaimFixture(t, ctx, "Briefing dangling")
want := enqueueClaimTask(t, ctx, fx, true /*isLeader*/, true /*withSquadID*/)
// Hard-delete the squad AFTER enqueue, leaving task.squad_id dangling.
// There is no FK (migration 127), so the task row is untouched.
if _, err := testPool.Exec(ctx, `DELETE FROM squad WHERE id = $1`, fx.SquadID); err != nil {
t.Fatalf("delete squad: %v", err)
}
// Confirm the task still carries the (now orphaned) squad_id — i.e. the
// delete did not cascade/null it, which is the whole point of no-FK.
var stillSet bool
if err := testPool.QueryRow(ctx,
`SELECT squad_id = $2 FROM agent_task_queue WHERE id = $1`, want, fx.SquadID,
).Scan(&stillSet); err != nil {
t.Fatalf("reload task squad_id: %v", err)
}
if !stillSet {
t.Fatalf("expected task.squad_id to remain the dangling UUID after squad delete (no FK)")
}
got, instr, raw := claimAgentInstructionsForTest(t, fx.RuntimeID)
if got != want {
t.Fatalf("claimed task id = %q, want %q (claim must still succeed 200): %s", got, want, raw)
}
if strings.Contains(instr, "## Squad Operating Protocol") || strings.Contains(instr, "## Squad Roster") {
t.Fatalf("dangling squad_id must NOT get squad briefing, got:\n%s", instr)
}
}
// leader tasks enqueued before migration 127 (or by an old binary) have a NULL
// squad_id. The claim handler must skip injection rather than panic or guess —
// equivalent to the pre-fix "condition not matched" behavior, never a stale
// briefing.
func TestClaim_LeaderTaskWithoutSquadID_NoBriefing(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx := context.Background()
fx := newSquadBriefingClaimFixture(t, ctx, "Briefing nullsquad")
enqueueClaimTask(t, ctx, fx, true /*isLeader*/, false /*withSquadID*/)
_, instr, _ := claimAgentInstructionsForTest(t, fx.RuntimeID)
if strings.Contains(instr, "## Squad Operating Protocol") || strings.Contains(instr, "## Squad Roster") {
t.Fatalf("leader task with NULL squad_id must NOT get squad briefing, got:\n%s", instr)
}
}