Files
multica/server/internal/service/issue_trigger.go
Bohan Jiang 39ccb7d342 fix(server): do not cancel issue tasks on assignee change (MUL-4113, #4963) (#4975)
* fix(server): don't cancel issue tasks on assignee change (#4963)

Changing an issue's assignee previously called CancelTasksForIssue,
which cancels every active task on the issue by issue_id alone —
regardless of which agent owns the task or how it was triggered. In a
multi-agent workspace this silently dropped unrelated in-flight work
(a mention-triggered run for another agent, a squad task) with no
requeue, and it self-cancelled a run that reassigned the issue from
inside its own turn (the daemon then interrupted the live run before
its post-handoff cleanup could finish).

Reassignment now cancels nothing: ownership handoff no longer implies
interruption. The new assignee's run, if any, is still enqueued by
WillEnqueueRun and runs alongside whatever was already in flight.
Explicit terminal actions — issue -> cancelled and delete issue —
still cancel active tasks, unchanged.

Applies to both UpdateIssue and BatchUpdateIssues. Adds handler tests
that fail against the old behavior (both the previous assignee's own
run and an unrelated agent's run got cancelled) and pass now.

MUL-4113

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

* test(server): cover agent→agent reassign; fix stale WillEnqueueRun comment

Addresses review nits on #4975 (MUL-4113, #4963):

- Rewrite the outdated WillEnqueueRun doc comment. The assign source no
  longer cancels existing tasks, so the old "assign cancels existing
  tasks before enqueuing, pending task moot" premise is wrong. Describe
  the real invariant instead: the write is guarded by the
  (issue_id, agent_id) partial unique index, only the status source needs
  the pending-task dedup, and the assign source safely skips it.

- Add a handler test for the core agent→agent handoff path. The existing
  no-cancel tests only reassigned to a member; this one reassigns from one
  agent to another and asserts both effects independently: the previous
  agent's running task survives (no collateral cancel) and the new
  assignee still gets exactly one run enqueued.

MUL-4113

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-06 19:11:47 +08:00

184 lines
6.9 KiB
Go

package service
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// RunEnqueueSource identifies which kind of issue write would start an agent
// run. It is surfaced in preview responses so the UI can explain each trigger.
type RunEnqueueSource string
const (
// RunSourceAssign covers issue creation and assignee changes — the issue
// is being handed to an agent/squad. Parks silently on backlog.
RunSourceAssign RunEnqueueSource = "assign"
// RunSourceStatus covers promoting an already-assigned issue out of
// backlog into an active status.
RunSourceStatus RunEnqueueSource = "status"
)
// IssueTriggerProbe carries the request-scoped checks WillEnqueueRun cannot
// resolve from issue state alone.
//
// CanAccessAgent is the private-agent gate. The write paths enforce it at the
// HTTP boundary (validateAssigneePair on assign, canEnqueueSquadLeader inside
// the squad enqueue helper) and therefore pass an allow-all probe so the gate
// is never duplicated or sunk into the service layer. Preview passes the real
// gate so it never leaks a private agent's readiness to a member who cannot
// see it. A nil func is treated as allow-all.
//
// IsSelfLoop reports whether promoting this issue out of backlog would be the
// calling agent re-triggering its own running task. Only the status source
// consults it; create and assign never do. A nil func means "not a self-loop".
type IssueTriggerProbe struct {
CanAccessAgent func(agent db.Agent) bool
IsSelfLoop func() bool
}
// IssueTriggerInput describes one prospective issue write in its post-write
// shape. AssigneeChanged / StatusChanged mark which fields the write touches;
// IsCreate marks a brand-new issue (no prior task to cancel, no self-loop).
type IssueTriggerInput struct {
Issue db.Issue
PrevStatus string
IsCreate bool
AssigneeChanged bool
StatusChanged bool
}
// IssueRunTrigger is the resolved decision shared by preview and the write
// paths. AgentID is the agent that will actually run — the assignee for an
// agent issue, the squad leader for a squad issue.
type IssueRunTrigger struct {
IssueID pgtype.UUID
AgentID pgtype.UUID
AssigneeType string
Source RunEnqueueSource
}
func allowAllAgents(db.Agent) bool { return true }
// WillEnqueueRun is the single predicate answering "will this issue write
// start an agent run, and for whom". It is the one source of truth shared by
// the issue update / batch-update write paths and the preview endpoint,
// replacing the per-site copies that drifted (squad omitted, self-loop
// omitted, four entry points inconsistent — see MUL-3375).
//
// It is intentionally a distinct predicate from the comment trigger
// (assignee fallback comment routing): issue writes park on backlog while comments fire
// in any status. The two only share leaf readiness checks (AgentReadiness,
// the pending-task dedup), not the top-level decision.
//
// The decision must equal the real enqueue conditions so preview never claims
// a net-new run that the write path then drops. The write enqueues through
// CreateAgentTask, guarded by the (issue_id, agent_id) partial unique index
// over pending (queued/dispatched) tasks; the pending check below mirrors that
// guard, and only the status source needs it:
// - status source (backlog → active) can re-fire against an assignee that
// already holds a pending task (e.g. one a @mention raised while the issue
// sat in backlog); the check keeps preview from promising a run the unique
// index would coalesce away.
// - assign source (create / assignee change) skips the check: a create
// targets a fresh issue with no prior task, and a reassignment no longer
// cancels existing tasks (#4963 / MUL-4113) — in the rare case the new
// assignee already holds a pending task the insert simply no-ops on the
// same unique index, so the assignee still ends up with one pending run.
func (s *IssueService) WillEnqueueRun(ctx context.Context, in IssueTriggerInput, probe IssueTriggerProbe) (IssueRunTrigger, bool) {
issue := in.Issue
if !issue.AssigneeType.Valid || !issue.AssigneeID.Valid {
return IssueRunTrigger{}, false
}
canAccess := probe.CanAccessAgent
if canAccess == nil {
canAccess = allowAllAgents
}
var source RunEnqueueSource
switch {
case in.IsCreate || in.AssigneeChanged:
// Backlog is the parking lot: assigning into backlog never starts a run.
if issue.Status == "backlog" {
return IssueRunTrigger{}, false
}
source = RunSourceAssign
case in.StatusChanged && in.PrevStatus == "backlog" &&
issue.Status != "done" && issue.Status != "cancelled":
if probe.IsSelfLoop != nil && probe.IsSelfLoop() {
return IssueRunTrigger{}, false
}
source = RunSourceStatus
default:
return IssueRunTrigger{}, false
}
switch issue.AssigneeType.String {
case "agent":
agent, err := s.Queries.GetAgent(ctx, issue.AssigneeID)
if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid {
return IssueRunTrigger{}, false
}
if !canAccess(agent) {
return IssueRunTrigger{}, false
}
if source == RunSourceStatus && s.hasPendingRun(ctx, issue.ID, issue.AssigneeID) {
return IssueRunTrigger{}, false
}
return IssueRunTrigger{
IssueID: issue.ID,
AgentID: issue.AssigneeID,
AssigneeType: "agent",
Source: source,
}, true
case "squad":
squad, err := s.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{
ID: issue.AssigneeID,
WorkspaceID: issue.WorkspaceID,
})
if err != nil {
return IssueRunTrigger{}, false
}
leader, err := s.Queries.GetAgent(ctx, squad.LeaderID)
if err != nil {
return IssueRunTrigger{}, false
}
ready, _, err := AgentReadiness(ctx, s.Queries, leader)
if err != nil || !ready {
return IssueRunTrigger{}, false
}
if !canAccess(leader) {
return IssueRunTrigger{}, false
}
if source == RunSourceStatus && s.hasPendingRun(ctx, issue.ID, squad.LeaderID) {
return IssueRunTrigger{}, false
}
return IssueRunTrigger{
IssueID: issue.ID,
AgentID: squad.LeaderID,
AssigneeType: "squad",
Source: source,
}, true
}
return IssueRunTrigger{}, false
}
// hasPendingRun reports whether the agent already holds a queued or dispatched
// task for the issue (the (issue_id, agent_id) unique-index slot). Errors fail
// closed to "pending" so preview never over-promises a run.
func (s *IssueService) hasPendingRun(ctx context.Context, issueID, agentID pgtype.UUID) bool {
pending, err := s.Queries.HasPendingTaskForIssueAndAgent(ctx, db.HasPendingTaskForIssueAndAgentParams{
IssueID: issueID,
AgentID: agentID,
// Key dedup on the reviewed head so a pending run against an old HEAD
// does not shadow a request after HEAD advanced (TEN-356).
HeadSha: headShaText(s.TaskService.ResolveIssueReviewSHA(ctx, issueID)),
})
if err != nil {
return true
}
return pending
}