Files
multica/server/internal/handler/squad.go
Naiyuan Qing 4ab335b8a5 MUL-3416: Issue pre-trigger preview + Handoff Note (#4383)
* feat(issues): unify run-enqueue decision behind WillEnqueueRun + preview endpoint

Collapse the issue update/batch enqueue copies into one service predicate
service.IssueService.WillEnqueueRun, shared verbatim with a new dry-run
endpoint POST /api/issues/preview-trigger so the four entry points stop
drifting (squad/self-loop/batch omissions, MUL-3375). The private-agent gate
stays at the HTTP boundary: write paths inject allow-all, preview injects the
real gate so it never leaks a private agent's readiness.

Add suppress_run to issue update/batch: the change applies but no run starts.
Remove the now-dead handler mirrors shouldEnqueueSquadLeaderOnAssign /
isSquadLeaderReady. service.Create and the comment trigger chain are untouched.

Tests: preview behavior, preview<->write-path match, batch aggregation,
member no-trigger, suppress_run skip, malformed-body 400.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(issues): inject handoff note into assigned runs via first-class task field

Add an optional handoff_note carried by issue assign/promote into the run's
opening prompt and issue_context.md, via a dedicated agent_task_queue column
(migration 122) and a daemon assignment-handoff render branch — never a
fabricated comment, never trigger_comment_id (MUL-3375 §6.1).

Thread the note through enqueueIssueTask/enqueueMentionTask + WithHandoff
public variants and dispatchIssueRun; suppress_run or a parked write drops it
(no run = nothing to inject). Soft version gate: MinHandoffCLIVersion +
HandoffSupported, surfaced per-trigger as handoff_supported in the preview so
the UI can gray the note box on old daemons; the assignment never hard-fails.

Tests: daemon prompt + issue_context render via the assignment branch (not
quick-create/comment), version helper matrix, note persists on the task,
suppressed assign enqueues nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(issues): leave a display-only handoff record on the timeline

When an assign/promote with a handoff note starts a run, write one
type='handoff' timeline record via TaskService.RecordHandoff — a direct
Queries.CreateComment + timeline event that bypasses Handler.CreateComment, so
it never reaches triggerTasksForComment and cannot start a second run
(MUL-3375 §6.2, the must-not-retrigger invariant). Author is the actor who
handed off; body is the note. Migration 123 admits the 'handoff' comment type.
Recorded only on a real run start: suppress_run or a parked write writes
nothing. enqueueSquadLeaderTask now reports whether it enqueued so the trace
is gated on an actual dispatch.

Test: exactly one handoff record on assign-with-note, exactly one task (no
re-trigger), and no record when suppressed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(issues): frontend plumbing for issue-trigger preview + handoff (core)

Add api.previewIssueTrigger + IssueTriggerPreviewSchema (zod parseWithFallback),
the use-issue-trigger-preview hook, issueKeys.issueTriggerPreview(+All) with WS
queue-state invalidation, suppress_run/handoff_note on UpdateIssueRequest, the
'handoff' CommentType, and stripping of the control fields from optimistic
update/batch cache patches (MUL-3375 §9).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): exclude handoff records from new-comment counting

type='handoff' is a display-only timeline record, not conversation. Exclude it
from CountNewCommentsSince so a handoff note never inflates the count of
"new comments to catch up on" fed to a claiming agent (MUL-3375 §12). Analytics
already excludes it (RecordHandoff is a direct write that emits no analytics
event), and the comment-trigger path is already bypassed.

Test: a handoff record does not bump the new-comment count; a real comment does.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(issues): pre-trigger preview UI, handoff note, timeline card (web/desktop)

Wire the §9 frontend onto the preview endpoint + handoff fields:
- Delete the backlog blocking dialog (backlog-agent-hint*) and its modal type;
  the over-eager nag is gone. Backlog awareness is now a passive label.
- RunConfirmModal: single assign + batch assign/status route here. Shows the
  backend predicate's verdict ("将启动 @X" / "将启动 N 个" / parked), an optional
  handoff note (assign only, soft-gated by handoff_supported), and 暂不启动 —
  then applies via update/batch. No frontend guessing.
- create modal: passive CreateRunHint ("将启动 @X" / backlog parked).
- single status change stays a direct apply (unchanged).
- timeline: render type='handoff' as a distinct, non-interactive handoff card.
- i18n run_confirm + handoff_card across en/ja/ko/zh-Hans; drop backlog action
  keys; locale parity green.

Tests: use-issue-actions (assign → run-confirm modal, member → direct),
create-issue + comment-card suites updated/green; views typecheck + lint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* test(issues): use a valid anchor in the handoff count-exclusion test

CountNewCommentsSince filters id <> @anchor_id; SQL id <> NULL is NULL and
excludes every row, so an empty anchor made the control assertion read 0. The
production caller always passes a real anchor — mirror that with a non-matching
sentinel uuid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* test(issues): RunConfirmModal apply logic (start/suppress/note-gate/batch)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* test(core): preview schema malformed/missing/null fallback coverage

Cover IssueTriggerPreviewSchema via parseWithFallback (MUL-3375): well-formed
parse, top-level + item default fills (empty/older backend), and fallback to
{ triggers: [], total_count: 0 } for malformed shapes, a dropped required
issue_id, a wrong-typed total_count, and null/non-object bodies — so the four
entry points degrade to "nothing will start" instead of throwing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* refactor(issues): remove display-only handoff timeline record (留痕)

The handoff "留痕" timeline record (type='handoff' comment written on run
start) was judged superfluous and dropped per product call. This removes
only the display-only trace; the handoff NOTE injection into the run's
opening prompt + issue_context.md is untouched.

- backend: drop RecordHandoff + its call in dispatchIssueRun
- db: drop the `type <> 'handoff'` exclusion in CountNewCommentsSince and
  migration 123 (comment_type_check reverts to the 4-type set from 001);
  no production data exists for this unreleased feature
- frontend: drop the "handoff" CommentType, HandoffCard, and handoff_card
  i18n (all locales)
- tests: drop handoff_count_test.go and the record-write assertions in
  issue_trigger_preview_test.go (note-injection tests retained)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(issues): dismissable run-confirm modal + team-handoff copy

Two fixes to the pre-trigger confirm modal (MUL-3375).

1. Dismissable: switch RunConfirmModal from AlertDialog to the standard
   shadcn Dialog so it has the close (X) button + Esc + click-outside.
   Previously the only choices were "start" / "don't start now" with no
   way to abort the action entirely; dismissing now cancels with no write.

2. Copy: rework the action-surface wording away from the backend term
   "run" toward team-handoff voice — 指派 / 开始 / 交接 (run stays only on
   record surfaces). Unifies the note's three names to "交接说明", and
   parallels the rewrite across en/ja/ko.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* chore(agent): bump handoff note min CLI version to 0.3.28

The daemon release that renders handoff notes ships in 0.3.28 (0.3.27
was the prior tag), so move the soft-gate threshold up. Below this the
note is silently dropped and the frontend grays the note box — assignment
is never blocked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(issues): skip run-confirm when batch-moving issues to backlog

A move into backlog never starts a run (service/issue_trigger.go), so the
pre-trigger confirm modal degenerated to an empty "won't start" box with a
single Apply button — pure friction. Apply directly instead, matching the
single-issue status path. Other target statuses still route through the
modal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(issues): refine pre-trigger preview hint and copy

- Move the create-issue run hint to a reveal band (grid 0fr→1fr) above the
  property toolbar. It was sharing the footer button row and, lacking a
  width constraint, reflowed the submit buttons whenever it appeared.
  Restyle to a borderless, comment-style avatar+caption that is purely a
  caption (non-interactive avatar).
- Distinguish squad from agent in the pre-trigger copy: a squad's leader
  evaluates and delegates rather than "starting work" itself. Add
  will_start_named_squad / will_start_squad / create_will_start_squad across
  en/zh/ja/ko (reusing the squad_leader_* evaluate→arrange vocabulary) and
  branch run-confirm + the create hint on squad assignees.
- Bold the assignee name in the run-confirm headline via a language-safe
  sentinel split (no per-language prefix/suffix keys).
- Align zh "开始处理" → "开始工作" on the single-assign copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(issues): stub ActorAvatar in create-issue suite

CreateRunHint now renders an ActorAvatar for agent/squad assignees, which
pulls in getActorInitials/getActorAvatarUrl + the workspace/presence/navigation
hook tree. This form-focused suite only stubbed getActorName, so the
squad-forwarding test crashed with "getActorInitials is not a function". Stub
the avatar inert — its own behavior is covered elsewhere.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-23 13:17:13 +08:00

988 lines
32 KiB
Go

package handler
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/analytics"
obsmetrics "github.com/multica-ai/multica/server/internal/metrics"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
// ── Response types ──────────────────────────────────────────────────────────
type SquadResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description string `json:"description"`
Instructions string `json:"instructions"`
AvatarURL *string `json:"avatar_url"`
LeaderID string `json:"leader_id"`
CreatorID string `json:"creator_id"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ArchivedAt *string `json:"archived_at"`
ArchivedBy *string `json:"archived_by"`
MemberCount int `json:"member_count"`
MemberPreview []SquadMemberPreviewResponse `json:"member_preview"`
}
type SquadMemberPreviewResponse struct {
MemberType string `json:"member_type"`
MemberID string `json:"member_id"`
Role string `json:"role"`
}
type squadMemberSummary struct {
count int
preview []SquadMemberPreviewResponse
}
type SquadMemberResponse struct {
ID string `json:"id"`
SquadID string `json:"squad_id"`
MemberType string `json:"member_type"`
MemberID string `json:"member_id"`
Role string `json:"role"`
CreatedAt string `json:"created_at"`
}
// ── Converters ──────────────────────────────────────────────────────────────
func squadToResponse(s db.Squad) SquadResponse {
return SquadResponse{
ID: uuidToString(s.ID),
WorkspaceID: uuidToString(s.WorkspaceID),
Name: s.Name,
Description: s.Description,
Instructions: s.Instructions,
AvatarURL: textToPtr(s.AvatarUrl),
LeaderID: uuidToString(s.LeaderID),
CreatorID: uuidToString(s.CreatorID),
CreatedAt: timestampToString(s.CreatedAt),
UpdatedAt: timestampToString(s.UpdatedAt),
ArchivedAt: timestampToPtr(s.ArchivedAt),
ArchivedBy: uuidToPtr(s.ArchivedBy),
MemberPreview: []SquadMemberPreviewResponse{},
}
}
func squadMemberToResponse(m db.SquadMember) SquadMemberResponse {
return SquadMemberResponse{
ID: uuidToString(m.ID),
SquadID: uuidToString(m.SquadID),
MemberType: m.MemberType,
MemberID: uuidToString(m.MemberID),
Role: m.Role,
CreatedAt: timestampToString(m.CreatedAt),
}
}
func addSquadMemberPreview(summary *squadMemberSummary, memberType string, memberID pgtype.UUID, role string) {
summary.count++
if len(summary.preview) >= 3 {
return
}
summary.preview = append(summary.preview, SquadMemberPreviewResponse{
MemberType: memberType,
MemberID: uuidToString(memberID),
Role: role,
})
}
func applySquadMemberSummary(resp *SquadResponse, summary *squadMemberSummary) {
if summary == nil {
return
}
resp.MemberCount = summary.count
resp.MemberPreview = summary.preview
}
// ── Helpers ─────────────────────────────────────────────────────────────────
// loadSquadInWorkspace loads a squad scoped to the current workspace.
func (h *Handler) loadSquadInWorkspace(w http.ResponseWriter, r *http.Request) (db.Squad, string, bool) {
workspaceID := workspaceIDFromURL(r, "workspaceId")
squadID := chi.URLParam(r, "id")
squadUUID, ok := parseUUIDOrBadRequest(w, squadID, "squad id")
if !ok {
return db.Squad{}, "", false
}
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
if !ok {
return db.Squad{}, "", false
}
squad, err := h.Queries.GetSquadInWorkspace(r.Context(), db.GetSquadInWorkspaceParams{
ID: squadUUID,
WorkspaceID: wsUUID,
})
if err != nil {
writeError(w, http.StatusNotFound, "squad not found")
return db.Squad{}, "", false
}
return squad, workspaceID, true
}
func (h *Handler) loadSquadMemberSummary(ctx context.Context, squadID pgtype.UUID) (*squadMemberSummary, error) {
rows, err := h.Queries.ListSquadMemberPreviewRowsBySquad(ctx, squadID)
if err != nil {
return nil, err
}
summary := &squadMemberSummary{}
for _, row := range rows {
addSquadMemberPreview(summary, row.MemberType, row.MemberID, row.Role)
}
return summary, nil
}
func (h *Handler) squadToResponseWithPreview(ctx context.Context, squad db.Squad) (SquadResponse, error) {
resp := squadToResponse(squad)
summary, err := h.loadSquadMemberSummary(ctx, squad.ID)
if err != nil {
return resp, err
}
applySquadMemberSummary(&resp, summary)
return resp, nil
}
// ── Handlers ────────────────────────────────────────────────────────────────
func (h *Handler) ListSquads(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "workspaceId")
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
if !ok {
return
}
squads, err := h.Queries.ListSquads(r.Context(), wsUUID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list squads")
return
}
previewRows, err := h.Queries.ListSquadMemberPreviewRows(r.Context(), wsUUID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list squad member preview")
return
}
summaries := make(map[string]*squadMemberSummary, len(squads))
for _, row := range previewRows {
squadID := uuidToString(row.SquadID)
summary := summaries[squadID]
if summary == nil {
summary = &squadMemberSummary{}
summaries[squadID] = summary
}
addSquadMemberPreview(summary, row.MemberType, row.MemberID, row.Role)
}
resp := make([]SquadResponse, len(squads))
for i, s := range squads {
resp[i] = squadToResponse(s)
applySquadMemberSummary(&resp[i], summaries[uuidToString(s.ID)])
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) CreateSquad(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "workspaceId")
member, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin")
if !ok {
return
}
var req struct {
Name string `json:"name"`
Description string `json:"description"`
LeaderID string `json:"leader_id"`
AvatarURL *string `json:"avatar_url"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if req.LeaderID == "" {
writeError(w, http.StatusBadRequest, "leader_id is required")
return
}
leaderUUID, ok := parseUUIDOrBadRequest(w, req.LeaderID, "leader_id")
if !ok {
return
}
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
if !ok {
return
}
// Validate leader is an agent in this workspace.
_, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
ID: leaderUUID,
WorkspaceID: wsUUID,
})
if err != nil {
writeError(w, http.StatusBadRequest, "leader must be a valid agent in this workspace")
return
}
avatarURL := pgtype.Text{}
if req.AvatarURL != nil {
avatarURL = pgtype.Text{String: *req.AvatarURL, Valid: true}
}
squad, err := h.Queries.CreateSquad(r.Context(), db.CreateSquadParams{
WorkspaceID: wsUUID,
Name: req.Name,
Description: req.Description,
LeaderID: leaderUUID,
CreatorID: member.UserID,
AvatarUrl: avatarURL,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create squad")
return
}
// Auto-add leader as a member with role "leader".
h.Queries.AddSquadMember(r.Context(), db.AddSquadMemberParams{
SquadID: squad.ID,
MemberType: "agent",
MemberID: leaderUUID,
Role: "leader",
})
resp, err := h.squadToResponseWithPreview(r.Context(), squad)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load squad member preview")
return
}
h.publish(protocol.EventSquadCreated, workspaceID, "member", uuidToString(member.UserID), map[string]any{"squad": resp})
obsmetrics.RecordEvent(h.Analytics, h.Metrics, analytics.SquadCreated(
uuidToString(member.UserID),
workspaceID,
uuidToString(squad.ID),
1,
))
writeJSON(w, http.StatusCreated, resp)
}
func (h *Handler) GetSquad(w http.ResponseWriter, r *http.Request) {
squad, _, ok := h.loadSquadInWorkspace(w, r)
if !ok {
return
}
resp, err := h.squadToResponseWithPreview(r.Context(), squad)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load squad member preview")
return
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) UpdateSquad(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "workspaceId")
if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !ok {
return
}
squad, _, ok := h.loadSquadInWorkspace(w, r)
if !ok {
return
}
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
if !ok {
return
}
var req struct {
Name *string `json:"name"`
Description *string `json:"description"`
Instructions *string `json:"instructions"`
LeaderID *string `json:"leader_id"`
AvatarURL *string `json:"avatar_url"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
params := db.UpdateSquadParams{ID: squad.ID}
if req.Name != nil {
params.Name = pgtype.Text{String: *req.Name, Valid: true}
}
if req.Description != nil {
params.Description = pgtype.Text{String: *req.Description, Valid: true}
}
if req.Instructions != nil {
params.Instructions = pgtype.Text{String: *req.Instructions, Valid: true}
}
if req.AvatarURL != nil {
params.AvatarUrl = pgtype.Text{String: *req.AvatarURL, Valid: true}
}
if req.LeaderID != nil {
lid, ok := parseUUIDOrBadRequest(w, *req.LeaderID, "leader_id")
if !ok {
return
}
// Validate new leader is an agent in workspace.
if _, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
ID: lid, WorkspaceID: wsUUID,
}); err != nil {
writeError(w, http.StatusBadRequest, "leader must be a valid agent in this workspace")
return
}
// Ensure new leader is a squad member; auto-add if not.
isMember, _ := h.Queries.IsSquadMember(r.Context(), db.IsSquadMemberParams{
SquadID: squad.ID, MemberType: "agent", MemberID: lid,
})
if !isMember {
h.Queries.AddSquadMember(r.Context(), db.AddSquadMemberParams{
SquadID: squad.ID, MemberType: "agent", MemberID: lid, Role: "leader",
})
}
params.LeaderID = lid
}
updated, err := h.Queries.UpdateSquad(r.Context(), params)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update squad")
return
}
resp, err := h.squadToResponseWithPreview(r.Context(), updated)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load squad member preview")
return
}
h.publish(protocol.EventSquadUpdated, workspaceID, "member", requestUserID(r), map[string]any{"squad": resp})
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) DeleteSquad(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "workspaceId")
if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !ok {
return
}
squad, _, ok := h.loadSquadInWorkspace(w, r)
if !ok {
return
}
if squad.ArchivedAt.Valid {
writeError(w, http.StatusBadRequest, "squad is already archived")
return
}
// Transfer issues assigned to this squad to the leader agent.
if err := h.Queries.TransferSquadAssignees(r.Context(), db.TransferSquadAssigneesParams{
AssigneeID: squad.ID,
AssigneeID_2: squad.LeaderID,
}); err != nil {
slog.Warn("transfer squad assignees failed", "squad_id", uuidToString(squad.ID), "error", err)
}
// Mirror the issue-assignee transfer for autopilots that target this
// squad. Without this, autopilot.assignee_id would still point at the
// archived squad row and every subsequent dispatch would skip with
// "assignee squad is archived" — visible to ops but useless to the
// owner. Rewriting to the leader keeps the autopilot semantics
// unchanged (Path A from MUL-2429 is leader-only execution anyway).
if err := h.Queries.TransferSquadAutopilotsToLeader(r.Context(), db.TransferSquadAutopilotsToLeaderParams{
AssigneeID: squad.ID,
AssigneeID_2: squad.LeaderID,
}); err != nil {
slog.Warn("transfer squad autopilots failed", "squad_id", uuidToString(squad.ID), "error", err)
}
userID := requestUserID(r)
userUUID, _ := parseUUIDOrBadRequest(w, userID, "user_id")
if _, err := h.Queries.ArchiveSquad(r.Context(), db.ArchiveSquadParams{
ID: squad.ID,
ArchivedBy: userUUID,
}); err != nil {
writeError(w, http.StatusInternalServerError, "failed to archive squad")
return
}
h.publish(protocol.EventSquadDeleted, workspaceID, "member", userID, map[string]any{
"squad_id": uuidToString(squad.ID),
"leader_id": uuidToString(squad.LeaderID),
})
w.WriteHeader(http.StatusNoContent)
}
// ── Squad Members ───────────────────────────────────────────────────────────
func (h *Handler) ListSquadMembers(w http.ResponseWriter, r *http.Request) {
squad, _, ok := h.loadSquadInWorkspace(w, r)
if !ok {
return
}
members, err := h.Queries.ListSquadMembers(r.Context(), squad.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list squad members")
return
}
resp := make([]SquadMemberResponse, len(members))
for i, m := range members {
resp[i] = squadMemberToResponse(m)
}
writeJSON(w, http.StatusOK, resp)
}
// ── Squad Member Status ────────────────────────────────────────────────────
// SquadMemberStatus is the per-member entry in the squad member status
// response. Agent members carry a derived working/idle/offline/unstable
// status plus any active issues; human members are returned with member_type
// only so the front-end can render them in the same list without
// reordering.
type SquadMemberStatusResponse struct {
MemberType string `json:"member_type"`
MemberID string `json:"member_id"`
Status *string `json:"status"`
ActiveIssues []SquadActiveIssueBrief `json:"active_issues"`
LastActiveAt *string `json:"last_active_at"`
}
type SquadActiveIssueBrief struct {
IssueID string `json:"issue_id"`
Identifier string `json:"identifier"`
Title string `json:"title"`
IssueStatus string `json:"issue_status"`
}
type SquadMemberStatusListResponse struct {
Members []SquadMemberStatusResponse `json:"members"`
}
// deriveSquadMemberStatus collapses runtime + task signals into the five
// status buckets used by the squad UI. Mirrors the workload+availability
// split in packages/core/agents/derive-presence.ts: working wins over
// runtime health (an agent that is in the middle of dispatched/running
// work counts as working even if the runtime briefly drops), then
// availability buckets decide between idle / unstable / offline.
//
// Thresholds match deriveRuntimeHealth: any offline runtime whose
// last_seen_at is within the last 5 minutes is reported as "unstable" so
// the squad UI surfaces transient drops the same way the agent dot does.
//
// Archived agents always report `archived` regardless of any leftover
// runtime row or task — they should appear in the list but never look
// like they're still working or merely offline (a leftover online
// runtime row would otherwise read as "offline" and hide the fact that
// the agent has been archived). Per the RFC decision (see MUL-2319), we
// surface archived agents in this endpoint rather than filtering them
// out in the SQL.
func deriveSquadMemberStatus(
archived bool,
runtimeStatus pgtype.Text,
lastSeen pgtype.Timestamptz,
hasActiveTask bool,
now time.Time,
) string {
if archived {
return "archived"
}
if hasActiveTask {
return "working"
}
if !runtimeStatus.Valid {
return "offline"
}
if runtimeStatus.String == "online" {
return "idle"
}
if !lastSeen.Valid {
return "offline"
}
if now.Sub(lastSeen.Time) < 5*time.Minute {
return "unstable"
}
return "offline"
}
// ListSquadMemberStatus returns one entry per squad member with derived
// status, the issues each agent member is currently running, and the last
// observed runtime activity. The endpoint is read-only and inherits the
// workspace-membership guard from the route middleware — any member of the
// workspace can read it.
func (h *Handler) ListSquadMemberStatus(w http.ResponseWriter, r *http.Request) {
squad, _, ok := h.loadSquadInWorkspace(w, r)
if !ok {
return
}
rows, err := h.Queries.ListSquadMemberStatusRows(r.Context(), squad.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list squad member status")
return
}
prefix := h.getIssuePrefix(r.Context(), squad.WorkspaceID)
now := time.Now()
// Group rows by member_id while preserving the SQL ORDER BY (squad_member
// insertion order). One member may appear in multiple rows when they have
// more than one active task.
type memberAcc struct {
response SquadMemberStatusResponse
archived bool
hasActiveTask bool
runtimeStatus pgtype.Text
runtimeSeenAt pgtype.Timestamptz
latestActiveAt pgtype.Timestamptz
}
order := make([]string, 0, len(rows))
acc := make(map[string]*memberAcc, len(rows))
for _, row := range rows {
memberID := uuidToString(row.MemberID)
entry, exists := acc[memberID]
if !exists {
entry = &memberAcc{
response: SquadMemberStatusResponse{
MemberType: row.MemberType,
MemberID: memberID,
ActiveIssues: []SquadActiveIssueBrief{},
},
archived: row.AgentArchivedAt.Valid,
runtimeStatus: row.RuntimeStatus,
runtimeSeenAt: row.RuntimeLastSeenAt,
}
acc[memberID] = entry
order = append(order, memberID)
}
if row.MemberType != "agent" {
continue
}
// A dispatched/running task occupies an agent slot even when it
// has no associated issue (chat / quick-create tasks set
// agent_task_queue.issue_id = NULL). The `working` bucket is
// defined by task presence, not by whether we can render an
// issue link, so flag the agent here regardless of issue_id.
if row.TaskID.Valid {
entry.hasActiveTask = true
if row.TaskIssueID.Valid {
brief := SquadActiveIssueBrief{
IssueID: uuidToString(row.TaskIssueID),
Identifier: prefix + "-" + strconv.Itoa(int(row.IssueNumber.Int32)),
Title: row.IssueTitle.String,
IssueStatus: func() string {
if row.IssueStatus.Valid {
return row.IssueStatus.String
}
return ""
}(),
}
entry.response.ActiveIssues = append(entry.response.ActiveIssues, brief)
}
if row.TaskDispatchedAt.Valid && (!entry.latestActiveAt.Valid ||
row.TaskDispatchedAt.Time.After(entry.latestActiveAt.Time)) {
entry.latestActiveAt = row.TaskDispatchedAt
}
}
}
resp := SquadMemberStatusListResponse{
Members: make([]SquadMemberStatusResponse, 0, len(order)),
}
for _, id := range order {
entry := acc[id]
if entry.response.MemberType == "agent" {
status := deriveSquadMemberStatus(
entry.archived,
entry.runtimeStatus,
entry.runtimeSeenAt,
entry.hasActiveTask,
now,
)
entry.response.Status = &status
// last_active_at prefers the freshest active-task dispatch
// over the runtime heartbeat: a working agent should not
// look stale because the runtime heartbeat is a few seconds
// behind. Falls back to runtime last_seen_at otherwise.
if entry.latestActiveAt.Valid {
entry.response.LastActiveAt = timestampToPtr(entry.latestActiveAt)
} else if entry.runtimeSeenAt.Valid {
entry.response.LastActiveAt = timestampToPtr(entry.runtimeSeenAt)
}
}
resp.Members = append(resp.Members, entry.response)
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) AddSquadMember(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "workspaceId")
if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !ok {
return
}
squad, _, ok := h.loadSquadInWorkspace(w, r)
if !ok {
return
}
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
if !ok {
return
}
var req struct {
MemberType string `json:"member_type"`
MemberID string `json:"member_id"`
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.MemberType != "agent" && req.MemberType != "member" {
writeError(w, http.StatusBadRequest, "member_type must be 'agent' or 'member'")
return
}
if req.MemberID == "" {
writeError(w, http.StatusBadRequest, "member_id is required")
return
}
memberUUID, ok := parseUUIDOrBadRequest(w, req.MemberID, "member_id")
if !ok {
return
}
// Validate the member belongs to this workspace.
if req.MemberType == "agent" {
if _, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
ID: memberUUID, WorkspaceID: wsUUID,
}); err != nil {
writeError(w, http.StatusBadRequest, "agent not found in this workspace")
return
}
} else {
if _, err := h.Queries.GetMemberByUserAndWorkspace(r.Context(), db.GetMemberByUserAndWorkspaceParams{
UserID: memberUUID, WorkspaceID: wsUUID,
}); err != nil {
writeError(w, http.StatusBadRequest, "member not found in this workspace")
return
}
}
sm, err := h.Queries.AddSquadMember(r.Context(), db.AddSquadMemberParams{
SquadID: squad.ID,
MemberType: req.MemberType,
MemberID: memberUUID,
Role: req.Role,
})
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "member already in squad")
return
}
writeError(w, http.StatusInternalServerError, "failed to add squad member")
return
}
writeJSON(w, http.StatusCreated, squadMemberToResponse(sm))
h.publish(protocol.EventSquadUpdated, workspaceID, "member", requestUserID(r), map[string]any{
"squad_id": uuidToString(squad.ID),
})
}
func (h *Handler) RemoveSquadMember(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "workspaceId")
if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !ok {
return
}
squad, _, ok := h.loadSquadInWorkspace(w, r)
if !ok {
return
}
var req struct {
MemberType string `json:"member_type"`
MemberID string `json:"member_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
memberUUID, ok := parseUUIDOrBadRequest(w, req.MemberID, "member_id")
if !ok {
return
}
// Prevent removing the leader.
if req.MemberType == "agent" && uuidToString(squad.LeaderID) == req.MemberID {
writeError(w, http.StatusBadRequest, "cannot remove the squad leader; change leader first")
return
}
rows, err := h.Queries.RemoveSquadMember(r.Context(), db.RemoveSquadMemberParams{
SquadID: squad.ID,
MemberType: req.MemberType,
MemberID: memberUUID,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to remove squad member")
return
}
if rows == 0 {
writeError(w, http.StatusNotFound, "squad member not found")
return
}
h.publish(protocol.EventSquadUpdated, workspaceID, "member", requestUserID(r), map[string]any{
"squad_id": uuidToString(squad.ID),
})
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) UpdateSquadMemberRole(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "workspaceId")
if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !ok {
return
}
squad, _, ok := h.loadSquadInWorkspace(w, r)
if !ok {
return
}
var req struct {
MemberType string `json:"member_type"`
MemberID string `json:"member_id"`
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
memberUUID, ok := parseUUIDOrBadRequest(w, req.MemberID, "member_id")
if !ok {
return
}
sm, err := h.Queries.UpdateSquadMemberRole(r.Context(), db.UpdateSquadMemberRoleParams{
SquadID: squad.ID,
MemberType: req.MemberType,
MemberID: memberUUID,
Role: req.Role,
})
if err != nil {
writeError(w, http.StatusNotFound, "squad member not found")
return
}
h.publish(protocol.EventSquadUpdated, workspaceID, "member", requestUserID(r), map[string]any{
"squad_id": uuidToString(squad.ID),
})
writeJSON(w, http.StatusOK, squadMemberToResponse(sm))
}
// ── Squad Leader Evaluation ──────────────────────────────────────────────────
// RecordSquadLeaderEvaluation records a squad leader's evaluation decision
// into the unified activity_log. Called by the leader agent via CLI after
// each trigger to record whether it took action, stayed silent, or failed.
func (h *Handler) RecordSquadLeaderEvaluation(w http.ResponseWriter, r *http.Request) {
issue, ok := h.loadIssueForUser(w, r, chi.URLParam(r, "id"))
if !ok {
return
}
var req struct {
Outcome string `json:"outcome"` // action | no_action | failed
Reason string `json:"reason"` // short explanation from leader
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Outcome != "action" && req.Outcome != "no_action" && req.Outcome != "failed" {
writeError(w, http.StatusBadRequest, "outcome must be 'action', 'no_action', or 'failed'")
return
}
// The issue must be assigned to a squad.
if !issue.AssigneeType.Valid || issue.AssigneeType.String != "squad" || !issue.AssigneeID.Valid {
writeError(w, http.StatusBadRequest, "issue is not assigned to a squad")
return
}
squad, err := h.Queries.GetSquadInWorkspace(r.Context(), db.GetSquadInWorkspaceParams{
ID: issue.AssigneeID,
WorkspaceID: issue.WorkspaceID,
})
if err != nil {
writeError(w, http.StatusNotFound, "squad not found")
return
}
// Security: only the squad leader agent can record evaluations.
workspaceID := uuidToString(issue.WorkspaceID)
userID := requestUserID(r)
actorType, actorID := h.resolveActor(r, userID, workspaceID)
if actorType != "agent" || actorID != uuidToString(squad.LeaderID) {
writeError(w, http.StatusForbidden, "only the squad leader agent can record evaluations")
return
}
taskID := r.Header.Get("X-Task-ID")
taskUUID, ok := parseUUIDOrBadRequest(w, taskID, "task id")
if !ok {
return
}
task, err := h.Queries.GetAgentTask(r.Context(), taskUUID)
if err != nil || !task.IssueID.Valid || uuidToString(task.IssueID) != uuidToString(issue.ID) {
writeError(w, http.StatusBadRequest, "task does not belong to issue")
return
}
details, _ := json.Marshal(map[string]string{
"squad_id": uuidToString(squad.ID),
"task_id": util.UUIDToString(taskUUID),
"outcome": req.Outcome,
"reason": req.Reason,
})
activity, err := h.Queries.CreateActivity(r.Context(), db.CreateActivityParams{
WorkspaceID: issue.WorkspaceID,
IssueID: issue.ID,
ActorType: pgtype.Text{String: "agent", Valid: true},
ActorID: squad.LeaderID,
Action: "squad_leader_evaluated",
Details: details,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to record evaluation")
return
}
h.publish(protocol.EventActivityCreated, uuidToString(issue.WorkspaceID), "agent", actorID, map[string]any{
"issue_id": uuidToString(issue.ID),
"entry": map[string]any{
"type": "activity",
"id": uuidToString(activity.ID),
"actor_type": "agent",
"actor_id": actorID,
"action": activity.Action,
"details": json.RawMessage(details),
"created_at": timestampToString(activity.CreatedAt),
},
})
writeJSON(w, http.StatusCreated, map[string]string{
"id": uuidToString(activity.ID),
"action": activity.Action,
"created_at": timestampToString(activity.CreatedAt),
})
}
// ── Squad Trigger Logic ─────────────────────────────────────────────────────
// lastTaskWasLeader returns true when the agent's most recent task on the
// issue was enqueued in the squad-leader role. Used by the self-trigger
// guards to tell apart a comment posted while the agent was acting as
// leader (skip) from one posted while it was acting as a worker (do not
// skip). When the agent has no prior task on this issue the role is
// undetermined and we treat it as non-leader so a brand-new external
// trigger can still reach the leader.
func (h *Handler) lastTaskWasLeader(ctx context.Context, issueID, agentID pgtype.UUID) bool {
flag, err := h.Queries.GetLatestTaskIsLeaderForIssueAndAgent(ctx, db.GetLatestTaskIsLeaderForIssueAndAgentParams{
IssueID: issueID,
AgentID: agentID,
})
if err != nil {
return false
}
return flag
}
// commentMentionsAnyone returns true when the comment body contains at least
// one routing-style mention — [@Name](mention://agent|member|squad|all/<id>).
// Issue cross-references (mention://issue/...) are ignored because they are
// not directed at a participant. Only the current comment is inspected —
// parent (thread root) mentions are NOT inherited here.
func commentMentionsAnyone(content string) bool {
for _, m := range util.ParseMentions(content) {
switch m.Type {
case "agent", "member", "squad", "all":
return true
}
}
return false
}
// The squad-leader assign/promotion readiness decision now lives in the single
// service.IssueService.WillEnqueueRun predicate (MUL-3375), shared by the issue
// write paths and the preview endpoint. The former handler-local mirrors
// (shouldEnqueueSquadLeaderOnAssign / isSquadLeaderReady) were removed to stop
// the four-entry-point drift. The squad enqueue side effect still flows through
// enqueueSquadLeaderTask below, which keeps the leader access gate and pending
// dedup in one place.
// enqueueSquadLeaderTask triggers the squad leader agent for an issue assigned
// to a squad. Assign and backlog-promotion paths use this directly; comment
// paths go through computeCommentAgentTriggers so preview and create share the
// same trigger set.
// enqueueSquadLeaderTask returns true when it actually enqueued a leader task
// (so the caller can record a handoff trace only on a real run start).
func (h *Handler) enqueueSquadLeaderTask(ctx context.Context, issue db.Issue, triggerCommentID pgtype.UUID, authorType, authorID, handoffNote string) bool {
squad, err := h.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{
ID: issue.AssigneeID,
WorkspaceID: issue.WorkspaceID,
})
if err != nil {
return false
}
if !h.canEnqueueSquadLeader(ctx, squad.LeaderID, authorType, authorID, uuidToString(issue.WorkspaceID)) {
return false
}
hasPending, err := h.Queries.HasPendingTaskForIssueAndAgent(ctx, db.HasPendingTaskForIssueAndAgentParams{
IssueID: issue.ID,
AgentID: squad.LeaderID,
})
if err != nil || hasPending {
return false
}
// triggerCommentID is always empty on the assign/promote path; the handoff
// note rides its own task column, never trigger_comment_id.
_ = triggerCommentID
if _, err := h.TaskService.EnqueueTaskForSquadLeaderWithHandoff(ctx, issue, squad.LeaderID, handoffNote); err != nil {
slog.Warn("enqueue squad leader task failed",
"issue_id", uuidToString(issue.ID),
"squad_id", uuidToString(squad.ID),
"leader_id", uuidToString(squad.LeaderID),
"error", err)
return false
}
return true
}