From 87150c9e8ab895bdc93a6b1bf4b7cf2776516882 Mon Sep 17 00:00:00 2001 From: Zook Bot <275398848+zook-bot@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:18:30 +0000 Subject: [PATCH 1/2] fix(session): release a closed bead's name claim for its own configured identity (gc-5fdrr) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A CLOSED session bead kept the runtime identifiers it ran under, and name resolution still consulted them, so an on_demand agent's alias could be reserved forever by a dead record. The live incident (gc-5fdrr) held `shutupandlisten/gc-toolkit.refinery` unspawnable for ~40h: every documented lever refused it — nudge/wake by alias hit "name already exists", nudge/wake/ kill by bead ID hit "session is closed", `prune` does not accept the closed state, and `session new` has no reclaim flag. It was broken only by hand metadata surgery on three undiscoverable keys. Root cause is a divergence between two checks of the same name. The named-session start path clears `EnsureSessionNameAvailableWithConfigForOwner`, whose legacy bypass exists precisely for pre-ga841 phantoms, and then `Manager.CreateSession` re-checks the same name through the cfg-less `ensureSessionNameAvailableForSelfAndOwner`. That inner check recognized a closed configured-named holder only by the boolean flag or the recorded identity — both owner-AGNOSTIC — so a phantom carrying its identity solely as alias/agent_name (ga-n2d Gap C shape) fell through and the inner check vetoed what the outer check had just allowed. The fix reuses `configuredNamedIdentitySignalsMatch`, the owner-SCOPED recognizer already used for this same trap in the startup sweep and the alias-availability check: a CLOSED holder releases the name only to the configured identity its own recorded signals resolve to. A different claimant, an ownerless claimant, and any live holder are all still rejected, so the permanent-identity rule for ad-hoc explicit names is untouched. Two things the automatic release deliberately does not cover — an ad-hoc explicit name, and a holder whose signals name no claimant — still left the operator with no lever, so this also adds `gc session release-name `: it clears session_name, alias, and the canonical-identity record from CLOSED holders (agent_name survives as history), refuses all-or-nothing when any holder is live, and no-ops cleanly when nothing holds the name. The ErrSessionNameExists message now says the holder is closed and names that command, so the next operator to hit this reads the remedy off the error instead of diagnosing for 40h. Validation: new unit tests in internal/session cover the release, the other-owner / ownerless / live-holder guards, the error text, and the release-name domain function; cmd/gc tests cover the command end to end. The generated command census, metrics catalog, example schema enum, and CLI reference were regenerated by their own generators; the productmetrics catalog-size baseline moves 199 -> 200 for the new command. On ask 4 of the bead (make gc doctor's `configured-named-conflict` finding say a closed holder needs metadata surgery): that check skips closed beads outright (`cmd/gc/doctor_session_model.go`), so it only ever reports a LIVE conflict, and its premise no longer holds — a closed holder is now either auto-released to its own identity or releasable with one command. The live finding is left as-is: a live conflict's remedy is stopping or reconciling that session, not releasing a name. --- cmd/gc/cmd_session.go | 3 +- cmd/gc/cmd_session_release_name.go | 97 +++++++++++ cmd/gc/cmd_session_release_name_test.go | 147 +++++++++++++++++ cmd/gc/metrics_census_gen.go | 2 + cmd/gc/productmetrics_command_census.json | 17 +- cmd/gc/session_action_json_test.go | 1 + docs/reference/cli.md | 30 ++++ internal/productmetrics/command_ids_gen.go | 4 +- internal/productmetrics/event_test.go | 4 +- internal/session/name_release.go | 98 ++++++++++++ internal/session/name_release_test.go | 150 ++++++++++++++++++ internal/session/names.go | 41 ++++- internal/session/names_test.go | 145 +++++++++++++++++ schemas/metrics/example/result.schema.json | 3 +- .../session/release-name/result.schema.json | 16 ++ 15 files changed, 751 insertions(+), 7 deletions(-) create mode 100644 cmd/gc/cmd_session_release_name.go create mode 100644 cmd/gc/cmd_session_release_name_test.go create mode 100644 internal/session/name_release.go create mode 100644 internal/session/name_release_test.go create mode 100644 schemas/session/release-name/result.schema.json 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/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 +} From 1637d6679cbe9189273f158051df6b8eb3ad59c3 Mon Sep 17 00:00:00 2001 From: Zook Bot <275398848+zook-bot@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:13:28 +0000 Subject: [PATCH 2/2] test(session): pin the CreateSession layer that vetoed the gc-5fdrr spawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The helper-level tests cover ensureSessionNameAvailableForSelfAndOwner in isolation, but the outage happened one layer up: the named-session start path pre-cleared the name through the cfg-aware check and Manager.CreateSession then re-checked it through the cfg-less helper and refused. A test that never goes through CreateSession would not have caught that divergence. This adds the end-to-end guard — a bead-only CreateSession for a configured named identity over a CLOSED legacy holder whose identity survives only as alias/agent_name. Confirmed to fail without the fix with the exact production error ("already belongs to gc-1 (closed)") and pass with it. --- internal/session/manager_test.go | 61 ++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) 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) + } +}