two-step agent spawn: request_init_config + request_spawn
This commit is contained in:
parent
42437f9c6a
commit
80dd5bb69e
7 changed files with 165 additions and 14 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(¬es_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();
|
||||
|
|
|
|||
|
|
@ -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}'"),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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()))?;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue