diff --git a/cmd/gc/cmd_session.go b/cmd/gc/cmd_session.go index b557609d94..fb0cdb9e34 100644 --- a/cmd/gc/cmd_session.go +++ b/cmd/gc/cmd_session.go @@ -45,7 +45,7 @@ continuity.`, Args: cobra.ArbitraryArgs, RunE: func(_ *cobra.Command, args []string) error { if len(args) == 0 { - fmt.Fprintln(stderr, "gc session: missing subcommand (new, list, attach, submit, suspend, pin, unpin, reset, close, rename, prune, peek, kill, nudge, logs, wake, wait)") //nolint:errcheck // best-effort stderr + fmt.Fprintln(stderr, "gc session: missing subcommand (new, list, attach, submit, suspend, pin, unpin, reset, close, rename, prune, release-name, peek, kill, nudge, logs, wake, wait)") //nolint:errcheck // best-effort stderr } else { fmt.Fprintf(stderr, "gc session: unknown subcommand %q\n", args[0]) //nolint:errcheck // best-effort stderr } @@ -64,6 +64,7 @@ continuity.`, newSessionCloseCmd(stdout, stderr), newSessionRenameCmd(stdout, stderr), newSessionPruneCmd(stdout, stderr), + newSessionReleaseNameCmd(stdout, stderr), newSessionPeekCmd(stdout, stderr), newSessionKillCmd(stdout, stderr), newSessionNudgeCmd(stdout, stderr), diff --git a/cmd/gc/cmd_session_release_name.go b/cmd/gc/cmd_session_release_name.go new file mode 100644 index 0000000000..05a2e0d247 --- /dev/null +++ b/cmd/gc/cmd_session_release_name.go @@ -0,0 +1,97 @@ +package main + +import ( + "fmt" + "io" + "strings" + + "github.com/gastownhall/gascity/internal/config" + "github.com/gastownhall/gascity/internal/session" + "github.com/spf13/cobra" +) + +// newSessionReleaseNameCmd creates the "gc session release-name " +// command. +func newSessionReleaseNameCmd(stdout, stderr io.Writer) *cobra.Command { + var jsonOutput bool + cmd := &cobra.Command{ + Use: "release-name ", + Short: "Free a runtime name still reserved by a closed session", + Long: `Clear the runtime session name, alias, and canonical-identity record held +by closed session beads matching the given name or alias. + +A closed session bead keeps the identifiers it ran under, and name resolution +still consults them, so the name can stay reserved after every runtime that +used it is gone. Nothing else releases it: nudge, wake, and kill all reject a +closed bead, and prune does not accept the closed state. Use this when +creating or waking a session fails with "already belongs to +(closed)". + +A name held by a live session is refused — stop that session instead.`, + Example: ` gc session release-name rig/pack.refinery + gc session release-name city--pack__refinery --json`, + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + if cmdSessionReleaseName(args[0], stdout, stderr, jsonOutput) != 0 { + return errExit + } + return nil + }, + } + cmd.Flags().BoolVar(&jsonOutput, "json", false, "emit JSONL") + return cmd +} + +// cmdSessionReleaseName is the CLI entry point for "gc session release-name". +func cmdSessionReleaseName(target string, stdout, stderr io.Writer, jsonOutput ...bool) int { + asJSON := sessionJSONRequested(jsonOutput) + + store, code := openCityStore(stderr, "gc session release-name") + if store == nil { + return code + } + + // The reservation lives on session beads, so route through the session + // coordination-class store for relocation-safety, as the other session + // catalog commands do. + cityPath, cityErr := resolveCity() + var cfg *config.City + if cityErr == nil { + cfg, _ = loadCityConfig(cityPath, configWarnWriter(asJSON, stderr)) + } + sessStore := cliSessionStore(store, cfg, cityPath) + + released, err := session.ReleaseSessionNameClaim(sessStore, target) + if err != nil { + fmt.Fprintf(stderr, "gc session release-name: %v\n", err) //nolint:errcheck // best-effort stderr + return 1 + } + + count := len(released) + if asJSON { + result := sessionActionResult{ + Action: "release-name", + Identity: strings.TrimSpace(target), + Count: &count, + } + if count == 1 { + result.SessionID = released[0].BeadID + } + if err := writeSessionActionJSON(stdout, result); err != nil { + fmt.Fprintf(stderr, "gc session release-name: %v\n", err) //nolint:errcheck // best-effort stderr + return 1 + } + return 0 + } + + if count == 0 { + fmt.Fprintf(stdout, "No closed session reserves %q.\n", target) //nolint:errcheck // best-effort stdout + return 0 + } + ids := make([]string, 0, count) + for _, r := range released { + ids = append(ids, r.BeadID) + } + fmt.Fprintf(stdout, "Released %q from %s.\n", target, strings.Join(ids, ", ")) //nolint:errcheck // best-effort stdout + return 0 +} diff --git a/cmd/gc/cmd_session_release_name_test.go b/cmd/gc/cmd_session_release_name_test.go new file mode 100644 index 0000000000..5c0087a752 --- /dev/null +++ b/cmd/gc/cmd_session_release_name_test.go @@ -0,0 +1,147 @@ +package main + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/gastownhall/gascity/internal/beads" + "github.com/gastownhall/gascity/internal/session" +) + +// releaseNameTestCity stands up a city store and returns it plus a helper that +// creates session beads holding runtime identifiers. +func releaseNameTestCity(t *testing.T) (beads.Store, func(metadata map[string]string) beads.Bead) { + t.Helper() + clearGCEnv(t) + clearInheritedCityRoutingEnv(t) + t.Setenv("GC_BEADS", "file") + t.Setenv("GC_SESSION", "fake") + + cityDir := t.TempDir() + t.Setenv("GC_CITY", cityDir) + t.Setenv("GC_CITY_PATH", cityDir) + writeNamedSessionCityTOML(t, cityDir) + + store, err := openCityStoreAt(cityDir) + if err != nil { + t.Fatalf("openCityStoreAt(%q): %v", cityDir, err) + } + create := func(metadata map[string]string) beads.Bead { + t.Helper() + created, err := store.Create(beads.Bead{ + Title: metadata["session_name"], + Type: session.BeadType, + Labels: []string{session.LabelSession}, + Metadata: metadata, + }) + if err != nil { + t.Fatalf("create session bead: %v", err) + } + return created + } + return store, create +} + +// TestCmdSessionReleaseNameClearsClosedHolder is the end-to-end operator lever: +// a CLOSED session bead reserving an on_demand agent's runtime name is cleared +// by one command, with no metadata surgery and no bead ID to look up first. +func TestCmdSessionReleaseNameClearsClosedHolder(t *testing.T) { + const ( + sessionName = "shutupandlisten--gc-toolkit__refinery" + identity = "shutupandlisten/gc-toolkit.refinery" + ) + store, create := releaseNameTestCity(t) + holder := create(map[string]string{ + "session_name": sessionName, + "alias": identity, + "agent_name": identity, + session.CanonicalInstanceNameMetadata: identity, + "template": "test", + }) + if err := store.Close(holder.ID); err != nil { + t.Fatalf("Close: %v", err) + } + + var stdout, stderr bytes.Buffer + if code := cmdSessionReleaseName(sessionName, &stdout, &stderr, true); code != 0 { + t.Fatalf("cmdSessionReleaseName = %d, want 0; stderr=%s", code, stderr.String()) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } + + var got sessionActionResult + if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &got); err != nil { + t.Fatalf("stdout is not JSON: %v; stdout=%q", err, stdout.String()) + } + if got.Action != "release-name" { + t.Fatalf("action = %q, want release-name", got.Action) + } + if got.Identity != sessionName { + t.Fatalf("identity = %q, want %q", got.Identity, sessionName) + } + if got.Count == nil || *got.Count != 1 { + t.Fatalf("count = %v, want 1", got.Count) + } + if got.SessionID != holder.ID { + t.Fatalf("session_id = %q, want %q", got.SessionID, holder.ID) + } + + after, err := store.Get(holder.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + for _, key := range []string{"session_name", "alias", session.CanonicalInstanceNameMetadata} { + if v := after.Metadata[key]; v != "" { + t.Fatalf("metadata[%q] = %q, want empty after release", key, v) + } + } +} + +// TestCmdSessionReleaseNameRefusesLiveHolder keeps the lever from stealing a +// name out from under a running session. +func TestCmdSessionReleaseNameRefusesLiveHolder(t *testing.T) { + const sessionName = "shutupandlisten--gc-toolkit__refinery" + store, create := releaseNameTestCity(t) + holder := create(map[string]string{ + "session_name": sessionName, + "state": string(session.StateActive), + "template": "test", + }) + + var stdout, stderr bytes.Buffer + if code := cmdSessionReleaseName(sessionName, &stdout, &stderr, false); code == 0 { + t.Fatalf("cmdSessionReleaseName(live holder) = 0, want non-zero; stdout=%s", stdout.String()) + } + if !strings.Contains(stderr.String(), holder.ID) { + t.Fatalf("stderr = %q, want it to name the live holder %s", stderr.String(), holder.ID) + } + + after, err := store.Get(holder.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if after.Metadata["session_name"] != sessionName { + t.Fatalf("session_name = %q, want %q left intact", after.Metadata["session_name"], sessionName) + } +} + +// TestCmdSessionReleaseNameNoHolder reports a clean no-op, so the command is +// safe to run while diagnosing. +func TestCmdSessionReleaseNameNoHolder(t *testing.T) { + _, _ = releaseNameTestCity(t) + + var stdout, stderr bytes.Buffer + if code := cmdSessionReleaseName("nobody-holds-this", &stdout, &stderr, true); code != 0 { + t.Fatalf("cmdSessionReleaseName = %d, want 0; stderr=%s", code, stderr.String()) + } + var got sessionActionResult + if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &got); err != nil { + t.Fatalf("stdout is not JSON: %v; stdout=%q", err, stdout.String()) + } + if got.Count == nil || *got.Count != 0 { + t.Fatalf("count = %v, want 0", got.Count) + } +} diff --git a/cmd/gc/metrics_census_gen.go b/cmd/gc/metrics_census_gen.go index db57339cfe..bdb08b06b8 100644 --- a/cmd/gc/metrics_census_gen.go +++ b/cmd/gc/metrics_census_gen.go @@ -202,6 +202,7 @@ const ( productMetricsGeneratedCommandID201 productMetricsCommandID = 201 productMetricsGeneratedCommandID202 productMetricsCommandID = 202 productMetricsGeneratedCommandID203 productMetricsCommandID = 203 + productMetricsGeneratedCommandID204 productMetricsCommandID = 204 ) var generatedProductMetricsGlobalConditionalModes = []productMetricsConditionalMode{productMetricsConditionalGenericMachineOutput, productMetricsConditionalManagedContext, productMetricsConditionalProviderHook} @@ -446,6 +447,7 @@ var generatedProductMetricsCommandCensus = []productMetricsCommandCensusEntry{ {Path: "gc session peek", Aliases: []string{}, ConditionalModes: []productMetricsConditionalMode{}, Hidden: false, EffectiveHidden: false, DisableFlagParsing: false, Shape: productMetricsShapeRunnable, Classification: "session-peek", Mode: productMetricsModeStandard, Notice: productMetricsNoticeEligible, Recording: productMetricsRecordingRecordable, Owner: productMetricsOwnerImmediate, ID: productMetricsGeneratedCommandID147}, {Path: "gc session pin", Aliases: []string{}, ConditionalModes: []productMetricsConditionalMode{}, Hidden: false, EffectiveHidden: false, DisableFlagParsing: false, Shape: productMetricsShapeRunnable, Classification: "session-pin", Mode: productMetricsModeStandard, Notice: productMetricsNoticeEligible, Recording: productMetricsRecordingRecordable, Owner: productMetricsOwnerImmediate, ID: productMetricsGeneratedCommandID148}, {Path: "gc session prune", Aliases: []string{}, ConditionalModes: []productMetricsConditionalMode{}, Hidden: false, EffectiveHidden: false, DisableFlagParsing: false, Shape: productMetricsShapeRunnable, Classification: "session-prune", Mode: productMetricsModeStandard, Notice: productMetricsNoticeEligible, Recording: productMetricsRecordingRecordable, Owner: productMetricsOwnerImmediate, ID: productMetricsGeneratedCommandID149}, + {Path: "gc session release-name", Aliases: []string{}, ConditionalModes: []productMetricsConditionalMode{}, Hidden: false, EffectiveHidden: false, DisableFlagParsing: false, Shape: productMetricsShapeRunnable, Classification: "session-release-name", Mode: productMetricsModeStandard, Notice: productMetricsNoticeEligible, Recording: productMetricsRecordingRecordable, Owner: productMetricsOwnerImmediate, ID: productMetricsGeneratedCommandID204}, {Path: "gc session rename", Aliases: []string{}, ConditionalModes: []productMetricsConditionalMode{}, Hidden: false, EffectiveHidden: false, DisableFlagParsing: false, Shape: productMetricsShapeRunnable, Classification: "session-rename", Mode: productMetricsModeStandard, Notice: productMetricsNoticeEligible, Recording: productMetricsRecordingRecordable, Owner: productMetricsOwnerImmediate, ID: productMetricsGeneratedCommandID150}, {Path: "gc session reset", Aliases: []string{}, ConditionalModes: []productMetricsConditionalMode{}, Hidden: false, EffectiveHidden: false, DisableFlagParsing: false, Shape: productMetricsShapeRunnable, Classification: "session-reset", Mode: productMetricsModeStandard, Notice: productMetricsNoticeEligible, Recording: productMetricsRecordingRecordable, Owner: productMetricsOwnerImmediate, ID: productMetricsGeneratedCommandID151}, {Path: "gc session submit", Aliases: []string{}, ConditionalModes: []productMetricsConditionalMode{}, Hidden: false, EffectiveHidden: false, DisableFlagParsing: false, Shape: productMetricsShapeRunnable, Classification: "session-submit", Mode: productMetricsModeStandard, Notice: productMetricsNoticeEligible, Recording: productMetricsRecordingRecordable, Owner: productMetricsOwnerImmediate, ID: productMetricsGeneratedCommandID152}, diff --git a/cmd/gc/productmetrics_command_census.json b/cmd/gc/productmetrics_command_census.json index 2480fb6786..ec5c8c7019 100644 --- a/cmd/gc/productmetrics_command_census.json +++ b/cmd/gc/productmetrics_command_census.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "next_id": 204, + "next_id": 205, "permanent_ids": [ { "name": "help", @@ -3705,6 +3705,21 @@ "owner": "immediate", "id": 149 }, + { + "path": "gc session release-name", + "aliases": [], + "conditional_modes": [], + "hidden": false, + "effective_hidden": false, + "disable_flag_parsing": false, + "shape": "runnable", + "recording_policy": "recordable", + "mode": "standard", + "notice_policy": "eligible", + "classification": "session-release-name", + "owner": "immediate", + "id": 204 + }, { "path": "gc session rename", "aliases": [], diff --git a/cmd/gc/session_action_json_test.go b/cmd/gc/session_action_json_test.go index 263fa6d65c..ebf98c1534 100644 --- a/cmd/gc/session_action_json_test.go +++ b/cmd/gc/session_action_json_test.go @@ -68,6 +68,7 @@ func TestSessionMutationActionSchemasDeclared(t *testing.T) { {"session", "kill", "--json-schema=result"}, {"session", "rename", "--json-schema=result"}, {"session", "prune", "--json-schema=result"}, + {"session", "release-name", "--json-schema=result"}, {"session", "reset", "--json-schema=result"}, {"session", "pin", "--json-schema=result"}, {"session", "unpin", "--json-schema=result"}, diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 91e7721171..46d584d7d5 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -3975,6 +3975,7 @@ gc session | [gc session peek](#gc-session-peek) | View session output without attaching | | [gc session pin](#gc-session-pin) | Keep a session awake | | [gc session prune](#gc-session-prune) | Close old dormant sessions | +| [gc session release-name](#gc-session-release-name) | Free a runtime name still reserved by a closed session | | [gc session rename](#gc-session-rename) | Rename a session | | [gc session reset](#gc-session-reset) | Restart a session fresh while preserving the bead | | [gc session submit](#gc-session-submit) | Submit a message with semantic delivery intent | @@ -4192,6 +4193,35 @@ gc session prune --state asleep,suspended,drained --before 1h | `--json` | bool | | emit JSONL | | `--state` | string | `suspended` | comma-separated states to prune (suspended, asleep, drained) | +## gc session release-name + +Clear the runtime session name, alias, and canonical-identity record held +by closed session beads matching the given name or alias. + +A closed session bead keeps the identifiers it ran under, and name resolution +still consults them, so the name can stay reserved after every runtime that +used it is gone. Nothing else releases it: nudge, wake, and kill all reject a +closed bead, and prune does not accept the closed state. Use this when +creating or waking a session fails with "already belongs to <bead-id> +(closed)". + +A name held by a live session is refused — stop that session instead. + +``` +gc session release-name [flags] +``` + +**Example:** + +``` +gc session release-name rig/pack.refinery +gc session release-name city--pack__refinery --json +``` + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--json` | bool | | emit JSONL | + ## gc session rename Rename a session diff --git a/internal/productmetrics/command_ids_gen.go b/internal/productmetrics/command_ids_gen.go index 82853851f4..97d330f304 100644 --- a/internal/productmetrics/command_ids_gen.go +++ b/internal/productmetrics/command_ids_gen.go @@ -2,7 +2,7 @@ package productmetrics -// command-census-ledger: {"next_id":204,"identities":[{"name":"agent-add","id":5,"wire":"agent-add","retired":false},{"name":"agent-list","id":6,"wire":"agent-list","retired":false},{"name":"agent-resume","id":7,"wire":"agent-resume","retired":false},{"name":"agent-suspend","id":8,"wire":"agent-suspend","retired":false},{"name":"agent-script","id":9,"wire":"agent-script","retired":false},{"name":"analyze-reliability","id":10,"wire":"analyze-reliability","retired":false},{"name":"bd","id":11,"wire":"bd","retired":false},{"name":"beads-city-use-external","id":12,"wire":"beads-city-use-external","retired":false},{"name":"beads-city-use-managed","id":13,"wire":"beads-city-use-managed","retired":false},{"name":"beads-health","id":14,"wire":"beads-health","retired":false},{"name":"beads-list","id":15,"wire":"beads-list","retired":false},{"name":"beads-show","id":16,"wire":"beads-show","retired":false},{"name":"build-image","id":17,"wire":"build-image","retired":false},{"name":"cities","id":18,"wire":"cities","retired":false},{"name":"cities-list","id":19,"wire":"cities-list","retired":false},{"name":"completion","id":20,"wire":"completion","retired":false},{"name":"config-explain","id":21,"wire":"config-explain","retired":false},{"name":"config-show","id":22,"wire":"config-show","retired":false},{"name":"converge-approve","id":23,"wire":"converge-approve","retired":false},{"name":"converge-create","id":24,"wire":"converge-create","retired":false},{"name":"converge-iterate","id":25,"wire":"converge-iterate","retired":false},{"name":"converge-list","id":26,"wire":"converge-list","retired":false},{"name":"converge-retry","id":27,"wire":"converge-retry","retired":false},{"name":"converge-status","id":28,"wire":"converge-status","retired":false},{"name":"converge-stop","id":29,"wire":"converge-stop","retired":false},{"name":"converge-test-gate","id":30,"wire":"converge-test-gate","retired":false},{"name":"converge-test-trigger","id":31,"wire":"converge-test-trigger","retired":false},{"name":"convoy-add","id":32,"wire":"convoy-add","retired":false},{"name":"convoy-check","id":33,"wire":"convoy-check","retired":false},{"name":"convoy-close","id":34,"wire":"convoy-close","retired":false},{"name":"convoy-control","id":35,"wire":"convoy-control","retired":false},{"name":"convoy-create","id":36,"wire":"convoy-create","retired":false},{"name":"convoy-delete","id":37,"wire":"convoy-delete","retired":false},{"name":"convoy-delete-source","id":38,"wire":"convoy-delete-source","retired":false},{"name":"convoy-land","id":39,"wire":"convoy-land","retired":false},{"name":"convoy-list","id":40,"wire":"convoy-list","retired":false},{"name":"convoy-reopen-source","id":41,"wire":"convoy-reopen-source","retired":false},{"name":"convoy-status","id":42,"wire":"convoy-status","retired":false},{"name":"convoy-stranded","id":43,"wire":"convoy-stranded","retired":false},{"name":"convoy-target","id":44,"wire":"convoy-target","retired":false},{"name":"costs","id":45,"wire":"costs","retired":false},{"name":"dashboard","id":46,"wire":"dashboard","retired":false},{"name":"dashboard-serve","id":47,"wire":"dashboard-serve","retired":false},{"name":"doctor","id":48,"wire":"doctor","retired":false},{"name":"dolt-cleanup","id":49,"wire":"dolt-cleanup","retired":false},{"name":"events","id":50,"wire":"events","retired":false},{"name":"events-rotate","id":51,"wire":"events-rotate","retired":false},{"name":"extmsg-bind","id":52,"wire":"extmsg-bind","retired":false},{"name":"extmsg-handoff","id":53,"wire":"extmsg-handoff","retired":false},{"name":"extmsg-unbind","id":54,"wire":"extmsg-unbind","retired":false},{"name":"formula-cook","id":55,"wire":"formula-cook","retired":false},{"name":"formula-list","id":56,"wire":"formula-list","retired":false},{"name":"formula-show","id":57,"wire":"formula-show","retired":false},{"name":"formula-version-check","id":58,"wire":"formula-version-check","retired":false},{"name":"github-pr-backfill","id":59,"wire":"github-pr-backfill","retired":false},{"name":"graph","id":60,"wire":"graph","retired":false},{"name":"handoff","id":61,"wire":"handoff","retired":false},{"name":"import-add","id":62,"wire":"import-add","retired":false},{"name":"import-check","id":63,"wire":"import-check","retired":false},{"name":"import-credential-add","id":64,"wire":"import-credential-add","retired":false},{"name":"import-credential-list","id":65,"wire":"import-credential-list","retired":false},{"name":"import-credential-remove","id":66,"wire":"import-credential-remove","retired":false},{"name":"import-install","id":67,"wire":"import-install","retired":false},{"name":"import-list","id":68,"wire":"import-list","retired":false},{"name":"import-prune","id":69,"wire":"import-prune","retired":false},{"name":"import-remove","id":70,"wire":"import-remove","retired":false},{"name":"import-status","id":71,"wire":"import-status","retired":false},{"name":"import-upgrade","id":72,"wire":"import-upgrade","retired":false},{"name":"import-why","id":73,"wire":"import-why","retired":false},{"name":"init","id":74,"wire":"init","retired":false},{"name":"lint","id":75,"wire":"lint","retired":false},{"name":"mail-archive","id":76,"wire":"mail-archive","retired":false},{"name":"mail-check","id":77,"wire":"mail-check","retired":false},{"name":"mail-count","id":78,"wire":"mail-count","retired":false},{"name":"mail-delete","id":79,"wire":"mail-delete","retired":false},{"name":"mail-inbox","id":80,"wire":"mail-inbox","retired":false},{"name":"mail-mark-read","id":81,"wire":"mail-mark-read","retired":false},{"name":"mail-mark-unread","id":82,"wire":"mail-mark-unread","retired":false},{"name":"mail-peek","id":83,"wire":"mail-peek","retired":false},{"name":"mail-read","id":84,"wire":"mail-read","retired":false},{"name":"mail-reply","id":85,"wire":"mail-reply","retired":false},{"name":"mail-send","id":86,"wire":"mail-send","retired":false},{"name":"mail-thread","id":87,"wire":"mail-thread","retired":false},{"name":"maintenance-dolt-gc","id":88,"wire":"maintenance-dolt-gc","retired":false},{"name":"maintenance-status","id":89,"wire":"maintenance-status","retired":false},{"name":"mcp-list","id":90,"wire":"mcp-list","retired":false},{"name":"nudge-status","id":91,"wire":"nudge-status","retired":false},{"name":"order-check","id":92,"wire":"order-check","retired":false},{"name":"order-history","id":93,"wire":"order-history","retired":false},{"name":"order-list","id":94,"wire":"order-list","retired":false},{"name":"order-run","id":95,"wire":"order-run","retired":false},{"name":"order-show","id":96,"wire":"order-show","retired":false},{"name":"order-sweep-nudge-mail","id":97,"wire":"order-sweep-nudge-mail","retired":false},{"name":"order-sweep-tracking","id":98,"wire":"order-sweep-tracking","retired":false},{"name":"pack-fetch","id":99,"wire":"pack-fetch","retired":false},{"name":"pack-list","id":100,"wire":"pack-list","retired":false},{"name":"pack-registry-add","id":101,"wire":"pack-registry-add","retired":false},{"name":"pack-registry-list","id":102,"wire":"pack-registry-list","retired":false},{"name":"pack-registry-login","id":103,"wire":"pack-registry-login","retired":false},{"name":"pack-registry-publish","id":104,"wire":"pack-registry-publish","retired":false},{"name":"pack-registry-refresh","id":105,"wire":"pack-registry-refresh","retired":false},{"name":"pack-registry-remove","id":106,"wire":"pack-registry-remove","retired":false},{"name":"pack-registry-search","id":107,"wire":"pack-registry-search","retired":false},{"name":"pack-registry-show","id":108,"wire":"pack-registry-show","retired":false},{"name":"pack-registry-whoami","id":109,"wire":"pack-registry-whoami","retired":false},{"name":"pack-release-hash","id":110,"wire":"pack-release-hash","retired":false},{"name":"pack-release-stamp","id":111,"wire":"pack-release-stamp","retired":false},{"name":"pack-release-validate","id":112,"wire":"pack-release-validate","retired":false},{"name":"pack-release-verify","id":113,"wire":"pack-release-verify","retired":false},{"name":"perf-run","id":114,"wire":"perf-run","retired":false},{"name":"perf-session-new","id":115,"wire":"perf-session-new","retired":false},{"name":"prime","id":116,"wire":"prime","retired":false},{"name":"prompt-synth","id":117,"wire":"prompt-synth","retired":false},{"name":"register","id":118,"wire":"register","retired":false},{"name":"reload","id":119,"wire":"reload","retired":false},{"name":"restart","id":120,"wire":"restart","retired":false},{"name":"resume","id":121,"wire":"resume","retired":false},{"name":"rig-add","id":122,"wire":"rig-add","retired":false},{"name":"rig-list","id":123,"wire":"rig-list","retired":false},{"name":"rig-remove","id":124,"wire":"rig-remove","retired":false},{"name":"rig-restart","id":125,"wire":"rig-restart","retired":false},{"name":"rig-resume","id":126,"wire":"rig-resume","retired":false},{"name":"rig-set-endpoint","id":127,"wire":"rig-set-endpoint","retired":false},{"name":"rig-status","id":128,"wire":"rig-status","retired":false},{"name":"rig-suspend","id":129,"wire":"rig-suspend","retired":false},{"name":"runtime-check","id":130,"wire":"runtime-check","retired":false},{"name":"runtime-conformance","id":131,"wire":"runtime-conformance","retired":false},{"name":"runtime-drain","id":132,"wire":"runtime-drain","retired":false},{"name":"runtime-drain-ack","id":133,"wire":"runtime-drain-ack","retired":false},{"name":"runtime-drain-check","id":134,"wire":"runtime-drain-check","retired":false},{"name":"runtime-request-restart","id":135,"wire":"runtime-request-restart","retired":false},{"name":"runtime-undrain","id":136,"wire":"runtime-undrain","retired":false},{"name":"service-doctor","id":137,"wire":"service-doctor","retired":false},{"name":"service-list","id":138,"wire":"service-list","retired":false},{"name":"service-restart","id":139,"wire":"service-restart","retired":false},{"name":"session-attach","id":140,"wire":"session-attach","retired":false},{"name":"session-close","id":141,"wire":"session-close","retired":false},{"name":"session-kill","id":142,"wire":"session-kill","retired":false},{"name":"session-list","id":143,"wire":"session-list","retired":false},{"name":"session-logs","id":144,"wire":"session-logs","retired":false},{"name":"session-new","id":145,"wire":"session-new","retired":false},{"name":"session-nudge","id":146,"wire":"session-nudge","retired":false},{"name":"session-peek","id":147,"wire":"session-peek","retired":false},{"name":"session-pin","id":148,"wire":"session-pin","retired":false},{"name":"session-prune","id":149,"wire":"session-prune","retired":false},{"name":"session-rename","id":150,"wire":"session-rename","retired":false},{"name":"session-reset","id":151,"wire":"session-reset","retired":false},{"name":"session-submit","id":152,"wire":"session-submit","retired":false},{"name":"session-suspend","id":153,"wire":"session-suspend","retired":false},{"name":"session-unpin","id":154,"wire":"session-unpin","retired":false},{"name":"session-wait","id":155,"wire":"session-wait","retired":false},{"name":"session-wake","id":156,"wire":"session-wake","retired":false},{"name":"shell-install","id":157,"wire":"shell-install","retired":false},{"name":"shell-remove","id":158,"wire":"shell-remove","retired":false},{"name":"shell-status","id":159,"wire":"shell-status","retired":false},{"name":"skill-list","id":160,"wire":"skill-list","retired":false},{"name":"sling","id":161,"wire":"sling","retired":false},{"name":"start","id":162,"wire":"start","retired":false},{"name":"status","id":163,"wire":"status","retired":false},{"name":"stop","id":164,"wire":"stop","retired":false},{"name":"supervisor-install","id":165,"wire":"supervisor-install","retired":false},{"name":"supervisor-logs","id":166,"wire":"supervisor-logs","retired":false},{"name":"supervisor-reload","id":167,"wire":"supervisor-reload","retired":false},{"name":"supervisor-run","id":168,"wire":"supervisor-run","retired":false},{"name":"supervisor-start","id":169,"wire":"supervisor-start","retired":false},{"name":"supervisor-status","id":170,"wire":"supervisor-status","retired":false},{"name":"supervisor-stop","id":171,"wire":"supervisor-stop","retired":false},{"name":"supervisor-uninstall","id":172,"wire":"supervisor-uninstall","retired":false},{"name":"suspend","id":173,"wire":"suspend","retired":false},{"name":"trace-cycle","id":174,"wire":"trace-cycle","retired":false},{"name":"trace-reasons","id":175,"wire":"trace-reasons","retired":false},{"name":"trace-show","id":176,"wire":"trace-show","retired":false},{"name":"trace-start","id":177,"wire":"trace-start","retired":false},{"name":"trace-status","id":178,"wire":"trace-status","retired":false},{"name":"trace-stop","id":179,"wire":"trace-stop","retired":false},{"name":"trace-tail","id":180,"wire":"trace-tail","retired":false},{"name":"unregister","id":181,"wire":"unregister","retired":false},{"name":"wait-cancel","id":182,"wire":"wait-cancel","retired":false},{"name":"wait-inspect","id":183,"wire":"wait-inspect","retired":false},{"name":"wait-list","id":184,"wire":"wait-list","retired":false},{"name":"wait-ready","id":185,"wire":"wait-ready","retired":false},{"name":"context-add","id":186,"wire":"context-add","retired":false},{"name":"context-current","id":187,"wire":"context-current","retired":false},{"name":"context-list","id":188,"wire":"context-list","retired":false},{"name":"context-remove","id":189,"wire":"context-remove","retired":false},{"name":"context-show","id":190,"wire":"context-show","retired":false},{"name":"context-use","id":191,"wire":"context-use","retired":false},{"name":"login","id":192,"wire":"login","retired":false},{"name":"logout","id":193,"wire":"logout","retired":false},{"name":"whoami","id":194,"wire":"whoami","retired":false},{"name":"runtime-heartbeat","id":195,"wire":"runtime-heartbeat","retired":false},{"name":"pack-registry-requests","id":196,"wire":"pack-registry-requests","retired":false},{"name":"events-reemit-execution","id":197,"wire":"events-reemit-execution","retired":false},{"name":"storage-migrate","id":198,"wire":"storage-migrate","retired":false},{"name":"storage-status","id":199,"wire":"storage-status","retired":false},{"name":"ready","id":200,"wire":"ready","retired":false},{"name":"storage-recover-stranded","id":201,"wire":"storage-recover-stranded","retired":false},{"name":"beads-metadata-cas","id":202,"wire":"beads-metadata-cas","retired":false},{"name":"nudge-show","id":203,"wire":"nudge-show","retired":false}]} +// command-census-ledger: {"next_id":205,"identities":[{"name":"agent-add","id":5,"wire":"agent-add","retired":false},{"name":"agent-list","id":6,"wire":"agent-list","retired":false},{"name":"agent-resume","id":7,"wire":"agent-resume","retired":false},{"name":"agent-suspend","id":8,"wire":"agent-suspend","retired":false},{"name":"agent-script","id":9,"wire":"agent-script","retired":false},{"name":"analyze-reliability","id":10,"wire":"analyze-reliability","retired":false},{"name":"bd","id":11,"wire":"bd","retired":false},{"name":"beads-city-use-external","id":12,"wire":"beads-city-use-external","retired":false},{"name":"beads-city-use-managed","id":13,"wire":"beads-city-use-managed","retired":false},{"name":"beads-health","id":14,"wire":"beads-health","retired":false},{"name":"beads-list","id":15,"wire":"beads-list","retired":false},{"name":"beads-show","id":16,"wire":"beads-show","retired":false},{"name":"build-image","id":17,"wire":"build-image","retired":false},{"name":"cities","id":18,"wire":"cities","retired":false},{"name":"cities-list","id":19,"wire":"cities-list","retired":false},{"name":"completion","id":20,"wire":"completion","retired":false},{"name":"config-explain","id":21,"wire":"config-explain","retired":false},{"name":"config-show","id":22,"wire":"config-show","retired":false},{"name":"converge-approve","id":23,"wire":"converge-approve","retired":false},{"name":"converge-create","id":24,"wire":"converge-create","retired":false},{"name":"converge-iterate","id":25,"wire":"converge-iterate","retired":false},{"name":"converge-list","id":26,"wire":"converge-list","retired":false},{"name":"converge-retry","id":27,"wire":"converge-retry","retired":false},{"name":"converge-status","id":28,"wire":"converge-status","retired":false},{"name":"converge-stop","id":29,"wire":"converge-stop","retired":false},{"name":"converge-test-gate","id":30,"wire":"converge-test-gate","retired":false},{"name":"converge-test-trigger","id":31,"wire":"converge-test-trigger","retired":false},{"name":"convoy-add","id":32,"wire":"convoy-add","retired":false},{"name":"convoy-check","id":33,"wire":"convoy-check","retired":false},{"name":"convoy-close","id":34,"wire":"convoy-close","retired":false},{"name":"convoy-control","id":35,"wire":"convoy-control","retired":false},{"name":"convoy-create","id":36,"wire":"convoy-create","retired":false},{"name":"convoy-delete","id":37,"wire":"convoy-delete","retired":false},{"name":"convoy-delete-source","id":38,"wire":"convoy-delete-source","retired":false},{"name":"convoy-land","id":39,"wire":"convoy-land","retired":false},{"name":"convoy-list","id":40,"wire":"convoy-list","retired":false},{"name":"convoy-reopen-source","id":41,"wire":"convoy-reopen-source","retired":false},{"name":"convoy-status","id":42,"wire":"convoy-status","retired":false},{"name":"convoy-stranded","id":43,"wire":"convoy-stranded","retired":false},{"name":"convoy-target","id":44,"wire":"convoy-target","retired":false},{"name":"costs","id":45,"wire":"costs","retired":false},{"name":"dashboard","id":46,"wire":"dashboard","retired":false},{"name":"dashboard-serve","id":47,"wire":"dashboard-serve","retired":false},{"name":"doctor","id":48,"wire":"doctor","retired":false},{"name":"dolt-cleanup","id":49,"wire":"dolt-cleanup","retired":false},{"name":"events","id":50,"wire":"events","retired":false},{"name":"events-rotate","id":51,"wire":"events-rotate","retired":false},{"name":"extmsg-bind","id":52,"wire":"extmsg-bind","retired":false},{"name":"extmsg-handoff","id":53,"wire":"extmsg-handoff","retired":false},{"name":"extmsg-unbind","id":54,"wire":"extmsg-unbind","retired":false},{"name":"formula-cook","id":55,"wire":"formula-cook","retired":false},{"name":"formula-list","id":56,"wire":"formula-list","retired":false},{"name":"formula-show","id":57,"wire":"formula-show","retired":false},{"name":"formula-version-check","id":58,"wire":"formula-version-check","retired":false},{"name":"github-pr-backfill","id":59,"wire":"github-pr-backfill","retired":false},{"name":"graph","id":60,"wire":"graph","retired":false},{"name":"handoff","id":61,"wire":"handoff","retired":false},{"name":"import-add","id":62,"wire":"import-add","retired":false},{"name":"import-check","id":63,"wire":"import-check","retired":false},{"name":"import-credential-add","id":64,"wire":"import-credential-add","retired":false},{"name":"import-credential-list","id":65,"wire":"import-credential-list","retired":false},{"name":"import-credential-remove","id":66,"wire":"import-credential-remove","retired":false},{"name":"import-install","id":67,"wire":"import-install","retired":false},{"name":"import-list","id":68,"wire":"import-list","retired":false},{"name":"import-prune","id":69,"wire":"import-prune","retired":false},{"name":"import-remove","id":70,"wire":"import-remove","retired":false},{"name":"import-status","id":71,"wire":"import-status","retired":false},{"name":"import-upgrade","id":72,"wire":"import-upgrade","retired":false},{"name":"import-why","id":73,"wire":"import-why","retired":false},{"name":"init","id":74,"wire":"init","retired":false},{"name":"lint","id":75,"wire":"lint","retired":false},{"name":"mail-archive","id":76,"wire":"mail-archive","retired":false},{"name":"mail-check","id":77,"wire":"mail-check","retired":false},{"name":"mail-count","id":78,"wire":"mail-count","retired":false},{"name":"mail-delete","id":79,"wire":"mail-delete","retired":false},{"name":"mail-inbox","id":80,"wire":"mail-inbox","retired":false},{"name":"mail-mark-read","id":81,"wire":"mail-mark-read","retired":false},{"name":"mail-mark-unread","id":82,"wire":"mail-mark-unread","retired":false},{"name":"mail-peek","id":83,"wire":"mail-peek","retired":false},{"name":"mail-read","id":84,"wire":"mail-read","retired":false},{"name":"mail-reply","id":85,"wire":"mail-reply","retired":false},{"name":"mail-send","id":86,"wire":"mail-send","retired":false},{"name":"mail-thread","id":87,"wire":"mail-thread","retired":false},{"name":"maintenance-dolt-gc","id":88,"wire":"maintenance-dolt-gc","retired":false},{"name":"maintenance-status","id":89,"wire":"maintenance-status","retired":false},{"name":"mcp-list","id":90,"wire":"mcp-list","retired":false},{"name":"nudge-status","id":91,"wire":"nudge-status","retired":false},{"name":"order-check","id":92,"wire":"order-check","retired":false},{"name":"order-history","id":93,"wire":"order-history","retired":false},{"name":"order-list","id":94,"wire":"order-list","retired":false},{"name":"order-run","id":95,"wire":"order-run","retired":false},{"name":"order-show","id":96,"wire":"order-show","retired":false},{"name":"order-sweep-nudge-mail","id":97,"wire":"order-sweep-nudge-mail","retired":false},{"name":"order-sweep-tracking","id":98,"wire":"order-sweep-tracking","retired":false},{"name":"pack-fetch","id":99,"wire":"pack-fetch","retired":false},{"name":"pack-list","id":100,"wire":"pack-list","retired":false},{"name":"pack-registry-add","id":101,"wire":"pack-registry-add","retired":false},{"name":"pack-registry-list","id":102,"wire":"pack-registry-list","retired":false},{"name":"pack-registry-login","id":103,"wire":"pack-registry-login","retired":false},{"name":"pack-registry-publish","id":104,"wire":"pack-registry-publish","retired":false},{"name":"pack-registry-refresh","id":105,"wire":"pack-registry-refresh","retired":false},{"name":"pack-registry-remove","id":106,"wire":"pack-registry-remove","retired":false},{"name":"pack-registry-search","id":107,"wire":"pack-registry-search","retired":false},{"name":"pack-registry-show","id":108,"wire":"pack-registry-show","retired":false},{"name":"pack-registry-whoami","id":109,"wire":"pack-registry-whoami","retired":false},{"name":"pack-release-hash","id":110,"wire":"pack-release-hash","retired":false},{"name":"pack-release-stamp","id":111,"wire":"pack-release-stamp","retired":false},{"name":"pack-release-validate","id":112,"wire":"pack-release-validate","retired":false},{"name":"pack-release-verify","id":113,"wire":"pack-release-verify","retired":false},{"name":"perf-run","id":114,"wire":"perf-run","retired":false},{"name":"perf-session-new","id":115,"wire":"perf-session-new","retired":false},{"name":"prime","id":116,"wire":"prime","retired":false},{"name":"prompt-synth","id":117,"wire":"prompt-synth","retired":false},{"name":"register","id":118,"wire":"register","retired":false},{"name":"reload","id":119,"wire":"reload","retired":false},{"name":"restart","id":120,"wire":"restart","retired":false},{"name":"resume","id":121,"wire":"resume","retired":false},{"name":"rig-add","id":122,"wire":"rig-add","retired":false},{"name":"rig-list","id":123,"wire":"rig-list","retired":false},{"name":"rig-remove","id":124,"wire":"rig-remove","retired":false},{"name":"rig-restart","id":125,"wire":"rig-restart","retired":false},{"name":"rig-resume","id":126,"wire":"rig-resume","retired":false},{"name":"rig-set-endpoint","id":127,"wire":"rig-set-endpoint","retired":false},{"name":"rig-status","id":128,"wire":"rig-status","retired":false},{"name":"rig-suspend","id":129,"wire":"rig-suspend","retired":false},{"name":"runtime-check","id":130,"wire":"runtime-check","retired":false},{"name":"runtime-conformance","id":131,"wire":"runtime-conformance","retired":false},{"name":"runtime-drain","id":132,"wire":"runtime-drain","retired":false},{"name":"runtime-drain-ack","id":133,"wire":"runtime-drain-ack","retired":false},{"name":"runtime-drain-check","id":134,"wire":"runtime-drain-check","retired":false},{"name":"runtime-request-restart","id":135,"wire":"runtime-request-restart","retired":false},{"name":"runtime-undrain","id":136,"wire":"runtime-undrain","retired":false},{"name":"service-doctor","id":137,"wire":"service-doctor","retired":false},{"name":"service-list","id":138,"wire":"service-list","retired":false},{"name":"service-restart","id":139,"wire":"service-restart","retired":false},{"name":"session-attach","id":140,"wire":"session-attach","retired":false},{"name":"session-close","id":141,"wire":"session-close","retired":false},{"name":"session-kill","id":142,"wire":"session-kill","retired":false},{"name":"session-list","id":143,"wire":"session-list","retired":false},{"name":"session-logs","id":144,"wire":"session-logs","retired":false},{"name":"session-new","id":145,"wire":"session-new","retired":false},{"name":"session-nudge","id":146,"wire":"session-nudge","retired":false},{"name":"session-peek","id":147,"wire":"session-peek","retired":false},{"name":"session-pin","id":148,"wire":"session-pin","retired":false},{"name":"session-prune","id":149,"wire":"session-prune","retired":false},{"name":"session-rename","id":150,"wire":"session-rename","retired":false},{"name":"session-reset","id":151,"wire":"session-reset","retired":false},{"name":"session-submit","id":152,"wire":"session-submit","retired":false},{"name":"session-suspend","id":153,"wire":"session-suspend","retired":false},{"name":"session-unpin","id":154,"wire":"session-unpin","retired":false},{"name":"session-wait","id":155,"wire":"session-wait","retired":false},{"name":"session-wake","id":156,"wire":"session-wake","retired":false},{"name":"shell-install","id":157,"wire":"shell-install","retired":false},{"name":"shell-remove","id":158,"wire":"shell-remove","retired":false},{"name":"shell-status","id":159,"wire":"shell-status","retired":false},{"name":"skill-list","id":160,"wire":"skill-list","retired":false},{"name":"sling","id":161,"wire":"sling","retired":false},{"name":"start","id":162,"wire":"start","retired":false},{"name":"status","id":163,"wire":"status","retired":false},{"name":"stop","id":164,"wire":"stop","retired":false},{"name":"supervisor-install","id":165,"wire":"supervisor-install","retired":false},{"name":"supervisor-logs","id":166,"wire":"supervisor-logs","retired":false},{"name":"supervisor-reload","id":167,"wire":"supervisor-reload","retired":false},{"name":"supervisor-run","id":168,"wire":"supervisor-run","retired":false},{"name":"supervisor-start","id":169,"wire":"supervisor-start","retired":false},{"name":"supervisor-status","id":170,"wire":"supervisor-status","retired":false},{"name":"supervisor-stop","id":171,"wire":"supervisor-stop","retired":false},{"name":"supervisor-uninstall","id":172,"wire":"supervisor-uninstall","retired":false},{"name":"suspend","id":173,"wire":"suspend","retired":false},{"name":"trace-cycle","id":174,"wire":"trace-cycle","retired":false},{"name":"trace-reasons","id":175,"wire":"trace-reasons","retired":false},{"name":"trace-show","id":176,"wire":"trace-show","retired":false},{"name":"trace-start","id":177,"wire":"trace-start","retired":false},{"name":"trace-status","id":178,"wire":"trace-status","retired":false},{"name":"trace-stop","id":179,"wire":"trace-stop","retired":false},{"name":"trace-tail","id":180,"wire":"trace-tail","retired":false},{"name":"unregister","id":181,"wire":"unregister","retired":false},{"name":"wait-cancel","id":182,"wire":"wait-cancel","retired":false},{"name":"wait-inspect","id":183,"wire":"wait-inspect","retired":false},{"name":"wait-list","id":184,"wire":"wait-list","retired":false},{"name":"wait-ready","id":185,"wire":"wait-ready","retired":false},{"name":"context-add","id":186,"wire":"context-add","retired":false},{"name":"context-current","id":187,"wire":"context-current","retired":false},{"name":"context-list","id":188,"wire":"context-list","retired":false},{"name":"context-remove","id":189,"wire":"context-remove","retired":false},{"name":"context-show","id":190,"wire":"context-show","retired":false},{"name":"context-use","id":191,"wire":"context-use","retired":false},{"name":"login","id":192,"wire":"login","retired":false},{"name":"logout","id":193,"wire":"logout","retired":false},{"name":"whoami","id":194,"wire":"whoami","retired":false},{"name":"runtime-heartbeat","id":195,"wire":"runtime-heartbeat","retired":false},{"name":"pack-registry-requests","id":196,"wire":"pack-registry-requests","retired":false},{"name":"events-reemit-execution","id":197,"wire":"events-reemit-execution","retired":false},{"name":"storage-migrate","id":198,"wire":"storage-migrate","retired":false},{"name":"storage-status","id":199,"wire":"storage-status","retired":false},{"name":"ready","id":200,"wire":"ready","retired":false},{"name":"storage-recover-stranded","id":201,"wire":"storage-recover-stranded","retired":false},{"name":"beads-metadata-cas","id":202,"wire":"beads-metadata-cas","retired":false},{"name":"nudge-show","id":203,"wire":"nudge-show","retired":false},{"name":"session-release-name","id":204,"wire":"session-release-name","retired":false}]} const ( generatedCommandID5 CommandID = 5 @@ -204,6 +204,7 @@ const ( generatedCommandID201 CommandID = 201 generatedCommandID202 CommandID = 202 generatedCommandID203 CommandID = 203 + generatedCommandID204 CommandID = 204 ) func generatedCommandIDCatalog(yield func(commandIDEntry)) { @@ -406,4 +407,5 @@ func generatedCommandIDCatalog(yield func(commandIDEntry)) { yield(commandIDEntry{id: generatedCommandID201, wire: "storage-recover-stranded"}) yield(commandIDEntry{id: generatedCommandID202, wire: "beads-metadata-cas"}) yield(commandIDEntry{id: generatedCommandID203, wire: "nudge-show"}) + yield(commandIDEntry{id: generatedCommandID204, wire: "session-release-name"}) } diff --git a/internal/productmetrics/event_test.go b/internal/productmetrics/event_test.go index 4f93992d27..5296118352 100644 --- a/internal/productmetrics/event_test.go +++ b/internal/productmetrics/event_test.go @@ -350,8 +350,8 @@ func TestInjectedImmutableCommandCatalogRoundTripsWithoutExpandingProduction(t * generatedCount := 0 generatedCommandIDCatalog(func(commandIDEntry) { generatedCount++ }) - if generatedCount != 199 { - t.Fatalf("generated production catalog has %d entries, want 199", generatedCount) + if generatedCount != 200 { + t.Fatalf("generated production catalog has %d entries, want 200", generatedCount) } injected := func(yield func(commandIDEntry)) { diff --git a/internal/session/manager_test.go b/internal/session/manager_test.go index d09483b93e..6ed60d813e 100644 --- a/internal/session/manager_test.go +++ b/internal/session/manager_test.go @@ -5336,3 +5336,64 @@ func TestPersistInvocationUsageCursor(t *testing.T) { t.Fatalf("cursor metadata after no-ops = %q, want u2", got) } } + +// TestCreateSessionBeadOnlyReclaimsNameFromClosedLegacyHolder is the +// end-to-end regression guard for gc-5fdrr, at the layer that actually vetoed +// the spawn. +// +// The named-session start path pre-clears the name through the cfg-aware +// EnsureSessionNameAvailableWithConfigForOwner, whose legacy bypass handles +// exactly this closed-phantom shape — and then CreateSession re-checks the same +// name through the cfg-less helper. When that inner check recognized a closed +// configured-named holder only by flag/identity, the phantom below (identity +// carried solely by alias/agent_name) made the inner check reject what the +// outer check had just allowed, permanently bricking an on_demand agent. +func TestCreateSessionBeadOnlyReclaimsNameFromClosedLegacyHolder(t *testing.T) { + const ( + sessionName = "shutupandlisten--gc-toolkit__refinery" + identity = "shutupandlisten/gc-toolkit.refinery" + ) + store := beads.NewMemStore() + holder, err := store.Create(beads.Bead{ + Type: BeadType, + Labels: []string{LabelSession}, + Metadata: map[string]string{ + "alias": identity, + "agent_name": identity, + CanonicalInstanceNameMetadata: identity, + }, + }) + if err != nil { + t.Fatalf("Create(holder): %v", err) + } + if err := store.Update(holder.ID, beads.UpdateOpts{Metadata: map[string]string{"session_name": sessionName}}); err != nil { + t.Fatalf("Update(session_name): %v", err) + } + if err := store.Close(holder.ID); err != nil { + t.Fatalf("Close(holder): %v", err) + } + + mgr := NewManagerWithOptions(store, runtime.NewFake()) + info, err := mgr.CreateSession(context.Background(), CreateOptions{ + BeadOnly: true, + Alias: identity, + ExplicitName: sessionName, + Template: "refinery", + Command: "claude", + WorkDir: "/tmp", + Provider: "claude", + ExtraMeta: map[string]string{ + NamedSessionMetadataKey: "true", + NamedSessionIdentityMetadata: identity, + }, + }) + if err != nil { + t.Fatalf("CreateSession over closed legacy holder: %v", err) + } + if info.ID == holder.ID { + t.Fatalf("CreateSession reused the closed holder %s instead of creating a fresh bead", holder.ID) + } + if info.SessionName != sessionName { + t.Fatalf("SessionName = %q, want %q", info.SessionName, sessionName) + } +} diff --git a/internal/session/name_release.go b/internal/session/name_release.go new file mode 100644 index 0000000000..387ab24e5c --- /dev/null +++ b/internal/session/name_release.go @@ -0,0 +1,98 @@ +package session + +import ( + "errors" + "fmt" + "sort" + "strings" + + "github.com/gastownhall/gascity/internal/beads" +) + +// ErrSessionNameClaimLive reports that a session name cannot be released +// because a live session bead still owns it. +var ErrSessionNameClaimLive = errors.New("session name is held by a live session") + +// ReleasedNameClaim records the runtime identifiers cleared from one closed +// session bead, so callers can report exactly what was freed. +type ReleasedNameClaim struct { + // BeadID is the closed session bead that held the reservation. + BeadID string + // SessionName is the runtime session name the bead reserved, if any. + SessionName string + // Alias is the human-facing alias the bead reserved, if any. + Alias string +} + +// ReleaseSessionNameClaim frees the runtime identifiers reserved by CLOSED +// session beads matching target, which may be either a runtime session name or +// an alias. It returns one entry per bead released. +// +// A closed session bead retains session_name, alias, and its canonical-identity +// record, and name resolution consults those records. Where the automatic +// releases in ensureSessionNameAvailableForSelfAndOwner do not apply — an +// ad-hoc explicit name, or a holder whose identity signals do not resolve to +// the claimant — the reservation outlives every runtime it described while no +// lifecycle command will touch it: nudge, wake, kill, and prune all reject a +// closed bead, and prune does not accept the closed state at all. This is the +// operator lever for that state (gc-5fdrr), replacing hand metadata surgery. +// +// agent_name is deliberately preserved: it is historical attribution, not a +// runtime reservation, and nothing resolves a name through it on a closed bead. +// +// A live holder is refused rather than released. Clearing a running session's +// identifiers would let a second session claim the same name, so the remedy +// there is to stop that session first. The refusal is all-or-nothing: no bead +// is modified when any holder is live, so a partial release cannot leave the +// name half-owned. +func ReleaseSessionNameClaim(store beads.Store, target string) ([]ReleasedNameClaim, error) { + target = strings.TrimSpace(target) + if target == "" { + return nil, fmt.Errorf("releasing session name claim: target must not be empty") + } + if store == nil { + return nil, nil + } + + candidates, err := ExactMetadataSessionCandidates(store, true, + map[string]string{"session_name": target}, + map[string]string{"alias": target}, + ) + if err != nil { + return nil, fmt.Errorf("listing sessions holding %q: %w", target, err) + } + + holders := make([]beads.Bead, 0, len(candidates)) + for _, b := range candidates { + if !IsSessionBeadOrRepairable(b) { + continue + } + if strings.TrimSpace(b.Metadata["session_name"]) != target && + strings.TrimSpace(b.Metadata["alias"]) != target { + continue + } + if b.Status != "closed" { + return nil, fmt.Errorf("%w: %q is held by %s (status %s) — stop that session first", + ErrSessionNameClaimLive, target, b.ID, b.Status) + } + holders = append(holders, b) + } + // Candidate order follows the per-filter query order, so sort by bead ID + // for a stable report across the session_name and alias lookups. + sort.Slice(holders, func(i, j int) bool { return holders[i].ID < holders[j].ID }) + + released := make([]ReleasedNameClaim, 0, len(holders)) + for _, b := range holders { + meta := map[string]string{"session_name": "", "alias": ""} + freeCanonicalIdentityMetadata(meta) + if err := store.Update(b.ID, beads.UpdateOpts{Metadata: meta}); err != nil { + return released, fmt.Errorf("releasing session name claim on %s: %w", b.ID, err) + } + released = append(released, ReleasedNameClaim{ + BeadID: b.ID, + SessionName: strings.TrimSpace(b.Metadata["session_name"]), + Alias: strings.TrimSpace(b.Metadata["alias"]), + }) + } + return released, nil +} diff --git a/internal/session/name_release_test.go b/internal/session/name_release_test.go new file mode 100644 index 0000000000..63ab6f3bdc --- /dev/null +++ b/internal/session/name_release_test.go @@ -0,0 +1,150 @@ +package session + +import ( + "errors" + "testing" + + "github.com/gastownhall/gascity/internal/beads" +) + +// closedNameHolder stores a CLOSED session bead holding the given runtime +// identifiers, in the shape the live outage left behind: session_name, alias, +// and the canonical-identity record all retained past close. +func closedNameHolder(t *testing.T, store beads.Store, sessionName, identity string) string { + t.Helper() + bead, err := store.Create(beads.Bead{ + Type: BeadType, + Labels: []string{LabelSession}, + Metadata: map[string]string{ + "alias": identity, + "agent_name": identity, + CanonicalInstanceNameMetadata: identity, + CanonicalPoolSlotMetadata: "2", + }, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := store.Update(bead.ID, beads.UpdateOpts{Metadata: map[string]string{"session_name": sessionName}}); err != nil { + t.Fatalf("Update(session_name): %v", err) + } + if err := store.Close(bead.ID); err != nil { + t.Fatalf("Close: %v", err) + } + return bead.ID +} + +// TestReleaseSessionNameClaim_ClearsClosedHolderReservations is the operator +// lever the outage lacked: a CLOSED session bead reserving a runtime name has +// its reservations cleared by name, without metadata surgery. +func TestReleaseSessionNameClaim_ClearsClosedHolderReservations(t *testing.T) { + store := beads.NewMemStore() + const ( + sessionName = "shutupandlisten--gc-toolkit__refinery" + identity = "shutupandlisten/gc-toolkit.refinery" + ) + id := closedNameHolder(t, store, sessionName, identity) + + released, err := ReleaseSessionNameClaim(store, sessionName) + if err != nil { + t.Fatalf("ReleaseSessionNameClaim: %v", err) + } + if len(released) != 1 || released[0].BeadID != id { + t.Fatalf("released = %+v, want one entry for %s", released, id) + } + if released[0].SessionName != sessionName || released[0].Alias != identity { + t.Fatalf("released[0] = %+v, want session_name %q and alias %q", released[0], sessionName, identity) + } + + got, err := store.Get(id) + if err != nil { + t.Fatalf("store.Get: %v", err) + } + for _, key := range []string{"session_name", "alias", CanonicalInstanceNameMetadata, CanonicalPoolSlotMetadata} { + if v := got.Metadata[key]; v != "" { + t.Fatalf("metadata[%q] = %q, want empty after release", key, v) + } + } + // agent_name is history, not a runtime reservation — it survives. + if got.Metadata["agent_name"] != identity { + t.Fatalf("agent_name = %q, want %q preserved as history", got.Metadata["agent_name"], identity) + } + + // The released name is available again. + if err := ensureSessionNameAvailable(store, sessionName); err != nil { + t.Fatalf("ensureSessionNameAvailable after release = %v, want nil", err) + } +} + +// TestReleaseSessionNameClaim_ResolvesByAlias confirms the lever accepts the +// human-facing alias as well as the sanitized runtime session name — the +// operator hitting this deadlock knows the alias, not the tmux-safe form. +func TestReleaseSessionNameClaim_ResolvesByAlias(t *testing.T) { + store := beads.NewMemStore() + const ( + sessionName = "shutupandlisten--gc-toolkit__refinery" + identity = "shutupandlisten/gc-toolkit.refinery" + ) + id := closedNameHolder(t, store, sessionName, identity) + + released, err := ReleaseSessionNameClaim(store, identity) + if err != nil { + t.Fatalf("ReleaseSessionNameClaim(alias): %v", err) + } + if len(released) != 1 || released[0].BeadID != id { + t.Fatalf("released = %+v, want one entry for %s", released, id) + } +} + +// TestReleaseSessionNameClaim_RefusesLiveHolder is the safety gate: releasing a +// name a running session still owns would let a second session claim it. The +// operator stops that session instead. +func TestReleaseSessionNameClaim_RefusesLiveHolder(t *testing.T) { + store := beads.NewMemStore() + const sessionName = "shutupandlisten--gc-toolkit__refinery" + live, err := store.Create(beads.Bead{ + Type: BeadType, + Labels: []string{LabelSession}, + Metadata: map[string]string{"session_name": sessionName}, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + released, err := ReleaseSessionNameClaim(store, sessionName) + if !errors.Is(err, ErrSessionNameClaimLive) { + t.Fatalf("ReleaseSessionNameClaim(live holder) = %v, want %v", err, ErrSessionNameClaimLive) + } + if len(released) != 0 { + t.Fatalf("released = %+v, want none when a live holder blocks", released) + } + if got, getErr := store.Get(live.ID); getErr != nil { + t.Fatalf("store.Get: %v", getErr) + } else if got.Metadata["session_name"] != sessionName { + t.Fatalf("session_name = %q, want %q left intact", got.Metadata["session_name"], sessionName) + } +} + +// TestReleaseSessionNameClaim_NoHolder reports a clean no-op rather than an +// error, so the lever is safe to run speculatively while diagnosing. +func TestReleaseSessionNameClaim_NoHolder(t *testing.T) { + store := beads.NewMemStore() + + released, err := ReleaseSessionNameClaim(store, "nobody-holds-this") + if err != nil { + t.Fatalf("ReleaseSessionNameClaim(no holder) = %v, want nil", err) + } + if len(released) != 0 { + t.Fatalf("released = %+v, want none", released) + } +} + +// TestReleaseSessionNameClaim_RejectsEmptyTarget guards against an empty +// target matching every bead with an unset identifier. +func TestReleaseSessionNameClaim_RejectsEmptyTarget(t *testing.T) { + store := beads.NewMemStore() + + if _, err := ReleaseSessionNameClaim(store, " "); err == nil { + t.Fatal("ReleaseSessionNameClaim(empty target) = nil, want error") + } +} diff --git a/internal/session/names.go b/internal/session/names.go index 3ac5bb891e..80331032e9 100644 --- a/internal/session/names.go +++ b/internal/session/names.go @@ -365,6 +365,29 @@ func ensureSessionNameAvailableForSelfAndOwner(store beads.Store, name, selfID, if b.Status == "closed" && wasConfiguredNamedSession(b) { continue } + // Owner-scoped counterpart of the release above, for the pre-ga841 + // phantom shape whose identity survives only as an alias / + // agent_name / template label (ga-n2d Gap C). The named-session + // start path clears its cfg-aware pre-check + // (EnsureSessionNameAvailableWithConfigForOwner's legacy bypass) + // and then Manager.CreateSession re-checks the same name here + // without a cfg. Recognizing only the flag/identity made this + // inner check veto what the outer check had just allowed, so a + // closed phantom bricked its configured identity's runtime name + // permanently — and every documented lever (nudge, wake, kill, + // prune, new) rejects the holder for being closed, leaving no CLI + // escape (gc-5fdrr; a live 40h on_demand-agent outage). + // + // configuredNamedIdentitySignalsMatch is the owner-SCOPED + // recognizer already used for this same trap in name_claim_sweep.go + // and the alias-availability check: it requires a non-empty owner + // and matches the bead's recorded identity, alias, agent_name, or + // template/role label against THAT owner. So only the configured + // identity that owns the reserved name reclaims it — never an + // unrelated claimant, and never a live holder. + if b.Status == "closed" && configuredNamedIdentitySignalsMatch(b, selfOwner) { + continue + } // A retired ephemeral pool slot must not permanently reserve the // name either. The reconciler closes the slot bead without // clearing session_name, and a configured named session that was @@ -378,7 +401,7 @@ func ensureSessionNameAvailableForSelfAndOwner(store beads.Store, name, selfID, strings.TrimSpace(b.Metadata["session_origin"]) == "ephemeral" { continue } - return fmt.Errorf("%w: %q already belongs to %s", ErrSessionNameExists, name, b.ID) + return sessionNameHolderError(name, b) } if b.Status == "closed" { continue @@ -410,6 +433,22 @@ func ensureSessionNameAvailableForSelfAndOwner(store beads.Store, name, selfID, return nil } +// sessionNameHolderError reports that a session name is reserved by another +// session bead, naming both the holder and the remedy. +// +// A CLOSED holder is the dangerous shape: it has no runtime to stop, so an +// operator reading only "already belongs to " has no way to tell the name +// is held by a dead record rather than a live session, and no lever to try. +// That ambiguity is what turned gc-5fdrr into a ~40h outage resolved by hand +// metadata surgery. Say the holder is closed and name the release lever. +func sessionNameHolderError(name string, b beads.Bead) error { + if b.Status == "closed" { + return fmt.Errorf("%w: %q already belongs to %s (closed); release it with: gc session release-name %q", + ErrSessionNameExists, name, b.ID, name) + } + return fmt.Errorf("%w: %q already belongs to %s", ErrSessionNameExists, name, b.ID) +} + func failedCreateIdentityReleased(b beads.Bead) bool { return strings.TrimSpace(b.Metadata["state"]) == string(StateFailedCreate) } diff --git a/internal/session/names_test.go b/internal/session/names_test.go index fc8aa194d6..00fdf20c4a 100644 --- a/internal/session/names_test.go +++ b/internal/session/names_test.go @@ -366,6 +366,151 @@ func TestEnsureSessionNameAvailable_RejectsLiveNamedSessionByIdentity(t *testing } } +// legacyClosedNameClaim stores a CLOSED session bead in the pre-ga841 phantom +// shape from the live 40h refinery outage: it reserves a configured named +// session's runtime name while recording its identity only through the +// alias / agent_name / canonical-identity mirrors — no configured_named_session +// flag and no configured_named_identity. +func legacyClosedNameClaim(t *testing.T, store beads.Store, runtimeName, identity string) { + t.Helper() + bead, err := store.Create(beads.Bead{ + Type: BeadType, + Labels: []string{LabelSession}, + Metadata: map[string]string{ + "session_name": "runtime-name-placeholder", + "alias": identity, + "agent_name": identity, + CanonicalInstanceNameMetadata: identity, + }, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := store.Update(bead.ID, beads.UpdateOpts{Metadata: map[string]string{"session_name": runtimeName}}); err != nil { + t.Fatalf("Update(session_name): %v", err) + } + if err := store.Close(bead.ID); err != nil { + t.Fatalf("Close: %v", err) + } +} + +// TestEnsureSessionNameAvailableForOwner_ReleasesClosedLegacyIdentityClaim +// reproduces the outage where a CLOSED session bead permanently bricked an +// on_demand agent's alias, so the agent could never be spawned again. +// +// The named-session start path already clears its cfg-aware pre-check +// (EnsureSessionNameAvailableWithConfigForOwner's legacy bypass), then +// Manager.CreateSession re-checks the same name through this cfg-less helper. +// Recognition here was owner-AGNOSTIC (wasConfiguredNamedSession), so the +// pre-ga841 phantom shape — identity carried only by alias/agent_name — fell +// through to ErrSessionNameExists and the inner check vetoed what the outer +// check had just allowed. Every documented lever (nudge, wake, kill, prune, +// new) then rejected the bead for being closed, leaving no CLI escape. +func TestEnsureSessionNameAvailableForOwner_ReleasesClosedLegacyIdentityClaim(t *testing.T) { + store := beads.NewMemStore() + const ( + runtimeName = "shutupandlisten--gc-toolkit__refinery" + identity = "shutupandlisten/gc-toolkit.refinery" + ) + legacyClosedNameClaim(t, store, runtimeName, identity) + + if err := ensureSessionNameAvailableForSelfAndOwner(store, runtimeName, "", identity); err != nil { + t.Fatalf("ensureSessionNameAvailableForSelfAndOwner(closed legacy claim, owning identity) = %v, want nil", err) + } +} + +// TestEnsureSessionNameAvailableForOwner_RejectsClosedLegacyClaimForOtherOwner +// is the ownership guard: the release above is scoped to the configured +// identity that OWNS the reserved name. A different identity claiming the same +// runtime name is still rejected, so the fix cannot hand one agent's name to +// another. +func TestEnsureSessionNameAvailableForOwner_RejectsClosedLegacyClaimForOtherOwner(t *testing.T) { + store := beads.NewMemStore() + const runtimeName = "shutupandlisten--gc-toolkit__refinery" + legacyClosedNameClaim(t, store, runtimeName, "shutupandlisten/gc-toolkit.refinery") + + err := ensureSessionNameAvailableForSelfAndOwner(store, runtimeName, "", "signal-loom/gc-toolkit.refinery") + if !errors.Is(err, ErrSessionNameExists) { + t.Fatalf("ensureSessionNameAvailableForSelfAndOwner(closed legacy claim, other identity) = %v, want %v", err, ErrSessionNameExists) + } +} + +// TestEnsureSessionNameAvailableForOwner_RejectsClosedLegacyClaimWithoutOwner +// pins the ownerless case: a caller that asserts no configured identity cannot +// attribute the closed holder to itself, so the permanent-identity rule for +// ad-hoc explicit session names still holds. +func TestEnsureSessionNameAvailableForOwner_RejectsClosedLegacyClaimWithoutOwner(t *testing.T) { + store := beads.NewMemStore() + const runtimeName = "shutupandlisten--gc-toolkit__refinery" + legacyClosedNameClaim(t, store, runtimeName, "shutupandlisten/gc-toolkit.refinery") + + err := ensureSessionNameAvailableForSelfAndOwner(store, runtimeName, "", "") + if !errors.Is(err, ErrSessionNameExists) { + t.Fatalf("ensureSessionNameAvailableForSelfAndOwner(closed legacy claim, no owner) = %v, want %v", err, ErrSessionNameExists) + } +} + +// TestEnsureSessionNameAvailableForOwner_RejectsLiveLegacyClaimForOwner is the +// liveness guard: only CLOSED holders release. An open bead for the same +// identity still owns its runtime name so two live sessions cannot collide. +func TestEnsureSessionNameAvailableForOwner_RejectsLiveLegacyClaimForOwner(t *testing.T) { + store := beads.NewMemStore() + const ( + runtimeName = "shutupandlisten--gc-toolkit__refinery" + identity = "shutupandlisten/gc-toolkit.refinery" + ) + if _, err := store.Create(beads.Bead{ + Type: BeadType, + Labels: []string{LabelSession}, + Metadata: map[string]string{ + "session_name": runtimeName, + "alias": identity, + "agent_name": identity, + CanonicalInstanceNameMetadata: identity, + }, + }); err != nil { + t.Fatalf("Create: %v", err) + } + + err := ensureSessionNameAvailableForSelfAndOwner(store, runtimeName, "", identity) + if !errors.Is(err, ErrSessionNameExists) { + t.Fatalf("ensureSessionNameAvailableForSelfAndOwner(live legacy claim) = %v, want %v", err, ErrSessionNameExists) + } +} + +// TestEnsureSessionNameAvailable_NameExistsErrorIsActionable pins the +// diagnostic half of the outage: the operator saw only "already belongs to +// " with no hint that the holder was CLOSED and no remedy, so the deadlock +// took ~40h and metadata surgery to break. The message must name the holder's +// terminal status and point at the release lever. +func TestEnsureSessionNameAvailable_NameExistsErrorIsActionable(t *testing.T) { + store := beads.NewMemStore() + bead, err := store.Create(beads.Bead{ + Type: BeadType, + Labels: []string{LabelSession}, + Metadata: map[string]string{ + "session_name": "my-custom-session", + }, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := store.Close(bead.ID); err != nil { + t.Fatalf("Close: %v", err) + } + + err = ensureSessionNameAvailable(store, "my-custom-session") + if !errors.Is(err, ErrSessionNameExists) { + t.Fatalf("ensureSessionNameAvailable(closed ad-hoc) = %v, want %v", err, ErrSessionNameExists) + } + msg := err.Error() + for _, want := range []string{bead.ID, "closed", "gc session release-name"} { + if !strings.Contains(msg, want) { + t.Fatalf("error %q does not mention %q", msg, want) + } + } +} + func TestEnsureAliasAvailableWithConfig_AllowsLiveAliasHistoryReuse(t *testing.T) { store := beads.NewMemStore() _, err := store.Create(beads.Bead{ diff --git a/schemas/metrics/example/result.schema.json b/schemas/metrics/example/result.schema.json index 071374c907..be5c1ca22a 100644 --- a/schemas/metrics/example/result.schema.json +++ b/schemas/metrics/example/result.schema.json @@ -213,7 +213,8 @@ "ready", "storage-recover-stranded", "beads-metadata-cas", - "nudge-show" + "nudge-show", + "session-release-name" ] }, "event_id": { diff --git a/schemas/session/release-name/result.schema.json b/schemas/session/release-name/result.schema.json new file mode 100644 index 0000000000..0b4e8c47a1 --- /dev/null +++ b/schemas/session/release-name/result.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "x-gc-jsonl": {}, + "type": "object", + "properties": { + "schema_version": { "const": "1" }, + "ok": { "const": true }, + "command": { "const": "session release-name" }, + "action": { "const": "release-name" }, + "identity": { "type": "string", "minLength": 1 }, + "count": { "type": "integer", "minimum": 0 }, + "session_id": { "type": "string", "minLength": 1 } + }, + "required": ["schema_version", "ok", "command", "action", "identity", "count"], + "additionalProperties": true +}