Skip to content

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

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

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

Conversation

@michael-dg

@michael-dg michael-dg commented Oct 3, 2026 •

Copy link
Copy Markdown
Owner

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: the existing branch was checked out and the base dropped without a word, so the session ran on the wrong start point
    • now: an error names the branch 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). 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 in parallel runs on both. 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, 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

@michael-dg michael-dg left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merge-risk review — read, not run

Verdict: 🟡 Merge with care — the flag reuses an already-exposed path and is well tested, but the shared find-or-cut helper looks up the branch outside the worktree lock, so two parallel spawns onto one new branch fail instead of sharing it, contrary to the description.
Basis: the diff (+363 / −78 over 11 files), the surrounding code at the merge base 0f98b95 and the head a1f5fe7, the description, CI (none: no checks reported on this fork branch, and upstream builds or tests nothing on a PR either) and the prior reviews (none). Nothing was built, run or checked out for this review.

🔒 Security & production risk

  • Should fix — the branch lookup runs outside worktree_ops. crates/nebula-daemon/src/registry.rs:781 — worktree_on_branch reads load_tree() before create_worktree takes the lock, so two nebula spawn --worktree feat-x calls arriving together both miss, both cut, and the second dies on worktree path already exists (git.rs add_worktree_inner). Triggered by an orchestrating agent firing parallel Bash calls, the very use case this PR enables. Blast radius: one failed spawn with a readable error, no orphan checkout, no data loss. The race predates the PR (enter_worktree had it inline at 0f98b95), but it now also sits behind the "A branch is reused, never doubled" promise. Confirmed by reading. Fix: do the lookup under the lock, as the sibling pr_worktree (registry.rs:813) already does, e.g. a lock-held variant of create_worktree that re-checks first.
  • Nit — the caller is looked up twice. crates/nebula-daemon/src/sibling.rs:141 and sibling.rs:76 — spawning_caller runs before the worktree is cut and again in sibling_spec. An archive that lands between the two (seconds while git fetches) refuses the spawn after the worktree exists, leaving one empty checkout. Confirmed by reading; negligible in practice. Passing the already-fetched Agent into sibling_spec would close it.
  • Production — Protocol 45. crates/nebula-core/src/protocol.rs:10 — correct and required: the codec writes structs as arrays, so a v44 DAEMON cannot decode the longer SpawnSiblingAgent. Every user must nebula kill once after upgrading, which stops their live sessions. The description and the Notes say so.
  • Otherwise nothing found. Looked at: the widened ClientRequest (same-uid DAEMON SOCKET, the same create_worktree inputs EnterWorktree already accepts, git argv with no shell), the guidance text added to Claude's appended system prompt (constant, no user text), and logging (no task text logged).

⚡ Performance

  • Nothing found. Off every hot path: the one-shot CLI connection waits on fetch + WORKTREE HOOK only with --worktree, exactly as nebula worktree does. Nit: the spawn guidance grows every Claude session's appended system prompt by ~400 bytes.

🧩 Fit with the codebase

  • Follows the patterns: the extraction removes duplication from enter_worktree instead of copying it, the PROTOCOL VERSION bump matches 5bebba4, there are in-file unit tests plus E2E PTY coverage over real processes, and docs/commands.md, docs/how-it-works.md and --help are updated.
  • Nit: registry.rs grows by a net ~10 lines; the helper could live beside create_worktree as it does, which is acceptable for a two-caller helper.

📐 Scope

The description claims the flag, the refusal ordering, the guidance text, the protocol bump and the docs; the diff touches exactly those 11 files. Nothing is outside the claim.

❓ Unsettled by reading

  • Two concurrent nebula spawn --worktree <new-branch> from one session: a "path already exists" error on the second confirms the Should-fix above.
  • A v0.42.0 DAEMON left running under this build: nebula spawn should be refused at the handshake with the nebula kill message, not drop mid-request.
  • make ci on the author's machine: the description reports 1698 passing tests and 3 failures that also fail on main. This review did not run it.

🤖 Generated with Claude Code

@michael-dg michael-dg left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merge-risk review — read, not run (second pass)

Verdict: 🟢 Low risk — both findings of the first pass are fixed as described. One narrower case of the --base promise remains: a branch that exists in git without a worktree.
Basis: the diff (+506 / −103 over 11 files, two commits), the surrounding code at the merge base 0f98b95 and the head 2056554, the description, CI (none: no checks reported on this fork branch, and upstream builds or tests nothing on a PR either) and the first-pass review. Nothing was built, run or checked out for this review.

🔒 Security & production risk

  • Resolved — lookup outside the lock. crates/nebula-daemon/src/registry.rs:796 — worktree_on_branch now holds worktree_ops across the lookup and cut_worktree, as pr_worktree does. No caller already holds the lock: the only callers are enter_worktree (registry.rs:1602, reached from server.rs:516 with no lock held) and spawn_sibling_agent, so the non-reentrant tokio mutex cannot deadlock. Confirmed by reading.
  • Resolved — orphan worktree on a refused create. crates/nebula-daemon/src/sibling.rs:157 — check_cold_launch (registry.rs:1036) runs create_agent's prompt, harness and missing-CLI refusals before git. It covers every refusal create_agent can give this spec ahead of the insert, since the cloud, PR and issue arms are all None here. Confirmed by reading.
  • Resolved — double caller read. The caller is read once (spawning_caller) and passed down, which closes the archive-between-reads window.
  • Should fix — --base is still dropped for a branch that exists without a worktree. crates/nebula-daemon/src/git.rs:269 — when <branch> is already a local branch with no checkout, git worktree add -b <branch> <base> fails on "already exists", and the fallback checks out the existing branch without <base>. worktree_on_branch reports created = true, so the refusal at sibling.rs:172 never fires and the spawn succeeds on the branch's own history. Triggered by nebula spawn --worktree hotfix --base v0.21.0 after a hotfix worktree was deleted but its branch kept (the default for a delete). Blast radius: a session on the wrong start point, reported to the user as the base asked for. The same was already true of nebula worktree --base at 0f98b95. Confirmed by reading. Either refuse --base in that fallback (it knows the branch exists), or narrow the description's claim to "a branch with a worktree".
  • Nit — the blank-branch guard left the shared helper. registry.rs:796 — create_worktree still refuses a blank branch, but worktree_on_branch calls cut_worktree directly and relies on both of its callers to have trimmed and checked. They both do today. Confirmed by reading.
  • Production — Protocol 45, unchanged from the first pass: one nebula kill after upgrading, stated in the Notes.

⚡ Performance

  • Nothing found. check_cold_launch can probe the login shell (probe_cli, bounded by CLI_PROBE_TIMEOUT) only on a cache miss, on a one-shot CLI connection; the hit it leaves behind makes create_agent's own check free. The worktree lock is now also held across the lookup, a single load_tree, next to a git fetch it already covered.

🧩 Fit with the codebase

  • cut_worktree as "the body for a lock holder" mirrors how pr_worktree keeps lookup and cut under one guard, and check_cold_launch reuses create_agent's own functions (validate_starting_prompt, resolve_harness, cli_available_for_create) instead of copying their logic. Tests sit beside the change, both in-file and in e2e_pty, and --help and docs/commands.md describe the --base refusal.

📐 Scope

The description (updated for the second commit) claims the refusal ordering, the lock, the --base refusal, grok in the guidance, and the docs. The diff matches it, except for the existing-branch case above.

❓ Unsettled by reading

  • Two concurrent nebula spawn --worktree <new-branch>: should now share one checkout. The PR says this has no test.
  • nebula spawn --worktree <branch-with-no-worktree> --base <other>: a success here confirms the Should-fix.

🤖 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>
@michael-dg

Copy link
Copy Markdown
Owner Author

Superseded by the upstream PR AgentSystemLabs#125 (same branch, same commit e530f59). Closed without merging so this fork's main stays identical to upstream.

@michael-dg michael-dg closed this Oct 4, 2026
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