tic: run per-user under a system-agent scheduler, with a run log
Nightly Build / build (push) Successful in 6m58s
Nightly Build / build (push) Successful in 6m58s
Reframe TIC from an ownerless global loop into a per-user system agent.
The events it reads live in each user's own encrypted mcp_events, the
connectors that produced them run in that user's container, and the
notifications go to that user's hub — so the previous design (built
against the ownerless Conversation bundle, writing into system.db and
notifying a hub with no subscribers) was inert by construction.
Core changes
- TicManager owns no timer and no user list. It now exposes
run_for(user_id, pool, sessions, hub): one tick for one user, over
deps unpacked from that user's UserContext. Removed from the
Conversation bundle; Skald::tic_manager() is gone.
- New spawn_system_agents in wiring.rs: one instance-wide loop, spawned
post-construction with a Weak<Skald> (like spawn_user_lifecycle).
Each pass walks the directory and runs TIC for one user at a time —
sequential, because a pass is N container round-trips and N LLM calls
nobody is waiting on. A ConfigKeyUpdated on the interval key cuts the
current wait short; enabled is re-read per pass.
- A user whose database is still locked is skipped (normal, not an
error): the pool is the unlock token, so a user who hasn't logged in
since restart has no readable events and nowhere to record a skip.
- The configured tic.security_group is re-checked per user through
run_context::reconcile_group_for_user — a restricted member never
gets a tool set their role wouldn't grant; unconfigured starts from
role_default_run_context, never None (None = catch-all = wider).
- New system_agent_runs owner table (no user_id column — the file is
the owner): start/finish split so a crash leaves a visible 'running'
row, swept to 'failed' by the next start; safe because the scheduler
is sequential and single-instance. An idle tick writes nothing.
- counting_notify wraps the notify tool so the run log can report
notifications emitted without the tool knowing it's counted.
- The session's event channel is drained by a spawned task instead of
a dropped receiver — the translator awaits its sends and would wedge
at capacity.
EventLog::{Persist,Discard} on McpManager::new
- mcp_events is an owner table and its only reader (TIC) is per-user,
so an event is something that happened to someone. The per-user
runtime gets Persist; the ownerless global runtime gets Discard (its
pool is system.db, rows would be unattributable and unread).
API + UI
- GET /api/system-agents/runs: the caller's own run history, scoped
through require_context with no admin override (same promise as the
rest of the private pool).
- web/components/system-agents.js replaces tic-sessions.js. The old
#tic debug page inferred runs from leftover ephemeral sessions; the
new #system-agents page (sidebar group 'extensions', visible to
everyone — the data is the caller's own) reads the real run log.
- i18n: tic.* keys replaced with system_agents.* in en/it/fr.
Docs
- New docs/system-agents.md (user-facing: what TIC does, why it runs
per person, why a run can be missing). Updated docs/settings.md and
docs/index.md.
- agents/tic/AGENT.md reframed per-user: events are that person's,
memory is user-memory/ (private) — never shared-memory/.
- CLAUDE.md records the system-agents design and the EventLog seam.
This commit is contained in:
@@ -29,6 +29,7 @@ pub mod scheduled_jobs;
|
||||
pub mod scratchpad;
|
||||
pub mod shared_folders;
|
||||
pub mod sources;
|
||||
pub mod system_agent_runs;
|
||||
pub mod tool_permission_groups;
|
||||
pub mod users;
|
||||
|
||||
@@ -895,6 +896,45 @@ pub async fn create_owner_tables(pool: &SqlitePool) -> Result<()> {
|
||||
.execute(pool)
|
||||
.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.
|
||||
//
|
||||
// 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
|
||||
// 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
|
||||
// produces no row at all: the only file that could hold it is the one we
|
||||
// cannot open. Hence no 'skipped' status — the skip is a log line (§9).
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS system_agent_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_id TEXT NOT NULL,
|
||||
session_id INTEGER,
|
||||
started_at TEXT NOT NULL,
|
||||
completed_at TEXT,
|
||||
duration_ms INTEGER,
|
||||
status TEXT NOT NULL
|
||||
CHECK(status IN ('running', 'completed', 'failed', 'cancelled')),
|
||||
stats TEXT,
|
||||
error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_system_agent_runs_agent
|
||||
ON system_agent_runs (agent_id, created_at DESC)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS mcp_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -1098,6 +1138,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();
|
||||
// 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();
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Execution log of the system agents (blueprint §13).
|
||||
//!
|
||||
//! One row per run, in the **user's own** database — a system agent runs on a
|
||||
//! user's behalf, over their events, so its trace is theirs (see the table
|
||||
//! comment in [`super::create_owner_tables`]). There is no `user_id` column
|
||||
//! because the file is the owner.
|
||||
//!
|
||||
//! The write is split in two, unlike [`super::job_runs`]: [`start`] before the
|
||||
//! agent runs, [`finish`] after. A run that never reaches `finish` — the process
|
||||
//! died mid-turn — stays `running` and is swept to `failed` by the next [`start`]
|
||||
//! for the same agent, which is safe because the scheduler is sequential and
|
||||
//! single-instance: no live run can be in that state when a new one begins.
|
||||
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// Terminal statuses. `running` is the transient one written by [`start`].
|
||||
pub const STATUS_COMPLETED: &str = "completed";
|
||||
pub const STATUS_FAILED: &str = "failed";
|
||||
pub const STATUS_CANCELLED: &str = "cancelled";
|
||||
|
||||
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||
pub struct SystemAgentRun {
|
||||
pub id: i64,
|
||||
pub agent_id: String,
|
||||
pub session_id: Option<i64>,
|
||||
pub started_at: String,
|
||||
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,
|
||||
/// notifications emitted). Never the event contents.
|
||||
pub stats: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// Open a run: sweep any leftover `running` row for this agent, then insert.
|
||||
pub async fn start(pool: &SqlitePool, agent_id: &str) -> Result<i64> {
|
||||
sqlx::query(
|
||||
"UPDATE system_agent_runs
|
||||
SET status = 'failed', error = 'interrupted (server restarted)',
|
||||
completed_at = datetime('now')
|
||||
WHERE agent_id = ? AND status = 'running'",
|
||||
)
|
||||
.bind(agent_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
let id = sqlx::query_scalar::<_, i64>(
|
||||
"INSERT INTO system_agent_runs (agent_id, started_at, status)
|
||||
VALUES (?, datetime('now'), 'running')
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(agent_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Close a run. `duration_ms` is computed by the caller, which holds the
|
||||
/// `Instant` — `datetime('now')` has second resolution and a tick is often faster.
|
||||
pub async fn finish(
|
||||
pool: &SqlitePool,
|
||||
run_id: i64,
|
||||
status: &str,
|
||||
session_id: Option<i64>,
|
||||
duration_ms: i64,
|
||||
stats: Option<&str>,
|
||||
error: Option<&str>,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE system_agent_runs
|
||||
SET status = ?, session_id = ?, completed_at = datetime('now'),
|
||||
duration_ms = ?, stats = ?, error = ?
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(status)
|
||||
.bind(session_id)
|
||||
.bind(duration_ms)
|
||||
.bind(stats)
|
||||
.bind(error)
|
||||
.bind(run_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Newest-first page of runs, optionally narrowed to one agent.
|
||||
pub async fn list(
|
||||
pool: &SqlitePool,
|
||||
agent_id: Option<&str>,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<SystemAgentRun>> {
|
||||
let rows = sqlx::query_as::<_, SystemAgentRun>(
|
||||
"SELECT id, agent_id, session_id, started_at, completed_at, duration_ms,
|
||||
status, stats, error, created_at
|
||||
FROM system_agent_runs
|
||||
WHERE (? IS NULL OR agent_id = ?)
|
||||
ORDER BY id DESC
|
||||
LIMIT ? OFFSET ?",
|
||||
)
|
||||
.bind(agent_id)
|
||||
.bind(agent_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Total matching [`list`]'s filter, for pagination.
|
||||
pub async fn count(pool: &SqlitePool, agent_id: Option<&str>) -> Result<i64> {
|
||||
let total = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM system_agent_runs WHERE (? IS NULL OR agent_id = ?)",
|
||||
)
|
||||
.bind(agent_id)
|
||||
.bind(agent_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(total)
|
||||
}
|
||||
Reference in New Issue
Block a user