mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-06 22:09:44 +02:00
* fix(squad): inject leader briefing by task flag, not issue assignee Key squad-leader briefing injection off task.IsLeaderTask + task.SquadID instead of issue.AssigneeType=='squad'. The old gate missed the most common path — an @squad mention in a comment on an issue assigned to a plain agent (MUL-3724) — so the leader booted with zero squad context and did the work itself instead of orchestrating. - migration 127: add agent_task_queue.squad_id (no FK) + partial index - sqlc: CreateAgentTask stamps squad_id; CreateRetryTask inherits it - service: thread squadID through EnqueueTaskForSquadLeader(+WithHandoff), enqueueMentionTask, and the rerun path; all 5 call sites pass the squad id - daemon claim: unified injection keyed on leader-task + squad_id, with a defensive leader-identity re-check; quick-create block retained (it serves issue-less tasks and sets resp.SquadID/SquadName) - briefing: strengthen leader Operating Protocol opening - tests: claim-time injection (comment-mention/non-leader/null-squad), squad_id enqueue stamping, retry inheritance; existing fixture updated Co-authored-by: multica-agent <github@multica.ai> * test+docs(squad): dangling squad_id regression + clarify quick-create path Address review nits on #4606: - Add TestClaim_LeaderTaskWithDanglingSquadID_NoBriefing: squad hard-deleted after enqueue leaves task.squad_id dangling (no FK); claim still 200 and skips injection via the err!=nil guard. This is the load-bearing contract for dropping the FK. - Rewrite the daemon.go injection comment to state quick-create does NOT use the is_leader_task/squad_id columns — it routes squad via the context JSON branch (qc.SquadID) and must not be folded into the column-based path. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: 魏和尚 <agent@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
568 lines
25 KiB
Go
568 lines
25 KiB
Go
package handler
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log/slog"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||
)
|
||
|
||
// notifyParentOfChildDone posts a top-level system comment on the parent
|
||
// issue when a child issue transitions from non-done into done. This replaces
|
||
// the agent-prompt rule that previously made child agents post the
|
||
// notification themselves (PR #2918 user feedback — the agent rule caused
|
||
// self-mention loops, planner ping-pong, and accidental `MUL-` prefix
|
||
// hardcoding because the agent did not always know the workspace prefix).
|
||
//
|
||
// Guards on whether the comment fires at all:
|
||
// - the child must transition from a non-terminal status INTO a terminal one
|
||
// (done or cancelled). Repeat saves of an already-terminal child do not
|
||
// re-fire; only the entering transition does. Cancelled counts because a
|
||
// cancelled sibling never finishes and so closes its stage (see the entry
|
||
// guard and isTerminalChildStatus).
|
||
// - issue.ParentIssueID must be set
|
||
// - parent must not be "done" or "cancelled" — the parent is already
|
||
// closed and a notification has no follow-up to drive
|
||
// - parent must not be "backlog" — a parent parked in backlog is being
|
||
// deliberately held for later; waking its assignee (which can then
|
||
// promote sibling backlog sub-issues into todo) is exactly the
|
||
// unwanted auto-activation reported in #4320 / MUL-3497. A parked
|
||
// parent stays inert until the user explicitly moves it out of backlog.
|
||
// - parent assignee must not be a member (human). Humans read their
|
||
// issues manually; an automated system comment is pure noise for them
|
||
// and there is nothing to "trigger" on a human assignee. Skipping the
|
||
// comment entirely (Bohan's call on MUL-2538) also sidesteps the
|
||
// mention question — no comment, no mention, no inbox row.
|
||
// - the completion must close a STAGE barrier (MUL-3508). Sub-issues under
|
||
// a parent can be grouped into ordered stages via issue.stage; the
|
||
// notification + wake fire only when every sibling in the lowest
|
||
// unfinished stage is terminal (stageBarrierClosed). An unstaged sibling
|
||
// set is one implicit stage, so this fires once when the last sub-issue
|
||
// finishes instead of on every child — the default fix for the
|
||
// fire-on-every-child cascade reported in #4320. The woken assignee
|
||
// decides whether to promote the next stage (agent-driven advancement);
|
||
// the server only detects the barrier and wakes.
|
||
//
|
||
// The comment is inserted directly via db.Queries (not through the
|
||
// CreateComment HTTP handler) so it bypasses the generic on_comment trigger
|
||
// path. When the parent has an agent or squad assignee, the comment body
|
||
// embeds a single `mention://{agent,squad}/<id>` link that targets the
|
||
// parent assignee — Bohan's product call on MUL-2538 ("system child-done
|
||
// comment 无脑 mention parent assignee,member/squad/agent 都覆盖", later
|
||
// narrowed to skip member assignees outright). To keep the platform in
|
||
// control of side effects, the cmd/server notification + subscriber
|
||
// listeners still skip system comments wholesale, so smuggled mentions from
|
||
// the child title cannot light up unrelated members. The parent assignee's
|
||
// own trigger is fired explicitly by dispatchParentAssigneeTrigger below,
|
||
// with the loop and idempotency guards documented there.
|
||
//
|
||
// Errors are logged at warn level and swallowed: this is a best-effort
|
||
// notification on the side of a successful status update; failing it must
|
||
// not roll back the user's status change.
|
||
func (h *Handler) notifyParentOfChildDone(ctx context.Context, prev, issue db.Issue, actorType, actorID string) {
|
||
if !issue.ParentIssueID.Valid {
|
||
return
|
||
}
|
||
// Fire on a transition INTO a terminal status (done OR cancelled), not only
|
||
// `done`. A cancelled child can close a stage too: isTerminalChildStatus
|
||
// treats cancelled as terminal (a cancelled sibling never finishes, so it
|
||
// must not hold the stage open), so the barrier has to be evaluated when the
|
||
// last open child of a stage is cancelled. Keying on the transition also
|
||
// makes a later cancelled -> done edit a no-op (terminal -> terminal), which
|
||
// avoids a lagging duplicate wake.
|
||
if isTerminalChildStatus(prev.Status) || !isTerminalChildStatus(issue.Status) {
|
||
return
|
||
}
|
||
parent, err := h.Queries.GetIssue(ctx, issue.ParentIssueID)
|
||
if err != nil {
|
||
slog.Warn("child done: failed to load parent",
|
||
"error", err,
|
||
"child_id", uuidToString(issue.ID),
|
||
"parent_id", uuidToString(issue.ParentIssueID))
|
||
return
|
||
}
|
||
if parent.Status == "done" || parent.Status == "cancelled" {
|
||
return
|
||
}
|
||
// A parent parked in backlog is deliberately held for later. Posting the
|
||
// system comment would wake its assignee, and the woken agent can then
|
||
// promote sibling backlog sub-issues into todo — the surprise auto-
|
||
// activation reported in #4320 / MUL-3497. Skip the whole notification so
|
||
// a backlog parent stays inert until the user explicitly promotes it.
|
||
if parent.Status == "backlog" {
|
||
return
|
||
}
|
||
// Human-assigned parents read their own timeline; an automated system
|
||
// comment is just noise and there is no agent task to trigger. Skip the
|
||
// whole notification (comment + mention + inbox row) — MUL-2538.
|
||
if parent.AssigneeType.Valid && parent.AssigneeType.String == "member" {
|
||
return
|
||
}
|
||
|
||
// Stage barrier (MUL-3508 / discussion #4320). The notification + assignee
|
||
// wake fire only when this completion *closes a stage* — i.e. every sibling
|
||
// in the lowest unfinished stage is now terminal. An unstaged sibling set is
|
||
// one implicit stage, so this collapses to "wake once when the last
|
||
// sub-issue finishes" instead of the old fire-on-every-child behavior that
|
||
// caused the surprise cascade. A completion that does not close a stage is
|
||
// silent: no comment, no wake. ListChildIssues already reflects this child's
|
||
// committed `done` status (the status update commits before this runs).
|
||
children, err := h.Queries.ListChildIssues(ctx, parent.ID)
|
||
if err != nil {
|
||
slog.Warn("child done: failed to list siblings for stage barrier",
|
||
"error", err,
|
||
"child_id", uuidToString(issue.ID),
|
||
"parent_id", uuidToString(parent.ID))
|
||
return
|
||
}
|
||
if !stageBarrierClosed(children, issue) {
|
||
return
|
||
}
|
||
staged := siblingsAreStaged(children)
|
||
|
||
prefix := h.getIssuePrefix(ctx, issue.WorkspaceID)
|
||
identifier := prefix + "-" + strconv.Itoa(int(issue.Number))
|
||
childID := uuidToString(issue.ID)
|
||
title := sanitizeChildTitleForSystemComment(issue.Title)
|
||
parentID := uuidToString(parent.ID)
|
||
|
||
// Build the parent-assignee mention prefix. Empty when the parent has no
|
||
// assignee or the assignee row is missing (deleted member, archived
|
||
// agent the workspace lost track of, etc.).
|
||
mentionPrefix := h.buildParentAssigneeMention(ctx, parent)
|
||
|
||
var content string
|
||
// When the set is staged and the barrier closed, the completed child is
|
||
// guaranteed to carry a stage (stageBarrierClosed returns false for an
|
||
// unstaged completed child in a staged set), so issue.Stage.Int32 is safe.
|
||
if staged {
|
||
closedStage := issue.Stage.Int32
|
||
summary, nextStage := stageProgressSummary(children, closedStage)
|
||
var advance string
|
||
if nextStage > 0 {
|
||
advance = fmt.Sprintf(
|
||
" Stage %d is next. Review the full layout with `multica issue children %s`, and if Stage %d's dependencies are satisfied promote its `backlog` sub-issues to `todo` to continue. Read each sub-issue's description first and only promote items whose stated dependencies are already met — do not rely on this parent's higher-level breakdown alone. If a description conflicts with that breakdown, leave it `backlog` and post a comment to confirm first.",
|
||
nextStage, parentID, nextStage,
|
||
)
|
||
} else {
|
||
advance = " This was the final stage. Wrap up the parent — synthesize the results and move it forward, or close it out if nothing remains."
|
||
}
|
||
content = fmt.Sprintf(
|
||
"%sStage %d of this issue is complete — its last sub-issue [%s](mention://issue/%s) — \"%s\" — just finished. Stage progress — %s.%s",
|
||
mentionPrefix, closedStage, identifier, childID, title, summary, advance,
|
||
)
|
||
} else {
|
||
content = fmt.Sprintf(
|
||
"%sAll sub-issues are complete — the last one, [%s](mention://issue/%s) — \"%s\", just finished. Continue the parent: synthesize the children's results and move it forward, or close it out if nothing remains.",
|
||
mentionPrefix, identifier, childID, title,
|
||
)
|
||
}
|
||
|
||
// author_type='system', author_id=zero UUID. The zero UUID is a valid 16
|
||
// byte value and the column is NOT NULL; frontend code should branch on
|
||
// author_type === 'system' rather than on the UUID value.
|
||
comment, err := h.Queries.CreateComment(ctx, db.CreateCommentParams{
|
||
IssueID: parent.ID,
|
||
WorkspaceID: parent.WorkspaceID,
|
||
AuthorType: "system",
|
||
AuthorID: pgtype.UUID{Valid: true},
|
||
Content: content,
|
||
Type: "system",
|
||
ParentID: pgtype.UUID{Valid: false},
|
||
})
|
||
if err != nil {
|
||
slog.Warn("child done: create system comment failed",
|
||
"error", err,
|
||
"child_id", childID,
|
||
"parent_id", uuidToString(parent.ID))
|
||
return
|
||
}
|
||
|
||
h.publish(protocol.EventCommentCreated, uuidToString(parent.WorkspaceID), "system", "", map[string]any{
|
||
"comment": commentToResponse(comment, nil, nil),
|
||
"issue_title": parent.Title,
|
||
"issue_assignee_type": textToPtr(parent.AssigneeType),
|
||
"issue_assignee_id": uuidToPtr(parent.AssigneeID),
|
||
"issue_status": parent.Status,
|
||
})
|
||
|
||
// Dispatch the explicit trigger / inbox row for the parent assignee.
|
||
// Listener-level mention parsing is intentionally NOT involved (the
|
||
// notification + subscriber listeners both short-circuit on
|
||
// author_type='system'); this keeps smuggled mentions from the child
|
||
// title inert and gives the platform a single place to apply the loop
|
||
// and idempotency guards.
|
||
h.dispatchParentAssigneeTrigger(ctx, parent, issue, comment, actorType, actorID)
|
||
}
|
||
|
||
// isTerminalChildStatus reports whether a child issue status counts as
|
||
// "finished" for stage-barrier purposes. Cancelled counts as terminal: a
|
||
// cancelled sibling will never complete, so it must not hold a stage open.
|
||
func isTerminalChildStatus(status string) bool {
|
||
return status == "done" || status == "cancelled"
|
||
}
|
||
|
||
// siblingsAreStaged reports whether any child in the set carries an explicit
|
||
// stage. A set with no stages is treated as a single implicit stage.
|
||
func siblingsAreStaged(children []db.Issue) bool {
|
||
for _, c := range children {
|
||
if c.Stage.Valid {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// stageBarrierClosed reports whether the completion of `completed` closed a
|
||
// stage barrier among `children` — the full sibling set under one parent,
|
||
// already reflecting completed's terminal status.
|
||
//
|
||
// - Unstaged sibling set (no child carries a stage): a single implicit
|
||
// stage. The barrier closes only when every child is terminal — the "wake
|
||
// once when the last sub-issue finishes" default.
|
||
// - Staged sibling set: only children that carry a stage form stages.
|
||
// Unstaged children do NOT participate (matches migration 123: a NULL
|
||
// stage does not take part in staged grouping) — completing one closes
|
||
// nothing, and a non-terminal unstaged child never holds a stage open.
|
||
// The completed child's stage S closes when every *staged* child with
|
||
// stage <= S is terminal (frontier closure). Later stages are normally
|
||
// parked in `backlog`, so they cannot fire out of order; the caller's
|
||
// idempotency guard collapses any duplicate wake.
|
||
func stageBarrierClosed(children []db.Issue, completed db.Issue) bool {
|
||
if !siblingsAreStaged(children) {
|
||
for _, c := range children {
|
||
if !isTerminalChildStatus(c.Status) {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
// Staged set: an unstaged completed child belongs to no stage, so it closes
|
||
// nothing.
|
||
if !completed.Stage.Valid {
|
||
return false
|
||
}
|
||
s := completed.Stage.Int32
|
||
for _, c := range children {
|
||
if !c.Stage.Valid {
|
||
continue // unstaged children are ignored by the frontier
|
||
}
|
||
if c.Stage.Int32 <= s && !isTerminalChildStatus(c.Status) {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// stageProgressSummary renders a compact per-stage breakdown for the
|
||
// child-done system comment (e.g. "Stage 1: 3/3 done; Stage 2: 0/4 done") and
|
||
// returns the lowest stage above closedStage that still has non-terminal
|
||
// children — the next group to promote — or 0 when none remain. Unstaged
|
||
// children are skipped (they are not part of any stage), so the breakdown
|
||
// never renders a "Stage 0".
|
||
func stageProgressSummary(children []db.Issue, closedStage int32) (summary string, nextStage int32) {
|
||
type agg struct{ total, done int }
|
||
byStage := map[int32]*agg{}
|
||
order := []int32{}
|
||
for _, c := range children {
|
||
if !c.Stage.Valid {
|
||
continue // unstaged children do not belong to any stage
|
||
}
|
||
s := c.Stage.Int32
|
||
a, ok := byStage[s]
|
||
if !ok {
|
||
a = &agg{}
|
||
byStage[s] = a
|
||
order = append(order, s)
|
||
}
|
||
a.total++
|
||
if isTerminalChildStatus(c.Status) {
|
||
a.done++
|
||
}
|
||
}
|
||
sort.Slice(order, func(i, j int) bool { return order[i] < order[j] })
|
||
parts := make([]string, 0, len(order))
|
||
for _, s := range order {
|
||
a := byStage[s]
|
||
label := fmt.Sprintf("Stage %d: %d/%d done", s, a.done, a.total)
|
||
if nextStage == 0 && s > closedStage && a.done < a.total {
|
||
nextStage = s
|
||
label += " (next)"
|
||
}
|
||
parts = append(parts, label)
|
||
}
|
||
return strings.Join(parts, "; "), nextStage
|
||
}
|
||
|
||
// sanitizeChildTitleForSystemComment removes mention-style markdown from a
|
||
// child issue's title before it is embedded into the parent's system
|
||
// comment. Smuggled mentions are already harmless on the listener path
|
||
// (notification + subscriber listeners both skip system comments), but the
|
||
// timeline still renders the title verbatim — stripping the markdown keeps
|
||
// the rendered comment readable and stops a maliciously titled child issue
|
||
// from looking like a directive ("@all please look").
|
||
func sanitizeChildTitleForSystemComment(title string) string {
|
||
// Replace any markdown link target so the regex no longer matches it,
|
||
// while preserving the human-readable label text. `]` and `(` are the
|
||
// minimum delimiters of the mention regex; replacing the `(` is enough
|
||
// to break the match without mangling the label.
|
||
cleaned := strings.ReplaceAll(title, "](mention://", "] (mention-stripped://")
|
||
return cleaned
|
||
}
|
||
|
||
// buildParentAssigneeMention returns the markdown prefix that the system
|
||
// comment should lead with, including a trailing space, so the body reads
|
||
// like a normal mention-led comment. Returns the empty string when the
|
||
// parent has no assignee or the assignee row could not be loaded.
|
||
func (h *Handler) buildParentAssigneeMention(ctx context.Context, parent db.Issue) string {
|
||
if !parent.AssigneeType.Valid || !parent.AssigneeID.Valid {
|
||
return ""
|
||
}
|
||
label, ok := h.resolveAssigneeMentionLabel(ctx, parent.WorkspaceID, parent.AssigneeType.String, parent.AssigneeID)
|
||
if !ok {
|
||
return ""
|
||
}
|
||
return fmt.Sprintf("[@%s](mention://%s/%s) ", label, parent.AssigneeType.String, uuidToString(parent.AssigneeID))
|
||
}
|
||
|
||
// resolveAssigneeMentionLabel returns the label text to render inside the
|
||
// mention link. The label is for human display only — the mention regex
|
||
// keys off the URL path, not the label — but a sensible fallback keeps the
|
||
// rendered comment legible if the frontend has not pre-loaded the assignee.
|
||
// Returns ok=false when the assignee row cannot be loaded; the caller
|
||
// should then omit the mention entirely rather than emit a broken link.
|
||
func (h *Handler) resolveAssigneeMentionLabel(ctx context.Context, workspaceID pgtype.UUID, assigneeType string, assigneeID pgtype.UUID) (string, bool) {
|
||
switch assigneeType {
|
||
case "agent":
|
||
agent, err := h.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{
|
||
ID: assigneeID,
|
||
WorkspaceID: workspaceID,
|
||
})
|
||
if err != nil {
|
||
return "", false
|
||
}
|
||
return sanitizeMentionLabel(agent.Name), true
|
||
case "squad":
|
||
squad, err := h.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{
|
||
ID: assigneeID,
|
||
WorkspaceID: workspaceID,
|
||
})
|
||
if err != nil {
|
||
return "", false
|
||
}
|
||
return sanitizeMentionLabel(squad.Name), true
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
// sanitizeMentionLabel strips characters that would break the mention
|
||
// markdown if a name contained them. The mention regex is non-greedy on the
|
||
// label, so a stray `]` would short-circuit it. Names with `]` are
|
||
// vanishingly rare but cheap to defend against.
|
||
func sanitizeMentionLabel(name string) string {
|
||
cleaned := strings.ReplaceAll(name, "]", "")
|
||
cleaned = strings.TrimSpace(cleaned)
|
||
if cleaned == "" {
|
||
return "assignee"
|
||
}
|
||
return cleaned
|
||
}
|
||
|
||
// dispatchParentAssigneeTrigger fires the explicit side effect that pairs
|
||
// with the @mention link in the system comment body — an agent task for
|
||
// agent or squad-leader assignees. Member assignees never reach this code
|
||
// path; notifyParentOfChildDone skips them outright. The generic comment
|
||
// listener is intentionally bypassed (it short-circuits on
|
||
// author_type='system'), so this is the single place where the platform
|
||
// applies loop and idempotency guards for the child-done notification.
|
||
//
|
||
// Side-effect semantics (intentionally narrower than a normal @mention):
|
||
// - agent parent: one EnqueueTaskForMention on the parent assignee, same
|
||
// trigger surface as a real @-mention so dedupe and readiness checks
|
||
// match what users already rely on.
|
||
// - squad parent: one EnqueueTaskForSquadLeader on the squad LEADER only.
|
||
// Unlike a human @squad mention, this does NOT fan out to squad members
|
||
// — child-done is a coordination signal, the leader decides whether
|
||
// and how to wake the rest of the squad. Documented here so reviewers
|
||
// don't read "system mention" as inheriting the full member fan-out.
|
||
// - notification_preference is not consulted: this is a platform routing
|
||
// signal targeted at the assignee that already owns the parent, not a
|
||
// general notification. Per-user mute settings are evaluated by the
|
||
// downstream agent_task / inbox pipeline once the task is dispatched.
|
||
// - notification_listeners.go short-circuits on author_type='system', so
|
||
// subscriber emails and member-inbox rows from smuggled mentions in the
|
||
// child title are inert — only the explicit dispatch below runs.
|
||
//
|
||
// Guards applied here:
|
||
// - No-op when the parent has no assignee row.
|
||
// - Squad loop guard (squad parent only): skip when the finished child is
|
||
// the same squad, or its effective owner is the parent squad's leader. A
|
||
// squad leader already observes same-squad work through its own
|
||
// coordination cycle — the worker's completion comment wakes the leader
|
||
// via computeAssignedSquadLeaderCommentTrigger — so the child-done trigger would
|
||
// be redundant; this also closes the cross-squad shared-leader loop. The
|
||
// AGENT parent path intentionally has NO such guard (MUL-2808): a lone
|
||
// agent that decomposes its parent into sub-issues it owns itself has no
|
||
// other wake path, and waking the parent agent when its child finishes is
|
||
// a serial sub-task handoff across two DIFFERENT issues — explicitly not a
|
||
// self-loop per isAgentRunningOnIssue, and consistent with the @mention
|
||
// self-trigger path (computeMentionedAgentCommentTriggers). Runaway re-triggering is
|
||
// bounded by the idempotency guard below, not by suppressing the trigger.
|
||
// - Idempotency: HasPendingTaskForIssueAndAgent dedupes rapid-fire enqueues
|
||
// for the same parent (e.g. two children finishing back-to-back).
|
||
// - Readiness: archived agents / missing runtimes are silently skipped
|
||
// so a closed-out agent does not surface as a phantom assignee.
|
||
func (h *Handler) dispatchParentAssigneeTrigger(ctx context.Context, parent, child db.Issue, systemComment db.Comment, actorType, actorID string) {
|
||
if !parent.AssigneeType.Valid || !parent.AssigneeID.Valid {
|
||
return
|
||
}
|
||
|
||
switch parent.AssigneeType.String {
|
||
case "agent":
|
||
h.triggerChildDoneAgent(ctx, parent, systemComment.ID)
|
||
case "squad":
|
||
h.triggerChildDoneSquad(ctx, parent, child, systemComment.ID, actorType, actorID)
|
||
}
|
||
}
|
||
|
||
// triggerChildDoneAgent enqueues a mention-style task for the parent's
|
||
// agent assignee.
|
||
//
|
||
// There is intentionally NO same-agent self-trigger guard here, unlike the
|
||
// squad path. Waking the parent agent when one of its children finishes is a
|
||
// serial sub-task handoff between two DIFFERENT issues, which the platform
|
||
// loop model treats as legitimate ("not a loop and must fire" — see
|
||
// isAgentRunningOnIssue); only re-entering the SAME issue is a loop. A lone
|
||
// agent that decomposes its parent into sub-issues it owns itself has no
|
||
// other wake path, so the old "child owner == parent agent" guard silently
|
||
// stranded those parents (MUL-2808). Runaway re-triggering is prevented by
|
||
// the HasPendingTaskForIssueAndAgent dedup below, exactly as the @mention
|
||
// self-trigger path relies on it (see computeMentionedAgentCommentTriggers).
|
||
func (h *Handler) triggerChildDoneAgent(ctx context.Context, parent db.Issue, triggerCommentID pgtype.UUID) {
|
||
agent, err := h.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{
|
||
ID: parent.AssigneeID,
|
||
WorkspaceID: parent.WorkspaceID,
|
||
})
|
||
if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid {
|
||
return
|
||
}
|
||
|
||
hasPending, err := h.Queries.HasPendingTaskForIssueAndAgent(ctx, db.HasPendingTaskForIssueAndAgentParams{
|
||
IssueID: parent.ID,
|
||
AgentID: parent.AssigneeID,
|
||
})
|
||
if err != nil || hasPending {
|
||
return
|
||
}
|
||
|
||
if _, err := h.TaskService.EnqueueTaskForMention(ctx, parent, parent.AssigneeID, triggerCommentID); err != nil {
|
||
slog.Warn("child done: enqueue parent agent task failed",
|
||
"error", err,
|
||
"parent_id", uuidToString(parent.ID),
|
||
"agent_id", uuidToString(parent.AssigneeID))
|
||
}
|
||
}
|
||
|
||
// triggerChildDoneSquad enqueues a leader-role task for the parent's squad
|
||
// assignee, applying the self-trigger guard against:
|
||
// - same squad on both sides (the leader already observed the child via
|
||
// its own coordination cycle), and
|
||
// - same effective leader on both sides — child agent == leader, or
|
||
// child squad's leader == this squad's leader (the cross-squad shared
|
||
// leader loop).
|
||
func (h *Handler) triggerChildDoneSquad(ctx context.Context, parent, child db.Issue, triggerCommentID pgtype.UUID, actorType, actorID string) {
|
||
squad, err := h.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{
|
||
ID: parent.AssigneeID,
|
||
WorkspaceID: parent.WorkspaceID,
|
||
})
|
||
if err != nil {
|
||
return
|
||
}
|
||
|
||
// Private-leader gate: deny if the actor cannot access the leader.
|
||
if !h.canEnqueueSquadLeader(ctx, squad.LeaderID, actorType, actorID, uuidToString(parent.WorkspaceID)) {
|
||
return
|
||
}
|
||
|
||
// Same-squad child → the leader has already observed the work via its
|
||
// own coordination cycle on the child; firing again on the parent would
|
||
// just re-trigger the same leader run with no new signal.
|
||
if childAssigneeIsSquad(child, parent.AssigneeID) {
|
||
return
|
||
}
|
||
// Shared-leader loop: child driven directly by the parent squad's leader,
|
||
// or by another squad whose leader is the same agent.
|
||
if owner := h.effectiveChildAgentOwner(ctx, child); owner.Valid &&
|
||
uuidToString(owner) == uuidToString(squad.LeaderID) {
|
||
return
|
||
}
|
||
|
||
agent, err := h.Queries.GetAgent(ctx, squad.LeaderID)
|
||
if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid {
|
||
return
|
||
}
|
||
|
||
hasPending, err := h.Queries.HasPendingTaskForIssueAndAgent(ctx, db.HasPendingTaskForIssueAndAgentParams{
|
||
IssueID: parent.ID,
|
||
AgentID: squad.LeaderID,
|
||
})
|
||
if err != nil || hasPending {
|
||
return
|
||
}
|
||
|
||
if _, err := h.TaskService.EnqueueTaskForSquadLeader(ctx, parent, squad.LeaderID, squad.ID, triggerCommentID); err != nil {
|
||
slog.Warn("child done: enqueue parent squad leader task failed",
|
||
"error", err,
|
||
"parent_id", uuidToString(parent.ID),
|
||
"squad_id", uuidToString(squad.ID),
|
||
"leader_id", uuidToString(squad.LeaderID))
|
||
}
|
||
}
|
||
|
||
// effectiveChildAgentOwner returns the agent identity that effectively
|
||
// "owns" the child issue from the perspective of the child-done trigger:
|
||
//
|
||
// - child agent assignee → that agent
|
||
// - child squad assignee → that squad's leader (the agent that would
|
||
// actually act on a leader task and is the entry point for any squad
|
||
// work; a shared leader across two squads is the loop vector the
|
||
// callers above defend against)
|
||
// - anything else (member assignee, no assignee, missing squad row) →
|
||
// invalid UUID, signalling "no shared owner to compare against"
|
||
//
|
||
// Callers compare this against the agent they are about to trigger; equality
|
||
// means we'd be enqueueing the same agent that just finished the child,
|
||
// which is the loop case.
|
||
func (h *Handler) effectiveChildAgentOwner(ctx context.Context, child db.Issue) pgtype.UUID {
|
||
if !child.AssigneeType.Valid || !child.AssigneeID.Valid {
|
||
return pgtype.UUID{}
|
||
}
|
||
switch child.AssigneeType.String {
|
||
case "agent":
|
||
return child.AssigneeID
|
||
case "squad":
|
||
squad, err := h.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{
|
||
ID: child.AssigneeID,
|
||
WorkspaceID: child.WorkspaceID,
|
||
})
|
||
if err != nil {
|
||
return pgtype.UUID{}
|
||
}
|
||
return squad.LeaderID
|
||
}
|
||
return pgtype.UUID{}
|
||
}
|
||
|
||
func childAssigneeIsSquad(child db.Issue, squadID pgtype.UUID) bool {
|
||
if !child.AssigneeType.Valid || child.AssigneeType.String != "squad" || !child.AssigneeID.Valid {
|
||
return false
|
||
}
|
||
return uuidToString(child.AssigneeID) == uuidToString(squadID)
|
||
}
|