Compare commits

...

4 Commits

Author SHA1 Message Date
Lambda
540097dbd6 feat(autopilot): mixed agent/squad assignee picker in dialog (MUL-2429)
End-to-end UI for assigning an autopilot to a squad. Closes the PR #2888
backend gap: the squad-as-assignee feature was already wired in Go (Path A,
RFC §4) but the desktop dialog never offered the choice.

- core/types/autopilot: add `AutopilotAssigneeType`, surface
  `assignee_type` on `Autopilot` + Create/Update request payloads.
- views/autopilots/pickers/agent-picker: switch to a polymorphic
  AssigneeSelection (`{type, id}`); render agents and squads as two
  grouped sections with shared pinyin search.
- views/autopilots/autopilot-dialog: maintain `assigneeType` state, send
  it on create/update, render the trigger avatar / hover dot with
  `assignee.type`.
- views/autopilots/autopilots-page + autopilot-detail-page: render the
  assignee row using `autopilot.assignee_type` so squad-typed autopilots
  show the squad avatar + name, not a broken agent lookup.
- locales: add `agents_group` / `squads_group` / `select_assignee` keys
  (en + zh-Hans), keep legacy `select_agent` for callers that still
  reference it.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 11:16:57 +08:00
Lambda
d01d7607c0 fix(autopilot): bump last_run_at on post-admission skip (MUL-2429)
Match recordSkippedRun (pre-flight skip) and the success path so the
scheduler / "last seen" UI both reflect that this tick evaluated the
trigger, even when the post-admission readiness gate caught a late
regression.

Addresses Emacs review caveat #1 on PR #2888.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 02:05:29 +08:00
Lambda
9d8ca8fc45 fix(autopilot): reject archived squads, route post-admission skips, cleanup dangling-agent autopilots (MUL-2429)
Addresses three review findings on PR #2888:

1. Archived squad handling: validateAutopilotAssignee now rejects squads
   with archived_at set; resolveAutopilotLeader returns errSquadArchived
   so the admission gate fails closed; DeleteSquad now mirrors the issue
   transfer for autopilot rows (TransferSquadAutopilotsToLeader) so
   surviving autopilots flip to assignee_type='agent' (leader) instead
   of dangling at the archived squad.

2. dispatchRunOnly post-admission readiness: introduces errDispatchSkipped
   sentinel, recognised by DispatchAutopilot via handleDispatchSkip so
   the run is recorded as `skipped` (not `failed`). Manual triggers no
   longer 500 when the leader's runtime goes offline between admission
   and task creation. New TestManualTriggerDoesNotErrorOnPostAdmissionSkip
   locks the behaviour in.

3. Dangling agent assignee after migration 096 dropped the FK:
   shouldSkipDispatch now distinguishes pgx.ErrNoRows / errSquadArchived
   (hard skip — retrying won't help) from transient DB errors
   (fail-open). DeleteAgentRuntime pauses autopilots that target agents
   about to be hard-deleted (ListArchivedAgentIDsByRuntime +
   PauseAutopilotsByAgentAssignees) so the breakage surfaces as a paused
   row in the UI instead of a quiet skip-burning loop.

Unit tests cover the sentinel unwrap contract and errSquadArchived
errors.Is behaviour. Integration test
TestAutopilotDispatchSkipsWhenRuntimeOffline re-verified against a fresh
DB with migration 096 applied.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 01:53:34 +08:00
Lambda
6223a82fb5 feat(autopilot): support assigning autopilot to a squad (MUL-2429)
Path A (Squad-as-Leader) from the RFC: when an autopilot's assignee is a
squad, dispatch resolves to squad.leader_id and executes against the
leader's runtime — semantics match a human manually assigning the issue
to that squad, no fan-out.

Backend scope only; frontend picker change is a follow-up PR.

Changes:
- 096_autopilot_squad_assignee migration: drop agent FK on
  autopilot.assignee_id, add assignee_type column (default 'agent'),
  add autopilot_run.squad_id attribution column.
- service.AgentReadiness: single source of truth for archived /
  runtime-bound / runtime-online checks. Shared by autopilot
  admission gate, run_only dispatch, and isSquadLeaderReady.
- service.resolveAutopilotLeader: translates assignee_type/id to the
  agent that actually runs the work.
- dispatchCreateIssue: stamps issue with assignee_type='squad' for
  squad autopilots and enqueues via EnqueueTaskForSquadLeader.
- dispatchRunOnly: belt-and-braces readiness re-check after resolving
  squad → leader so a leader that went offline between admission and
  dispatch produces a clean failure instead of a doomed task.
- handler.CreateAutopilot / UpdateAutopilot: accept assignee_type with
  squad/agent existence + leader-archived validation. Backward-compatible
  default of "agent" preserves the contract for older clients.
- Analytics: AutopilotRunStarted/Completed/Failed events carry
  assignee_type and squad_id; PostHog can now group autopilot runs by
  squad without joining back to the autopilot row.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 01:32:57 +08:00
27 changed files with 1108 additions and 153 deletions

View File

@@ -2,6 +2,12 @@ export type AutopilotStatus = "active" | "paused" | "archived";
export type AutopilotExecutionMode = "create_issue" | "run_only";
// `assignee_type` selects which polymorphic actor backs the autopilot:
// "agent" → assignee_id references agent(id); "squad" → assignee_id references
// squad(id) and dispatch resolves to squad.leader_id at run time (MUL-2429,
// Path A). Older servers omit this field — callers should default to "agent".
export type AutopilotAssigneeType = "agent" | "squad";
export type AutopilotTriggerKind = "schedule" | "webhook" | "api";
// `skipped` is emitted by the backend pre-flight admission check
@@ -22,6 +28,7 @@ export interface Autopilot {
workspace_id: string;
title: string;
description: string | null;
assignee_type: AutopilotAssigneeType;
assignee_id: string;
status: AutopilotStatus;
execution_mode: AutopilotExecutionMode;
@@ -75,6 +82,9 @@ export interface AutopilotRun {
export interface CreateAutopilotRequest {
title: string;
description?: string;
// Optional on the wire — when omitted the server defaults to "agent" so
// older clients keep working.
assignee_type?: AutopilotAssigneeType;
assignee_id: string;
execution_mode: AutopilotExecutionMode;
issue_title_template?: string;
@@ -83,6 +93,9 @@ export interface CreateAutopilotRequest {
export interface UpdateAutopilotRequest {
title?: string;
description?: string | null;
// Send `assignee_type` together with `assignee_id` whenever you change the
// assignee — the server requires both for a type swap.
assignee_type?: AutopilotAssigneeType;
assignee_id?: string;
status?: AutopilotStatus;
execution_mode?: AutopilotExecutionMode;

View File

@@ -90,6 +90,7 @@ export type {
Autopilot,
AutopilotStatus,
AutopilotExecutionMode,
AutopilotAssigneeType,
AutopilotTrigger,
AutopilotTriggerKind,
AutopilotRun,

View File

@@ -724,8 +724,16 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
<div>
<label className="text-xs text-muted-foreground">{t(($) => $.detail.field_agent)}</label>
<div className="mt-1 flex items-center gap-2">
<ActorAvatar actorType="agent" actorId={autopilot.assignee_id} size={20} enableHoverCard showStatusDot />
<span className="cursor-pointer">{getActorName("agent", autopilot.assignee_id)}</span>
<ActorAvatar
actorType={autopilot.assignee_type}
actorId={autopilot.assignee_id}
size={20}
enableHoverCard={autopilot.assignee_type === "agent"}
showStatusDot={autopilot.assignee_type === "agent"}
/>
<span className="cursor-pointer">
{getActorName(autopilot.assignee_type, autopilot.assignee_id)}
</span>
</div>
</div>
<div>
@@ -796,7 +804,7 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
<RunHistoryList
runs={runs}
agentId={autopilot.assignee_id}
agentName={getActorName("agent", autopilot.assignee_id)}
agentName={getActorName(autopilot.assignee_type, autopilot.assignee_id)}
/>
)}
</section>
@@ -828,6 +836,7 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
initial={{
title: autopilot.title,
description: autopilot.description ?? "",
assignee_type: autopilot.assignee_type,
assignee_id: autopilot.assignee_id,
execution_mode: autopilot.execution_mode as AutopilotExecutionMode,
}}

View File

@@ -37,7 +37,7 @@ import { TimeInput } from "@multica/ui/components/ui/time-input";
import { TimezonePicker } from "./pickers/timezone-picker";
import { useCurrentWorkspace } from "@multica/core/paths";
import { useWorkspaceId } from "@multica/core/hooks";
import { agentListOptions } from "@multica/core/workspace/queries";
import { agentListOptions, squadListOptions } from "@multica/core/workspace/queries";
import {
useCreateAutopilot,
useCreateAutopilotTrigger,
@@ -47,12 +47,13 @@ import {
import { buildAutopilotWebhookUrl } from "@multica/core/autopilots";
import { api } from "@multica/core/api";
import type {
AutopilotAssigneeType,
AutopilotExecutionMode,
AutopilotTrigger,
} from "@multica/core/types";
import { TitleEditor, ContentEditor } from "../../editor";
import { ActorAvatar } from "../../common/actor-avatar";
import { AgentPicker } from "./pickers/agent-picker";
import { AgentPicker, type AssigneeSelection } from "./pickers/agent-picker";
import {
getDefaultTriggerConfig,
getLocalTimezone,
@@ -71,6 +72,7 @@ import { formatSchedulePartialFailureToast } from "./autopilot-dialog-toast";
export interface AutopilotInitial {
title: string;
description: string;
assignee_type: AutopilotAssigneeType;
assignee_id: string;
execution_mode: AutopilotExecutionMode;
}
@@ -242,6 +244,7 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
const workspaceName = useCurrentWorkspace()?.name;
const wsId = useWorkspaceId();
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: squads = [] } = useQuery(squadListOptions(wsId));
const [isExpanded, setIsExpanded] = useState(false);
const isCreate = props.mode === "create";
@@ -251,6 +254,9 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
const [title, setTitle] = useState(initial.title ?? "");
const [description, setDescription] = useState(initial.description ?? "");
const [assigneeType, setAssigneeType] = useState<AutopilotAssigneeType>(
initial.assignee_type ?? "agent",
);
const [assigneeId, setAssigneeId] = useState<string>(initial.assignee_id ?? "");
const [executionMode, setExecutionMode] = useState<AutopilotExecutionMode>(
initial.execution_mode ?? "create_issue",
@@ -296,10 +302,20 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
const triggerCount = isCreate ? 0 : props.triggers.length;
const schedulePillDisabled = !isCreate && triggerCount >= 2;
const selectedAgent = useMemo(
() => agents.find((a) => a.id === assigneeId) ?? null,
[agents, assigneeId],
);
const selectedAssignee = useMemo(() => {
if (!assigneeId) return null;
if (assigneeType === "squad") {
const squad = squads.find((s) => s.id === assigneeId);
return squad ? { name: squad.name, description: squad.description } : null;
}
const agent = agents.find((a) => a.id === assigneeId);
return agent ? { name: agent.name, description: agent.description } : null;
}, [agents, squads, assigneeId, assigneeType]);
const handleAssigneeChange = (next: AssigneeSelection) => {
setAssigneeType(next.type);
setAssigneeId(next.id);
};
const createAutopilot = useCreateAutopilot();
const createTrigger = useCreateAutopilotTrigger();
@@ -324,6 +340,7 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
const autopilot = await createAutopilot.mutateAsync({
title: title.trim(),
description: description.trim() || undefined,
assignee_type: assigneeType,
assignee_id: assigneeId,
execution_mode: executionMode,
});
@@ -370,6 +387,7 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
id: props.autopilotId,
title: title.trim(),
description: description.trim() || null,
assignee_type: assigneeType,
assignee_id: assigneeId,
execution_mode: executionMode,
});
@@ -548,10 +566,11 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
{/* Right: Configuration */}
<aside className="w-full lg:w-[340px] shrink-0 overflow-y-auto px-5 py-5 space-y-5 bg-muted/30">
<AgentSection
selectedType={assigneeType}
selectedId={assigneeId}
onChange={setAssigneeId}
selectedName={selectedAgent?.name}
selectedDescription={selectedAgent?.description}
onChange={handleAssigneeChange}
selectedName={selectedAssignee?.name}
selectedDescription={selectedAssignee?.description}
/>
<OutputModeSection mode={executionMode} onChange={setExecutionMode} />
@@ -618,22 +637,25 @@ function SectionLabel({ children }: { children: React.ReactNode }) {
}
function AgentSection({
selectedType,
selectedId,
onChange,
selectedName,
selectedDescription,
}: {
selectedType: AutopilotAssigneeType;
selectedId: string;
onChange: (id: string) => void;
onChange: (next: AssigneeSelection) => void;
selectedName?: string;
selectedDescription?: string;
}) {
const { t } = useT("autopilots");
const hasSelection = selectedId.length > 0;
return (
<div>
<SectionLabel>{t(($) => $.dialog.section_agent)}</SectionLabel>
<SectionLabel>{t(($) => $.dialog.section_assignee)}</SectionLabel>
<AgentPicker
agentId={selectedId || null}
assignee={hasSelection ? { type: selectedType, id: selectedId } : null}
onChange={onChange}
align="start"
triggerRender={
@@ -644,12 +666,12 @@ function AgentSection({
"hover:bg-accent/40 transition-colors cursor-pointer",
)}
>
{selectedId ? (
{hasSelection ? (
<ActorAvatar
actorType="agent"
actorType={selectedType}
actorId={selectedId}
size={28}
showStatusDot
showStatusDot={selectedType === "agent"}
/>
) : (
<span className="inline-flex size-7 items-center justify-center rounded-full bg-muted text-muted-foreground">
@@ -658,7 +680,7 @@ function AgentSection({
)}
<span className="flex-1 min-w-0">
<span className="block text-sm font-medium truncate">
{selectedName ?? t(($) => $.dialog.select_agent)}
{selectedName ?? t(($) => $.dialog.select_assignee)}
</span>
{selectedDescription && (
<span className="block text-xs text-muted-foreground truncate">

View File

@@ -146,11 +146,17 @@ function AutopilotRow({ autopilot }: { autopilot: Autopilot }) {
</AppLink>
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 pl-6 text-xs sm:contents sm:pl-0">
{/* Agent */}
{/* Assignee — agent or squad */}
<span className="flex min-w-0 items-center gap-1.5 text-muted-foreground sm:w-32 sm:shrink-0">
<ActorAvatar actorType="agent" actorId={autopilot.assignee_id} size={18} enableHoverCard showStatusDot />
<ActorAvatar
actorType={autopilot.assignee_type}
actorId={autopilot.assignee_id}
size={18}
enableHoverCard={autopilot.assignee_type === "agent"}
showStatusDot={autopilot.assignee_type === "agent"}
/>
<span className="truncate">
{getActorName("agent", autopilot.assignee_id)}
{getActorName(autopilot.assignee_type, autopilot.assignee_id)}
</span>
</span>

View File

@@ -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 (
<PropertyPicker
@@ -53,37 +77,59 @@ export function AgentPicker({
trigger={
customTrigger ?? (
<>
{selected ? (
{assignee && (selectedAgent || selectedSquad) ? (
<>
<ActorAvatar actorType="agent" actorId={selected.id} size={16} showStatusDot />
<span className="truncate">{selected.name}</span>
<ActorAvatar
actorType={assignee.type}
actorId={assignee.id}
size={16}
showStatusDot={assignee.type === "agent"}
/>
<span className="truncate">{selectedName}</span>
</>
) : (
<>
<Bot className="size-3" />
<span>{t(($) => $.agent_picker.select_agent)}</span>
<span>{t(($) => $.agent_picker.select_assignee)}</span>
</>
)}
</>
)
}
>
{filteredAgents.length === 0 ? (
{filteredAgents.length === 0 && filteredSquads.length === 0 ? (
<PickerEmpty />
) : (
filteredAgents.map((a) => (
<PickerItem
key={a.id}
selected={a.id === agentId}
onClick={() => {
onChange(a.id);
setOpen(false);
}}
>
<ActorAvatar actorType="agent" actorId={a.id} size={16} showStatusDot />
<span className="truncate">{a.name}</span>
</PickerItem>
))
<>
{filteredAgents.length > 0 && (
<PickerSection label={t(($) => $.agent_picker.agents_group)}>
{filteredAgents.map((a) => (
<PickerItem
key={a.id}
selected={isSelected("agent", a.id)}
onClick={() => handlePick("agent", a.id)}
>
<ActorAvatar actorType="agent" actorId={a.id} size={16} showStatusDot />
<span className="truncate">{a.name}</span>
</PickerItem>
))}
</PickerSection>
)}
{filteredSquads.length > 0 && (
<PickerSection label={t(($) => $.agent_picker.squads_group)}>
{filteredSquads.map((s) => (
<PickerItem
key={s.id}
selected={isSelected("squad", s.id)}
onClick={() => handlePick("squad", s.id)}
>
<ActorAvatar actorType="squad" actorId={s.id} size={16} />
<span className="truncate">{s.name}</span>
</PickerItem>
))}
</PickerSection>
)}
</>
)}
</PropertyPicker>
);

View File

@@ -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..."

View File

@@ -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": "搜索时区..."

View File

@@ -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",

View File

@@ -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)
}
}

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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")

View File

@@ -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")

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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"):

View File

@@ -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")
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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),

View File

@@ -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

View File

@@ -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

View File

@@ -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