hive-c0re: optional reset-timer on manual fire-now for recurring schedules

This commit is contained in:
damocles 2026-06-22 00:11:49 +02:00 committed by mara
commit b6aeaf0b57
4 changed files with 89 additions and 17 deletions

View file

@ -77,19 +77,29 @@ pub(super) async fn post_schedule_new(
}
}
/// Optional JSON body for `fire-now`. Absent / empty body ⇒
/// `reset_timer = false` (back-compat: cadence stays intact).
#[derive(serde::Deserialize, Default)]
pub(super) struct FireNowBody {
#[serde(default)]
reset_timer: bool,
}
/// `POST /api/schedules/{id}/fire-now` — operator-initiated
/// out-of-band fire of a scheduled prompt. Runs the
/// per-target fan-out once immediately and reports per-target
/// outcome counts. Does NOT touch `next_fire_at_unix` on
/// recurring schedules (their cadence stays intact); one-shot
/// schedules are consumed (cancelled) by a manual fire — the
/// operator's intent is "send this now, the scheduled time was
/// wrong."
/// out-of-band fire of a scheduled prompt. Runs the per-target
/// fan-out once immediately and reports per-target outcome counts.
/// One-shot schedules are consumed (cancelled) by a manual fire —
/// the operator's intent is "send this now, the scheduled time was
/// wrong." For recurring schedules the cadence stays intact unless
/// the body carries `{"reset_timer": true}`, in which case the
/// countdown is re-armed from now (`next_fire_at = now + interval`).
pub(super) async fn post_schedule_fire_now(
State(state): State<AppState>,
AxumPath(id): AxumPath<i64>,
body: Option<axum::Json<FireNowBody>>,
) -> Response {
match crate::scheduled_prompts_worker::fire_now(&state.coord, id).await {
let reset_timer = body.is_some_and(|axum::Json(b)| b.reset_timer);
match crate::scheduled_prompts_worker::fire_now(&state.coord, id, reset_timer).await {
Ok(report) => {
state.coord.emit_schedules_snapshot();
axum::Json(report).into_response()

View file

@ -727,7 +727,9 @@ async fn handle_fire_schedule_now(
),
};
}
match crate::scheduled_prompts_worker::fire_now(coord, schedule_id).await {
// MCP fire_schedule_now stays no-reset (cadence intact); the
// reset-timer option is a dashboard-dialog affordance.
match crate::scheduled_prompts_worker::fire_now(coord, schedule_id, false).await {
Ok(_report) => {
coord.emit_schedules_snapshot();
ManagerResponse::Ok

View file

@ -340,6 +340,20 @@ impl ScheduledPrompts {
Ok(skipped)
}
/// Set a schedule's `next_fire_at` to an absolute timestamp. Unlike
/// [`rearm`](Self::rearm) (which advances along the existing cadence
/// with catch-up), this writes the value verbatim — used by the
/// manual fire-now "reset timer" path to re-arm a recurring schedule
/// from now (`now + interval`).
pub fn set_next_fire(&self, id: i64, next_unix: i64) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE scheduled_prompts SET next_fire_at_unix = ?1 WHERE id = ?2",
params![next_unix, id],
)?;
Ok(())
}
/// Partial-update an existing schedule's mutable fields.
/// Every scalar field is `Option<_>`; `None` means "leave the
/// existing value alone", `Some(_)` means "set it to this."
@ -684,6 +698,28 @@ mod tests {
assert_eq!(s.next_fire_at_unix, 460);
}
#[test]
fn set_next_fire_writes_absolute_value() {
let (_dir, db) = open();
// Recurring every 60s, first fire at t=100.
let id = db
.submit(&NewSchedule {
owner: "operator".into(),
targets: vec!["alice".into()],
body: "wake".into(),
first_fire_at_unix: 100,
interval_seconds: Some(60),
description: None,
source: ScheduleSource::Operator,
})
.expect("submit");
// Unlike rearm (cadence-aligned), set_next_fire writes verbatim —
// the manual fire-now "reset timer" path uses now + interval.
db.set_next_fire(id, 1_234).expect("set_next_fire");
let s = db.get(id).expect("get").expect("present");
assert_eq!(s.next_fire_at_unix, 1_234);
}
#[test]
fn rearm_advances_one_step_when_caught_up() {
let (_dir, db) = open();

View file

@ -261,6 +261,10 @@ pub struct FireNowReport {
/// schedules never auto-cancel on manual fire — they keep
/// their cadence).
pub one_shot_consumed: bool,
/// Whether this manual fire re-armed a recurring schedule's timer
/// (`next_fire_at = now + interval`). `true` only when the caller
/// passed `reset_timer` AND the schedule is recurring.
pub timer_reset: bool,
}
/// Manual / out-of-band fire of a scheduled prompt ("fire now"
@ -275,12 +279,18 @@ pub struct FireNowReport {
/// the dashboard's per-target last-result column can distinguish
/// scheduled fires from operator-initiated ones at a glance.
///
/// `reset_timer` re-arms a *recurring* schedule's countdown from now
/// (`next_fire_at = now + interval`) after the fan-out — the dashboard
/// fire-now dialog's "reset timer" checkbox. It's a no-op for one-shots
/// (still consumed) and when `false` (today's default: cadence intact).
///
/// Returns Err if the schedule is missing, cancelled, or fully
/// drained of active targets — the dashboard can surface those
/// as plain 4xxs instead of pretending to fire a phantom row.
pub async fn fire_now(
coord: &std::sync::Arc<Coordinator>,
schedule_id: i64,
reset_timer: bool,
) -> anyhow::Result<FireNowReport> {
let now = now_unix();
let schedule = coord
@ -303,6 +313,7 @@ pub async fn fire_now(
failed: 0,
missing: 0,
one_shot_consumed: false,
timer_reset: false,
};
for target_row in &schedule.targets {
if target_row.cancelled_at_unix.is_some() {
@ -352,15 +363,28 @@ pub async fn fire_now(
tracing::warn!(error = ?e, schedule = schedule_id, %target, "record_target_result failed");
}
}
if schedule.interval_seconds.is_none() {
// One-shot is consumed by the manual fire. Recurring
// schedules stay untouched — their cadence is the whole
// point and a manual fire is meant to be additive.
if let Err(e) = coord.scheduled_prompts.cancel_all(schedule_id) {
tracing::warn!(error = ?e, schedule = schedule_id, "cancel_all after one-shot manual fire failed");
} else {
report.one_shot_consumed = true;
match schedule.interval_seconds {
None => {
// One-shot is consumed by the manual fire. (reset_timer is
// moot here — there's no recurring cadence to re-arm.)
if let Err(e) = coord.scheduled_prompts.cancel_all(schedule_id) {
tracing::warn!(error = ?e, schedule = schedule_id, "cancel_all after one-shot manual fire failed");
} else {
report.one_shot_consumed = true;
}
}
Some(interval) if reset_timer => {
// Recurring + operator asked to reset: re-arm the countdown
// from now (now + interval), not along the existing cadence.
let next = now.saturating_add(i64::try_from(interval).unwrap_or(i64::MAX));
if let Err(e) = coord.scheduled_prompts.set_next_fire(schedule_id, next) {
tracing::warn!(error = ?e, schedule = schedule_id, "set_next_fire after manual fire reset failed");
} else {
report.timer_reset = true;
}
}
// Recurring without reset: cadence stays intact (additive fire).
Some(_) => {}
}
Ok(report)
}