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

@ -655,6 +655,19 @@ pub async fn serve_manager_stdio(socket: PathBuf) -> Result<()> {
// Manager tool surface
// -----------------------------------------------------------------------------
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct RequestInitConfigArgs {
/// New sub-agent name (≤9 chars). Queues an InitConfig approval; on
/// approval hive-c0re seeds the proposed config repo at
/// `/agents/<name>/config/agent.nix` with the default template.
/// After the approval the manager can edit and commit the config before
/// calling `request_spawn`.
pub name: String,
/// Optional description shown on the dashboard approval card.
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct RequestSpawnArgs {
/// New sub-agent name (≤9 chars). Queues a Spawn approval; the
@ -842,8 +855,43 @@ impl ManagerServer {
}
#[tool(
description = "Queue a Spawn approval for a brand-new sub-agent. The operator \
approves on the dashboard before the container is actually created."
description = "Step 1 of 2 for creating a new agent: initialise the proposed config \
repo and queue an InitConfig approval. On operator approval hive-c0re seeds \
`/agents/<name>/config/agent.nix` with the default template so the manager can \
customise it before spawning. After the ConfigReady helper event arrives, edit \
agent.nix, commit the changes, then call `request_spawn`. Fails if a config repo \
for this name already exists (use `request_apply_commit` to update an existing agent)."
)]
async fn request_init_config(
&self,
Parameters(args): Parameters<RequestInitConfigArgs>,
) -> String {
let log = format!("{args:?}");
let name = args.name.clone();
run_tool_envelope("request_init_config", log, async move {
let (resp, retries) = self
.dispatch(hive_sh4re::ManagerRequest::RequestInitConfig {
name: args.name,
description: args.description,
})
.await;
annotate_retries(
format_ack(
resp,
"request_init_config",
format!("init_config approval queued for {name}"),
),
retries,
)
})
.await
}
#[tool(
description = "Step 2 of 2 for creating a new agent: queue a Spawn approval after \
the config has been initialised and customised via `request_init_config`. Requires \
a prior approved InitConfig so the manager can review and edit agent.nix first. \
Fails if no proposed config repo exists for the given name."
)]
async fn request_spawn(&self, Parameters(args): Parameters<RequestSpawnArgs>) -> String {
let log = format!("{args:?}");
@ -1177,8 +1225,9 @@ impl ManagerServer {
#[tool_handler(
instructions = "You are the hyperhive manager (hm1nd). You coordinate sub-agents and \
relay between them and the operator. Use `send` to talk to agents/operator, `recv` \
to drain your inbox. Privileged: `request_spawn` (new agent, gated on operator \
approval), `kill` (graceful stop), `request_apply_commit` (config change for \
to drain your inbox. Privileged: `request_init_config` + `request_spawn` (two-step \
new agent creation - init config first, customise agent.nix, then spawn, both \
gated on operator approval), `kill` (graceful stop), `request_apply_commit` (config change for \
any agent including yourself), `ask` (structured question to the operator or a \
sub-agent non-blocking, answer arrives later as a `question_answered` event), \
`answer` (respond to a `question_asked` event directed at you), \
@ -1233,6 +1282,7 @@ pub fn allowed_mcp_tools(flavor: Flavor) -> Vec<String> {
Flavor::Manager => &[
"send",
"recv",
"request_init_config",
"request_spawn",
"kill",
"start",

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,

View file

@ -99,6 +99,12 @@ pub enum ApprovalKind {
ApplyCommit,
/// Create + start a new sub-agent container with the given name.
Spawn,
/// Initialise a new agent's proposed config repo so the manager can
/// customise it before submitting a `RequestSpawn`. On approval
/// hive-c0re seeds `proposed/<name>/` with the default `agent.nix`
/// template but does NOT create the container - that requires a
/// subsequent `RequestSpawn` approval.
InitConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@ -577,6 +583,10 @@ pub enum HelperEvent {
#[serde(default, skip_serializing_if = "Option::is_none")]
tag: Option<String>,
},
/// A new agent's proposed config repo was initialised (post-`InitConfig`
/// approval). The manager can now edit `/agents/<agent>/config/agent.nix`,
/// commit the changes, and submit a `RequestSpawn` to create the container.
ConfigReady { agent: String },
/// A sub-agent's container was stopped (the systemd unit is down;
/// persistent state is unchanged).
Killed { agent: String },
@ -672,9 +682,23 @@ pub enum ManagerRequest {
Recent {
limit: u64,
},
/// Initialise a brand-new agent's proposed config repo and queue an
/// approval for the operator to review. On approval hive-c0re seeds
/// `/agents/<name>/config/` with the default `agent.nix` template,
/// giving the manager RW access so it can customise the config and
/// commit changes before calling `request_spawn`. Fails if a proposed
/// repo for this name already exists (use `request_apply_commit` to
/// update an existing agent's config). Must precede `request_spawn`.
RequestInitConfig {
name: String,
/// Optional description shown on the dashboard approval card.
#[serde(default, skip_serializing_if = "Option::is_none")]
description: Option<String>,
},
/// Submit a spawn request for the user to approve. On approval the host
/// creates and starts the container. Brand-new agent names only — if an
/// agent of the same name already exists, the approval will fail.
/// creates and starts the container. Requires a prior approved
/// `request_init_config` so the manager can customise `agent.nix` first.
/// Fails if the proposed config repo for this name does not exist yet.
RequestSpawn {
name: String,
/// Optional description shown on the dashboard approval card.