todos: reopen an acked keyed row when the caller says so

This commit is contained in:
damocles 2026-08-13 12:54:18 +02:00
commit 1b72ed56ff
12 changed files with 158 additions and 45 deletions

View file

@ -967,6 +967,7 @@ pub async fn destroy(coord: &Arc<Coordinator>, name: &str, purge: bool) -> Resul
Some(format!("destroyed:{name}")),
format!("agent '{name}' destroyed"),
None,
false,
)
.await;
// Container row disappeared — rescan so the dashboard fires

View file

@ -1439,6 +1439,8 @@ impl Coordinator {
/// (`let _ = coord.push_todo(...).await;`); callers that track a
/// per-target delivery outcome (e.g. the scheduled-prompts worker's
/// `last_result` column) use it instead of assuming success.
/// `reopen_if_acked` forwards to `UpsertTodo` (see its doc comment) —
/// `false` for every caller here except the scheduled-prompts worker.
pub async fn push_todo(
&self,
agent: &str,
@ -1446,6 +1448,7 @@ impl Coordinator {
key: Option<String>,
summary: String,
source: Option<String>,
reopen_if_acked: bool,
) -> Result<(), String> {
let Ok(ident) = hive_types::Ident::parse(agent) else {
tracing::warn!(%agent, "push_todo: not a valid agent ident, skipping");
@ -1461,6 +1464,7 @@ impl Coordinator {
key,
summary,
source,
reopen_if_acked,
};
match hive_sock_client::request::<_, hive_agent_sock::Response>(
&path,
@ -1482,7 +1486,10 @@ impl Coordinator {
}
/// `push_todo` to whichever agent submitted approval `approval_id` —
/// same resolution `notify_submitter` uses.
/// same resolution `notify_submitter` uses. Every caller here is a
/// one-shot approval-resolution notice, so `reopen_if_acked` is
/// unconditionally `false` — there's no reconciler/event distinction
/// to make for an event that only ever fires once.
pub async fn push_todo_submitter(
&self,
approval_id: i64,
@ -1492,7 +1499,7 @@ impl Coordinator {
source: Option<String>,
) -> Result<(), String> {
let target = self.submitter_or_manager(approval_id);
self.push_todo(&target, subsystem, key, summary, source)
self.push_todo(&target, subsystem, key, summary, source, false)
.await
}

View file

@ -178,6 +178,7 @@ async fn run_emit_rebuilt(coord: &Arc<Coordinator>, agent: &str, dag_id: Option<
Some(format!("rebuilt:{agent}")),
summary,
None,
false,
)
.await;
}
@ -441,6 +442,7 @@ async fn run_stop(coord: &Arc<Coordinator>, name: &str) -> Result<()> {
Some(format!("killed:{name}")),
format!("agent '{name}' killed"),
None,
false,
)
.await;
coord.rescan_containers_and_emit().await;

View file

@ -303,6 +303,7 @@ async fn handle_spawn(coord: &Arc<Coordinator>, name: &str) -> Result<HostRespon
Some(format!("spawned:{name}")),
format!("agent '{name}' spawned"),
None,
false,
)
.await;
// Update tmpfiles.d so the new agent's dirs survive a reboot.
@ -318,6 +319,7 @@ async fn handle_spawn(coord: &Arc<Coordinator>, name: &str) -> Result<HostRespon
Some(format!("spawned:{name}")),
format!("agent '{name}' spawn FAILED: {e:#}"),
None,
false,
)
.await;
return Err(e);

View file

@ -145,6 +145,7 @@ pub(super) async fn handle_kill(coord: &Arc<Coordinator>, agent: &str, name: &st
Some(format!("killed:{name}")),
format!("agent '{name}' killed"),
None,
false,
)
.await;
Response::Ok

View file

@ -143,6 +143,7 @@ async fn emit_login_transitions(
Some(format!("logged_in:{agent}")),
format!("agent '{agent}' logged in"),
None,
false,
)
.await;
}
@ -174,6 +175,7 @@ async fn emit_login_transitions(
Some(format!("needs_login:{agent}")),
format!("agent '{agent}' needs login"),
None,
false,
)
.await;
}

View file

@ -87,8 +87,14 @@ async fn tick(coord: &Arc<Coordinator>) {
/// upsert-by-key dedup the same job a now-removed
/// `has_pending_with_body` broker check used to do — collapsing a
/// re-fire of the *same schedule* against a target that hasn't
/// reviewed the last one yet — and does it more precisely (keyed on
/// schedule identity, not on the body happening to be byte-identical).
/// reviewed the last one yet, keyed on schedule identity rather than on
/// the body happening to be byte-identical. `deliver_to_target` also
/// passes `reopen_if_acked = true`, so this collapse only applies while
/// the previous fire is genuinely still sitting there un-reviewed — once
/// the target acks it, the *next* fire reopens the row and wakes again
/// even with an identical body, instead of silently staying quiet
/// forever — a recurring schedule with a static body used to wake its
/// target once, ever, and then look dead.
async fn fire_schedule(coord: &Arc<Coordinator>, schedule: &Schedule, now: i64) {
let known: std::collections::HashSet<String> = known_agents().await;
for target_row in &schedule.targets {
@ -158,7 +164,9 @@ async fn fire_schedule(coord: &Arc<Coordinator>, schedule: &Schedule, now: i64)
/// to push into there), a `push_todo` otherwise, keyed on the
/// schedule's own identity (`schedule:{schedule_id}`) so a re-fire
/// against a target that hasn't reviewed the last one collapses via
/// `push_todo`'s own upsert-by-key dedup. Shared by the periodic
/// `push_todo`'s own upsert-by-key dedup — but `reopen_if_acked = true`
/// means that collapse only holds while unreviewed; once acked, the next
/// fire reopens regardless of whether the body changed. Shared by the periodic
/// `fire_schedule` tick and the manual `fire_now` dashboard action —
/// the only difference between them is what each caller does with the
/// `Result` (log/prefix and per-target `last_result`/`FireNowReport`
@ -188,6 +196,14 @@ async fn deliver_to_target(
Some(format!("schedule:{schedule_id}")),
body.to_owned(),
Some("scheduled".to_owned()),
// Each fire is a distinct occurrence, not a restatement of
// a persisting condition — an agent that already acked the
// *previous* firing hasn't acked *this* one, so an acked
// row must reopen even when the body is byte-identical
// (the common case: most schedules don't vary their text
// per fire). See `Todos::upsert`'s doc comment for the
// full reconciler-vs-event rationale.
true,
)
.await
}