Watch
0
0
Fork
You've already forked hyperhive
0

swarm: named scheduled prompts for one or more agents, fired in each agent

swarm-controller keeps every scheduled prompt (id, name, agents, body,
anchor, interval, paused) as one document under one key of a new
`schedule-definitions` KV bucket, so each change is a single
compare-and-set and a prompt exists for all of its agents or none. It
writes each agent's key of a new `agent-schedules` bucket as the prompts
naming that agent, without names or other agents, after every change and
on a one-minute reconcile pass. It is the only writer of both, behind
GET/POST /api/schedules and PATCH/DELETE /api/schedules/{id}.
`swarmctl schedule add|list|edit|pause|resume|rm` drives those routes over
the controller socket.

An agent's own queue credential is granted one more subject, the direct
get of its own key in `agent-schedules`. A KV watch is not granted: its
consumer create subject carries no key, so it would cover every agent's.

hive-agent's new schedule ticker reads that key every 10 seconds and fires
due slots as the same `schedule:<id>` todo hive-c0re's worker pushes. Fire
times are anchor + k*interval; the last fired slot per schedule id is kept
in the state db, so a body edit or list refresh never moves or refires a
slot, downtime fires the latest missed slot once, a restart refires
nothing, a one-shot fires once, and paused slots are skipped.

Refs #4914
This commit is contained in:
atlas 2026-10-04 16:29:44 +02:00 • committed by mara
commit a0b1846006
24 changed files with 2617 additions and 19 deletions

View file

@ -29,6 +29,7 @@
mod agent;
mod forge;
mod schedule;
mod users;
use std::fs::{self, File, Permissions};
@ -119,6 +120,16 @@ enum Verb {
#[command(subcommand)]
command: AgentVerb,
},
/// Manage scheduled prompts.
///
/// The swarm keeps each prompt, and every agent it names fires it in its
/// own container. Fire times are the schedule's start time plus whole
/// intervals: changing the body never moves them, and an agent that was
/// down fires the latest missed one once.
Schedule {
#[command(subcommand)]
command: ScheduleVerb,
},
/// Manage subjects in the swarm's SSO provider.
User {
#[command(subcommand)]
@ -276,6 +287,119 @@ struct ForgeMakeAdminArgs {
controller_socket: Option<PathBuf>,
}
#[derive(Subcommand)]
enum ScheduleVerb {
/// Add a scheduled prompt and print it with its new id.
///
/// Without `--every` it fires once. Without `--at` its first fire is now.
Add(ScheduleAddArgs),
/// List scheduled prompts, one row each, with their agents and next fire
/// time.
List(ScheduleListArgs),
/// Change a scheduled prompt.
///
/// Only the flags given change, and the id stays. `--at` and `--every`
/// move the fire times; `--name`, `--body` and the agent flags don't.
Edit(ScheduleEditArgs),
/// Stop a scheduled prompt firing. Times that pass while it's paused
/// aren't fired when it resumes.
Pause(ScheduleIdArgs),
/// Let a paused scheduled prompt fire again, from its next time on.
Resume(ScheduleIdArgs),
/// Remove a scheduled prompt from every agent it names.
Rm(ScheduleIdArgs),
}
#[derive(Args)]
struct ScheduleAddArgs {
/// The prompt's title.
#[arg(long, value_name = "TEXT")]
name: String,
/// Agent to fire it in. Repeatable; at least one.
#[arg(long = "agent", value_name = "AGENT", required = true)]
agents: Vec<String>,
/// The prompt, delivered to each agent verbatim.
#[arg(long, value_name = "TEXT")]
body: String,
/// First fire time: an RFC 3339 time with its offset, such as
/// `2026-10-05T09:00:00+02:00`, or unix seconds. Defaults to now.
#[arg(long, value_name = "TIME")]
at: Option<String>,
/// Time between fires: a count with a unit of `s`, `m`, `h` or `d`,
/// such as `1h` or `7d`. Leave out for a one-shot.
#[arg(long, value_name = "DURATION")]
every: Option<String>,
/// Add it paused.
#[arg(long)]
paused: bool,
#[command(flatten)]
socket: ControllerSocketArg,
}
#[derive(Args)]
struct ScheduleListArgs {
/// Show only the prompts that fire in this agent.
#[arg(long, value_name = "AGENT")]
agent: Option<String>,
#[command(flatten)]
socket: ControllerSocketArg,
}
#[derive(Args)]
struct ScheduleEditArgs {
/// The prompt's id, as `schedule list` shows it.
id: String,
/// New title.
#[arg(long, value_name = "TEXT")]
name: Option<String>,
/// New prompt text.
#[arg(long, value_name = "TEXT")]
body: Option<String>,
/// New first fire time, in the form `schedule add --at` takes.
#[arg(long, value_name = "TIME")]
at: Option<String>,
/// New time between fires, in the form `schedule add --every` takes.
#[arg(long, value_name = "DURATION", conflicts_with = "once")]
every: Option<String>,
/// Make it a one-shot.
#[arg(long)]
once: bool,
/// Agent to start firing it in. Repeatable.
#[arg(long = "add-agent", value_name = "AGENT")]
add_agents: Vec<String>,
/// Agent to stop firing it in. Repeatable. Fails if the agent isn't a
/// target, or if no agent would remain.
#[arg(long = "remove-agent", value_name = "AGENT")]
remove_agents: Vec<String>,
#[command(flatten)]
socket: ControllerSocketArg,
}
#[derive(Args)]
struct ScheduleIdArgs {
/// The prompt's id, as `schedule list` shows it.
id: String,
#[command(flatten)]
socket: ControllerSocketArg,
}
#[derive(Args)]
struct ControllerSocketArg {
/// swarm-controller's unix socket.
///
/// Supplied by the nix module that installs this binary, from the same
/// `socketPath` option the daemon binds; falls back to
/// `SWARM_CONTROLLER_SOCKET`.
#[arg(long, value_name = "PATH")]
controller_socket: Option<PathBuf>,
}
impl ControllerSocketArg {
fn resolve(self) -> Result<PathBuf> {
path_from(self.controller_socket, "SWARM_CONTROLLER_SOCKET")
}
}
#[derive(Subcommand)]
enum UserVerb {
/// Add a user, generating a password for them.
@ -379,6 +503,8 @@ fn main() -> Result<()> {
let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?;
forge::make_admin(&socket, &args.name)
}
// Same socket-resolution reasoning as `Create` above.
Verb::Schedule { command } => schedule(command),
// Resolved lazily, inside the one arm that actually touches the
// deployment env vars — see the `MarkdownDocs` doc comment above
// for why an unconditional resolve up front would be wrong.
@ -418,6 +544,38 @@ fn main() -> Result<()> {
}
}
fn schedule(command: ScheduleVerb) -> Result<()> {
match command {
ScheduleVerb::Add(a) => schedule::add(
&a.socket.resolve()?,
&schedule::AddFlags {
name: &a.name,
agents: &a.agents,
body: &a.body,
at: a.at.as_deref(),
every: a.every.as_deref(),
paused: a.paused,
},
),
ScheduleVerb::List(a) => schedule::list(&a.socket.resolve()?, a.agent.as_deref()),
ScheduleVerb::Edit(a) => {
let edit = schedule::EditRequest::new(schedule::EditFlags {
name: a.name,
body: a.body,
at: a.at.as_deref(),
every: a.every.as_deref(),
once: a.once,
add_agents: a.add_agents,
remove_agents: a.remove_agents,
})?;
schedule::edit(&a.socket.resolve()?, &a.id, &edit)
}
ScheduleVerb::Pause(a) => schedule::set_paused(&a.socket.resolve()?, &a.id, true),
ScheduleVerb::Resume(a) => schedule::set_paused(&a.socket.resolve()?, &a.id, false),
ScheduleVerb::Rm(a) => schedule::remove(&a.socket.resolve()?, &a.id),
}
}
fn user_add(paths: &Paths, args: AddArgs) -> Result<()> {
users::validate_username(&args.username)?;
@ -713,6 +871,23 @@ mod tests {
Cli::command().debug_assert();
}
/// A schedule with no agent would fire nowhere, so `--agent` is required
/// at parse time, and repeats collect into the target list.
#[test]
fn schedule_add_takes_one_or_more_agents() {
let base = ["swarmctl", "schedule", "add", "--name", "n", "--body", "b"];
assert!(Cli::try_parse_from(base).is_err());
let cli = Cli::try_parse_from(base.iter().copied().chain(["--agent", "a", "--agent", "b"]))
.expect("parses");
let Verb::Schedule {
command: ScheduleVerb::Add(args),
} = cli.command
else {
panic!("parsed as another verb");
};
assert_eq!(args.agents, ["a", "b"]);
}
/// `--hive` carries the address the agent is deployed to and the
/// endpoint has no default for it, so omitting it has to fail at parse
/// time rather than reach the controller as an empty string.