mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-11 02:46:45 +08:00
* 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]>
219 lines
9.0 KiB
Go
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
|
|
}
|