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
|
|
@ -57,6 +57,7 @@ mod matrix_account;
|
|||
mod otel_http_client;
|
||||
mod queue_identity;
|
||||
mod read_policy;
|
||||
mod schedules;
|
||||
mod status;
|
||||
mod store;
|
||||
mod subagent_term;
|
||||
|
|
@ -616,6 +617,7 @@ fn socket_path() -> PathBuf {
|
|||
(name = "webhook", description = "swarm-wide forge webhook receipt"),
|
||||
(name = "repos", description = "forge repo browsing (swarm-ui's issue-report page)"),
|
||||
(name = "forge", description = "forge site admins (`swarmctl forge make-admin`)"),
|
||||
(name = "schedules", description = "scheduled prompts (`swarmctl schedule`)"),
|
||||
)
|
||||
)]
|
||||
struct ApiDoc;
|
||||
|
|
@ -933,8 +935,8 @@ fn error_problem(status: axum::http::StatusCode, detail: &str) -> problem_detail
|
|||
problem_details::ProblemDetails::from_status_code(status).with_detail(detail)
|
||||
}
|
||||
|
||||
/// The status a [`wanted::WantedWriter`] or [`agent_icon::AgentIconReader`]
|
||||
/// failure should answer with.
|
||||
/// The status a [`wanted::WantedWriter`], [`agent_icon::AgentIconReader`] or
|
||||
/// `schedules` queue failure should answer with.
|
||||
///
|
||||
/// `WantedWriter::view`/`set` call `ensure_connected` internally and
|
||||
/// propagate through `anyhow`, so the concrete
|
||||
|
|
@ -945,7 +947,7 @@ fn error_problem(status: axum::http::StatusCode, detail: &str) -> problem_detail
|
|||
/// is reconnecting, or hasn't yet) and every other queue-backed route here
|
||||
/// already answers 503 for it; anything else is a genuine failure to publish
|
||||
/// or read, which stays 500.
|
||||
fn wanted_error_status(e: &anyhow::Error) -> axum::http::StatusCode {
|
||||
pub(crate) fn wanted_error_status(e: &anyhow::Error) -> axum::http::StatusCode {
|
||||
match e.downcast_ref::<swarm_queue_client::Error>() {
|
||||
Some(swarm_queue_client::Error::NotConnected(_)) => {
|
||||
axum::http::StatusCode::SERVICE_UNAVAILABLE
|
||||
|
|
@ -2409,6 +2411,23 @@ fn spawn_subagent_streams(status: Option<&Arc<status::StatusReader>>, hives: &[H
|
|||
subagent_term::spawn(status.queue_client(), wanted, hives);
|
||||
}
|
||||
|
||||
/// The per-agent background passes: subagent streams and schedule keys.
|
||||
/// Lifted out of `main` for `clippy::too_many_lines`.
|
||||
fn spawn_per_agent_upkeep(status: Option<&Arc<status::StatusReader>>, hives: &[HiveEntry]) {
|
||||
spawn_subagent_streams(status, hives);
|
||||
spawn_schedule_reconcile(status, hives);
|
||||
}
|
||||
|
||||
/// Start rewriting every agent's schedule key from the definitions when a
|
||||
/// swarm queue is wired up. Same shape as [`spawn_subagent_streams`].
|
||||
fn spawn_schedule_reconcile(status: Option<&Arc<status::StatusReader>>, hives: &[HiveEntry]) {
|
||||
let (Some(status), Some(wanted)) = (status, wanted_writer(status)) else {
|
||||
return;
|
||||
};
|
||||
let hives = hives.iter().map(|h| h.name.clone()).collect();
|
||||
schedules::spawn_reconcile(status.queue_client(), wanted, hives);
|
||||
}
|
||||
|
||||
/// Check an agent's forge token now, and mint one if it is missing or stale.
|
||||
///
|
||||
/// The periodic pass (`forge::agent_token::spawn`) does the same every five
|
||||
|
|
@ -2937,7 +2956,7 @@ async fn main() -> Result<()> {
|
|||
let state_forge = keep_forge_for_state(forge_client, webhook_secret.clone());
|
||||
|
||||
spawn_agent_renewal(&jobq, wanted_writer(status.as_ref()), &hives);
|
||||
spawn_subagent_streams(status.as_ref(), &hives);
|
||||
spawn_per_agent_upkeep(status.as_ref(), &hives);
|
||||
// Before serving, because a hive whose role does not exist cannot log in,
|
||||
// and one whose policy does not exist logs in able to read nothing —
|
||||
// either way it cannot collect what this daemon writes for it. A store
|
||||
|
|
@ -3016,6 +3035,11 @@ fn build_app(state: AppState) -> axum::Router {
|
|||
.routes(routes!(subagent_term::stream_subagent_term))
|
||||
.routes(routes!(agent_state_stream::stream_agent_state))
|
||||
.routes(routes!(agent_input::post_agent_input))
|
||||
.routes(routes!(schedules::list_schedules, schedules::add_schedule))
|
||||
.routes(routes!(
|
||||
schedules::edit_schedule,
|
||||
schedules::remove_schedule
|
||||
))
|
||||
.routes(routes!(issue_report::get_repos))
|
||||
.routes(routes!(issue_report::get_issue_report_all))
|
||||
.routes(routes!(issue_report::get_issue_report))
|
||||
|
|
|
|||
Loading…
Reference in a new issue