From e530f59aee2aa90fb50ca83b0ad15fe338c8eaa3 Mon Sep 17 00:00:00 2001 From: Michael De Gols Date: Sun, 4 Oct 2026 15:57:21 +0200 Subject: [PATCH] nebula spawn --worktree starts the new session in that branch's worktree, cut first when the branch has none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `nebula spawn "" --worktree ` starts the sibling session in the project's worktree on 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 ` 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 --- crates/nebula-core/src/protocol.rs | 26 ++- crates/nebula-daemon/src/git.rs | 33 ++++ crates/nebula-daemon/src/registry.rs | 102 ++++++++-- crates/nebula-daemon/src/server.rs | 11 +- crates/nebula-daemon/src/sibling.rs | 275 +++++++++++++++++++++------ crates/nebula-tui/src/ipc.rs | 43 ++++- crates/nebula-tui/src/lib.rs | 19 +- crates/nebula/src/cli.rs | 26 ++- crates/nebula/src/main.rs | 7 +- crates/nebula/tests/e2e_pty.rs | 117 ++++++++++++ docs/commands.md | 17 +- docs/how-it-works.md | 5 +- 12 files changed, 575 insertions(+), 106 deletions(-) diff --git a/crates/nebula-core/src/protocol.rs b/crates/nebula-core/src/protocol.rs index 136d61c9..60914e3d 100644 --- a/crates/nebula-core/src/protocol.rs +++ b/crates/nebula-core/src/protocol.rs @@ -7,7 +7,7 @@ use std::path::PathBuf; /// Bump on any breaking change to these enums. The daemon refuses mismatched /// clients; the client then offers a kill-and-restart of the old daemon. -pub const PROTOCOL_VERSION: u32 = 44; +pub const PROTOCOL_VERSION: u32 = 45; /// Max IPC frame size (length prefix sanity bound). pub const MAX_FRAME_LEN: u32 = 4 * 1024 * 1024; @@ -225,17 +225,29 @@ pub enum ClientRequest { base: Option, }, /// `nebula spawn ""`, run by the agent from inside its own - /// session: start a new AGENT beside it — same WORKTREE, and the same - /// AGENT KIND / MODEL / EFFORT unless `kind` names another harness — - /// with `starting_prompt` as the new CLI's first prompt, so it begins - /// the task at once. The caller's own process is untouched. Answered - /// with `Ack { created: Some(EntityId::Agent(..)) }`; the row reaches - /// every TUI as an ordinary `EntityUpserted`. + /// session: start a new AGENT beside it — same WORKTREE unless + /// `worktree` names a branch, and the same AGENT KIND / MODEL / EFFORT + /// unless `kind` names another harness — with `starting_prompt` as the + /// new CLI's first prompt, so it begins the task at once. The caller's + /// own process is untouched. Answered with `Ack { created: + /// Some(EntityId::Agent(..)) }`; the row reaches every TUI as an + /// ordinary `EntityUpserted`. SpawnSiblingAgent { req_id: u64, id: AgentId, kind: Option, starting_prompt: String, + /// `--worktree `: start the session in the caller's + /// PROJECT's worktree on this branch instead, created (as + /// `EnterWorktree` creates one) when there is none. None: the + /// caller's own worktree. + #[serde(default)] + worktree: Option, + /// `--base `: start point for a new `worktree` branch, resolved + /// and refused as `EnterWorktree`'s `base` is (a branch that + /// already exists is refused). Ignored without `worktree`. + #[serde(default)] + base: Option, }, /// `nebula open …`, run by the agent from inside its own session: /// show these files to the user in every attached TUI's FILE TABS — diff --git a/crates/nebula-daemon/src/git.rs b/crates/nebula-daemon/src/git.rs index 5cf8f45b..1af2fb94 100644 --- a/crates/nebula-daemon/src/git.rs +++ b/crates/nebula-daemon/src/git.rs @@ -197,7 +197,18 @@ pub async fn add_worktree_off_default(repo: &Path, branch: &str) -> Result Result { + if local_branch(repo, branch).await { + bail!( + "branch `{branch}` already exists; --base only applies to a new branch — run it \ + again without --base to use the branch as it is" + ); + } fetch_origin_if_any(repo).await; match origin_branch(repo, base).await { Some(remote) => add_worktree_inner(repo, branch, Some(&remote), false).await, @@ -1061,6 +1072,28 @@ mod tests { assert_ne!(head, landed); } + /// A named base is refused for a branch that already exists without a + /// worktree — a kept branch of a deleted checkout — instead of being + /// dropped by the check-out-the-existing-branch fallback. + #[tokio::test] + async fn a_named_base_is_refused_for_a_branch_that_already_exists() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + std::fs::create_dir(&repo).unwrap(); + init_repo(&repo).await; + git(&repo, &["tag", "v1"]).await.unwrap(); + git(&repo, &["branch", "hotfix"]).await.unwrap(); + + let err = add_worktree_off_ref(&repo, "hotfix", "v1") + .await + .unwrap_err(); + assert!(err.to_string().contains("already exists"), "{err}"); + assert!( + !worktree_dir(&repo, "hotfix").exists(), + "nothing is checked out" + ); + } + /// The `worktree_base_branch` SETTING says `main` while the checkout's /// `main` is a commit behind origin's: the branch starts at origin's /// main, untracked — the same answer `--base main` gives. diff --git a/crates/nebula-daemon/src/registry.rs b/crates/nebula-daemon/src/registry.rs index 0bb7712d..fb3ccf9e 100644 --- a/crates/nebula-daemon/src/registry.rs +++ b/crates/nebula-daemon/src/registry.rs @@ -739,6 +739,20 @@ impl Daemon { bail!("branch name is empty"); } let ops = self.worktree_ops.lock().await; + let worktree = self.cut_worktree(project_id, branch, base).await?; + drop(ops); + Ok(EntityId::Worktree(worktree.id)) + } + + /// [`Self::create_worktree`]'s work, for a caller that already holds + /// `worktree_ops`: cut the checkout, register its row, run the + /// WORKTREE HOOK. + async fn cut_worktree( + self: &Arc, + project_id: &ProjectId, + branch: &str, + base: Option<&str>, + ) -> Result { let project = self .store .get_project(project_id)? @@ -768,8 +782,46 @@ impl Daemon { // what that holds the lock for. self.run_worktree_hook(WorktreeHook::Create, &project.repo_path, &worktree) .await; + Ok(worktree) + } + + /// The PROJECT's worktree on `branch` — the ROOT WORKTREE when the + /// branch is checked out there — or a new one cut from `base` (None: + /// the configured base). A `base` for a branch that already has a + /// checkout is refused rather than dropped, so neither command reports + /// a start point the checkout lacks (a branch kept without one is + /// `git::add_worktree_off_ref`'s to refuse). Looked up under + /// `worktree_ops`, as [`Self::pr_worktree`] does, so two requests for + /// one new branch get one checkout instead of a race the second loses. + /// What `nebula worktree` moves a session into and `nebula spawn + /// --worktree` starts one in. + pub(crate) async fn worktree_on_branch( + self: &Arc, + project_id: &ProjectId, + branch: &str, + base: Option<&str>, + ) -> Result { + if branch.trim().is_empty() { + bail!("branch name is empty"); + } + let ops = self.worktree_ops.lock().await; + let (_, worktrees, _, _) = self.store.load_tree()?; + if let Some(existing) = worktrees + .into_iter() + .find(|w| &w.project_id == project_id && w.branch == branch) + { + if base.is_some() { + bail!( + "branch `{branch}` already has a worktree at {}; --base only applies to a new \ + branch — run it again without --base to use that worktree", + existing.path.display() + ); + } + return Ok(existing); + } + let worktree = self.cut_worktree(project_id, branch, base).await?; drop(ops); - Ok(EntityId::Worktree(worktree.id)) + Ok(worktree) } /// The checkout every PR SESSION for pull request `number` runs in: the @@ -987,6 +1039,27 @@ impl Daemon { // ---- agents ---- + /// The refusals [`Self::create_agent`] would give a cold launch on a + /// STARTING PROMPT — the prompt, the harness, a missing CLI — asked + /// ahead of it, for a caller about to do something costly first + /// (`nebula spawn --worktree` cuts a checkout) that the create could + /// then refuse. A miss is re-probed, so the create's own check is the + /// cached hit this leaves behind. + pub(crate) async fn check_cold_launch( + &self, + kind: AgentKind, + custom_harness: Option<&str>, + starting_prompt: &str, + ) -> Result<()> { + validate_starting_prompt(starting_prompt)?; + let harness = resolve_harness(kind, custom_harness)?; + let program = harness.program.trim(); + if !self.cli_available_for_create(program).await { + bail!("{}", cli_missing_message(program)); + } + Ok(()) + } + pub(crate) async fn create_agent(self: &Arc, spec: CreateAgentSpec) -> Result { let CreateAgentSpec { worktree: worktree_id, @@ -1537,24 +1610,9 @@ impl Daemon { .store .get_worktree(&agent.worktree_id)? .context("worktree not found")?; - let (_, worktrees, _, _) = self.store.load_tree()?; - let existing = worktrees - .into_iter() - .find(|w| w.project_id == current.project_id && w.branch == branch); - let target = match existing { - Some(w) => w, - None => { - let created = self - .create_worktree(¤t.project_id, branch, base) - .await?; - let EntityId::Worktree(new_id) = created else { - bail!("worktree creation returned a non-worktree entity"); - }; - self.store - .get_worktree(&new_id)? - .context("worktree not found")? - } - }; + let target = self + .worktree_on_branch(¤t.project_id, branch, base) + .await?; if target.id == current.id { return Ok((target, EnterOutcome::AlreadyThere)); } @@ -5411,6 +5469,12 @@ mod tests { // Blank names are refused before anything is touched. assert!(daemon.enter_worktree(&a1, " ", None).await.is_err()); + // So is a start point for a branch that already has a checkout. + let err = daemon + .enter_worktree(&a1, "feat", Some("main")) + .await + .unwrap_err(); + assert!(err.to_string().contains("already has a worktree"), "{err}"); } /// Between `nebula worktree` and the turn's Stop the row already sits diff --git a/crates/nebula-daemon/src/server.rs b/crates/nebula-daemon/src/server.rs index 27d5b918..7ad6d130 100644 --- a/crates/nebula-daemon/src/server.rs +++ b/crates/nebula-daemon/src/server.rs @@ -471,10 +471,19 @@ async fn handle_client(daemon: Arc, stream: UnixStream) -> Result<()> { id, kind, starting_prompt, + worktree, + base, } => { // Logged by mode only — never the prompt text. + let worktree = + worktree + .as_deref() + .map(|branch| crate::sibling::SiblingWorktree { + branch, + base: base.as_deref(), + }); let result = daemon - .spawn_sibling_agent(&id, kind, &starting_prompt) + .spawn_sibling_agent(&id, kind, worktree, &starting_prompt) .await; match &result { Ok(nebula_core::EntityId::Agent(agent)) => tracing::info!( diff --git a/crates/nebula-daemon/src/sibling.rs b/crates/nebula-daemon/src/sibling.rs index 9da70d85..8f60589e 100644 --- a/crates/nebula-daemon/src/sibling.rs +++ b/crates/nebula-daemon/src/sibling.rs @@ -1,13 +1,14 @@ //! `nebula spawn ""` from inside an agent session: a new AGENT beside -//! the caller — same WORKTREE, same harness unless another is named — that -//! opens on the task as its STARTING PROMPT. The caller's own process is -//! never touched (unlike `nebula worktree`, nothing here waits on a turn -//! end), so the model runs it, tells the user, and carries on. +//! the caller — same WORKTREE unless `--worktree` names another branch, +//! same harness unless another is named — that opens on the task as its +//! STARTING PROMPT. The caller's own process is never touched (unlike +//! `nebula worktree`, nothing here waits on a turn end), so the model runs +//! it, tells the user, and carries on. use std::sync::Arc; use anyhow::{bail, Context, Result}; -use nebula_core::{AgentId, AgentKind, EntityId}; +use nebula_core::{Agent, AgentId, AgentKind, EntityId, WorktreeId}; use crate::registry::{CreateAgentSpec, Daemon}; @@ -21,11 +22,24 @@ session (\"start a new nebula session that …\", \"spin up another session to session for …\"), do not launch an agent process yourself. Run this shell command instead, exactly \ once:\n\n nebula spawn \"\"\n\nwhere is the work the user wants that session to do, \ in their own words — the new session opens on it as its first prompt, so make it self-contained. \ -Add `--kind claude|codex|cursor|pi|muse|opencode` only when the user names the harness; otherwise the new session \ +Add `--kind claude|codex|cursor|pi|muse|grok|opencode` only when the user names the harness; otherwise the new session \ matches this one. nebula starts it beside this session, in the same worktree, and it shows up in \ -the sessions list on its own. This session is unaffected: carry on with whatever else the user \ -asked, and if starting the session was the whole request, tell the user in one line that it is \ -running. If the command fails, report the error."; +the sessions list on its own. When the user wants that session on its own branch or worktree \ +(\"in a new worktree\", \"on branch fix-login\"), add `--worktree `: nebula starts it in the \ +project's worktree on that branch, creating it when there is none (`--base ` picks a new \ +branch's start point, only when the user names one). This session is unaffected either way: carry \ +on with whatever else the user asked, and if starting the session was the whole request, tell the \ +user in one line that it is running. If the command fails, report the error."; + +/// `nebula spawn --worktree [--base ]`: the branch whose +/// worktree the new session starts in, and where a new branch is cut from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SiblingWorktree<'a> { + pub branch: &'a str, + /// Start point for a branch with no worktree yet; None is the + /// `worktree_base_branch` SETTING, else origin's default branch. + pub base: Option<&'a str>, +} /// The first free `agent-N` among `taken` — the same default the TUI's /// name prompt offers, which is what makes the new row eligible for @@ -38,45 +52,71 @@ pub(crate) fn sibling_name(taken: &[String]) -> String { } impl Daemon { - /// The create spec for a session started beside `id`: its worktree, its - /// harness (and model / effort) unless `kind` overrides — a different - /// CLI cannot take this one's model name — a default `agent-N` name so - /// AUTO-TITLE applies, and `starting_prompt` as the first prompt (which - /// `create_agent` validates). Pure lookup, so it is unit-testable - /// without a PTY. - pub(crate) fn sibling_spec( - &self, - id: &AgentId, - kind: Option, - starting_prompt: &str, - ) -> Result { + /// The agent running `nebula spawn`: refused when unknown or archived. + fn spawning_caller(&self, id: &AgentId) -> Result { let caller = self.store.get_agent(id)?.context("agent not found")?; if caller.archived { bail!("agent is archived"); } + Ok(caller) + } + + /// The first free `agent-N` among the rows in `worktree`. + fn free_name_in(&self, worktree: &WorktreeId) -> Result { let (_, _, agents, _) = self.store.load_tree()?; let taken = agents .iter() - .filter(|a| a.worktree_id == caller.worktree_id) + .filter(|a| &a.worktree_id == worktree) .map(|a| a.name.clone()) .collect::>(); + Ok(sibling_name(&taken)) + } + + /// The harness a session started beside `caller` runs — its own (with + /// its model, effort and custom registry id) unless `kind` names + /// another, which drops them: a different CLI cannot take this one's + /// model name, and an override to Custom without an id is refused at + /// create with its reason. + fn sibling_harness(caller: &Agent, kind: Option) -> SiblingHarness { let kind = kind.unwrap_or(caller.kind); - let (model, effort) = if kind == caller.kind { - (caller.model.clone(), caller.effort.clone()) - } else { - (None, None) - }; - // A sibling on the same harness keeps its custom registry id; an - // override to another harness drops it (an override to Custom - // without an id is refused at create with its reason). - let custom_harness = if kind == caller.kind { - caller.custom_harness.clone() + if kind == caller.kind { + SiblingHarness { + kind, + custom_harness: caller.custom_harness.clone(), + model: caller.model.clone(), + effort: caller.effort.clone(), + } } else { - None - }; + SiblingHarness { + kind, + custom_harness: None, + model: None, + effort: None, + } + } + } + + /// The create spec for a session started beside `caller` in + /// `worktree`: the caller's harness unless `kind` overrides, a default + /// `agent-N` name free in that worktree so AUTO-TITLE applies, and + /// `starting_prompt` as the first prompt (which `create_agent` + /// validates). Pure lookup, so it is unit-testable without a PTY. + pub(crate) fn sibling_spec( + &self, + caller: &Agent, + kind: Option, + worktree: &WorktreeId, + starting_prompt: &str, + ) -> Result { + let SiblingHarness { + kind, + custom_harness, + model, + effort, + } = Self::sibling_harness(caller, kind); Ok(CreateAgentSpec { - worktree: caller.worktree_id.clone(), - name: sibling_name(&taken), + worktree: worktree.clone(), + name: self.free_name_in(worktree)?, kind, custom_harness, model, @@ -91,19 +131,56 @@ impl Daemon { /// `nebula spawn`, run by the agent inside its own session: create and /// boot a new agent beside it with `starting_prompt` as its first - /// prompt. Returns the new row's id; the upsert reaches every client - /// through the ordinary create path. + /// prompt — in the caller's worktree, or with `worktree` in the + /// caller's PROJECT's worktree on that branch, cut first when there is + /// none. Returns the new row's id; a created worktree and the agent + /// reach every client through the ordinary create paths. pub async fn spawn_sibling_agent( self: &Arc, id: &AgentId, kind: Option, + worktree: Option>, starting_prompt: &str, ) -> Result { - let spec = self.sibling_spec(id, kind, starting_prompt)?; + let caller = self.spawning_caller(id)?; + let target = match worktree { + None => caller.worktree_id.clone(), + Some(SiblingWorktree { branch, base }) => { + let branch = branch.trim(); + if branch.is_empty() { + bail!("branch name is empty"); + } + // Whatever the create would refuse — the prompt, the + // harness, a missing CLI — is asked before git cuts a + // checkout nobody would then launch in. + let harness = Self::sibling_harness(&caller, kind); + self.check_cold_launch( + harness.kind, + harness.custom_harness.as_deref(), + starting_prompt, + ) + .await?; + let project = self + .store + .get_worktree(&caller.worktree_id)? + .context("worktree not found")? + .project_id; + self.worktree_on_branch(&project, branch, base).await?.id + } + }; + let spec = self.sibling_spec(&caller, kind, &target, starting_prompt)?; self.create_agent(spec).await } } +/// [`Daemon::sibling_harness`]: what the new session runs. +struct SiblingHarness { + kind: AgentKind, + custom_harness: Option, + model: Option, + effort: Option, +} + #[cfg(test)] mod tests { use super::*; @@ -189,8 +266,9 @@ mod tests { .insert_agent(&agent("agent-2", "root", AgentKind::Codex, None)) .unwrap(); + let caller = daemon.spawning_caller(&AgentId("agent-1".into())).unwrap(); let spec = daemon - .sibling_spec(&AgentId("agent-1".into()), None, "Fix the login redirect") + .sibling_spec(&caller, None, &caller.worktree_id, "Fix the login redirect") .unwrap(); assert_eq!(spec.worktree.to_string(), "feat"); assert_eq!(spec.name, "agent-2"); @@ -212,10 +290,12 @@ mod tests { .store .insert_agent(&agent("agent-1", "feat", AgentKind::Claude, Some("opus"))) .unwrap(); + let caller = daemon.spawning_caller(&AgentId("agent-1".into())).unwrap(); let spec = daemon .sibling_spec( - &AgentId("agent-1".into()), + &caller, Some(AgentKind::Codex), + &caller.worktree_id, "Run the tests", ) .unwrap(); @@ -227,8 +307,9 @@ mod tests { // Naming the caller's own harness keeps its knobs. let same = daemon .sibling_spec( - &AgentId("agent-1".into()), + &caller, Some(AgentKind::Claude), + &caller.worktree_id, "Run the tests", ) .unwrap(); @@ -238,26 +319,22 @@ mod tests { #[test] fn unknown_and_archived_callers_are_refused() { let daemon = daemon(); - // `CreateAgentSpec` is deliberately not Debug (it carries the - // prompt), so the Err side is taken by hand. let missing = daemon - .sibling_spec(&AgentId("nope".into()), None, "x") - .err() - .expect("an unknown caller is refused"); + .spawning_caller(&AgentId("nope".into())) + .expect_err("an unknown caller is refused"); assert!(missing.to_string().contains("agent not found")); let mut archived = agent("agent-1", "feat", AgentKind::Claude, None); archived.archived = true; daemon.store.insert_agent(&archived).unwrap(); let err = daemon - .sibling_spec(&AgentId("agent-1".into()), None, "x") - .err() - .expect("an archived caller is refused"); + .spawning_caller(&AgentId("agent-1".into())) + .expect_err("an archived caller is refused"); assert!(err.to_string().contains("archived")); } - /// The prompt itself is `create_agent`'s to validate (blank, NUL, too - /// long), so a bad one fails there, before any worktree lookup. + /// Without `--worktree` the prompt is `create_agent`'s to validate + /// (blank, NUL, too long). #[tokio::test] async fn spawn_sibling_agent_rejects_a_blank_prompt() { let daemon = daemon(); @@ -266,9 +343,101 @@ mod tests { .insert_agent(&agent("agent-1", "feat", AgentKind::Claude, None)) .unwrap(); let err = daemon - .spawn_sibling_agent(&AgentId("agent-1".into()), None, " \n ") + .spawn_sibling_agent(&AgentId("agent-1".into()), None, None, " \n ") .await .unwrap_err(); assert!(err.to_string().contains("is empty"), "{err}"); } + + #[test] + fn a_named_worktree_names_the_spawn_against_its_own_rows() { + let daemon = daemon(); + daemon + .store + .insert_agent(&agent("agent-1", "feat", AgentKind::Claude, None)) + .unwrap(); + let mut in_root = agent("other", "root", AgentKind::Codex, None); + in_root.name = "agent-2".into(); + daemon.store.insert_agent(&in_root).unwrap(); + // `agent-1` is the caller's, in another worktree; `agent-2` is + // taken where the spawn lands. + assert_eq!( + daemon.free_name_in(&WorktreeId("root".into())).unwrap(), + "agent-1" + ); + } + + /// A branch that already has a checkout is taken as it stands, no git + /// involved (the test project is no repo, so a cut would fail), and a + /// start point for it is refused rather than dropped. + #[tokio::test] + async fn a_branch_with_a_worktree_is_reused_not_cut() { + let daemon = daemon(); + let project = ProjectId("p".into()); + let worktree = daemon + .worktree_on_branch(&project, "feat", None) + .await + .unwrap(); + assert_eq!(worktree.id, WorktreeId("feat".into())); + + let err = daemon + .worktree_on_branch(&project, "feat", Some("main")) + .await + .unwrap_err(); + assert!(err.to_string().contains("already has a worktree"), "{err}"); + } + + /// With `--worktree`, a call the create would refuse is refused before + /// git is asked for a worktree: the test project is no repo, so a git + /// error here would mean the checks came too late. + #[tokio::test] + async fn a_worktree_spawn_is_refused_before_any_worktree_is_cut() { + let daemon = daemon(); + daemon + .store + .insert_agent(&agent("agent-1", "feat", AgentKind::Claude, None)) + .unwrap(); + let caller = AgentId("agent-1".into()); + let fresh = SiblingWorktree { + branch: "fix-login", + base: None, + }; + + let blank_branch = SiblingWorktree { + branch: " ", + base: None, + }; + let err = daemon + .spawn_sibling_agent(&caller, None, Some(blank_branch), "Fix it") + .await + .unwrap_err(); + assert!(err.to_string().contains("branch name is empty"), "{err}"); + + let err = daemon + .spawn_sibling_agent(&caller, None, Some(fresh), " \n ") + .await + .unwrap_err(); + assert!(err.to_string().contains("is empty"), "{err}"); + + let err = daemon + .spawn_sibling_agent(&AgentId("nope".into()), None, Some(fresh), "Fix it") + .await + .unwrap_err(); + assert!(err.to_string().contains("agent not found"), "{err}"); + + // The harness is checked before git too: a Custom row without its + // registry id is refused by the same check `create_agent` makes. + daemon + .store + .insert_agent(&agent("custom-1", "feat", AgentKind::Custom, None)) + .unwrap(); + let err = daemon + .spawn_sibling_agent(&AgentId("custom-1".into()), None, Some(fresh), "Fix it") + .await + .unwrap_err(); + assert!(err.to_string().contains("missing its registry id"), "{err}"); + + let (_, worktrees, _, _) = daemon.store.load_tree().unwrap(); + assert_eq!(worktrees.len(), 2, "no worktree was registered"); + } } diff --git a/crates/nebula-tui/src/ipc.rs b/crates/nebula-tui/src/ipc.rs index 95f5a652..4ef6ed41 100644 --- a/crates/nebula-tui/src/ipc.rs +++ b/crates/nebula-tui/src/ipc.rs @@ -315,21 +315,42 @@ pub async fn rename_current_agent(title: &str, mode: RenameMode) -> Result<()> { Ok(()) } -/// CLI: `nebula spawn "" [--kind ]` from inside -/// an agent session — ask the daemon to start a new agent beside this one, -/// in the same worktree, opening on `task` as its first prompt. The caller -/// is untouched: no relocation, no turn-end wait. Never spawns a daemon: no -/// daemon means no session to sit beside. +/// CLI: `nebula spawn "" [--kind ] [--worktree +/// [--base ]]` from inside an agent session — ask the daemon +/// to start a new agent beside this one, in the same worktree or in the +/// project's worktree on `worktree` (created when the branch has none), +/// opening on `task` as its first prompt. The caller is untouched: no +/// relocation, no turn-end wait. Never spawns a daemon: no daemon means no +/// session to sit beside. /// /// What this prints is read by the model that ran it, so it says what /// happened and that this session carries on. A daemon-side refusal (a -/// blank task, a missing CLI) is a nonzero exit the model reports. -pub async fn spawn_sibling_for_current_agent(task: &str, kind: Option) -> Result<()> { +/// blank task, a missing CLI, a worktree git could not create) is a +/// nonzero exit the model reports. +pub async fn spawn_sibling_for_current_agent( + task: &str, + kind: Option, + worktree: Option, + base: Option, +) -> Result<()> { let agent_id = current_agent_id("spawn")?; let task = task.trim(); if task.is_empty() { bail!("the task is empty — `nebula spawn \"\"` needs the work the new session starts on"); } + // The words become a branch as `nebula worktree`'s do, so both + // commands land on the same branch for the same words. Unlike there, no + // name is invented for a blank one: a spawn names where its work goes. + let worktree = match worktree.as_deref().map(crate::branch_name::slugify) { + Some(slug) if slug.is_empty() => { + bail!("the branch is empty — `nebula spawn --worktree ` needs a branch name") + } + other => other, + }; + let base = match base.as_deref().map(str::trim) { + Some("") => bail!("the base is empty — `--base ` needs a branch, tag or commit"), + other => other.map(str::to_string), + }; let sock = paths::socket_path(); let Ok(stream) = try_connect(&sock).await else { bail!("no nebula daemon is running — no session started"); @@ -343,13 +364,19 @@ pub async fn spawn_sibling_for_current_agent(task: &str, kind: Option id: AgentId(agent_id), kind, starting_prompt: task.to_string(), + worktree: worktree.clone(), + base, }, ) .await?; await_ack(&mut conn, req_id).await?; let harness = kind.map(|k| format!("{} ", k.as_str())).unwrap_or_default(); + let place = match &worktree { + Some(branch) => format!("the worktree on branch \"{branch}\""), + None => "this worktree".to_string(), + }; println!( - "started a new {harness}session in this worktree; it is working on that task now and \ + "started a new {harness}session in {place}; it is working on that task now and \ shows in the sessions list. This session is unaffected — carry on." ); Ok(()) diff --git a/crates/nebula-tui/src/lib.rs b/crates/nebula-tui/src/lib.rs index f71b9edd..9a97d20e 100644 --- a/crates/nebula-tui/src/lib.rs +++ b/crates/nebula-tui/src/lib.rs @@ -97,11 +97,20 @@ pub fn run_open(files: Vec) -> Result<()> { runtime()?.block_on(ipc::open_files_for_current_agent(&files)) } -/// `nebula spawn "" [--kind ]` — start a new agent session -/// beside the current one (see `ipc::spawn_sibling_for_current_agent`). -/// `kind` is the CLI's `--kind`, already parsed where the flag is. -pub fn run_spawn(task: String, kind: Option) -> Result<()> { - runtime()?.block_on(ipc::spawn_sibling_for_current_agent(&task, kind)) +/// `nebula spawn "" [--kind ] [--worktree [--base +/// ]]` — start a new agent session beside the current one, or in +/// another worktree of its project (see +/// `ipc::spawn_sibling_for_current_agent`). `kind` is the CLI's `--kind`, +/// already parsed where the flag is. +pub fn run_spawn( + task: String, + kind: Option, + worktree: Option, + base: Option, +) -> Result<()> { + runtime()?.block_on(ipc::spawn_sibling_for_current_agent( + &task, kind, worktree, base, + )) } /// `nebula add ` / bare `nebula ` — register a directory as a diff --git a/crates/nebula/src/cli.rs b/crates/nebula/src/cli.rs index 08c6889b..429e6c3f 100644 --- a/crates/nebula/src/cli.rs +++ b/crates/nebula/src/cli.rs @@ -136,16 +136,18 @@ pub(crate) enum Command { /// /// A branch name origin has means origin's copy of it, fetched first: /// `main` is `origin/main`, never this checkout's local branch. A tag, - /// a SHA or a branch origin lacks is used as named. + /// a SHA or a branch origin lacks is used as named. Refused when the + /// branch already exists, since it already has a start point. #[arg(long, value_name = "REF")] base: Option, }, /// Start another agent session beside this one. /// /// Run from inside a nebula agent session; agents run it when you ask for - /// a new nebula session. The new session starts in the same worktree, on - /// the task you name as its first prompt, and shows up on the grid on - /// its own — this session carries on untouched. + /// a new nebula session. The new session starts in the same worktree (or + /// the one `--worktree` names), on the task you name as its first + /// prompt, and shows up on the grid on its own — this session carries + /// on untouched. #[command(after_help = SPAWN_EXAMPLES)] Spawn { /// The task the new session starts on; multiple words need no quotes. @@ -157,6 +159,17 @@ pub(crate) enum Command { /// Defaults to the harness this session is running. #[arg(long, value_name = "KIND", value_parser = parse_agent_kind)] kind: Option, + /// Start the session in this project's worktree on BRANCH instead, + /// creating the worktree when the branch has none; spaces become + /// hyphens. + #[arg(long, value_name = "BRANCH")] + worktree: Option, + /// Start point for a new `--worktree` branch, resolved as + /// `nebula worktree --base` resolves it (default: the + /// `worktree_base_branch` setting, else origin's default branch). + /// Refused when the branch already exists. + #[arg(long, value_name = "REF", requires = "worktree")] + base: Option, }, /// Show files to the user inside this nebula. /// @@ -335,7 +348,10 @@ Examples: const SPAWN_EXAMPLES: &str = "\ Examples: nebula spawn \"port the tests to the new fixture\" - nebula spawn --kind codex \"review the diff on this branch\""; + nebula spawn --kind codex \"review the diff on this branch\" + nebula spawn --worktree fix-login \"fix the login redirect\" + start it in that branch's worktree, made if new + nebula spawn --worktree hotfix --base v0.21.0 \"backport the fix\""; const OPEN_EXAMPLES: &str = "\ Examples: diff --git a/crates/nebula/src/main.rs b/crates/nebula/src/main.rs index 54a8e249..1ba7d5f1 100644 --- a/crates/nebula/src/main.rs +++ b/crates/nebula/src/main.rs @@ -40,7 +40,12 @@ fn main() -> Result<()> { nebula_tui::run_rename(title.join(" "), mode) } Some(Command::Worktree { name, base }) => nebula_tui::run_worktree(name.join(" "), base), - Some(Command::Spawn { task, kind }) => nebula_tui::run_spawn(task.join(" "), kind), + Some(Command::Spawn { + task, + kind, + worktree, + base, + }) => nebula_tui::run_spawn(task.join(" "), kind, worktree, base), Some(Command::Open { files }) => nebula_tui::run_open(files), Some(Command::Browser { port, diff --git a/crates/nebula/tests/e2e_pty.rs b/crates/nebula/tests/e2e_pty.rs index 81033661..948a36c0 100644 --- a/crates/nebula/tests/e2e_pty.rs +++ b/crates/nebula/tests/e2e_pty.rs @@ -4115,6 +4115,123 @@ async fn nebula_spawn_cli_starts_a_sibling_session_in_the_same_worktree() { "agent-3" ); + // `--worktree` starts it in the project's worktree on that branch, + // cutting one first (the words slugified as `nebula worktree` does). + let out = agent_cli( + &env, + &caller, + &["spawn", "--worktree", "feat login", "port the tests"], + ); + assert!( + out.status.success(), + "nebula spawn --worktree failed: {out:?}" + ); + assert!( + String::from_utf8_lossy(&out.stdout).contains("branch \"feat-login\""), + "stdout names the branch: {out:?}" + ); + let worktree_of = |evs: &[ServerEvent]| { + evs.iter().find_map(|e| match e { + ServerEvent::EntityUpserted { + entity: Entity::Worktree(w), + } if w.branch == "feat-login" => Some(w.clone()), + _ => None, + }) + }; + let in_worktree = |evs: &[ServerEvent], worktree: &nebula_core::WorktreeId| { + evs.iter().find_map(|e| match e { + ServerEvent::EntityUpserted { + entity: Entity::Agent(a), + } if &a.worktree_id == worktree && a.alive => Some(a.clone()), + _ => None, + }) + }; + let events = read_events_until(&mut c, SLOW_TIMEOUT, |evs| { + worktree_of(evs).is_some_and(|w| in_worktree(evs, &w.id).is_some()) + }) + .await; + let feat = worktree_of(&events).unwrap(); + assert!(!feat.is_main && feat.path.is_dir(), "a real new checkout"); + let spawned = in_worktree(&events, &feat.id).unwrap(); + assert_eq!(spawned.kind, AgentKind::Claude, "the caller's harness"); + assert_eq!(spawned.name, "agent-1", "named against its own worktree"); + let caller_row = events.iter().rev().find_map(|e| match e { + ServerEvent::EntityUpserted { + entity: Entity::Agent(a), + } if a.id == caller => Some(a.clone()), + _ => None, + }); + assert!( + caller_row.is_none_or(|a| a.worktree_id == main_worktree.id), + "the caller stays in its own worktree" + ); + // Asked again, the same branch reuses that checkout. + let out = agent_cli( + &env, + &caller, + &["spawn", "--worktree", "feat-login", "review it"], + ); + assert!(out.status.success(), "reusing the worktree failed: {out:?}"); + let events = read_events_until(&mut c, SLOW_TIMEOUT, |evs| { + evs.iter().any(|e| { + matches!(e, ServerEvent::EntityUpserted { entity: Entity::Agent(a) } + if a.worktree_id == feat.id && a.name == "agent-2" && a.alive) + }) + }) + .await; + assert!( + worktree_of(&events).is_none(), + "no second worktree for the same branch" + ); + + // A start point for a branch that already has a checkout is refused, + // never silently dropped, and nothing is spawned for it. + let out = agent_cli( + &env, + &caller, + &["spawn", "--worktree", "feat-login", "--base", "main", "x"], + ); + assert!( + !out.status.success(), + "--base on an existing worktree: {out:?}" + ); + assert!( + String::from_utf8_lossy(&out.stderr).contains("already has a worktree"), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + // So is one for a branch kept without a checkout (a deleted worktree's). + let kept = std::process::Command::new("git") + .args(["-C", repo.to_str().unwrap(), "branch", "kept"]) + .output() + .unwrap(); + assert!(kept.status.success(), "git branch: {kept:?}"); + let out = agent_cli( + &env, + &caller, + &["spawn", "--worktree", "kept", "--base", "main", "x"], + ); + assert!(!out.status.success(), "--base on a kept branch: {out:?}"); + assert!( + String::from_utf8_lossy(&out.stderr).contains("already exists"), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + + // `--base` means nothing without `--worktree`, or blank. + let out = agent_cli(&env, &caller, &["spawn", "--base", "main", "x"]); + assert!(!out.status.success(), "--base alone must fail: {out:?}"); + let out = agent_cli( + &env, + &caller, + &["spawn", "--worktree", "feat-login", "--base", " ", "x"], + ); + assert!( + String::from_utf8_lossy(&out.stderr).contains("base is empty"), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + // A bad harness name and a blank task are the CLI's own refusals. let out = agent_cli(&env, &caller, &["spawn", "--kind", "gemini", "x"]); assert!(!out.status.success(), "unknown harness must fail: {out:?}"); diff --git a/docs/commands.md b/docs/commands.md index b7876259..34edd019 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -53,12 +53,17 @@ nebula rename # title the current session (agents run this; --force nebula worktree [name] [--base <ref>] # move the current session into a worktree of its project, # creating the branch if it's new (agents run this when you ask for a # worktree; no name invents one; --base picks a new branch's start point, - # a branch name meaning origin's fetched copy — main is origin/main; - # without it the worktree_base_branch setting, else origin's default) -nebula spawn <task> [--kind <claude|codex|cursor|pi|muse|grok|opencode>] # start a new agent session beside the current - # one, in the same worktree, opening on <task> (agents run this when you - # ask for a new nebula session; --kind defaults to this session's harness; - # custom harnesses launch from the TUI picker and presets, not --kind) + # a branch name meaning origin's fetched copy — main is origin/main — + # and refused for a branch that already exists; without it the + # worktree_base_branch setting, else origin's default) +nebula spawn <task> [--kind <claude|codex|cursor|pi|muse|grok|opencode>] [--worktree <branch> [--base <ref>]] + # start a new agent session beside the current one, in the same worktree, + # opening on <task> (agents run this when you ask for a new nebula session; + # --kind defaults to this session's harness; custom harnesses launch from + # the TUI picker and presets, not --kind); --worktree starts it in the + # project's worktree on <branch> instead, creating it when the branch has + # none (--base as for nebula worktree, refused when the branch already + # exists), and leaves this session where it is nebula open <file>… # show the files in this nebula's FILE TABS — a modal with one tab per # file, the focused one previewed, Enter editing it (agents run this only # when you ask to see a file; text files only — an image or any other diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 1b918a1d..2e244e5f 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -282,7 +282,10 @@ agent beside it — same worktree, same harness, model and effort unless `--kind claude|codex|cursor|pi|muse|grok|opencode` names another — opening on that task as its first prompt, so it is working before you look. The new card appears on the grid on its own (default name, so it titles itself), and the session you - asked from is untouched: no restart, no focus change. Claude learns this from the same appended system + asked from is untouched: no restart, no focus change. Ask for it "in a new worktree" or "on branch + fix-login" and the agent adds `--worktree <branch>`: the session starts in the project's worktree on + that branch — cut first, from the same base `nebula worktree` uses, when there is none — while the + one you asked from stays in its own checkout. Claude learns this from the same appended system prompt as the worktree rule, plus a `Bash(nebula spawn:*)` permission. - **Ask the agent to show you a file and it opens in nebula.** Say "open it" or "show me the examples" and the session runs `nebula open <file>…`; every TUI attached to the daemon raises its file tabs on