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.
This commit is contained in:
Wes
2026-06-16 16:58:10 +08:00
committed by GitHub
parent 8ba1ef2dce
commit 4f1797598e
4 changed files with 310 additions and 2 deletions

View File

@@ -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 <runtime-id>",
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
}

View File

@@ -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)
}
}

View File

@@ -40,11 +40,12 @@ multica runtime list --output json
multica runtime usage <runtime-id> --output json
multica runtime activity <runtime-id> --output json
multica runtime update <runtime-id> --target-version <version> --output json
multica runtime delete <runtime-id>
multica repo checkout <url>
multica repo checkout <url> --ref <branch-or-sha>
```
`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.

View File

@@ -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 <url> [--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.