Skip to content

nebula spawn --worktree <branch> starts the new session in that branch's worktree, cut first when the branch has none - #125

Open
michael-dg wants to merge 1 commit into
AgentSystemLabs:mainfrom
michael-dg:spawn-worktree
Open

michael-dg wants to merge 1 commit into
AgentSystemLabs:mainfrom
michael-dg:spawn-worktree

Conversation

@michael-dg

Copy link
Copy Markdown

nebula spawn could only start a session in the caller's own worktree, so two agents working in parallel shared one checkout, index and branch. --worktree <branch> starts the new session in that branch's worktree instead, cutting it first when the branch has none, so an agent (or a .md workflow it follows) can hand each piece of work to its own session on its own branch with one command.

Contents

✨ What you get

🌿 Parallel work on separate branches

  • A session on another branch — nebula spawn --worktree <branch> "<task>"
    • starts the new session in the project's worktree on that branch, the ROOT WORKTREE included when the branch is checked out there
    • cuts the worktree first when the branch has none, from the same base nebula worktree uses
    • the session that ran it stays where it is, untouched, as with a plain nebula spawn
  • Same defaults as a plain spawn — --kind, AUTO-TITLE
    • the new session keeps the caller's harness, model and effort unless --kind names another
    • its default agent-N name is the first one free in the worktree it lands in, so it titles itself
    • the branch words are slugified as nebula worktree slugifies them: feat login is feat-login
  • A chosen start point — --base <ref>
    • picks where a new branch is cut from, resolved as nebula worktree --base resolves it
    • refused when the branch already exists — with a worktree (its path is named) or kept without one — so the model never reports a start point the session lacks
    • refused without --worktree, or blank
  • Claude knows the flag — "start a new session in a new worktree that …"
    • the spawn guidance in Claude's appended system prompt now names --worktree for "in a new worktree" / "on branch X" requests, and lists grok among the --kind values
    • a plain "start a new nebula session" still lands beside the caller, as before

🛡️ Nothing left behind

  • Refusals come before git — a blank branch or task, an unknown or archived caller, a harness that does not resolve, a CLI that is not installed
    • each is refused before any worktree is cut, so a refused spawn never leaves an orphan checkout
    • the harness and CLI checks are the ones the create itself makes, asked ahead of it
  • A branch is reused, never doubled — a second --worktree on the same branch
    • lands in the worktree the first one cut, beside the session already there
    • holds for parallel calls too: the lookup runs under the worktree lock, so two spawns arriving together share one checkout

🔁 Also changes nebula worktree

Both commands now share one find-or-cut path, so two of its fixes reach nebula worktree as well. Flagged here so neither lands unnoticed:

  • No more race on a new branch — no behaviour change
    • the lookup runs under the worktree lock, so a nebula worktree racing a spawn (or another nebula worktree) for one new branch shares the checkout instead of failing on "worktree path already exists"
  • --base on a branch that already exists is refused — the one behaviour change outside spawn
    • before: a branch with a worktree was reused, and a branch kept without one checked out, both with the base dropped without a word, so the session ran on a start point it was not asked for
    • now: an error names the branch (and its checkout, when it has one) and says to run it again without --base
    • happy to take this out of this PR if you'd rather keep nebula worktree as it was; spawn would then refuse the case on its own
  • Unchanged — nebula worktree without --base, and every worktree cut from the TUI, behave exactly as before

📸 Screenshots

No PNG: the change is a CLI flag with no screen of its own. The card appears on the grid exactly as a plain nebula spawn's does, under the worktree's band. This is what the model reads back:

$ nebula spawn --worktree "feat login" "port the tests to the new fixture"
started a new session in the worktree on branch "feat-login"; it is working on that task now and shows in the sessions list. This session is unaffected — carry on.

$ nebula spawn --base main "x"
error: the following required arguments were not provided:
  --worktree <BRANCH>

🧭 How it flows

sequenceDiagram
  participant A as Agent CLI (caller)
  participant C as nebula spawn --worktree
  participant D as DAEMON
  participant G as git
  A->>C: runs it (NEBULA_AGENT_ID)
  C->>D: SpawnSiblingAgent { worktree, base }
  D->>D: refuse blank branch, unknown or archived caller
  D->>D: check_cold_launch (prompt, harness, CLI installed)
  Note over D: worktree_ops lock held
  alt project has a worktree on the branch
    D->>D: worktree_on_branch reuses it, refused if --base was given
  else no worktree yet
    D->>G: cut_worktree (fetch, worktree add, WORKTREE HOOK)
    Note over D,G: an existing branch with --base is refused
    G-->>D: new checkout
  end
  D->>D: create_agent in that worktree, task as STARTING PROMPT
  D-->>C: Ack { created: Agent }
  C-->>A: started a new session in the worktree on branch …
Loading

⚠️ Risk

🎯 Attack surface

  • Two new fields on an existing ClientRequest. SpawnSiblingAgent now carries worktree and base. Reached by: any process on the DAEMON's socket (mode 0700, same user) that names a live agent id — the same reach nebula spawn and nebula worktree already had. Held by: the values go to the same create_worktree that EnterWorktree already feeds the same two strings to (git argv, never a shell; / in the branch folded to - for the directory). Not held: nothing new — a caller who could already move its own session into any branch can now start a sibling there.

Verdict: 🟢 Low risk — an existing request grows two optional fields that reuse an existing, already-exposed path; the one cost is the protocol bump.

Level Why
🔒 Security & production Low no new reach beyond EnterWorktree's; the PROTOCOL VERSION bump means an upgraded client refuses an old DAEMON at the handshake with the usual nebula kill message; nebula worktree --base on an existing branch now errors where it used to succeed on the wrong start point
⚡ Performance Low off every hot path: a one-shot CLI connection that, with --worktree, waits on the fetch and the WORKTREE HOOK exactly as nebula worktree already does
🧩 Fit with the codebase Low the find-or-cut half of enter_worktree is extracted, not duplicated, and now locks like pr_worktree; the pre-create check reuses create_agent's own refusals; tests follow the module's in-file and e2e_pty patterns

Rollback: git revert <merge> removes the flag; it does not undo Protocol 45, so clients and DAEMONs built across the revert still need nebula kill once.

🔧 Technical overview

  • Mechanism. The CLI slugifies --worktree, trims --base, and sends both on SpawnSiblingAgent. Daemon::spawn_sibling_agent reads the caller once, refuses a blank branch, then runs Daemon::check_cold_launch — the prompt, harness and missing-CLI refusals create_agent would give — before resolving the target through Daemon::worktree_on_branch: the find-or-cut that enter_worktree used to do inline, now holding worktree_ops across the lookup and the cut (cut_worktree is create_worktree's body for a lock holder) and refusing a base for a branch that already has a checkout; git::add_worktree_off_ref refuses one for a branch kept without a checkout. sibling_spec then picks the default name against the target worktree's rows.
  • Protocol 45. The codec writes structs as arrays (rmp_serde::to_vec), so even with #[serde(default)] a v44 DAEMON fails to decode the longer request (LengthMismatch, checked with a throwaway program) and drops the connection. The bump turns that into a handshake refusal, as 5bebba4 did for Protocol 43.
  • Files. crates/nebula-daemon/src/sibling.rs — the worktree branch of the spawn and its guidance text; crates/nebula-daemon/src/registry.rs — worktree_on_branch (shared with enter_worktree), cut_worktree, check_cold_launch; crates/nebula-daemon/src/git.rs — add_worktree_off_ref refuses an existing branch; crates/nebula-core/src/protocol.rs — the two fields and the bump; crates/nebula/src/cli.rs and crates/nebula-tui/src/ipc.rs — the flags and the request; docs/commands.md, docs/how-it-works.md.
  • Not done. No spawn from outside a session (a shell, a script): it needs the project from the cwd and the harness from the settings, and is left for a separate PR if wanted. No random branch when --worktree is empty — it is refused, since a spawn names where its work goes. No rollback of a cut worktree when the create fails anyway (a spawn error after the checks): removing a checkout is destructive, so the checks move ahead of the cut instead.
  • Gate. make ci: fmt clean, clippy adds no warning (the remaining ones are in untouched files), 1701 tests pass. Two fail on this machine and fail the same way on main at 0f98b95: e2e_tui's nebula_open_from_inside_a_session_raises_the_file_tabs and tui_drag_past_the_pane_top_autoscrolls_and_copies_the_run (time out waiting for the TUI); ipc::tests::kill_stops_a_skewed_daemon_whose_pidfile_is_gone is flaky on both (it failed 1 run in 6 on main, alone). New: a_named_worktree_names_the_spawn_against_its_own_rows, a_branch_with_a_worktree_is_reused_not_cut, a_worktree_spawn_is_refused_before_any_worktree_is_cut, a_named_base_is_refused_for_a_branch_that_already_exists, enter_worktree_takes_an_existing_branch_and_moves_the_row_now extended (nebula worktree --base on an existing checkout refused), and nebula_spawn_cli_starts_a_sibling_session_in_the_same_worktree extended end to end (a real worktree cut and reused, --base refused alone, blank, on an existing worktree and on a branch kept without one). The parallel-spawn race has no test: it would need two requests held at the lock at once.

📝 Notes

  • Branched from main at 0f98b95 (v0.42.0); no conflicts.
  • Protocol 45: run nebula kill once after upgrading so the DAEMON restarts on the new build.

🤖 Generated with Claude Code

…h's worktree, cut first when the branch has none

- `nebula spawn "<task>" --worktree <branch>` starts the sibling session
  in the project's worktree on <branch> instead of the caller's: the
  checkout already on that branch (the root one included), or a new one
  cut the way `nebula worktree` cuts it. The branch words are slugified
  as `nebula worktree` slugifies them, the session keeps the caller's
  harness, model and effort unless `--kind` says otherwise, and its
  default `agent-N` name is the first one free in the worktree it lands
  in. The caller stays where it is.
- Whatever the create would refuse is refused before git cuts anything,
  so a refused spawn never leaves a worktree behind: a blank branch, an
  unknown or archived caller, and — through `Daemon::check_cold_launch`,
  the same refusals `create_agent` gives a cold launch on a starting
  prompt — the prompt, a harness that does not resolve and a CLI that is
  not installed.
- The find-or-cut half of `nebula worktree` moves into
  `Daemon::worktree_on_branch`, which both commands share. It holds
  `worktree_ops` across the lookup and the cut, as `pr_worktree` does, so
  two requests for one new branch share one checkout instead of the
  second failing on "worktree path already exists".
  `create_worktree`'s body becomes `cut_worktree`, for a lock holder.
- `--base <ref>` picks a new branch's start point and is refused when the
  branch already exists instead of being dropped, so neither command
  reports a start point the checkout lacks: `worktree_on_branch` refuses
  it for a branch with a worktree (naming its path), and
  `git::add_worktree_off_ref` — the path only a user-named base takes —
  for a branch kept without one. Both sit on the shared path, so
  `nebula worktree --base` gets the same refusals; bases nobody named
  still check an existing branch out. `--base` is refused without
  `--worktree`, or blank.
- Claude's spawn guidance names `--worktree` for "in a new worktree" /
  "on branch X" requests and lists `grok` among the `--kind` values.
- Protocol 45: `SpawnSiblingAgent` carries `worktree` and `base`. The
  codec writes structs as arrays, so a v44 daemon cannot read the
  longer request even when both are None; refusing at the handshake
  gives the usual `nebula kill` message instead of a dropped connection.
- `--help` for both commands, docs/commands.md and docs/how-it-works.md
  describe the flag and when --base is refused.

Tests: a_named_worktree_names_the_spawn_against_its_own_rows,
a_branch_with_a_worktree_is_reused_not_cut (and `--base` for it refused),
a_worktree_spawn_is_refused_before_any_worktree_is_cut (a blank branch,
a blank task, an unknown caller and a Custom caller with no registry id,
none registering a worktree),
a_named_base_is_refused_for_a_branch_that_already_exists,
enter_worktree_takes_an_existing_branch_and_moves_the_row_now extended
(`--base` on an existing checkout refused), and
nebula_spawn_cli_starts_a_sibling_session_in_the_same_worktree extended
end to end (a real worktree cut for `feat login`, the session alive in
it as agent-1, the same branch reused on a second spawn, `--base`
refused alone, blank, on an existing worktree and on a branch kept
without one).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant