From b6aeaf0b5709c7f72fa4f27bbe1312d55384991e Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 22 Jun 2026 00:11:49 +0200 Subject: [PATCH] hive-c0re: optional reset-timer on manual fire-now for recurring schedules --- hive-c0re/src/dashboard/schedules.rs | 26 ++++++++++----- hive-c0re/src/manager_server.rs | 4 ++- hive-c0re/src/scheduled_prompts.rs | 36 ++++++++++++++++++++ hive-c0re/src/scheduled_prompts_worker.rs | 40 ++++++++++++++++++----- 4 files changed, 89 insertions(+), 17 deletions(-) diff --git a/hive-c0re/src/dashboard/schedules.rs b/hive-c0re/src/dashboard/schedules.rs index 91b22e3f..b22f92d3 100644 --- a/hive-c0re/src/dashboard/schedules.rs +++ b/hive-c0re/src/dashboard/schedules.rs @@ -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, AxumPath(id): AxumPath, + body: Option>, ) -> 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() diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index 0df86f01..21f94999 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -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 diff --git a/hive-c0re/src/scheduled_prompts.rs b/hive-c0re/src/scheduled_prompts.rs index fb66fe80..774bb540 100644 --- a/hive-c0re/src/scheduled_prompts.rs +++ b/hive-c0re/src/scheduled_prompts.rs @@ -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(); diff --git a/hive-c0re/src/scheduled_prompts_worker.rs b/hive-c0re/src/scheduled_prompts_worker.rs index 08523bda..2bdba46b 100644 --- a/hive-c0re/src/scheduled_prompts_worker.rs +++ b/hive-c0re/src/scheduled_prompts_worker.rs @@ -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, schedule_id: i64, + reset_timer: bool, ) -> anyhow::Result { 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) }