mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 12:35:35 +02:00
* fix(squads): align parent issue status ownership with agent-managed model Squad leaders now open assigned parents to in_progress on first dispatch, keep them there while members work, and only move to in_review when overall completion is confirmed—matching ordinary agent status semantics without server auto-flips. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(squads): scope leader parent-status ownership to squad-assigned issues Review follow-up on the parent-status alignment change. Two boundaries were left ambiguous, both of which the change's own premise ("don't make the model resolve a contradiction in the prompt") argues should be closed in-place. 1. Status ownership was granted too widely. The leader briefing is injected on every leader path, keyed off is_leader_task — including the MUL-3724 case where an issue is assigned to a plain agent and a squad was merely @mentioned for help. The unqualified "Own the parent issue status" responsibility therefore also reached guest leaders, who could push another assignee's in-flight issue to in_review. buildSquadLeaderBriefing now takes ownsIssueStatus and selects between two variants of responsibility 6: the grant only when the issue's assignee is this squad, otherwise an explicit "do NOT change this issue's status". Quick-create passes false — no issue exists on that turn. Everything else in the protocol (roster, delegation, evaluation) is unchanged for both. 2. The comment-triggered path still contradicted itself. The runtime brief says "do not change status unless the comment explicitly asks", and a member's delivery comment never asks. Squads that dispatch by @mention create no child issues, so no child-done system comment exists to carry the explicit ask either — that parent would sit in in_progress indefinitely. writeWorkflowComment now names the protocol responsibility as the one exception for squad leaders. It is safe to state unconditionally because the grant is only present in the instructions when the server decided this squad owns the issue; for a guest leader the sentence has nothing to activate. Tests: two composition tests assemble both halves (server-side briefing + daemon-side CLAUDE.md) for one real scenario each, since asserting each half alone is how the original contradiction shipped. Plus execenv coverage that the carve-out appears only for leaders and the ordinary-agent rule stays absolute. Docs and the multica-squads skill / source map record the narrower contract. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
696 lines
32 KiB
Go
696 lines
32 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 idempotency guard 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) {
|
||
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)
|
||
// 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.
|
||
var closedStage int32
|
||
if staged {
|
||
closedStage = issue.Stage.Int32
|
||
}
|
||
h.postChildDoneComment(ctx, parent, issue, children, staged, closedStage, false)
|
||
}
|
||
|
||
// notifyParentsOfBatchChildDone emits child-done parent notifications for a
|
||
// whole batch AFTER every status write has committed. `completed` is the set of
|
||
// children that transitioned non-terminal -> terminal during the batch.
|
||
//
|
||
// Evaluating the stage barrier per-child inside the batch loop used the
|
||
// mid-batch sibling snapshot, so a batch that closed several stages at once
|
||
// fired one comment per intermediate stage: the first (stale) comment pinned the
|
||
// parent assignee's wake to an already-superseded "advance Stage N+1"
|
||
// instruction while the accurate final wake was swallowed by the pending-task
|
||
// dedup, and the outcome depended on issue_ids order (MUL-4155). Aggregating
|
||
// here makes the result order-independent — each affected parent gets at most
|
||
// one comment built from the final state, plus one wake pinned to that comment.
|
||
//
|
||
// Best-effort, mirroring notifyParentOfChildDone: a failure on one parent is
|
||
// logged and skipped; it never rolls back the committed batch.
|
||
func (h *Handler) notifyParentsOfBatchChildDone(ctx context.Context, completed []db.Issue) {
|
||
if len(completed) == 0 {
|
||
return
|
||
}
|
||
|
||
// Group the completed children by parent, preserving first-seen order so the
|
||
// emitted comments (and any test assertions) are deterministic.
|
||
type parentGroup struct {
|
||
parentID pgtype.UUID
|
||
children []db.Issue
|
||
}
|
||
var groups []*parentGroup
|
||
index := map[string]*parentGroup{}
|
||
for _, c := range completed {
|
||
if !c.ParentIssueID.Valid {
|
||
continue
|
||
}
|
||
key := uuidToString(c.ParentIssueID)
|
||
g, ok := index[key]
|
||
if !ok {
|
||
g = &parentGroup{parentID: c.ParentIssueID}
|
||
index[key] = g
|
||
groups = append(groups, g)
|
||
}
|
||
g.children = append(g.children, c)
|
||
}
|
||
|
||
for _, g := range groups {
|
||
parent, err := h.Queries.GetIssue(ctx, g.parentID)
|
||
if err != nil {
|
||
slog.Warn("batch child done: failed to load parent",
|
||
"error", err, "parent_id", uuidToString(g.parentID))
|
||
continue
|
||
}
|
||
// Same parent guards as the single path (see notifyParentOfChildDone).
|
||
if parent.Status == "done" || parent.Status == "cancelled" {
|
||
continue
|
||
}
|
||
if parent.Status == "backlog" {
|
||
continue
|
||
}
|
||
if parent.AssigneeType.Valid && parent.AssigneeType.String == "member" {
|
||
continue
|
||
}
|
||
|
||
children, err := h.Queries.ListChildIssues(ctx, parent.ID)
|
||
if err != nil {
|
||
slog.Warn("batch child done: failed to list siblings for stage barrier",
|
||
"error", err, "parent_id", uuidToString(parent.ID))
|
||
continue
|
||
}
|
||
|
||
batch := len(g.children) > 1
|
||
if !siblingsAreStaged(children) {
|
||
// Unstaged: one implicit stage. Fire once iff every child is terminal
|
||
// in the final state. stageBarrierClosed ignores `completed` on the
|
||
// unstaged path, so any completed child stands in for the barrier check.
|
||
if !stageBarrierClosed(children, g.children[0]) {
|
||
continue
|
||
}
|
||
h.postChildDoneComment(ctx, parent, g.children[0], children, false, 0, batch)
|
||
continue
|
||
}
|
||
|
||
// Staged: announce the HIGHEST stage among this batch's completed children
|
||
// whose barrier is closed in the final state. This is what makes the
|
||
// result order-independent — whether the caller sent [stage1, stage2] or
|
||
// [stage2, stage1], the final committed state is identical, so the same
|
||
// top stage wins and stageProgressSummary's "Stage N is next" reflects
|
||
// reality rather than a mid-batch snapshot. A lower closed stage would
|
||
// re-introduce the stale "advance the next stage" instruction the bug was
|
||
// about.
|
||
var rep db.Issue
|
||
var bestStage int32
|
||
found := false
|
||
for _, c := range g.children {
|
||
if !c.Stage.Valid {
|
||
continue // an unstaged child in a staged set closes no stage
|
||
}
|
||
if !stageBarrierClosed(children, c) {
|
||
continue
|
||
}
|
||
if !found || c.Stage.Int32 > bestStage {
|
||
found = true
|
||
bestStage = c.Stage.Int32
|
||
rep = c
|
||
}
|
||
}
|
||
if !found {
|
||
continue
|
||
}
|
||
h.postChildDoneComment(ctx, parent, rep, children, true, bestStage, batch)
|
||
}
|
||
}
|
||
|
||
// postChildDoneComment builds and posts the parent's child-done system comment
|
||
// for a closed stage barrier, then dispatches the parent-assignee trigger. It
|
||
// assumes every guard in notifyParentOfChildDone / notifyParentsOfBatchChildDone
|
||
// has already passed and that `completed` is a terminal child whose barrier is
|
||
// closed within `children` (the final sibling set).
|
||
//
|
||
// `completed` is the representative finished child named in the comment.
|
||
// `staged`/`closedStage` describe the closed barrier (closedStage is unused for
|
||
// an unstaged set). `batch` selects batch-aware wording: a single update keeps
|
||
// its historical byte-identical copy, while a batch that finished several
|
||
// children at once must not claim "the last sub-issue just finished".
|
||
func (h *Handler) postChildDoneComment(ctx context.Context, parent, completed db.Issue, children []db.Issue, staged bool, closedStage int32, batch bool) {
|
||
prefix := h.getIssuePrefix(ctx, completed.WorkspaceID)
|
||
identifier := prefix + "-" + strconv.Itoa(int(completed.Number))
|
||
childID := uuidToString(completed.ID)
|
||
title := sanitizeChildTitleForSystemComment(completed.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
|
||
if staged {
|
||
summary, nextStage := stageProgressSummary(children, closedStage)
|
||
advance := stageAdvanceInstruction(nextStage, parentID)
|
||
if batch {
|
||
content = fmt.Sprintf(
|
||
"%sStage %d of this issue is complete — its sub-issues just finished together in a batch update, most recently [%s](mention://issue/%s) — \"%s\". Stage progress — %s.%s",
|
||
mentionPrefix, closedStage, identifier, childID, title, summary, advance,
|
||
)
|
||
} else {
|
||
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 {
|
||
if batch {
|
||
content = fmt.Sprintf(
|
||
"%sAll sub-issues are complete — they just finished together in a batch update, most recently [%s](mention://issue/%s) — \"%s\". Continue the parent: synthesize the children's results and move it forward, or — if nothing remains — run `multica issue status %s in_review` to mark the parent ready for review.",
|
||
mentionPrefix, identifier, childID, title, parentID,
|
||
)
|
||
} 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 — if nothing remains — run `multica issue status %s in_review` to mark the parent ready for review.",
|
||
mentionPrefix, identifier, childID, title, parentID,
|
||
)
|
||
}
|
||
}
|
||
|
||
// 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, comment)
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// stageAdvanceInstruction returns the trailing instruction appended to a
|
||
// staged child-done system comment, given the next stage with pending work
|
||
// among the sub-issues that currently exist (nextStage, 0 = none).
|
||
//
|
||
// - nextStage > 0: a later stage with unfinished work already exists, so
|
||
// point the leader at it.
|
||
// - nextStage == 0: no later stage exists *among the sub-issues created so
|
||
// far*. This deliberately does NOT assert that the workflow is finished.
|
||
// The server has no declarative workflow model — stages are agent-driven
|
||
// and often created lazily (stage N+1's sub-issues are only written after
|
||
// stage N produces the inputs they depend on), so an intermediate stage in
|
||
// such a pipeline reaches nextStage == 0 exactly like a true final stage
|
||
// does. The old wording ("This was the final stage. Wrap up the parent")
|
||
// asserted a finality the server cannot know and pushed leaders to wrap up
|
||
// mid-workflow (MUL-4062 / #4927). The message now names both possibilities
|
||
// and hands the create-next-vs-wrap-up decision back to the leader.
|
||
func stageAdvanceInstruction(nextStage int32, parentID string) string {
|
||
if nextStage > 0 {
|
||
return 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,
|
||
)
|
||
}
|
||
return fmt.Sprintf(" Completing this stage does not mean the whole issue is done. Decide whether the issue is actually complete — if so, synthesize the results and run `multica issue status %s in_review` to mark the parent ready for review — or whether the next stage still needs to be created, in which case create that stage and its sub-issues now.", parentID)
|
||
}
|
||
|
||
// 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 the idempotency guard 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. The
|
||
// actor that closed the child is irrelevant to routing: the target is the
|
||
// parent's own leader, chosen (and permission-checked) at squad-assign
|
||
// time, so no actor identity is threaded in — see triggerChildDoneSquad.
|
||
// - 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.
|
||
// - NO self-trigger guard on either the agent OR the squad path. Waking the
|
||
// parent assignee when one of its children finishes is a serial sub-task
|
||
// handoff across two DIFFERENT issues, not a self-loop — legitimate per
|
||
// isAgentRunningOnIssue and the @mention self-trigger path
|
||
// (computeMentionedAgentCommentTriggers). The squad path used to skip a
|
||
// same-squad or shared-leader child on the theory that the leader had
|
||
// already observed the work through its own coordination cycle on the
|
||
// child. That stranded the common pattern where a squad decomposes its
|
||
// parent into sub-issues assigned to its own squad: the stage-barrier
|
||
// system comment lands on the PARENT carrying the "advance the next stage /
|
||
// wrap up" instruction, which a child-side wake never delivers — so the
|
||
// parent silently stalled in in_progress (MUL-3969). The squad path now
|
||
// mirrors the agent path (MUL-2808): always dispatch, bounded only by
|
||
// idempotency.
|
||
// - Idempotency: HasPendingTaskForIssueAndAgent dedupes rapid-fire enqueues
|
||
// for the same parent (e.g. two children finishing back-to-back). It also
|
||
// bounds any re-trigger, since a leader waking on the parent does not by
|
||
// itself push a child back into a terminal transition.
|
||
// - 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 db.Issue, systemComment db.Comment) {
|
||
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, systemComment.ID)
|
||
}
|
||
}
|
||
|
||
// 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,
|
||
// Key dedup on the reviewed head (TEN-356).
|
||
HeadSha: h.TaskService.ResolveIssueReviewSHAParam(ctx, parent.ID),
|
||
})
|
||
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. It mirrors the agent path (see triggerChildDoneAgent) exactly:
|
||
//
|
||
// - NO self-trigger guard: even when the finished child is owned by the same
|
||
// squad or by another squad sharing this leader, the leader must still be
|
||
// woken on the PARENT to advance the next stage or wrap up. The prior
|
||
// same-squad / shared-leader guards assumed the leader had already observed
|
||
// the child via its own coordination cycle, but that wake lands on the
|
||
// CHILD and never carries the parent-level stage-barrier instruction, so it
|
||
// stranded the common "squad decomposes its parent into sub-issues assigned
|
||
// to its own squad" pattern (MUL-3969).
|
||
// - NO leader-invocation gate. Waking the parent's OWN squad leader on
|
||
// child-done is a coordination handoff on an issue the leader already owns,
|
||
// not a fresh invocation — invocation permission was already enforced when
|
||
// the parent was assigned to the squad (validateAssigneePair). The agent
|
||
// path has never gated this. Re-checking it here on behalf of the child's
|
||
// completer — an agent/system actor with no resolvable human originator —
|
||
// failed closed for the DEFAULT private leader, silently stranding every
|
||
// process-squad pipeline after its first stage while direct-to-leader-agent
|
||
// parents advanced fine (MUL-4063 / GH #4928). Removed so agent and squad
|
||
// child-done follow one path; if invocation permission is ever reintroduced
|
||
// it must be added to BOTH paths together.
|
||
//
|
||
// Re-triggering is bounded by the HasPendingTaskForIssueAndAgent idempotency
|
||
// check below, exactly as the agent path relies on it.
|
||
func (h *Handler) triggerChildDoneSquad(ctx context.Context, parent db.Issue, triggerCommentID pgtype.UUID) {
|
||
squad, err := h.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{
|
||
ID: parent.AssigneeID,
|
||
WorkspaceID: parent.WorkspaceID,
|
||
})
|
||
if err != nil {
|
||
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,
|
||
// Key dedup on the reviewed head (TEN-356).
|
||
HeadSha: h.TaskService.ResolveIssueReviewSHAParam(ctx, parent.ID),
|
||
})
|
||
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))
|
||
}
|
||
}
|