rename the TIC system agent to event triage
Nightly Build / build (push) Successful in 7m16s

TIC said nothing about what the agent does, and named the wrong thing: the
tick belongs to the scheduler, which is generic and lives outside it. The
agent's only decision is whether an incoming event deserves an interruption
— it sorts, it never acts — so it is now event-triage, matching the
functional naming of the two memory lints.

- agents/tic/ -> agents/event-triage/, module tic/ -> event_triage/,
  TicManager -> EventTriageManager, TicConfig -> EventTriageConfig
- agent id and chat source: "tic" -> "event-triage"
- config keys: tic.* -> event_triage.*, and the config.yml section tic: ->
  event_triage: (greenfield: previously set values fall back to defaults)
- i18n en/it/fr: Event triage / Triage eventi / Tri des evenements; dropped
  the stale "TIC sessions" mention from the debug-pages description
- docs/system-agents.md, docs/index.md, docs/settings.md, CLAUDE.md, SKALD.md
This commit is contained in:
2026-07-28 21:59:37 +01:00
parent 0b793d56ae
commit 046f060fcd
50 changed files with 251 additions and 240 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ pub struct ChatMessage {
pub status: String,
pub input_tokens: Option<i64>,
pub output_tokens: Option<i64>,
/// True for messages injected synthetically (e.g. TIC notifications) — not
/// True for messages injected synthetically (e.g. event triage notifications) — not
/// typed by a real user. Stored in DB so the UI can skip them on reload.
pub is_synthetic: bool,
/// Chain-of-thought from reasoning models (e.g. DeepSeek thinking mode).
+2 -2
View File
@@ -5,9 +5,9 @@ pub struct ChatSession {
pub source: String,
pub agent_id: String,
/// True when a real user is actively participating (web, telegram).
/// False for fully automated sessions (cron, tic).
/// False for fully automated sessions (cron, event-triage).
pub is_interactive: bool,
/// True for short-lived task sessions (cron, tic) with no long-term
/// True for short-lived task sessions (cron, event-triage) with no long-term
/// conversational value. May be used to skip memory / analytics sinks.
pub is_ephemeral: bool,
/// Optional RunContext JSON blob assigned to this session.
+1 -1
View File
@@ -58,7 +58,7 @@ pub async fn mark_processed(pool: &SqlitePool, ids: &[i64]) -> Result<()> {
// ── Read ──────────────────────────────────────────────────────────────────────
/// Oldest N pending (unprocessed) events, ordered oldest-first.
/// Used by TicManager to fetch a bounded batch each tick.
/// Used by EventTriageManager to fetch a bounded batch each pass.
pub async fn pending_limited(pool: &SqlitePool, limit: i64) -> Result<Vec<McpEvent>> {
let rows = sqlx::query_as::<_, (i64, String, String, String, bool, Option<String>, String)>(
"SELECT id, source, method, payload, processed, processed_at, created_at
+10 -10
View File
@@ -898,14 +898,14 @@ pub async fn create_owner_tables(pool: &SqlitePool) -> Result<()> {
.await?;
// Execution log of the **system agents** — the background agents the instance
// runs on a user's behalf without being asked (TIC is the first and, today,
// the only one). The sibling of `job_runs`: same shape, but keyed on the agent
// instead of a scheduled job, because a system agent has no user-authored row
// to point at.
// runs on a user's behalf without being asked (event triage is the first and,
// today, the only one). The sibling of `job_runs`: same shape, but keyed on the
// agent instead of a scheduled job, because a system agent has no user-authored
// row to point at.
//
// Owner table, and that is the whole privacy story: a run of TIC summarises
// what landed in this user's inbox, so it belongs in *their* encrypted file
// and nowhere else. There is deliberately no `user_id` column — the file is
// Owner table, and that is the whole privacy story: an event-triage run
// summarises what landed in this user's inbox, so it belongs in *their*
// encrypted file and nowhere else. There is deliberately no `user_id` column — the file is
// the owner (§5.1). An admin reading `system.db` learns nothing about it.
//
// A user whose database is still locked is skipped by the scheduler and
@@ -948,8 +948,8 @@ pub async fn create_owner_tables(pool: &SqlitePool) -> Result<()> {
// productive run aged out.
//
// Persisting it is what makes a long interval survive a restart. An in-memory
// deadline is fine at TIC's scale — a few minutes, re-armed on boot — but a
// weekly agent on a machine rebooted every few days would have its deadline
// deadline is fine at event triage's scale — a few minutes, re-armed on boot —
// but a weekly agent on a machine rebooted every few days would have its deadline
// reset before it ever fired, and would simply never run.
//
// Owner table for the same reason as the run log: when an agent last ran for
@@ -1166,7 +1166,7 @@ mod tests {
one("INSERT INTO scheduled_jobs (id, title, cron, prompt, session_id) VALUES (1, 't', '* * * * *', 'p', 1)")
.await.unwrap();
one("INSERT INTO job_runs (job_id, started_at, status) VALUES (1, 'now', 'completed')").await.unwrap();
one("INSERT INTO system_agent_runs (agent_id, started_at, status) VALUES ('tic', 'now', 'running')").await.unwrap();
one("INSERT INTO system_agent_runs (agent_id, started_at, status) VALUES ('event-triage', 'now', 'running')").await.unwrap();
// Owner table with a BARE `catalog_name` ref — proves it stands alone with
// FKs on (an owner→registry FK here would die on this INSERT).
one("INSERT INTO mcp_user_servers (name, catalog_name, source) VALUES ('u', 'whatsapp', 'local_script')").await.unwrap();
@@ -28,7 +28,7 @@ pub struct SystemAgentRun {
pub completed_at: Option<String>,
pub duration_ms: Option<i64>,
pub status: String,
/// Free-form JSON with the agent's own counters (TIC: events processed,
/// Free-form JSON with the agent's own counters (event triage: events processed,
/// notifications emitted). Never the event contents.
pub stats: Option<String>,
pub error: Option<String>,
@@ -65,19 +65,19 @@ mod tests {
#[tokio::test]
async fn never_attempted_reads_as_due() {
let pool = pool().await;
assert!(last_attempt_at(&pool, "tic").await.unwrap().is_none());
assert!(seconds_since_attempt(&pool, "tic").await.unwrap().is_none());
assert!(last_attempt_at(&pool, "event-triage").await.unwrap().is_none());
assert!(seconds_since_attempt(&pool, "event-triage").await.unwrap().is_none());
}
#[tokio::test]
async fn an_attempt_is_recorded_and_then_overwritten() {
let pool = pool().await;
mark_attempt(&pool, "tic").await.unwrap();
let first = last_attempt_at(&pool, "tic").await.unwrap().unwrap();
mark_attempt(&pool, "event-triage").await.unwrap();
let first = last_attempt_at(&pool, "event-triage").await.unwrap().unwrap();
// Fresh attempt: still one row for this agent, and the age is small.
mark_attempt(&pool, "tic").await.unwrap();
assert!(seconds_since_attempt(&pool, "tic").await.unwrap().unwrap() < 5);
mark_attempt(&pool, "event-triage").await.unwrap();
assert!(seconds_since_attempt(&pool, "event-triage").await.unwrap().unwrap() < 5);
assert!(!first.is_empty());
let rows = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM system_agent_state")
@@ -90,8 +90,8 @@ mod tests {
#[tokio::test]
async fn agents_do_not_share_a_row() {
let pool = pool().await;
mark_attempt(&pool, "tic").await.unwrap();
assert!(seconds_since_attempt(&pool, "tic").await.unwrap().is_some());
mark_attempt(&pool, "event-triage").await.unwrap();
assert!(seconds_since_attempt(&pool, "event-triage").await.unwrap().is_some());
assert!(seconds_since_attempt(&pool, "memory-lint").await.unwrap().is_none());
}
}