mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-31 00:40:46 +02:00
* fix(squad): wake private-leader squad parent leader on child-done The child-done parent wake routed squad leaders through canEnqueueSquadLeader/canInvokeAgent, while the agent-parent path (triggerChildDoneAgent) has never gated. Agents default to private visibility, so a default squad leader is private; when a child is closed by an agent/system actor (the normal process-squad pipeline) there is no resolvable human originator, the gate fails closed, and the leader is never woken -- stranding every multi-stage squad pipeline after its first stage. Assigning the parent directly to the leader agent worked only because that path is ungated. Remove the child-done leader-invocation gate so agent and squad child-done follow one path. The parent was already permission-checked at squad-assign time (validateAssigneePair); waking its own leader to advance the next stage is a coordination handoff, not a fresh invocation, and grants no new privilege -- the actor can only wake the leader on the specific parent that leader already owns. If invocation permission is ever reintroduced it must be added to both paths together. Also drops the now-dead actor plumbing threaded solely for the gate, flips the plain-member child-done test to assert the leader is woken, adds an agent-actor regression, and updates the squad / mentioning skill docs. MUL-4063 Co-authored-by: multica-agent <github@multica.ai> * docs(squad): refresh Private Leader Access source map to canInvokeAgent The squad + mentioning source maps still described the old canAccessPrivateAgent model (visibility!=private, agent short-circuit, system->agent remap). The trigger gate is canInvokeAgent (MUL-3963); update both to match and note the child-done wake is now ungated (MUL-4063). Review nit follow-up, docs only. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
498 lines
19 KiB
Go
498 lines
19 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
// TestCreateIssue_SquadPrivateLeader_PlainMemberBlocked verifies that a
|
|
// plain member cannot create an issue assigned to a squad whose leader is
|
|
// a private agent.
|
|
func TestCreateIssue_SquadPrivateLeader_PlainMemberBlocked(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
agentID, _, memberID := privateAgentTestFixture(t)
|
|
|
|
var squadID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
|
|
VALUES ($1, 'Private Leader Create Test', '', $2, $3)
|
|
RETURNING id
|
|
`, testWorkspaceID, 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)
|
|
})
|
|
|
|
w := httptest.NewRecorder()
|
|
r := newRequestAs(memberID, "POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Should be blocked",
|
|
"assignee_type": "squad",
|
|
"assignee_id": squadID,
|
|
})
|
|
testHandler.CreateIssue(w, r)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestUpdateIssue_SquadPrivateLeader_PlainMemberBlocked verifies that a
|
|
// plain member cannot update an issue's assignee to a private-leader squad.
|
|
func TestUpdateIssue_SquadPrivateLeader_PlainMemberBlocked(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
agentID, _, memberID := privateAgentTestFixture(t)
|
|
|
|
var squadID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
|
|
VALUES ($1, 'Private Leader Update Test', '', $2, $3)
|
|
RETURNING id
|
|
`, testWorkspaceID, 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)
|
|
})
|
|
|
|
// Create an unassigned issue as workspace owner.
|
|
var issueID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (workspace_id, creator_type, creator_id, title)
|
|
VALUES ($1, 'member', $2, 'update target')
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID).Scan(&issueID); err != nil {
|
|
t.Fatalf("create issue: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
|
|
})
|
|
|
|
w := httptest.NewRecorder()
|
|
r := newRequestAs(memberID, "PATCH", "/api/issues/"+issueID, map[string]any{
|
|
"assignee_type": "squad",
|
|
"assignee_id": squadID,
|
|
})
|
|
r = withURLParam(r, "id", issueID)
|
|
testHandler.UpdateIssue(w, r)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestCreateIssue_SquadPrivateLeader_OwnerAllowed verifies that a workspace
|
|
// owner CAN assign an issue to a squad with a private leader.
|
|
func TestCreateIssue_SquadPrivateLeader_OwnerAllowed(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
agentID, ownerID, _ := privateAgentTestFixture(t)
|
|
|
|
var squadID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
|
|
VALUES ($1, 'Private Leader Owner Test', '', $2, $3)
|
|
RETURNING id
|
|
`, testWorkspaceID, 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)
|
|
})
|
|
|
|
// The AGENT OWNER assigns — allowed. (MUL-3963: workspace owner/admin no
|
|
// longer bypasses a private leader's invocation gate.)
|
|
w := httptest.NewRecorder()
|
|
r := newRequestAs(ownerID, "POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Owner assigns private-leader squad",
|
|
"assignee_type": "squad",
|
|
"assignee_id": squadID,
|
|
})
|
|
testHandler.CreateIssue(w, r)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var created IssueResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, created.ID)
|
|
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, created.ID)
|
|
})
|
|
}
|
|
|
|
// TestComment_SquadPrivateLeader_PlainMemberNoEnqueue verifies that a plain
|
|
// member posting a comment on an issue assigned to a private-leader squad
|
|
// does NOT trigger the leader.
|
|
func TestComment_SquadPrivateLeader_PlainMemberNoEnqueue(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
agentID, _, memberID := privateAgentTestFixture(t)
|
|
|
|
var squadID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
|
|
VALUES ($1, 'Private Leader Comment Test', '', $2, $3)
|
|
RETURNING id
|
|
`, testWorkspaceID, 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)
|
|
})
|
|
|
|
// Create issue assigned to the squad as workspace owner.
|
|
var issueID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (workspace_id, creator_type, creator_id, title, assignee_type, assignee_id)
|
|
VALUES ($1, 'member', $2, 'private leader comment test', 'squad', $3)
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID, squadID).Scan(&issueID); err != nil {
|
|
t.Fatalf("create issue: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
|
|
testPool.Exec(context.Background(), `DELETE FROM comment WHERE issue_id = $1`, issueID)
|
|
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
|
|
})
|
|
|
|
// Plain member posts a plain comment (not a @mention).
|
|
w := httptest.NewRecorder()
|
|
r := newRequestAs(memberID, "POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
|
"content": "any update on this?",
|
|
})
|
|
r = withURLParam(r, "id", issueID)
|
|
testHandler.CreateComment(w, r)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateComment: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// The private leader must NOT have a queued task.
|
|
var count int
|
|
if err := testPool.QueryRow(ctx,
|
|
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
|
|
issueID, agentID,
|
|
).Scan(&count); err != nil {
|
|
t.Fatalf("count tasks: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("private leader got %d queued tasks from plain member comment; want 0", count)
|
|
}
|
|
}
|
|
|
|
// TestChildDone_SquadPrivateLeader_PlainMemberWakesLeader verifies that when
|
|
// a plain member completes a child issue whose parent is assigned to a
|
|
// private-leader squad, the leader IS woken. Child-done no longer re-checks
|
|
// leader invocation permission (MUL-4063 / GH #4928): the parent was already
|
|
// assigned to the squad — which passed the invocation gate — so waking that
|
|
// squad's own leader to advance the next stage is a coordination handoff, not
|
|
// a fresh invocation. This mirrors the ungated agent-parent path
|
|
// (triggerChildDoneAgent); agent and squad child-done now follow one path.
|
|
func TestChildDone_SquadPrivateLeader_PlainMemberWakesLeader(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
agentID, ownerID, memberID := privateAgentTestFixture(t)
|
|
|
|
var squadID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
|
|
VALUES ($1, 'Private Leader ChildDone Test', '', $2, $3)
|
|
RETURNING id
|
|
`, testWorkspaceID, 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)
|
|
})
|
|
|
|
// Create parent issue assigned to the squad (as the AGENT OWNER, who is
|
|
// allowed to invoke the private leader under MUL-3963).
|
|
w := httptest.NewRecorder()
|
|
r := newRequestAs(ownerID, "POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "parent with private-leader squad",
|
|
"assignee_type": "squad",
|
|
"assignee_id": squadID,
|
|
})
|
|
testHandler.CreateIssue(w, r)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("create parent: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var parent IssueResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&parent); err != nil {
|
|
t.Fatalf("decode parent: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, parent.ID)
|
|
testPool.Exec(context.Background(), `DELETE FROM comment WHERE issue_id = $1`, parent.ID)
|
|
testPool.Exec(context.Background(), `DELETE FROM issue WHERE parent_issue_id = $1`, parent.ID)
|
|
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, parent.ID)
|
|
})
|
|
|
|
// Clear any tasks enqueued by the create.
|
|
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, parent.ID)
|
|
|
|
// Create a child issue via API (as workspace owner, with member assignee).
|
|
w = httptest.NewRecorder()
|
|
r = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "child task",
|
|
"parent_issue_id": parent.ID,
|
|
"assignee_type": "member",
|
|
"assignee_id": memberID,
|
|
"status": "in_progress",
|
|
})
|
|
testHandler.CreateIssue(w, r)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("create child: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var child IssueResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&child); err != nil {
|
|
t.Fatalf("decode child: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, child.ID)
|
|
})
|
|
|
|
// Plain member moves child to done.
|
|
w = httptest.NewRecorder()
|
|
r = newRequestAs(memberID, "PATCH", "/api/issues/"+child.ID, map[string]any{
|
|
"status": "done",
|
|
})
|
|
r = withURLParam(r, "id", child.ID)
|
|
testHandler.UpdateIssue(w, r)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateIssue (child done): expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// The private leader MUST have a queued task on the parent — child-done
|
|
// wakes the parent's own leader regardless of who closed the child.
|
|
var count int
|
|
if err := testPool.QueryRow(ctx,
|
|
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
|
|
parent.ID, agentID,
|
|
).Scan(&count); err != nil {
|
|
t.Fatalf("count tasks: %v", err)
|
|
}
|
|
if count == 0 {
|
|
t.Fatalf("private leader got 0 queued tasks from plain member child-done; want >=1")
|
|
}
|
|
}
|
|
|
|
// TestChildDone_SquadPrivateLeader_AgentActorWakesLeader is the core MUL-4063
|
|
// regression: an AGENT (a squad worker) closes a child under a private-leader
|
|
// squad parent, and the child's completing agent has NO human originator who
|
|
// could invoke the private leader. This is the exact process-squad pipeline
|
|
// shape that used to strand — the removed canEnqueueSquadLeader gate failed
|
|
// closed here — while a direct-to-leader-agent parent advanced fine. The
|
|
// leader must now be woken.
|
|
func TestChildDone_SquadPrivateLeader_AgentActorWakesLeader(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
agentID, ownerID, memberID := privateAgentTestFixture(t)
|
|
workerAgentID := createHandlerTestAgent(t, "squad-private-leader-childdone-worker", nil)
|
|
|
|
var squadID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
|
|
VALUES ($1, 'Private Leader ChildDone AgentActor Test', '', $2, $3)
|
|
RETURNING id
|
|
`, testWorkspaceID, 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)
|
|
})
|
|
|
|
// Parent assigned to the squad by the agent owner (allowed under MUL-3963).
|
|
w := httptest.NewRecorder()
|
|
r := newRequestAs(ownerID, "POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "parent with private-leader squad (agent child-done)",
|
|
"assignee_type": "squad",
|
|
"assignee_id": squadID,
|
|
})
|
|
testHandler.CreateIssue(w, r)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("create parent: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var parent IssueResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&parent); err != nil {
|
|
t.Fatalf("decode parent: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, parent.ID)
|
|
testPool.Exec(context.Background(), `DELETE FROM comment WHERE issue_id = $1`, parent.ID)
|
|
testPool.Exec(context.Background(), `DELETE FROM issue WHERE parent_issue_id = $1`, parent.ID)
|
|
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, parent.ID)
|
|
})
|
|
|
|
// Clear the leader task the assign enqueued so the child-done wake is the
|
|
// only thing that can create one.
|
|
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, parent.ID)
|
|
|
|
// Child assigned to the worker agent, in progress.
|
|
w = httptest.NewRecorder()
|
|
r = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "child task worked by an agent",
|
|
"parent_issue_id": parent.ID,
|
|
"assignee_type": "agent",
|
|
"assignee_id": workerAgentID,
|
|
"status": "in_progress",
|
|
})
|
|
testHandler.CreateIssue(w, r)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("create child: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var child IssueResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&child); err != nil {
|
|
t.Fatalf("decode child: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, child.ID)
|
|
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, child.ID)
|
|
})
|
|
|
|
// The worker agent's running task on the CHILD. Its originator is the plain
|
|
// member — who cannot invoke the private leader — proving the wake no longer
|
|
// depends on the completer being able to invoke the leader.
|
|
var workerTaskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority, issue_id, originator_user_id)
|
|
VALUES ($1, (SELECT runtime_id FROM agent WHERE id = $1), 'running', 0, $2, $3)
|
|
RETURNING id
|
|
`, workerAgentID, child.ID, memberID).Scan(&workerTaskID); err != nil {
|
|
t.Fatalf("create worker task: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, workerTaskID)
|
|
})
|
|
|
|
// Worker agent moves the child to done (agent actor via X-Agent-ID/X-Task-ID).
|
|
w = httptest.NewRecorder()
|
|
r = newRequest("PATCH", "/api/issues/"+child.ID, map[string]any{
|
|
"status": "done",
|
|
})
|
|
r.Header.Set("X-Agent-ID", workerAgentID)
|
|
r.Header.Set("X-Task-ID", workerTaskID)
|
|
r = withURLParam(r, "id", child.ID)
|
|
testHandler.UpdateIssue(w, r)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateIssue (child done): expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// The private leader MUST have a queued task on the parent.
|
|
var count int
|
|
if err := testPool.QueryRow(ctx,
|
|
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
|
|
parent.ID, agentID,
|
|
).Scan(&count); err != nil {
|
|
t.Fatalf("count tasks: %v", err)
|
|
}
|
|
if count == 0 {
|
|
t.Fatalf("private leader got 0 queued tasks from agent child-done; want >=1 (MUL-4063)")
|
|
}
|
|
}
|
|
|
|
// TestComment_SquadPrivateLeader_AgentActorAllowed verifies that an agent
|
|
// actor CAN explicitly trigger the private leader via a squad mention.
|
|
func TestComment_SquadPrivateLeader_AgentActorAllowed(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
agentID, ownerID, _ := privateAgentTestFixture(t)
|
|
otherAgentID := createHandlerTestAgent(t, "squad-private-leader-agent-actor", nil)
|
|
|
|
var squadID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
|
|
VALUES ($1, 'Private Leader Agent Actor Test', '', $2, $3)
|
|
RETURNING id
|
|
`, testWorkspaceID, 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)
|
|
})
|
|
|
|
// Create issue assigned to the squad.
|
|
var issueID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (workspace_id, creator_type, creator_id, title, assignee_type, assignee_id)
|
|
VALUES ($1, 'member', $2, 'private leader agent actor test', 'squad', $3)
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID, squadID).Scan(&issueID); err != nil {
|
|
t.Fatalf("create issue: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
|
|
testPool.Exec(context.Background(), `DELETE FROM comment WHERE issue_id = $1`, issueID)
|
|
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
|
|
})
|
|
|
|
// Create a task for the other agent whose top-of-chain originator is the
|
|
// private leader's OWNER. Under MUL-3963 A2A is judged by that originator,
|
|
// so the agent-actor squad mention resolves to the owner and may invoke
|
|
// the private leader.
|
|
var taskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority, issue_id, originator_user_id)
|
|
VALUES ($1, (SELECT runtime_id FROM agent WHERE id = $1), 'running', 0, $2, $3)
|
|
RETURNING id
|
|
`, otherAgentID, issueID, ownerID).Scan(&taskID); err != nil {
|
|
t.Fatalf("create agent task: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID)
|
|
})
|
|
|
|
// Agent posts a comment with an explicit squad mention.
|
|
w := httptest.NewRecorder()
|
|
r := newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
|
"content": "[@Squad](mention://squad/" + squadID + ") agent reporting in",
|
|
})
|
|
r.Header.Set("X-Agent-ID", otherAgentID)
|
|
r.Header.Set("X-Task-ID", taskID)
|
|
r = withURLParam(r, "id", issueID)
|
|
testHandler.CreateComment(w, r)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateComment: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// The private leader SHOULD have a queued task — the agent-actor mention's
|
|
// top-of-chain originator is the leader's owner, so A2A-by-originator
|
|
// admits it (MUL-3963).
|
|
var count int
|
|
if err := testPool.QueryRow(ctx,
|
|
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
|
|
issueID, agentID,
|
|
).Scan(&count); err != nil {
|
|
t.Fatalf("count tasks: %v", err)
|
|
}
|
|
if count == 0 {
|
|
t.Fatalf("private leader got 0 queued tasks; want >=1 (A2A originator is the leader owner)")
|
|
}
|
|
}
|