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:
@@ -35,7 +35,6 @@ use crate::run_context::RunContextManager;
|
||||
use crate::secrets::SecretsStore;
|
||||
use crate::session::handler::{DEFAULT_MAX_PARALLEL_SUBAGENTS, DEFAULT_MAX_TOOL_ROUNDS};
|
||||
use crate::session::manager::ChatSessionManager;
|
||||
use crate::tic::TicManager;
|
||||
use crate::tool_catalog::ToolCatalog;
|
||||
use crate::tool_discovery::ToolDiscovery;
|
||||
use crate::tools::ToolRegistry;
|
||||
@@ -160,7 +159,14 @@ impl Integrations {
|
||||
/// after the elicitation handler is wired) and the plugin manager (plugins are
|
||||
/// injected by `main.rs`; `start_enabled()` runs later, from `WebFrontend`).
|
||||
pub(super) fn build(rt: &Runtime, plugins: Vec<Arc<dyn Plugin>>) -> Self {
|
||||
let mcp = Arc::new(McpManager::new(Arc::clone(&rt.db), rt.shutdown_token.clone(), "data"));
|
||||
// The global runtime has no owner, so its notifications are not persisted:
|
||||
// `mcp_events` is per-user and its only reader (TIC) runs per-user.
|
||||
let mcp = Arc::new(McpManager::new(
|
||||
Arc::clone(&rt.db),
|
||||
rt.shutdown_token.clone(),
|
||||
"data",
|
||||
crate::mcp::EventLog::Discard,
|
||||
));
|
||||
|
||||
let mut plugin_manager = PluginManager::new(Arc::clone(&rt.db));
|
||||
for plugin in plugins {
|
||||
@@ -297,16 +303,12 @@ impl Interaction {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Conversation: session manager + chat hub + run context + TIC ────────────
|
||||
// ── Conversation: session manager + chat hub + run context ──────────────────
|
||||
|
||||
pub(super) struct Conversation {
|
||||
pub(super) manager: Arc<ChatSessionManager>,
|
||||
pub(super) chat_hub: Arc<ChatHub>,
|
||||
pub(super) run_context_manager: Arc<RunContextManager>,
|
||||
/// TIC lives here (rather than in `Tasks`) because it is constructed from and
|
||||
/// drives the conversation stack (session manager + chat hub + run context);
|
||||
/// this keeps every bundle a single-shot `build()` with no two-phase init.
|
||||
pub(super) tic_manager: Arc<TicManager>,
|
||||
}
|
||||
|
||||
impl Conversation {
|
||||
@@ -395,17 +397,12 @@ impl Conversation {
|
||||
chat_hub.register("web").await;
|
||||
chat_hub.register("talk").await;
|
||||
|
||||
let tic_manager = TicManager::new(
|
||||
Arc::clone(&rt.db),
|
||||
Arc::clone(&manager),
|
||||
Arc::clone(&chat_hub),
|
||||
config.tic.clone(),
|
||||
Arc::clone(&rt.config),
|
||||
Arc::clone(&run_context_manager),
|
||||
Arc::clone(&rt.system_bus),
|
||||
);
|
||||
// TIC is deliberately absent: it is a system agent that runs *per user*,
|
||||
// over that user's own events, sessions and hub. Building it here would
|
||||
// bind it to the ownerless stack above (§19) — which is precisely the bug
|
||||
// that made it inert. It is constructed by `wiring::spawn_system_agents`.
|
||||
|
||||
Ok(Conversation { manager, chat_hub, run_context_manager, tic_manager })
|
||||
Ok(Conversation { manager, chat_hub, run_context_manager })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user