mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-13 13:18:56 +02:00
* 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>
184 lines
6.9 KiB
Go
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
|
|
}
|