Watch
0
0
Fork
You've already forked hyperhive
0

hive-c0re stops owning scheduled prompts; agents lose the schedule tools

Scheduled prompts are swarm-level now (swarmctl schedule, #4936). The hive
keeps the rows it has only so the operator can copy them over.

- hive-c0re no longer fires anything: the scheduled-prompts worker is gone,
  and with it every create/edit/cancel/pause/resume/fire path (dashboard
  endpoints, agent-socket verbs, the SchedulePrompt approval kind and its
  approve handler). The store keeps open + list; GET /api/schedules stays.
- A v6 approvals migration drops schedule_prompt rows.
- Nothing submits approvals any more, so the submit path goes too:
  Approvals::submit_kind (now a test fixture), the ApprovalAdded dashboard
  event and its emitter.
- Coordinator::push_todo, whose only caller was the worker, goes, and with
  it hive_host_sock::agent_todo_socket and hive-c0re's hive-agent-sock /
  hive-sock-client deps.
- The schedules_changed dashboard event goes; nothing mutates the rows.
- Agents lose request_schedule_prompt, cancel_schedule, fire_schedule_now,
  edit_schedule and list_schedules, the `scheduling` tool group, their
  system-prompt line and their transcript formatters. `remind` is unchanged.
This commit is contained in:
atlas 2026-10-04 21:23:18 +02:00
commit c3a54bbdd0
31 changed files with 273 additions and 3124 deletions

View file

@ -3,8 +3,8 @@
//! authenticates the caller: connecting to `<.../agents/foo/mcp.sock>` means
//! you are `foo`; the manager socket simply serves as `ruth`. There is no
//! privilege flag — both transports run the same [`serve`] / [`dispatch`]
//! code, and authority derives uniformly from the caller's identity:
//! tool-group membership for the orchestration verbs. An agent-targeting
//! code, and authority derives uniformly from the caller's identity
//! (tool-group membership for cancelling an approval). An agent-targeting
//! verb may name any agent, so `ruth` reaches every agent exactly the way
//! every other agent does, not via any hardcoded name match.
@ -21,16 +21,6 @@ use tokio::task::JoinHandle;
use crate::coordinator::Coordinator;
mod schedules;
pub(crate) use schedules::filter_ghost_schedule_targets;
pub use schedules::schedule_to_wire_public;
use schedules::{
EditSchedulePatch, handle_cancel_schedule, handle_edit_schedule, handle_fire_schedule_now,
handle_list_schedules, handle_request_schedule_prompt,
};
pub struct AgentSocket {
pub path: PathBuf,
pub handle: JoinHandle<()>,
@ -275,21 +265,15 @@ pub(crate) fn recv_timeout(wait_seconds: Option<u64>) -> std::time::Duration {
}
}
/// Handle the subset of `Request` variants that are identical on both
/// the agent socket and the manager socket. Returns `Some(response)` for
/// every variant it handles; returns `None` for the remaining variants —
/// `GetLooseEnds` and the orchestration verbs (schedules), which need
/// per-verb tool-group gating — or for host-admin / unknown requests
/// invalid on either socket.
///
/// The unified `dispatch` calls this first; the remaining arms (which gate
/// on topology / capabilities / tool-groups) are handled there.
pub(crate) async fn dispatch_shared(
/// Unified dispatch for every socket connection — per-agent sockets and the
/// (now pure-transport) manager socket alike. There is no privilege bit;
/// authority derives uniformly from the caller's identity.
async fn dispatch(
req: &hive_core_agent_sock::Request,
agent: &str,
coord: &Arc<Coordinator>,
) -> Option<hive_core_agent_sock::Response> {
Some(match req {
) -> hive_core_agent_sock::Response {
match req {
hive_core_agent_sock::Request::Send {
to,
body,
@ -331,9 +315,8 @@ pub(crate) async fn dispatch_shared(
coord.clear_pause_pending(agent);
hive_core_agent_sock::Response::Ok
}
// Not a shared variant.
_ => return None,
})
hive_core_agent_sock::Request::GetLooseEnds => handle_get_loose_ends(coord, agent),
}
}
/// `Recv` — long-poll the broker for up to `max` messages (capped at
@ -572,104 +555,6 @@ fn handle_requeue_inflight(
}
}
/// Unified dispatch for every socket connection — per-agent sockets and the
/// (now pure-transport) manager socket alike. There is no privilege bit;
/// authority derives uniformly from the caller's identity: hive-wide
/// orchestration verbs (schedules) require the matching tool-group (the
/// grantable capability).
async fn dispatch(req: &Request, agent: &str, coord: &Arc<Coordinator>) -> Response {
if let Some(resp) = dispatch_shared(req, agent, coord).await {
return resp;
}
match req {
Request::GetLooseEnds => handle_get_loose_ends(coord, agent),
// Orchestration verbs — gated per-verb on tool-group membership
// (see `dispatch_orchestration`).
_ => dispatch_orchestration(req, agent, coord).await,
}
}
/// Handle the hive-wide orchestration verbs (scheduling). No blanket socket
/// gate: each verb gates on the grantable capability that authorises it —
/// the matching tool-group (`scheduling`). Any other variant is a
/// host-admin / unknown request invalid on either socket.
async fn dispatch_orchestration(req: &Request, agent: &str, coord: &Arc<Coordinator>) -> Response {
match req {
Request::RequestSchedulePrompt(payload) => {
if let Some(err) = require_group(agent, "scheduling", "schedule a prompt") {
return err;
}
handle_request_schedule_prompt(coord, agent, payload)
}
Request::CancelSchedule { id, targets } => {
if let Some(err) = require_group(agent, "scheduling", "cancel a schedule") {
return err;
}
handle_cancel_schedule(coord, agent, *id, targets.as_deref())
}
Request::EditSchedule {
id,
body,
description,
interval_seconds,
next_fire_at_unix,
targets_add,
targets_remove,
} => {
if let Some(err) = require_group(agent, "scheduling", "edit a schedule") {
return err;
}
handle_edit_schedule(
coord,
agent,
*id,
EditSchedulePatch {
body: body.clone(),
description: description.clone(),
interval_seconds: *interval_seconds,
next_fire_at_unix: *next_fire_at_unix,
targets_add: targets_add.clone(),
targets_remove: targets_remove.clone(),
},
)
}
Request::ListSchedules => {
if let Some(err) = require_group(agent, "scheduling", "list schedules") {
return err;
}
handle_list_schedules(coord, agent)
}
Request::FireScheduleNow { id } => {
if let Some(err) = require_group(agent, "scheduling", "fire a schedule") {
return err;
}
handle_fire_schedule_now(coord, agent, *id).await
}
// Host-admin-only / unknown variants: never valid on either socket.
_ => Response::Err {
message: "request not handled on this socket".to_owned(),
},
}
}
/// Capability guard for the hive-wide orchestration verbs: the caller must
/// hold the given tool-group. The tool-group (c0re-owned `tool_groups.json`,
/// read server-side via [`crate::tool_groups::groups_for`]) is the grantable
/// capability — granting it to an orchestrator (e.g. the root) authorises
/// these verbs without any positional/hardcoded privilege. `action` is the
/// verb phrase for the message.
fn require_group(agent: &str, group: &str, action: &str) -> Option<Response> {
match crate::tool_groups::groups_for(agent) {
Ok(groups) if groups.iter().any(|g| g == group) => None,
Ok(_) => Some(Response::Err {
message: format!("agent `{agent}` cannot {action}: requires the `{group}` tool group"),
}),
Err(e) => Some(Response::Err {
message: format!("agent `{agent}` cannot {action}: {e}"),
}),
}
}
/// `GetLooseEnds` — always the caller's own loose ends.
fn handle_get_loose_ends(coord: &Arc<Coordinator>, agent: &str) -> Response {
match crate::loose_ends::for_agent(coord, agent) {
@ -767,10 +652,9 @@ fn check_can_cancel_approval(canceller: &str) -> Result<(), String> {
}
}
/// Common Send handler shared between dispatch arms. Applies the
/// `Send` handler. Applies the
/// 4 KiB body cap, then routes broadcast (`to == "*"`) / unicast through
/// their respective broker calls.
/// `pub(crate)` so `dispatch_shared` can use it across both socket paths.
pub(crate) fn handle_send(
coord: &Arc<Coordinator>,
agent: &str,
@ -837,6 +721,21 @@ pub(crate) fn handle_send(
mod tests {
use super::*;
/// A real `Coordinator` over a throwaway sqlite dir. `serve` takes an
/// `Arc<Coordinator>`, so there is no lighter way in; `open` touches
/// nothing outside the db path it is handed.
fn coordinator() -> (tempfile::TempDir, Arc<Coordinator>) {
let dir = tempfile::tempdir().expect("tempdir");
let coord = Coordinator::open(
&dir.path().join("broker.sqlite"),
crate::coordinator::HiveEnv::default(),
crate::hive_stats::PriceTable::new(),
1,
)
.expect("open coordinator");
(dir, Arc::new(coord))
}
#[test]
fn approval_cancel_rejects_callers_without_the_approvals_group() {
// A caller that doesn't hold the `approvals` tool-group must not be
@ -869,7 +768,7 @@ mod tests {
#[tokio::test]
async fn serve_answers_an_over_long_line_with_an_error_and_closes() {
let (_dir, coord) = schedules::tests::coordinator();
let (_dir, coord) = coordinator();
let (client, server) = UnixStream::pair().expect("socketpair");
let handler = tokio::spawn(serve(server, "iris".to_owned(), coord));
let (read, mut write) = client.into_split();