mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-25 20:15:37 +02:00
* 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>
217 lines
8.8 KiB
Go
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)
|
|
}
|
|
}
|