Files
multica/server/internal/handler/issue_child_done.go
LinYushen 3692b6a862 fix(squad): inject leader briefing by task flag, not issue assignee (MUL-3730) (#4606)
* 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>
2026-06-26 16:01:33 +08:00

568 lines
25 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 assigneemember/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)
}