mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 19:59:20 +02:00
Compare commits
4 Commits
fix/cloud-
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
540097dbd6 | ||
|
|
d01d7607c0 | ||
|
|
9d8ca8fc45 | ||
|
|
6223a82fb5 |
@@ -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;
|
||||
|
||||
@@ -90,6 +90,7 @@ export type {
|
||||
Autopilot,
|
||||
AutopilotStatus,
|
||||
AutopilotExecutionMode,
|
||||
AutopilotAssigneeType,
|
||||
AutopilotTrigger,
|
||||
AutopilotTriggerKind,
|
||||
AutopilotRun,
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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": "搜索时区..."
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
43
server/internal/service/agent_ready.go
Normal file
43
server/internal/service/agent_ready.go
Normal 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
|
||||
}
|
||||
@@ -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"):
|
||||
|
||||
96
server/internal/service/autopilot_squad_test.go
Normal file
96
server/internal/service/autopilot_squad_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
21
server/migrations/096_autopilot_squad_assignee.down.sql
Normal file
21
server/migrations/096_autopilot_squad_assignee.down.sql
Normal 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;
|
||||
33
server/migrations/096_autopilot_squad_assignee.up.sql
Normal file
33
server/migrations/096_autopilot_squad_assignee.up.sql
Normal 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;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user