mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-06 19:00:01 +02:00
* feat(issues): mention parent assignee in child-done system comment (MUL-2538)
Per Bohan's product call on MUL-2538 ("方案 C"), the platform's child-done
system comment now @mentions the parent assignee — member, squad, or
agent — and the platform fires the matching side effect explicitly:
- agent → mention task via TaskService.EnqueueTaskForMention
- squad → leader task via TaskService.EnqueueTaskForSquadLeader
- member → 'mentioned' inbox row + EventInboxNew broadcast
The generic comment listener still short-circuits on author_type='system'
(see notification_listeners.go) so smuggled mention links in the child
title can never light up unrelated members; the parent assignee mention
is the only side effect, and it is fired from the handler with explicit
guards rather than the listener path.
Guards retained / added:
- Comment-fire gates from prior PR unchanged (status transition, parent
state, no parent).
- Loop guard: skip trigger when child and parent share the same assignee
(same agent / same squad / same member). The comment + mention still
render so the timeline tells the full story; the second task does not
fire.
- Idempotency: HasPendingTaskForIssueAndAgent dedupes rapid-fire enqueues
for the same parent (back-to-back child completions).
- Readiness: archived agents / missing runtimes are silently skipped.
Tests:
- TestChildDoneMentionsParentAssignee_{Agent,Member,Squad} verify the
mention link + the matching trigger / inbox row.
- TestChildDoneSelfTriggerGuard_SameAgent asserts that an agent assigned
to both the child and the parent gets the comment + mention but no
second task — the documented loop break.
- TestChildDoneNotifiesParent updated: when the parent has no assignee
(its existing fixture), no routing mention should appear; the assigned
branches are exercised by the new cases above.
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): skip child-done parent notification for human assignees (MUL-2538)
Humans read their own timeline manually — an automated system comment
is pure noise for member-assigned parents, and there is no agent task
to trigger. Skipping the notification entirely also removes the mention
question (no comment → no mention → no inbox row).
The agent / squad / unassigned branches stay unchanged.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): close cross-squad shared-leader loop in child-done dispatch (MUL-2538)
Elon's review of PR #3065 flagged that triggerChildDoneAgent and
triggerChildDoneSquad only compared the child's direct assignee, so a
child-done event could still wake the same agent when:
- parent assigned to agent A, child assigned to a squad whose leader is A;
- parent and child assigned to two different squads sharing the same
leader agent.
Replace the per-side checks with a single effectiveChildAgentOwner helper
that reduces the child to "the agent that would actually act on it" (the
agent assignee, or the squad's leader) and lets both trigger paths compare
apples to apples. Add coverage for both newly-blocked cases, and tighten
the documented side-effect semantics (squad triggers leader only — no
member fan-out; notification_preference is not consulted, downstream
agent_task / inbox pipeline still respects mutes).
Also fix the member-skip test fixture to write user_id, matching the
production invariant that issue.assignee_id for assignee_type='member'
references user_id (validateAssigneePair, server/internal/handler/issue.go).
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
377 lines
16 KiB
Go
377 lines
16 KiB
Go
package handler
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log/slog"
|
||
"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:
|
||
// - prev.Status must not already be "done" (idempotent — repeat saves of
|
||
// done do not re-fire; only the transition fires)
|
||
// - issue.Status must be "done"
|
||
// - 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 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 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) {
|
||
if !issue.ParentIssueID.Valid {
|
||
return
|
||
}
|
||
if prev.Status == "done" || issue.Status != "done" {
|
||
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
|
||
}
|
||
// 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
|
||
}
|
||
|
||
prefix := h.getIssuePrefix(ctx, issue.WorkspaceID)
|
||
identifier := prefix + "-" + strconv.Itoa(int(issue.Number))
|
||
childID := uuidToString(issue.ID)
|
||
title := sanitizeChildTitleForSystemComment(issue.Title)
|
||
|
||
// 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)
|
||
|
||
content := fmt.Sprintf(
|
||
"%sSub-issue [%s](mention://issue/%s) — \"%s\" — is done. Confirm whether to advance the next step on this parent (and promote any waiting `backlog` sub-issues).",
|
||
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)
|
||
}
|
||
|
||
// 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.
|
||
// - Loop guard: skip when the agent that effectively "owns" the child
|
||
// (its agent assignee, or the leader of its squad assignee) is the same
|
||
// agent the parent would trigger (the parent agent itself, or the
|
||
// parent squad's leader). Without this an agent that drives both child
|
||
// and parent immediately re-runs on the parent and can post another
|
||
// child, looping — including the cross-squad case where two different
|
||
// squads share a leader.
|
||
// - 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) {
|
||
if !parent.AssigneeType.Valid || !parent.AssigneeID.Valid {
|
||
return
|
||
}
|
||
|
||
switch parent.AssigneeType.String {
|
||
case "agent":
|
||
h.triggerChildDoneAgent(ctx, parent, child, systemComment.ID)
|
||
case "squad":
|
||
h.triggerChildDoneSquad(ctx, parent, child, systemComment.ID)
|
||
}
|
||
}
|
||
|
||
// triggerChildDoneAgent enqueues a mention-style task for the parent's
|
||
// agent assignee, applying the self-trigger guard documented on
|
||
// dispatchParentAssigneeTrigger.
|
||
func (h *Handler) triggerChildDoneAgent(ctx context.Context, parent, child db.Issue, triggerCommentID pgtype.UUID) {
|
||
if owner := h.effectiveChildAgentOwner(ctx, child); owner.Valid &&
|
||
uuidToString(owner) == uuidToString(parent.AssigneeID) {
|
||
return
|
||
}
|
||
|
||
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) {
|
||
squad, err := h.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{
|
||
ID: parent.AssigneeID,
|
||
WorkspaceID: parent.WorkspaceID,
|
||
})
|
||
if err != nil {
|
||
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, 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)
|
||
}
|