use std::collections::HashSet;
use std::fmt::Write as _;
use std::sync::LazyLock;
use std::time::Duration;
use anyhow::Context as _;
use anyhow::Result;
use chrono::{DateTime, Duration as ChronoDuration};
use crate::Workspace;
use crate::db;
use crate::tools::custom::CallRefusal;
use crate::tools::shell::ProgramOutcome;
use crate::util::UnwrapPoison;
crate::define_store! {
pub(crate) static ALARMS: AlarmStore,
expect = "ALARMS not initialized — call init_all_stores() first",
}
crate::columns! {
ALARM_COLUMNS [ALARM] {
ID => "id",
SESSION_ID => "session_id",
USER_NAME => "user_name",
KIND => "kind",
TEXT => "text",
TRIGGER => "trigger",
ROLE => "role",
IDENTIFIER => "identifier",
WORKSPACE => "workspace",
INTERVAL_SECONDS => "interval_seconds",
NEXT_FIRE_AT => "next_fire_at",
UNFINISHED_ATTEMPTS => "unfinished_attempts",
}
}
#[derive(Debug, Clone)]
pub(crate) struct Alarm {
pub id: String,
pub session_id: String,
pub user_name: String,
pub kind: String,
pub text: String,
pub trigger: Option<StoredTrigger>,
pub role: Option<String>,
pub identifier: Option<String>,
pub workspace: Option<String>,
pub interval_seconds: Option<i64>,
pub next_fire_at: String,
pub unfinished_attempts: i64,
}
impl Alarm {
pub(crate) fn role_target(&self) -> Option<String> {
let role = self.role.as_deref()?;
let mut out = format!("addressed to custom role \"{role}\"");
if let Some(identifier) = &self.identifier {
let _ = write!(out, " (branch \"{identifier}\")");
}
if let Some(workspace) = &self.workspace {
let _ = write!(out, " in {workspace}");
}
Some(out)
}
}
pub(crate) struct AlarmTarget {
pub role: String,
pub identifier: Option<String>,
pub workspace: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub(crate) struct Trigger {
pub tool: String,
#[serde(default)]
pub args: serde_json::Map<String, serde_json::Value>,
}
impl Trigger {
fn encode(&self) -> String {
serde_json::to_string(self).expect("a trigger is always serializable")
}
fn render(&self) -> String {
if self.args.is_empty() {
return self.tool.clone();
}
format!(
"{} {}",
self.tool,
serde_json::to_string(&self.args)
.expect("a trigger's arguments are always serializable")
)
}
}
#[derive(Debug, Clone)]
pub(crate) enum StoredTrigger {
Tool(Trigger),
Unreadable,
}
const UNREADABLE_TRIGGER: &str = "(unreadable stored check)";
impl StoredTrigger {
fn decode(raw: &str) -> Self {
match serde_json::from_str(raw) {
Ok(trigger) => Self::Tool(trigger),
Err(_) => Self::Unreadable,
}
}
pub(crate) fn render(&self) -> String {
match self {
Self::Tool(trigger) => trigger.render(),
Self::Unreadable => UNREADABLE_TRIGGER.to_string(),
}
}
}
fn alarm_from_row(row: &db::Row) -> Result<Alarm, ::turso::Error> {
Ok(Alarm {
id: row.get(COL_ALARM_ID)?,
session_id: row.get(COL_ALARM_SESSION_ID)?,
user_name: row.get(COL_ALARM_USER_NAME)?,
kind: row.get(COL_ALARM_KIND)?,
text: row.get(COL_ALARM_TEXT)?,
trigger: stored_trigger(row)?,
role: row.get(COL_ALARM_ROLE)?,
identifier: row.get(COL_ALARM_IDENTIFIER)?,
workspace: row.get(COL_ALARM_WORKSPACE)?,
interval_seconds: row.get(COL_ALARM_INTERVAL_SECONDS)?,
next_fire_at: row.get(COL_ALARM_NEXT_FIRE_AT)?,
unfinished_attempts: row.get(COL_ALARM_UNFINISHED_ATTEMPTS)?,
})
}
fn stored_trigger(row: &db::Row) -> Result<Option<StoredTrigger>, ::turso::Error> {
match row.get_value(COL_ALARM_TRIGGER)? {
db::Value::Null => Ok(None),
db::Value::Text(raw) => Ok(Some(StoredTrigger::decode(&raw))),
_ => Ok(Some(StoredTrigger::Unreadable)),
}
}
const MAX_ACTIVE_ALARMS: i64 = 50;
const MAX_ALARM_TRIGGER_CHARS: usize = 2000;
const MAX_PERIOD_SECS: i64 = i64::MAX / 1_000_000_000;
const SWEEP_TICK: Duration = Duration::from_secs(1);
#[expect(clippy::cast_possible_wrap)] const MISSED_FIRING_TOLERANCE: ChronoDuration =
ChronoDuration::seconds(30 * SWEEP_TICK.as_secs() as i64);
const RETRY_COOLDOWN_SECS: i64 = 60;
const UNFINISHED_NOTICE_ATTEMPTS: i64 = 3;
fn is_missed_firing(now: &str, scheduled: &str) -> bool {
let (Ok(now), Ok(scheduled)) = (
db::parse_utc_timestamp(now),
db::parse_utc_timestamp(scheduled),
) else {
return false;
};
now.signed_duration_since(scheduled) > MISSED_FIRING_TOLERANCE
}
enum Timing {
Due,
Missed,
Late,
}
fn reminder_line(alarm: &Alarm, timing: &Timing) -> String {
let asset = match timing {
Timing::Due => "alarm_reminder_due.md",
Timing::Missed => "alarm_reminder_missed.md",
Timing::Late => "alarm_reminder_late.md",
};
crate::prompt::substitute(
&crate::prompt::load_prompt(asset),
&[
("{{text}}", &alarm.text),
("{{fire_at}}", &alarm.next_fire_at),
],
)
}
fn trigger_timing(alarm: &Alarm, missed: bool, reported: bool) -> Timing {
if reported && alarm.unfinished_attempts > 0 {
Timing::Late
} else if missed {
Timing::Missed
} else {
Timing::Due
}
}
pub(crate) fn format_fire_time(timestamp: &str) -> Result<String> {
let dt = DateTime::parse_from_rfc3339(timestamp)?;
let local = dt
.with_timezone(&chrono::Local)
.format("%Y-%m-%d %H:%M:%S %Z");
let utc = dt.with_timezone(&chrono::Utc).format("%Y-%m-%d %H:%M:%S");
Ok(format!("{local} local time ({utc} UTC)"))
}
async fn validate_alarm_target(
user_name: &str,
text: &str,
target: Option<AlarmTarget>,
has_trigger: bool,
) -> Result<(Option<String>, Option<String>, Option<String>)> {
let Some(target) = target else {
return Ok((None, None, None));
};
anyhow::ensure!(
crate::users::is_admin_name(user_name),
"usage: only the administrator may address an alarm to a custom role"
);
anyhow::ensure!(
crate::custom_role::get(&target.role).await?.is_some(),
"no custom role named '{}'",
target.role
);
let identifier = target
.identifier
.map(|id| id.trim().to_string())
.filter(|id| !id.is_empty());
if let Some(id) = &identifier {
crate::custom_role::validate_identifier(id)?;
}
let workspace = match target
.workspace
.map(|w| w.trim().to_string())
.filter(|w| !w.is_empty())
{
None => None,
Some(raw) => Some(
crate::launch::launch_workspace(Some(raw.as_str()))
.await?
.name,
),
};
anyhow::ensure!(
!has_trigger,
"usage: a role-addressed alarm's text IS the role's launch message, so its \
check cannot be combined with a role target"
);
anyhow::ensure!(
!text.trim().is_empty(),
"usage: a role-addressed alarm's text is the role's launch message, so it must not be empty"
);
Ok((Some(target.role), identifier, workspace))
}
#[must_use]
pub(crate) fn owner_alarm_session(agent_id: &str, user_name: &str) -> String {
if crate::session::is_custom_role_agent_id(agent_id) {
crate::session::assistant_agent_id(user_name)
} else {
agent_id.to_string()
}
}
pub(crate) async fn add_alarm(
session_id: &str,
user_name: &str,
text: &str,
fire_at: Option<&str>,
interval_seconds: Option<u64>,
trigger: Option<Trigger>,
target: Option<AlarmTarget>,
) -> Result<Alarm> {
let session_id = owner_alarm_session(session_id, user_name);
let (role, identifier, workspace) =
validate_alarm_target(user_name, text, target, trigger.is_some()).await?;
let encoded_trigger = trigger.as_ref().map(Trigger::encode);
if let Some(encoded) = &encoded_trigger {
anyhow::ensure!(
encoded.chars().count() <= MAX_ALARM_TRIGGER_CHARS,
"Alarm trigger too long: the stored trigger — the tool name plus its arguments as JSON — is limited to {MAX_ALARM_TRIGGER_CHARS} characters; shorten the arguments"
);
}
let normalized_fire_at = fire_at
.map(|f| {
db::parse_utc_timestamp(f)
.map(|dt| dt.to_rfc3339())
.with_context(|| format!("Invalid RFC3339/ISO-8601 fire time: {f} — pass a UTC timestamp like 2026-08-28T10:30:00Z"))
})
.transpose()?;
let (kind, interval_secs, next_fire_at) = match (&normalized_fire_at, interval_seconds) {
(Some(fire), None) => {
anyhow::ensure!(
db::parse_utc_timestamp(fire)? > db::parse_utc_timestamp(&db::now())?,
"Cannot set an alarm for a time in the past: pass a future RFC3339/ISO-8601 UTC time (convert the user's local time to UTC)"
);
("one-shot", None, fire.clone())
}
(None, Some(interval)) => {
anyhow::ensure!(
interval >= 5,
"Period must be at least 5 seconds: raise `interval_seconds`"
);
let interval_secs = i64::try_from(interval)
.ok()
.filter(|v| *v <= MAX_PERIOD_SECS)
.with_context(|| "Period must be at most 292 years")?;
let start = db::parse_utc_timestamp(&db::now())?;
let next = start
.checked_add_signed(ChronoDuration::seconds(interval_secs))
.unwrap_or(DateTime::<chrono::Utc>::MAX_UTC);
("periodic", Some(interval_secs), next.to_rfc3339())
}
_ => anyhow::bail!(
"Exactly one of fire_at or interval_seconds must be provided: pass `fire_at` for a one-shot reminder or `interval_seconds` for a periodic one, never both"
),
};
let rows = store()
.conn
.query(
"SELECT COUNT(*) FROM alarms WHERE session_id = ?1 AND status = 'active'",
db::params![session_id.as_str()],
)
.await?;
let count: i64 = rows.first().map(|r| r.get(0)).transpose()?.unwrap_or(0);
anyhow::ensure!(
count < MAX_ACTIVE_ALARMS,
"Alarm limit reached (maximum {MAX_ACTIVE_ALARMS} active alarms): remove one with `remove_alarm` before adding another"
);
let id = crate::generate_id();
let created_at = db::now();
store()
.conn
.execute(
"INSERT INTO alarms \
(id, session_id, user_name, kind, text, fire_at, interval_seconds, next_fire_at, status, created_at, trigger, role, identifier, workspace) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'active', ?9, ?10, ?11, ?12, ?13)",
db::params![
id.clone(),
session_id.as_str(),
user_name,
kind,
text,
normalized_fire_at.clone(),
interval_secs,
next_fire_at.clone(),
created_at.clone(),
encoded_trigger.clone(),
role.clone(),
identifier.clone(),
workspace.clone(),
],
)
.await?;
Ok(Alarm {
id,
session_id,
user_name: user_name.to_string(),
kind: kind.to_string(),
text: text.to_string(),
trigger: trigger.map(StoredTrigger::Tool),
role,
identifier,
workspace,
interval_seconds: interval_secs,
next_fire_at,
unfinished_attempts: 0,
})
}
pub(crate) async fn list_alarms(session_id: &str) -> Result<Vec<Alarm>> {
let sql = format!(
"SELECT {ALARM_COLUMNS} FROM alarms \
WHERE session_id = ?1 AND status = 'active' \
ORDER BY next_fire_at ASC"
);
let rows = store().conn.query(&sql, db::params![session_id]).await?;
Ok(rows.iter().map(alarm_from_row).collect::<Result<_, _>>()?)
}
pub(crate) async fn list_user_alarms(user_name: &str) -> Result<Vec<Alarm>> {
let sql = format!(
"SELECT {ALARM_COLUMNS} FROM alarms \
WHERE user_name = ?1 AND status = 'active' \
ORDER BY next_fire_at ASC"
);
let rows = store().conn.query(&sql, db::params![user_name]).await?;
Ok(rows.iter().map(alarm_from_row).collect::<Result<_, _>>()?)
}
pub(crate) async fn list_all_active_alarms() -> Result<Vec<Alarm>> {
let sql = format!(
"SELECT {ALARM_COLUMNS} FROM alarms \
WHERE status = 'active' \
ORDER BY user_name ASC, next_fire_at ASC, id ASC"
);
let rows = store().conn.query(&sql, db::params![]).await?;
Ok(rows.iter().map(alarm_from_row).collect::<Result<_, _>>()?)
}
pub(crate) async fn remove_alarm(session_id: &str, id: &str) -> Result<Option<Alarm>> {
let sql = format!(
"SELECT {ALARM_COLUMNS} FROM alarms \
WHERE id = ?1 AND session_id = ?2 AND status = 'active'"
);
let rows = store()
.conn
.query(&sql, db::params![id, session_id])
.await?;
let Some(row) = rows.first() else {
return Ok(None);
};
let alarm = alarm_from_row(row)?;
store()
.conn
.execute(
"UPDATE alarms SET status = 'removed' WHERE id = ?1 AND session_id = ?2",
db::params![id, session_id],
)
.await?;
Ok(Some(alarm))
}
#[expect(clippy::cast_possible_truncation)] pub(crate) async fn delete_alarms_for_role(role_name: &str) -> Result<usize> {
let changed = store()
.conn
.execute(
"UPDATE alarms SET status = 'removed' WHERE role = ?1 AND status = 'active'",
db::params![role_name],
)
.await?;
Ok(changed as usize)
}
async fn delete_alarm_after_run(alarm: &Alarm) -> Result<()> {
store()
.conn
.execute(
"UPDATE alarms SET status = 'removed' WHERE id = ?1 AND session_id = ?2",
db::params![alarm.id.as_str(), alarm.session_id.as_str()],
)
.await?;
Ok(())
}
async fn due_alarms(now: &str) -> Result<Vec<Alarm>> {
let sql = format!(
"SELECT {ALARM_COLUMNS} FROM alarms \
WHERE status = 'active' AND next_fire_at <= ?1 \
AND (retry_at IS NULL OR retry_at <= ?1) \
ORDER BY next_fire_at ASC"
);
let rows = store().conn.query(&sql, db::params![now]).await?;
Ok(rows.iter().map(alarm_from_row).collect::<Result<_, _>>()?)
}
async fn fire_alarm(alarm: &Alarm) -> Result<()> {
if alarm.role.is_some() {
return fire_custom_role_alarm(alarm).await;
}
let missed = is_missed_firing(&db::now(), &alarm.next_fire_at);
match &alarm.trigger {
Some(trigger) => {
fire_trigger_alarm(alarm, trigger, missed);
Ok(())
}
None => fire_plain_alarm(alarm, missed).await,
}
}
async fn fire_custom_role_alarm(alarm: &Alarm) -> Result<()> {
let role = alarm.role.as_deref().unwrap_or_default();
let (reason, removed) = if crate::custom_role::get(role).await?.is_none() {
("the custom role no longer exists".to_string(), true)
} else {
let launched = async {
let workspace = crate::launch::launch_workspace(alarm.workspace.as_deref()).await?;
crate::launch::custom_role(
role,
&alarm.text,
alarm.identifier.as_deref(),
&workspace,
crate::agent::message_router::AnsweredFor::Agent,
)
.await
}
.await;
match launched {
Ok(_) => {
advance_alarm_state(alarm, &db::now()).await?;
return Ok(());
}
Err(e) if crate::db::is_store_error(&e) => {
tracing::warn!(
alarm = %alarm.id,
role = %role,
error = %e,
"Store failure launching a custom-role alarm — alarm left armed"
);
return Err(e);
}
Err(e) => {
tracing::warn!(
alarm = %alarm.id,
role = %role,
error = %e,
"Failed to launch a custom role alarm"
);
if alarm.kind == "one-shot" {
(e.to_string(), true)
} else {
advance_alarm_state(alarm, &db::now()).await?;
(e.to_string(), false)
}
}
}
};
if removed {
delete_alarm_after_run(alarm).await?;
}
let workspace_line = match &alarm.workspace {
Some(workspace) => format!(" in {workspace}"),
None => String::new(),
};
let outcome = if removed {
"The alarm was removed."
} else {
"The reminder stays armed and will fire again at its next scheduled time."
};
let content = crate::prompt::substitute(
&crate::prompt::load_prompt("alarm_role_notification.md"),
&[
("{{text}}", &alarm.text),
("{{role}}", role),
("{{workspace_line}}", &workspace_line),
("{{reason}}", &reason),
("{{outcome}}", outcome),
],
);
deliver_alarm_notification(alarm, content).await;
Ok(())
}
async fn fire_plain_alarm(alarm: &Alarm, missed: bool) -> Result<()> {
let now = db::now();
let content = crate::prompt::substitute(
&crate::prompt::load_prompt("alarm_notification.md"),
&[(
"{{reminder_line}}",
&reminder_line(alarm, &trigger_timing(alarm, missed, false)),
)],
);
deliver_alarm_notification(alarm, content).await;
advance_alarm_state(alarm, &now).await?;
Ok(())
}
async fn deliver_alarm_notification(alarm: &Alarm, content: String) {
if let Err(e) = crate::agent::message_router::deliver_assistant_notice(
&alarm.session_id,
&alarm.user_name,
content,
)
.await
{
tracing::warn!(
alarm = %alarm.id,
error = %e,
"Failed to persist alarm delivery — routing best-effort"
);
}
}
async fn advance_alarm_state(alarm: &Alarm, now: &str) -> Result<()> {
if alarm.kind == "one-shot" {
store()
.conn
.execute(
"UPDATE alarms SET status = 'fired', retry_at = NULL, unfinished_attempts = 0 \
WHERE id = ?1 AND status = 'active'",
db::params![alarm.id.as_str()],
)
.await?;
} else {
let next = next_periodic_fire(now, &alarm.next_fire_at, periodic_interval(alarm)?)?;
store()
.conn
.execute(
"UPDATE alarms SET next_fire_at = ?1, retry_at = NULL, unfinished_attempts = 0 \
WHERE id = ?2 AND status = 'active'",
db::params![next, alarm.id.as_str()],
)
.await?;
}
Ok(())
}
fn periodic_interval(alarm: &Alarm) -> Result<i64> {
let interval = alarm.interval_seconds.unwrap_or(0);
anyhow::ensure!(
interval > 0,
"Periodic alarm {} has no valid interval",
alarm.id
);
Ok(interval)
}
static ATTEMPTS_IN_FLIGHT: LazyLock<std::sync::Mutex<HashSet<String>>> =
LazyLock::new(|| std::sync::Mutex::new(HashSet::new()));
fn claim_attempt_slot(alarm_id: &str) -> Option<AttemptSlot> {
let inserted = ATTEMPTS_IN_FLIGHT
.lock()
.unwrap_poison()
.insert(alarm_id.to_string());
inserted.then(|| AttemptSlot(alarm_id.to_string()))
}
fn attempt_in_flight(alarm_id: &str) -> bool {
ATTEMPTS_IN_FLIGHT.lock().unwrap_poison().contains(alarm_id)
}
struct AttemptSlot(String);
impl Drop for AttemptSlot {
fn drop(&mut self) {
ATTEMPTS_IN_FLIGHT.lock().unwrap_poison().remove(&self.0);
}
}
fn fire_trigger_alarm(alarm: &Alarm, trigger: &StoredTrigger, missed: bool) {
let Some(slot) = claim_attempt_slot(&alarm.id) else {
return;
};
tokio::spawn(run_alarm_trigger_task(
slot,
alarm.clone(),
trigger.clone(),
missed,
));
}
enum TriggerOutcome {
Silent,
Ran(ProgramOutcome),
CouldNotRun(String),
Unfinished,
}
impl TriggerOutcome {
fn from_run(outcome: ProgramOutcome) -> Self {
if outcome.timed_out {
return Self::Unfinished;
}
if outcome.success && !outcome.has_output {
Self::Silent
} else {
Self::Ran(outcome)
}
}
}
async fn run_trigger(ws: &Workspace, user: &str, trigger: &Trigger) -> TriggerOutcome {
let call =
match crate::tools::custom::resolve_tool_call(user, &trigger.tool, &trigger.args, true)
.await
{
Ok(call) => call,
Err(CallRefusal::Unavailable { .. }) => {
return TriggerOutcome::CouldNotRun(format!(
"the custom tool \"{}\" is no longer available to you",
trigger.tool
));
}
Err(CallRefusal::Name { .. } | CallRefusal::NotUsable { .. }) => {
return TriggerOutcome::CouldNotRun(format!(
"the custom tool \"{}\" is no longer usable",
trigger.tool
));
}
Err(CallRefusal::Arguments(e)) => {
return TriggerOutcome::CouldNotRun(format!(
"the stored arguments no longer fit \"{}\" — {e}",
trigger.tool
));
}
};
let Some(bun) = crate::tools::bun::bun_binary_path() else {
return TriggerOutcome::CouldNotRun("the managed bun runtime is unavailable".to_string());
};
TriggerOutcome::from_run(
crate::tools::shell::run_program_outcome(
ws.as_path(),
&bun,
&[call.path.display().to_string(), call.payload()],
)
.await,
)
}
async fn run_alarm_trigger_task(
_slot: AttemptSlot,
alarm: Alarm,
trigger: StoredTrigger,
missed: bool,
) {
let outcome = match &trigger {
StoredTrigger::Tool(check) => {
let ws = crate::users::personal_workspace_struct(&alarm.user_name);
run_trigger(&ws, &alarm.user_name, check).await
}
StoredTrigger::Unreadable => {
TriggerOutcome::CouldNotRun("the stored check cannot be read".to_string())
}
};
if let Err(e) = settle_trigger_attempt(&alarm, &trigger, outcome, missed).await {
tracing::warn!(alarm = %alarm.id, error = %e, "Failed to settle an alarm check");
}
}
async fn settle_trigger_attempt(
alarm: &Alarm,
trigger: &StoredTrigger,
outcome: TriggerOutcome,
missed: bool,
) -> Result<()> {
match &outcome {
TriggerOutcome::Unfinished => return defer_trigger_attempt(alarm, trigger).await,
TriggerOutcome::Silent | TriggerOutcome::Ran(ProgramOutcome { success: true, .. }) => {}
TriggerOutcome::Ran(_) | TriggerOutcome::CouldNotRun(_) => {
let deletion = delete_alarm_after_run(alarm).await;
if let Some(content) =
trigger_notification(alarm, trigger, &outcome, Some(&deletion), missed)
{
deliver_alarm_notification(alarm, content).await;
}
if deletion.is_err() {
advance_alarm_state(alarm, &db::now()).await?;
}
return Ok(());
}
}
if let Some(content) = trigger_notification(alarm, trigger, &outcome, None, missed) {
deliver_alarm_notification(alarm, content).await;
}
advance_alarm_state(alarm, &db::now()).await
}
async fn defer_trigger_attempt(alarm: &Alarm, trigger: &StoredTrigger) -> Result<()> {
let attempt = alarm.unfinished_attempts + 1;
let retry_at = (db::parse_utc_timestamp(&db::now())?
+ ChronoDuration::seconds(RETRY_COOLDOWN_SECS))
.to_rfc3339();
let changed = store()
.conn
.execute(
"UPDATE alarms SET retry_at = ?1, unfinished_attempts = ?2 \
WHERE id = ?3 AND session_id = ?4 AND status = 'active' AND next_fire_at = ?5",
db::params![
retry_at,
attempt,
alarm.id.as_str(),
alarm.session_id.as_str(),
alarm.next_fire_at.as_str(),
],
)
.await?;
if changed == 0 {
return Ok(());
}
if attempt == UNFINISHED_NOTICE_ATTEMPTS {
let content = crate::prompt::substitute(
&crate::prompt::load_prompt("alarm_trigger_unfinished.md"),
&[
("{{text}}", &alarm.text),
("{{trigger}}", &trigger.render()),
],
);
deliver_alarm_notification(alarm, content).await;
}
Ok(())
}
fn trigger_notification(
alarm: &Alarm,
trigger: &StoredTrigger,
outcome: &TriggerOutcome,
deletion: Option<&Result<()>>,
missed: bool,
) -> Option<String> {
let (status, output) = match outcome {
TriggerOutcome::Silent | TriggerOutcome::Unfinished => return None,
TriggerOutcome::Ran(outcome) => {
let status = if outcome.success {
format!("The check exited successfully ({}).", outcome.detail)
} else {
format!("The check FAILED ({}).", outcome.detail)
};
let shown = if !outcome.output.is_empty() {
crate::util::truncate_sandwich(
&outcome.output,
crate::util::TOOL_OUTPUT_BUDGET_BYTES,
"check output",
)
} else if outcome.has_output {
"(output was not printable)".to_string()
} else {
"(no output)".to_string()
};
(status, format!("\n\n{shown}"))
}
TriggerOutcome::CouldNotRun(reason) => {
(format!("The check could not run: {reason}."), String::new())
}
};
let deletion_line = match deletion {
None => String::new(),
Some(Ok(())) => crate::prompt::load_prompt("alarm_trigger_deletion.md"),
Some(Err(_)) => crate::prompt::load_prompt("alarm_trigger_deletion_failed.md"),
};
let reported = matches!(outcome, TriggerOutcome::Ran(run) if run.success);
let timing = trigger_timing(alarm, missed, reported);
Some(crate::prompt::substitute(
&crate::prompt::load_prompt("alarm_trigger_notification.md"),
&[
("{{reminder_line}}", &reminder_line(alarm, &timing)),
("{{trigger}}", &trigger.render()),
("{{trigger_status}}", &status),
("{{trigger_deletion}}", &deletion_line),
("{{trigger_output}}", &output),
],
))
}
fn next_periodic_fire(now: &str, next_fire: &str, interval_secs: i64) -> Result<String> {
let now_dt = db::parse_utc_timestamp(now)?;
let next = db::parse_utc_timestamp(next_fire)?;
let elapsed = now_dt.signed_duration_since(next).num_seconds();
let to_skip = elapsed.div_euclid(interval_secs).saturating_add(1);
let add_secs = interval_secs.saturating_mul(to_skip).min(MAX_PERIOD_SECS);
let next = next
.checked_add_signed(ChronoDuration::seconds(add_secs))
.unwrap_or(DateTime::<chrono::Utc>::MAX_UTC);
Ok(next.to_rfc3339())
}
fn startable(due: Vec<Alarm>, batch_limit: usize) -> Vec<Alarm> {
due.into_iter()
.filter(|alarm| !attempt_in_flight(&alarm.id))
.take(batch_limit)
.collect()
}
async fn run_alarm_sweep(batch_limit: usize) -> Result<()> {
let due = startable(due_alarms(&db::now()).await?, batch_limit);
let mut fired = 0usize;
let mut failed = 0usize;
for alarm in due {
match fire_alarm(&alarm).await {
Ok(()) => fired += 1,
Err(e) => {
failed += 1;
tracing::warn!(alarm = %alarm.id, error = %e, "Failed to fire alarm");
}
}
}
if fired > 0 || failed > 0 {
tracing::debug!(fired, failed, "alarm sweep complete");
}
Ok(())
}
pub async fn run_alarm_sweep_loop() {
loop {
if !crate::shutdown::sleep_or_shutdown_or_drain(SWEEP_TICK).await {
break;
}
if let Err(e) = run_alarm_sweep(50).await {
tracing::warn!(error = %e, "alarm sweep failed");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::test::ProbeFile;
#[test]
fn format_fire_time_renders_local_and_utc() {
let out = format_fire_time("2026-08-28T07:30:00+00:00").unwrap();
assert!(
out.ends_with("local time (2026-08-28 07:30:00 UTC)"),
"got: {out}"
);
assert!(!out.contains("UTC UTC"), "double-UTC in output: {out}");
}
#[test]
fn format_fire_time_rejects_invalid() {
assert!(format_fire_time("not-a-time").is_err());
}
#[test]
fn missed_firing_needs_a_firing_far_past_its_scheduled_time() {
let scheduled = "2026-09-04T12:00:00+00:00";
assert!(!is_missed_firing("2026-09-04T12:00:00+00:00", scheduled));
assert!(!is_missed_firing("2026-09-04T12:00:05+00:00", scheduled));
assert!(!is_missed_firing("2026-09-04T12:00:30+00:00", scheduled));
assert!(
is_missed_firing("2026-09-04T12:01:00+00:00", scheduled),
"a firing a minute late is past the tolerance"
);
assert!(
is_missed_firing("2026-09-04T23:00:00+00:00", scheduled),
"an overnight gap must be reported as missed"
);
assert!(!is_missed_firing("2026-09-04T12:00:00+00:00", "not-a-time"));
assert!(!is_missed_firing("2026-09-04T11:00:00+00:00", scheduled));
}
#[test]
fn reminder_line_states_exactly_one_timing() {
let alarm = trigger_alarm();
assert_eq!(
reminder_line(&alarm, &Timing::Due),
"Your reminder \"check the thing\" is due now \
(scheduled for 2026-09-04T12:00:00+00:00)."
);
let missed = reminder_line(&alarm, &Timing::Missed);
assert!(missed.contains("was MISSED"), "got: {missed}");
assert!(
missed.contains("2026-09-04T12:00:00+00:00"),
"the scheduled time must be named: {missed}"
);
assert!(
missed.contains("Tell the user"),
"the assistant must be told to pass it on: {missed}"
);
assert!(
!missed.contains("due now"),
"a missed firing must not read as current: {missed}"
);
let due_at = "2026-09-04T12:00:00+00:00";
let late = reminder_line(&alarm, &Timing::Late);
assert!(late.contains("LATE"), "got: {late}");
assert!(
late.contains(due_at),
"the moment it was due for must be named: {late}"
);
assert!(
!late.contains("MISSED") && !late.contains("due now"),
"a late report is one statement, not a stack of lateness: {late}"
);
}
#[test]
fn next_periodic_fire_skips_missed_periods_in_one_step() {
let now = "2026-08-28T00:00:25Z";
let next_fire = "2026-08-28T00:00:00Z";
let out = next_periodic_fire(now, next_fire, 10).unwrap();
assert_eq!(out, "2026-08-28T00:00:30+00:00");
}
#[test]
fn next_periodic_fire_saturates_absurd_interval() {
let out =
next_periodic_fire("2026-08-28T00:00:00Z", "2026-08-28T00:00:00Z", i64::MAX).unwrap();
let parsed = db::parse_utc_timestamp(&out).unwrap();
assert!(
parsed > db::parse_utc_timestamp("2026-08-28T00:00:00Z").unwrap(),
"advance must move past now, got {out}"
);
}
#[tokio::test]
async fn add_alarm_rejects_past_one_shot() {
let err = add_alarm(
"session-a",
"alice",
"remind me",
Some("2020-01-01T00:00:00Z"),
None,
None,
None,
)
.await
.unwrap_err();
assert!(err.to_string().contains("past"), "got: {err}");
}
#[tokio::test]
async fn add_alarm_rejects_short_periodic_interval() {
let err = add_alarm("session-a", "alice", "remind me", None, Some(4), None, None)
.await
.unwrap_err();
assert!(err.to_string().contains("at least 5 seconds"), "got: {err}");
}
#[tokio::test]
async fn add_alarm_rejects_absurd_periodic_interval() {
let err = add_alarm(
"session-a",
"alice",
"remind me",
None,
Some(u64::MAX),
None,
None,
)
.await
.unwrap_err();
assert!(err.to_string().contains("at most 292 years"), "got: {err}");
}
#[tokio::test]
async fn add_alarm_requires_exactly_one_of_fire_at_or_interval() {
let err = add_alarm("session-a", "alice", "remind me", None, None, None, None)
.await
.unwrap_err();
assert!(err.to_string().contains("Exactly one"), "got: {err}");
let err = add_alarm(
"session-a",
"alice",
"remind me",
Some("2099-01-01T00:00:00Z"),
Some(60),
None,
None,
)
.await
.unwrap_err();
assert!(err.to_string().contains("Exactly one"), "got: {err}");
}
#[tokio::test]
async fn add_alarm_enforces_active_cap() {
crate::util::test::init_test_stores().await;
let session = "cap-session";
for i in 0..MAX_ACTIVE_ALARMS {
let fire = format!("2099-01-01T00:00:{i:02}Z");
add_alarm(
session,
"alice",
&format!("reminder {i}"),
Some(&fire),
None,
None,
None,
)
.await
.unwrap();
}
let err = add_alarm(
session,
"alice",
"one too many",
Some("2099-01-01T00:01:00Z"),
None,
None,
None,
)
.await
.unwrap_err();
assert!(
err.to_string()
.contains(&format!("maximum {MAX_ACTIVE_ALARMS}")),
"got: {err}"
);
}
fn args_of(value: &serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
value.as_object().cloned().unwrap_or_default()
}
#[tokio::test]
async fn add_alarm_rejects_overlong_trigger() {
crate::util::test::init_test_stores().await;
let overlong = |trigger: Trigger| async move {
add_alarm(
"session-a",
"alice",
"remind me",
Some("2099-01-01T00:00:00Z"),
None,
Some(trigger),
None,
)
.await
.unwrap_err()
.to_string()
};
let err = overlong(Trigger {
tool: "weather".to_string(),
args: args_of(&serde_json::json!({ "city": "x".repeat(MAX_ALARM_TRIGGER_CHARS + 1) })),
})
.await;
assert!(err.contains("too long"), "got: {err}");
let err = overlong(Trigger {
tool: "x".repeat(MAX_ALARM_TRIGGER_CHARS),
args: args_of(&serde_json::json!({ "city": "Minsk" })),
})
.await;
assert!(err.contains("too long"), "got: {err}");
}
#[tokio::test]
async fn add_alarm_stores_trigger() {
crate::util::test::init_test_stores().await;
let session = "trigger-session";
let alarm = add_alarm(
session,
"alice",
"remind me",
Some("2099-01-01T00:00:00Z"),
None,
Some(weather_trigger()),
None,
)
.await
.unwrap();
let trigger = alarm.trigger.as_ref().expect("stored trigger");
assert_eq!(trigger.render(), "weather {\"city\":\"Minsk\"}");
let listed = list_alarms(session).await.unwrap();
assert_eq!(listed.len(), 1, "one triggered alarm must be listed");
let listed_trigger = listed[0].trigger.as_ref().expect("listed trigger");
assert_eq!(listed_trigger.render(), "weather {\"city\":\"Minsk\"}");
}
fn weather_trigger() -> Trigger {
let mut args = serde_json::Map::new();
args.insert("city".to_string(), serde_json::json!("Minsk"));
Trigger {
tool: "weather".to_string(),
args,
}
}
fn trigger_alarm() -> Alarm {
Alarm {
id: "alarm-trigger".to_string(),
session_id: "assistant:alice".to_string(),
user_name: "alice".to_string(),
kind: "periodic".to_string(),
text: "check the thing".to_string(),
trigger: Some(StoredTrigger::Tool(weather_trigger())),
role: None,
identifier: None,
workspace: None,
interval_seconds: Some(60),
next_fire_at: "2026-09-04T12:00:00+00:00".to_string(),
unfinished_attempts: 0,
}
}
fn ran(success: bool, detail: &str, output: &str, has_output: bool) -> ProgramOutcome {
ProgramOutcome {
success,
detail: detail.to_string(),
output: output.to_string(),
has_output,
timed_out: false,
}
}
#[test]
fn trigger_notification_stays_silent_when_check_reports_nothing() {
let alarm = trigger_alarm();
let trigger = StoredTrigger::Tool(weather_trigger());
for missed in [false, true] {
assert!(
trigger_notification(&alarm, &trigger, &TriggerOutcome::Silent, None, missed)
.is_none(),
"a silent check must not wake the assistant, late or not"
);
}
}
#[test]
fn wake_decision_reads_only_whether_the_run_reported_anything() {
let silent = |outcome| matches!(outcome, TriggerOutcome::Silent);
assert!(
silent(TriggerOutcome::from_run(ran(
true,
"exit status 0",
"",
false
))),
"a clean run that said nothing must not wake"
);
assert!(!silent(TriggerOutcome::from_run(ran(
true,
"exit status 0",
"key: sk-live-1234",
true
))));
assert!(!silent(TriggerOutcome::from_run(ran(
true,
"exit status 0",
"",
true
))));
assert!(!silent(TriggerOutcome::from_run(ran(
false,
"exit status 2",
"",
false
))));
}
#[test]
fn trigger_notification_wakes_on_output() {
let alarm = trigger_alarm();
let trigger = StoredTrigger::Tool(weather_trigger());
let outcome = TriggerOutcome::Ran(ran(true, "exit status 0", "all good", true));
let content = trigger_notification(&alarm, &trigger, &outcome, None, false).unwrap();
assert!(content.contains("<alarm-notification>"));
assert!(content.contains("check the thing"));
assert!(content.contains("weather {\"city\":\"Minsk\"}"));
assert!(content.contains("exited successfully (exit status 0)"));
assert!(content.contains("all good"));
assert!(
!content.contains("DELETED"),
"clean path must not report a deletion"
);
assert!(
content.contains("is due now (scheduled for 2026-09-04T12:00:00+00:00)."),
"a firing on time keeps today's wording: {content}"
);
}
#[test]
fn trigger_notification_says_a_late_firing_was_missed() {
let alarm = trigger_alarm();
let trigger = StoredTrigger::Tool(weather_trigger());
for outcome in [
TriggerOutcome::Ran(ran(true, "exit status 0", "all good", true)),
TriggerOutcome::Ran(ran(false, "exit status 2", "boom", true)),
TriggerOutcome::CouldNotRun("the managed bun runtime is unavailable".to_string()),
] {
let content = trigger_notification(&alarm, &trigger, &outcome, None, true).unwrap();
assert!(content.contains("was MISSED"), "got: {content}");
assert!(
content.contains("2026-09-04T12:00:00+00:00"),
"got: {content}"
);
assert!(!content.contains("due now"), "got: {content}");
}
}
#[test]
fn trigger_notification_does_not_call_escape_only_output_empty() {
let alarm = trigger_alarm();
let trigger = StoredTrigger::Tool(weather_trigger());
let outcome = TriggerOutcome::Ran(ran(true, "exit status 0", "", true));
let content = trigger_notification(&alarm, &trigger, &outcome, None, false).unwrap();
assert!(content.contains("not printable"), "got: {content}");
assert!(!content.contains("(no output)"), "got: {content}");
}
#[test]
fn a_broken_check_after_retries_is_not_late_and_names_its_scheduled_time() {
let alarm = Alarm {
unfinished_attempts: 3,
..trigger_alarm()
};
let trigger = StoredTrigger::Tool(weather_trigger());
for outcome in [
TriggerOutcome::Ran(ran(false, "exit status 2", "boom", true)),
TriggerOutcome::CouldNotRun("the managed bun runtime is unavailable".to_string()),
] {
let content = trigger_notification(&alarm, &trigger, &outcome, Some(&Ok(())), true)
.expect("a broken check always notifies");
assert!(!content.contains("LATE"), "got: {content}");
assert!(
content.contains("was MISSED — it was scheduled for 2026-09-04T12:00:00+00:00"),
"the occurrence's own scheduled time must be named: {content}"
);
}
}
#[test]
fn trigger_notification_wakes_on_failure_and_reports_the_deletion_outcome() {
let alarm = trigger_alarm();
let trigger = StoredTrigger::Tool(weather_trigger());
let outcome = TriggerOutcome::Ran(ran(false, "exit status 2", "", false));
let deleted =
trigger_notification(&alarm, &trigger, &outcome, Some(&Ok(())), false).unwrap();
assert!(deleted.contains("FAILED (exit status 2)"), "got: {deleted}");
assert!(deleted.contains("(no output)"), "got: {deleted}");
assert!(deleted.contains("DELETED"), "got: {deleted}");
let failed = trigger_notification(
&alarm,
&trigger,
&outcome,
Some(&Err(anyhow::anyhow!("db down"))),
false,
)
.unwrap();
assert!(failed.contains("FAILED"), "got: {failed}");
assert!(failed.contains("delete"), "got: {failed}");
assert!(
!failed.contains("was DELETED"),
"must not claim a successful deletion: {failed}"
);
}
#[test]
fn trigger_notification_reports_a_check_that_could_not_run() {
let alarm = trigger_alarm();
let trigger = StoredTrigger::Tool(weather_trigger());
let outcome =
TriggerOutcome::CouldNotRun("the custom tool \"weather\" is no longer usable".into());
let content =
trigger_notification(&alarm, &trigger, &outcome, Some(&Ok(())), false).unwrap();
assert!(
content.contains("no longer usable"),
"the reason must reach the assistant: {content}"
);
assert!(content.contains("weather {\"city\":\"Minsk\"}"));
assert!(content.contains("DELETED"), "got: {content}");
assert!(
!content.contains("FAILED"),
"a check that never ran did not fail: {content}"
);
}
async fn alarm_status(id: &str) -> String {
store()
.conn
.query("SELECT status FROM alarms WHERE id = ?1", db::params![id])
.await
.unwrap()
.first()
.map(|r| r.get(0))
.transpose()
.unwrap()
.expect("alarm row must exist")
}
async fn alarm_retry_at(id: &str) -> Option<String> {
store()
.conn
.query("SELECT retry_at FROM alarms WHERE id = ?1", db::params![id])
.await
.unwrap()
.first()
.map(|row| row.get::<Option<String>>(0))
.transpose()
.unwrap()
.flatten()
}
async fn active_alarm(session_id: &str, id: &str) -> Alarm {
list_alarms(session_id)
.await
.unwrap()
.into_iter()
.find(|alarm| alarm.id == id)
.expect("an active alarm row")
}
async fn is_due(id: &str) -> bool {
due_alarms(&db::now())
.await
.unwrap()
.iter()
.any(|alarm| alarm.id == id)
}
fn seconds_from_now(timestamp: &str) -> i64 {
db::parse_utc_timestamp(timestamp)
.unwrap()
.signed_duration_since(db::parse_utc_timestamp(&db::now()).unwrap())
.num_seconds()
}
async fn make_overdue(id: &str) {
store()
.conn
.execute(
"UPDATE alarms SET next_fire_at = ?1 WHERE id = ?2",
db::params!["2020-01-01T00:00:00+00:00", id],
)
.await
.unwrap();
}
#[tokio::test]
async fn one_attempt_at_a_time_and_a_waiting_alarm_waits_only_for_itself() {
let session = "assistant:alarm-one-attempt-it";
let (waiting, _rx) = arm_trigger_alarm(
session,
"alarm-waiting",
"no_such_alarm_tool",
serde_json::Map::new(),
)
.await;
let neighbour = add_alarm(
session,
"alarm-neighbour",
"the other check",
None,
Some(3600),
Some(weather_trigger()),
None,
)
.await
.expect("arm a second triggered alarm");
make_overdue(&waiting.id).await;
make_overdue(&neighbour.id).await;
assert!(
is_due(&waiting.id).await && is_due(&neighbour.id).await,
"both occurrences are due"
);
let slot = claim_attempt_slot(&waiting.id).expect("the attempt takes the alarm's slot");
assert!(
claim_attempt_slot(&waiting.id).is_none(),
"a second attempt of the same alarm must not start"
);
drop(slot);
let due = active_alarm(session, &waiting.id).await;
settle_trigger_attempt(
&due,
&StoredTrigger::Tool(weather_trigger()),
TriggerOutcome::Unfinished,
false,
)
.await
.unwrap();
let deferred = active_alarm(session, &waiting.id).await;
let retry_at = alarm_retry_at(&waiting.id)
.await
.expect("a wait is recorded");
let retry_in = seconds_from_now(&retry_at);
assert!(
(50..=70).contains(&retry_in),
"the retry must be about a minute out, got {retry_in}s"
);
assert_eq!(
deferred.next_fire_at, due.next_fire_at,
"the wait never moves the occurrence's own due moment"
);
assert!(
!is_due(&waiting.id).await,
"a waiting alarm is not due before its retry"
);
assert!(
is_due(&neighbour.id).await,
"a waiting alarm must not hold back the others"
);
store()
.conn
.execute(
"UPDATE alarms SET retry_at = ?1 WHERE id = ?2",
db::params!["2020-01-01T00:00:00+00:00", waiting.id.as_str()],
)
.await
.unwrap();
assert!(
is_due(&waiting.id).await,
"the retry becomes due when its cool-down passes"
);
crate::agent::message_router::unregister_agent(session);
}
#[cfg(unix)]
#[tokio::test]
async fn a_check_stopped_by_its_ceiling_keeps_the_alarm_and_its_report_is_late() {
let session = "assistant:alarm-ceiling-it";
let (alarm, mut rx) = arm_trigger_alarm(
session,
"alarm-ceiling",
"no_such_alarm_tool",
serde_json::Map::new(),
)
.await;
let due_at = alarm.next_fire_at.clone();
let trigger = StoredTrigger::Tool(weather_trigger());
let tmp = tempfile::TempDir::new().expect("tempdir");
let ws = crate::workspace::test_ws(tmp.path());
let stopped = crate::tools::shell::run_program_outcome_within(
ws.as_path(),
std::path::Path::new("/bin/sh"),
&["-c".to_string(), "echo started; sleep 30".to_string()],
Duration::from_secs(1),
)
.await;
assert!(
stopped.timed_out,
"the one-second ceiling must stop the run"
);
settle_trigger_attempt(&alarm, &trigger, TriggerOutcome::from_run(stopped), false)
.await
.unwrap();
assert!(
rx.try_recv().is_err(),
"nothing is delivered for an attempt that did not finish"
);
assert!(
!attempt_in_flight(&alarm.id),
"a restarted process has no attempt in flight"
);
let waiting = active_alarm(session, &alarm.id).await;
assert_eq!(
alarm_status(&alarm.id).await,
"active",
"a one-shot whose check did not finish is not consumed"
);
assert_eq!(waiting.unfinished_attempts, 1);
assert_eq!(waiting.next_fire_at, due_at);
assert!(
seconds_from_now(&alarm_retry_at(&alarm.id).await.expect("a wait is recorded")) > 0,
"the occurrence is kept and its retry is scheduled"
);
settle_trigger_attempt(
&waiting,
&trigger,
TriggerOutcome::Ran(ran(true, "exit status 0", "found it", true)),
true,
)
.await
.unwrap();
let job = rx.try_recv().expect("the late report must be delivered");
assert!(job.content.contains("LATE"), "got: {}", job.content);
assert!(
!job.content.contains("MISSED"),
"one lateness statement, not two: {}",
job.content
);
assert!(
job.content.contains(&due_at),
"the moment it was due for must be named: {}",
job.content
);
assert!(job.content.contains("found it"));
crate::agent::message_router::unregister_agent(session);
}
#[tokio::test]
async fn a_failed_check_removes_the_alarm_and_never_refires() {
let session = "assistant:alarm-failed-it";
let (alarm, mut rx) = arm_trigger_alarm(
session,
"alarm-failed",
"no_such_alarm_tool",
serde_json::Map::new(),
)
.await;
make_overdue(&alarm.id).await;
let due = active_alarm(session, &alarm.id).await;
settle_trigger_attempt(
&due,
&StoredTrigger::Tool(weather_trigger()),
TriggerOutcome::Ran(ran(false, "exit status 2", "boom", true)),
false,
)
.await
.unwrap();
let job = rx.try_recv().expect("a broken check must notify");
assert!(
job.content.contains("FAILED (exit status 2)"),
"got: {}",
job.content
);
assert!(job.content.contains("DELETED"), "got: {}", job.content);
assert_eq!(alarm_status(&alarm.id).await, "removed");
assert!(
!is_due(&alarm.id).await,
"a broken check must never re-fire"
);
crate::agent::message_router::unregister_agent(session);
}
#[test]
fn a_running_check_does_not_take_a_sweep_slot() {
let running = Alarm {
id: "alarm-slot-running".to_string(),
..trigger_alarm()
};
let waiting = Alarm {
id: "alarm-slot-waiting".to_string(),
..trigger_alarm()
};
let slot = claim_attempt_slot(&running.id).expect("the running check holds its slot");
let picked = startable(vec![running.clone(), waiting.clone()], 1);
assert_eq!(picked.len(), 1, "the batch keeps its full size");
assert_eq!(
picked[0].id, waiting.id,
"the running check is skipped, not counted"
);
assert!(
!attempt_in_flight(&waiting.id),
"picking an alarm takes no slot; its firing does"
);
drop(slot);
}
#[tokio::test]
async fn three_unfinished_attempts_notify_once_and_the_alarm_keeps_retrying() {
let session = "assistant:alarm-stuck-it";
let trigger = StoredTrigger::Tool(weather_trigger());
let mut alarm = arm_periodic_trigger_alarm(session, "alarm-stuck", 3600).await;
let mut rx = crate::agent::message_router::register_agent(session);
for attempt in 1..=4 {
settle_trigger_attempt(&alarm, &trigger, TriggerOutcome::Unfinished, false)
.await
.unwrap();
alarm = active_alarm(session, &alarm.id).await;
assert_eq!(alarm.unfinished_attempts, attempt);
if attempt == UNFINISHED_NOTICE_ATTEMPTS {
let job = rx
.try_recv()
.expect("the third unfinished attempt must notify");
assert!(
job.content.contains("not producing a result"),
"got: {}",
job.content
);
assert!(
!job.content.contains("DELETED"),
"this is not the deletion notice: {}",
job.content
);
} else {
assert!(rx.try_recv().is_err(), "attempt {attempt} must stay silent");
}
}
assert_eq!(
alarm_status(&alarm.id).await,
"active",
"a check that never finishes never removes its alarm"
);
settle_trigger_attempt(&alarm, &trigger, TriggerOutcome::Silent, false)
.await
.unwrap();
alarm = active_alarm(session, &alarm.id).await;
assert_eq!(alarm.unfinished_attempts, 0);
assert!(
alarm_retry_at(&alarm.id).await.is_none(),
"a completed check leaves no wait"
);
for attempt in 1..UNFINISHED_NOTICE_ATTEMPTS {
settle_trigger_attempt(&alarm, &trigger, TriggerOutcome::Unfinished, false)
.await
.unwrap();
alarm = active_alarm(session, &alarm.id).await;
assert_eq!(alarm.unfinished_attempts, attempt, "the count restarted");
assert!(
rx.try_recv().is_err(),
"the count restarted, so no notice arrives yet"
);
}
crate::agent::message_router::unregister_agent(session);
}
#[tokio::test]
async fn a_completed_check_resumes_the_periodic_schedule() {
let session = "assistant:alarm-resume-it";
let alarm = arm_periodic_trigger_alarm(session, "alarm-resume", 60).await;
let mut rx = crate::agent::message_router::register_agent(session);
let trigger = StoredTrigger::Tool(weather_trigger());
make_overdue(&alarm.id).await;
let due = active_alarm(session, &alarm.id).await;
let due_at = due.next_fire_at.clone();
settle_trigger_attempt(&due, &trigger, TriggerOutcome::Unfinished, false)
.await
.unwrap();
let retry = active_alarm(session, &alarm.id).await;
assert!(
alarm_retry_at(&alarm.id).await.is_some(),
"a waiting alarm records the retry it owes"
);
settle_trigger_attempt(&retry, &trigger, TriggerOutcome::Silent, false)
.await
.unwrap();
let resumed = active_alarm(session, &alarm.id).await;
assert!(alarm_retry_at(&alarm.id).await.is_none());
assert_eq!(resumed.unfinished_attempts, 0);
let next_in = seconds_from_now(&resumed.next_fire_at);
assert!(
(0..=60).contains(&next_in),
"the next occurrence is one period out at most, got {next_in}s"
);
let due = db::parse_utc_timestamp(&due_at).unwrap();
let next = db::parse_utc_timestamp(&resumed.next_fire_at).unwrap();
assert_eq!(
next.signed_duration_since(due).num_seconds() % 60,
0,
"the schedule resumes on its own grid: {} from {due_at}",
resumed.next_fire_at
);
assert!(
rx.try_recv().is_err(),
"a silent check must not wake the assistant"
);
crate::agent::message_router::unregister_agent(session);
}
async fn arm_periodic_trigger_alarm(session_id: &str, owner: &str, period_secs: u64) -> Alarm {
seed_owner(owner).await;
add_alarm(
session_id,
owner,
"poll the thing",
None,
Some(period_secs),
Some(weather_trigger()),
None,
)
.await
.expect("arm a periodic trigger alarm")
}
async fn arm_trigger_alarm(
session_id: &str,
owner: &str,
tool: &str,
args: serde_json::Map<String, serde_json::Value>,
) -> (
Alarm,
tokio::sync::mpsc::UnboundedReceiver<crate::agent::message_router::AgentJob>,
) {
seed_owner(owner).await;
let rx = crate::agent::message_router::register_agent(session_id);
let alarm = add_alarm(
session_id,
owner,
"poll the thing",
Some("2099-01-01T00:00:00Z"),
None,
Some(Trigger {
tool: tool.to_string(),
args,
}),
None,
)
.await
.expect("arm the trigger alarm");
(alarm, rx)
}
async fn seed_owner(name: &str) -> &'static crate::users::UserStore {
crate::util::test::init_management_test_stores().await;
let store = crate::users::USER_STORE
.get()
.expect("user store initialized");
store.add_user(name).await.unwrap();
store
}
fn catalogue_probe(name: &str, header: &str) -> ProbeFile {
let dir =
crate::users::personal_workspace_path(crate::users::ADMIN_USER_NAME).join("shared");
std::fs::create_dir_all(&dir).expect("create the shared folder");
let probe = ProbeFile(dir.join(format!("{name}.ts")));
std::fs::write(&probe.0, header).expect("write the probe tool");
probe
}
async fn fire_and_collect_notification(
alarm: &Alarm,
session_id: &str,
rx: &mut tokio::sync::mpsc::UnboundedReceiver<crate::agent::message_router::AgentJob>,
) -> String {
fire_alarm(alarm).await.unwrap();
let job = tokio::time::timeout(std::time::Duration::from_secs(30), rx.recv())
.await
.expect("check resolution must finish in time")
.expect("a check that could not run must notify");
crate::agent::message_router::unregister_agent(session_id);
assert!(job.content.contains("DELETED"), "got: {}", job.content);
assert_eq!(
alarm_status(&alarm.id).await,
"removed",
"a check that cannot run must not re-fire"
);
job.content
}
#[tokio::test]
async fn fire_plain_alarm_delivers_reminder_and_terminalizes_the_one_shot() {
crate::util::test::init_management_test_stores().await;
let mut rx = crate::agent::message_router::register_agent("assistant:alarm-plain-it");
let alarm = add_alarm(
"assistant:alarm-plain-it",
"alarm-plain",
"check the deploy",
Some("2099-01-01T00:00:00Z"),
None,
None,
None,
)
.await
.unwrap();
fire_alarm(&alarm).await.unwrap();
let job = rx.recv().await.expect("plain reminder must route");
assert!(job.content.contains("<alarm-notification>"));
assert!(job.content.contains("check the deploy"));
assert!(
!job.content.contains("MISSED"),
"a firing on time must not claim it was missed: {}",
job.content
);
crate::agent::message_router::unregister_agent("assistant:alarm-plain-it");
assert_eq!(
alarm_status(&alarm.id).await,
"fired",
"one-shot must be terminalized"
);
}
#[tokio::test]
async fn fire_late_alarm_says_the_firing_was_missed() {
crate::util::test::init_management_test_stores().await;
let session = "assistant:alarm-late-it";
let mut rx = crate::agent::message_router::register_agent(session);
let alarm = add_alarm(
session,
"alarm-late",
"check the deploy",
None,
Some(3600),
None,
None,
)
.await
.unwrap();
store()
.conn
.execute(
"UPDATE alarms SET next_fire_at = ?1 WHERE id = ?2",
db::params!["2020-01-01T00:00:00Z", alarm.id.as_str()],
)
.await
.unwrap();
let due = due_alarms(&db::now()).await.unwrap();
let stored = due
.iter()
.find(|candidate| candidate.id == alarm.id)
.expect("the alarm must be due");
fire_alarm(stored).await.unwrap();
let job = rx.recv().await.expect("late reminder must route");
crate::agent::message_router::unregister_agent(session);
assert!(
job.content
.contains("scheduled for 2020-01-01T00:00:00Z, which had already passed"),
"the occurrence that was missed must be the one named: {}",
job.content
);
assert_eq!(
alarm_status(&alarm.id).await,
"active",
"a periodic alarm survives its firing"
);
}
fn role_target(role: &str) -> AlarmTarget {
AlarmTarget {
role: role.to_string(),
identifier: None,
workspace: None,
}
}
async fn try_arm_role(user: &str, role: &str, trigger: Option<Trigger>) -> String {
add_alarm(
"assistant:role-target-it",
user,
"run it",
Some("2099-01-01T00:00:00Z"),
None,
trigger,
Some(role_target(role)),
)
.await
.unwrap_err()
.to_string()
}
#[tokio::test]
async fn add_alarm_validates_a_role_target_before_storing_it() {
crate::util::test::init_test_stores().await;
let err = try_arm_role("alice", "analyst", None).await;
assert!(err.contains("only the administrator"), "got: {err}");
let err = try_arm_role(crate::users::ADMIN_USER_NAME, "no_such_role", None).await;
assert!(
err.contains("no custom role named 'no_such_role'"),
"got: {err}"
);
crate::custom_role::create(
"alarm_role_probe",
"do it",
&[],
crate::custom_role::CustomRoleBehavior::Continuous,
)
.await
.unwrap();
let err = try_arm_role(
crate::users::ADMIN_USER_NAME,
"alarm_role_probe",
Some(weather_trigger()),
)
.await;
assert!(
err.contains("cannot be combined with a role target"),
"got: {err}"
);
}
#[tokio::test]
async fn fire_role_alarm_launches_the_role_and_terminalizes_the_alarm() {
crate::util::test::init_management_test_stores().await;
crate::custom_role::create(
"alarm_runner",
"do the thing",
&[],
crate::custom_role::CustomRoleBehavior::Continuous,
)
.await
.unwrap();
let alarm = add_alarm(
&crate::session::assistant_agent_id(crate::users::ADMIN_USER_NAME),
crate::users::ADMIN_USER_NAME,
"run the runner",
Some("2099-01-01T00:00:00Z"),
None,
None,
Some(role_target("alarm_runner")),
)
.await
.unwrap();
let branch = crate::session::custom_role_agent_id("alarm_runner", None);
let mut rx = crate::agent::message_router::register_agent(&branch);
fire_alarm(&alarm).await.unwrap();
let job = rx
.recv()
.await
.expect("the role's branch must receive the launch message");
crate::agent::message_router::unregister_agent(&branch);
assert_eq!(job.content, "run the runner");
assert_eq!(
alarm_status(&alarm.id).await,
"fired",
"one-shot must be terminalized"
);
}
#[tokio::test]
async fn fire_role_alarm_reports_a_deleted_role_and_removes_the_alarm() {
crate::util::test::init_management_test_stores().await;
crate::custom_role::create(
"alarm_vanishing",
"do the thing",
&[],
crate::custom_role::CustomRoleBehavior::Continuous,
)
.await
.unwrap();
let alarm = add_alarm(
&crate::session::assistant_agent_id(crate::users::ADMIN_USER_NAME),
crate::users::ADMIN_USER_NAME,
"wake the role",
Some("2099-01-01T00:00:00Z"),
None,
None,
Some(role_target("alarm_vanishing")),
)
.await
.unwrap();
crate::custom_role::CUSTOM_ROLES
.get()
.expect("custom role store")
.conn
.execute(
"DELETE FROM custom_roles WHERE name = ?1",
db::params!["alarm_vanishing"],
)
.await
.unwrap();
let mut rx = crate::agent::message_router::register_agent(&alarm.session_id);
fire_alarm(&alarm).await.unwrap();
let job = rx.recv().await.expect("the owner must be told");
crate::agent::message_router::unregister_agent(&alarm.session_id);
assert!(job.content.contains("<alarm-notification>"));
assert!(
job.content.contains("alarm_vanishing"),
"got: {}",
job.content
);
assert!(
job.content.contains("no longer exists"),
"got: {}",
job.content
);
assert_eq!(alarm_status(&alarm.id).await, "removed");
}
#[tokio::test]
async fn delete_alarms_for_role_soft_deletes_only_its_active_alarms() {
crate::util::test::init_management_test_stores().await;
crate::custom_role::create(
"alarm_cleanup",
"done",
&[],
crate::custom_role::CustomRoleBehavior::Continuous,
)
.await
.unwrap();
let alarm = add_alarm(
&crate::session::assistant_agent_id(crate::users::ADMIN_USER_NAME),
crate::users::ADMIN_USER_NAME,
"one",
Some("2099-01-01T00:00:00Z"),
None,
None,
Some(role_target("alarm_cleanup")),
)
.await
.unwrap();
let plain = add_alarm(
"assistant:alarm-cleanup-it",
"alarm-cleanup-user",
"two",
Some("2099-01-01T00:00:00Z"),
None,
None,
None,
)
.await
.unwrap();
assert_eq!(delete_alarms_for_role("alarm_cleanup").await.unwrap(), 1);
assert_eq!(alarm_status(&alarm.id).await, "removed");
assert_eq!(alarm_status(&plain.id).await, "active");
assert_eq!(delete_alarms_for_role("alarm_cleanup").await.unwrap(), 0);
}
#[tokio::test]
async fn fire_trigger_alarm_reports_a_tool_that_is_gone_and_removes_the_alarm() {
let (alarm, mut rx) = arm_trigger_alarm(
"assistant:alarm-gone-it",
crate::users::ADMIN_USER_NAME,
"no_such_alarm_tool",
serde_json::Map::new(),
)
.await;
let content =
fire_and_collect_notification(&alarm, "assistant:alarm-gone-it", &mut rx).await;
assert!(content.contains("<alarm-notification>"));
assert!(content.contains("poll the thing"));
assert!(content.contains("no_such_alarm_tool"), "got: {content}");
assert!(content.contains("no longer usable"), "got: {content}");
}
#[tokio::test]
async fn fire_trigger_alarm_reports_a_revoked_grant_and_removes_the_alarm() {
let owner = "revoked_grant_guest";
let store = seed_owner(owner).await;
store.add_grant(owner, "alarm_grant_probe").await.unwrap();
let (alarm, mut rx) = arm_trigger_alarm(
"assistant:alarm-revoked-it",
owner,
"alarm_grant_probe",
serde_json::Map::new(),
)
.await;
let _probe = catalogue_probe("alarm_grant_probe", "// @description Probe.\n");
store
.remove_grant(owner, "alarm_grant_probe")
.await
.unwrap();
let content =
fire_and_collect_notification(&alarm, "assistant:alarm-revoked-it", &mut rx).await;
assert!(
content.contains("no longer available to you"),
"got: {content}"
);
}
#[tokio::test]
async fn fire_trigger_alarm_reports_arguments_that_no_longer_fit() {
let (alarm, mut rx) = arm_trigger_alarm(
"assistant:alarm-args-it",
crate::users::ADMIN_USER_NAME,
"alarm_args_probe",
args_of(&serde_json::json!({ "city": "Minsk" })),
)
.await;
let _probe = catalogue_probe("alarm_args_probe", "// @description Probe.\n");
let content =
fire_and_collect_notification(&alarm, "assistant:alarm-args-it", &mut rx).await;
assert!(content.contains("no longer fit"), "got: {content}");
assert!(
content.contains("[ignored arguments: city]"),
"got: {content}"
);
}
#[tokio::test]
async fn fire_trigger_alarm_reports_a_stored_check_it_cannot_read() {
let session = "assistant:alarm-unreadable-it";
let (alarm, mut rx) = arm_trigger_alarm(
session,
crate::users::ADMIN_USER_NAME,
"alarm_unreadable_probe",
serde_json::Map::new(),
)
.await;
store()
.conn
.execute(
"UPDATE alarms SET trigger = ?1, next_fire_at = ?2 WHERE id = ?3",
db::params![
"not a stored check",
"2020-01-01T00:00:00Z",
alarm.id.as_str()
],
)
.await
.unwrap();
let due = due_alarms(&db::now()).await.unwrap();
let stored = due
.iter()
.find(|candidate| candidate.id == alarm.id)
.expect("the alarm must be due");
let content = fire_and_collect_notification(stored, session, &mut rx).await;
assert!(content.contains("cannot be read"), "got: {content}");
assert!(
content.contains("unreadable stored check"),
"the notice must show a marker where the check would be: {content}"
);
}
}