feat(event-triage): per-user check interval, overriding the instance one
Nightly Build / build (push) Successful in 5m4s

Event triage is the one system agent whose right cadence depends on who it
runs for: it fires on inbound events, so someone on a dozen mailing lists
has something waiting on nearly every tick while a quiet account has
something waiting almost never. A single instance-wide interval serves one
of them badly, and the observed failure is the first: the agent starts on
practically every pass.

An admin can now set a per-person interval on that user's page (Users ->
the person -> Event triage). Empty means "follow the instance setting",
which stays the state nobody has a row for.

- New registry table `system_agent_user_settings(agent_id, user_id,
  interval_secs)`. A row is an override and its absence is inheritance --
  no sentinel value, no row seeded at user creation, clearing the field
  deletes the row. Registry rather than the user's own file because the
  writer is the admin and a member's database is unreadable unless they
  happen to be logged in; a setting that could only be changed during its
  subject's session would not be a setting. Keyed by agent_id though only
  one agent uses it, so a future agent's schedule is not a schema change.

- `SystemAgent` gains `interval_secs_for(user_id)`, which `is_due` now
  measures against, and `shortest_interval_secs()`. Both default to the
  existing `interval_secs`, so every other agent implements nothing. The
  second is the non-obvious half: `base_tick` sleeps for the shortest
  interval any enabled agent asks for, so without it an override below the
  instance value would be rounded up to it -- an override that works when
  it lengthens and silently does nothing when it shortens.

- `GET/PUT /api/users/{id}/event-triage`, admin-gated, minutes on the
  wire, null to clear. Nothing rides the bus: the scheduler re-reads the
  interval every tick and due-ness is counted from the user's own last
  attempt, so a change lands on the next wake-up with no push.

Both helpers fail open onto the instance value -- an unreadable registry
must not turn into an agent that stops running for someone.

Docs: docs/system-agents.md gains the per-person section and no longer
reads as if the interval were one number for everybody.
This commit is contained in:
Daniele
2026-08-14 13:07:45 +01:00
parent 402c9ffe50
commit e7c802f0d7
13 changed files with 573 additions and 12 deletions
+30 -1
View File
@@ -35,6 +35,7 @@ pub mod supervision;
pub mod system_agent_coverage;
pub mod system_agent_runs;
pub mod system_agent_state;
pub mod system_agent_user_settings;
pub mod tool_permission_groups;
pub mod user_config;
pub mod users;
@@ -192,7 +193,7 @@ async fn ensure_column(pool: &SqlitePool, table: &str, column: &str, decl: &str)
// Instance-wide, readable without any user key: the directory you must open
// before you know who exists. Nothing here is scoped to one user.
async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
pub(crate) async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS llm_providers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -760,6 +761,34 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
.execute(pool)
.await?;
// Per-user overrides of a system agent's schedule. **A row is an override and
// nothing else** — its absence means "use the instance-wide setting", which is
// why there is no `inherit` flag and no row written at user creation.
//
// Registry rather than owner, and not for the reason `system_agent_coverage`
// is: this one is written *by the admin about a member*, on the Users page,
// and a member's own file is unreadable unless they happen to be logged in
// (§9). A setting an admin can only change while its subject has a live
// session would not be a setting. It is admin-readable, like the rest of the
// directory metadata next to it, and holds no content — a number of seconds.
//
// `agent_id` is bare TEXT with no `system_agent_*` table to reference (the
// agents are code, not rows), and is kept in the key even though only event
// triage uses it today: the alternative is a column per agent on `users`, and
// "a fourth agent is a trait impl plus one registry line" would stop being
// true the moment its schedule needed a schema change.
sqlx::query(
"CREATE TABLE IF NOT EXISTS system_agent_user_settings (
agent_id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
interval_secs INTEGER,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (agent_id, user_id)
)",
)
.execute(pool)
.await?;
Ok(())
}
@@ -0,0 +1,161 @@
//! Accessor for `system_agent_user_settings` — per-user overrides of a system
//! agent's schedule.
//!
//! The whole contract is in the absence of a row: **no row means the instance
//! setting applies**, so every read here answers `Option` and every caller falls
//! back rather than defaulting. Clearing an override therefore [`clear`]s the row
//! instead of writing a sentinel — a `0` or a `-1` standing for "inherit" would
//! be a second way to say what the empty table already says, and the two would
//! eventually disagree.
//!
//! Registry table: written by an admin about a member, from the Users page. See
//! the table comment in [`super::create_registry_tables`] for why it cannot live
//! in the member's own file.
use anyhow::Result;
use sqlx::SqlitePool;
/// One user's override of `agent_id`'s interval, in seconds, or `None` when they
/// have none and the instance-wide setting stands.
pub async fn interval_secs(
pool: &SqlitePool,
agent_id: &str,
user_id: &str,
) -> Result<Option<i64>> {
let secs = sqlx::query_scalar::<_, Option<i64>>(
"SELECT interval_secs FROM system_agent_user_settings
WHERE agent_id = ? AND user_id = ?",
)
.bind(agent_id)
.bind(user_id)
.fetch_optional(pool)
.await?
.flatten();
Ok(secs)
}
/// Set `user_id`'s override for `agent_id`.
pub async fn set_interval_secs(
pool: &SqlitePool,
agent_id: &str,
user_id: &str,
secs: i64,
) -> Result<()> {
sqlx::query(
"INSERT INTO system_agent_user_settings (agent_id, user_id, interval_secs)
VALUES (?, ?, ?)
ON CONFLICT(agent_id, user_id) DO UPDATE SET
interval_secs = excluded.interval_secs,
updated_at = datetime('now')",
)
.bind(agent_id)
.bind(user_id)
.bind(secs)
.execute(pool)
.await?;
Ok(())
}
/// Drop `user_id`'s override, so they follow the instance setting again.
pub async fn clear(pool: &SqlitePool, agent_id: &str, user_id: &str) -> Result<()> {
sqlx::query("DELETE FROM system_agent_user_settings WHERE agent_id = ? AND user_id = ?")
.bind(agent_id)
.bind(user_id)
.execute(pool)
.await?;
Ok(())
}
/// The shortest override anyone holds for `agent_id`, or `None` when nobody
/// overrides it.
///
/// Exists for the scheduler's wake-up: it sleeps for the shortest interval any
/// enabled agent asks for, and an override *below* the instance value would
/// otherwise be rounded up to it — silently, and only in that direction, which is
/// the kind of half-working setting that is worse than one that does nothing.
pub async fn shortest_interval_secs(pool: &SqlitePool, agent_id: &str) -> Result<Option<i64>> {
let secs = sqlx::query_scalar::<_, Option<i64>>(
"SELECT MIN(interval_secs) FROM system_agent_user_settings
WHERE agent_id = ? AND interval_secs IS NOT NULL",
)
.bind(agent_id)
.fetch_one(pool)
.await?;
Ok(secs)
}
#[cfg(test)]
mod tests {
use super::*;
const AGENT: &str = "event-triage";
async fn pool() -> SqlitePool {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
crate::db::create_registry_tables(&pool).await.unwrap();
crate::db::roles::seed_admin(&pool).await.unwrap();
for id in ["alice", "bob"] {
sqlx::query(
"INSERT INTO users (id, username, role_id, encrypted) VALUES (?, ?, 'admin', 0)",
)
.bind(id)
.bind(id)
.execute(&pool)
.await
.unwrap();
}
pool
}
#[tokio::test]
async fn no_row_means_inherit() {
let pool = pool().await;
assert_eq!(interval_secs(&pool, AGENT, "alice").await.unwrap(), None);
assert_eq!(shortest_interval_secs(&pool, AGENT).await.unwrap(), None);
}
#[tokio::test]
async fn an_override_is_set_then_replaced_then_cleared() {
let pool = pool().await;
set_interval_secs(&pool, AGENT, "alice", 3600).await.unwrap();
assert_eq!(interval_secs(&pool, AGENT, "alice").await.unwrap(), Some(3600));
set_interval_secs(&pool, AGENT, "alice", 1800).await.unwrap();
assert_eq!(interval_secs(&pool, AGENT, "alice").await.unwrap(), Some(1800));
let rows = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM system_agent_user_settings")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(rows, 1, "setting an override must upsert, not accumulate");
clear(&pool, AGENT, "alice").await.unwrap();
assert_eq!(interval_secs(&pool, AGENT, "alice").await.unwrap(), None);
}
#[tokio::test]
async fn users_and_agents_do_not_share_a_row() {
let pool = pool().await;
set_interval_secs(&pool, AGENT, "alice", 3600).await.unwrap();
assert_eq!(interval_secs(&pool, AGENT, "bob").await.unwrap(), None);
assert_eq!(interval_secs(&pool, "memory-lint", "alice").await.unwrap(), None);
}
#[tokio::test]
async fn the_shortest_override_is_the_scheduler_floor() {
let pool = pool().await;
set_interval_secs(&pool, AGENT, "alice", 3600).await.unwrap();
set_interval_secs(&pool, AGENT, "bob", 120).await.unwrap();
assert_eq!(shortest_interval_secs(&pool, AGENT).await.unwrap(), Some(120));
// Another agent's overrides must not drag this one's wake-up down.
set_interval_secs(&pool, "memory-lint", "alice", 60).await.unwrap();
assert_eq!(shortest_interval_secs(&pool, AGENT).await.unwrap(), Some(120));
}
#[tokio::test]
async fn deleting_a_user_takes_their_overrides() {
let pool = pool().await;
set_interval_secs(&pool, AGENT, "alice", 3600).await.unwrap();
sqlx::query("DELETE FROM users WHERE id = 'alice'").execute(&pool).await.unwrap();
assert_eq!(interval_secs(&pool, AGENT, "alice").await.unwrap(), None);
}
}
+18 -3
View File
@@ -38,8 +38,8 @@ use crate::config_store::GlobalConfigManager;
use crate::db::mcp_events;
use crate::system_agents::{
AgentOutcome, AgentRunCtx, AgentScope, SystemAgent, configured_run_context,
enabled_from_config, enabled_property, interval_from_config, run_ephemeral_turn,
security_group_property,
enabled_from_config, enabled_property, interval_for_user, interval_from_config,
run_ephemeral_turn, security_group_property, shortest_interval_for,
};
/// The chat `source` the ephemeral triage sessions carry. Kept distinct from the
@@ -77,7 +77,9 @@ pub fn config_set() -> ConfigSet {
name: "Check interval (minutes)".into(),
description: "How long between passes for each user, in minutes. Counted per \
person from their own last pass. Leave empty to use the value from \
config.yml (event_triage.interval_secs)."
config.yml (event_triage.interval_secs). This is the default: a \
single user can be put on a slower (or faster) cadence from their \
own page under Users."
.into(),
property_type: PropertyType::Int,
default_value: Some("15".into()),
@@ -174,6 +176,19 @@ impl SystemAgent for EventTriageManager {
.await
}
/// This user's own cadence, if an admin set one on their page.
async fn interval_secs_for(&self, user_id: &str) -> u64 {
let instance = self.interval_secs().await;
interval_for_user(&self.registry_pool, EVENT_TRIAGE_AGENT, user_id, instance).await
}
/// The shortest cadence anybody is on, so the scheduler's wake-up is frequent
/// enough to honour an override *below* the instance interval.
async fn shortest_interval_secs(&self) -> u64 {
let instance = self.interval_secs().await;
shortest_interval_for(&self.registry_pool, EVENT_TRIAGE_AGENT, instance).await
}
/// No pending events means no pass at all — and no row. The batch is re-read
/// in [`EventTriageManager::triage`]; it is one indexed query on a small
/// table, and paying it twice is cheaper than a trait shaped around carrying
+7 -2
View File
@@ -395,6 +395,11 @@ pub(super) fn spawn_system_agents(skald: &Arc<super::Skald>) {
/// not to spin. The floor keeps a misconfigured one-minute interval from turning
/// into a busy loop; the ceiling keeps a box that runs only weekly agents from
/// sleeping so long that a freshly changed setting takes hours to be noticed.
///
/// It asks each agent for its *shortest* interval rather than its instance one,
/// because a per-user override can be shorter than the instance setting and
/// would otherwise be rounded up to it — an override that works when it
/// lengthens and quietly does nothing when it shortens.
async fn base_tick(agents: &[Arc<dyn SystemAgent>]) -> Duration {
const FLOOR_SECS: u64 = 60;
const CEIL_SECS: u64 = 15 * 60;
@@ -402,7 +407,7 @@ async fn base_tick(agents: &[Arc<dyn SystemAgent>]) -> Duration {
let mut shortest = CEIL_SECS;
for agent in agents {
if agent.is_enabled().await {
shortest = shortest.min(agent.interval_secs().await);
shortest = shortest.min(agent.shortest_interval_secs().await);
}
}
Duration::from_secs(shortest.clamp(FLOOR_SECS, CEIL_SECS))
@@ -621,7 +626,7 @@ async fn run_one(
return;
};
if !system_agents::is_due(agent, &ctx.pool).await {
if !system_agents::is_due(agent, &ctx.pool, user_id).await {
return;
}
+123 -4
View File
@@ -157,9 +157,36 @@ pub trait SystemAgent: Send + Sync {
/// Instance-wide on/off switch, re-read every pass.
async fn is_enabled(&self) -> bool;
/// How long between passes **for one user**, in seconds.
/// How long between passes **for one user**, in seconds — the instance-wide
/// setting, which stands for anyone with no override of their own.
async fn interval_secs(&self) -> u64;
/// The same, for one named user: the effective cadence [`is_due`] measures
/// against.
///
/// Defaults to [`Self::interval_secs`], so an agent whose schedule is the
/// same for everybody implements nothing. Only event triage differs today,
/// and for a reason that does not generalise on its own: it fires on inbound
/// events, so its cadence is a property of *how much mail a person gets*
/// rather than of the instance — someone on a dozen mailing lists is triaged
/// on almost every tick, which is a per-person problem and wants a per-person
/// answer.
async fn interval_secs_for(&self, _user_id: &str) -> u64 {
self.interval_secs().await
}
/// The shortest interval this agent could ask for, over every user.
///
/// The scheduler sleeps for the shortest interval any enabled agent wants, so
/// an agent whose per-user overrides can go *below* its instance setting has
/// to say so here — otherwise the wake-up never comes round often enough and
/// the override silently only works in one direction. Defaults to
/// [`Self::interval_secs`] alongside the method above, so the two stay
/// consistent for an agent that implements neither.
async fn shortest_interval_secs(&self) -> u64 {
self.interval_secs().await
}
/// Cheap look at whether this pass would do anything, before a run row is
/// opened. `false` means "nothing to do" and leaves no trace behind.
async fn has_work(&self, ctx: &AgentRunCtx<'_>) -> Result<bool>;
@@ -350,12 +377,12 @@ impl fmt::Display for ManualRunError {
}
/// Is `agent` due for this user? `true` when it has never run here, or when the
/// last attempt is older than the configured interval.
/// last attempt is older than the interval **that user** is on.
///
/// Read from the database rather than an in-memory deadline, which is what makes
/// a weekly agent survive a restart — see the `system_agent_state` table comment.
pub async fn is_due(agent: &dyn SystemAgent, pool: &SqlitePool) -> bool {
let interval = agent.interval_secs().await as i64;
pub async fn is_due(agent: &dyn SystemAgent, pool: &SqlitePool, user_id: &str) -> bool {
let interval = agent.interval_secs_for(user_id).await as i64;
match system_agent_state::seconds_since_attempt(pool, agent.id()).await {
Ok(Some(elapsed)) => elapsed >= interval,
// Never attempted here — due now.
@@ -563,6 +590,42 @@ pub async fn interval_from_config(
default_secs
}
/// `user_id`'s own interval for `agent_id`, falling back to `instance_secs` when
/// they have no override.
///
/// Fails **open**, onto the instance value: an unreadable registry must not turn
/// into an agent that stops running for someone, and the instance setting is the
/// answer that was correct before overrides existed.
pub async fn interval_for_user(
registry_pool: &SqlitePool,
agent_id: &str,
user_id: &str,
instance_secs: u64,
) -> u64 {
match crate::db::system_agent_user_settings::interval_secs(registry_pool, agent_id, user_id).await {
Ok(Some(secs)) if secs > 0 => secs as u64,
Ok(_) => instance_secs,
Err(e) => {
warn!(agent = agent_id, user = %user_id, error = %e,
"system-agents: cannot read the per-user interval, using the instance one");
instance_secs
}
}
}
/// The shortest cadence `agent_id` is on anywhere: the instance setting, or a
/// shorter override if some user holds one. For [`SystemAgent::shortest_interval_secs`].
pub async fn shortest_interval_for(
registry_pool: &SqlitePool,
agent_id: &str,
instance_secs: u64,
) -> u64 {
match crate::db::system_agent_user_settings::shortest_interval_secs(registry_pool, agent_id).await {
Ok(Some(secs)) if secs > 0 => instance_secs.min(secs as u64),
_ => instance_secs,
}
}
/// The on/off switch every system agent has.
pub fn enabled_property(key: &str, description: &str) -> ConfigProperty {
ConfigProperty {
@@ -661,6 +724,62 @@ mod tests {
);
}
/// The event-triage agent, over a real registry so the per-user interval has
/// somewhere to be read from.
async fn triage_over_registry() -> (Arc<dyn SystemAgent>, Arc<SqlitePool>) {
let pool = Arc::new(SqlitePool::connect("sqlite::memory:").await.unwrap());
crate::db::create_registry_tables(&pool).await.unwrap();
crate::db::roles::seed_admin(&pool).await.unwrap();
sqlx::query("INSERT INTO users (id, username, role_id, encrypted) VALUES ('alice','alice','admin',0)")
.execute(pool.as_ref()).await.unwrap();
let bus = Arc::new(core_api::system_bus::SystemEventBus::new());
let cfg = Arc::new(GlobalConfigManager::new(Arc::clone(&pool), Arc::clone(&bus)));
let agent = registry(Default::default(), cfg, Arc::clone(&pool), bus)
.into_iter()
.find(|a| a.id() == crate::event_triage::EVENT_TRIAGE_AGENT)
.unwrap();
(agent, pool)
}
#[tokio::test]
async fn with_no_override_a_user_is_on_the_instance_interval() {
let (agent, _pool) = triage_over_registry().await;
let instance = agent.interval_secs().await;
assert_eq!(agent.interval_secs_for("alice").await, instance);
assert_eq!(agent.shortest_interval_secs().await, instance);
// Somebody with no row at all — the ordinary case for every other agent.
assert_eq!(agent.interval_secs_for("nobody").await, instance);
}
#[tokio::test]
async fn an_override_moves_only_that_user() {
let (agent, pool) = triage_over_registry().await;
let instance = agent.interval_secs().await;
crate::db::system_agent_user_settings::set_interval_secs(
&pool, crate::event_triage::EVENT_TRIAGE_AGENT, "alice", 3600,
).await.unwrap();
assert_eq!(agent.interval_secs_for("alice").await, 3600);
assert_eq!(agent.interval_secs_for("bob").await, instance);
// Longer than the instance value, so the scheduler's wake-up must not move.
assert_eq!(agent.shortest_interval_secs().await, instance);
}
/// The direction that would silently do nothing if `base_tick` asked for the
/// instance interval: an override *below* it has to pull the wake-up down.
#[tokio::test]
async fn a_shorter_override_pulls_the_wake_up_down() {
let (agent, pool) = triage_over_registry().await;
let instance = agent.interval_secs().await;
crate::db::system_agent_user_settings::set_interval_secs(
&pool, crate::event_triage::EVENT_TRIAGE_AGENT, "alice", 120,
).await.unwrap();
assert!(instance > 120, "the shipped default is 15 minutes");
assert_eq!(agent.shortest_interval_secs().await, 120);
}
/// Constructing the agents touches no table — the pool is only a handle they
/// hold on to — so an empty database is enough.
async fn test_agents() -> Arc<SystemAgents> {