two-step agent spawn: request_init_config + request_spawn

This commit is contained in:
damocles 2026-05-20 14:32:54 +02:00 committed by Mara
parent 42437f9c6a
commit 80dd5bb69e
7 changed files with 165 additions and 14 deletions

View file

@ -57,6 +57,18 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
}
finish_approval(&coord, &approval, result, terminal_tag)
}
ApprovalKind::InitConfig => {
// Seed the proposed config repo. Runs synchronously — it's just
// a few git operations with no nixos-container involvement.
let result: Result<()> = async {
lifecycle::setup_proposed(&proposed_dir, &approval.agent).await?;
lifecycle::ensure_claude_dir(&claude_dir)?;
lifecycle::ensure_state_dir(&notes_dir)?;
Ok(())
}
.await;
finish_approval(&coord, &approval, result, None)
}
ApprovalKind::Spawn => {
// Run the spawn in the background so the approve POST returns
// immediately. The dashboard reads `transient` to render a spinner.
@ -144,6 +156,7 @@ fn finish_approval(
let approval_kind = match approval.kind {
ApprovalKind::Spawn => "spawn",
ApprovalKind::ApplyCommit => "apply_commit",
ApprovalKind::InitConfig => "init_config",
};
let sha_short = approval
.fetched_sha
@ -159,12 +172,19 @@ fn finish_approval(
note.clone(),
approval.description.clone(),
);
// For spawn/rebuild approvals, also surface the underlying action so
// the manager knows whether the container actually came up. The
// ApprovalResolved event already carries the same `ok` signal but
// For spawn/rebuild/init_config approvals, also surface the underlying
// action so the manager knows whether the lifecycle step succeeded.
// The ApprovalResolved event already carries the same `ok` signal but
// separating it lets the manager react to the lifecycle change
// without having to special-case approvals.
match approval.kind {
ApprovalKind::InitConfig => {
if ok {
coord.notify_manager(&HelperEvent::ConfigReady {
agent: approval.agent.clone(),
});
}
}
ApprovalKind::Spawn => coord.notify_manager(&HelperEvent::Spawned {
agent: approval.agent.clone(),
ok,
@ -439,6 +459,7 @@ pub async fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()
let approval_kind = match a.kind {
ApprovalKind::Spawn => "spawn",
ApprovalKind::ApplyCommit => "apply_commit",
ApprovalKind::InitConfig => "init_config",
};
let sha_short = sha.as_deref().map(|s| s[..s.len().min(12)].to_owned());
let description = a.description.clone();

View file

@ -307,6 +307,7 @@ fn kind_to_str(kind: ApprovalKind) -> &'static str {
match kind {
ApprovalKind::ApplyCommit => "apply_commit",
ApprovalKind::Spawn => "spawn",
ApprovalKind::InitConfig => "init_config",
}
}
@ -314,6 +315,7 @@ fn kind_from_str(s: &str) -> Result<ApprovalKind> {
Ok(match s {
"apply_commit" => ApprovalKind::ApplyCommit,
"spawn" => ApprovalKind::Spawn,
"init_config" => ApprovalKind::InitConfig,
other => bail!("unknown approval kind '{other}'"),
})
}

View file

@ -563,6 +563,7 @@ fn history_view(a: Approval) -> ApprovalHistoryView {
let kind = match a.kind {
hive_sh4re::ApprovalKind::ApplyCommit => "apply_commit",
hive_sh4re::ApprovalKind::Spawn => "spawn",
hive_sh4re::ApprovalKind::InitConfig => "init_config",
};
ApprovalHistoryView {
id: a.id,
@ -603,6 +604,14 @@ async fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
diff: None,
description: a.description,
},
hive_sh4re::ApprovalKind::InitConfig => ApprovalView {
id: a.id,
agent: a.agent,
kind: "init_config",
sha_short: None,
diff: None,
description: a.description,
},
});
}
out
@ -1736,9 +1745,12 @@ fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<Approval> {
approvals
.into_iter()
.filter(|a| {
// Spawn approvals are for not-yet-existent agents; the proposed
// dir is supposed to be missing.
if matches!(a.kind, hive_sh4re::ApprovalKind::Spawn) {
// Spawn and InitConfig approvals are for not-yet-existent agents;
// the proposed dir is supposed to be missing.
if matches!(
a.kind,
hive_sh4re::ApprovalKind::Spawn | hive_sh4re::ApprovalKind::InitConfig
) {
return true;
}
if Coordinator::agent_proposed_dir(&a.agent).exists() {

View file

@ -471,7 +471,9 @@ pub async fn setup_applied(
/// Create the per-agent Claude credentials dir if missing. Mode 0700 — only
/// root inside the container reads/writes it. Idempotent: existing dirs are
/// left untouched (an agent's OAuth tokens survive `destroy`/recreate).
fn ensure_claude_dir(claude_dir: &Path) -> Result<()> {
/// Public for the `InitConfig` approval path in `actions.rs` which seeds
/// dirs without calling the full `spawn`.
pub fn ensure_claude_dir(claude_dir: &Path) -> Result<()> {
if !claude_dir.exists() {
std::fs::create_dir_all(claude_dir)
.with_context(|| format!("create {}", claude_dir.display()))?;
@ -485,7 +487,9 @@ fn ensure_claude_dir(claude_dir: &Path) -> Result<()> {
Ok(())
}
fn ensure_state_dir(notes_dir: &Path) -> Result<()> {
/// Public for the `InitConfig` approval path in `actions.rs` which seeds
/// dirs without calling the full `spawn`.
pub fn ensure_state_dir(notes_dir: &Path) -> Result<()> {
if !notes_dir.exists() {
std::fs::create_dir_all(notes_dir)
.with_context(|| format!("create {}", notes_dir.display()))?;

View file

@ -164,8 +164,46 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc<Coordinator>) -> ManagerResp
},
}
}
ManagerRequest::RequestInitConfig { name, description } => {
tracing::info!(%name, "manager: request_init_config");
let proposed_dir = crate::coordinator::Coordinator::agent_proposed_dir(name);
if proposed_dir.join(".git").exists() {
return ManagerResponse::Err {
message: format!(
"proposed config repo for '{name}' already exists at {} - \
use request_apply_commit to update an existing agent's config",
proposed_dir.display()
),
};
}
match coord.approvals.submit_kind(
name,
hive_sh4re::ApprovalKind::InitConfig,
"",
description.as_deref(),
) {
Ok(id) => {
tracing::info!(%id, %name, "init_config approval queued");
coord.emit_approval_added(id, name, "init_config", None, None, description.clone());
ManagerResponse::Ok
}
Err(e) => ManagerResponse::Err {
message: format!("{e:#}"),
},
}
}
ManagerRequest::RequestSpawn { name, description } => {
tracing::info!(%name, "manager: request_spawn");
let proposed_dir = crate::coordinator::Coordinator::agent_proposed_dir(name);
if !proposed_dir.join(".git").exists() {
return ManagerResponse::Err {
message: format!(
"no proposed config repo found for '{name}' - \
call request_init_config first to initialise and customise \
the config before spawning"
),
};
}
match coord.approvals.submit_kind(
name,
hive_sh4re::ApprovalKind::Spawn,