mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 04:56:20 +02:00
* feat(autopilot): support assigning autopilot to a squad (MUL-2429) Path A (Squad-as-Leader) from the RFC: when an autopilot's assignee is a squad, dispatch resolves to squad.leader_id and executes against the leader's runtime — semantics match a human manually assigning the issue to that squad, no fan-out. Backend scope only; frontend picker change is a follow-up PR. Changes: - 096_autopilot_squad_assignee migration: drop agent FK on autopilot.assignee_id, add assignee_type column (default 'agent'), add autopilot_run.squad_id attribution column. - service.AgentReadiness: single source of truth for archived / runtime-bound / runtime-online checks. Shared by autopilot admission gate, run_only dispatch, and isSquadLeaderReady. - service.resolveAutopilotLeader: translates assignee_type/id to the agent that actually runs the work. - dispatchCreateIssue: stamps issue with assignee_type='squad' for squad autopilots and enqueues via EnqueueTaskForSquadLeader. - dispatchRunOnly: belt-and-braces readiness re-check after resolving squad → leader so a leader that went offline between admission and dispatch produces a clean failure instead of a doomed task. - handler.CreateAutopilot / UpdateAutopilot: accept assignee_type with squad/agent existence + leader-archived validation. Backward-compatible default of "agent" preserves the contract for older clients. - Analytics: AutopilotRunStarted/Completed/Failed events carry assignee_type and squad_id; PostHog can now group autopilot runs by squad without joining back to the autopilot row. Co-authored-by: multica-agent <github@multica.ai> * fix(autopilot): reject archived squads, route post-admission skips, cleanup dangling-agent autopilots (MUL-2429) Addresses three review findings on PR #2888: 1. Archived squad handling: validateAutopilotAssignee now rejects squads with archived_at set; resolveAutopilotLeader returns errSquadArchived so the admission gate fails closed; DeleteSquad now mirrors the issue transfer for autopilot rows (TransferSquadAutopilotsToLeader) so surviving autopilots flip to assignee_type='agent' (leader) instead of dangling at the archived squad. 2. dispatchRunOnly post-admission readiness: introduces errDispatchSkipped sentinel, recognised by DispatchAutopilot via handleDispatchSkip so the run is recorded as `skipped` (not `failed`). Manual triggers no longer 500 when the leader's runtime goes offline between admission and task creation. New TestManualTriggerDoesNotErrorOnPostAdmissionSkip locks the behaviour in. 3. Dangling agent assignee after migration 096 dropped the FK: shouldSkipDispatch now distinguishes pgx.ErrNoRows / errSquadArchived (hard skip — retrying won't help) from transient DB errors (fail-open). DeleteAgentRuntime pauses autopilots that target agents about to be hard-deleted (ListArchivedAgentIDsByRuntime + PauseAutopilotsByAgentAssignees) so the breakage surfaces as a paused row in the UI instead of a quiet skip-burning loop. Unit tests cover the sentinel unwrap contract and errSquadArchived errors.Is behaviour. Integration test TestAutopilotDispatchSkipsWhenRuntimeOffline re-verified against a fresh DB with migration 096 applied. Co-authored-by: multica-agent <github@multica.ai> * fix(autopilot): bump last_run_at on post-admission skip (MUL-2429) Match recordSkippedRun (pre-flight skip) and the success path so the scheduler / "last seen" UI both reflect that this tick evaluated the trigger, even when the post-admission readiness gate caught a late regression. Addresses Emacs review caveat #1 on PR #2888. Co-authored-by: multica-agent <github@multica.ai> * feat(autopilot): mixed agent/squad assignee picker in dialog (MUL-2429) End-to-end UI for assigning an autopilot to a squad. Closes the PR #2888 backend gap: the squad-as-assignee feature was already wired in Go (Path A, RFC §4) but the desktop dialog never offered the choice. - core/types/autopilot: add `AutopilotAssigneeType`, surface `assignee_type` on `Autopilot` + Create/Update request payloads. - views/autopilots/pickers/agent-picker: switch to a polymorphic AssigneeSelection (`{type, id}`); render agents and squads as two grouped sections with shared pinyin search. - views/autopilots/autopilot-dialog: maintain `assigneeType` state, send it on create/update, render the trigger avatar / hover dot with `assignee.type`. - views/autopilots/autopilots-page + autopilot-detail-page: render the assignee row using `autopilot.assignee_type` so squad-typed autopilots show the squad avatar + name, not a broken agent lookup. - locales: add `agents_group` / `squads_group` / `select_assignee` keys (en + zh-Hans), keep legacy `select_agent` for callers that still reference it. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
97 lines
3.6 KiB
Go
97 lines
3.6 KiB
Go
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")
|
|
}
|
|
}
|