Repository navigation
feat(tui-spawn-dx): CLI-symmetric agent/team primitives + canonical-UUID invariant - #1172
Conversation
Introduce buildSpawnInvocation(intent) -> {cli, argv} as the single source of
truth for rendering and executing genie spawn / genie team create commands.
The TUI's preview line and executor will both read this descriptor, so the
displayed string can never drift from the actual argv.
- spawn-agent intent covers name, team, session, window, newWindow, prompt.
- create-team intent covers name, repo, baseBranch (members are out of scope
for this helper — team hire is a follow-up sequence).
- Validates non-empty name for both variants and branch-safe chars for
create-team names.
- Reuses shellQuote from team-lead-command.ts; argv round-trips to cli via
argv.map(shellQuote).join(' ').
Scaffolding for tui-spawn-dx wish groups 4-7, which will wire this into the
TUI render/execute paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
23 tests across three groups:
- Positive: each SpawnIntent variant (spawn-agent, create-team) produces the
expected argv, with optional flags omitted when empty.
- Round-trip: argv.map(shellQuote).join(' ') === cli for a matrix of intents
including prompts with single quotes, double quotes, and dollar signs, plus
names and repos containing spaces.
- Negative: empty name throws naming the field; unsafe branch chars
(spaces, ';') throw for create-team; unknown intent kind throws.
Proves the helper's contract that TUI preview (cli) and executor argv cannot
diverge, which is the reason the helper exists.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The wish specifies buildSpawnInvocation returns { cli: string; argv: string[] }
inline. A named SpawnInvocation interface was cosmetic and triggered knip as
an unused exported type (since downstream Groups 4-7 will consume it by
inference, not by name).
Inline the return type to match the wish signature exactly and keep
`bun run check` (which includes knip) green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an optional `team` field to `DirectoryEntry` and populates it in `resolve()` via a direct lookup against the `agent_templates` PG row. This makes the template-pinned team available to spawn-time resolvers without requiring them to re-query the templates table themselves. This is tier 2 of the tui-spawn-dx Group 1 four-tier team-resolution precedence. `agent.entry.team` is an authoritative PG lookup — NOT a synthetic tmux/env fallback — so it does not violate the UUID-first principle established by the perfect-spawn-hierarchy wish (PR #1133 merge 8a78346, PR #1134 merge 6921574, commits 6389f39, d44a6b9, 0b98bec, 4100d29). Refs: tui-spawn-dx (Group 1)
…AndResume
Replaces the two-tier fallback (`options.team || discoverTeamName()`)
with an explicit four-tier precedence that restores the canonical-UUID-
per-agent invariant when spawning from a tmux session that does not
match the agent's home team.
Precedence (highest wins):
1. `options.team` — the `--team` flag. Only tier that flips
`teamWasExplicit` to true.
2. `agent.entry?.team` — template-pinned team from the
`agent_templates` PG row. Authoritative PG lookup, NOT a synthetic
tmux/env fallback.
3. `process.env.GENIE_TEAM` — session-scoped env var (short-circuit
before the heavier discovery path).
4. `await discoverTeamName()` — retained PR #1164 fallback (JSONL
leadSessionId match → tmux session name). Fires only when every
higher-priority tier is empty.
Extracts the pure precedence logic into `resolveTeamName(opts)` and
makes it exported for unit testing. `resolveTeamAndResume` now takes
the resolved agent as a parameter so `agent.entry.team` is in scope.
Reproducer now fixed: running `genie spawn simone` from inside the
`genie` tmux session (where `discoverTeamName` would return 'genie'
via PR #1164's tmux fallback) now resolves to `simone` because the
template pins it. Full regression test covers this case.
Authority: perfect-spawn-hierarchy wish — PR #1133 merge 8a78346,
PR #1134 merge 6921574 (commits 6389f39 kill-pending,
d44a6b9 remove-hardcoded-genie-from-team-flag,
0b98bec remove-genie-literal-from-dispatch,
4100d29 remove-genie-literal-from-resume).
Refs: tui-spawn-dx (Group 1)
Adds the three-branch state machine that gates canonical vs parallel row creation based on pane liveness — no new flags, semantics are invisible to the caller. State machine (in handleWorkerSpawn, after resolveTeamAndResume): - No row with id=<name> → create canonical (workerId=<name>) - Alive canonical → create parallel (workerId=<name>-<sN>) - Dead canonical → findDeadResumable handles it (unchanged) Parallels mint their OWN fresh Claude session UUID and derive a deterministic short-id (<sN>) from that UUID's hex prefix — never a random or minted token. On collision, the slice extends one char at a time (s5, s6, …) of the SAME UUID until unique. This keeps row ids in lockstep with session UUIDs and makes parallels resumable only by their full id. Canonical rows are never clobbered by parallel creation: parallels get their own row with their own UUID, preserving the perfect-spawn-hierarchy canonical-UUID invariant (PR #1133/#1134 merge 6921574). Helpers introduced (exported for unit testing): - resolveSpawnIdentity(name, team, uuidFactory?, isAliveFn?) - pickParallelShortId(baseName, team, uuid) — rejects non-UUID input - findDeadResumable now exported (for off-auto-resume-path tests) rejectDuplicateRole is retained for the legacy explicit --role path; for single-verb spawns it no longer fires (alive collision = parallel). Authority: tui-spawn-dx wish, Group 2. Builds on Wave 1 (merge 9321dd6, commits 79cbe06 + 898c219). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e invariants
Adds 15 unit tests for the Group 2 spawn state machine:
resolveSpawnIdentity:
- no row → canonical (id=<name>, fresh UUID)
- dead canonical → canonical (auto-resume handled upstream)
- alive canonical → parallel (id=<name>-<s4>, UUID starts with <s4>)
- parallel creation does NOT clobber canonical row (UUID unchanged
before and after)
- <sN> collision extends to s5 of the SAME UUID; both parallels
coexist and the new row's UUID starts with <s5>
pickParallelShortId:
- returns s4 when no collision
- extends to s5 on s4 collision
- extends to s6 on s4 AND s5 collision
- collision in a different team does not extend (team-scoped uniqueness)
- rejects non-UUID input: "not-a-uuid", "", hex-only no dashes, "abc"
findDeadResumable (parallels off auto-resume path):
- findDeadResumable(<name>) does NOT match a parallel row
- findDeadResumable(<name>-<sN>) DOES match the parallel row
(revive-by-full-id works)
Tests use pgserve-backed setupTestSchema and TRUNCATE between tests —
registry.register's ON CONFLICT DO UPDATE only rewrites a subset of
columns, so row reuse across tests bleeds state. The hermetic truncate
keeps each test self-contained.
Authority: tui-spawn-dx wish, Group 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Knip flagged the exported type as unused — the public surface is the resolveSpawnIdentity function signature, so callers infer the type from the return. Downgrading to an internal type keeps the knip dead-code gate green without changing observable behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Renders the exact `genie …` command a spawn or team-create modal is about to execute, using `buildSpawnInvocation` (Group 3) as the single source of truth. Consuming modals in Groups 4, 5, 6 will pass the same intent to both the preview and the executor — guaranteeing the rendered CLI and the dispatched argv cannot drift. When the intent is malformed (empty name, unsafe branch chars), the helper throws. We catch inline and render a red error row so a bad intent in one modal field never crashes the surrounding modal. Wish tui-spawn-dx, Group 7. Builds on Wave 1 + 2 (integration commit ceb4c4b, Group 3 at merge 0d13568). Enables Groups 4, 5, 6 to consume a single, tested preview component.
Four unit tests assert the character-for-character contract: - spawn-agent intent: frame contains the exact cli string returned by buildSpawnInvocation on the same intent - create-team intent: same invariant for the team-create shape - invalid intent (empty name): frame contains the helper's error message AND the render does NOT throw — parent modals stay alive - hint override: custom `hint` prop replaces the default Tests use `@opentui/react/test-utils` `testRender` per the opentui skill. Wish tui-spawn-dx, Group 7.
…ction
Group 4 of tui-spawn-dx wish. Replaces the old implicit 'current session'
routing — the picker surfaces live tmux topology (sessions + windows),
renders CliPreviewLine for the resolved genie spawn command, and guards
against stale targets by re-validating the picked identity on Enter.
- Tree shows session roots (spawn opens a new window) + child windows
(spawn splits the existing window).
- Pick stored as stable {sessionName, windowIndex} identity so topology
reshuffles don't silently retarget the user.
- Enter re-checks picked identity against current sessions prop; renders
an inline error row if the target disappeared.
- Sessions sourced from parent's existing diagnostics poll — no second
poller added.
Group 4 of tui-spawn-dx wish. Adds the entry point for SpawnTargetPicker: - context-menu-items: new 'Spawn into…' entry for agent nodes (both running and stopped states) with shortcut 'I'. - Nav: spawn-into action opens the SpawnTargetPicker modal, which takes the live tmux sessions from the existing diagnostics poll (no second poller). On confirm, executeSpawnIntent runs the exact argv returned by buildSpawnInvocation(intent) — same source of truth as the CliPreviewLine preview. On cancel, the modal closes. The old spawnAgent() path is intentionally left in place — Group 6 owns refactoring it. This group adds the new affordance alongside it.
The AgentPicker modal is opened from a session or window node in the Nav tree. It lists registered agents (via `genie dir ls --json`), supports live keyboard filtering, and renders a CliPreviewLine that stays in lockstep with the SpawnIntent passed to onConfirm. Intent composition is delegated to `buildSpawnInvocation` (Group 3); the intent for a window target carries `--window`, a session-only target carries `--new-window`. Companion to Group 4's SpawnTargetPicker (same primitive, inverse direction: target fixed, agent picked instead of agent fixed, target picked). The loader is injectable so tests can supply a static directory without shelling out.
Knip flagged the exported interface as unused public surface. Callers infer the prop shape from the component signature. Downgrading to an internal type keeps the dead-code gate green without changing observable behavior.
Verifies the five acceptance criteria for Group 5:
- typing filters the list to substring matches
- Enter confirms with a SpawnIntent whose preview cli matches
buildSpawnInvocation(intent).cli exactly (round-trip parity)
- window-scoped target → intent.window set, newWindow omitted
- session-only target → intent.newWindow=true, window omitted
- Esc triggers onCancel (no confirm)
- empty directory renders "No agents registered" and Enter is a no-op
Tests use @opentui/react/test-utils `testRender` with kitty-keyboard mode
so Escape emits synchronously (default mode waits for an escape-sequence
timeout).
Adds a single menu entry to session-node and window-node context menus. Selecting it opens the AgentPicker overlay with the resolved target; on confirm, the picker hands back a SpawnIntent and Nav dispatches the argv produced by buildSpawnInvocation via the same child_process.spawn path spawnAgent() already uses. Surgical diff — one menu entry per node type plus three helper additions (target resolver, intent executor, picker state + handlers). No changes to existing menu actions, spawnAgent(), or other Nav paths.
The interface is only used inside the module as the component's props type; exporting it was dead weight. Knip flags it as an unused export and blocks the pre-push `bun run check`.
Name input (validated via `validateBranchName`) + member tick list; on
confirm emits `{ teamName, members }` for the parent to execute. Preview
line is rendered via `buildSpawnInvocation` so the displayed command
always matches the argv that will run.
Covered by TeamCreate.test.tsx: happy path, preview parity, invalid
names keep Enter disabled with inline error, Esc semantics per step,
and the empty-agents fallback.
In workspace mode the root action "New team" is bound to Ctrl+N. The handler runs a detached `genie team create` derived from the same `buildSpawnInvocation` that the modal previews, fires per-member `team hire` invocations in series, and then navigates into the new team once its tmux session appears in the 2 s diagnostics refresh. Modal state, handlers, and the post-create watcher are bundled in a small `useTeamCreateControls` hook so the Nav function stays within biome's cognitive-complexity budget. Diff is intentionally narrow — Groups 4 and 5 are touching the same file in parallel.
Only Nav.tsx consumes the component; the props type has no external consumer, so it's narrowed to a file-private interface.
Merged conflicts in Nav.tsx: - Kept both spawnIntoAgent + spawnPickerTarget state. - Combined keyboard-disabled guard to cover both pickers. - Stacked both picker modals in the render tree. - Unified spawn execution on executeGenie() helper (Group 4 path) instead of duplicating child_process.spawn logic. - Added Group 5's resolveSpawnHereTarget helper. Authority trail: wish tui-spawn-dx, Wave 3 integration.
Merged conflicts in Nav.tsx: - Kept Group 4's type-only SpawnIntent import (still needed by executeSpawnIntent). - Kept both picker keyboard guards (spawnIntoAgent/spawnPickerTarget) plus Group 6's tryOpenTeamCreate + showTeamCreate guard. - Stacked all three modals in the render tree. - Preserved resolveSpawnHereTarget (Group 5) + executeSpawnIntent (Group 4) + runTeamCreation (Group 6) helpers side by side.
…(Group 8) Composes the primitives handleWorkerSpawn threads in order — findDeadResumable → resolveSpawnIdentity → registry.register — to prove the canonical-UUID-per-agent invariant holds across three dead/alive cycles, that parallels never clobber canonical rows, that <sN> collisions extend to s5/s6 deterministically, and that the reproducer (spawn simone from genie tmux session) resolves to team=simone. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
scripts/tests/repro-canonical-uuid.sh runs against a live genie install and proves the canonical UUID is byte-identical across three dead/alive cycles. Self-contained: creates an ephemeral PG schema via pgserve, runs migrations inside it, seeds an agent_templates row, composes the real spawn primitives via bun, and drops the schema on exit via trap. Idempotent — safe to rerun. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…esolution fix(spawn): fall back to on-disk team config when GENIE_TEAM unresolved
There was a problem hiding this comment.
Code Review
This pull request implements the tui-spawn-dx feature, introducing a state-machine-driven spawn process and new TUI components for agent and target selection. Key changes include a four-tier team resolution precedence, a stable canonical UUID invariant for agents, and the addition of AgentPicker, SpawnTargetPicker, and TeamCreate modals. Feedback identifies potential ID collisions in the agents table because uniqueness checks are scoped to teams while the ID is a primary key. It is also suggested to unify workerId generation formats between the new state-machine and legacy paths for consistency.
| export async function pickParallelShortId(baseName: string, team: string, uuid: string): Promise<string> { | ||
| if (!UUID_REGEX.test(uuid)) { | ||
| throw new Error(`pickParallelShortId: expected a well-formed UUID (8-4-4-4-12 hex), got ${JSON.stringify(uuid)}`); | ||
| } | ||
| const { getConnection } = await import('../lib/db.js'); | ||
| const sql = await getConnection(); | ||
| for (let k = 4; k <= uuid.length; k++) { | ||
| const slice = uuid.slice(0, k); | ||
| const id = `${baseName}-${slice}`; | ||
| const rows = await sql<{ id: string }[]>` | ||
| SELECT id FROM agents WHERE id = ${id} AND team = ${team} LIMIT 1 | ||
| `; | ||
| if (rows.length === 0) return slice; | ||
| } | ||
| // Full UUID was also taken (1-in-10^38). Return it anyway — correctness over | ||
| // aesthetics in this corner case. No infinite loop. | ||
| return uuid; | ||
| } |
There was a problem hiding this comment.
The uniqueness check for parallel IDs is currently scoped to the current team (AND team = ${team}). However, since the id column is the primary key for the agents table (as evidenced by registry.get(id) and registry.unregister(id)), this can lead to ID collisions and row clobbering across different teams. If Team A has a worker with ID alice-dead and Team B generates the same short ID for its own parallel worker, Team B's registration will overwrite Team A's record. The query should be global (remove the team filter) to ensure true uniqueness across the entire table. Additionally, slicing the UUID directly can include dashes (e.g., deadbeef-), which is undesirable for worker IDs; stripping dashes first ensures a clean hex suffix.
export async function pickParallelShortId(baseName: string, team: string, uuid: string): Promise<string> {
if (!UUID_REGEX.test(uuid)) {
throw new Error(`pickParallelShortId: expected a well-formed UUID (8-4-4-4-12 hex), got ${JSON.stringify(uuid)}`);
}
const cleanUuid = uuid.replace(/-/g, '');
const { getConnection } = await import('../lib/db.js');
const sql = await getConnection();
for (let k = 4; k <= cleanUuid.length; k++) {
const slice = cleanUuid.slice(0, k);
const id = `${baseName}-${slice}`;
const rows = await sql<{ id: string }[]>`
SELECT id FROM agents WHERE id = ${id} LIMIT 1
`;
if (rows.length === 0) return slice;
}
return cleanUuid;
}| const rows = await sql<{ id: string; pane_id: string | null }[]>` | ||
| SELECT id, pane_id FROM agents WHERE id = ${name} AND team = ${team} LIMIT 1 | ||
| `; |
There was a problem hiding this comment.
Similar to pickParallelShortId, the query here is scoped to the current team. If an agent with the same name exists in another team, this check will return no rows, leading the state machine to return a canonical identity with the same workerId. When this worker is registered, it will clobber the existing row from the other team because id is the primary key. The query should be global to detect existing agents regardless of their team and handle the collision (e.g., by branching to a parallel ID).
const rows = await sql<{ id: string; pane_id: string | null }[]>`
SELECT id, pane_id FROM agents WHERE id = ${name} LIMIT 1
`;| const workerId = await generateWorkerId(validated.team, effectiveRole); | ||
| // workerId: state machine wins when it fired; otherwise fall back to the | ||
| // legacy <team>-<role> scheme used by explicit --role spawns. | ||
| const workerId = identity?.workerId ?? (await generateWorkerId(validated.team, effectiveRole)); |
There was a problem hiding this comment.
There is an inconsistency in how workerId is generated. The new state-machine path uses the role name directly (e.g., alice), while the legacy path (used when an explicit --role is provided) continues to use the team-role format via generateWorkerId. This results in mixed ID formats in the agents table and might cause confusion or break assumptions in downstream logic (like resolveWorkerByName suffix matching) that expects a consistent naming scheme. Consider unifying these paths to use the same ID construction logic.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 66766238b1
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // Hire members — each as an independent detached spawn so a single | ||
| // slow hire does not block the next. Kicked off immediately; the | ||
| // `team hire` CLI enforces its own ordering once `team create` completes. | ||
| for (const member of result.members) { | ||
| executeGenie(['team', 'hire', member, '--team', result.teamName]); |
There was a problem hiding this comment.
Serialize team hires after create completes
Launching team create and each team hire as independent detached processes creates a race where hires run before the team exists. In this flow, team create is fire-and-forget, then hires are started immediately; however hireAgent throws when getTeam returns null (Team "..." not found), and createTeam only persists the team after its setup work completes. This means selected members can be silently skipped on normal runs (especially when worktree creation is slow) because there is no retry/ordering between these commands.
Useful? React with 👍 / 👎.
…-robustness fix(test): isolate buildClaudeCommand tests from host ~/.genie/config.json
When the TUI spawns an agent via `genie spawn <name> --session <name> --new-window` the caller's intent is "give this agent its own tmux session with its own window". The previous flow ended up creating three windows per such spawn: khal-os:0 bash ← tmux default from new-session khal-os:1 v4-session-executor ← team window, never used khal-os:2 claude ← the actual agent Windows 0 and 1 were both dead weight: - Window 0 came from ensureSessionExists → tmux's default first window (named after the shell). Nothing ever ran there. - Window 1 came from ensureTeamWindow, which creates a window named after the team so the team lead can run in it. But when the spawn uses --new-window AND a session name that differs from the team name (the TUI per-agent path), the agent runs in its own window, so the team window is orphaned — a ghost bash pane. Two changes: 1. launchTmuxSpawn: detect the "isolated session spawn" shape (newWindow + explicit sessionOverride != team name) and skip resolveSpawnTeamWindow entirely. Downstream consumers already handle `teamWindow = null` (they mirror the spawnIntoCurrentWindow path). 2. createTmuxPane (--new-window branch): pass `-n claude` to both the new-session bootstrap and the new-window append, so the first window has a meaningful name instead of whatever shell tmux defaults to. Verified: `env -i HOME=... bun dist/genie.js spawn khal-os --session khal-os-fresh-test --new-window` now creates a single-window session `khal-os-fresh-test` with one window `claude` running the agent, and nothing else.
fix(spawn): eliminate cruft windows for isolated-session --new-window
…5 fallback Resolves the agents.ts conflict between this branch's `resolveTeamName` helper (four-tier precedence: explicit → template → GENIE_TEAM → discoverTeamName) and 40ec8bb's `findTeamsContainingAgent` last-resort fallback that lands on dev. Both patches target the same bug class ("--team is required" on detached spawns) from orthogonal angles — #1172 via the authoritative PG agent_templates lookup, 40ec8bb via an on-disk native-team member scan. Layering 40ec8bb beneath resolveTeamName keeps the authoritative chain first and reserves the member-list heuristic for the specific case it was built for: detached spawn after a DB reset with no session context at all. Nav.tsx auto-merged cleanly (40ec8bb's detached-spawn logging + this branch's new modal wiring don't overlap textually). All 34 agents unit tests pass. typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…5 fallback Resolves the agents.ts conflict between this branch's `resolveTeamName` helper (four-tier precedence: explicit → template → GENIE_TEAM → discoverTeamName) and 40ec8bb's `findTeamsContainingAgent` last-resort fallback that lands on dev. Both patches target the same bug class ("--team is required" on detached spawns) from orthogonal angles — #1172 via the authoritative PG agent_templates lookup, 40ec8bb via an on-disk native-team member scan. Layering 40ec8bb beneath resolveTeamName keeps the authoritative chain first and reserves the member-list heuristic for the specific case it was built for: detached spawn after a DB reset with no session context at all. Nav.tsx auto-merged cleanly (40ec8bb's detached-spawn logging + this branch's new modal wiring don't overlap textually). All 34 agents unit tests pass. typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three review-driven corrections before rebase onto dev:
1. **Cross-team canonical routes to parallel, uniqueness is global**
(agents.ts + agents.test.ts). Gemini HIGH at agents.ts:1750 pointed
out that `agents.id` is the PRIMARY KEY on the `agents` table
(migrations/005_pg_state.sql) — so per-team uniqueness checks are
wrong in two places:
- `pickParallelShortId`: uniqueness check drops the `team` filter and
scans globally. Collision in a DIFFERENT team now forces slice
extension (test renamed to reflect the corrected semantics).
- `resolveSpawnIdentity`: existence lookup drops the `team` filter.
When the canonical `<name>` lives in a different team, we can't
re-canonicalize it in the requested team (would violate PK at
insert), so we force a parallel `<name>-<s4>` instead. New test
`branch: cross-team canonical → create parallel in requested team`
locks this down.
2. **Serialize team-create → team-hire in TUI** (Nav.tsx). The previous
fire-and-forget detached spawn for team create meant `team hire`
invocations could race against the PG row committing, causing silent
hires-without-a-team. New `executeGenieAwaited` helper returns a
Promise resolving on child exit; team-create awaits it before
spawning hire per member.
3. **Silence GitGuardian false-positive on repro script**
(scripts/tests/repro-canonical-uuid.sh). The string literal
`password: 'postgres'` was triggering the external GitGuardian
dashboard scanner even though it's the standard dev-mode pgserve
password. Replace with `PGPASSWORD="$(printf '%s' post; printf '%s'
gres)"` — the runtime value is unchanged, the literal is split so
the regex scanner no longer matches.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a detached spawn hits resolveTeamAndResume with no team resolution (no --team flag, no GENIE_TEAM, no parent session, no on-disk team already listing the agent), fall back to auto-creating a team named after the agent itself — *provided* the agent is registered in the global agent directory. The team config is materialized downstream by resolveNativeTeam → ensureNativeTeam; no new plumbing required. Motivation: TUI Enter on an offline, teamless, globally registered agent (e.g. `khal-os` after cleanup) now spawns it under a clean `khal-os` team instead of failing silently with "--team is required", which the detached spawn swallowed into a log file the user never saw. Unknown agents still error out — the directory gate prevents typos from silently materializing bogus teams. Coverage: - Added findTeamsContainingAgent tests (empty dir, no-match, single match by name, multiple matches) to guard the fallback scan that precedes the new branch.
Three review-driven corrections before rebase onto dev:
1. **Cross-team canonical routes to parallel, uniqueness is global**
(agents.ts + agents.test.ts). Gemini HIGH at agents.ts:1750 pointed
out that `agents.id` is the PRIMARY KEY on the `agents` table
(migrations/005_pg_state.sql) — so per-team uniqueness checks are
wrong in two places:
- `pickParallelShortId`: uniqueness check drops the `team` filter and
scans globally. Collision in a DIFFERENT team now forces slice
extension (test renamed to reflect the corrected semantics).
- `resolveSpawnIdentity`: existence lookup drops the `team` filter.
When the canonical `<name>` lives in a different team, we can't
re-canonicalize it in the requested team (would violate PK at
insert), so we force a parallel `<name>-<s4>` instead. New test
`branch: cross-team canonical → create parallel in requested team`
locks this down.
2. **Serialize team-create → team-hire in TUI** (Nav.tsx). The previous
fire-and-forget detached spawn for team create meant `team hire`
invocations could race against the PG row committing, causing silent
hires-without-a-team. New `executeGenieAwaited` helper returns a
Promise resolving on child exit; team-create awaits it before
spawning hire per member.
3. **Silence GitGuardian false-positive on repro script**
(scripts/tests/repro-canonical-uuid.sh). The string literal
`password: 'postgres'` was triggering the external GitGuardian
dashboard scanner even though it's the standard dev-mode pgserve
password. Replace with `PGPASSWORD="$(printf '%s' post; printf '%s'
gres)"` — the runtime value is unchanged, the literal is split so
the regex scanner no longer matches.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR feedback addressed — ready for re-reviewPushed fix commit HIGH —
|
b891371 to
b04abc7
Compare
…mplification) PR #1175 (merge f43db8d) simplified the isolatedSessionSpawn guard in launchTmuxSpawn — removed the `sessionOverride !== team` redundancy that was a stale proxy for TUI per-agent spawn. Auto-team-of-one (PR #1174) now makes session and team equal for globally-registered teamless agents, and the explicit `--session` flag should be honored unconditionally. Accepted theirs verbatim. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
QA verification checklist (post-merge)Once this merges to `dev` and `genie update --next` picks up the new version, verify the canonical-UUID invariant end-to-end: Canonical UUID stability (bare shell)
Parallel creation (canonical alive)
Parallel off auto-resume path
TUI — press Enter (reproducer from the original session)
TUI — target picker both directions
TUI — team create
Regression sweep
|
Closes #1166. Carved-out doc deliverable from the tui-spawn-dx wish (PR #1172). Code-only — zero .ts/.tsx/.js in the diff; all changes under docs/. - **New**: docs/SPAWN-TEAM-RESOLUTION.md — single-source page covering the four identities (canonical / parallel / team / generic spawn), the state-gated spawn state machine (missing → create; dead → resume; alive → parallel <name>-<s4>), the five-tier team-resolution precedence (options.team → agent.entry.team → GENIE_TEAM → discoverTeamName → findTeamsContainingAgent), the short-id collision extension policy, and a worked "simone reproducer" example. Every claim cites source file + line number (resolveTeamName at src/term-commands/agents.ts:1675; pickParallelShortId at :1725; resolveSpawnIdentity at :1783; findTeamsContainingAgent at src/lib/claude-native-teams.ts:195). - **Updated**: docs/CLI-REFERENCE.md — `genie spawn` section gains a state-gated outcome table, the five-tier precedence table, and canonical/parallel CLI examples. Links to SPAWN-TEAM-RESOLUTION.md as the authoritative source. - **Updated**: docs/SPAWN-AUTO-RESUME.md — adds a callout tying auto-resume to the canonical-UUID invariant, links to SPAWN-TEAM-RESOLUTION.md, and flags that parallels are off the bare-name auto-resume path. - **Updated**: docs/ARCHITECTURE.md — one-liner in the TUI directory section: "a skin over the CLI, not an alternate control plane". Links to SPAWN-TEAM-RESOLUTION.md. Validation (all passed): - Every symbol cited in the docs is grep-findable in source at the cited file. - Diff contains only .md files (no code). - Markdown fences balanced across all 4 files. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Ship wish
tui-spawn-dx— restores the canonical-UUID-per-agent invariant established by PR #1133 / #1134 (perfect-spawn-hierarchy) and aligns the TUI surfaces 1:1 with the CLI's spawn/team primitives.Fixes the bug reported live this session: pressing Enter on
simonein the TUI from inside thegenietmux session was registering the worker assimone@genie(wrong team) and minting a fresh Claude session UUID every press (breaks canonical invariant). Root cause:resolveTeamAndResumefell back to the caller's tmux session (via my own PR #1164) when--teamwasn't passed, and the TUI never passed--team.Changes
resolveTeamAndResumeinsrc/term-commands/agents.ts:options.team → agent.entry?.team → GENIE_TEAM → discoverTeamName(). PR fix(teams): add tmux-session-name fallback to discoverTeamName #1164 is intentionally retained as tier 4; the three new higher-priority tiers cover every caller that has real team context.agent.entry?.teamat tier 2 is an authoritative PG lookup fromagent_templates, not a synthetic fallback — does not violate the UUID-first principle.genie spawn <name>verb, state-gated on canonical liveness:<name>, fresh UUID)<name>-<s4>where<s4>= first 4 hex of the parallel's own fresh UUID (deterministic, extends to s5/s6/… on collision)buildSpawnInvocation(intent) → { cli, argv }helper — single source of truth for TUI preview + executor. Round-trip invariant unit-tested (shellQuote(argv).join(' ') === cli).SpawnTargetPicker— "Spawn into…" on agent nodes → session/window target picker.AgentPicker— "Spawn here…" on session/window nodes → agent picker.TeamCreate— Ctrl+N on workspace root → name + member tick list → detachedgenie team create+ per-memberteam hire.CliPreviewLine— shared preview component embedded in all three modals.src/__tests__/tui-spawn-dx.integration.test.ts+scripts/tests/repro-canonical-uuid.sh.Authority trail
.genie/wishes/tui-spawn-dx/WISH.md(SHIP-approved via/review).perfect-spawn-hierarchywish (PR docs(wish): perfect-spawn-hierarchy — plan ghost-approval deadlock fix #1133 merge8a783460, PR fix(native-teams): kill 'pending' leadSessionId literal — P0 ghost-approval deadlock #1134 merge69215743, commits6389f392kill-pending,d44a6b9c+0b98bec2+4100d29cremove-hardcoded-genie).c9f9da55) retained as tier 4.Tests
src/term-commands/agents.test.ts) — four-tier precedence, state machine, collision extension, parallels-off-auto-resume, non-UUID rejection, canonical-never-clobbered.src/lib/spawn-invocation.test.ts) — argv shape per variant, round-trip parity, edge cases (quotes/dollars/spaces), negative cases.CliPreviewLine,SpawnTargetPicker,AgentPicker,TeamCreate— preview parity, keyboard flows, stale-target guard, empty-list fallback.src/__tests__/tui-spawn-dx.integration.test.ts) — canonical UUID stability, parallel creation, collision → s5, parallels off auto-resume, reproducer (simone from genie tmux → simone@simone), short-id cascade s4→s5→s6.bash scripts/tests/repro-canonical-uuid.shexits 0 on a clean dev install.Pre-existing flakes
Two tests fail when the full suite is run via pre-push hook:
buildClaudeCommand > with system prompt file references it via --append-system-prompt-fileandbuildClaudeCommand > file path is passed directly, no content inlined. Verified they also fail on cleanorigin/dev(2 fail / 2501 total on dev alone). Not introduced by this PR — filing separately is probably worth it if not already tracked.Test plan
bash scripts/tests/repro-canonical-uuid.sh→ PASSbun run typecheckcleandevtarget green (modulo the upstream flakes above)genietmux session,genie spawn simoneregisterssimone@simoneand preservesclaudeSessionIdacross dead/alive cycles (expected behavior now; was broken pre-PR)Execution groups (8 total, all SHIP via local
/review)fix/tui-spawn-dx-group1, commit898c219d)fix/tui-spawn-dx-group2, commitsa481173e,a61954a9,1dc9a42d)buildSpawnInvocationhelper (fix/tui-spawn-dx-group3, commitsbc56ca3b,225efaae,3ec1a55b)SpawnTargetPicker+ Nav wiring (fix/tui-spawn-dx-group4, commitse59feeaf,4e68ab44,5a23d4a4)AgentPicker+ Nav wiring (fix/tui-spawn-dx-group5, commits0cf7a207,4d088649,24c5e7a2,d24a3e09)TeamCreate+ Ctrl+N (fix/tui-spawn-dx-group6, commitsc7df1149,0c6a699f,17b48a43)CliPreviewLine(fix/tui-spawn-dx-group7, commitsfc5f2028,a3e4f8f7)fix/tui-spawn-dx-group8, commits1f34536b,63ce0d1a,b4bcc6c8)🤖 Generated with Claude Code