Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions crates/nebula-core/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -225,17 +225,29 @@ pub enum ClientRequest {
base: Option<String>,
},
/// `nebula spawn "<task>"`, 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<AgentKind>,
starting_prompt: String,
/// `--worktree <branch>`: 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<String>,
/// `--base <ref>`: 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<String>,
},
/// `nebula open <file>…`, run by the agent from inside its own session:
/// show these files to the user in every attached TUI's FILE TABS β€”
Expand Down
33 changes: 33 additions & 0 deletions crates/nebula-daemon/src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,18 @@ pub async fn add_worktree_off_default(repo: &Path, branch: &str) -> Result<PathB
/// decides. The rewrite does not wait on the fetch succeeding: offline,
/// `origin/main` as last fetched is still never behind the local branch's
/// last pull, and the daemon log says the fetch failed.
///
/// A `branch` that already exists locally is refused: it already has a
/// start point, and `add_worktree_inner`'s fallback would check it out
/// with `base` dropped without a word β€” so a session reported as started
/// off `base` would run on the branch's own history instead.
pub async fn add_worktree_off_ref(repo: &Path, branch: &str, base: &str) -> Result<PathBuf> {
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,
Expand Down Expand Up @@ -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.
Expand Down
102 changes: 83 additions & 19 deletions crates/nebula-daemon/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self>,
project_id: &ProjectId,
branch: &str,
base: Option<&str>,
) -> Result<Worktree> {
let project = self
.store
.get_project(project_id)?
Expand Down Expand Up @@ -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<Self>,
project_id: &ProjectId,
branch: &str,
base: Option<&str>,
) -> Result<Worktree> {
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
Expand Down Expand Up @@ -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<Self>, spec: CreateAgentSpec) -> Result<EntityId> {
let CreateAgentSpec {
worktree: worktree_id,
Expand Down Expand Up @@ -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(&current.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(&current.project_id, branch, base)
.await?;
if target.id == current.id {
return Ok((target, EnterOutcome::AlreadyThere));
}
Expand Down Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion crates/nebula-daemon/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -471,10 +471,19 @@ async fn handle_client(daemon: Arc<Daemon>, 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!(
Expand Down
Loading