Skip to content

feat(desktop): first-class cron jobs in the sidebar + dashboard scheduler#40684

Merged
OutThisLife merged 11 commits into
mainfrom
bb/cron-sessions-sidebar
Jun 7, 2026
Merged

feat(desktop): first-class cron jobs in the sidebar + dashboard scheduler#40684
OutThisLife merged 11 commits into
mainfrom
bb/cron-sessions-sidebar

Conversation

@OutThisLife

@OutThisLife OutThisLife commented Jun 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

Screenshot 2026-06-06 at 16 19 54 Screenshot 2026-06-06 at 16 37 43

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 jobs section

  • Job-centric: lists the jobs (not run sessions), only shown when you have any, collapsed by default.
  • Each row: a state pip (matching the session-row dot) + a live next-run countdown.
  • Click a row to toggle an inline run-history peek; click a run to open its chat (active run highlighted). Hover reveals trigger-now and manage.
  • Capped at 50 jobs with a 50+ badge so large fleets never balloon the sidebar.

Cron page — shares the Profiles overlay

  • The manage overlay now uses the same OverlaySplitLayout as Profiles (sidebar list → selected-job detail) instead of a bespoke search-shell + grid. One pattern, not two.
  • Detail pane carries schedule, state, actions (pause/resume · trigger · edit · delete) and the job's run history; clicking a run opens its chat.
  • Extracted a shared OverlayNewButton ("+ New …") used by both Profiles and cron; its hover underline is scoped to the label so it never strokes the leading icon.
  • Flat to match the overlay design system (feat(desktop): unified overlay design system, BrandMark & onboarding redesign #40708): no card-in-card borders, hairline/tint surfaces only.

Backend

  • The desktop backend runs the cron scheduler tick, so jobs created in the app actually fire.
  • Cron run sessions get a clean, job-derived title instead of the injected [IMPORTANT: …] hint.
  • New GET /api/cron/jobs/{id}/runs returns a job's run sessions (the run history).

Other

  • Cron tool calls render a tidy summary in the transcript instead of a raw key/value dump.
  • Shared STATE_DOT/jobState/jobTitle between 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 in cron/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 then LIMITs, 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

  • Create a recurring job in the app → appears in the sidebar with a ticking countdown and actually fires.
  • Expand a job → recent runs; click one → opens that run's chat, highlighted as active.
  • Hover a job → trigger-now fires immediately; manage opens the Cron page focused on it.
  • Cron page: select jobs, pause/resume/trigger/edit/delete, open runs; layout matches Profiles.
  • Sessions list no longer shows [IMPORTANT] cron spam.

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.
@github-actions

github-actions Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

🔎 Lint report: bb/cron-sessions-sidebar vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 9977 on HEAD, 9963 on base (🆕 +14)

🆕 New issues (5):

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.
@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/cron Cron scheduler and job management labels Jun 6, 2026
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.
@OutThisLife OutThisLife changed the title feat(desktop): split cron sessions into their own sidebar section feat(desktop): first-class cron jobs in the sidebar + dashboard scheduler Jun 6, 2026
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 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 on HERMES_DESKTOP=1, takes the existing cron/.tick.lock so it can't double-fire alongside a real gateway. Daemon thread, stop-event on lifespan exit, errors swallowed to debug. New GET /api/cron/jobs/{id}/runs resolves name → canonical id, then queries source='cron' + id prefix. source / exclude_sources query 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 touch model / system_prompt, so the prompt-cache invariant holds. Correct to do it post-run rather than at create time.
  • hermes_state.py — adds exclude_sources to session_count only (list_sessions_rich already had it). Purely additive, defaults to None.

Desktop TS (the bulk)

  • Follows the nanostores conventions in AGENTS.md — feature-owned atoms (store/cron.ts), useStore in 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.ts is a large, well-factored pure module; cronjobSubtitle / cronjobDetail produce 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_title signatures — 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.

@OutThisLife OutThisLife merged commit 846821d into main Jun 7, 2026
23 checks passed
@OutThisLife OutThisLife deleted the bb/cron-sessions-sidebar branch June 7, 2026 05:32
teknium1 added a commit that referenced this pull request Jun 7, 2026
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.
teknium1 added a commit that referenced this pull request Jun 7, 2026
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.
changman pushed a commit to changman/hermes-agent that referenced this pull request Jun 10, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cron Cron scheduler and job management P3 Low — cosmetic, nice to have type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants