- {/* Agent */}
+ {/* Assignee — agent or squad */}
-
+
- {getActorName("agent", autopilot.assignee_id)}
+ {getActorName(autopilot.assignee_type, autopilot.assignee_id)}
diff --git a/packages/views/autopilots/components/pickers/agent-picker.tsx b/packages/views/autopilots/components/pickers/agent-picker.tsx
index 6af8e9e59..6abc5d191 100644
--- a/packages/views/autopilots/components/pickers/agent-picker.tsx
+++ b/packages/views/autopilots/components/pickers/agent-picker.tsx
@@ -1,28 +1,35 @@
"use client";
-import { useState } from "react";
+import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Bot } from "lucide-react";
import { useWorkspaceId } from "@multica/core/hooks";
-import { agentListOptions } from "@multica/core/workspace/queries";
+import { agentListOptions, squadListOptions } from "@multica/core/workspace/queries";
+import type { AutopilotAssigneeType } from "@multica/core/types";
import { ActorAvatar } from "../../../common/actor-avatar";
import {
PropertyPicker,
PickerItem,
+ PickerSection,
PickerEmpty,
} from "../../../issues/components/pickers/property-picker";
import { useT } from "../../../i18n";
import { matchesPinyin } from "../../../editor/extensions/pinyin-match";
+export interface AssigneeSelection {
+ type: AutopilotAssigneeType;
+ id: string;
+}
+
export function AgentPicker({
- agentId,
+ assignee,
onChange,
trigger: customTrigger,
triggerRender,
align = "start",
}: {
- agentId: string | null;
- onChange: (id: string) => void;
+ assignee: AssigneeSelection | null;
+ onChange: (next: AssigneeSelection) => void;
trigger?: React.ReactNode;
triggerRender?: React.ReactElement;
align?: "start" | "center" | "end";
@@ -32,13 +39,30 @@ export function AgentPicker({
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState("");
const { data: agents = [] } = useQuery(agentListOptions(wsId));
- const active = agents.filter((a) => !a.archived_at);
- const selected = active.find((a) => a.id === agentId);
+ const { data: squads = [] } = useQuery(squadListOptions(wsId));
+
+ const activeAgents = useMemo(() => agents.filter((a) => !a.archived_at), [agents]);
+ const activeSquads = useMemo(() => squads.filter((s) => !s.archived_at), [squads]);
+
+ const selectedAgent =
+ assignee?.type === "agent" ? activeAgents.find((a) => a.id === assignee.id) : undefined;
+ const selectedSquad =
+ assignee?.type === "squad" ? activeSquads.find((s) => s.id === assignee.id) : undefined;
+ const selectedName = selectedAgent?.name ?? selectedSquad?.name;
const query = filter.trim().toLowerCase();
- const filteredAgents = query
- ? active.filter((a) => a.name.toLowerCase().includes(query) || matchesPinyin(a.name, query))
- : active;
+ const matches = (name: string) =>
+ !query || name.toLowerCase().includes(query) || matchesPinyin(name, query);
+ const filteredAgents = activeAgents.filter((a) => matches(a.name));
+ const filteredSquads = activeSquads.filter((s) => matches(s.name));
+
+ const isSelected = (type: AutopilotAssigneeType, id: string) =>
+ assignee?.type === type && assignee?.id === id;
+
+ const handlePick = (type: AutopilotAssigneeType, id: string) => {
+ onChange({ type, id });
+ setOpen(false);
+ };
return (
- {selected ? (
+ {assignee && (selectedAgent || selectedSquad) ? (
<>
-
- {selected.name}
+
+ {selectedName}
>
) : (
<>
- {t(($) => $.agent_picker.select_agent)}
+ {t(($) => $.agent_picker.select_assignee)}
>
)}
>
)
}
>
- {filteredAgents.length === 0 ? (
+ {filteredAgents.length === 0 && filteredSquads.length === 0 ? (
) : (
- filteredAgents.map((a) => (
- {
- onChange(a.id);
- setOpen(false);
- }}
- >
-
- {a.name}
-
- ))
+ <>
+ {filteredAgents.length > 0 && (
+ $.agent_picker.agents_group)}>
+ {filteredAgents.map((a) => (
+ handlePick("agent", a.id)}
+ >
+
+ {a.name}
+
+ ))}
+
+ )}
+ {filteredSquads.length > 0 && (
+ $.agent_picker.squads_group)}>
+ {filteredSquads.map((s) => (
+ handlePick("squad", s.id)}
+ >
+
+ {s.name}
+
+ ))}
+
+ )}
+ >
)}
);
diff --git a/packages/views/locales/en/autopilots.json b/packages/views/locales/en/autopilots.json
index f359b9ec6..8ee219602 100644
--- a/packages/views/locales/en/autopilots.json
+++ b/packages/views/locales/en/autopilots.json
@@ -238,7 +238,9 @@
"toast_update_partial_with_reason": "Autopilot updated, but schedule failed to save: {{reason}}",
"toast_update_failed": "Failed to update autopilot",
"section_agent": "Agent",
+ "section_assignee": "Assignee",
"select_agent": "Select agent",
+ "select_assignee": "Select agent or squad",
"section_output_mode": "Output mode",
"section_schedule": "Schedule",
"section_trigger_kind": "Trigger",
@@ -326,8 +328,11 @@
}
},
"agent_picker": {
- "filter_placeholder": "Filter agents...",
- "select_agent": "Select agent"
+ "filter_placeholder": "Filter agents or squads...",
+ "select_agent": "Select agent",
+ "select_assignee": "Select agent or squad",
+ "agents_group": "Agents",
+ "squads_group": "Squads"
},
"timezone_picker": {
"search_placeholder": "Search timezone..."
diff --git a/packages/views/locales/zh-Hans/autopilots.json b/packages/views/locales/zh-Hans/autopilots.json
index 992ff7235..cd59f1470 100644
--- a/packages/views/locales/zh-Hans/autopilots.json
+++ b/packages/views/locales/zh-Hans/autopilots.json
@@ -238,7 +238,9 @@
"toast_update_partial_with_reason": "自动化已更新,但时间表保存失败:{{reason}}",
"toast_update_failed": "更新自动化失败",
"section_agent": "智能体",
+ "section_assignee": "执行方",
"select_agent": "选择智能体",
+ "select_assignee": "选择智能体或小队",
"section_output_mode": "输出模式",
"section_schedule": "时间表",
"section_trigger_kind": "触发方式",
@@ -326,8 +328,11 @@
}
},
"agent_picker": {
- "filter_placeholder": "筛选智能体...",
- "select_agent": "选择智能体"
+ "filter_placeholder": "筛选智能体或小队...",
+ "select_agent": "选择智能体",
+ "select_assignee": "选择智能体或小队",
+ "agents_group": "智能体",
+ "squads_group": "小队"
},
"timezone_picker": {
"search_placeholder": "搜索时区..."
diff --git a/server/cmd/server/autopilot_failure_monitor_test.go b/server/cmd/server/autopilot_failure_monitor_test.go
index e2cb94813..8eee7e6d7 100644
--- a/server/cmd/server/autopilot_failure_monitor_test.go
+++ b/server/cmd/server/autopilot_failure_monitor_test.go
@@ -34,6 +34,7 @@ func seedAutopilot(t *testing.T, queries *db.Queries, title, creatorType string,
ap, err := queries.CreateAutopilot(ctx, db.CreateAutopilotParams{
WorkspaceID: parseUUID(testWorkspaceID),
Title: title,
+ AssigneeType: "agent",
AssigneeID: agentID,
Status: "active",
ExecutionMode: "run_only",
diff --git a/server/cmd/server/autopilot_listeners_test.go b/server/cmd/server/autopilot_listeners_test.go
index 6db6999c6..84fcd0280 100644
--- a/server/cmd/server/autopilot_listeners_test.go
+++ b/server/cmd/server/autopilot_listeners_test.go
@@ -62,6 +62,7 @@ func TestAutopilotRunOnlyTaskTerminalEventsUpdateRun(t *testing.T) {
WorkspaceID: parseUUID(testWorkspaceID),
Title: "Run-only listener " + tc.name,
Description: pgtype.Text{String: "Run listener regression test", Valid: true},
+ AssigneeType: "agent",
AssigneeID: parseUUID(agentID),
Status: "active",
ExecutionMode: "run_only",
@@ -167,6 +168,7 @@ func TestAutopilotDispatchSkipsWhenRuntimeOffline(t *testing.T) {
WorkspaceID: parseUUID(testWorkspaceID),
Title: "Offline-runtime autopilot",
Description: pgtype.Text{String: "MUL-1899 admission test", Valid: true},
+ AssigneeType: "agent",
AssigneeID: parseUUID(agentID),
Status: "active",
ExecutionMode: "run_only",
@@ -210,3 +212,90 @@ func TestAutopilotDispatchSkipsWhenRuntimeOffline(t *testing.T) {
t.Fatalf("expected 0 queued tasks for offline-runtime agent, got %d", taskCount)
}
}
+
+// TestManualTriggerDoesNotErrorOnPostAdmissionSkip locks in PR #2888 review
+// fix #2: if the dispatcher decides to skip after the admission gate has
+// already passed (e.g. the leader's runtime went offline between admission
+// and task creation), DispatchAutopilot must return (run, nil) with
+// status='skipped' rather than (nil, err). Without this, manual trigger
+// surfaces a 500 to the user even though the work was correctly suppressed
+// — the same regression Emacs flagged on the original PR.
+//
+// We synthesise the race by:
+// 1. Creating an online runtime + agent so the admission gate passes.
+// 2. Flipping the runtime to offline.
+// 3. Triggering the autopilot. Admission has already loaded the agent +
+// runtime once with status='online' at row-fetch time, so the second
+// check inside dispatchRunOnly is what catches the offline state.
+//
+// In this implementation the admission gate also re-reads the runtime, so
+// the same offline state actually fires the admission skip first. That is
+// fine for the assertion we care about: the manual trigger must not 500 and
+// the run must be `skipped`. The post-admission branch is exercised
+// separately by the errDispatchSkipped unwrap unit test in the service
+// package.
+func TestManualTriggerDoesNotErrorOnPostAdmissionSkip(t *testing.T) {
+ ctx := context.Background()
+ queries := db.New(testPool)
+ bus := events.New()
+ taskSvc := service.NewTaskService(queries, testPool, nil, bus)
+ autopilotSvc := service.NewAutopilotService(queries, testPool, bus, taskSvc)
+
+ var runtimeID, agentID string
+ if err := testPool.QueryRow(ctx, `
+ INSERT INTO agent_runtime (
+ workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at
+ )
+ VALUES ($1, NULL, 'Manual-trigger skip runtime', 'local', 'mul2429_manual_skip_runtime', 'offline', '{}'::jsonb, '{}'::jsonb, now())
+ RETURNING id::text
+ `, parseUUID(testWorkspaceID)).Scan(&runtimeID); err != nil {
+ t.Fatalf("create runtime: %v", err)
+ }
+ t.Cleanup(func() {
+ _, _ = testPool.Exec(context.Background(), `DELETE FROM agent_runtime WHERE id = $1`, runtimeID)
+ })
+
+ if err := testPool.QueryRow(ctx, `
+ INSERT INTO agent (
+ workspace_id, name, description, runtime_mode, runtime_config,
+ runtime_id, visibility, max_concurrent_tasks, owner_id
+ )
+ VALUES ($1, 'mul2429-manual-skip-agent', '', 'local', '{}'::jsonb, $2, 'workspace', 1, $3)
+ RETURNING id::text
+ `, parseUUID(testWorkspaceID), runtimeID, parseUUID(testUserID)).Scan(&agentID); err != nil {
+ t.Fatalf("create agent: %v", err)
+ }
+ t.Cleanup(func() {
+ _, _ = testPool.Exec(context.Background(), `DELETE FROM agent WHERE id = $1`, agentID)
+ })
+
+ ap, err := queries.CreateAutopilot(ctx, db.CreateAutopilotParams{
+ WorkspaceID: parseUUID(testWorkspaceID),
+ Title: "Manual-trigger skip autopilot",
+ Description: pgtype.Text{String: "PR #2888 review fix #2", Valid: true},
+ AssigneeType: "agent",
+ AssigneeID: parseUUID(agentID),
+ Status: "active",
+ ExecutionMode: "run_only",
+ IssueTitleTemplate: pgtype.Text{},
+ CreatedByType: "member",
+ CreatedByID: parseUUID(testUserID),
+ })
+ if err != nil {
+ t.Fatalf("CreateAutopilot: %v", err)
+ }
+ t.Cleanup(func() {
+ _, _ = testPool.Exec(context.Background(), `DELETE FROM autopilot WHERE id = $1`, ap.ID)
+ })
+
+ run, err := autopilotSvc.DispatchAutopilot(ctx, ap, pgtype.UUID{}, "manual", nil)
+ if err != nil {
+ t.Fatalf("manual DispatchAutopilot returned error (would 500 the handler): %v", err)
+ }
+ if run == nil {
+ t.Fatal("expected a run, got nil")
+ }
+ if run.Status != "skipped" {
+ t.Fatalf("expected run status 'skipped', got %q", run.Status)
+ }
+}
diff --git a/server/internal/analytics/events.go b/server/internal/analytics/events.go
index 394aea4f0..b0c3a6e68 100644
--- a/server/internal/analytics/events.go
+++ b/server/internal/analytics/events.go
@@ -328,18 +328,29 @@ func AgentTaskCancelled(ctx TaskContext, durationMS int64) Event {
})
}
-func AutopilotRunStarted(actorID, workspaceID, autopilotID, runID, agentID, triggerSource string) Event {
- return autopilotRunEvent(EventAutopilotRunStarted, actorID, workspaceID, autopilotID, runID, agentID, triggerSource, nil)
+// AutopilotAssignee describes the autopilot's configured target. agent_id is
+// always the agent that will actually execute the work (the squad leader for
+// squad autopilots) so funnels grouping by agent stay consistent. assignee_*
+// fields record the original configuration so reports can tell a solo-agent
+// autopilot apart from a squad one without joining back to the autopilot row.
+type AutopilotAssignee struct {
+ AgentID string // executing agent — leader for squad autopilots
+ AssigneeType string // "agent" or "squad"
+ SquadID string // empty when AssigneeType != "squad"
}
-func AutopilotRunCompleted(actorID, workspaceID, autopilotID, runID, agentID, triggerSource string, durationMS int64) Event {
- return autopilotRunEvent(EventAutopilotRunCompleted, actorID, workspaceID, autopilotID, runID, agentID, triggerSource, map[string]any{
+func AutopilotRunStarted(actorID, workspaceID, autopilotID, runID string, assignee AutopilotAssignee, triggerSource string) Event {
+ return autopilotRunEvent(EventAutopilotRunStarted, actorID, workspaceID, autopilotID, runID, assignee, triggerSource, nil)
+}
+
+func AutopilotRunCompleted(actorID, workspaceID, autopilotID, runID string, assignee AutopilotAssignee, triggerSource string, durationMS int64) Event {
+ return autopilotRunEvent(EventAutopilotRunCompleted, actorID, workspaceID, autopilotID, runID, assignee, triggerSource, map[string]any{
"duration_ms": durationMS,
})
}
-func AutopilotRunFailed(actorID, workspaceID, autopilotID, runID, agentID, triggerSource, failureReason, errorType string, willRetry bool, durationMS int64) Event {
- return autopilotRunEvent(EventAutopilotRunFailed, actorID, workspaceID, autopilotID, runID, agentID, triggerSource, map[string]any{
+func AutopilotRunFailed(actorID, workspaceID, autopilotID, runID string, assignee AutopilotAssignee, triggerSource, failureReason, errorType string, willRetry bool, durationMS int64) Event {
+ return autopilotRunEvent(EventAutopilotRunFailed, actorID, workspaceID, autopilotID, runID, assignee, triggerSource, map[string]any{
"duration_ms": durationMS,
"failure_reason": failureReason,
"error_type": errorType,
@@ -528,7 +539,7 @@ func agentTaskEvent(name string, ctx TaskContext, extra map[string]any) Event {
}
}
-func autopilotRunEvent(name, actorID, workspaceID, autopilotID, runID, agentID, triggerSource string, extra map[string]any) Event {
+func autopilotRunEvent(name, actorID, workspaceID, autopilotID, runID string, assignee AutopilotAssignee, triggerSource string, extra map[string]any) Event {
if extra == nil {
extra = map[string]any{}
}
@@ -536,11 +547,17 @@ func autopilotRunEvent(name, actorID, workspaceID, autopilotID, runID, agentID,
props := withCoreProperties(extra, CoreProperties{
UserID: nonAgentUserID(actorID),
WorkspaceID: workspaceID,
- AgentID: agentID,
+ AgentID: assignee.AgentID,
AutopilotRunID: runID,
Source: SourceAutopilot,
})
props["autopilot_id"] = autopilotID
+ if assignee.AssigneeType != "" {
+ props["assignee_type"] = assignee.AssigneeType
+ }
+ if assignee.SquadID != "" {
+ props["squad_id"] = assignee.SquadID
+ }
return Event{
Name: name,
DistinctID: actorID,
diff --git a/server/internal/analytics/events_test.go b/server/internal/analytics/events_test.go
index a742c9577..16abe0815 100644
--- a/server/internal/analytics/events_test.go
+++ b/server/internal/analytics/events_test.go
@@ -30,7 +30,7 @@ func TestFailedEventsUseWillRetry(t *testing.T) {
t.Fatalf("task failure should not emit recoverable")
}
- runEv := AutopilotRunFailed("user-1", "workspace-1", "autopilot-1", "run-1", "agent-1", "manual", "task failed", "task_error", false, 10)
+ runEv := AutopilotRunFailed("user-1", "workspace-1", "autopilot-1", "run-1", AutopilotAssignee{AgentID: "agent-1", AssigneeType: "agent"}, "manual", "task failed", "task_error", false, 10)
if got := runEv.Properties["will_retry"]; got != false {
t.Fatalf("autopilot will_retry = %v, want false", got)
}
diff --git a/server/internal/handler/autopilot.go b/server/internal/handler/autopilot.go
index 0ecea1909..5d317412d 100644
--- a/server/internal/handler/autopilot.go
+++ b/server/internal/handler/autopilot.go
@@ -28,6 +28,10 @@ type AutopilotResponse struct {
WorkspaceID string `json:"workspace_id"`
Title string `json:"title"`
Description *string `json:"description"`
+ // AssigneeType is "agent" or "squad". Path A from MUL-2429: when set
+ // to "squad", AssigneeID points at squad(id) rather than agent(id) and
+ // dispatch resolves to squad.leader_id at run time.
+ AssigneeType string `json:"assignee_type"`
AssigneeID string `json:"assignee_id"`
Status string `json:"status"`
ExecutionMode string `json:"execution_mode"`
@@ -94,11 +98,19 @@ type AutopilotRunResponse struct {
// ── Converters ──────────────────────────────────────────────────────────────
func autopilotToResponse(a db.Autopilot) AutopilotResponse {
+ assigneeType := a.AssigneeType
+ if assigneeType == "" {
+ // Older rows pre-MUL-2429 may surface as "" against an out-of-date
+ // schema view; default to "agent" so the API contract stays
+ // non-null.
+ assigneeType = "agent"
+ }
return AutopilotResponse{
ID: uuidToString(a.ID),
WorkspaceID: uuidToString(a.WorkspaceID),
Title: a.Title,
Description: textToPtr(a.Description),
+ AssigneeType: assigneeType,
AssigneeID: uuidToString(a.AssigneeID),
Status: a.Status,
ExecutionMode: a.ExecutionMode,
@@ -207,6 +219,9 @@ func runToResponseSlim(r db.AutopilotRun) AutopilotRunResponse {
type CreateAutopilotRequest struct {
Title string `json:"title"`
Description *string `json:"description"`
+ // AssigneeType is optional and defaults to "agent" — preserves backward
+ // compatibility with desktop clients shipped before MUL-2429.
+ AssigneeType *string `json:"assignee_type"`
AssigneeID string `json:"assignee_id"`
ExecutionMode string `json:"execution_mode"`
IssueTitleTemplate *string `json:"issue_title_template"`
@@ -215,6 +230,7 @@ type CreateAutopilotRequest struct {
type UpdateAutopilotRequest struct {
Title *string `json:"title"`
Description *string `json:"description"`
+ AssigneeType *string `json:"assignee_type"`
AssigneeID *string `json:"assignee_id"`
Status *string `json:"status"`
ExecutionMode *string `json:"execution_mode"`
@@ -369,19 +385,22 @@ func (h *Handler) CreateAutopilot(w http.ResponseWriter, r *http.Request) {
return
}
- // Validate assignee is an agent in the workspace.
- _, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
- ID: assigneeUUID,
- WorkspaceID: wsUUID,
- })
- if err != nil {
- writeError(w, http.StatusBadRequest, "assignee must be a valid agent in this workspace")
+ assigneeType := "agent"
+ if req.AssigneeType != nil && *req.AssigneeType != "" {
+ assigneeType = *req.AssigneeType
+ }
+ if !isValidAutopilotAssigneeType(assigneeType) {
+ writeError(w, http.StatusBadRequest, "assignee_type must be agent or squad")
+ return
+ }
+ if !h.validateAutopilotAssignee(w, r, assigneeType, assigneeUUID, wsUUID) {
return
}
autopilot, err := h.Queries.CreateAutopilot(r.Context(), db.CreateAutopilotParams{
WorkspaceID: wsUUID,
Title: req.Title,
+ AssigneeType: assigneeType,
AssigneeID: assigneeUUID,
Status: "active",
ExecutionMode: req.ExecutionMode,
@@ -454,20 +473,49 @@ func (h *Handler) UpdateAutopilot(w http.ResponseWriter, r *http.Request) {
}
params.IssueTitleTemplate = ptrToText(req.IssueTitleTemplate)
}
- if _, ok := rawFields["assignee_id"]; ok {
- if req.AssigneeID != nil {
- assigneeUUID, ok := parseUUIDOrBadRequest(w, *req.AssigneeID, "assignee_id")
+ // assignee_type and assignee_id are validated as a pair: switching
+ // between agent and squad without supplying a new id would leave the
+ // row pointing at the wrong table. The client is expected to send both
+ // fields on any change; partial updates that change only one are
+ // rejected.
+ _, typeSent := rawFields["assignee_type"]
+ _, idSent := rawFields["assignee_id"]
+ if typeSent || idSent {
+ nextType := prev.AssigneeType
+ if typeSent && req.AssigneeType != nil && *req.AssigneeType != "" {
+ nextType = *req.AssigneeType
+ }
+ if !isValidAutopilotAssigneeType(nextType) {
+ writeError(w, http.StatusBadRequest, "assignee_type must be agent or squad")
+ return
+ }
+ nextID := prev.AssigneeID
+ if idSent {
+ if req.AssigneeID == nil {
+ writeError(w, http.StatusBadRequest, "assignee_id cannot be null")
+ return
+ }
+ parsed, ok := parseUUIDOrBadRequest(w, *req.AssigneeID, "assignee_id")
if !ok {
return
}
- if _, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
- ID: assigneeUUID,
- WorkspaceID: prev.WorkspaceID,
- }); err != nil {
- writeError(w, http.StatusBadRequest, "assignee must be a valid agent in this workspace")
- return
- }
- params.AssigneeID = assigneeUUID
+ nextID = parsed
+ }
+ // Reject the agent↔squad switch without a paired id, otherwise the
+ // row would address agent(id) under assignee_type='squad' or vice
+ // versa.
+ if typeSent && !idSent && nextType != prev.AssigneeType {
+ writeError(w, http.StatusBadRequest, "assignee_id is required when changing assignee_type")
+ return
+ }
+ if !h.validateAutopilotAssignee(w, r, nextType, nextID, prev.WorkspaceID) {
+ return
+ }
+ if typeSent {
+ params.AssigneeType = pgtype.Text{String: nextType, Valid: true}
+ }
+ if idSent {
+ params.AssigneeID = nextID
}
}
@@ -695,6 +743,71 @@ func isAllowedWebhookProvider(p string) bool {
}
}
+func isValidAutopilotAssigneeType(t string) bool {
+ switch t {
+ case "agent", "squad":
+ return true
+ default:
+ return false
+ }
+}
+
+// validateAutopilotAssignee checks that the assignee (agent or squad) exists
+// in the given workspace, and for squad assignees that the squad's leader
+// agent is in a workable state at create / update time. Writes an HTTP error
+// and returns false on any failure.
+//
+// At dispatch time the same checks (resolveAutopilotLeader + AgentReadiness)
+// run again — they live there to handle "leader was online at save time but
+// went offline by trigger time". Save-time validation exists so the user gets
+// immediate feedback ("can't pick this squad because its leader is archived")
+// instead of discovering the autopilot is dead at the next schedule tick.
+func (h *Handler) validateAutopilotAssignee(w http.ResponseWriter, r *http.Request, assigneeType string, assigneeID, workspaceID pgtype.UUID) bool {
+ switch assigneeType {
+ case "agent":
+ if _, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
+ ID: assigneeID,
+ WorkspaceID: workspaceID,
+ }); err != nil {
+ writeError(w, http.StatusBadRequest, "assignee must be a valid agent in this workspace")
+ return false
+ }
+ return true
+ case "squad":
+ squad, err := h.Queries.GetSquadInWorkspace(r.Context(), db.GetSquadInWorkspaceParams{
+ ID: assigneeID,
+ WorkspaceID: workspaceID,
+ })
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "assignee must be a valid squad in this workspace")
+ return false
+ }
+ // Archived squads must be rejected at save time: the dispatcher will
+ // otherwise produce an unbroken stream of skipped runs against a
+ // squad that can never be revived without an explicit un-archive.
+ // Pair with TransferSquadAutopilotsToLeader on DeleteSquad so any
+ // autopilot that survives the archive flips to assignee_type='agent'
+ // (the leader) and stops referencing the dead squad row.
+ if squad.ArchivedAt.Valid {
+ writeError(w, http.StatusUnprocessableEntity, "squad is archived; pick a different squad")
+ return false
+ }
+ leader, err := h.Queries.GetAgent(r.Context(), squad.LeaderID)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "squad leader agent not found")
+ return false
+ }
+ if leader.ArchivedAt.Valid {
+ writeError(w, http.StatusUnprocessableEntity, "squad leader is archived; pick a different squad or rotate the leader before assigning autopilot")
+ return false
+ }
+ return true
+ default:
+ writeError(w, http.StatusBadRequest, "assignee_type must be agent or squad")
+ return false
+ }
+}
+
func (h *Handler) UpdateAutopilotTrigger(w http.ResponseWriter, r *http.Request) {
autopilotID := chi.URLParam(r, "id")
triggerID := chi.URLParam(r, "triggerId")
diff --git a/server/internal/handler/runtime.go b/server/internal/handler/runtime.go
index 3f358c754..e485aaae3 100644
--- a/server/internal/handler/runtime.go
+++ b/server/internal/handler/runtime.go
@@ -692,6 +692,25 @@ func (h *Handler) DeleteAgentRuntime(w http.ResponseWriter, r *http.Request) {
return
}
+ // Pause autopilots pointing at the archived agents BEFORE we delete
+ // them. Migration 096 dropped the autopilot.assignee_id agent FK, so a
+ // hard-delete here would otherwise leave dangling rows that subsequent
+ // scheduler ticks would skip with "assignee agent no longer exists" —
+ // quiet, but burning a run record every tick until an operator notices.
+ // Pausing makes the breakage visible in the autopilot list so the owner
+ // can re-point or delete the row instead.
+ archivedAgentIDs, err := h.Queries.ListArchivedAgentIDsByRuntime(r.Context(), rt.ID)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "failed to enumerate archived agents")
+ return
+ }
+ if len(archivedAgentIDs) > 0 {
+ if err := h.Queries.PauseAutopilotsByAgentAssignees(r.Context(), archivedAgentIDs); err != nil {
+ slog.Warn("pause autopilots for archived agents failed",
+ "runtime_id", uuidToString(rt.ID), "error", err)
+ }
+ }
+
// Remove archived agents so the FK constraint (ON DELETE RESTRICT) won't block deletion.
if err := h.Queries.DeleteArchivedAgentsByRuntime(r.Context(), rt.ID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to clean up archived agents")
diff --git a/server/internal/handler/squad.go b/server/internal/handler/squad.go
index 2ed8a4e66..902e0974d 100644
--- a/server/internal/handler/squad.go
+++ b/server/internal/handler/squad.go
@@ -10,6 +10,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
+ "github.com/multica-ai/multica/server/internal/service"
"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"
@@ -299,6 +300,19 @@ func (h *Handler) DeleteSquad(w http.ResponseWriter, r *http.Request) {
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")
@@ -892,7 +906,10 @@ func (h *Handler) shouldEnqueueSquadLeaderOnAssign(ctx context.Context, issue db
}
// isSquadLeaderReady returns true when the issue is assigned to a squad whose
-// leader agent is ready (has a runtime, not archived).
+// leader agent can accept work right now. Readiness criteria (archived,
+// runtime bound, runtime online) are shared with the autopilot admission
+// gate via service.AgentReadiness — both paths must move together or one
+// will start enqueueing tasks the other refuses (MUL-2429 RFC §4.b B4).
func (h *Handler) isSquadLeaderReady(ctx context.Context, issue db.Issue) bool {
if !issue.AssigneeType.Valid || issue.AssigneeType.String != "squad" || !issue.AssigneeID.Valid {
return false
@@ -905,10 +922,16 @@ func (h *Handler) isSquadLeaderReady(ctx context.Context, issue db.Issue) bool {
return false
}
agent, err := h.Queries.GetAgent(ctx, squad.LeaderID)
- if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid {
+ if err != nil {
return false
}
- return true
+ ready, _, err := service.AgentReadiness(ctx, h.Queries, agent)
+ if err != nil {
+ // Fail closed when we can't tell — same posture as the rest of
+ // this function (any error path returns false).
+ return false
+ }
+ return ready
}
// enqueueSquadLeaderTask triggers the squad leader agent for an issue assigned to a squad.
diff --git a/server/internal/service/agent_ready.go b/server/internal/service/agent_ready.go
new file mode 100644
index 000000000..92d70e369
--- /dev/null
+++ b/server/internal/service/agent_ready.go
@@ -0,0 +1,43 @@
+package service
+
+import (
+ "context"
+
+ db "github.com/multica-ai/multica/server/pkg/db/generated"
+)
+
+// AgentReadiness reports whether an agent can accept new work right now.
+// "Ready" means archived_at IS NULL, runtime_id IS NOT NULL, and the bound
+// runtime's status is 'online'. When not ready, reason describes which gate
+// failed in language suitable for autopilot_run.failure_reason.
+//
+// err is non-nil only on DB lookup failure for the runtime row. Callers that
+// treat a transient DB error as "do not skip" (the autopilot admission gate)
+// should swallow it; callers that need a hard yes/no (the squad-leader
+// pre-enqueue check in the handler) should fail closed.
+//
+// This is the single source of truth shared by:
+// - service.shouldSkipDispatch (autopilot admission gate)
+// - service.dispatchRunOnly (squad-leader runtime check, MUL-2429)
+// - handler.isSquadLeaderReady (issue-assign / comment-trigger path)
+//
+// Keeping these aligned matters because the three paths can otherwise drift
+// — e.g. one starts allowing "starting" runtimes while another doesn't, and
+// the bug only surfaces when a user assigns the same squad through two
+// different entry points. Touch this function, all three paths move together.
+func AgentReadiness(ctx context.Context, q *db.Queries, agent db.Agent) (ready bool, reason string, err error) {
+ if agent.ArchivedAt.Valid {
+ return false, "agent is archived", nil
+ }
+ if !agent.RuntimeID.Valid {
+ return false, "agent has no runtime bound", nil
+ }
+ rt, err := q.GetAgentRuntime(ctx, agent.RuntimeID)
+ if err != nil {
+ return false, "", err
+ }
+ if rt.Status != "online" {
+ return false, "agent runtime is " + rt.Status, nil
+ }
+ return true, "", nil
+}
diff --git a/server/internal/service/autopilot.go b/server/internal/service/autopilot.go
index 4ca182678..88ace10eb 100644
--- a/server/internal/service/autopilot.go
+++ b/server/internal/service/autopilot.go
@@ -3,6 +3,7 @@ package service
import (
"context"
"encoding/json"
+ "errors"
"fmt"
"log/slog"
"regexp"
@@ -43,6 +44,10 @@ func NewAutopilotService(q *db.Queries, tx TxStarter, bus *events.Bus, taskSvc *
// failure_reason and return without enqueueing. This is the "触发时准入" gate
// from MUL-1899 — without it a paused laptop / offline daemon causes scheduled
// autopilots to pile thousands of doomed tasks onto agent_task_queue.
+//
+// When assignee_type='squad' the gate runs against the squad leader (Path A
+// from MUL-2429: Autopilot-on-squad ≈ Autopilot-on-leader), so an offline or
+// archived leader produces the same skip behaviour as an offline solo agent.
func (s *AutopilotService) DispatchAutopilot(
ctx context.Context,
autopilot db.Autopilot,
@@ -66,6 +71,7 @@ func (s *AutopilotService) DispatchAutopilot(
Source: source,
Status: initialStatus,
TriggerPayload: payload,
+ SquadID: autopilotSquadAttribution(autopilot),
})
if err != nil {
return nil, fmt.Errorf("create run: %w", err)
@@ -75,12 +81,18 @@ func (s *AutopilotService) DispatchAutopilot(
switch autopilot.ExecutionMode {
case "create_issue":
if err := s.dispatchCreateIssue(ctx, autopilot, &run); err != nil {
+ if skipped := s.handleDispatchSkip(ctx, autopilot, &run, err); skipped != nil {
+ return skipped, nil
+ }
s.failRun(ctx, run.ID, err.Error())
s.captureAutopilotRunFailed(autopilot, run, source, err.Error())
return &run, fmt.Errorf("dispatch create_issue: %w", err)
}
case "run_only":
if err := s.dispatchRunOnly(ctx, autopilot, &run); err != nil {
+ if skipped := s.handleDispatchSkip(ctx, autopilot, &run, err); skipped != nil {
+ return skipped, nil
+ }
s.failRun(ctx, run.ID, err.Error())
s.captureAutopilotRunFailed(autopilot, run, source, err.Error())
return &run, fmt.Errorf("dispatch run_only: %w", err)
@@ -111,7 +123,22 @@ func (s *AutopilotService) DispatchAutopilot(
}
// dispatchCreateIssue creates an issue and enqueues a task for the agent.
+//
+// When the autopilot is assigned to a squad (Path A from MUL-2429), the
+// created issue inherits assignee_type='squad' + assignee_id=squad. The
+// existing issue listener chain (shouldEnqueueSquadLeaderOnAssign →
+// enqueueSquadLeaderTask) then routes the work to the squad leader, exactly
+// as a human manually assigning the issue to that squad would.
+//
+// Creator on the issue is always the agent that will actually do the work
+// (the resolved leader for a squad autopilot, otherwise the assignee agent
+// itself), so activity / mentions render with the right author identity.
func (s *AutopilotService) dispatchCreateIssue(ctx context.Context, ap db.Autopilot, run *db.AutopilotRun) error {
+ leader, _, err := s.resolveAutopilotLeader(ctx, ap)
+ if err != nil {
+ return fmt.Errorf("resolve leader: %w", err)
+ }
+
tx, err := s.TxStarter.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
@@ -134,13 +161,15 @@ func (s *AutopilotService) dispatchCreateIssue(ctx context.Context, ap db.Autopi
Description: description,
Status: "todo",
Priority: "none",
- AssigneeType: pgtype.Text{String: "agent", Valid: true},
+ AssigneeType: pgtype.Text{String: ap.AssigneeType, Valid: true},
AssigneeID: ap.AssigneeID,
// The agent that the autopilot dispatches to is the issue's creator,
// not the human who originally configured the autopilot. The latter
- // is captured separately via origin_type=autopilot + origin_id.
+ // is captured separately via origin_type=autopilot + origin_id. For
+ // squad-assigned autopilots, the creator is the resolved leader —
+ // the same agent the issue listener will end up enqueueing.
CreatorType: "agent",
- CreatorID: ap.AssigneeID,
+ CreatorID: leader.ID,
ParentIssueID: pgtype.UUID{},
Position: 0,
StartDate: pgtype.Timestamptz{},
@@ -169,47 +198,90 @@ func (s *AutopilotService) dispatchCreateIssue(ctx context.Context, ap db.Autopi
*run = updatedRun
// Publish issue:created so the existing event chain fires
- // (subscriber listeners, activity listeners, notification listeners).
+ // (subscriber listeners, activity listeners, notification listeners). For
+ // squad autopilots, this is what triggers shouldEnqueueSquadLeaderOnAssign
+ // → enqueueSquadLeaderTask — no separate squad-routing code needed here.
prefix := s.getIssuePrefix(ap.WorkspaceID)
s.Bus.Publish(events.Event{
Type: protocol.EventIssueCreated,
WorkspaceID: util.UUIDToString(ap.WorkspaceID),
ActorType: "agent",
- ActorID: util.UUIDToString(ap.AssigneeID),
+ ActorID: util.UUIDToString(leader.ID),
Payload: map[string]any{
"issue": issueToMap(issue, prefix),
},
})
- s.captureIssueCreatedFromAutopilot(ap, run, issue)
+ s.captureIssueCreatedFromAutopilot(ap, run, issue, leader.ID)
- // Enqueue agent task via the existing flow.
- if _, err := s.TaskSvc.EnqueueTaskForIssue(ctx, issue); err != nil {
- return fmt.Errorf("enqueue task for issue: %w", err)
+ // Enqueue agent task via the existing flow. Squad-assigned autopilots
+ // route to the resolved leader as the executing agent (Path A from
+ // MUL-2429); agent-assigned autopilots go through the standard issue
+ // path. Both code paths land in agent_task_queue with agent_id = leader.
+ if ap.AssigneeType == "squad" {
+ if _, err := s.TaskSvc.EnqueueTaskForSquadLeader(ctx, issue, leader.ID, pgtype.UUID{}); err != nil {
+ return fmt.Errorf("enqueue squad leader task: %w", err)
+ }
+ } else {
+ if _, err := s.TaskSvc.EnqueueTaskForIssue(ctx, issue); err != nil {
+ return fmt.Errorf("enqueue task for issue: %w", err)
+ }
}
slog.Info("autopilot dispatched (create_issue)",
"autopilot_id", util.UUIDToString(ap.ID),
+ "assignee_type", ap.AssigneeType,
"issue_id", util.UUIDToString(issue.ID),
+ "leader_id", util.UUIDToString(leader.ID),
"run_id", util.UUIDToString(run.ID),
)
return nil
}
+// errDispatchSkipped wraps a readiness failure encountered after the
+// admission gate has already passed. dispatchRunOnly returns this when a
+// resolved leader has gone offline / been archived between admission and
+// task creation; DispatchAutopilot recognises it and records a `skipped`
+// run (with the wrapped reason) instead of a `failed` run.
+//
+// Without the sentinel, the existing failRun path would mark these races as
+// failures and bubble a 500 out of the manual-trigger handler — both wrong
+// (the work was never attempted, no one is at fault) and noisy (the failure
+// monitor would auto-pause autopilots whose only crime was a flaky runtime).
+type errDispatchSkipped struct {
+ reason string
+}
+
+func (e *errDispatchSkipped) Error() string { return e.reason }
+
// dispatchRunOnly enqueues a direct agent task without creating an issue.
+//
+// For squad autopilots, the executing agent is the squad leader resolved at
+// trigger time (Path A from MUL-2429). The same archived / runtime-bound /
+// runtime-online gates that the upstream admission check (shouldSkipDispatch)
+// applies also run here as belt-and-braces: if the leader changed between
+// admission and dispatch, or the runtime went offline in the gap, we still
+// fail closed instead of enqueueing a doomed task.
func (s *AutopilotService) dispatchRunOnly(ctx context.Context, ap db.Autopilot, run *db.AutopilotRun) error {
- agent, err := s.Queries.GetAgent(ctx, ap.AssigneeID)
+ agent, _, err := s.resolveAutopilotLeader(ctx, ap)
if err != nil {
- return fmt.Errorf("load agent: %w", err)
+ // Same admission-vs-failure classification as shouldSkipDispatch:
+ // if the row disappeared or the squad was archived between
+ // admission and dispatch, that is a skip, not a failure.
+ if errors.Is(err, pgx.ErrNoRows) || errors.Is(err, errSquadArchived) {
+ return &errDispatchSkipped{reason: formatAdmissionReason(ap, "assignee no longer resolvable")}
+ }
+ return fmt.Errorf("resolve leader: %w", err)
}
- if agent.ArchivedAt.Valid {
- return fmt.Errorf("agent is archived")
+ ready, reason, err := AgentReadiness(ctx, s.Queries, agent)
+ if err != nil {
+ return fmt.Errorf("check agent readiness: %w", err)
}
- if !agent.RuntimeID.Valid {
- return fmt.Errorf("agent has no runtime")
+ if !ready {
+ return &errDispatchSkipped{reason: formatAdmissionReason(ap, reason)}
}
task, err := s.Queries.CreateAutopilotTask(ctx, db.CreateAutopilotTaskParams{
- AgentID: ap.AssigneeID,
+ AgentID: agent.ID,
RuntimeID: agent.RuntimeID,
Priority: 0,
AutopilotRunID: run.ID,
@@ -341,6 +413,47 @@ func (s *AutopilotService) SyncRunFromTask(ctx context.Context, task db.AgentTas
}
}
+// handleDispatchSkip recognises an errDispatchSkipped returned from a
+// dispatch function and rewrites the in-flight run to `skipped` (instead of
+// `failed`). Returns the updated run on a real skip, nil otherwise — callers
+// fall through to the failure path on nil.
+//
+// Lives here, not inside dispatchRunOnly, because the run row was created by
+// DispatchAutopilot up the stack and the failure-vs-skip distinction is
+// owned by the dispatcher entry point. Keeps dispatchRunOnly free of
+// state-mutation helpers.
+func (s *AutopilotService) handleDispatchSkip(ctx context.Context, ap db.Autopilot, run *db.AutopilotRun, err error) *db.AutopilotRun {
+ var skipErr *errDispatchSkipped
+ if !errors.As(err, &skipErr) {
+ return nil
+ }
+ updated, uerr := s.Queries.UpdateAutopilotRunSkipped(ctx, db.UpdateAutopilotRunSkippedParams{
+ ID: run.ID,
+ FailureReason: pgtype.Text{String: skipErr.reason, Valid: true},
+ })
+ if uerr != nil {
+ slog.Warn("failed to mark dispatch as skipped",
+ "run_id", util.UUIDToString(run.ID), "error", uerr)
+ // Leave the run in its current (running/issue_created) state if
+ // the update failed; the failure monitor will eventually fail it
+ // out, but at least we didn't pretend it succeeded.
+ return nil
+ }
+ *run = updated
+ slog.Info("autopilot dispatch skipped post-admission",
+ "autopilot_id", util.UUIDToString(ap.ID),
+ "run_id", util.UUIDToString(run.ID),
+ "reason", skipErr.reason,
+ )
+ // Bump last_run_at on parity with recordSkippedRun (pre-flight skip) and
+ // the success path: from the scheduler's / UI's point of view we did
+ // evaluate the trigger this tick, even though the post-admission gate
+ // caught a late readiness regression.
+ s.Queries.UpdateAutopilotLastRunAt(ctx, ap.ID)
+ s.publishRunDone(util.UUIDToString(ap.WorkspaceID), updated, "skipped")
+ return run
+}
+
func (s *AutopilotService) failRun(ctx context.Context, runID pgtype.UUID, reason string) {
if _, err := s.Queries.UpdateAutopilotRunFailed(ctx, db.UpdateAutopilotRunFailedParams{
ID: runID,
@@ -352,31 +465,58 @@ func (s *AutopilotService) failRun(ctx context.Context, runID pgtype.UUID, reaso
// shouldSkipDispatch is the pre-flight admission check from MUL-1899.
// Returns (reason, true) when dispatching now would only enqueue a doomed
-// task — i.e. the assignee agent is gone, archived, has no runtime bound, or
-// its runtime is not currently online. Returns ("", false) on the happy path.
+// task — i.e. the assignee (or, for squad autopilots, the squad leader) is
+// gone, archived, has no runtime bound, or its runtime is not currently
+// online. Returns ("", false) on the happy path.
//
-// Errors loading the agent / runtime are logged but treated as "do not skip"
-// so a transient DB hiccup never silently swallows a scheduled run.
+// Errors are split into two classes:
+// - pgx.ErrNoRows / errSquadArchived (the row truly doesn't exist or is
+// archived) → hard skip. Retrying won't change anything; piling failed
+// runs would pollute the failure-rate auto-pause monitor.
+// - Anything else (connection drop, statement timeout, etc.) → fail-open:
+// log + do not skip, so a transient DB hiccup never silently swallows a
+// scheduled run. Migration 096 removed the agent FK on autopilot, so an
+// agent assignee being missing is now a real condition the gate must
+// handle (previously cascade-deleted).
func (s *AutopilotService) shouldSkipDispatch(ctx context.Context, ap db.Autopilot) (string, bool) {
if !ap.AssigneeID.Valid {
return "autopilot has no assignee", true
}
- agent, err := s.Queries.GetAgent(ctx, ap.AssigneeID)
+ agent, squadResolved, err := s.resolveAutopilotLeader(ctx, ap)
if err != nil {
- slog.Warn("autopilot admission: failed to load assignee agent",
+ // Hard-skip the cases where another retry will produce the same
+ // outcome. Logging is unconditional so ops can still spot a run of
+ // dangling rows pointing at a deleted agent / archived squad.
+ missing := errors.Is(err, pgx.ErrNoRows)
+ archived := errors.Is(err, errSquadArchived)
+ slog.Warn("autopilot admission: failed to resolve leader",
"autopilot_id", util.UUIDToString(ap.ID),
- "agent_id", util.UUIDToString(ap.AssigneeID),
+ "assignee_type", ap.AssigneeType,
+ "assignee_id", util.UUIDToString(ap.AssigneeID),
+ "missing", missing,
+ "archived", archived,
"error", err,
)
+ switch {
+ case archived:
+ // Squad row exists but is archived — DeleteSquad's transfer
+ // should have rewritten this autopilot's assignee to the leader
+ // already; surfacing the case explicitly keeps the failure
+ // reason useful when something slipped past the transfer.
+ return "assignee squad is archived", true
+ case missing && squadResolved:
+ return "assignee squad cannot be resolved", true
+ case missing && !squadResolved:
+ // Agent row gone. With migration 096 the FK is gone too, so
+ // this is the new "agent was hard-deleted under us" case. Skip
+ // rather than fail-open: we know retrying will not help.
+ return "assignee agent no longer exists", true
+ }
+ // Transient DB error — fail-open so the next scheduler tick gets a
+ // chance to succeed.
return "", false
}
- if agent.ArchivedAt.Valid {
- return "assignee agent is archived", true
- }
- if !agent.RuntimeID.Valid {
- return "assignee agent has no runtime bound", true
- }
- rt, err := s.Queries.GetAgentRuntime(ctx, agent.RuntimeID)
+ ready, reason, err := AgentReadiness(ctx, s.Queries, agent)
if err != nil {
slog.Warn("autopilot admission: failed to load runtime",
"autopilot_id", util.UUIDToString(ap.ID),
@@ -385,8 +525,8 @@ func (s *AutopilotService) shouldSkipDispatch(ctx context.Context, ap db.Autopil
)
return "", false
}
- if rt.Status != "online" {
- return "agent runtime is " + rt.Status + " at dispatch time", true
+ if !ready {
+ return formatAdmissionReason(ap, reason), true
}
// Private-agent gate at the autopilot layer. Caller identity = the
// autopilot's creator: if the creator no longer has access to the
@@ -394,6 +534,11 @@ func (s *AutopilotService) shouldSkipDispatch(ctx context.Context, ap db.Autopil
// Agent-created autopilots bypass the gate to preserve A2A
// collaboration. Errors loading the workspace member fail closed —
// without an authoritative role the gate cannot grant access.
+ //
+ // For squad autopilots the gate runs against the resolved leader.
+ // Leader visibility is the right thing to check — if the human creator
+ // can no longer reach the leader, the autopilot would silently fail
+ // even though the squad itself looks intact.
if agent.Visibility == "private" && ap.CreatedByType == "member" {
creatorID := util.UUIDToString(ap.CreatedByID)
if util.UUIDToString(agent.OwnerID) != creatorID {
@@ -412,6 +557,90 @@ func (s *AutopilotService) shouldSkipDispatch(ctx context.Context, ap db.Autopil
return "", false
}
+// formatAdmissionReason rewrites the generic AgentReadiness reason into the
+// admission-gate phrasing the failure monitor and existing alerting are tuned
+// for. Keeping the prefix stable matters: dashboards group skip reasons by
+// substring ("offline at dispatch time" is how the MUL-1899 alert fires).
+//
+// For squad autopilots the message names the squad so an operator looking at
+// the failure_reason field knows which squad's leader is down without
+// joining back to autopilot_run.squad_id.
+func formatAdmissionReason(ap db.Autopilot, raw string) string {
+ prefix := "assignee "
+ if ap.AssigneeType == "squad" {
+ prefix = "squad leader "
+ }
+ switch raw {
+ case "agent is archived":
+ return prefix + "agent is archived"
+ case "agent has no runtime bound":
+ return prefix + "agent has no runtime bound"
+ default:
+ // raw is "agent runtime is X" — surface the runtime status while
+ // preserving the legacy "at dispatch time" suffix from MUL-1899
+ // so alert queries do not need to change.
+ return raw + " at dispatch time"
+ }
+}
+
+// errSquadArchived signals that an autopilot's squad assignee has been
+// archived. Distinct from a missing/loadable-but-failed squad so the
+// admission gate can phrase the skip reason precisely and the failure
+// monitor does not see "cannot be resolved" wear noise for what is a
+// known, expected post-archive condition.
+var errSquadArchived = errors.New("squad is archived")
+
+// resolveAutopilotLeader returns the agent that will actually execute the
+// autopilot's work. For assignee_type='agent' the agent is the assignee
+// itself; for assignee_type='squad' it is the squad's leader_id. The second
+// return is true when the resolver took the squad branch — callers use this
+// to distinguish "failed loading an agent" from "failed loading a squad", so
+// the admission gate can choose between fail-open (transient DB error on a
+// known-good agent) and fail-closed (squad row gone, no point retrying).
+//
+// Archived squads are rejected here too: TransferSquadAutopilotsToLeader
+// flips surviving autopilots to assignee_type='agent' on DeleteSquad, but
+// the gate still has to fail closed for any row that slips through that
+// transfer (e.g. squad archived through a code path that bypasses the
+// handler) so an archived squad never produces work.
+//
+// Unknown assignee_type values return an error. assignee_type is gated by a
+// CHECK constraint at the DB layer, so this only fires if a future code path
+// inserts a row that bypasses the check.
+func (s *AutopilotService) resolveAutopilotLeader(ctx context.Context, ap db.Autopilot) (agent db.Agent, squadResolved bool, err error) {
+ switch ap.AssigneeType {
+ case "", "agent":
+ agent, err = s.Queries.GetAgent(ctx, ap.AssigneeID)
+ return agent, false, err
+ case "squad":
+ squad, err := s.Queries.GetSquad(ctx, ap.AssigneeID)
+ if err != nil {
+ return db.Agent{}, true, fmt.Errorf("load squad: %w", err)
+ }
+ if squad.ArchivedAt.Valid {
+ return db.Agent{}, true, errSquadArchived
+ }
+ agent, err = s.Queries.GetAgent(ctx, squad.LeaderID)
+ if err != nil {
+ return db.Agent{}, true, fmt.Errorf("load squad leader: %w", err)
+ }
+ return agent, true, nil
+ default:
+ return db.Agent{}, false, fmt.Errorf("unknown assignee_type %q", ap.AssigneeType)
+ }
+}
+
+// autopilotSquadAttribution returns the squad_id attribution hook for an
+// autopilot_run row. Only populated when assignee_type='squad'. First-version
+// reports do not consume this; it exists so a future squad-cost view does not
+// need to backfill — see RFC §4.e (MUL-2429).
+func autopilotSquadAttribution(ap db.Autopilot) pgtype.UUID {
+ if ap.AssigneeType == "squad" && ap.AssigneeID.Valid {
+ return ap.AssigneeID
+ }
+ return pgtype.UUID{}
+}
+
// recordSkippedRun persists a `skipped` autopilot_run with the given reason
// and emits the same WS / analytics signals that a normal terminal transition
// would. Returns the run + nil error so callers (scheduler tick, manual
@@ -430,6 +659,7 @@ func (s *AutopilotService) recordSkippedRun(
Source: source,
Status: "skipped",
TriggerPayload: payload,
+ SquadID: autopilotSquadAttribution(autopilot),
})
if err != nil {
return nil, fmt.Errorf("create skipped run: %w", err)
@@ -474,15 +704,18 @@ func (s *AutopilotService) publishRunDone(workspaceID string, run db.AutopilotRu
})
}
-func (s *AutopilotService) captureIssueCreatedFromAutopilot(ap db.Autopilot, run *db.AutopilotRun, issue db.Issue) {
+func (s *AutopilotService) captureIssueCreatedFromAutopilot(ap db.Autopilot, run *db.AutopilotRun, issue db.Issue, leaderID pgtype.UUID) {
if s.TaskSvc == nil || s.TaskSvc.Analytics == nil {
return
}
+ // For PostHog the agent_id should be the agent that will actually run
+ // the work (the resolved leader for squad autopilots) so per-agent task
+ // counts line up with what daemons report.
s.TaskSvc.Analytics.Capture(analytics.IssueCreated(
autopilotActorID(ap),
util.UUIDToString(ap.WorkspaceID),
util.UUIDToString(issue.ID),
- util.UUIDToString(ap.AssigneeID),
+ util.UUIDToString(leaderID),
"",
util.UUIDToString(run.ID),
analytics.SourceAutopilot,
@@ -498,7 +731,7 @@ func (s *AutopilotService) captureAutopilotRunStarted(ap db.Autopilot, run db.Au
util.UUIDToString(ap.WorkspaceID),
util.UUIDToString(ap.ID),
util.UUIDToString(run.ID),
- util.UUIDToString(ap.AssigneeID),
+ s.autopilotAssigneeAnalytics(ap),
triggerSource,
))
}
@@ -512,7 +745,7 @@ func (s *AutopilotService) captureAutopilotRunCompleted(ap db.Autopilot, run db.
util.UUIDToString(ap.WorkspaceID),
util.UUIDToString(ap.ID),
util.UUIDToString(run.ID),
- util.UUIDToString(ap.AssigneeID),
+ s.autopilotAssigneeAnalytics(ap),
run.Source,
autopilotRunDurationMS(run),
))
@@ -530,7 +763,7 @@ func (s *AutopilotService) captureAutopilotRunFailed(ap db.Autopilot, run db.Aut
util.UUIDToString(ap.WorkspaceID),
util.UUIDToString(ap.ID),
util.UUIDToString(run.ID),
- util.UUIDToString(ap.AssigneeID),
+ s.autopilotAssigneeAnalytics(ap),
triggerSource,
reason,
autopilotErrorType(reason),
@@ -539,6 +772,28 @@ func (s *AutopilotService) captureAutopilotRunFailed(ap db.Autopilot, run db.Aut
))
}
+// autopilotAssigneeAnalytics builds the PostHog assignee descriptor for an
+// autopilot. For squad autopilots agent_id is best-effort the resolved
+// leader (so per-agent funnels stay consistent); a resolve error degrades
+// to the raw assignee_id rather than dropping the event — incomplete data
+// in the dashboard is preferable to silent attribution gaps.
+func (s *AutopilotService) autopilotAssigneeAnalytics(ap db.Autopilot) analytics.AutopilotAssignee {
+ assignee := analytics.AutopilotAssignee{
+ AssigneeType: ap.AssigneeType,
+ }
+ if ap.AssigneeType == "squad" {
+ assignee.SquadID = util.UUIDToString(ap.AssigneeID)
+ if leader, _, err := s.resolveAutopilotLeader(context.Background(), ap); err == nil {
+ assignee.AgentID = util.UUIDToString(leader.ID)
+ } else {
+ assignee.AgentID = util.UUIDToString(ap.AssigneeID)
+ }
+ } else {
+ assignee.AgentID = util.UUIDToString(ap.AssigneeID)
+ }
+ return assignee
+}
+
func autopilotErrorType(reason string) string {
switch {
case strings.Contains(reason, "unknown execution_mode"):
diff --git a/server/internal/service/autopilot_squad_test.go b/server/internal/service/autopilot_squad_test.go
new file mode 100644
index 000000000..ed08e3054
--- /dev/null
+++ b/server/internal/service/autopilot_squad_test.go
@@ -0,0 +1,96 @@
+package service
+
+import (
+ "errors"
+ "fmt"
+ "testing"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgtype"
+ db "github.com/multica-ai/multica/server/pkg/db/generated"
+)
+
+func TestAutopilotSquadAttribution(t *testing.T) {
+ id := pgtype.UUID{Valid: true}
+ copy(id.Bytes[:], []byte("01234567890123456789012345678901"))
+
+ tests := []struct {
+ name string
+ ap db.Autopilot
+ want pgtype.UUID
+ }{
+ {"agent assignee returns zero", db.Autopilot{AssigneeType: "agent", AssigneeID: id}, pgtype.UUID{}},
+ {"squad assignee returns squad id", db.Autopilot{AssigneeType: "squad", AssigneeID: id}, id},
+ {"squad with invalid id returns zero", db.Autopilot{AssigneeType: "squad", AssigneeID: pgtype.UUID{}}, pgtype.UUID{}},
+ {"unset type defaults to non-squad", db.Autopilot{AssigneeID: id}, pgtype.UUID{}},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := autopilotSquadAttribution(tc.ap)
+ if got.Valid != tc.want.Valid {
+ t.Fatalf("Valid mismatch: got %v want %v", got.Valid, tc.want.Valid)
+ }
+ if got.Valid && got.Bytes != tc.want.Bytes {
+ t.Fatalf("Bytes mismatch")
+ }
+ })
+ }
+}
+
+func TestFormatAdmissionReason(t *testing.T) {
+ tests := []struct {
+ name string
+ ap db.Autopilot
+ raw string
+ want string
+ }{
+ {"agent archived", db.Autopilot{AssigneeType: "agent"}, "agent is archived", "assignee agent is archived"},
+ {"squad archived", db.Autopilot{AssigneeType: "squad"}, "agent is archived", "squad leader agent is archived"},
+ {"agent no runtime", db.Autopilot{AssigneeType: "agent"}, "agent has no runtime bound", "assignee agent has no runtime bound"},
+ {"squad no runtime", db.Autopilot{AssigneeType: "squad"}, "agent has no runtime bound", "squad leader agent has no runtime bound"},
+ {"runtime offline retains MUL-1899 suffix", db.Autopilot{AssigneeType: "agent"}, "agent runtime is offline", "agent runtime is offline at dispatch time"},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := formatAdmissionReason(tc.ap, tc.raw); got != tc.want {
+ t.Fatalf("got %q want %q", got, tc.want)
+ }
+ })
+ }
+}
+
+// errDispatchSkipped must be distinguishable via errors.As from a wrapped
+// fmt.Errorf, otherwise DispatchAutopilot's failure-vs-skip switch will treat
+// it as a generic failure and the manual-trigger handler will 500. Locks in
+// the contract that fixed the post-admission race (PR #2888 review fix #2).
+func TestErrDispatchSkippedUnwraps(t *testing.T) {
+ base := &errDispatchSkipped{reason: "squad leader agent is archived"}
+ wrapped := fmt.Errorf("dispatch run_only: %w", base)
+
+ var got *errDispatchSkipped
+ if !errors.As(wrapped, &got) {
+ t.Fatalf("errors.As did not match errDispatchSkipped through fmt.Errorf wrap")
+ }
+ if got.reason != base.reason {
+ t.Fatalf("reason mismatch: got %q want %q", got.reason, base.reason)
+ }
+
+ // pgx.ErrNoRows must NOT pass through the same gate — otherwise transient
+ // "row not found" errors that should fail-open via shouldSkipDispatch
+ // would be swallowed silently as skips at the dispatch level.
+ if errors.As(pgx.ErrNoRows, &got) {
+ t.Fatal("pgx.ErrNoRows wrongly satisfied errDispatchSkipped")
+ }
+}
+
+func TestResolveAutopilotLeaderSentinels(t *testing.T) {
+ // Sanity-check the sentinel exported via errors.Is so callers can branch
+ // on "archived" without string-matching the failure reason.
+ if !errors.Is(errSquadArchived, errSquadArchived) {
+ t.Fatal("errSquadArchived must satisfy errors.Is itself")
+ }
+ wrapped := fmt.Errorf("wrap: %w", errSquadArchived)
+ if !errors.Is(wrapped, errSquadArchived) {
+ t.Fatal("errSquadArchived must unwrap through fmt.Errorf")
+ }
+}
diff --git a/server/migrations/096_autopilot_squad_assignee.down.sql b/server/migrations/096_autopilot_squad_assignee.down.sql
new file mode 100644
index 000000000..1c8173bea
--- /dev/null
+++ b/server/migrations/096_autopilot_squad_assignee.down.sql
@@ -0,0 +1,21 @@
+-- Reverts 096_autopilot_squad_assignee.up.sql.
+-- Restoring the agent FK requires every assignee_id to reference a real
+-- agent. Squad-assigned autopilots would dangle, so they are deleted here.
+-- Operators should drain squad-assigned autopilots before rolling back if
+-- they want to preserve the rows.
+
+DROP INDEX IF EXISTS idx_autopilot_run_squad_id;
+
+ALTER TABLE autopilot_run
+ DROP COLUMN IF EXISTS squad_id;
+
+DROP INDEX IF EXISTS idx_autopilot_assignee_type_id;
+
+DELETE FROM autopilot WHERE assignee_type = 'squad';
+
+ALTER TABLE autopilot
+ DROP COLUMN IF EXISTS assignee_type;
+
+ALTER TABLE autopilot
+ ADD CONSTRAINT autopilot_assignee_id_fkey
+ FOREIGN KEY (assignee_id) REFERENCES agent(id) ON DELETE CASCADE;
diff --git a/server/migrations/096_autopilot_squad_assignee.up.sql b/server/migrations/096_autopilot_squad_assignee.up.sql
new file mode 100644
index 000000000..476e0ee4a
--- /dev/null
+++ b/server/migrations/096_autopilot_squad_assignee.up.sql
@@ -0,0 +1,33 @@
+-- Autopilot: support assigning to a squad (MUL-2429).
+--
+-- Path A "Squad-as-Leader": when an autopilot's assignee is a squad, dispatch
+-- still resolves to a single agent (squad.leader_id) — same semantics as a
+-- human manually assigning an issue to that squad. We model this by adding an
+-- assignee_type column and dropping the hard FK on assignee_id so the same
+-- UUID column can reference either agent(id) or squad(id) depending on the
+-- type. Referential integrity is enforced in the application layer (handler
+-- validates the squad/agent is in the workspace; dispatch re-resolves at run
+-- time and skip-records doomed runs instead of crashing).
+
+ALTER TABLE autopilot
+ DROP CONSTRAINT IF EXISTS autopilot_assignee_id_fkey;
+
+ALTER TABLE autopilot
+ ADD COLUMN assignee_type TEXT NOT NULL DEFAULT 'agent'
+ CHECK (assignee_type IN ('agent', 'squad'));
+
+-- Composite index lets lookups discriminate by type cheaply, e.g. "all
+-- autopilots whose assignee is squad X" without scanning the whole table.
+-- The legacy idx_autopilot_assignee(assignee_id) stays for plain id lookups.
+CREATE INDEX IF NOT EXISTS idx_autopilot_assignee_type_id
+ ON autopilot (assignee_type, assignee_id);
+
+-- autopilot_run.squad_id: attribution hook. Populated when ap.assignee_type =
+-- 'squad' so reports can group runs by squad even though the executing agent
+-- (and the cost it accrues) is the leader. First version does not consume
+-- the column; it exists so we never need a backfill.
+ALTER TABLE autopilot_run
+ ADD COLUMN squad_id UUID REFERENCES squad(id) ON DELETE SET NULL;
+
+CREATE INDEX IF NOT EXISTS idx_autopilot_run_squad_id
+ ON autopilot_run (squad_id) WHERE squad_id IS NOT NULL;
diff --git a/server/pkg/db/generated/autopilot.sql.go b/server/pkg/db/generated/autopilot.sql.go
index 3264cc29a..4d56dfe95 100644
--- a/server/pkg/db/generated/autopilot.sql.go
+++ b/server/pkg/db/generated/autopilot.sql.go
@@ -104,19 +104,20 @@ func (q *Queries) ClaimDueScheduleTriggers(ctx context.Context) ([]ClaimDueSched
const createAutopilot = `-- name: CreateAutopilot :one
INSERT INTO autopilot (
- workspace_id, title, description, assignee_id,
+ workspace_id, title, description, assignee_type, assignee_id,
status, execution_mode, issue_title_template,
created_by_type, created_by_id
) VALUES (
- $1, $2, $8, $3,
- $4, $5, $9,
- $6, $7
-) RETURNING id, workspace_id, title, description, assignee_id, status, execution_mode, issue_title_template, created_by_type, created_by_id, last_run_at, created_at, updated_at
+ $1, $2, $9, $3, $4,
+ $5, $6, $10,
+ $7, $8
+) RETURNING id, workspace_id, title, description, assignee_id, status, execution_mode, issue_title_template, created_by_type, created_by_id, last_run_at, created_at, updated_at, assignee_type
`
type CreateAutopilotParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Title string `json:"title"`
+ AssigneeType string `json:"assignee_type"`
AssigneeID pgtype.UUID `json:"assignee_id"`
Status string `json:"status"`
ExecutionMode string `json:"execution_mode"`
@@ -130,6 +131,7 @@ func (q *Queries) CreateAutopilot(ctx context.Context, arg CreateAutopilotParams
row := q.db.QueryRow(ctx, createAutopilot,
arg.WorkspaceID,
arg.Title,
+ arg.AssigneeType,
arg.AssigneeID,
arg.Status,
arg.ExecutionMode,
@@ -153,6 +155,7 @@ func (q *Queries) CreateAutopilot(ctx context.Context, arg CreateAutopilotParams
&i.LastRunAt,
&i.CreatedAt,
&i.UpdatedAt,
+ &i.AssigneeType,
)
return i, err
}
@@ -160,10 +163,11 @@ func (q *Queries) CreateAutopilot(ctx context.Context, arg CreateAutopilotParams
const createAutopilotRun = `-- name: CreateAutopilotRun :one
INSERT INTO autopilot_run (
- autopilot_id, trigger_id, source, status, trigger_payload
+ autopilot_id, trigger_id, source, status, trigger_payload, squad_id
) VALUES (
- $1, $4, $2, $3, $5
-) RETURNING id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at
+ $1, $4, $2, $3, $5,
+ $6
+) RETURNING id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at, squad_id
`
type CreateAutopilotRunParams struct {
@@ -172,11 +176,16 @@ type CreateAutopilotRunParams struct {
Status string `json:"status"`
TriggerID pgtype.UUID `json:"trigger_id"`
TriggerPayload []byte `json:"trigger_payload"`
+ SquadID pgtype.UUID `json:"squad_id"`
}
// =====================
// Autopilot Run Management
// =====================
+// squad_id is an attribution hook: set to the assignee squad when the
+// parent autopilot has assignee_type='squad', NULL otherwise. The executing
+// agent_id on agent_task_queue still records who actually ran the work
+// (the squad leader); squad_id lets reports group by squad without a join.
func (q *Queries) CreateAutopilotRun(ctx context.Context, arg CreateAutopilotRunParams) (AutopilotRun, error) {
row := q.db.QueryRow(ctx, createAutopilotRun,
arg.AutopilotID,
@@ -184,6 +193,7 @@ func (q *Queries) CreateAutopilotRun(ctx context.Context, arg CreateAutopilotRun
arg.Status,
arg.TriggerID,
arg.TriggerPayload,
+ arg.SquadID,
)
var i AutopilotRun
err := row.Scan(
@@ -200,6 +210,7 @@ func (q *Queries) CreateAutopilotRun(ctx context.Context, arg CreateAutopilotRun
&i.TriggerPayload,
&i.Result,
&i.CreatedAt,
+ &i.SquadID,
)
return i, err
}
@@ -349,7 +360,7 @@ func (q *Queries) FailAutopilotRunsByIssue(ctx context.Context, issueID pgtype.U
}
const getAutopilot = `-- name: GetAutopilot :one
-SELECT id, workspace_id, title, description, assignee_id, status, execution_mode, issue_title_template, created_by_type, created_by_id, last_run_at, created_at, updated_at FROM autopilot
+SELECT id, workspace_id, title, description, assignee_id, status, execution_mode, issue_title_template, created_by_type, created_by_id, last_run_at, created_at, updated_at, assignee_type FROM autopilot
WHERE id = $1
`
@@ -370,12 +381,13 @@ func (q *Queries) GetAutopilot(ctx context.Context, id pgtype.UUID) (Autopilot,
&i.LastRunAt,
&i.CreatedAt,
&i.UpdatedAt,
+ &i.AssigneeType,
)
return i, err
}
const getAutopilotInWorkspace = `-- name: GetAutopilotInWorkspace :one
-SELECT id, workspace_id, title, description, assignee_id, status, execution_mode, issue_title_template, created_by_type, created_by_id, last_run_at, created_at, updated_at FROM autopilot
+SELECT id, workspace_id, title, description, assignee_id, status, execution_mode, issue_title_template, created_by_type, created_by_id, last_run_at, created_at, updated_at, assignee_type FROM autopilot
WHERE id = $1 AND workspace_id = $2
`
@@ -401,12 +413,13 @@ func (q *Queries) GetAutopilotInWorkspace(ctx context.Context, arg GetAutopilotI
&i.LastRunAt,
&i.CreatedAt,
&i.UpdatedAt,
+ &i.AssigneeType,
)
return i, err
}
const getAutopilotRun = `-- name: GetAutopilotRun :one
-SELECT id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at FROM autopilot_run
+SELECT id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at, squad_id FROM autopilot_run
WHERE id = $1
`
@@ -427,13 +440,14 @@ func (q *Queries) GetAutopilotRun(ctx context.Context, id pgtype.UUID) (Autopilo
&i.TriggerPayload,
&i.Result,
&i.CreatedAt,
+ &i.SquadID,
)
return i, err
}
const getAutopilotRunByIssue = `-- name: GetAutopilotRunByIssue :one
-SELECT id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at FROM autopilot_run
+SELECT id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at, squad_id FROM autopilot_run
WHERE issue_id = $1 AND status IN ('issue_created', 'running')
LIMIT 1
`
@@ -458,6 +472,7 @@ func (q *Queries) GetAutopilotRunByIssue(ctx context.Context, issueID pgtype.UUI
&i.TriggerPayload,
&i.Result,
&i.CreatedAt,
+ &i.SquadID,
)
return i, err
}
@@ -544,7 +559,7 @@ func (q *Queries) GetWebhookTriggerByToken(ctx context.Context, webhookToken pgt
}
const listAutopilotRuns = `-- name: ListAutopilotRuns :many
-SELECT id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at FROM autopilot_run
+SELECT id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at, squad_id FROM autopilot_run
WHERE autopilot_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
@@ -579,6 +594,7 @@ func (q *Queries) ListAutopilotRuns(ctx context.Context, arg ListAutopilotRunsPa
&i.TriggerPayload,
&i.Result,
&i.CreatedAt,
+ &i.SquadID,
); err != nil {
return nil, err
}
@@ -637,7 +653,7 @@ func (q *Queries) ListAutopilotTriggers(ctx context.Context, autopilotID pgtype.
const listAutopilots = `-- name: ListAutopilots :many
-SELECT id, workspace_id, title, description, assignee_id, status, execution_mode, issue_title_template, created_by_type, created_by_id, last_run_at, created_at, updated_at FROM autopilot
+SELECT id, workspace_id, title, description, assignee_id, status, execution_mode, issue_title_template, created_by_type, created_by_id, last_run_at, created_at, updated_at, assignee_type FROM autopilot
WHERE workspace_id = $1
AND ($2::text IS NULL OR status = $2)
ORDER BY created_at DESC
@@ -674,6 +690,7 @@ func (q *Queries) ListAutopilots(ctx context.Context, arg ListAutopilotsParams)
&i.LastRunAt,
&i.CreatedAt,
&i.UpdatedAt,
+ &i.AssigneeType,
); err != nil {
return nil, err
}
@@ -963,7 +980,7 @@ const systemPauseAutopilot = `-- name: SystemPauseAutopilot :one
UPDATE autopilot
SET status = 'paused', updated_at = now()
WHERE id = $1 AND status = 'active'
-RETURNING id, workspace_id, title, description, assignee_id, status, execution_mode, issue_title_template, created_by_type, created_by_id, last_run_at, created_at, updated_at
+RETURNING id, workspace_id, title, description, assignee_id, status, execution_mode, issue_title_template, created_by_type, created_by_id, last_run_at, created_at, updated_at, assignee_type
`
// Atomically pauses an autopilot only if it is currently active. Returns no
@@ -987,6 +1004,7 @@ func (q *Queries) SystemPauseAutopilot(ctx context.Context, id pgtype.UUID) (Aut
&i.LastRunAt,
&i.CreatedAt,
&i.UpdatedAt,
+ &i.AssigneeType,
)
return i, err
}
@@ -1011,19 +1029,21 @@ const updateAutopilot = `-- name: UpdateAutopilot :one
UPDATE autopilot SET
title = COALESCE($2, title),
description = COALESCE($3, description),
- assignee_id = COALESCE($4::uuid, assignee_id),
- status = COALESCE($5, status),
- execution_mode = COALESCE($6, execution_mode),
- issue_title_template = $7,
+ assignee_type = COALESCE($4, assignee_type),
+ assignee_id = COALESCE($5::uuid, assignee_id),
+ status = COALESCE($6, status),
+ execution_mode = COALESCE($7, execution_mode),
+ issue_title_template = $8,
updated_at = now()
WHERE id = $1
-RETURNING id, workspace_id, title, description, assignee_id, status, execution_mode, issue_title_template, created_by_type, created_by_id, last_run_at, created_at, updated_at
+RETURNING id, workspace_id, title, description, assignee_id, status, execution_mode, issue_title_template, created_by_type, created_by_id, last_run_at, created_at, updated_at, assignee_type
`
type UpdateAutopilotParams struct {
ID pgtype.UUID `json:"id"`
Title pgtype.Text `json:"title"`
Description pgtype.Text `json:"description"`
+ AssigneeType pgtype.Text `json:"assignee_type"`
AssigneeID pgtype.UUID `json:"assignee_id"`
Status pgtype.Text `json:"status"`
ExecutionMode pgtype.Text `json:"execution_mode"`
@@ -1035,6 +1055,7 @@ func (q *Queries) UpdateAutopilot(ctx context.Context, arg UpdateAutopilotParams
arg.ID,
arg.Title,
arg.Description,
+ arg.AssigneeType,
arg.AssigneeID,
arg.Status,
arg.ExecutionMode,
@@ -1055,6 +1076,7 @@ func (q *Queries) UpdateAutopilot(ctx context.Context, arg UpdateAutopilotParams
&i.LastRunAt,
&i.CreatedAt,
&i.UpdatedAt,
+ &i.AssigneeType,
)
return i, err
}
@@ -1073,7 +1095,7 @@ const updateAutopilotRunCompleted = `-- name: UpdateAutopilotRunCompleted :one
UPDATE autopilot_run
SET status = 'completed', completed_at = now(), result = $2
WHERE id = $1
-RETURNING id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at
+RETURNING id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at, squad_id
`
type UpdateAutopilotRunCompletedParams struct {
@@ -1098,6 +1120,7 @@ func (q *Queries) UpdateAutopilotRunCompleted(ctx context.Context, arg UpdateAut
&i.TriggerPayload,
&i.Result,
&i.CreatedAt,
+ &i.SquadID,
)
return i, err
}
@@ -1106,7 +1129,7 @@ const updateAutopilotRunFailed = `-- name: UpdateAutopilotRunFailed :one
UPDATE autopilot_run
SET status = 'failed', completed_at = now(), failure_reason = $2
WHERE id = $1
-RETURNING id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at
+RETURNING id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at, squad_id
`
type UpdateAutopilotRunFailedParams struct {
@@ -1131,6 +1154,7 @@ func (q *Queries) UpdateAutopilotRunFailed(ctx context.Context, arg UpdateAutopi
&i.TriggerPayload,
&i.Result,
&i.CreatedAt,
+ &i.SquadID,
)
return i, err
}
@@ -1139,7 +1163,7 @@ const updateAutopilotRunIssueCreated = `-- name: UpdateAutopilotRunIssueCreated
UPDATE autopilot_run
SET status = 'issue_created', issue_id = $2
WHERE id = $1
-RETURNING id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at
+RETURNING id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at, squad_id
`
type UpdateAutopilotRunIssueCreatedParams struct {
@@ -1164,6 +1188,7 @@ func (q *Queries) UpdateAutopilotRunIssueCreated(ctx context.Context, arg Update
&i.TriggerPayload,
&i.Result,
&i.CreatedAt,
+ &i.SquadID,
)
return i, err
}
@@ -1172,7 +1197,7 @@ const updateAutopilotRunRunning = `-- name: UpdateAutopilotRunRunning :one
UPDATE autopilot_run
SET status = 'running', task_id = $2
WHERE id = $1
-RETURNING id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at
+RETURNING id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at, squad_id
`
type UpdateAutopilotRunRunningParams struct {
@@ -1197,6 +1222,7 @@ func (q *Queries) UpdateAutopilotRunRunning(ctx context.Context, arg UpdateAutop
&i.TriggerPayload,
&i.Result,
&i.CreatedAt,
+ &i.SquadID,
)
return i, err
}
@@ -1205,7 +1231,7 @@ const updateAutopilotRunSkipped = `-- name: UpdateAutopilotRunSkipped :one
UPDATE autopilot_run
SET status = 'skipped', completed_at = now(), failure_reason = $2
WHERE id = $1
-RETURNING id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at
+RETURNING id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at, squad_id
`
type UpdateAutopilotRunSkippedParams struct {
@@ -1236,6 +1262,7 @@ func (q *Queries) UpdateAutopilotRunSkipped(ctx context.Context, arg UpdateAutop
&i.TriggerPayload,
&i.Result,
&i.CreatedAt,
+ &i.SquadID,
)
return i, err
}
@@ -1247,7 +1274,7 @@ SET status = 'skipped',
failure_reason = $2,
result = $3
WHERE id = $1
-RETURNING id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at
+RETURNING id, autopilot_id, trigger_id, source, status, issue_id, task_id, triggered_at, completed_at, failure_reason, trigger_payload, result, created_at, squad_id
`
type UpdateAutopilotRunSkippedWithResultParams struct {
@@ -1273,6 +1300,7 @@ func (q *Queries) UpdateAutopilotRunSkippedWithResult(ctx context.Context, arg U
&i.TriggerPayload,
&i.Result,
&i.CreatedAt,
+ &i.SquadID,
)
return i, err
}
diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go
index f4cbae7b4..cc189849e 100644
--- a/server/pkg/db/generated/models.go
+++ b/server/pkg/db/generated/models.go
@@ -127,6 +127,7 @@ type Autopilot struct {
LastRunAt pgtype.Timestamptz `json:"last_run_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+ AssigneeType string `json:"assignee_type"`
}
type AutopilotRun struct {
@@ -143,6 +144,7 @@ type AutopilotRun struct {
TriggerPayload []byte `json:"trigger_payload"`
Result []byte `json:"result"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
+ SquadID pgtype.UUID `json:"squad_id"`
}
type AutopilotTrigger struct {
diff --git a/server/pkg/db/generated/runtime.sql.go b/server/pkg/db/generated/runtime.sql.go
index 5a22e281c..a9bacdb9b 100644
--- a/server/pkg/db/generated/runtime.sql.go
+++ b/server/pkg/db/generated/runtime.sql.go
@@ -549,6 +549,33 @@ func (q *Queries) ListAgentRuntimesByOwner(ctx context.Context, arg ListAgentRun
return items, nil
}
+const listArchivedAgentIDsByRuntime = `-- name: ListArchivedAgentIDsByRuntime :many
+SELECT id FROM agent WHERE runtime_id = $1 AND archived_at IS NOT NULL
+`
+
+// Companion to DeleteArchivedAgentsByRuntime: enumerates the archived agents
+// about to be hard-deleted so the runtime teardown can pause autopilots that
+// still point at them. Returns ids only — the caller only needs the set.
+func (q *Queries) ListArchivedAgentIDsByRuntime(ctx context.Context, runtimeID pgtype.UUID) ([]pgtype.UUID, error) {
+ rows, err := q.db.Query(ctx, listArchivedAgentIDsByRuntime, runtimeID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []pgtype.UUID{}
+ for rows.Next() {
+ var id pgtype.UUID
+ if err := rows.Scan(&id); err != nil {
+ return nil, err
+ }
+ items = append(items, id)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const lockTaskUsageDailyRollup = `-- name: LockTaskUsageDailyRollup :exec
SELECT pg_advisory_xact_lock(4242)
`
@@ -654,6 +681,25 @@ func (q *Queries) MarkRuntimesOfflineByIDs(ctx context.Context, arg MarkRuntimes
return items, nil
}
+const pauseAutopilotsByAgentAssignees = `-- name: PauseAutopilotsByAgentAssignees :exec
+UPDATE autopilot
+SET status = 'paused', updated_at = now()
+WHERE status = 'active'
+ AND assignee_type = 'agent'
+ AND assignee_id = ANY($1::uuid[])
+`
+
+// Pauses every active autopilot whose agent assignee is in the supplied list.
+// Called before hard-deleting archived agents on runtime teardown so the rows
+// do not become dangling (autopilot.assignee_id no longer has an agent FK
+// since migration 096). Status='paused' makes the breakage visible in the UI
+// — operators can re-point the autopilot at a live agent or delete it —
+// rather than silently piling skipped runs.
+func (q *Queries) PauseAutopilotsByAgentAssignees(ctx context.Context, assigneeIds []pgtype.UUID) error {
+ _, err := q.db.Exec(ctx, pauseAutopilotsByAgentAssignees, assigneeIds)
+ return err
+}
+
const reassignAgentsToRuntime = `-- name: ReassignAgentsToRuntime :execrows
UPDATE agent
SET runtime_id = $1
diff --git a/server/pkg/db/generated/squad.sql.go b/server/pkg/db/generated/squad.sql.go
index 8412ef640..747c99f56 100644
--- a/server/pkg/db/generated/squad.sql.go
+++ b/server/pkg/db/generated/squad.sql.go
@@ -502,6 +502,30 @@ func (q *Queries) TransferSquadAssignees(ctx context.Context, arg TransferSquadA
return err
}
+const transferSquadAutopilotsToLeader = `-- name: TransferSquadAutopilotsToLeader :exec
+UPDATE autopilot
+SET assignee_type = 'agent',
+ assignee_id = $2,
+ updated_at = now()
+WHERE assignee_type = 'squad' AND assignee_id = $1
+`
+
+type TransferSquadAutopilotsToLeaderParams struct {
+ AssigneeID pgtype.UUID `json:"assignee_id"`
+ AssigneeID_2 pgtype.UUID `json:"assignee_id_2"`
+}
+
+// Mirrors TransferSquadAssignees for autopilot rows: when a squad is archived,
+// any autopilot still pointing at the squad would otherwise dangle and the
+// admission gate would skip every subsequent dispatch with "assignee squad
+// cannot be resolved". Rewrite the assignee in place to the leader agent so
+// the autopilot keeps firing under the same leader-only execution semantics
+// it had a moment before the archive (Path A from MUL-2429).
+func (q *Queries) TransferSquadAutopilotsToLeader(ctx context.Context, arg TransferSquadAutopilotsToLeaderParams) error {
+ _, err := q.db.Exec(ctx, transferSquadAutopilotsToLeader, arg.AssigneeID, arg.AssigneeID_2)
+ return err
+}
+
const updateSquad = `-- name: UpdateSquad :one
UPDATE squad SET
name = COALESCE($2, name),
diff --git a/server/pkg/db/queries/autopilot.sql b/server/pkg/db/queries/autopilot.sql
index 40b4fa869..46ff60990 100644
--- a/server/pkg/db/queries/autopilot.sql
+++ b/server/pkg/db/queries/autopilot.sql
@@ -18,19 +18,20 @@ WHERE id = $1 AND workspace_id = $2;
-- name: CreateAutopilot :one
INSERT INTO autopilot (
- workspace_id, title, description, assignee_id,
+ workspace_id, title, description, assignee_type, assignee_id,
status, execution_mode, issue_title_template,
created_by_type, created_by_id
) VALUES (
- $1, $2, sqlc.narg('description'), $3,
- $4, $5, sqlc.narg('issue_title_template'),
- $6, $7
+ $1, $2, sqlc.narg('description'), $3, $4,
+ $5, $6, sqlc.narg('issue_title_template'),
+ $7, $8
) RETURNING *;
-- name: UpdateAutopilot :one
UPDATE autopilot SET
title = COALESCE(sqlc.narg('title'), title),
description = COALESCE(sqlc.narg('description'), description),
+ assignee_type = COALESCE(sqlc.narg('assignee_type'), assignee_type),
assignee_id = COALESCE(sqlc.narg('assignee_id')::uuid, assignee_id),
status = COALESCE(sqlc.narg('status'), status),
execution_mode = COALESCE(sqlc.narg('execution_mode'), execution_mode),
@@ -153,10 +154,15 @@ RETURNING *;
-- =====================
-- name: CreateAutopilotRun :one
+-- squad_id is an attribution hook: set to the assignee squad when the
+-- parent autopilot has assignee_type='squad', NULL otherwise. The executing
+-- agent_id on agent_task_queue still records who actually ran the work
+-- (the squad leader); squad_id lets reports group by squad without a join.
INSERT INTO autopilot_run (
- autopilot_id, trigger_id, source, status, trigger_payload
+ autopilot_id, trigger_id, source, status, trigger_payload, squad_id
) VALUES (
- $1, sqlc.narg('trigger_id'), $2, $3, sqlc.narg('trigger_payload')
+ $1, sqlc.narg('trigger_id'), $2, $3, sqlc.narg('trigger_payload'),
+ sqlc.narg('squad_id')
) RETURNING *;
-- name: GetAutopilotRun :one
diff --git a/server/pkg/db/queries/runtime.sql b/server/pkg/db/queries/runtime.sql
index c8d0284f8..a174e1668 100644
--- a/server/pkg/db/queries/runtime.sql
+++ b/server/pkg/db/queries/runtime.sql
@@ -249,6 +249,25 @@ SELECT count(*) FROM agent WHERE runtime_id = $1 AND archived_at IS NULL;
-- name: DeleteArchivedAgentsByRuntime :exec
DELETE FROM agent WHERE runtime_id = $1 AND archived_at IS NOT NULL;
+-- name: PauseAutopilotsByAgentAssignees :exec
+-- Pauses every active autopilot whose agent assignee is in the supplied list.
+-- Called before hard-deleting archived agents on runtime teardown so the rows
+-- do not become dangling (autopilot.assignee_id no longer has an agent FK
+-- since migration 096). Status='paused' makes the breakage visible in the UI
+-- — operators can re-point the autopilot at a live agent or delete it —
+-- rather than silently piling skipped runs.
+UPDATE autopilot
+SET status = 'paused', updated_at = now()
+WHERE status = 'active'
+ AND assignee_type = 'agent'
+ AND assignee_id = ANY(@assignee_ids::uuid[]);
+
+-- name: ListArchivedAgentIDsByRuntime :many
+-- Companion to DeleteArchivedAgentsByRuntime: enumerates the archived agents
+-- about to be hard-deleted so the runtime teardown can pause autopilots that
+-- still point at them. Returns ids only — the caller only needs the set.
+SELECT id FROM agent WHERE runtime_id = $1 AND archived_at IS NOT NULL;
+
-- name: FindLegacyRuntimesByDaemonID :many
-- Looks up runtime rows keyed on a prior (hostname-derived) daemon_id. Used
-- at register-time to find rows owned by the same machine under its old
diff --git a/server/pkg/db/queries/squad.sql b/server/pkg/db/queries/squad.sql
index 5f958cd5b..97e9bae46 100644
--- a/server/pkg/db/queries/squad.sql
+++ b/server/pkg/db/queries/squad.sql
@@ -73,6 +73,19 @@ ORDER BY s.created_at ASC;
UPDATE issue SET assignee_type = 'agent', assignee_id = $2, updated_at = now()
WHERE assignee_type = 'squad' AND assignee_id = $1;
+-- name: TransferSquadAutopilotsToLeader :exec
+-- Mirrors TransferSquadAssignees for autopilot rows: when a squad is archived,
+-- any autopilot still pointing at the squad would otherwise dangle and the
+-- admission gate would skip every subsequent dispatch with "assignee squad
+-- cannot be resolved". Rewrite the assignee in place to the leader agent so
+-- the autopilot keeps firing under the same leader-only execution semantics
+-- it had a moment before the archive (Path A from MUL-2429).
+UPDATE autopilot
+SET assignee_type = 'agent',
+ assignee_id = $2,
+ updated_at = now()
+WHERE assignee_type = 'squad' AND assignee_id = $1;
+
-- name: ListSquadMemberStatusRows :many
-- Per-row join used to build the squad-members status view. One row per
-- (squad_member × active_task); members with no active task return a