From fc8528d64ddd34c15b6057fb1463cf4d2aa4fbc0 Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Wed, 20 May 2026 05:30:13 +0200 Subject: [PATCH] feat(autopilot): support assigning to a squad (MUL-2429) (#2888) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 --------- Co-authored-by: Lambda Co-authored-by: multica-agent --- packages/core/types/autopilot.ts | 13 + packages/core/types/index.ts | 1 + .../components/autopilot-detail-page.tsx | 15 +- .../components/autopilot-dialog.tsx | 54 ++- .../autopilots/components/autopilots-page.tsx | 12 +- .../components/pickers/agent-picker.tsx | 102 ++++-- packages/views/locales/en/autopilots.json | 9 +- .../views/locales/zh-Hans/autopilots.json | 9 +- .../server/autopilot_failure_monitor_test.go | 1 + server/cmd/server/autopilot_listeners_test.go | 89 +++++ server/internal/analytics/events.go | 33 +- server/internal/analytics/events_test.go | 2 +- server/internal/handler/autopilot.go | 149 +++++++- server/internal/handler/runtime.go | 19 + server/internal/handler/squad.go | 29 +- server/internal/service/agent_ready.go | 43 +++ server/internal/service/autopilot.go | 329 ++++++++++++++++-- .../internal/service/autopilot_squad_test.go | 96 +++++ .../096_autopilot_squad_assignee.down.sql | 21 ++ .../096_autopilot_squad_assignee.up.sql | 33 ++ server/pkg/db/generated/autopilot.sql.go | 80 +++-- server/pkg/db/generated/models.go | 2 + server/pkg/db/generated/runtime.sql.go | 46 +++ server/pkg/db/generated/squad.sql.go | 24 ++ server/pkg/db/queries/autopilot.sql | 18 +- server/pkg/db/queries/runtime.sql | 19 + server/pkg/db/queries/squad.sql | 13 + 27 files changed, 1108 insertions(+), 153 deletions(-) create mode 100644 server/internal/service/agent_ready.go create mode 100644 server/internal/service/autopilot_squad_test.go create mode 100644 server/migrations/096_autopilot_squad_assignee.down.sql create mode 100644 server/migrations/096_autopilot_squad_assignee.up.sql diff --git a/packages/core/types/autopilot.ts b/packages/core/types/autopilot.ts index 4e0a0022b..79b85934e 100644 --- a/packages/core/types/autopilot.ts +++ b/packages/core/types/autopilot.ts @@ -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; diff --git a/packages/core/types/index.ts b/packages/core/types/index.ts index c8dbb3e67..d5dcd0045 100644 --- a/packages/core/types/index.ts +++ b/packages/core/types/index.ts @@ -90,6 +90,7 @@ export type { Autopilot, AutopilotStatus, AutopilotExecutionMode, + AutopilotAssigneeType, AutopilotTrigger, AutopilotTriggerKind, AutopilotRun, diff --git a/packages/views/autopilots/components/autopilot-detail-page.tsx b/packages/views/autopilots/components/autopilot-detail-page.tsx index e9e4aa095..9232ce75d 100644 --- a/packages/views/autopilots/components/autopilot-detail-page.tsx +++ b/packages/views/autopilots/components/autopilot-detail-page.tsx @@ -724,8 +724,16 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
- - {getActorName("agent", autopilot.assignee_id)} + + + {getActorName(autopilot.assignee_type, autopilot.assignee_id)} +
@@ -796,7 +804,7 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) { )} @@ -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, }} diff --git a/packages/views/autopilots/components/autopilot-dialog.tsx b/packages/views/autopilots/components/autopilot-dialog.tsx index 82faa655f..ccb2b5263 100644 --- a/packages/views/autopilots/components/autopilot-dialog.tsx +++ b/packages/views/autopilots/components/autopilot-dialog.tsx @@ -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( + initial.assignee_type ?? "agent", + ); const [assigneeId, setAssigneeId] = useState(initial.assignee_id ?? ""); const [executionMode, setExecutionMode] = useState( 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 */}