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:
parent
84c89de71c
commit
a0b1846006
24 changed files with 2617 additions and 19 deletions
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue