feat(desktop): first-class cron jobs in the sidebar + dashboard scheduler#40684
Conversation
Scheduler sessions (source=cron) were listed in recents, where their `[IMPORTANT: …]` first-message previews spammed the list — and because cron runs are always newest, a burst of them consumed the whole recents page budget and starved real conversations (sidebar showed 0 sessions). Recents and cron jobs are now two independent lists: - Backend: /api/sessions + /api/profiles/sessions accept source / exclude_sources; session_count gains exclude_sources. Recents query excludes cron; the cron section queries source=cron. - Desktop: separate $cronSessions store + refreshCronSessions fetch, a collapsed (persisted) "Cron jobs" section below Sessions that only renders when cron sessions exist, with its own bounded scroller.
🔎 Lint report:
|
| Rule | Count |
|---|---|
invalid-argument-type |
4 |
invalid-parameter-default |
1 |
First entries
hermes_cli/web_server.py:1775: [invalid-argument-type] invalid-argument-type: Argument to bound method `SessionDB.session_count` is incorrect: Expected `str`, found `(str & ~AlwaysFalsy) | None`
hermes_cli/web_server.py:1766: [invalid-argument-type] invalid-argument-type: Argument to bound method `SessionDB.list_sessions_rich` is incorrect: Expected `list[str]`, found `(list[LiteralString] & ~AlwaysFalsy) | None`
hermes_cli/web_server.py:1776: [invalid-argument-type] invalid-argument-type: Argument to bound method `SessionDB.session_count` is incorrect: Expected `list[str]`, found `(list[LiteralString] & ~AlwaysFalsy) | None`
hermes_cli/web_server.py:1701: [invalid-parameter-default] invalid-parameter-default: Default value of type `None` is not assignable to annotated parameter type `str`
hermes_cli/web_server.py:1765: [invalid-argument-type] invalid-argument-type: Argument to bound method `SessionDB.list_sessions_rich` is incorrect: Expected `str`, found `(str & ~AlwaysFalsy) | None`
✅ Fixed issues: none
Unchanged: 5167 pre-existing issues carried over.
Diagnostics are surfaced as warnings — this check never fails the build.
The cron scheduler tick loop only ran inside `hermes gateway run`, but the desktop app spawns a `hermes dashboard` backend with no gateway — so any cron a user created in the app was saved and never fired (silently). Run a minimal scheduler ticker inside the dashboard lifespan, gated on a new HERMES_DESKTOP=1 marker the electron shell injects, so server `hermes dashboard` is unaffected. Cross-process safe via the existing cron/.tick.lock, so it never double-fires alongside a real gateway.
A cron session's first message is the injected "[IMPORTANT: you are running as a scheduled cron job …]" delivery hint, so with no explicit title the sidebar and history rows fell back to that hint as their label. Set the session title from the job (name → short prompt → id) with a run-time suffix for uniqueness against the sessions.title index. Done after the run so the agent's own INSERT keeps model/system_prompt — this only updates the title.
Redesign the cron surface around jobs (not run sessions), following
power-user patterns (GitHub Actions / Airflow / Dagu): master → detail → output.
Sidebar "Cron jobs" section:
- jobs with a state pip + live next-run countdown
- click toggles an inline run-history peek; a run opens its chat (active run highlighted)
- hover: trigger-now + manage (open the Cron page)
- capped at 50 with a "50+" badge
Cron page: de-nested from a collapse-in-row accordion to master/detail —
job list + the selected job's schedule, actions, and run history.
Backend: GET /api/cron/jobs/{id}/runs lists a job's run sessions.
Share STATE_DOT/jobState across both surfaces; drop dead code/keys.
Sidebar and Cron page each carried a near-identical name→prompt→id title fn. Collapse to a single jobTitle in cron/job-state.ts (the page variant, which also falls back to script then 'Cron job').
De-box the master/detail Cron page ahead of #40708's flat-UI system: drop the two rounded-lg border/bg cards for a single --ui-stroke-tertiary hairline between list and detail, swap the header divider and schedule- preview chip onto the same stroke/bg-quinary tokens. No --stroke-nous (that lands with #40708); only tokens already on this branch.
Master/detail separated by gap, not a divider; header rule, schedule- preview chip border, and error-box border removed (subtle bg tints carry the grouping/semantics). Fully borderless to match the flat overlay pass.
# Conflicts: # apps/desktop/src/app/cron/index.tsx
Cron's manage overlay now uses the shared OverlaySplitLayout (sidebar list + main detail) instead of a bespoke PageSearchShell + grid, matching profiles. Extract OverlayNewButton (the "+ New …" sidebar action) so profiles and cron share one component — its hover underline is scoped to the label span so it never strokes the leading icon glyph.
active/createFirst/refresh/refreshing went unused when the cron overlay moved to the shared split layout (no count header, no refresh button, no EmptyState CTA). Remove from types + all four locales.
The manage overlay held its own local jobs list, so deleting/creating a job there left the sidebar's $cronJobs atom stale until the 30s poll (delete all → section lingered). Make the overlay read and mutate the shared atom directly (updateCronJobs), so sidebar + overlay are one source of truth and changes show immediately.
teknium1
left a comment
There was a problem hiding this comment.
Reviewed end-to-end — backend, desktop TS, i18n, tests. This is high-quality, conventions-respecting, and invariant-safe. Nothing blocking found.
We're not merging yet — putting this to the community to see if people want it before it lands.
Backend (the risk surface — all clean)
web_server.py— desktop cron ticker gated onHERMES_DESKTOP=1, takes the existingcron/.tick.lockso it can't double-fire alongside a real gateway. Daemon thread, stop-event on lifespan exit, errors swallowed to debug. NewGET /api/cron/jobs/{id}/runsresolves name → canonical id, then queriessource='cron'+ id prefix.source/exclude_sourcesquery params plumbed into both session endpoints.scheduler.py— titles the cron session from the job (name → prompt → id). UPDATE-only on the title column; does not touchmodel/system_prompt, so the prompt-cache invariant holds. Correct to do it post-run rather than at create time.hermes_state.py— addsexclude_sourcestosession_countonly (list_sessions_richalready had it). Purely additive, defaults toNone.
Desktop TS (the bulk)
- Follows the nanostores conventions in AGENTS.md — feature-owned atoms (
store/cron.ts),useStorein render /$atom.get()in actions, persistence beside the atom ($sidebarCronOpen). - Shared
job-state.ts(STATE_DOT/jobState/jobTitle) so the sidebar and Cron page can't drift. - Recents/cron split is the right architecture — always-newest cron rows would otherwise starve the recents page budget.
- Controller polling is careful: visibility-gated, 30s interval, signature-compare to avoid needless re-renders, non-fatal catches. Sidebar peek polls at 8s, and only while expanded + visible.
tool-fallback-model.tsis a large, well-factored pure module;cronjobSubtitle/cronjobDetailproduce the tidy transcript summary.- i18n fully parallel across en / ja / zh / zh-hant + types.ts.
Validation
- Targeted core tests green: 374 passed, 0 failed (
tests/cron/test_scheduler.py,tests/hermes_cli/test_web_server.py), including the 72 new lines. - Wired into existing
list_sessions_rich/set_session_titlesignatures — no API drift. - Desktop type-check left to CI (needs a full root install).
One honest caveat (author flagged it)
Desktop now actually fires idle jobs, so the pre-existing unbounded cron-run-session pile starts growing where it didn't before. No pruning exists in core today. The author's right that it grows slowly (a daily job ≈ tens of MB/yr) and that retention belongs in core (it affects CLI + gateway too), tracked separately. Agreed on both counts — not a blocker here.
Nice work, @OutThisLife.
The cron run-history endpoint (GET /api/cron/jobs/{id}/runs, added in
#40684) reused list_sessions_rich's order_by_last_active path with a
leading-wildcard id_query. That routes through the recursive
compression-chain CTE, which seeds from EVERY source='cron' row in the DB
and runs per-row preview/last_active subqueries before filtering to one
job and applying LIMIT. Work scaled with the total cron history, so a
large pile made the run-history load time out before eventually
populating.
Cron runs are flat, never-compressed sessions with ids of the form
cron_{job_id}_{ts}, so the chain machinery is pure overhead and the
job binding is a true prefix, not a substring.
- New SessionDB.list_cron_job_runs(): bounded [prefix, hi) id-range scan
on source='cron', ordered by started_at DESC, with the same
preview/last_active enrichment. No CTE, no leading-wildcard LIKE.
- Add idx_sessions_source(source, id) so the range is an index scan;
bump SCHEMA_VERSION 14 -> 15 (index reconciles onto existing DBs via
CREATE INDEX IF NOT EXISTS on startup).
- Point the endpoint at the new method.
Measured on a real SessionDB with 30k cron rows: 5ms vs 85ms for the old
path (16x), and the new path stays flat as the pile grows while the old
one scaled with it. Verified the query plan uses idx_sessions_source_id
(range scan, no full table scan), runs are correctly scoped (substring
collisions like cron_xalpha_ excluded), newest-first, and paged.
The cron run-history endpoint (GET /api/cron/jobs/{id}/runs, added in
#40684) reused list_sessions_rich's order_by_last_active path with a
leading-wildcard id_query. That routes through the recursive
compression-chain CTE, which seeds from EVERY source='cron' row in the DB
and runs per-row preview/last_active subqueries before filtering to one
job and applying LIMIT. Work scaled with the total cron history, so a
large pile made the run-history load time out before eventually
populating.
Cron runs are flat, never-compressed sessions with ids of the form
cron_{job_id}_{ts}, so the chain machinery is pure overhead and the
job binding is a true prefix, not a substring.
- New SessionDB.list_cron_job_runs(): bounded [prefix, hi) id-range scan
on source='cron', ordered by started_at DESC, with the same
preview/last_active enrichment. No CTE, no leading-wildcard LIKE.
- Add idx_sessions_source(source, id) so the range is an index scan;
bump SCHEMA_VERSION 14 -> 15 (index reconciles onto existing DBs via
CREATE INDEX IF NOT EXISTS on startup).
- Point the endpoint at the new method.
Measured on a real SessionDB with 30k cron rows: 5ms vs 85ms for the old
path (16x), and the new path stays flat as the pile grows while the old
one scaled with it. Verified the query plan uses idx_sessions_source_id
(range scan, no full table scan), runs are correctly scoped (substring
collisions like cron_xalpha_ excluded), newest-first, and paged.
…ch#41088) The cron run-history endpoint (GET /api/cron/jobs/{id}/runs, added in NousResearch#40684) reused list_sessions_rich's order_by_last_active path with a leading-wildcard id_query. That routes through the recursive compression-chain CTE, which seeds from EVERY source='cron' row in the DB and runs per-row preview/last_active subqueries before filtering to one job and applying LIMIT. Work scaled with the total cron history, so a large pile made the run-history load time out before eventually populating. Cron runs are flat, never-compressed sessions with ids of the form cron_{job_id}_{ts}, so the chain machinery is pure overhead and the job binding is a true prefix, not a substring. - New SessionDB.list_cron_job_runs(): bounded [prefix, hi) id-range scan on source='cron', ordered by started_at DESC, with the same preview/last_active enrichment. No CTE, no leading-wildcard LIKE. - Add idx_sessions_source(source, id) so the range is an index scan; bump SCHEMA_VERSION 14 -> 15 (index reconciles onto existing DBs via CREATE INDEX IF NOT EXISTS on startup). - Point the endpoint at the new method. Measured on a real SessionDB with 30k cron rows: 5ms vs 85ms for the old path (16x), and the new path stays flat as the pile grows while the old one scaled with it. Verified the query plan uses idx_sessions_source_id (range scan, no full table scan), runs are correctly scoped (substring collisions like cron_xalpha_ excluded), newest-first, and paged.
Summary
Makes scheduled jobs a first-class, well-behaved part of the desktop app. Previously cron runs leaked into the main sessions list as
[IMPORTANT] …spam, the dashboard couldn't actually fire jobs, and there was no real way to browse a job's history.Sidebar —
Cron jobssection50+badge so large fleets never balloon the sidebar.Cron page — shares the Profiles overlay
OverlaySplitLayoutas Profiles (sidebar list → selected-job detail) instead of a bespoke search-shell + grid. One pattern, not two.OverlayNewButton("+ New …") used by both Profiles and cron; its hover underline is scoped to the label so it never strokes the leading icon.Backend
[IMPORTANT: …]hint.GET /api/cron/jobs/{id}/runsreturns a job's run sessions (the run history).Other
STATE_DOT/jobState/jobTitlebetween the sidebar and Cron page; dead cron i18n keys removed; i18n across en/ja/zh/zh-hant.Notes — cron run retention (follow-up, not in this PR)
Cron has always persisted every run as a session (
cron_{job_id}_{ts}) with no pruning — pre-existing core behavior incron/scheduler.py, not introduced here. This PR makes it visible (run-history UI) and, on desktop only, makes idle jobs actually fire — so the existing pile now grows where it didn't before.It won't melt anyone's machine: on-disk SQLite (no memory growth), and the runs query filters on the
source='cron'index thenLIMITs, so polls scan only cron rows. Disk is the sole unbounded axis and grows slowly — a daily job ≈ tens of MB/yr; you'd need a sub-hourly job left untouched for a year to reach single/double-digit GB.Proper fix belongs in core, not desktop (it affects CLI + gateway too): a
cron:retention policy (keep-last-N per job, or age-out), config-gated. Tracked separately.Test plan
[IMPORTANT]cron spam.