Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cmd/gc/cmd_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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),
Expand Down
97 changes: 97 additions & 0 deletions cmd/gc/cmd_session_release_name.go
Original file line number Diff line number Diff line change
@@ -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 <name-or-alias>"
// command.
func newSessionReleaseNameCmd(stdout, stderr io.Writer) *cobra.Command {
var jsonOutput bool
cmd := &cobra.Command{
Use: "release-name <session-name-or-alias>",
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 <bead-id>
(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
}
147 changes: 147 additions & 0 deletions cmd/gc/cmd_session_release_name_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 2 additions & 0 deletions cmd/gc/metrics_census_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 16 additions & 1 deletion cmd/gc/productmetrics_command_census.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"schema_version": 1,
"next_id": 204,
"next_id": 205,
"permanent_ids": [
{
"name": "help",
Expand Down Expand Up @@ -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": [],
Expand Down
1 change: 1 addition & 0 deletions cmd/gc/session_action_json_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
30 changes: 30 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 &lt;bead-id&gt;
(closed)".

A name held by a live session is refused — stop that session instead.

```
gc session release-name <session-name-or-alias> [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
Expand Down
Loading
Loading