From ced158f7fff3ec456f66344245128d2b04fc5f61 Mon Sep 17 00:00:00 2001 From: Ser Os Date: Thu, 18 Jun 2026 13:20:01 +0300 Subject: [PATCH] OST-1136: add autopilot run cancellation --- CLI_AND_DAEMON.md | 1 + server/cmd/multica/cmd_autopilot.go | 35 +++ server/cmd/multica/cmd_autopilot_test.go | 44 ++++ server/cmd/server/router.go | 2 + server/internal/handler/autopilot.go | 141 +++++++++++ .../internal/handler/autopilot_cancel_test.go | 226 ++++++++++++++++++ .../multica-autopilots/SKILL.md | 5 +- .../references/autopilots-source-map.md | 8 +- ...22_autopilot_run_cancelled_status.down.sql | 4 + .../122_autopilot_run_cancelled_status.up.sql | 4 + server/pkg/db/generated/autopilot.sql.go | 37 +++ server/pkg/db/queries/autopilot.sql | 10 +- 12 files changed, 511 insertions(+), 6 deletions(-) create mode 100644 server/internal/handler/autopilot_cancel_test.go create mode 100644 server/migrations/122_autopilot_run_cancelled_status.down.sql create mode 100644 server/migrations/122_autopilot_run_cancelled_status.up.sql diff --git a/CLI_AND_DAEMON.md b/CLI_AND_DAEMON.md index 3c7fc90028..b69cba8e98 100644 --- a/CLI_AND_DAEMON.md +++ b/CLI_AND_DAEMON.md @@ -668,6 +668,7 @@ multica autopilot trigger # Fires the autopilot once, returns th ```bash multica autopilot runs multica autopilot runs --limit 50 --output json +multica autopilot runs cancel ``` ### Schedule Triggers diff --git a/server/cmd/multica/cmd_autopilot.go b/server/cmd/multica/cmd_autopilot.go index 671ebec95f..85d28f87c7 100644 --- a/server/cmd/multica/cmd_autopilot.go +++ b/server/cmd/multica/cmd_autopilot.go @@ -67,6 +67,13 @@ var autopilotRunsCmd = &cobra.Command{ RunE: runAutopilotRuns, } +var autopilotRunsCancelCmd = &cobra.Command{ + Use: "cancel ", + Short: "Cancel an autopilot run", + Args: exactArgs(1), + RunE: runAutopilotRunCancel, +} + var autopilotTriggerAddCmd = &cobra.Command{ Use: "trigger-add ", Short: "Add a schedule or webhook trigger to an autopilot", @@ -103,6 +110,7 @@ func init() { autopilotCmd.AddCommand(autopilotDeleteCmd) autopilotCmd.AddCommand(autopilotTriggerCmd) autopilotCmd.AddCommand(autopilotRunsCmd) + autopilotRunsCmd.AddCommand(autopilotRunsCancelCmd) autopilotCmd.AddCommand(autopilotTriggerAddCmd) autopilotCmd.AddCommand(autopilotTriggerUpdateCmd) autopilotCmd.AddCommand(autopilotTriggerDeleteCmd) @@ -147,6 +155,7 @@ func init() { autopilotRunsCmd.Flags().Int("limit", 20, "Max number of runs to return") autopilotRunsCmd.Flags().Int("offset", 0, "Pagination offset") autopilotRunsCmd.Flags().String("output", "table", "Output format: table or json") + autopilotRunsCancelCmd.Flags().String("output", "json", "Output format: table or json") // trigger-add — supports schedule and webhook autopilotTriggerAddCmd.Flags().String("kind", "schedule", "Trigger kind: schedule or webhook") @@ -510,6 +519,32 @@ func runAutopilotRuns(cmd *cobra.Command, args []string) error { return nil } +func runAutopilotRunCancel(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + if _, err := requireWorkspaceID(cmd); err != nil { + return err + } + + ctx, cancel := cli.APIContext(context.Background()) + defer cancel() + + var resp map[string]any + if err := client.PostJSON(ctx, "/api/autopilot-runs/"+args[0]+"/cancel", nil, &resp); err != nil { + return fmt.Errorf("cancel autopilot run: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, resp) + } + run, _ := resp["run"].(map[string]any) + fmt.Printf("Autopilot run %s status: %s\n", strVal(run, "id"), strVal(run, "status")) + return nil +} + func runAutopilotTriggerAdd(cmd *cobra.Command, args []string) error { client, err := newAPIClient(cmd) if err != nil { diff --git a/server/cmd/multica/cmd_autopilot_test.go b/server/cmd/multica/cmd_autopilot_test.go index bd38a5ea0b..ecac0ef093 100644 --- a/server/cmd/multica/cmd_autopilot_test.go +++ b/server/cmd/multica/cmd_autopilot_test.go @@ -40,6 +40,12 @@ func newAutopilotUpdateTestCmd() *cobra.Command { return cmd } +func newAutopilotRunCancelTestCmd() *cobra.Command { + cmd := &cobra.Command{Use: "cancel"} + cmd.Flags().String("output", "json", "") + return cmd +} + func TestResolveAgent(t *testing.T) { agentsResp := []map[string]any{ {"id": "11111111-1111-1111-1111-111111111111", "name": "Lambda"}, @@ -128,6 +134,44 @@ func TestResolveAgent(t *testing.T) { }) } +func TestRunAutopilotRunCancelPostsRunScopedEndpoint(t *testing.T) { + const runID = "11111111-1111-1111-1111-111111111111" + + var called bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/autopilot-runs/"+runID+"/cancel" { + http.NotFound(w, r) + return + } + called = true + if r.Method != http.MethodPost { + t.Errorf("method = %s, want POST", r.Method) + } + json.NewEncoder(w).Encode(map[string]any{ + "cancelled": true, + "already_terminal": false, + "cancelled_tasks": 1, + "run": map[string]any{ + "id": runID, + "status": "cancelled", + }, + }) + })) + defer srv.Close() + + t.Setenv("MULTICA_SERVER_URL", srv.URL) + t.Setenv("MULTICA_WORKSPACE_ID", "ws-1") + t.Setenv("MULTICA_TOKEN", "test-token") + + cmd := newAutopilotRunCancelTestCmd() + if err := runAutopilotRunCancel(cmd, []string{runID}); err != nil { + t.Fatalf("runAutopilotRunCancel: %v", err) + } + if !called { + t.Fatal("cancel endpoint was not called") + } +} + func TestRunAutopilotCreateSendsProjectID(t *testing.T) { const ( agentID = "11111111-1111-1111-1111-111111111111" diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 3ba8cb455a..a460b00d26 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -793,6 +793,8 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus // Squad leader evaluation (writes to activity_log) r.Post("/api/issues/{id}/squad-evaluated", h.RecordSquadLeaderEvaluation) + r.Post("/api/autopilot-runs/{runId}/cancel", h.CancelAutopilotRun) + // Autopilots r.Route("/api/autopilots", func(r chi.Router) { r.Get("/", h.ListAutopilots) diff --git a/server/internal/handler/autopilot.go b/server/internal/handler/autopilot.go index e531501310..03128c8f12 100644 --- a/server/internal/handler/autopilot.go +++ b/server/internal/handler/autopilot.go @@ -2,6 +2,7 @@ package handler import ( "encoding/json" + "errors" "fmt" "io" "net/http" @@ -10,6 +11,7 @@ import ( "time" "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/multica-ai/multica/server/internal/analytics" obsmetrics "github.com/multica-ai/multica/server/internal/metrics" @@ -123,6 +125,13 @@ type AutopilotRunResponse struct { CreatedAt string `json:"created_at"` } +type CancelAutopilotRunResponse struct { + Run AutopilotRunResponse `json:"run"` + Cancelled bool `json:"cancelled"` + AlreadyTerminal bool `json:"already_terminal"` + CancelledTasks int `json:"cancelled_tasks"` +} + // ── Converters ────────────────────────────────────────────────────────────── func autopilotToResponse(a db.Autopilot, subscribers []db.AutopilotSubscriber) AutopilotResponse { @@ -1495,6 +1504,138 @@ func (h *Handler) GetAutopilotRun(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, runToResponse(run)) } +func (h *Handler) CancelAutopilotRun(w http.ResponseWriter, r *http.Request) { + userID, ok := requireUserID(w, r) + if !ok { + return + } + workspaceID := h.resolveWorkspaceID(r) + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } + runID := chi.URLParam(r, "runId") + runUUID, ok := parseUUIDOrBadRequest(w, runID, "run id") + if !ok { + return + } + + run, err := h.Queries.GetAutopilotRun(r.Context(), runUUID) + if err != nil { + writeError(w, http.StatusNotFound, "run not found") + return + } + autopilot, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{ + ID: run.AutopilotID, + WorkspaceID: wsUUID, + }) + if err != nil { + writeError(w, http.StatusNotFound, "run not found") + return + } + if !h.canCancelAutopilotRun(r, autopilot, userID, workspaceID) { + writeError(w, http.StatusForbidden, "you do not have access to this autopilot run") + return + } + if autopilotRunStatusTerminal(run.Status) { + writeJSON(w, http.StatusOK, CancelAutopilotRunResponse{ + Run: runToResponse(run), + Cancelled: false, + AlreadyTerminal: true, + CancelledTasks: 0, + }) + return + } + + cancelledTasks := 0 + if run.TaskID.Valid { + cancelled, err := h.TaskService.CancelTaskWithResult(r.Context(), run.TaskID) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if cancelled.Task.Status == "cancelled" { + cancelledTasks++ + } + } + if run.IssueID.Valid { + cancelled, err := h.Queries.CancelAgentTasksByIssue(r.Context(), run.IssueID) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + h.TaskService.BroadcastCancelledTasks(r.Context(), cancelled) + cancelledTasks += len(cancelled) + } + + updated, err := h.Queries.CancelAutopilotRun(r.Context(), db.CancelAutopilotRunParams{ + ID: run.ID, + FailureReason: strToText("cancelled by user"), + }) + if errors.Is(err, pgx.ErrNoRows) { + current, getErr := h.Queries.GetAutopilotRun(r.Context(), run.ID) + if getErr == nil && autopilotRunStatusTerminal(current.Status) { + writeJSON(w, http.StatusOK, CancelAutopilotRunResponse{ + Run: runToResponse(current), + Cancelled: false, + AlreadyTerminal: true, + CancelledTasks: 0, + }) + return + } + } + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to cancel run") + return + } + + h.publish(protocol.EventAutopilotUpdated, workspaceID, "member", userID, map[string]any{ + "autopilot_id": uuidToString(autopilot.ID), + "run": runToResponse(updated), + }) + writeJSON(w, http.StatusOK, CancelAutopilotRunResponse{ + Run: runToResponse(updated), + Cancelled: true, + AlreadyTerminal: false, + CancelledTasks: cancelledTasks, + }) +} + +func (h *Handler) canCancelAutopilotRun(r *http.Request, autopilot db.Autopilot, userID, workspaceID string) bool { + actorType, actorID := h.resolveActor(r, userID, workspaceID) + assigneeType := autopilot.AssigneeType + if assigneeType == "" || assigneeType == "agent" { + agent, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{ + ID: autopilot.AssigneeID, + WorkspaceID: autopilot.WorkspaceID, + }) + if err != nil { + return false + } + return h.canAccessPrivateAgent(r.Context(), agent, actorType, actorID, workspaceID) + } + if assigneeType == "squad" { + squad, err := h.Queries.GetSquadInWorkspace(r.Context(), db.GetSquadInWorkspaceParams{ + ID: autopilot.AssigneeID, + WorkspaceID: autopilot.WorkspaceID, + }) + if err != nil { + return false + } + return h.canEnqueueSquadLeader(r.Context(), squad.LeaderID, actorType, actorID, workspaceID) + } + return false +} + +func autopilotRunStatusTerminal(status string) bool { + switch status { + case "completed", "failed", "skipped", "cancelled": + return true + default: + return false + } +} + // ── Manual trigger ────────────────────────────────────────────────────────── func (h *Handler) TriggerAutopilot(w http.ResponseWriter, r *http.Request) { diff --git a/server/internal/handler/autopilot_cancel_test.go b/server/internal/handler/autopilot_cancel_test.go new file mode 100644 index 0000000000..fe83e27e0c --- /dev/null +++ b/server/internal/handler/autopilot_cancel_test.go @@ -0,0 +1,226 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestCancelAutopilotRun_RunOnlyCancelsRunAndTask(t *testing.T) { + if testHandler == nil || testPool == nil { + t.Skip("database not available") + } + ctx := context.Background() + agentID := createHandlerTestAgent(t, "cancel-run-only-agent", []byte(`[]`)) + + var autopilotID, runID, taskID string + if err := testPool.QueryRow(ctx, ` + INSERT INTO autopilot (workspace_id, title, assignee_id, execution_mode, created_by_type, created_by_id, status) + VALUES ($1, 'cancel run only', $2, 'run_only', 'member', $3, 'active') + RETURNING id + `, testWorkspaceID, agentID, testUserID).Scan(&autopilotID); err != nil { + t.Fatalf("insert autopilot: %v", err) + } + t.Cleanup(func() { + testPool.Exec(context.Background(), `DELETE FROM autopilot WHERE id = $1`, autopilotID) + }) + if err := testPool.QueryRow(ctx, ` + INSERT INTO autopilot_run (autopilot_id, source, status) + VALUES ($1, 'manual', 'running') + RETURNING id + `, autopilotID).Scan(&runID); err != nil { + t.Fatalf("insert run: %v", err) + } + if err := testPool.QueryRow(ctx, ` + INSERT INTO agent_task_queue (agent_id, status, priority, autopilot_run_id) + VALUES ($1, 'running', 0, $2) + RETURNING id + `, agentID, runID).Scan(&taskID); err != nil { + t.Fatalf("insert task: %v", err) + } + if _, err := testPool.Exec(ctx, `UPDATE autopilot_run SET task_id = $1 WHERE id = $2`, taskID, runID); err != nil { + t.Fatalf("link task: %v", err) + } + + w := httptest.NewRecorder() + r := newRequest("POST", "/api/autopilot-runs/"+runID+"/cancel?workspace_id="+testWorkspaceID, nil) + r = withURLParam(r, "runId", runID) + testHandler.CancelAutopilotRun(w, r) + if w.Code != http.StatusOK { + t.Fatalf("CancelAutopilotRun: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp CancelAutopilotRunResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if !resp.Cancelled || resp.AlreadyTerminal || resp.CancelledTasks != 1 { + t.Fatalf("unexpected cancel response: %#v", resp) + } + if resp.Run.Status != "cancelled" { + t.Fatalf("run status in response = %q, want cancelled", resp.Run.Status) + } + + var runStatus, taskStatus string + if err := testPool.QueryRow(ctx, `SELECT status FROM autopilot_run WHERE id = $1`, runID).Scan(&runStatus); err != nil { + t.Fatalf("select run status: %v", err) + } + if err := testPool.QueryRow(ctx, `SELECT status FROM agent_task_queue WHERE id = $1`, taskID).Scan(&taskStatus); err != nil { + t.Fatalf("select task status: %v", err) + } + if runStatus != "cancelled" || taskStatus != "cancelled" { + t.Fatalf("statuses = run:%s task:%s, want cancelled/cancelled", runStatus, taskStatus) + } +} + +func TestCancelAutopilotRun_TerminalRunIsIdempotent(t *testing.T) { + if testHandler == nil || testPool == nil { + t.Skip("database not available") + } + ctx := context.Background() + agentID := createHandlerTestAgent(t, "cancel-terminal-agent", []byte(`[]`)) + + var autopilotID, runID string + if err := testPool.QueryRow(ctx, ` + INSERT INTO autopilot (workspace_id, title, assignee_id, execution_mode, created_by_type, created_by_id, status) + VALUES ($1, 'cancel terminal', $2, 'run_only', 'member', $3, 'active') + RETURNING id + `, testWorkspaceID, agentID, testUserID).Scan(&autopilotID); err != nil { + t.Fatalf("insert autopilot: %v", err) + } + t.Cleanup(func() { + testPool.Exec(context.Background(), `DELETE FROM autopilot WHERE id = $1`, autopilotID) + }) + if err := testPool.QueryRow(ctx, ` + INSERT INTO autopilot_run (autopilot_id, source, status, completed_at) + VALUES ($1, 'manual', 'completed', now()) + RETURNING id + `, autopilotID).Scan(&runID); err != nil { + t.Fatalf("insert run: %v", err) + } + + w := httptest.NewRecorder() + r := newRequest("POST", "/api/autopilot-runs/"+runID+"/cancel?workspace_id="+testWorkspaceID, nil) + r = withURLParam(r, "runId", runID) + testHandler.CancelAutopilotRun(w, r) + if w.Code != http.StatusOK { + t.Fatalf("CancelAutopilotRun: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp CancelAutopilotRunResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.Cancelled || !resp.AlreadyTerminal || resp.CancelledTasks != 0 { + t.Fatalf("unexpected idempotent response: %#v", resp) + } + if resp.Run.Status != "completed" { + t.Fatalf("run status in response = %q, want completed", resp.Run.Status) + } +} + +func TestCancelAutopilotRun_MissingOrWrongWorkspaceReturns404(t *testing.T) { + if testHandler == nil || testPool == nil { + t.Skip("database not available") + } + ctx := context.Background() + agentID := createHandlerTestAgent(t, "cancel-wrong-workspace-agent", []byte(`[]`)) + + var autopilotID, runID string + if err := testPool.QueryRow(ctx, ` + INSERT INTO autopilot (workspace_id, title, assignee_id, execution_mode, created_by_type, created_by_id, status) + VALUES ($1, 'cancel wrong workspace', $2, 'run_only', 'member', $3, 'active') + RETURNING id + `, testWorkspaceID, agentID, testUserID).Scan(&autopilotID); err != nil { + t.Fatalf("insert autopilot: %v", err) + } + t.Cleanup(func() { + testPool.Exec(context.Background(), `DELETE FROM autopilot WHERE id = $1`, autopilotID) + }) + if err := testPool.QueryRow(ctx, ` + INSERT INTO autopilot_run (autopilot_id, source, status) + VALUES ($1, 'manual', 'running') + RETURNING id + `, autopilotID).Scan(&runID); err != nil { + t.Fatalf("insert run: %v", err) + } + + t.Run("missing run", func(t *testing.T) { + missingRunID := "11111111-2222-3333-4444-555555555555" + w := httptest.NewRecorder() + r := newRequest("POST", "/api/autopilot-runs/"+missingRunID+"/cancel?workspace_id="+testWorkspaceID, nil) + r = withURLParam(r, "runId", missingRunID) + testHandler.CancelAutopilotRun(w, r) + if w.Code != http.StatusNotFound { + t.Fatalf("missing run: expected 404, got %d: %s", w.Code, w.Body.String()) + } + }) + + t.Run("wrong workspace", func(t *testing.T) { + w := httptest.NewRecorder() + r := newRequest("POST", "/api/autopilot-runs/"+runID+"/cancel?workspace_id=00000000-0000-0000-0000-000000000001", nil) + r = withURLParam(r, "runId", runID) + testHandler.CancelAutopilotRun(w, r) + if w.Code != http.StatusNotFound { + t.Fatalf("wrong workspace: expected 404, got %d: %s", w.Code, w.Body.String()) + } + }) +} + +func TestCancelAutopilotRun_PrivateAgentPlainMemberForbidden(t *testing.T) { + if testHandler == nil || testPool == nil { + t.Skip("database not available") + } + ctx := context.Background() + agentID, _, memberID := privateAgentTestFixture(t) + + var autopilotID, runID, taskID string + if err := testPool.QueryRow(ctx, ` + INSERT INTO autopilot (workspace_id, title, assignee_id, execution_mode, created_by_type, created_by_id, status) + VALUES ($1, 'cancel private agent', $2, 'run_only', 'member', $3, 'active') + RETURNING id + `, testWorkspaceID, agentID, testUserID).Scan(&autopilotID); err != nil { + t.Fatalf("insert autopilot: %v", err) + } + t.Cleanup(func() { + testPool.Exec(context.Background(), `DELETE FROM autopilot WHERE id = $1`, autopilotID) + }) + if err := testPool.QueryRow(ctx, ` + INSERT INTO autopilot_run (autopilot_id, source, status) + VALUES ($1, 'manual', 'running') + RETURNING id + `, autopilotID).Scan(&runID); err != nil { + t.Fatalf("insert run: %v", err) + } + if err := testPool.QueryRow(ctx, ` + INSERT INTO agent_task_queue (agent_id, status, priority, autopilot_run_id) + VALUES ($1, 'running', 0, $2) + RETURNING id + `, agentID, runID).Scan(&taskID); err != nil { + t.Fatalf("insert task: %v", err) + } + if _, err := testPool.Exec(ctx, `UPDATE autopilot_run SET task_id = $1 WHERE id = $2`, taskID, runID); err != nil { + t.Fatalf("link task: %v", err) + } + + w := httptest.NewRecorder() + r := newRequestAs(memberID, "POST", "/api/autopilot-runs/"+runID+"/cancel?workspace_id="+testWorkspaceID, nil) + r = withURLParam(r, "runId", runID) + testHandler.CancelAutopilotRun(w, r) + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d: %s", w.Code, w.Body.String()) + } + + var runStatus, taskStatus string + if err := testPool.QueryRow(ctx, `SELECT status FROM autopilot_run WHERE id = $1`, runID).Scan(&runStatus); err != nil { + t.Fatalf("select run status: %v", err) + } + if err := testPool.QueryRow(ctx, `SELECT status FROM agent_task_queue WHERE id = $1`, taskID).Scan(&taskStatus); err != nil { + t.Fatalf("select task status: %v", err) + } + if runStatus != "running" || taskStatus != "running" { + t.Fatalf("forbidden cancel mutated statuses: run:%s task:%s", runStatus, taskStatus) + } +} diff --git a/server/internal/service/builtin_skills/multica-autopilots/SKILL.md b/server/internal/service/builtin_skills/multica-autopilots/SKILL.md index 110788bb2e..e032824a5a 100644 --- a/server/internal/service/builtin_skills/multica-autopilots/SKILL.md +++ b/server/internal/service/builtin_skills/multica-autopilots/SKILL.md @@ -41,13 +41,14 @@ multica autopilot get --output json multica autopilot create --title "" --description "<task prompt>" --agent <agent-name-or-id> --mode create_issue|run_only --output json multica autopilot update <autopilot-id> --status active|paused --output json multica autopilot runs <autopilot-id> --output json +multica autopilot runs cancel <run-id> --output json multica autopilot trigger-add <autopilot-id> --kind schedule --cron "0 9 * * *" --timezone Asia/Shanghai --output json multica autopilot trigger-add <autopilot-id> --kind webhook --label "ci" --output json multica autopilot trigger <autopilot-id> --output json multica autopilot trigger-rotate-url <autopilot-id> <trigger-id> --yes --output json ``` -Use `trigger` only when the user explicitly asks for a manual run. Use `trigger-rotate-url` only when rotating a webhook URL; the old URL stops being valid. +Use `runs cancel` only when the user explicitly asks to stop a specific run; it preserves the autopilot definition, triggers, and run history. Use `trigger` only when the user explicitly asks for a manual run. Use `trigger-rotate-url` only when rotating a webhook URL; the old URL stops being valid. Webhook trigger output can include a URL/token. Do not paste webhook tokens or signing material into comments, logs, docs, or PRs. Redact secrets. @@ -63,6 +64,6 @@ For "why didn't it run": ## Side effects -These mutate durable state or start work: `create`, `update`, `delete`, trigger add/update/delete/rotate, `trigger`, and webhook calls to `/api/webhooks/autopilots/{token}`. +These mutate durable state or start work: `create`, `update`, `delete`, `runs cancel`, trigger add/update/delete/rotate, `trigger`, and webhook calls to `/api/webhooks/autopilots/{token}`. More source-backed details: `references/autopilots-source-map.md`. diff --git a/server/internal/service/builtin_skills/multica-autopilots/references/autopilots-source-map.md b/server/internal/service/builtin_skills/multica-autopilots/references/autopilots-source-map.md index 038df105e1..1018c8d6e5 100644 --- a/server/internal/service/builtin_skills/multica-autopilots/references/autopilots-source-map.md +++ b/server/internal/service/builtin_skills/multica-autopilots/references/autopilots-source-map.md @@ -1,9 +1,11 @@ # Autopilots source map -- `server/cmd/multica/cmd_autopilot.go` registers `list`, `get`, `create`, `update`, `delete`, `trigger`, `runs`, `trigger-add`, `trigger-update`, `trigger-delete`, and `trigger-rotate-url`. -- The CLI maps reads/writes to `/api/autopilots`, `/api/autopilots/{id}`, `/api/autopilots/{id}/trigger`, `/api/autopilots/{id}/runs`, and trigger subroutes. +- `server/cmd/multica/cmd_autopilot.go` registers `list`, `get`, `create`, `update`, `delete`, `trigger`, `runs`, nested `runs cancel`, `trigger-add`, `trigger-update`, `trigger-delete`, and `trigger-rotate-url` (lines 63-75, 105-158). +- The CLI maps reads/writes to `/api/autopilots`, `/api/autopilots/{id}`, `/api/autopilots/{id}/trigger`, `/api/autopilots/{id}/runs`, run-scoped `/api/autopilot-runs/{runId}/cancel`, and trigger subroutes (lines 522-545 for run cancel). - `server/internal/service/autopilot.go` has `DispatchAutopilot`, creates `autopilot_run`, and switches on `execution_mode`. - `create_issue` calls `dispatchCreateIssue`; `run_only` calls `dispatchRunOnly`. - `resolveAutopilotLeader` resolves squad-assigned autopilots to the squad leader. - `AgentReadiness` blocks archived/runtime-unready agents before enqueue. -- `server/cmd/server/router.go` exposes authenticated `/api/autopilots` routes and unauthenticated webhook ingress `/api/webhooks/autopilots/{token}`. +- `server/cmd/server/router.go` exposes authenticated `/api/autopilots` routes, authenticated `/api/autopilot-runs/{runId}/cancel` (line 796), and unauthenticated webhook ingress `/api/webhooks/autopilots/{token}`. +- `server/internal/handler/autopilot.go` `CancelAutopilotRun` enforces workspace scoping, private agent/squad-leader access, terminal idempotency, linked task cancellation, and a structured response (lines 1507-1628). +- `server/pkg/db/queries/autopilot.sql` `CancelAutopilotRun` marks active runs `cancelled` with `completed_at` and failure reason while leaving terminal runs untouched (lines 230-237). diff --git a/server/migrations/122_autopilot_run_cancelled_status.down.sql b/server/migrations/122_autopilot_run_cancelled_status.down.sql new file mode 100644 index 0000000000..5f9a096a2a --- /dev/null +++ b/server/migrations/122_autopilot_run_cancelled_status.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE autopilot_run DROP CONSTRAINT IF EXISTS autopilot_run_status_check; +ALTER TABLE autopilot_run ADD CONSTRAINT autopilot_run_status_check + CHECK (status IN ('issue_created', 'running', 'completed', 'failed', 'skipped')); + diff --git a/server/migrations/122_autopilot_run_cancelled_status.up.sql b/server/migrations/122_autopilot_run_cancelled_status.up.sql new file mode 100644 index 0000000000..a0b153645b --- /dev/null +++ b/server/migrations/122_autopilot_run_cancelled_status.up.sql @@ -0,0 +1,4 @@ +ALTER TABLE autopilot_run DROP CONSTRAINT IF EXISTS autopilot_run_status_check; +ALTER TABLE autopilot_run ADD CONSTRAINT autopilot_run_status_check + CHECK (status IN ('issue_created', 'running', 'completed', 'failed', 'skipped', 'cancelled')); + diff --git a/server/pkg/db/generated/autopilot.sql.go b/server/pkg/db/generated/autopilot.sql.go index fcef44a618..efab102308 100644 --- a/server/pkg/db/generated/autopilot.sql.go +++ b/server/pkg/db/generated/autopilot.sql.go @@ -28,6 +28,43 @@ func (q *Queries) AddAutopilotSubscriber(ctx context.Context, arg AddAutopilotSu return err } +const cancelAutopilotRun = `-- name: CancelAutopilotRun :one +UPDATE autopilot_run +SET status = 'cancelled', + completed_at = COALESCE(completed_at, now()), + failure_reason = COALESCE(failure_reason, $2) +WHERE id = $1 + AND status IN ('pending', 'issue_created', 'running') +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 CancelAutopilotRunParams struct { + ID pgtype.UUID `json:"id"` + FailureReason pgtype.Text `json:"failure_reason"` +} + +func (q *Queries) CancelAutopilotRun(ctx context.Context, arg CancelAutopilotRunParams) (AutopilotRun, error) { + row := q.db.QueryRow(ctx, cancelAutopilotRun, arg.ID, arg.FailureReason) + var i AutopilotRun + err := row.Scan( + &i.ID, + &i.AutopilotID, + &i.TriggerID, + &i.Source, + &i.Status, + &i.IssueID, + &i.TaskID, + &i.TriggeredAt, + &i.CompletedAt, + &i.FailureReason, + &i.TriggerPayload, + &i.Result, + &i.CreatedAt, + &i.SquadID, + ) + return i, err +} + const advanceTriggerNextRun = `-- name: AdvanceTriggerNextRun :exec UPDATE autopilot_trigger SET next_run_at = $2, diff --git a/server/pkg/db/queries/autopilot.sql b/server/pkg/db/queries/autopilot.sql index ced68b1d03..d44b84fee3 100644 --- a/server/pkg/db/queries/autopilot.sql +++ b/server/pkg/db/queries/autopilot.sql @@ -227,6 +227,15 @@ SET status = 'failed', completed_at = now(), failure_reason = $2 WHERE id = $1 RETURNING *; +-- name: CancelAutopilotRun :one +UPDATE autopilot_run +SET status = 'cancelled', + completed_at = COALESCE(completed_at, now()), + failure_reason = COALESCE(failure_reason, $2) +WHERE id = $1 + AND status IN ('pending', 'issue_created', 'running') +RETURNING *; + -- name: UpdateAutopilotRunSkipped :one -- Marks an autopilot_run as skipped without enqueueing any task. Used by the -- pre-flight admission check when the assignee agent's runtime is offline: @@ -373,4 +382,3 @@ ON CONFLICT (autopilot_id, user_type, user_id) DO NOTHING; -- Paired with a re-insert loop to implement full-replace PATCH semantics. DELETE FROM autopilot_subscriber WHERE autopilot_id = $1; -