nebula spawn --worktree <branch> starts the new session in that branch's worktree, cut first when the branch has none - #1
michael-dg wants to merge 1 commit into
Conversation
michael-dg
left a comment
There was a problem hiding this comment.
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_branchreadsload_tree()beforecreate_worktreetakes the lock, so twonebula spawn --worktree feat-xcalls arriving together both miss, both cut, and the second dies onworktree path already exists(git.rsadd_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_worktreehad it inline at0f98b95), 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 siblingpr_worktree(registry.rs:813) already does, e.g. a lock-held variant ofcreate_worktreethat re-checks first. - Nit — the caller is looked up twice.
crates/nebula-daemon/src/sibling.rs:141andsibling.rs:76—spawning_callerruns before the worktree is cut and again insibling_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-fetchedAgentintosibling_specwould 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 longerSpawnSiblingAgent. Every user mustnebula killonce 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 samecreate_worktreeinputsEnterWorktreealready 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 asnebula worktreedoes. 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_worktreeinstead of copying it, the PROTOCOL VERSION bump matches 5bebba4, there are in-file unit tests plus E2E PTY coverage over real processes, anddocs/commands.md,docs/how-it-works.mdand--helpare updated. - Nit:
registry.rsgrows by a net ~10 lines; the helper could live besidecreate_worktreeas 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 spawnshould be refused at the handshake with thenebula killmessage, not drop mid-request. make cion the author's machine: the description reports 1698 passing tests and 3 failures that also fail onmain. This review did not run it.
🤖 Generated with Claude Code
michael-dg
left a comment
There was a problem hiding this comment.
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_branchnow holdsworktree_opsacross the lookup andcut_worktree, aspr_worktreedoes. No caller already holds the lock: the only callers areenter_worktree(registry.rs:1602, reached fromserver.rs:516with no lock held) andspawn_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) runscreate_agent's prompt, harness and missing-CLI refusals before git. It covers every refusalcreate_agentcan 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 —
--baseis 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_branchreportscreated = true, so the refusal atsibling.rs:172never fires and the spawn succeeds on the branch's own history. Triggered bynebula spawn --worktree hotfix --base v0.21.0after ahotfixworktree 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 ofnebula worktree --baseat0f98b95. Confirmed by reading. Either refuse--basein 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_worktreestill refuses a blank branch, butworktree_on_branchcallscut_worktreedirectly 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 killafter upgrading, stated in the Notes.
⚡ Performance
- Nothing found.
check_cold_launchcan probe the login shell (probe_cli, bounded byCLI_PROBE_TIMEOUT) only on a cache miss, on a one-shot CLI connection; the hit it leaves behind makescreate_agent's own check free. The worktree lock is now also held across the lookup, a singleload_tree, next to a git fetch it already covered.
🧩 Fit with the codebase
cut_worktreeas "the body for a lock holder" mirrors howpr_worktreekeeps lookup and cut under one guard, andcheck_cold_launchreusescreate_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 ine2e_pty, and--helpanddocs/commands.mddescribe the--baserefusal.
📐 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
e36cacd to
c398b0c
Compare
…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>
c398b0c to
e530f59
Compare
|
Superseded by the upstream PR AgentSystemLabs#125 (same branch, same commit e530f59). Closed without merging so this fork's main stays identical to upstream. |
nebula spawncould 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.mdworkflow it follows) can hand each piece of work to its own session on its own branch with one command.Contents
nebula worktree✨ What you get
🌿 Parallel work on separate branches
nebula spawn --worktree <branch> "<task>"nebula worktreeusesnebula spawn--kind, AUTO-TITLE--kindnames anotheragent-Nname is the first one free in the worktree it lands in, so it titles itselfnebula worktreeslugifies them:feat loginisfeat-login--base <ref>nebula worktree --baseresolves it--worktree, or blank--worktreefor "in a new worktree" / "on branch X" requests, and listsgrokamong the--kindvalues🛡️ Nothing left behind
--worktreeon the same branch🔁 Also changes
nebula worktreeBoth commands now share one find-or-cut path, so two of its fixes reach
nebula worktreeas well. Flagged here so neither lands unnoticed:nebula worktreeracing a spawn (or anothernebula worktree) for one new branch shares the checkout instead of failing on "worktree path already exists"--baseon a branch that already exists is refused — the one behaviour change outsidespawn--basenebula worktreeas it was;spawnwould then refuse the case on its ownnebula worktreewithout--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:🧭 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 …🎯 Attack surface
ClientRequest.SpawnSiblingAgentnow carriesworktreeandbase. Reached by: any process on the DAEMON's socket (mode0700, same user) that names a live agent id — the same reachnebula spawnandnebula worktreealready had. Held by: the values go to the samecreate_worktreethatEnterWorktreealready 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.
EnterWorktree's; the PROTOCOL VERSION bump means an upgraded client refuses an old DAEMON at the handshake with the usualnebula killmessage;nebula worktree --baseon an existing branch now errors where it used to succeed on the wrong start point--worktree, waits on the fetch and the WORKTREE HOOK exactly asnebula worktreealready doesenter_worktreeis extracted, not duplicated, and now locks likepr_worktree; the pre-create check reusescreate_agent's own refusals; tests follow the module's in-file ande2e_ptypatternsRollback:
git revert <merge>removes the flag; it does not undo Protocol 45, so clients and DAEMONs built across the revert still neednebula killonce.🔧 Technical overview
--worktree, trims--base, and sends both onSpawnSiblingAgent.Daemon::spawn_sibling_agentreads the caller once, refuses a blank branch, then runsDaemon::check_cold_launch— the prompt, harness and missing-CLI refusalscreate_agentwould give — before resolving the target throughDaemon::worktree_on_branch: the find-or-cut thatenter_worktreeused to do inline, now holdingworktree_opsacross the lookup and the cut (cut_worktreeiscreate_worktree's body for a lock holder).sibling_specthen picks the default name against the target worktree's rows.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.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 withenter_worktree),cut_worktree,check_cold_launch;crates/nebula-daemon/src/git.rs—add_worktree_off_refrefuses an existing branch;crates/nebula-core/src/protocol.rs— the two fields and the bump;crates/nebula/src/cli.rsandcrates/nebula-tui/src/ipc.rs— the flags and the request;docs/commands.md,docs/how-it-works.md.--worktreeis 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.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 onmainat 0f98b95:e2e_tui'snebula_open_from_inside_a_session_raises_the_file_tabsandtui_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_goneis 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, andnebula_spawn_cli_starts_a_sibling_session_in_the_same_worktreeextended end to end (a real worktree cut and reused,--baserefused 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
mainat 0f98b95 (v0.42.0); no conflicts.nebula killonce after upgrading so the DAEMON restarts on the new build.🤖 Generated with Claude Code