Files
multica/server/internal/handler/squad_briefing.go
5ed09342af MUL-7680: issue wakeup v2 — conditions, runaway protection, check-ins and visibility (#8807)
* feat(wakeups): add rule deadlines and expose the child-done system rule (MUL-7680)

Wakeups can now end: an absolute deadline (expires_at) or a relative wait
(expires_in_seconds) that restarts when the rule is re-enabled. When the
deadline passes first, the scheduler ends the rule; event rules with
on_timeout=wake run the target once with a wakeup.timeout fact. A timed-out
rule keeps disabled_at NULL so its timeout run stays claimable.

The implicit "wake the parent's assignee when a stage of sub-issues
finishes" behavior is now described by GET /api/issues/{id}/system-wakeups
and can be turned off or given a supplementary instruction per issue via
PUT /api/issues/{id}/system-wakeups/child_done. Rule reads fail open to the
previous behavior.

List responses add the creator (member or agent) and the new expiry fields.
The CLI gains --expires-in, --expires-at and --on-timeout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(issues): let members create wakeups and show the child-done system rule (MUL-7680)

The issue sidebar's Wakeups section is always available on open issues and
gains a New wakeup popover. Members pick a condition (a time, a recurring
check with an end date, someone's reply, an agent's run ending, or raw
events), the agent to wake, the instruction, how often it fires, how long
to wait and what happens on timeout. It posts the same configuration agents
create with the CLI.

The parent's child-done wake appears as a System rule with its stage,
remaining sub-issues and target, a per-issue toggle and a supplementary
instruction. Rows show when a rule ends, timed-out rules read as such, and
details name who created the rule. Rule titles wrap to two lines instead of
truncating.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* docs(wakeups): document deadlines, member-created rules and the system rule (MUL-7680)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(wakeups): platform conditions, runaway protection and check-ins (MUL-7680)

Conditions: a wakeup can now wait for a fact the platform checks itself
(a status, assignee, label or property value; sub-issues or one stage
finishing; a linked PR's checks finishing or merging; another issue's
status). Related events only prompt an early evaluation; the rule wakes
its agent when the predicate becomes true, and a repeating rule fires
again only after it turned false or its facts changed. Finished PR checks
from before registration are ignored.

Runaway protection: repeating event rules stop after max_fires runs
(default 20), a rule pauses when its causal chain passes through it a
third time without a person in between, or when it starts 12 runs in an
hour. The reason is stored and shown.

Also: silent check-ins for every/cron checks (the run then posts no
fallback comment), wake now, delete, a per-rule run history, paused-rule
lists, timeline entries for created/triggered/timed-out/paused/check-in,
and source, paused scope, 7-day runs and child-done system rows in the
workspace list. The CLI gains --until-* conditions, --max-fires and the
trigger/delete/checkin/runs commands; the brief and wakeup prompt state
the check-in exception.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(issues): record child-done transitions in the status write (MUL-7680)

The child-done system rule ran after the status write committed and gave
up on any error, so a crash, deploy or transient failure lost the
parent's wake. A trigger now records each child's move into a closed
status in the writing transaction, for every writer. The request that
wrote it processes the rows right away and a scheduler sweep retries
anything left over; claims make the two paths process a transition
once.

The rule also follows a workspace default (settings key
system_wakeup_child_done) until an issue sets its own, the per-issue
update accepts partial bodies, and each trigger adds a timeline entry
that names its system comment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(views): wakeup conditions, history, timeline and workspace list (MUL-7680)

Issue sidebar: members can create the platform-checked conditions (a field
reaching a value, sub-issues or a stage finishing, a linked PR's CI or merge,
another issue's status) and cap how often a repeating wait fires. Rule
details show the fire count, why the platform paused a rule, the latest runs
with silent-check notes, and offer wake now and delete.

The issue header says what the issue is waiting for ("Emacs 在等 Jiayuan 回复
+2") and opens the Wakeups section. Board and list cards use the same short
sentence, or flag a paused rule. Wakeup runs read as "由唤醒触发 · <condition>"
in the execution log, transcript and on the comments they post.

The timeline shows created, triggered, timed-out, paused and check-in entries
with the rule they came from; consecutive check-ins merge, and the child-done
entry stands in for its system comment, which it can reveal.

Automation → Issue wakeups adds a source column and filter, 7-day runs, a
paused scope with a banner, the child-done system rule on each waiting parent,
and "New wakeup" with an issue picker. Settings → Issue statuses sets the
system rule's workspace default. Copy in all five languages.

The server records the watched issue's identifier in other-issue conditions
and the rule's creator in timeline snapshots, and returns board summaries with
their condition.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* docs(wakeups): document conditions, runaway protection and check-ins (MUL-7680)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(wakeups): list system rules without a revision and localize statuses (MUL-7680)

System rule rows returned revision 0, which clients reject as a rule
revision; they now return null, and a list row with an invalid revision no
longer fails the whole page. Built-in statuses in conditions and the status
picker read in the viewer's language. Adds a browser test for a condition
created from the form, the header summary, the scheduler waking the agent
and the workspace list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(views): say why a rule paused without repeating "paused" (MUL-7680)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(i18n): use the product's task terms in wakeup copy (MUL-7680)

ja, ko and fr wakeup strings said イシュー / 이슈 / ticket; the product term
for an issue in those languages is タスク / 태스크 / tâche (and sub-tasks
accordingly), per the conventions page. French strings are rewritten for
the feminine noun.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* test(views): pin the shortcut platform in the wakeup form test (MUL-7680)

The send shortcut is primary+Enter, which is Ctrl on Linux CI runners, so
pressing Cmd+Enter only submitted on macOS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(views): simplify the workspace wakeup list

Put the filters on one row: scope as a segmented control on the left,
source/trigger/agent and a search-as-you-type box on the right. "New
wakeup" moves to the page header, which drops the separate Search button.

Rows are single-line: the identifier sits before the title, frequency and
timezone move into the trigger tooltip, and selection, prompt edit and
the last run's transcript show on row hover or focus. The issue column
takes the remaining width and truncates, so the table fits the page.

Also fix creating from the list: the issue picker closes itself right
after reporting the selection, and that close cancelled the flow before
the form could open.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* refactor(server): run the child_done rule as a system issue wakeup

The parent-assignee wake on closed sub-issue stages was its own code path:
a system comment, a mention-style run, an override table and endpoints.
It is now an ordinary issue_wakeup row with system_rule set, sharing the
condition, receipts, runaway protection, timeline entries and run model of
people's rules.

- One children_done evaluator: a stage wakes the assignee when it closes
  while a later stage waits; the wrap-up waits for every sub-issue,
  unstaged ones included. People's sub-issue conditions read the same set.
- issue_child_event records closing, reopening, re-parenting and restaging
  for every writer; the request processes it right after commit and the
  sweep retries. User sub-issue rules become immediate too.
- The target is resolved when it fires: an agent run, a squad leader run,
  an inbox notification for a member, or only the timeline entry.
- A parked (backlog) parent catches up when it leaves backlog; a waiting
  run of the same agent is joined instead of queuing a second one; the
  hourly limit pauses a runaway rule. No system comment is posted.
- The run gets the issue's instruction, else the workspace's, else the
  built-in one, plus each stage's counts and the next stage.
- Workspace defaults move to GET/PUT /api/system-wakeups (owners and
  admins) and apply to every rule nobody customized; turning a rule on
  records what already holds so old facts do not fire.
- Existing open parents get their rule from a one-time backfill.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(views): workspace wakeup settings and sub-issue notifications

- Settings → Wakeups holds the sub-issue rule's workspace default and
  default instruction; the toggle leaves Issue statuses.
- The issue's system rule shows who it reaches (a member is notified),
  a platform pause, and the instruction it will use.
- Timeline entries say whether the assignee was woken, notified or joined
  a waiting run. The folding of the old system comment is gone.
- Inbox renders the new children_done notification on web, desktop and
  mobile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(server): stop wakeups from repeating their agent's runs

Runs are serialized per issue and agent, so a wakeup never ran beside
another run of its agent, but it queued behind one and repeated it. Each
rule now checks two things before it starts a run:

- Acknowledged: every input came from the agent itself. Its own comments
  and issue changes never wake it, and a condition its own unfinished run
  on the issue satisfied does not wake it when the platform or the agent
  set the rule up. This fixes #8849: a coordinator closing its own stage is
  not woken again while that run is going. A person's condition rule still
  runs afterwards, since the running agent lacks its instruction.
- Merged: when a run of the agent is already waiting to start on the issue
  (assignment, comment, another rule), the rule's instruction and facts
  join that run (context.wakeup_joined, sent to the daemon as
  wakeup_joined and rendered for every prompt kind) instead of queuing
  another. Turning the rule off or replacing it withdraws what it joined.

Sub-issue changes now record the run that made them, and hints and
condition.met inputs carry those runs so a satisfied condition knows
whether the agent caused it. A rule that names the agent itself as the
actor keeps waking it on its own changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(views): show merged and acknowledged wakeups, hint assignee comment rules

- Timeline entries say when a wakeup joined the agent's waiting run or was
  the agent's own doing, for people's rules and the sub-issue rule.
- The create form tells a member that a reply or comment rule for the
  issue's agent assignee joins the run a comment already starts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(server): hand waiting wakeups to a run when it is claimed

A rule that fired while its agent had a run waiting used to write its
instruction into that run right away and consume its inputs. That let a
member's rule run under another member's identity, kept deleted rules'
instructions in the run, lost the inputs when the run was cancelled or
claimed by an older daemon, and skipped the fire cap and loop check.

Now the rule only waits for a run that runs as the same person its own
run would, keeping its inputs. When a daemon advertising
joined-wakeups-v1 claims that run, JoinWaitingWakeups rechecks each rule
(on, same agent and person, creator still allowed, not the agent's own
input, no loop), consumes its inputs, counts the fire toward max_fires,
adds its chain and records the merge. A re-claim drops entries of rules
turned off or changed since. Anything else leaves the rule its inputs to
start its own run.

A child_done rule created by the backfill or by processing a change now
treats sub-issues with unprocessed close events as still open, so the
backfill no longer swallows a close waiting for the sweep.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(server): consume joined wakeup inputs only once the run starts

A claim used to consume the inputs of the rules that joined the run. If
that claim did not go through, the run went back to the queue carrying
inputs the rules no longer had: turning the sub-issue rule off on the
issue or for the workspace (which only changes enabled) did not remove
them from the next claim, and cancelling the run lost a once rule that
had already ended.

A claim now reserves the inputs for the run (receipt task_id, still
unprocessed). The rule's next dispatch settles them: a run that started
consumes them as a merged firing (once ends, max_fires counts and can
pause, timeline entry); a run that ended without starting gives them
back; one that has not started keeps them. Turning a rule off or
changing it discards its pending inputs, reserved ones included, and a
later claim of the same run rechecks every entry and drops the ones
without reserved inputs or no longer allowed to reach the run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-27 21:08:54 +08:00

369 lines
17 KiB
Go

package handler
import (
"context"
"strings"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// squadOperatingProtocolHeader is the hard-coded system-level briefing
// prepended to every squad-leader claim. It explains the leader's coordinator
// role, the @mention dispatch mechanism, and the stop-after-dispatch contract.
// Responsibility 6 (parent issue status) is appended separately by
// squadOperatingProtocolFor — it is the only part that varies by whether this
// squad actually owns the issue.
//
// Keep this text English-only (matches existing agent-harness conventions)
// and keep the mention syntax exactly aligned with util.MentionRe — the
// "Squad Roster" block below renders concrete examples that round-trip
// through util.ParseMentions, and the protocol text refers to that format.
//
// Responsibility 5 is the single statement of the no_action rule (MUL-6984).
// The runtime brief's workflow step 4 and ## Output, and the per-turn reply
// imperative, carry only the EXCEPTION to their own instruction and point
// back here; before that split the rule existed in four copies that had
// already drifted on the MUL-6622 failure fallback. When the rule changes,
// it changes here. Do not restate it on another surface, and do not say so
// inside the text: this const is sent to the model on every leader turn, so
// a note addressed to maintainers is tokens the leader pays for and cannot
// act on.
const squadOperatingProtocolHeader = `## Squad Operating Protocol
**If you are reading this section, you have been activated as a squad LEADER
for this task — regardless of how the work reached you (direct assignment,
an @squad mention in a comment, quick-create, or autopilot).** Your job is to
**coordinate**, NOT to do the work yourself. Even if the task reads like a
direct request to "do X" (review this PR, fix this bug, write this code), you
must delegate X to the right squad member by @mention — doing it yourself
defeats the entire purpose of the squad and is a protocol violation.
Your responsibilities, in order:
1. **Read the issue** (title, description, latest comments, acceptance
criteria) and decide which squad member is best suited to do the work.
Match the task to each member's listed **skills** and role in the Squad
Roster below — prefer the member whose skills cover the work.
2. **Delegate by @mention.** Post a single comment on this issue that
@mentions the chosen member(s) and tells them what to do.
- **Be terse.** Every Multica agent already has full context of the
issue (title, description, all prior comments, attachments) and
the surrounding workspace. Do NOT restate or summarise the
issue body, prior discussion, or known facts in your delegation
comment — they read it themselves.
- Say only what cannot be inferred from the issue: who you're
picking, why them (one short clause), and any *additional*
constraints, hints, or sequencing you want them to follow.
Two or three sentences is usually plenty.
- Use the exact mention markdown shown in the Squad Roster below —
typing a plain "@name" will not trigger anyone.
3. **Record your evaluation.** After every trigger — whether you delegated,
decided no action is needed, or encountered an error — record it:
` + "`" + `multica squad activity <issue-id> <outcome> --reason "<short reason>"` + "`" + `
Outcome values: ` + "`" + `action` + "`" + ` (you delegated or acted),
` + "`" + `no_action` + "`" + ` (you evaluated and decided nothing is needed),
` + "`" + `failed` + "`" + ` (you hit an error).
This is mandatory on every turn — it records your decision in the
issue timeline so humans can see you evaluated the trigger.
Record it against the issue THIS turn is running on (the issue id in
your task context). It does not need to be assigned to your squad.
If the call fails, make sure the turn still leaves a record, without
breaking the one-comment-per-turn rule: post a short comment with the
outcome and the error ONLY if you have not already commented this turn
(the no_action case). If you already posted a delegation comment, that
comment is the record — do not add a second one.
4. **Stop after dispatching.** Once your delegation comment is posted
and evaluation recorded, end your turn. Do not continue working,
do not write code, do not open files. You will be re-triggered
automatically when:
- a delegated member posts an update or asks you a question;
- a delegated member finishes and the issue moves forward;
- someone @mentions you again on this issue.
5. **Re-evaluate on each trigger.** When you wake up again, read the new
activity and decide whether to delegate the next step, escalate to
the human reporter, or close the loop. If no action is needed
(e.g. a member posted a progress update that requires no response),
record ` + "`" + `no_action` + "`" + ` and exit silently. Exiting silently
means posting NO comment at all — not one announcing no_action, not
one acknowledging another agent, not one saying you are exiting. The
` + "`" + `squad activity` + "`" + ` call IS the record; a comment on top of
it is noise. That prohibition holds only while the call succeeds — if
it errors, responsibility 3 applies and the turn leaves one short
comment instead.`
// squadParentStatusOwned is responsibility 6 for the case where the issue this
// leader was woken on is assigned to THIS squad. Only then does the leader own
// the parent's status arc.
//
// Since MUL-6417 the runtime brief has no grant routing that consumes this
// section by name: the unified workflow writes status as the work changes it
// (in_progress when a turn starts advancing the issue's ask, the reached
// state at turn end), and the leader's only brief-side special case is that a
// dispatch turn leaves the parent in_progress. This section's
// job is the owning/guest permission boundary, drawn at the Agent Identity
// layer (Instruction Precedence puts it above the workflow). The owning
// leader needs the standing wrap-up instruction below — the @mention-dispatch
// shape (no child issues, so no sub-issue wakeup) never produces a
// trigger that asks for in_review, so without it the parent would sit in
// in_progress forever; the guest leader gets the prohibition instead
// (squadParentStatusNotOwned). Both compositions are pinned by
// handler/squad_parent_status_contract_test.go.
const squadParentStatusOwned = `6. **Own the parent issue status.** This issue is assigned to your squad,
so its status is yours to manage (unless Agent Identity forbids status
changes). On the first assignment turn, move the parent to
` + "`" + `in_progress` + "`" + ` and keep it there while members work — a successful
dispatch is not completion. On later turns, do not flip status for
routine progress updates. When you confirm the overall goal is met, run
` + "`" + `multica issue status <issue-id> in_review` + "`" + ` — this responsibility is
itself the standing instruction that authorizes that change, so do it even
when no comment asked you to. Leave ` + "`" + `done` + "`" + ` to a human reviewer or
existing integrations (for example a PR with close intent that merges).`
// squadParentStatusNotOwned is responsibility 6 for every other leader path:
// an @squad mention on an issue owned by someone else (MUL-3724), and
// quick-create, where no issue exists yet on this turn. Granting status
// ownership there would let a squad that was merely pulled in to answer a
// question push another assignee's in-flight issue to in_review.
const squadParentStatusNotOwned = `6. **Do NOT change this issue's status.** This issue is not assigned to your
squad — you were pulled in by an @mention (or this is a quick-create turn,
where the issue does not exist yet). Its status belongs to its own
assignee. Answer, delegate, or escalate as usual, but never run
` + "`" + `multica issue status` + "`" + ` on it, no matter how complete the work looks
to you.`
const squadOperatingProtocolHardRules = `Hard rules:
- EVERY delegation MUST use the full mention markdown syntax
` + "`" + `[@Name](mention://<type>/<UUID>)` + "`" + ` exactly as shown in the Squad
Roster. A plain "@name" or bare name does NOT trigger the agent —
if you skip the mention link, the task is never delivered and the
issue stalls. This is non-negotiable: no mention link = no delegation.
- Do NOT restate the issue body or prior comments in your delegation —
the assignee already has them. Repeating context is noise that
buries the actual instruction.
- Do NOT do the implementation work yourself unless the squad has no
other suitable members. The squad exists so work is split — bypassing
it defeats the point.
- Do NOT @mention members who don't appear in the Squad Roster below;
they are not part of this squad.
- One delegation comment per turn is enough. Avoid spamming multiple
near-identical comments.
- If the squad has no member capable of the task, post a comment
explaining the gap (and @mention the issue's reporter if possible)
rather than silently doing the work.
- ALWAYS call ` + "`" + `multica squad activity` + "`" + ` before ending your turn —
even when the outcome is no_action. If it errors on a turn where you
posted no comment, leave one short comment instead; never let an
evaluation end with no record at all, and never post a second comment
just to report the error.
- A child issue you create with ` + "`" + `--status todo` + "`" + ` and an agent assignee
already fires that agent automatically — the assignment IS the trigger.
If you also @mention the same agent on this parent issue for the same
work, the agent runs twice in parallel (once from the mention, once
from the assignment). Pick exactly one path: either delegate by
@mention on this issue, or create a ` + "`" + `todo` + "`" + ` child issue assigned to
them. Never both for the same work.`
// squadOperatingProtocolFor assembles the protocol, selecting the parent-status
// responsibility that matches this leader's actual authority over the issue.
func squadOperatingProtocolFor(ownsIssueStatus bool) string {
status := squadParentStatusNotOwned
if ownsIssueStatus {
status = squadParentStatusOwned
}
return squadOperatingProtocolHeader + "\n" + status + "\n\n" + squadOperatingProtocolHardRules
}
// buildSquadLeaderBriefing composes the full system briefing appended to a
// squad leader's Instructions when it claims a task on a squad-assigned
// issue. The returned string contains three sections:
//
// 1. Squad Operating Protocol (constant, system-level rules).
// 2. Squad Roster (data — leader self-row + members with literal
// `[@Name](mention://<type>/<UUID>)` strings ready to paste).
// 3. Squad Instructions (user-defined `squad.instructions`, omitted when
// empty so we don't leave a dangling heading).
//
// ownsIssueStatus must be true only when the issue this task is bound to is
// assigned to this very squad. The briefing is injected on every leader path,
// including ones where the squad is a guest on someone else's issue, so this
// flag is what keeps status authority from leaking along with the roster.
//
// Archived agent members are skipped — there's no point asking the leader
// to delegate to a retired agent. Members whose underlying record can't be
// loaded (deleted user/agent races, FK weirdness) are also skipped silently.
func buildSquadLeaderBriefing(ctx context.Context, q *db.Queries, squad db.Squad, ownsIssueStatus bool) string {
var sb strings.Builder
sb.WriteString(squadOperatingProtocolFor(ownsIssueStatus))
sb.WriteString("\n\n")
sb.WriteString(buildSquadRoster(ctx, q, squad))
if trimmed := strings.TrimSpace(squad.Instructions); trimmed != "" {
sb.WriteString("\n\n## Squad Instructions (")
sb.WriteString(squad.Name)
sb.WriteString(")\n\n")
sb.WriteString(trimmed)
}
return sb.String()
}
// buildSquadRoster renders the "## Squad Roster" section: a leader self-row
// plus one row per non-archived member, with literal mention markdown.
func buildSquadRoster(ctx context.Context, q *db.Queries, squad db.Squad) string {
var sb strings.Builder
sb.WriteString("## Squad Roster\n\n")
// Leader self-row. Leaders are always agents (FK enforced in schema).
leaderName := "Leader"
if leader, err := q.GetAgent(ctx, squad.LeaderID); err == nil {
leaderName = leader.Name
}
sb.WriteString("Leader (you):\n")
sb.WriteString("- ")
sb.WriteString(leaderName)
sb.WriteString(" — agent — `")
sb.WriteString(formatMention(leaderName, "agent", util.UUIDToString(squad.LeaderID)))
sb.WriteString("`\n")
members, err := q.ListSquadMembers(ctx, squad.ID)
if err != nil {
members = nil
}
skillNamesByAgentID, skillsLoaded := loadSquadMemberSkillNames(ctx, q, members, util.UUIDToString(squad.LeaderID))
rows := make([]string, 0, len(members))
for _, m := range members {
// Skip the leader if they happen to also be in the member list —
// they're already shown above and we don't want self-delegation.
if m.MemberType == "agent" && util.UUIDToString(m.MemberID) == util.UUIDToString(squad.LeaderID) {
continue
}
row := renderMemberRow(ctx, q, m, skillNamesByAgentID, skillsLoaded)
if row != "" {
rows = append(rows, row)
}
}
if len(rows) == 0 {
sb.WriteString("\nMembers: (none — you are the only member of this squad)\n")
return sb.String()
}
sb.WriteString("\nMembers:\n")
for _, r := range rows {
sb.WriteString(r)
}
return sb.String()
}
func loadSquadMemberSkillNames(ctx context.Context, q *db.Queries, members []db.SquadMember, leaderID string) (map[string][]string, bool) {
agentIDs := make([]pgtype.UUID, 0)
seen := make(map[string]struct{}, len(members))
for _, m := range members {
if m.MemberType != "agent" {
continue
}
id := util.UUIDToString(m.MemberID)
if id == leaderID {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
agentIDs = append(agentIDs, m.MemberID)
}
if len(agentIDs) == 0 {
return map[string][]string{}, true
}
rows, err := q.ListAgentSkillNamesByAgentIDs(ctx, agentIDs)
if err != nil {
return nil, false
}
byAgentID := make(map[string][]string, len(agentIDs))
for _, row := range rows {
id := util.UUIDToString(row.AgentID)
byAgentID[id] = append(byAgentID[id], row.Name)
}
return byAgentID, true
}
// renderMemberRow renders a single roster row, returning "" if the member
// can't be resolved or should be skipped (e.g. archived agent).
func renderMemberRow(ctx context.Context, q *db.Queries, m db.SquadMember, skillNamesByAgentID map[string][]string, skillsLoaded bool) string {
id := util.UUIDToString(m.MemberID)
role := strings.TrimSpace(m.Role)
switch m.MemberType {
case "agent":
ag, err := q.GetAgent(ctx, m.MemberID)
if err != nil {
return ""
}
if ag.ArchivedAt.Valid {
return ""
}
// Agents carry skills; surfacing them lets the leader delegate by
// capability instead of guessing from the free-text role label.
return formatRosterRow(ag.Name, "agent", role, agentSkillsRosterSegment(skillNamesByAgentID, skillsLoaded, id), formatMention(ag.Name, "agent", id))
case "member":
user, err := q.GetUser(ctx, m.MemberID)
if err != nil {
return ""
}
// Mention syntax for humans uses the user_id (matches the rest of
// the product — see util.MentionRe and frontend mention payloads).
// Humans have no Multica skills, so no skills segment is rendered.
userID := util.UUIDToString(m.MemberID)
return formatRosterRow(user.Name, "member (human)", role, "", formatMention(user.Name, "member", userID))
default:
return ""
}
}
// agentSkillsRosterSegment returns the roster segment describing an agent
// member's assigned skills. "skills: a, b" when the agent has skills (the
// names are pre-sorted by ListAgentSkillNamesByAgentIDs), "no skills assigned"
// when it has none so the leader knows the capability is genuinely absent, and
// "" only when the lookup fails — a transient DB error degrades to the prior
// name+role row rather than asserting a misleading "no skills".
func agentSkillsRosterSegment(skillNamesByAgentID map[string][]string, skillsLoaded bool, agentID string) string {
if !skillsLoaded {
return ""
}
names := skillNamesByAgentID[agentID]
if len(names) == 0 {
return "no skills assigned"
}
return "skills: " + strings.Join(names, ", ")
}
func formatRosterRow(name, kind, role, skills, mention string) string {
var sb strings.Builder
sb.WriteString("- ")
sb.WriteString(name)
sb.WriteString(" — ")
sb.WriteString(kind)
if role != "" {
sb.WriteString(`, role: "`)
sb.WriteString(role)
sb.WriteString(`"`)
}
if skills != "" {
sb.WriteString(" — ")
sb.WriteString(skills)
}
sb.WriteString(" — `")
sb.WriteString(mention)
sb.WriteString("`\n")
return sb.String()
}
// formatMention emits a mention markdown string that round-trips through
// util.ParseMentions. The label is the human display name; the link target
// uses the mention:// scheme with the entity type and UUID.
func formatMention(name, mentionType, id string) string {
return "[@" + name + "](mention://" + mentionType + "/" + id + ")"
}