diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0078f94b1..6ad43e693 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -265,7 +265,7 @@ src/ │ ├── inject.ts Hook installation into Claude Code settings │ ├── dispatch-command.ts `genie hook dispatch` CLI command │ └── resolve-agent-name.ts Agent name resolution from env -├── tui/ Terminal UI (OpenTUI-based) +├── tui/ Terminal UI (OpenTUI-based) — a skin over the CLI, not an alternate control plane. Every action shows its `genie …` command before executing. See [`SPAWN-TEAM-RESOLUTION.md`](SPAWN-TEAM-RESOLUTION.md). │ ├── app.tsx TUI application root │ └── ... TUI components ├── types/ Shared types (genie-config Zod schema) diff --git a/docs/CLI-REFERENCE.md b/docs/CLI-REFERENCE.md index 8c95fe3bb..ab968a1f4 100644 --- a/docs/CLI-REFERENCE.md +++ b/docs/CLI-REFERENCE.md @@ -40,12 +40,34 @@ Top-level shortcuts for common operations. These are aliases for commands in the ### `genie spawn ` -Spawn a new agent by name (resolves from directory or built-ins). +Spawn a new agent by name (resolves from directory or built-ins). Single verb, state-gated by the canonical row's liveness — see [**SPAWN-TEAM-RESOLUTION.md**](SPAWN-TEAM-RESOLUTION.md) for the full model. + +**State-gated outcome:** + +| Canonical row `` | Result | +|------------------------|--------| +| missing | create canonical with a fresh UUID | +| present, pane **dead** | resume canonical (same UUID) | +| present, pane **alive** | create a **parallel** `-` (s4 = first 4 hex chars of the parallel's fresh UUID) | + +Parallels are off the bare-name auto-resume path — revive a specific parallel with `genie spawn -`. + +**Team-resolution precedence** (first non-null wins — see `resolveTeamName` at `src/term-commands/agents.ts:1675`): + +| Tier | Source | +|------|--------| +| 1 | `--team` flag | +| 2 | `agent.entry?.team` (PG `agent_templates`) | +| 3 | `$GENIE_TEAM` env var | +| 4 | `discoverTeamName()` — JSONL `leadSessionId` match → tmux session name | +| 5 | `findTeamsContainingAgent(name)` — on-disk native team config member scan (heuristic, last-resort) | + +If every tier yields nothing AND the agent is globally registered, `ensureNativeTeam` auto-creates a team-of-one named after the agent. | Option | Type | Default | Description | |--------|------|---------|-------------| | `--provider ` | string | `claude` | Provider: claude or codex | -| `--team ` | string | `$GENIE_TEAM` or `genie` | Team name | +| `--team ` | string | precedence chain above | Team name (tier 1) | | `--model ` | string | | Model override (e.g., sonnet, opus) | | `--skill ` | string | | Skill to load | | `--layout ` | string | `mosaic` | Layout mode: mosaic or vertical | @@ -67,12 +89,16 @@ Spawn a new agent by name (resolves from directory or built-ins). | `--sdk-effort ` | string | | SDK: reasoning effort level (low, medium, high, max) | ```bash +genie spawn simone # Canonical resume (or create if missing) +genie spawn simone # 2nd invocation while alive → parallel simone- +genie spawn simone-a3f7 # Revive a specific parallel by full id genie spawn engineer # Spawn built-in engineer role -genie spawn researcher --model sonnet # Spawn with model override -genie spawn my-agent --team my-feature # Spawn into a specific team +genie spawn my-agent --team my-feature # Tier 1 override (explicit) genie spawn council--questioner --provider codex # Use Codex provider ``` +Short-id collisions (two parallels minting the same 4-hex prefix) are resolved by extending the slice one char at a time until unique — see `pickParallelShortId` at `src/term-commands/agents.ts:1725`. Killed parallels free their id back to the pool. + ### `genie kill ` Force kill an agent by name. diff --git a/docs/SPAWN-AUTO-RESUME.md b/docs/SPAWN-AUTO-RESUME.md index a195e75f7..9bb4e6a66 100644 --- a/docs/SPAWN-AUTO-RESUME.md +++ b/docs/SPAWN-AUTO-RESUME.md @@ -2,6 +2,8 @@ Auto-resume automatically restores agent sessions when their tmux pane dies unexpectedly (crash, OOM, terminal close). Instead of losing the entire conversation context, Genie detects the dead pane and respawns the agent using its preserved Claude session ID. +> **Canonical-UUID invariant.** Auto-resume is the mechanism that keeps the "one name = one true Claude session UUID" guarantee alive across the canonical agent's lifetime. The invariant itself — plus the single-verb state-gated spawn model, the parallel `-` convention, and the five-tier team-resolution precedence — lives in [**SPAWN-TEAM-RESOLUTION.md**](SPAWN-TEAM-RESOLUTION.md). Authority trail: wishes `tui-spawn-dx`, `perfect-spawn-hierarchy`, and `fix-ghost-approval-p0` (all archived in `.genie/wishes/_archive/`). **Parallels** (`-`) are **off** the bare-name auto-resume path described here; they resume only via their full id. + ## How It Works Auto-resume operates at two levels: diff --git a/docs/SPAWN-TEAM-RESOLUTION.md b/docs/SPAWN-TEAM-RESOLUTION.md new file mode 100644 index 000000000..33f38609a --- /dev/null +++ b/docs/SPAWN-TEAM-RESOLUTION.md @@ -0,0 +1,141 @@ +# Spawn / Team Resolution + +> **Audience:** anyone invoking `genie spawn`, writing tooling that wraps it, or debugging why a worker landed in the wrong team / minted a fresh session UUID. +> +> **Source of truth:** `src/term-commands/agents.ts` (`resolveTeamName` at line 1675, `resolveSpawnIdentity` at line 1783), `src/lib/claude-native-teams.ts` (`findTeamsContainingAgent` at line 195). Line numbers reference `dev` HEAD at time of writing — re-verify if source has moved. + +## TL;DR + +Each named agent has **one true Claude session UUID** — the **canonical** row. `genie spawn ` is the single verb; its outcome is determined by the state of that row: + +| Canonical state | Result | +|-----------------|--------| +| row missing | create canonical `` with a fresh UUID | +| row present, pane **dead** | resume canonical (same UUID, same `.jsonl`) | +| row present, pane **alive** | create a **parallel** `-` where `` = first 4 hex chars of the parallel's own fresh UUID | + +Parallels are semi-ephemeral. Bare `genie spawn ` never touches them — they are revived only by their full id (`genie spawn -`), matching Claude Code's own `--resume ` pattern. + +--- + +## The four identities + +| Identity | Row id in `agents` | UUID | Auto-resume by bare name? | Lifespan | +|----------|-------------------|------|---------------------------|----------| +| **Canonical agent** | `` | stable, lives forever | **yes** — `genie spawn ` when canonical is dead | endless | +| **Parallel** | `-` (e.g. `simone-a3f7`) | fresh per parallel | **no** — only `genie spawn -` revives it | semi-ephemeral; persisted, explicit-id resume only | +| **Team** | separate `teams` table | — | — | grouped lifecycle: leader + members, own tmux session, own worktree | +| **Generic spawn** | `-` via `genie agent spawn --team ` | fresh | depends on role | ad-hoc workers within a team | + +Row ids are **not** team-prefixed for canonical or parallel — it's `simone-a3f7`, not `simone-simone-a3f7`. + +## Team-resolution precedence (5 tiers) + +`resolveTeamAndResume` in `src/term-commands/agents.ts:1851` resolves the effective team for every spawn by walking this chain top-down. The first tier that returns a non-null value wins. Implemented by `resolveTeamName` at `src/term-commands/agents.ts:1675` (tiers 1–4) plus the on-disk fallback inline in `resolveTeamAndResume` (tier 5). + +| Tier | Source | Authority | Notes | +|------|--------|-----------|-------| +| 1 | `options.team` (`--team` flag) | explicit user intent | only tier that flips `teamWasExplicit` | +| 2 | `agent.entry?.team` | PG `agent_templates` row | template-pinned teams | +| 3 | `process.env.GENIE_TEAM` | shell / automation context | | +| 4 | `discoverTeamName()` | full discovery: JSONL `leadSessionId` match → tmux session name | last-authoritative signal | +| 5 | `findTeamsContainingAgent(name)` | on-disk native team config member scan | heuristic; errors on ambiguity | + +Tier 5 is the last-resort heuristic: scan `~/.claude/teams/*/config.json`, match exactly one team whose `members` list contains the agent. Ambiguous hits (multiple teams) exit with a clear error. + +If every tier yields nothing AND the agent is globally registered, an **auto team-of-one** is materialized downstream by `resolveNativeTeam → ensureNativeTeam` (introduced by PR #1174). The team is named after the agent itself. + +### Worked example — the `simone` reproducer + +Before the five-tier chain existed, `genie spawn simone` from inside the `genie` tmux session would fall back to `discoverTeamName()` → `"genie"` (from the current tmux session name) and register the worker as `simone@genie`. Every press minted a **fresh** Claude UUID because `findDeadResumable("genie", "simone")` missed the real `simone@simone` row. + +After the chain, `simone`'s `agent_templates` row has `team='simone'`. Tier 2 returns `"simone"` before tier 4 ever fires, so: + +- `simone` resolves to team `simone` every time, regardless of where the caller is sitting. +- `findDeadResumable("simone", "simone")` hits the canonical row. +- When canonical is dead, it's resumed with its original `claude_session_id` — the one true UUID survives. + +## Single-verb state machine + +`resolveSpawnIdentity` in `src/term-commands/agents.ts:1783` is the state machine. It decides, based on the current `agents` table, whether `genie spawn ` creates canonical, resumes canonical, or creates a parallel. + +``` +┌─────────────────────────────────────────────────┐ +│ genie spawn │ +└─────────────────┬───────────────────────────────┘ + │ + ▼ + ┌─────────────────────┐ + │ row `` exists?│ + └─────────┬───────────┘ + │ + no ─────┴───── yes + │ │ + ▼ ▼ + ┌──────────┐ ┌───────────────────┐ + │ CREATE │ │ pane alive? │ + │ canonical│ └─────┬─────────────┘ + │ id=│ │ + │ fresh UUID│ no ────┴──── yes + └──────────┘ │ │ + ▼ ▼ + ┌──────────┐ ┌────────────────┐ + │ RESUME │ │ CREATE parallel│ + │ canonical│ │ id=- │ + │ same UUID│ │ fresh UUID │ + └──────────┘ └────────────────┘ +``` + +**Cross-team branch** — if `` already exists in a **different** team than the one being resolved, the state machine must route to parallel (not canonical) because `agents.id` is a PRIMARY KEY (see `migrations/005_pg_state.sql`). Re-canonicalizing in the requested team would PK-violate at insert. `resolveSpawnIdentity` forces parallel regardless of pane liveness in that case. + +## Short-id collisions + +`pickParallelShortId` in `src/term-commands/agents.ts:1725` computes the parallel row id from the parallel's own fresh UUID: + +```ts +slice = uuid.slice(0, 4) // tier-4 (s4): 65 536 possibilities per name +id = `${baseName}-${slice}` +``` + +Uniqueness is checked **globally** (across all teams) because `agents.id` is the PK. On collision, the slice extends one hex char at a time (`s5`, `s6`, …) until unique. Killed parallel rows are **deleted** from `agents` (not tombstoned), so their short-id returns to the available pool. + +Collision probability at s4: ≈ 1 / 65 536 per agent name per spawn — self-limiting since the space only fills with **live** parallels. + +## CLI invocations + +```bash +# Canonical resume (or create if missing) +genie spawn simone + +# Parallel (auto-chosen because canonical is alive) +genie spawn simone # → simone-a3f7 (next press: simone-8c21, etc.) + +# Explicit parallel resume by full id +genie spawn simone-a3f7 # revives that specific parallel + +# Override the resolved team +genie spawn simone --team some-other-team + +# Spawn an ad-hoc role (not a named canonical) +genie agent spawn engineer --team my-feature +``` + +## Observability + +| Signal | Check | +|--------|-------| +| Canonical UUID is stable | `genie db query "SELECT id, claude_session_id FROM agents WHERE id = ''"` — single row, stable UUID across kill/respawn. | +| Parallel ids are well-formed | `genie db query "SELECT id FROM agents WHERE id LIKE '-%'"` — each suffix matches `^[0-9a-f]{4,}$`. | +| Team binding is correct | `genie db query "SELECT id, team FROM agents WHERE id = ''"` — template-pinned team wins. | +| Resolver audit | `~/.genie/logs/scheduler.log` for `recovery_*` events on daemon restart; no `--team is required` errors under normal spawns. | + +## Related wishes (history) + +- **`tui-spawn-dx`** (PR #1172) — introduced the single-verb state-gated spawn, the `` parallel suffix, and the TUI surfaces for "Spawn into…" / "Spawn here…" / "New team…". +- **`perfect-spawn-hierarchy`** (PR #1133) + **`fix-ghost-approval-p0`** (PR #1134) — established that `leadSessionId` must be a real Claude UUID, never a synthetic placeholder like `"pending"` or `"genie-"`. The tier-2 template lookup added by `tui-spawn-dx` is an authoritative PG read, not a synthetic fallback — it does not violate this principle. + +## TUI is a skin, not an alternate control plane + +Every TUI action (Enter on an agent, context-menu "Spawn into…", "Spawn here…", "New team…") shows the exact `genie …` CLI command it is about to run in a preview line before Enter executes it. This comes from `buildSpawnInvocation(intent) → { cli, argv }` in `src/lib/spawn-invocation.ts` — one helper produces both the preview string and the executed argv, so render and execution can never drift. + +If something worked from the CLI, it works from the TUI. If something's broken in the TUI, `Ctrl+C` out and run the printed command directly.