Files
multica/server/internal/service/issue_trigger.go
T
cc21d19353 MUL-6463: confirm the promotion that starts an agent run (#7293)
* feat(issues): confirm the promotion that starts an agent run (MUL-6463)

Assigning an agent to an issue opens a confirmation with a handoff note and
"Don't start yet". Promoting a parked issue that ALREADY has an agent owner
starts a run just as directly — the status change alone is what fires
RunSourceStatus — but applied silently from a one-click picker, with no way to
tell the agent anything before it woke up.

The dialog now covers both writes. `RunConfirmModal` gains a `promote` mode
that submits the status change (plus the same optional handoff note, or
suppress_run for "Don't start yet") instead of an assignee change; the owner is
already on the issue, so the promotion payload carries the status and nothing
else.

The gate is the run predicate, not a status allow-list: parked (backlog
CATEGORY) + agent/squad owner + a target outside backlog/done/cancelled. So
built-in `todo` and every custom Todo-category status behave identically, which
is what the report asked for, and so do the other promotion targets that start
a run the same way. Drag-and-drop and the batch toolbar keep applying directly
— the existing split (MUL-4155), not a new one.

Also fixes the assign gate next to it, which still compared the raw status KEY
to "backlog": an issue parked in a CUSTOM backlog-category status popped a
confirm promising a run the server would never start. Both gates now resolve
the category the way the server does (issuestatus.Effective), preferring the
payload's own status_category and falling back to the workspace catalog.

Tests: promotion routing for built-in todo / custom todo / custom backlog
origin / non-todo target, the five no-run status changes that stay direct, the
custom-backlog assign regression, and the modal's promote payloads (note,
suppress_run, no assignee fields) plus its status naming. Verified each fails
without the change.

Docs: the promotion dialog in assigning-issues (en/zh/ja/ko).
Co-authored-by: multica-agent <[email protected]>

* fix(issues): bold the status name in the promotion confirm

"Move this issue to Rework." left the status looking like an ordinary word —
worse for a custom status an admin named something like Later or Blocked-ish.
The headline already had a fence-and-bold mechanism for the assignee name, so
generalize it to bold every fenced span and fence the status too.

CJK copy drops the 「」 it used for the same job: bold is now the one mechanism
across all four locales.

Co-authored-by: multica-agent <[email protected]>

* fix(issues): close the three run-confirm gaps from review (MUL-6463)

1. Server and client disagreed about moving WITHIN the backlog category.
   `WillEnqueueRun`'s status branch only excluded `done`/`cancelled`, so
   `backlog` → a custom Backlog-category status (`Later`) changed the key,
   normalized both sides to `backlog`, and still enqueued a run — while the
   client, which reads categories, applied it silently. Before custom statuses
   the two conditions were the same; now leaving the category is its own
   requirement. Regression test covers both halves: no run inside the category,
   still exactly one on the way out.

2. The table's inline pickers bypassed the gate entirely — `meta.updateIssue`
   went straight to the surface action, so promoting an agent-owned backlog row
   from the table started a run with no confirmation. `meta.updateIssue` now
   takes the issue (the gate needs its category and owner) and routes through
   the same intent.

3. "Unresolvable category" was collapsing into a guess. `catalog.categoryOf`
   answers `todo` for a custom key it has not loaded, which made an unknown
   status read as not-parked and skipped the promote dialog. Category
   resolution is now explicitly three-state — carried, built-in, catalog, else
   null — and both writes that can start a run confirm on null.

The gate itself moves to a pure `runConfirmIntent` next to a canonical matrix
test, so the issue detail, the context menu and the table all route on one
answer instead of three copies; the hook/table suites keep only their wiring.

Docs nit from the same review: the new CJK paragraphs said issue / イシュー /
이슈 where the glossary requires 任务 / タスク / 태스크, and named statuses in
capitalized prose instead of backticked keys.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 16:03:51 +08:00

219 lines
9.0 KiB
Go

package service
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/issuestatus"
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. A nil func means "not a self-loop".
//
// SuppressActiveSelfAssignment reports whether a direct agent assignment is a
// trusted agent claiming ownership for itself while the target (issue, agent)
// pair already has a non-terminal task. The ownership update still succeeds;
// only the duplicate enqueue is suppressed. Cross-issue handoffs to a fresh
// target remain runnable. A nil func means "do not suppress".
type IssueTriggerProbe struct {
CanAccessAgent func(agent db.Agent) bool
IsSelfLoop func() bool
SuppressActiveSelfAssignment func(agentID pgtype.UUID) 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
}
// The status source also requires LEAVING the backlog category, not merely
// changing the status key. Before custom statuses a key change out of
// `backlog` was always a category change, so the two were the same
// condition; now `backlog` → a custom status in the `backlog` category is a
// move within the parking lot, and starting a run on it would break the one
// promise backlog makes. (MUL-6463)
//
// Both sides of the transition are normalized to the canonical status they
// inherit, so a custom status in the `backlog` category parks exactly like
// Backlog and a custom status in the `todo` category starts a run exactly
// like Todo. Built-in keys resolve to themselves without a query, leaving
// this decision bit-identical for workspaces with no custom statuses —
// which is the whole set of them until an admin defines one. (MUL-6243)
currentStatus := issuestatus.Effective(ctx, s.Queries, issue.WorkspaceID, issue.Status)
prevStatus := issuestatus.Effective(ctx, s.Queries, issue.WorkspaceID, in.PrevStatus)
var source RunEnqueueSource
switch {
case in.IsCreate || in.AssigneeChanged:
// Backlog is the parking lot: assigning into backlog never starts a run.
if currentStatus == "backlog" {
return IssueRunTrigger{}, false
}
source = RunSourceAssign
case in.StatusChanged && prevStatus == "backlog" &&
currentStatus != "backlog" &&
currentStatus != "done" && currentStatus != "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 == RunSourceAssign && !in.IsCreate && probe.SuppressActiveSelfAssignment != nil &&
probe.SuppressActiveSelfAssignment(issue.AssigneeID) {
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":
// Pair-scoped self-assignment suppression intentionally applies only to
// direct agent ownership. Assigning a squad changes execution context
// (leader briefing, roles, and member routing), so even when the acting
// agent is that squad's leader it is an intentional group handoff rather
// than a redundant direct self-claim. The status path below still uses
// the leader's pending-task guard.
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
}
verdict, err := AgentReadiness(ctx, s.Queries, leader)
if err != nil || !verdict.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
}