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:
@@ -0,0 +1,308 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { t } from '../lib/i18n.js';
|
||||
|
||||
const PAGE_ID = 'system-agents';
|
||||
const PER_PAGE = 20;
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
day: '2-digit', month: '2-digit', year: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatDuration(ms) {
|
||||
if (ms == null) return '—';
|
||||
if (ms < 1000) return `${ms} ms`;
|
||||
const s = ms / 1000;
|
||||
if (s < 60) return `${s.toFixed(1)} s`;
|
||||
const m = Math.floor(s / 60);
|
||||
return `${m}m ${Math.round(s % 60)}s`;
|
||||
}
|
||||
|
||||
const STATUS_ICON = {
|
||||
running: 'bi-arrow-repeat',
|
||||
completed: 'bi-check-circle',
|
||||
failed: 'bi-exclamation-circle',
|
||||
cancelled: 'bi-slash-circle',
|
||||
};
|
||||
|
||||
export class SystemAgentsPage extends LightElement {
|
||||
static properties = {
|
||||
_open: { state: true },
|
||||
_items: { state: true },
|
||||
_total: { state: true },
|
||||
_page: { state: true },
|
||||
_loading: { state: true },
|
||||
_error: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._open = false;
|
||||
this._items = [];
|
||||
this._total = 0;
|
||||
this._page = 1;
|
||||
this._loading = false;
|
||||
this._error = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.__onLocaleChanged = () => this.requestUpdate();
|
||||
window.addEventListener('locale-changed', this.__onLocaleChanged);
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === PAGE_ID;
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
if (this._open) this._fetch(this._page);
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener('locale-changed', this.__onLocaleChanged);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
async _fetch(page) {
|
||||
this._loading = true;
|
||||
this._error = null;
|
||||
try {
|
||||
const params = new URLSearchParams({ page, per_page: PER_PAGE });
|
||||
const res = await fetch(`/api/system-agents/runs?${params}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
this._items = data.items;
|
||||
this._total = data.total;
|
||||
this._page = data.page;
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
_openSession(id) {
|
||||
if (id != null) window.location.hash = `session/${id}`;
|
||||
}
|
||||
|
||||
get _totalPages() { return Math.max(1, Math.ceil(this._total / PER_PAGE)); }
|
||||
|
||||
/// The agent's own counters. Rendered generically so a second system agent
|
||||
/// needs no change here: unknown keys fall back to the raw key name.
|
||||
_renderStats(stats) {
|
||||
if (!stats || typeof stats !== 'object') return '—';
|
||||
const parts = Object.entries(stats)
|
||||
.filter(([, v]) => v != null)
|
||||
.map(([k, v]) => {
|
||||
const label = t(`system_agents.stat.${k}`);
|
||||
return `${v} ${label.startsWith('system_agents.') ? k.replace(/_/g, ' ') : label}`;
|
||||
});
|
||||
return parts.length ? parts.join(' · ') : '—';
|
||||
}
|
||||
|
||||
_renderTable() {
|
||||
if (this._loading) return html`
|
||||
<div class="sa-state">
|
||||
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
|
||||
<span>${t('system_agents.loading')}</span>
|
||||
</div>
|
||||
`;
|
||||
if (this._error) return html`
|
||||
<div class="sa-state sa-state--error">
|
||||
<i class="bi bi-exclamation-circle"></i>
|
||||
<span>${this._error}</span>
|
||||
</div>
|
||||
`;
|
||||
if (this._items.length === 0) return html`
|
||||
<div class="sa-state sa-state--empty">
|
||||
<i class="bi bi-robot"></i>
|
||||
<span>${t('system_agents.empty')}</span>
|
||||
<small>${t('system_agents.empty_hint')}</small>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return html`
|
||||
<div class="sa-table-wrap">
|
||||
<table class="table table-sm sa-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>${t('system_agents.table.agent')}</th>
|
||||
<th>${t('system_agents.table.started')}</th>
|
||||
<th>${t('system_agents.table.status')}</th>
|
||||
<th class="text-end">${t('system_agents.table.duration')}</th>
|
||||
<th>${t('system_agents.table.result')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${this._items.map(r => html`
|
||||
<tr class=${r.session_id != null ? 'sa-row--clickable' : ''}
|
||||
@click=${() => this._openSession(r.session_id)}>
|
||||
<td><span class="sa-agent">${r.agent_id}</span></td>
|
||||
<td class="sa-date">${formatDate(r.started_at)}</td>
|
||||
<td>
|
||||
<span class="sa-status sa-status--${r.status}">
|
||||
<i class="bi ${STATUS_ICON[r.status] ?? 'bi-question-circle'}"></i>
|
||||
${t(`system_agents.status.${r.status}`)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end sa-num">${formatDuration(r.duration_ms)}</td>
|
||||
<td class="sa-result">
|
||||
${r.error
|
||||
? html`<span class="sa-error" title=${r.error}>${r.error}</span>`
|
||||
: this._renderStats(r.stats)}
|
||||
</td>
|
||||
</tr>
|
||||
`)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderPagination() {
|
||||
if (this._totalPages <= 1) return nothing;
|
||||
const pages = this._totalPages;
|
||||
const cur = this._page;
|
||||
return html`
|
||||
<div class="sa-pagination">
|
||||
<button class="btn btn-sm btn-outline-secondary" ?disabled=${cur <= 1}
|
||||
@click=${() => this._fetch(cur - 1)}>
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</button>
|
||||
<span class="sa-page-info">${t('system_agents.pagination', { cur, pages, total: this._total })}</span>
|
||||
<button class="btn btn-sm btn-outline-secondary" ?disabled=${cur >= pages}
|
||||
@click=${() => this._fetch(cur + 1)}>
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<style>
|
||||
.sa-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.sa-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.sa-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
.sa-total-badge {
|
||||
font-size: 0.75rem;
|
||||
color: var(--bs-secondary-color);
|
||||
background: var(--bs-tertiary-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 1rem;
|
||||
padding: 0.1rem 0.6rem;
|
||||
}
|
||||
.sa-refresh-btn { margin-left: auto; }
|
||||
.sa-subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--bs-secondary-color);
|
||||
margin-bottom: 1.25rem;
|
||||
max-width: 65ch;
|
||||
}
|
||||
.sa-table-wrap {
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.5rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.sa-table { margin-bottom: 0; }
|
||||
.sa-row--clickable { cursor: pointer; }
|
||||
.sa-row--clickable:hover td { background: var(--bs-tertiary-bg); }
|
||||
.sa-agent {
|
||||
font-family: monospace;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.sa-date {
|
||||
font-size: 0.82rem;
|
||||
color: var(--bs-secondary-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sa-num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sa-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sa-status--completed { color: var(--bs-success); }
|
||||
.sa-status--failed { color: var(--bs-danger); }
|
||||
.sa-status--running { color: var(--bs-secondary-color); }
|
||||
.sa-status--cancelled { color: var(--bs-secondary-color); }
|
||||
.sa-result {
|
||||
font-size: 0.82rem;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
.sa-error {
|
||||
color: var(--bs-danger);
|
||||
display: inline-block;
|
||||
max-width: 40ch;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
.sa-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 3rem;
|
||||
justify-content: center;
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.sa-state--empty i { font-size: 1.6rem; opacity: 0.6; }
|
||||
.sa-state--error { color: var(--bs-danger); flex-direction: row; }
|
||||
.sa-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
.sa-page-info {
|
||||
font-size: 0.82rem;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="sa-page">
|
||||
<div class="sa-header">
|
||||
<h2 class="sa-title"><i class="bi bi-robot"></i> ${t('system_agents.title')}</h2>
|
||||
<span class="sa-total-badge">${t('system_agents.total', { n: this._total })}</span>
|
||||
<button class="btn btn-sm btn-outline-secondary sa-refresh-btn"
|
||||
?disabled=${this._loading}
|
||||
@click=${() => this._fetch(this._page)}>
|
||||
<i class="bi bi-arrow-clockwise"></i> ${t('system_agents.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
<p class="sa-subtitle">${t('system_agents.subtitle')}</p>
|
||||
${this._renderTable()}
|
||||
${this._renderPagination()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user