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:
@@ -24,6 +24,7 @@ pub mod run_context;
|
||||
pub mod sessions;
|
||||
pub mod setup;
|
||||
pub mod shared_folders;
|
||||
pub mod system_agents;
|
||||
pub mod transcribe_audio;
|
||||
pub mod transcribe_models;
|
||||
pub mod tts_models;
|
||||
@@ -52,6 +53,8 @@ pub fn router() -> Router<Arc<Skald>> {
|
||||
// Custom slash commands (file-based, read-only listing for autocomplete + /help)
|
||||
.route("/commands", get(commands::list))
|
||||
.route("/sessions", get(sessions::list_sessions).post(sessions::create))
|
||||
// System agents (TIC …) — the caller's own run history
|
||||
.route("/system-agents/runs", get(system_agents::list_runs))
|
||||
// First-run setup
|
||||
.route("/setup/status", get(setup::status))
|
||||
.route("/setup/profiles", get(setup::profiles))
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//! System agents — the background agents the instance runs on a user's behalf
|
||||
//! (blueprint §13). Today that is TIC; the surface is written for more.
|
||||
//!
|
||||
//! **Scoped to the caller, with no admin override.** A run summarises what
|
||||
//! arrived in someone's inbox, so it is stored in their own encrypted database
|
||||
//! and read back through `require_context`, exactly like their sessions. There
|
||||
//! is deliberately no "all users" view: the admin sees their own runs and
|
||||
//! nobody else's, which is the same promise the rest of the private pool makes
|
||||
//! (§2/§3).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Extension, Json,
|
||||
extract::{Query, State},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use skald_core::db::system_agent_runs;
|
||||
use skald_core::skald::Skald;
|
||||
|
||||
use super::guard::AuthUser;
|
||||
use super::{ApiError, require_context};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListRunsQuery {
|
||||
/// Narrow to one agent (`tic`). Omitted = every system agent.
|
||||
pub agent_id: Option<String>,
|
||||
#[serde(default = "default_page")]
|
||||
pub page: i64,
|
||||
#[serde(default = "default_per_page")]
|
||||
pub per_page: i64,
|
||||
}
|
||||
|
||||
fn default_page() -> i64 { 1 }
|
||||
fn default_per_page() -> i64 { 20 }
|
||||
|
||||
/// `GET /api/system-agents/runs` — the caller's own run history, newest first.
|
||||
pub async fn list_runs(
|
||||
State(skald): State<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Query(q): Query<ListRunsQuery>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let ctx = require_context(&skald, &auth.user_id).await?;
|
||||
let per_page = q.per_page.clamp(1, 100);
|
||||
let offset = (q.page.max(1) - 1) * per_page;
|
||||
let agent = q.agent_id.as_deref().filter(|a| !a.is_empty());
|
||||
|
||||
let total = system_agent_runs::count(&ctx.pool, agent).await?;
|
||||
let rows = system_agent_runs::list(&ctx.pool, agent, per_page, offset).await?;
|
||||
|
||||
let items: Vec<Value> = rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
json!({
|
||||
"id": r.id,
|
||||
"agent_id": r.agent_id,
|
||||
"session_id": r.session_id,
|
||||
"started_at": r.started_at,
|
||||
"completed_at": r.completed_at,
|
||||
"duration_ms": r.duration_ms,
|
||||
"status": r.status,
|
||||
// Parsed here rather than in the browser: the column is the agent's
|
||||
// own JSON, and the client should not have to know it is a string.
|
||||
"stats": r.stats.as_deref()
|
||||
.and_then(|s| serde_json::from_str::<Value>(s).ok()),
|
||||
"error": r.error,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(json!({
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": q.page.max(1),
|
||||
"per_page": per_page,
|
||||
})))
|
||||
}
|
||||
Reference in New Issue
Block a user