mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-28 18:09:14 +02:00
Compare commits
3 Commits
agent/lamb
...
agent/j/de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cbd6c2be7 | ||
|
|
7e41d70bd2 | ||
|
|
6870b46ef7 |
@@ -208,19 +208,43 @@ func createHandlerTestAgent(t *testing.T, name string, mcpConfig []byte) string
|
||||
return agentID
|
||||
}
|
||||
|
||||
// createHandlerTestTaskForAgent seeds a queued agent_task_queue row for the
|
||||
// given agent and returns the task UUID. Used by tests that need to set
|
||||
// X-Task-ID alongside X-Agent-ID — resolveActor now requires the pair to be
|
||||
// present and consistent before granting "agent" actor identity.
|
||||
// createHandlerTestTaskForAgent seeds a running agent_task_queue row for the
|
||||
// given agent (with no associated issue) and returns the task UUID. Used by
|
||||
// tests that need to set X-Task-ID alongside X-Agent-ID — resolveActor now
|
||||
// requires the pair to be present and consistent before granting "agent"
|
||||
// actor identity.
|
||||
func createHandlerTestTaskForAgent(t *testing.T, agentID string) string {
|
||||
return createHandlerTestTaskForAgentOnIssue(t, agentID, "")
|
||||
}
|
||||
|
||||
// createHandlerTestTaskForAgentOnIssue seeds a running agent_task_queue row
|
||||
// for the given agent, optionally bound to an issue (pass "" to leave
|
||||
// issue_id NULL). The bound-issue form is needed by the self-loop guard
|
||||
// test, which compares the calling task's issue_id against the promoted
|
||||
// issue — only a same-issue match counts as a true self-loop.
|
||||
//
|
||||
// Status is 'running' because X-Task-ID is something a currently-executing
|
||||
// task sends. Using 'running' also keeps the seed outside the
|
||||
// idx_one_pending_task_per_issue_agent unique index (queued/dispatched only)
|
||||
// and outside callers' `status='queued'` count assertions, so tests can
|
||||
// assert that the handler did or did not enqueue a NEW task without
|
||||
// double-counting the seed.
|
||||
func createHandlerTestTaskForAgentOnIssue(t *testing.T, agentID, issueID string) string {
|
||||
t.Helper()
|
||||
|
||||
var issueArg any
|
||||
if issueID == "" {
|
||||
issueArg = nil
|
||||
} else {
|
||||
issueArg = issueID
|
||||
}
|
||||
|
||||
var taskID string
|
||||
if err := testPool.QueryRow(context.Background(), `
|
||||
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority)
|
||||
VALUES ($1, $2, 'queued', 0)
|
||||
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority, issue_id, started_at)
|
||||
VALUES ($1, $2, 'running', 0, $3, now())
|
||||
RETURNING id
|
||||
`, agentID, handlerTestRuntimeID(t)).Scan(&taskID); err != nil {
|
||||
`, agentID, handlerTestRuntimeID(t), issueArg).Scan(&taskID); err != nil {
|
||||
t.Fatalf("failed to create handler test task: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
@@ -2624,6 +2648,332 @@ func TestBacklogToTodoTriggersAgent(t *testing.T) {
|
||||
testHandler.DeleteIssue(httptest.NewRecorder(), cleanupReq)
|
||||
}
|
||||
|
||||
// TestBacklogToTodoByAgentTriggersDifferentAssignee verifies that the
|
||||
// documented sub-task chain works: when an agent (parent / Step 1) promotes
|
||||
// a backlog issue assigned to a different agent (child / Step 2), the
|
||||
// child's task is enqueued. Previously the backlog→active trigger was
|
||||
// gated on `actorType == "member"`, which silently dropped agent-driven
|
||||
// promotions and broke the serial sub-task workflow.
|
||||
func TestBacklogToTodoByAgentTriggersDifferentAssignee(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
// Parent agent (the actor) + child agent (the assignee).
|
||||
parentAgent := createHandlerTestAgent(t, "Backlog Parent Agent", nil)
|
||||
childAgent := createHandlerTestAgent(t, "Backlog Child Agent", nil)
|
||||
parentTask := createHandlerTestTaskForAgent(t, parentAgent)
|
||||
|
||||
// Create a backlog issue assigned to the child agent — should NOT trigger
|
||||
// on creation (backlog parking-lot rule).
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Serial sub-task Step 2",
|
||||
"status": "backlog",
|
||||
"assignee_type": "agent",
|
||||
"assignee_id": childAgent,
|
||||
})
|
||||
testHandler.CreateIssue(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var created IssueResponse
|
||||
json.NewDecoder(w.Body).Decode(&created)
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, created.ID)
|
||||
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, created.ID)
|
||||
})
|
||||
|
||||
// Parent agent promotes backlog → todo on behalf of the X-Task it is
|
||||
// currently running. Must enqueue exactly one task for the child agent.
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("PUT", "/api/issues/"+created.ID, map[string]any{"status": "todo"})
|
||||
req = withURLParam(req, "id", created.ID)
|
||||
req.Header.Set("X-Agent-ID", parentAgent)
|
||||
req.Header.Set("X-Task-ID", parentTask)
|
||||
testHandler.UpdateIssue(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("UpdateIssue: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var childTasks int
|
||||
if err := testPool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
|
||||
created.ID, childAgent,
|
||||
).Scan(&childTasks); err != nil {
|
||||
t.Fatalf("failed to count child tasks: %v", err)
|
||||
}
|
||||
if childTasks != 1 {
|
||||
t.Fatalf("expected exactly 1 task enqueued for child agent after agent-driven backlog→todo, got %d", childTasks)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBacklogToTodoByAgentSameIssueDoesNotSelfTrigger verifies the
|
||||
// task-issue-scoped self-loop guard: an agent whose CURRENT task is
|
||||
// running on issue I and who flips I from backlog to an active status
|
||||
// must NOT enqueue itself for I again. Without this guard the agent
|
||||
// would re-trigger every cycle it completed on I and immediately
|
||||
// re-enter the same path.
|
||||
//
|
||||
// This is the true self-loop case (calling task is on the SAME issue
|
||||
// being promoted). The complementary case — same agent, DIFFERENT
|
||||
// issue — is the documented serial chain and is covered by
|
||||
// TestBacklogToTodoByAgentSameAgentDifferentIssue.
|
||||
func TestBacklogToTodoByAgentSameIssueDoesNotSelfTrigger(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
selfAgent := createHandlerTestAgent(t, "Backlog Self Agent", nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Self-promoted backlog",
|
||||
"status": "backlog",
|
||||
"assignee_type": "agent",
|
||||
"assignee_id": selfAgent,
|
||||
})
|
||||
testHandler.CreateIssue(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var created IssueResponse
|
||||
json.NewDecoder(w.Body).Decode(&created)
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, created.ID)
|
||||
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, created.ID)
|
||||
})
|
||||
|
||||
// Task bound to the SAME issue being promoted — true self-loop.
|
||||
selfTask := createHandlerTestTaskForAgentOnIssue(t, selfAgent, created.ID)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("PUT", "/api/issues/"+created.ID, map[string]any{"status": "todo"})
|
||||
req = withURLParam(req, "id", created.ID)
|
||||
req.Header.Set("X-Agent-ID", selfAgent)
|
||||
req.Header.Set("X-Task-ID", selfTask)
|
||||
testHandler.UpdateIssue(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("UpdateIssue: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var tasks int
|
||||
if err := testPool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
|
||||
created.ID, selfAgent,
|
||||
).Scan(&tasks); err != nil {
|
||||
t.Fatalf("failed to count tasks: %v", err)
|
||||
}
|
||||
if tasks != 0 {
|
||||
t.Fatalf("expected no self-trigger when agent promotes the same issue its task is running on, got %d queued tasks", tasks)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBacklogToTodoByAgentSameAgentDifferentIssue verifies the documented
|
||||
// same-agent serial chain still fires: when an agent is running a task on
|
||||
// issue I1 and promotes a DIFFERENT backlog issue I2 (also assigned to
|
||||
// itself), I2 must be enqueued. This was over-blocked by the previous
|
||||
// agent-id-based self-loop guard, which made the same-agent serial
|
||||
// workflow silently break.
|
||||
func TestBacklogToTodoByAgentSameAgentDifferentIssue(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
agentID := createHandlerTestAgent(t, "Backlog Same-Agent Chain", nil)
|
||||
|
||||
// Step 1 issue — the one the agent is currently working on.
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Step 1 (running)",
|
||||
"status": "in_progress",
|
||||
"assignee_type": "agent",
|
||||
"assignee_id": agentID,
|
||||
})
|
||||
testHandler.CreateIssue(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateIssue step1: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var step1 IssueResponse
|
||||
json.NewDecoder(w.Body).Decode(&step1)
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, step1.ID)
|
||||
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, step1.ID)
|
||||
})
|
||||
|
||||
// Step 2 issue — backlog, also assigned to the same agent.
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Step 2 (backlog)",
|
||||
"status": "backlog",
|
||||
"assignee_type": "agent",
|
||||
"assignee_id": agentID,
|
||||
})
|
||||
testHandler.CreateIssue(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateIssue step2: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var step2 IssueResponse
|
||||
json.NewDecoder(w.Body).Decode(&step2)
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, step2.ID)
|
||||
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, step2.ID)
|
||||
})
|
||||
|
||||
// Task is running on step1 — promoting step2 is NOT a self-loop.
|
||||
step1Task := createHandlerTestTaskForAgentOnIssue(t, agentID, step1.ID)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("PUT", "/api/issues/"+step2.ID, map[string]any{"status": "todo"})
|
||||
req = withURLParam(req, "id", step2.ID)
|
||||
req.Header.Set("X-Agent-ID", agentID)
|
||||
req.Header.Set("X-Task-ID", step1Task)
|
||||
testHandler.UpdateIssue(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("UpdateIssue step2: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var step2Tasks int
|
||||
if err := testPool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
|
||||
step2.ID, agentID,
|
||||
).Scan(&step2Tasks); err != nil {
|
||||
t.Fatalf("failed to count step2 tasks: %v", err)
|
||||
}
|
||||
if step2Tasks != 1 {
|
||||
t.Fatalf("expected exactly 1 task enqueued on step2 for same-agent serial chain, got %d", step2Tasks)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchBacklogToTodoByAgentTriggersAssignee mirrors the single-update
|
||||
// serial-chain test on the BatchUpdateIssues path. Earlier the
|
||||
// member-only gate would silently drop agent-driven batch promotions; the
|
||||
// task-issue self-loop guard must let cross-issue (same-agent) batch
|
||||
// promotions through.
|
||||
func TestBatchBacklogToTodoByAgentTriggersAssignee(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
parentAgent := createHandlerTestAgent(t, "Batch Parent Agent", nil)
|
||||
childAgent := createHandlerTestAgent(t, "Batch Child Agent", nil)
|
||||
parentTask := createHandlerTestTaskForAgent(t, parentAgent)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Batch backlog child",
|
||||
"status": "backlog",
|
||||
"assignee_type": "agent",
|
||||
"assignee_id": childAgent,
|
||||
})
|
||||
testHandler.CreateIssue(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var created IssueResponse
|
||||
json.NewDecoder(w.Body).Decode(&created)
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, created.ID)
|
||||
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, created.ID)
|
||||
})
|
||||
|
||||
// Drive the batch endpoint with the same agent identity headers.
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("PATCH", "/api/issues/batch?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"issue_ids": []string{created.ID},
|
||||
"updates": map[string]any{"status": "todo"},
|
||||
})
|
||||
req.Header.Set("X-Agent-ID", parentAgent)
|
||||
req.Header.Set("X-Task-ID", parentTask)
|
||||
testHandler.BatchUpdateIssues(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("BatchUpdateIssues: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var childTasks int
|
||||
if err := testPool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
|
||||
created.ID, childAgent,
|
||||
).Scan(&childTasks); err != nil {
|
||||
t.Fatalf("failed to count child tasks: %v", err)
|
||||
}
|
||||
if childTasks != 1 {
|
||||
t.Fatalf("expected exactly 1 task enqueued for child agent after batch agent-driven backlog→todo, got %d", childTasks)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBacklogToTodoByAgentTriggersSquadLeader covers the squad branch of
|
||||
// the backlog→active trigger when the actor is an agent: the leader agent
|
||||
// of a squad must wake when one of its squad-assigned backlog issues is
|
||||
// promoted by another agent (or by the leader itself acting from a task
|
||||
// on a different issue). The task-issue self-loop guard must allow this —
|
||||
// only a true same-issue self-loop should be suppressed.
|
||||
func TestBacklogToTodoByAgentTriggersSquadLeader(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
leaderAgent := createHandlerTestAgent(t, "Backlog Squad Leader", nil)
|
||||
driverAgent := createHandlerTestAgent(t, "Backlog Squad Driver", nil)
|
||||
driverTask := createHandlerTestTaskForAgent(t, driverAgent)
|
||||
|
||||
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, "Backlog Trigger Squad", leaderAgent, testUserID).Scan(&squadID); err != nil {
|
||||
t.Fatalf("create squad: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM squad WHERE id = $1`, squadID) })
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Squad backlog issue",
|
||||
"status": "backlog",
|
||||
"assignee_type": "squad",
|
||||
"assignee_id": squadID,
|
||||
})
|
||||
testHandler.CreateIssue(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var created IssueResponse
|
||||
json.NewDecoder(w.Body).Decode(&created)
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, created.ID)
|
||||
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, created.ID)
|
||||
})
|
||||
|
||||
// Driver agent (not the leader, task is on no specific issue) promotes
|
||||
// the squad-assigned backlog issue. Squad leader must be enqueued.
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("PUT", "/api/issues/"+created.ID, map[string]any{"status": "todo"})
|
||||
req = withURLParam(req, "id", created.ID)
|
||||
req.Header.Set("X-Agent-ID", driverAgent)
|
||||
req.Header.Set("X-Task-ID", driverTask)
|
||||
testHandler.UpdateIssue(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("UpdateIssue: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var leaderTasks int
|
||||
if err := testPool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
|
||||
created.ID, leaderAgent,
|
||||
).Scan(&leaderTasks); err != nil {
|
||||
t.Fatalf("failed to count leader tasks: %v", err)
|
||||
}
|
||||
if leaderTasks != 1 {
|
||||
t.Fatalf("expected exactly 1 squad-leader task after agent-driven backlog→todo on squad issue, got %d", leaderTasks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonRegisterMissingWorkspaceReturns404(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/api/daemon/register", bytes.NewBufferString(`{
|
||||
|
||||
@@ -2239,11 +2239,19 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger the assigned agent when a member moves an issue out of backlog.
|
||||
// Backlog acts as a parking lot — moving to an active status signals the
|
||||
// issue is ready for work.
|
||||
if statusChanged && !assigneeChanged && actorType == "member" &&
|
||||
prevIssue.Status == "backlog" && issue.Status != "done" && issue.Status != "cancelled" {
|
||||
// Trigger the assigned agent when an issue moves out of backlog. Backlog
|
||||
// acts as a parking lot — moving to an active status signals the issue is
|
||||
// ready for work. Agent actors are allowed here so the documented
|
||||
// serial sub-task workflow works (parent agent finishes Step 1, then
|
||||
// promotes Step 2 from backlog→todo, regardless of who Step 2 is
|
||||
// assigned to). The only excluded case is the real self-loop: an agent
|
||||
// promoting the same issue its current task is running on. Same-agent,
|
||||
// cross-issue handoff (Agent A finishing one task and promoting another
|
||||
// issue assigned to A) must still fire — that is the documented serial
|
||||
// chain.
|
||||
if statusChanged && !assigneeChanged &&
|
||||
prevIssue.Status == "backlog" && issue.Status != "done" && issue.Status != "cancelled" &&
|
||||
!h.isAgentRunningOnIssue(r, actorType, issue) {
|
||||
if h.isAgentAssigneeReady(r.Context(), issue) {
|
||||
h.TaskService.EnqueueTaskForIssue(r.Context(), issue)
|
||||
}
|
||||
@@ -2373,6 +2381,43 @@ func (h *Handler) shouldEnqueueOnComment(ctx context.Context, issue db.Issue) bo
|
||||
return true
|
||||
}
|
||||
|
||||
// isAgentRunningOnIssue reports whether the calling agent's current task
|
||||
// (identified by X-Task-ID) is running for the exact issue being promoted.
|
||||
// That is the only true self-loop on backlog→active: the agent flipping
|
||||
// the same issue its own task is executing for would immediately re-enqueue
|
||||
// itself, complete the run, flip again, and so on.
|
||||
//
|
||||
// Same-agent cross-issue handoff (Agent A finishing a task on issue I1 then
|
||||
// promoting issue I2 — even when I2 is also assigned to A) is NOT a loop
|
||||
// and must fire; that is the documented serial sub-task chain. Member
|
||||
// actors never match.
|
||||
//
|
||||
// X-Task-ID is guaranteed to be present and consistent when actorType is
|
||||
// "agent": resolveActor demotes the actor to "member" otherwise (handler.go
|
||||
// resolveActor). We still recheck defensively — a future caller could pass
|
||||
// agent identity through a different path.
|
||||
func (h *Handler) isAgentRunningOnIssue(r *http.Request, actorType string, issue db.Issue) bool {
|
||||
if actorType != "agent" {
|
||||
return false
|
||||
}
|
||||
taskIDStr := r.Header.Get("X-Task-ID")
|
||||
if taskIDStr == "" {
|
||||
return false
|
||||
}
|
||||
taskUUID, err := util.ParseUUID(taskIDStr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
task, err := h.Queries.GetAgentTask(r.Context(), taskUUID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if !task.IssueID.Valid {
|
||||
return false
|
||||
}
|
||||
return uuidToString(task.IssueID) == uuidToString(issue.ID)
|
||||
}
|
||||
|
||||
// isAgentAssigneeReady checks if an issue is assigned to an active agent
|
||||
// with a valid runtime.
|
||||
func (h *Handler) isAgentAssigneeReady(ctx context.Context, issue db.Issue) bool {
|
||||
@@ -2667,9 +2712,13 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger agent when moving out of backlog (batch).
|
||||
if statusChanged && !assigneeChanged && actorType == "member" &&
|
||||
prevIssue.Status == "backlog" && issue.Status != "done" && issue.Status != "cancelled" {
|
||||
// Trigger agent when moving out of backlog (batch). Mirrors the
|
||||
// single-update path above — agent actors are allowed so serial
|
||||
// sub-task chains work, and the same task-issue self-loop guard
|
||||
// prevents an agent from re-triggering itself on the same issue.
|
||||
if statusChanged && !assigneeChanged &&
|
||||
prevIssue.Status == "backlog" && issue.Status != "done" && issue.Status != "cancelled" &&
|
||||
!h.isAgentRunningOnIssue(r, actorType, issue) {
|
||||
if h.isAgentAssigneeReady(r.Context(), issue) {
|
||||
h.TaskService.EnqueueTaskForIssue(r.Context(), issue)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user