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
|
// 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)]
|
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||||
pub struct RequestSpawnArgs {
|
pub struct RequestSpawnArgs {
|
||||||
/// New sub-agent name (≤9 chars). Queues a Spawn approval; the
|
/// New sub-agent name (≤9 chars). Queues a Spawn approval; the
|
||||||
|
|
@ -842,8 +855,43 @@ impl ManagerServer {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
description = "Queue a Spawn approval for a brand-new sub-agent. The operator \
|
description = "Step 1 of 2 for creating a new agent: initialise the proposed config \
|
||||||
approves on the dashboard before the container is actually created."
|
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 {
|
async fn request_spawn(&self, Parameters(args): Parameters<RequestSpawnArgs>) -> String {
|
||||||
let log = format!("{args:?}");
|
let log = format!("{args:?}");
|
||||||
|
|
@ -1177,8 +1225,9 @@ impl ManagerServer {
|
||||||
#[tool_handler(
|
#[tool_handler(
|
||||||
instructions = "You are the hyperhive manager (hm1nd). You coordinate sub-agents and \
|
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` \
|
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 \
|
to drain your inbox. Privileged: `request_init_config` + `request_spawn` (two-step \
|
||||||
approval), `kill` (graceful stop), `request_apply_commit` (config change for \
|
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 \
|
any agent including yourself), `ask` (structured question to the operator or a \
|
||||||
sub-agent — non-blocking, answer arrives later as a `question_answered` event), \
|
sub-agent — non-blocking, answer arrives later as a `question_answered` event), \
|
||||||
`answer` (respond to a `question_asked` event directed at you), \
|
`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 => &[
|
Flavor::Manager => &[
|
||||||
"send",
|
"send",
|
||||||
"recv",
|
"recv",
|
||||||
|
"request_init_config",
|
||||||
"request_spawn",
|
"request_spawn",
|
||||||
"kill",
|
"kill",
|
||||||
"start",
|
"start",
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,18 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
|
||||||
}
|
}
|
||||||
finish_approval(&coord, &approval, result, terminal_tag)
|
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 => {
|
ApprovalKind::Spawn => {
|
||||||
// Run the spawn in the background so the approve POST returns
|
// Run the spawn in the background so the approve POST returns
|
||||||
// immediately. The dashboard reads `transient` to render a spinner.
|
// immediately. The dashboard reads `transient` to render a spinner.
|
||||||
|
|
@ -144,6 +156,7 @@ fn finish_approval(
|
||||||
let approval_kind = match approval.kind {
|
let approval_kind = match approval.kind {
|
||||||
ApprovalKind::Spawn => "spawn",
|
ApprovalKind::Spawn => "spawn",
|
||||||
ApprovalKind::ApplyCommit => "apply_commit",
|
ApprovalKind::ApplyCommit => "apply_commit",
|
||||||
|
ApprovalKind::InitConfig => "init_config",
|
||||||
};
|
};
|
||||||
let sha_short = approval
|
let sha_short = approval
|
||||||
.fetched_sha
|
.fetched_sha
|
||||||
|
|
@ -159,12 +172,19 @@ fn finish_approval(
|
||||||
note.clone(),
|
note.clone(),
|
||||||
approval.description.clone(),
|
approval.description.clone(),
|
||||||
);
|
);
|
||||||
// For spawn/rebuild approvals, also surface the underlying action so
|
// For spawn/rebuild/init_config approvals, also surface the underlying
|
||||||
// the manager knows whether the container actually came up. The
|
// action so the manager knows whether the lifecycle step succeeded.
|
||||||
// ApprovalResolved event already carries the same `ok` signal but
|
// The ApprovalResolved event already carries the same `ok` signal but
|
||||||
// separating it lets the manager react to the lifecycle change
|
// separating it lets the manager react to the lifecycle change
|
||||||
// without having to special-case approvals.
|
// without having to special-case approvals.
|
||||||
match approval.kind {
|
match approval.kind {
|
||||||
|
ApprovalKind::InitConfig => {
|
||||||
|
if ok {
|
||||||
|
coord.notify_manager(&HelperEvent::ConfigReady {
|
||||||
|
agent: approval.agent.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
ApprovalKind::Spawn => coord.notify_manager(&HelperEvent::Spawned {
|
ApprovalKind::Spawn => coord.notify_manager(&HelperEvent::Spawned {
|
||||||
agent: approval.agent.clone(),
|
agent: approval.agent.clone(),
|
||||||
ok,
|
ok,
|
||||||
|
|
@ -439,6 +459,7 @@ pub async fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()
|
||||||
let approval_kind = match a.kind {
|
let approval_kind = match a.kind {
|
||||||
ApprovalKind::Spawn => "spawn",
|
ApprovalKind::Spawn => "spawn",
|
||||||
ApprovalKind::ApplyCommit => "apply_commit",
|
ApprovalKind::ApplyCommit => "apply_commit",
|
||||||
|
ApprovalKind::InitConfig => "init_config",
|
||||||
};
|
};
|
||||||
let sha_short = sha.as_deref().map(|s| s[..s.len().min(12)].to_owned());
|
let sha_short = sha.as_deref().map(|s| s[..s.len().min(12)].to_owned());
|
||||||
let description = a.description.clone();
|
let description = a.description.clone();
|
||||||
|
|
|
||||||
|
|
@ -307,6 +307,7 @@ fn kind_to_str(kind: ApprovalKind) -> &'static str {
|
||||||
match kind {
|
match kind {
|
||||||
ApprovalKind::ApplyCommit => "apply_commit",
|
ApprovalKind::ApplyCommit => "apply_commit",
|
||||||
ApprovalKind::Spawn => "spawn",
|
ApprovalKind::Spawn => "spawn",
|
||||||
|
ApprovalKind::InitConfig => "init_config",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -314,6 +315,7 @@ fn kind_from_str(s: &str) -> Result<ApprovalKind> {
|
||||||
Ok(match s {
|
Ok(match s {
|
||||||
"apply_commit" => ApprovalKind::ApplyCommit,
|
"apply_commit" => ApprovalKind::ApplyCommit,
|
||||||
"spawn" => ApprovalKind::Spawn,
|
"spawn" => ApprovalKind::Spawn,
|
||||||
|
"init_config" => ApprovalKind::InitConfig,
|
||||||
other => bail!("unknown approval kind '{other}'"),
|
other => bail!("unknown approval kind '{other}'"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -563,6 +563,7 @@ fn history_view(a: Approval) -> ApprovalHistoryView {
|
||||||
let kind = match a.kind {
|
let kind = match a.kind {
|
||||||
hive_sh4re::ApprovalKind::ApplyCommit => "apply_commit",
|
hive_sh4re::ApprovalKind::ApplyCommit => "apply_commit",
|
||||||
hive_sh4re::ApprovalKind::Spawn => "spawn",
|
hive_sh4re::ApprovalKind::Spawn => "spawn",
|
||||||
|
hive_sh4re::ApprovalKind::InitConfig => "init_config",
|
||||||
};
|
};
|
||||||
ApprovalHistoryView {
|
ApprovalHistoryView {
|
||||||
id: a.id,
|
id: a.id,
|
||||||
|
|
@ -603,6 +604,14 @@ async fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
|
||||||
diff: None,
|
diff: None,
|
||||||
description: a.description,
|
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
|
out
|
||||||
|
|
@ -1736,9 +1745,12 @@ fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<Approval> {
|
||||||
approvals
|
approvals
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|a| {
|
.filter(|a| {
|
||||||
// Spawn approvals are for not-yet-existent agents; the proposed
|
// Spawn and InitConfig approvals are for not-yet-existent agents;
|
||||||
// dir is supposed to be missing.
|
// the proposed dir is supposed to be missing.
|
||||||
if matches!(a.kind, hive_sh4re::ApprovalKind::Spawn) {
|
if matches!(
|
||||||
|
a.kind,
|
||||||
|
hive_sh4re::ApprovalKind::Spawn | hive_sh4re::ApprovalKind::InitConfig
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if Coordinator::agent_proposed_dir(&a.agent).exists() {
|
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
|
/// Create the per-agent Claude credentials dir if missing. Mode 0700 — only
|
||||||
/// root inside the container reads/writes it. Idempotent: existing dirs are
|
/// root inside the container reads/writes it. Idempotent: existing dirs are
|
||||||
/// left untouched (an agent's OAuth tokens survive `destroy`/recreate).
|
/// 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() {
|
if !claude_dir.exists() {
|
||||||
std::fs::create_dir_all(claude_dir)
|
std::fs::create_dir_all(claude_dir)
|
||||||
.with_context(|| format!("create {}", claude_dir.display()))?;
|
.with_context(|| format!("create {}", claude_dir.display()))?;
|
||||||
|
|
@ -485,7 +487,9 @@ fn ensure_claude_dir(claude_dir: &Path) -> Result<()> {
|
||||||
Ok(())
|
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() {
|
if !notes_dir.exists() {
|
||||||
std::fs::create_dir_all(notes_dir)
|
std::fs::create_dir_all(notes_dir)
|
||||||
.with_context(|| format!("create {}", notes_dir.display()))?;
|
.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 } => {
|
ManagerRequest::RequestSpawn { name, description } => {
|
||||||
tracing::info!(%name, "manager: request_spawn");
|
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(
|
match coord.approvals.submit_kind(
|
||||||
name,
|
name,
|
||||||
hive_sh4re::ApprovalKind::Spawn,
|
hive_sh4re::ApprovalKind::Spawn,
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,12 @@ pub enum ApprovalKind {
|
||||||
ApplyCommit,
|
ApplyCommit,
|
||||||
/// Create + start a new sub-agent container with the given name.
|
/// Create + start a new sub-agent container with the given name.
|
||||||
Spawn,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
|
@ -577,6 +583,10 @@ pub enum HelperEvent {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
tag: Option<String>,
|
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;
|
/// A sub-agent's container was stopped (the systemd unit is down;
|
||||||
/// persistent state is unchanged).
|
/// persistent state is unchanged).
|
||||||
Killed { agent: String },
|
Killed { agent: String },
|
||||||
|
|
@ -672,9 +682,23 @@ pub enum ManagerRequest {
|
||||||
Recent {
|
Recent {
|
||||||
limit: u64,
|
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
|
/// 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
|
/// creates and starts the container. Requires a prior approved
|
||||||
/// agent of the same name already exists, the approval will fail.
|
/// `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 {
|
RequestSpawn {
|
||||||
name: String,
|
name: String,
|
||||||
/// Optional description shown on the dashboard approval card.
|
/// Optional description shown on the dashboard approval card.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue