From 4f1797598e436b55edfcd89e7f370d83ece0fae5 Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 16 Jun 2026 16:58:10 +0800 Subject: [PATCH] MUL-3321: Add runtime delete CLI command Adds a command-line runtime delete flow with strict default behavior and explicit cascade support.\n\nFixes #3909. --- server/cmd/multica/cmd_runtime.go | 125 ++++++++++++ server/cmd/multica/cmd_runtime_test.go | 181 ++++++++++++++++++ .../multica-runtimes-and-repos/SKILL.md | 3 +- .../runtimes-and-repos-source-map.md | 3 +- 4 files changed, 310 insertions(+), 2 deletions(-) create mode 100644 server/cmd/multica/cmd_runtime_test.go diff --git a/server/cmd/multica/cmd_runtime.go b/server/cmd/multica/cmd_runtime.go index baa27d073..ecd7e4b37 100644 --- a/server/cmd/multica/cmd_runtime.go +++ b/server/cmd/multica/cmd_runtime.go @@ -2,8 +2,12 @@ package main import ( "context" + "encoding/json" + "errors" "fmt" + "net/http" "os" + "strings" "time" "github.com/spf13/cobra" @@ -43,11 +47,22 @@ var runtimeUpdateCmd = &cobra.Command{ RunE: runRuntimeUpdate, } +var runtimeDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a runtime from the workspace", + Long: "Delete a runtime registration from the workspace.\n\n" + + "By default this refuses when active agents are still bound to the runtime. " + + "Pass --cascade to archive those agents, cancel their queued/running tasks, and delete the runtime.", + Args: exactArgs(1), + RunE: runRuntimeDelete, +} + func init() { runtimeCmd.AddCommand(runtimeListCmd) runtimeCmd.AddCommand(runtimeUsageCmd) runtimeCmd.AddCommand(runtimeActivityCmd) runtimeCmd.AddCommand(runtimeUpdateCmd) + runtimeCmd.AddCommand(runtimeDeleteCmd) // runtime list runtimeListCmd.Flags().String("output", "table", "Output format: table or json") @@ -63,6 +78,10 @@ func init() { runtimeUpdateCmd.Flags().String("target-version", "", "Target version to update to (required)") runtimeUpdateCmd.Flags().String("output", "json", "Output format: table or json") runtimeUpdateCmd.Flags().Bool("wait", false, "Wait for update to complete (poll until done)") + + // runtime delete + runtimeDeleteCmd.Flags().Bool("cascade", false, "Archive active agents bound to the runtime, cancel their tasks, then delete the runtime") + runtimeDeleteCmd.Flags().String("output", "table", "Output format: table or json") } // --------------------------------------------------------------------------- @@ -177,6 +196,49 @@ func runRuntimeActivity(cmd *cobra.Command, args []string) error { return nil } +func runRuntimeDelete(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + runtimeID := args[0] + ctx, cancel := cli.APIContext(context.Background()) + defer cancel() + + err = client.DeleteJSON(ctx, "/api/runtimes/"+runtimeID) + if err == nil { + return printRuntimeDeleteResult(cmd, map[string]any{ + "id": runtimeID, + "deleted": true, + }) + } + + conflict, ok := runtimeDeleteConflict(err) + if !ok { + return fmt.Errorf("delete runtime: %w", err) + } + + cascade, _ := cmd.Flags().GetBool("cascade") + if !cascade { + return fmt.Errorf( + "delete runtime: runtime has active agents bound to it (%s); archive or reassign them first, or rerun with --cascade to archive them and delete the runtime", + strings.Join(conflict.AgentDisplays(), ", "), + ) + } + + body := map[string]any{ + "expected_active_agent_ids": conflict.AgentIDs(), + } + var result map[string]any + if err := client.PostJSON(ctx, "/api/runtimes/"+runtimeID+"/archive-agents-and-delete", body, &result); err != nil { + return fmt.Errorf("cascade delete runtime: %w", err) + } + result["id"] = runtimeID + result["deleted"] = true + return printRuntimeDeleteResult(cmd, result) +} + func runRuntimeUpdate(cmd *cobra.Command, args []string) error { client, err := newAPIClient(cmd) if err != nil { @@ -238,3 +300,66 @@ func runRuntimeUpdate(cmd *cobra.Command, args []string) error { } } } + +type runtimeDeleteConflictPayload struct { + Code string `json:"code"` + Error string `json:"error"` + ActiveAgents []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"active_agents"` +} + +func runtimeDeleteConflict(err error) (runtimeDeleteConflictPayload, bool) { + var httpErr *cli.HTTPError + if !errors.As(err, &httpErr) || httpErr.StatusCode != http.StatusConflict { + return runtimeDeleteConflictPayload{}, false + } + var payload runtimeDeleteConflictPayload + if json.Unmarshal([]byte(httpErr.Body), &payload) != nil { + return runtimeDeleteConflictPayload{}, false + } + if payload.Code != "runtime_has_active_agents" || len(payload.ActiveAgents) == 0 { + return runtimeDeleteConflictPayload{}, false + } + return payload, true +} + +func (p runtimeDeleteConflictPayload) AgentIDs() []string { + ids := make([]string, 0, len(p.ActiveAgents)) + for _, agent := range p.ActiveAgents { + if agent.ID != "" { + ids = append(ids, agent.ID) + } + } + return ids +} + +func (p runtimeDeleteConflictPayload) AgentDisplays() []string { + displays := make([]string, 0, len(p.ActiveAgents)) + for _, agent := range p.ActiveAgents { + switch { + case agent.Name != "" && agent.ID != "": + displays = append(displays, fmt.Sprintf("%s (%s)", agent.Name, agent.ID)) + case agent.Name != "": + displays = append(displays, agent.Name) + case agent.ID != "": + displays = append(displays, agent.ID) + } + } + return displays +} + +func printRuntimeDeleteResult(cmd *cobra.Command, result map[string]any) error { + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, result) + } + + if agentsArchived, ok := result["agents_archived"]; ok { + fmt.Fprintf(os.Stderr, "Runtime %s deleted; archived %v agent(s).\n", strVal(result, "id"), agentsArchived) + return nil + } + fmt.Fprintf(os.Stderr, "Runtime %s deleted.\n", strVal(result, "id")) + return nil +} diff --git a/server/cmd/multica/cmd_runtime_test.go b/server/cmd/multica/cmd_runtime_test.go new file mode 100644 index 000000000..0849e7dfa --- /dev/null +++ b/server/cmd/multica/cmd_runtime_test.go @@ -0,0 +1,181 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func newRuntimeDeleteTestCmd(serverURL string) *cobra.Command { + cmd := &cobra.Command{Use: "delete"} + cmd.Flags().String("server-url", "", "") + cmd.Flags().String("workspace-id", "", "") + cmd.Flags().String("profile", "", "") + cmd.Flags().Bool("cascade", false, "") + cmd.Flags().String("output", "table", "") + _ = cmd.Flags().Set("server-url", serverURL) + _ = cmd.Flags().Set("workspace-id", "ws-1") + return cmd +} + +func captureRuntimeStdout(t *testing.T, fn func() error) (string, error) { + t.Helper() + old := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe stdout: %v", err) + } + os.Stdout = w + defer func() { os.Stdout = old }() + + runErr := fn() + if err := w.Close(); err != nil { + t.Fatalf("close stdout writer: %v", err) + } + out, err := io.ReadAll(r) + if err != nil { + t.Fatalf("read stdout: %v", err) + } + return string(out), runErr +} + +func TestRunRuntimeDeleteStrictSuccessPrintsJSON(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("MULTICA_TOKEN", "test-token") + + var deleteCount int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Fatalf("method = %s, want DELETE", r.Method) + } + if r.URL.Path != "/api/runtimes/rt-1" { + t.Fatalf("path = %q, want /api/runtimes/rt-1", r.URL.Path) + } + if r.Header.Get("X-Workspace-ID") != "ws-1" { + t.Fatalf("X-Workspace-ID = %q, want ws-1", r.Header.Get("X-Workspace-ID")) + } + deleteCount++ + _ = json.NewEncoder(w).Encode(map[string]any{"status": "ok"}) + })) + defer srv.Close() + + cmd := newRuntimeDeleteTestCmd(srv.URL) + _ = cmd.Flags().Set("output", "json") + + out, err := captureRuntimeStdout(t, func() error { + return runRuntimeDelete(cmd, []string{"rt-1"}) + }) + if err != nil { + t.Fatalf("runRuntimeDelete: %v", err) + } + if deleteCount != 1 { + t.Fatalf("deleteCount = %d, want 1", deleteCount) + } + var got map[string]any + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("decode stdout JSON %q: %v", out, err) + } + if got["id"] != "rt-1" || got["deleted"] != true { + t.Fatalf("stdout = %#v, want deleted result for rt-1", got) + } +} + +func TestRunRuntimeDeleteConflictSuggestsCascade(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("MULTICA_TOKEN", "test-token") + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || r.URL.Path != "/api/runtimes/rt-1" { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + w.WriteHeader(http.StatusConflict) + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": "runtime_has_active_agents", + "error": "cannot delete runtime: it has active agents bound to it.", + "active_agents": []map[string]any{ + {"id": "agent-1", "name": "Codex"}, + }, + }) + })) + defer srv.Close() + + err := runRuntimeDelete(newRuntimeDeleteTestCmd(srv.URL), []string{"rt-1"}) + if err == nil { + t.Fatal("expected active-agent conflict") + } + msg := err.Error() + if !strings.Contains(msg, "Codex (agent-1)") || !strings.Contains(msg, "--cascade") { + t.Fatalf("error = %q, want agent name and --cascade guidance", msg) + } +} + +func TestRunRuntimeDeleteCascadeConfirmsActiveAgentSnapshot(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("MULTICA_TOKEN", "test-token") + + var gotExpectedIDs []string + var deleteCount, cascadeCount int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodDelete && r.URL.Path == "/api/runtimes/rt-1": + deleteCount++ + w.WriteHeader(http.StatusConflict) + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": "runtime_has_active_agents", + "error": "cannot delete runtime: it has active agents bound to it.", + "active_agents": []map[string]any{ + {"id": "agent-1", "name": "Codex"}, + {"id": "agent-2", "name": "Claude"}, + }, + }) + case r.Method == http.MethodPost && r.URL.Path == "/api/runtimes/rt-1/archive-agents-and-delete": + cascadeCount++ + var body struct { + ExpectedActiveAgentIDs []string `json:"expected_active_agent_ids"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode cascade body: %v", err) + } + gotExpectedIDs = body.ExpectedActiveAgentIDs + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "agents_archived": 2, + "tasks_cancelled": 1, + }) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + defer srv.Close() + + cmd := newRuntimeDeleteTestCmd(srv.URL) + _ = cmd.Flags().Set("cascade", "true") + _ = cmd.Flags().Set("output", "json") + + out, err := captureRuntimeStdout(t, func() error { + return runRuntimeDelete(cmd, []string{"rt-1"}) + }) + if err != nil { + t.Fatalf("runRuntimeDelete: %v", err) + } + if deleteCount != 1 || cascadeCount != 1 { + t.Fatalf("deleteCount/cascadeCount = %d/%d, want 1/1", deleteCount, cascadeCount) + } + if strings.Join(gotExpectedIDs, ",") != "agent-1,agent-2" { + t.Fatalf("expected_active_agent_ids = %#v", gotExpectedIDs) + } + + var got map[string]any + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("decode stdout JSON %q: %v", out, err) + } + if got["id"] != "rt-1" || got["deleted"] != true || got["agents_archived"] != float64(2) { + t.Fatalf("stdout = %#v, want cascade result", got) + } +} diff --git a/server/internal/service/builtin_skills/multica-runtimes-and-repos/SKILL.md b/server/internal/service/builtin_skills/multica-runtimes-and-repos/SKILL.md index 6ff2e5811..28addd542 100644 --- a/server/internal/service/builtin_skills/multica-runtimes-and-repos/SKILL.md +++ b/server/internal/service/builtin_skills/multica-runtimes-and-repos/SKILL.md @@ -40,11 +40,12 @@ multica runtime list --output json multica runtime usage --output json multica runtime activity --output json multica runtime update --target-version --output json +multica runtime delete multica repo checkout multica repo checkout --ref ``` -`runtime update` is a write. `repo checkout` creates a git worktree in the task working directory. +`runtime update` and `runtime delete` are writes. `runtime delete` removes a runtime registration; if active agents are still bound, it refuses unless the user explicitly passes `--cascade`, which archives those agents and cancels their queued/running tasks before deleting the runtime. `repo checkout` creates a git worktree in the task working directory. `repo checkout` requires `MULTICA_DAEMON_PORT`; it is intended to run inside a daemon task. If absent, you are not in the normal agent checkout path. diff --git a/server/internal/service/builtin_skills/multica-runtimes-and-repos/references/runtimes-and-repos-source-map.md b/server/internal/service/builtin_skills/multica-runtimes-and-repos/references/runtimes-and-repos-source-map.md index 064573cbd..054c3738e 100644 --- a/server/internal/service/builtin_skills/multica-runtimes-and-repos/references/runtimes-and-repos-source-map.md +++ b/server/internal/service/builtin_skills/multica-runtimes-and-repos/references/runtimes-and-repos-source-map.md @@ -1,8 +1,9 @@ # Runtimes and repos source map -- `server/cmd/multica/cmd_runtime.go` registers `runtime list`, `usage`, `activity`, and `update`. +- `server/cmd/multica/cmd_runtime.go` registers `runtime list`, `usage`, `activity`, `update`, and `delete`. - `runtime list` reads `/api/runtimes` and prints `id`, `name`, `runtime_mode`, `provider`, `status`, and `last_seen_at`. - `runtime update` posts to `/api/runtimes/{runtime-id}/update`; with `--wait` it polls update status. +- `runtime delete` deletes `/api/runtimes/{runtime-id}`; with `--cascade`, it first reads the `runtime_has_active_agents` conflict payload and posts those ids to `/api/runtimes/{runtime-id}/archive-agents-and-delete`. - `server/cmd/multica/cmd_repo.go` registers `repo checkout [--ref]`. - `repo checkout` requires `MULTICA_DAEMON_PORT`, sends `workspace_id`, `workdir`, `ref`, `agent_name`, and `task_id` to local daemon `/repo/checkout`, then prints the checked-out path. - `server/cmd/server/router.go` registers daemon APIs under `/api/daemon`, including workspace repos and task claim.