diff --git a/CLAUDE.md b/CLAUDE.md index c4416d0b..4cf65892 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,7 +82,8 @@ hive-c0re/ host daemon + sibling operator CLI (lib + 2 bins) (`ApprovalAdded` / `ApprovalResolved`, `QuestionAdded` / `QuestionResolved`, `TransientSet` / `TransientCleared`, - `RebuildQueueChanged`, `SchedulesChanged`). + `RebuildQueueChanged`, `SchedulesChanged`, + `RemindersChanged`). Each frame carries a monotonic per-process `seq` clients use to dedupe against snapshot reads. diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 610163fd..f02b23c0 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -852,6 +852,14 @@ payload): re-renders `schedulesState` on receipt; tab activation still re-fetches as a safety net for approval-path inserts and disconnect windows. +- `reminders_changed` (seq, reminders: `Vec`) — + full snapshot of all pending reminders. Emitted after every + reminder mutation: agent `remind` calls (`agent_server`), + operator cancel / retry (`/api/system/reminders/*`), `cancel_loose_end` + with Reminder kind, and the scheduler tick after each delivery + batch (`reminder_scheduler`). The SYST3M tab's reminders section + subscribes and calls `renderReminders` on receipt, so the list + updates live without polling. `/api/state` is **only fetched on cold-load and on the few forms that mutate non-event-derived state** (PURG3 + diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index 2f0eaaaa..31e6b7a2 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -227,6 +227,9 @@ window.marked = marked; schedulesState = (ev.schedules || []).slice(); renderSchedulesList(); } + function applyRemindersChanged(ev) { + renderReminders(ev.reminders || []); + } // Map from agent name → highest-priority in-flight queue entry // (`running` beats `queued`). Used by the container row renderer // to surface "building..." / "meta-updating..." badges on the @@ -3604,6 +3607,7 @@ window.marked = marked; meta_update_running: applyMetaUpdateRunning, rebuild_queue_changed: applyRebuildQueueChanged, schedules_changed: applySchedulesChanged, + reminders_changed: applyRemindersChanged, }; (function bindDashboardStream() { // Route through the SharedWorker so all open hyperhive tabs share diff --git a/hive-c0re/src/agent_server.rs b/hive-c0re/src/agent_server.rs index b7f50963..92401386 100644 --- a/hive-c0re/src/agent_server.rs +++ b/hive-c0re/src/agent_server.rs @@ -811,6 +811,7 @@ pub(crate) fn store_remind( .store_reminder(agent, &stored_message, stored_path.as_deref(), due_at) .map_err(|e| format!("failed to store reminder: {e:#}"))?; tracing::info!(%id, %agent, %due_at, "reminder scheduled"); + coord.emit_reminders_snapshot(); Ok(()) } diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 496f3083..6a13c03c 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -389,6 +389,24 @@ impl Coordinator { }); } + /// Emit a `RemindersChanged` snapshot event. Called from every + /// reminder mutation site (agent `remind` calls, operator cancel / + /// retry, and the scheduler after each delivery batch) so the + /// dashboard's pending-reminders list stays live without polling. + pub fn emit_reminders_snapshot(self: &Arc) { + let reminders = match self.broker.list_pending_reminders() { + Ok(rows) => rows, + Err(e) => { + tracing::warn!(error = ?e, "emit_reminders_snapshot: list failed"); + return; + } + }; + self.emit_dashboard_event(DashboardEvent::RemindersChanged { + seq: self.next_seq(), + reminders, + }); + } + /// Update the `step` label on a running queue entry and (if it /// actually changed) re-emit the queue snapshot so the dashboard /// renders the new phase. Returns `true` when the label was new diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index bdb78bcf..9290ab9f 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -2141,6 +2141,7 @@ async fn post_cancel_reminder( Ok(0) => error_response(&format!("reminder {id} not pending (already delivered?)")), Ok(_) => { tracing::info!(%id, "operator cancelled reminder"); + state.coord.emit_reminders_snapshot(); (StatusCode::OK, "ok").into_response() } Err(e) => error_response(&format!("cancel reminder {id} failed: {e:#}")), @@ -2160,6 +2161,7 @@ async fn post_retry_reminder( Ok(0) => error_response(&format!("reminder {id} not pending (already delivered?)")), Ok(_) => { tracing::info!(%id, "operator reset reminder failure for retry"); + state.coord.emit_reminders_snapshot(); (StatusCode::OK, "ok").into_response() } Err(e) => error_response(&format!("retry reminder {id} failed: {e:#}")), diff --git a/hive-c0re/src/dashboard_events.rs b/hive-c0re/src/dashboard_events.rs index 631547b6..818f452d 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -202,6 +202,14 @@ pub enum DashboardEvent { seq: u64, schedules: Vec, }, + /// Full snapshot of all pending reminders. Emitted after every + /// reminder mutation: agent `remind` calls, operator cancel / retry, + /// and the scheduler tick after each delivery batch. Lets the + /// dashboard's reminders section stay live without polling. + RemindersChanged { + seq: u64, + reminders: Vec, + }, } impl DashboardEvent { @@ -233,6 +241,7 @@ impl DashboardEvent { DashboardEvent::MetaUpdateRunning { .. } => "meta_update_running", DashboardEvent::RebuildQueueChanged { .. } => "rebuild_queue_changed", DashboardEvent::SchedulesChanged { .. } => "schedules_changed", + DashboardEvent::RemindersChanged { .. } => "reminders_changed", } } } @@ -355,6 +364,10 @@ mod tests { seq: 1, schedules: Vec::new(), }, + DashboardEvent::RemindersChanged { + seq: 1, + reminders: Vec::new(), + }, ]; for ev in samples { let v: serde_json::Value = serde_json::to_value(&ev).expect("serialise"); diff --git a/hive-c0re/src/questions.rs b/hive-c0re/src/questions.rs index 35eaa1c8..1d91647b 100644 --- a/hive-c0re/src/questions.rs +++ b/hive-c0re/src/questions.rs @@ -178,6 +178,7 @@ pub fn handle_cancel_loose_end( .cancel_reminder_as(id, canceller) .map_err(|e| format!("{e:#}"))?; tracing::info!(%id, %canceller, %owner, "reminder cancelled"); + coord.emit_reminders_snapshot(); Ok(()) } hive_sh4re::CancelLooseEndKind::Approval => { diff --git a/hive-c0re/src/reminder_scheduler.rs b/hive-c0re/src/reminder_scheduler.rs index c213882b..df00fb8d 100644 --- a/hive-c0re/src/reminder_scheduler.rs +++ b/hive-c0re/src/reminder_scheduler.rs @@ -63,6 +63,7 @@ fn tick(coord: &Arc) { // Single-transaction batch: one DB lock acquisition for N reminders // instead of N sequential lock/unlock cycles. let results = coord.broker.deliver_reminders_batch(&items); + let any_delivered = results.iter().any(|r| r.is_ok()); for ((id, agent, _body), result) in items.iter().zip(results.iter()) { if let Err(e) = result { let reason = format!("{e:#}"); @@ -82,6 +83,11 @@ fn tick(coord: &Arc) { } } } + // Emit after the batch so the dashboard's pending-reminders list + // updates when deliveries land (removes delivered rows). + if any_delivered { + coord.emit_reminders_snapshot(); + } } /// Build the inbox body for a due reminder. When `file_path` is None