Compare commits

...

1 Commits

Author SHA1 Message Date
Brooklyn Nicholson
3cd8a83ae8 fix(cron): prevent desktop timeout on large run history
Use an indexed ID-prefix session query for cron run history so large cron datasets no longer hit the expensive substring/chain path. Also bump desktop cron API timeout and add regression coverage for profile-scoped run filtering + limits.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-07 01:06:32 -05:00
4 changed files with 121 additions and 5 deletions

View File

@@ -42,6 +42,7 @@ import type {
} from '@/types/hermes'
const DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS = 30_000
const CRON_API_TIMEOUT_MS = 60_000
export type {
ActionResponse,
@@ -495,7 +496,8 @@ export function testMessagingPlatform(platformId: string): Promise<MessagingPlat
export function getCronJobs(): Promise<CronJob[]> {
return window.hermesDesktop.api<CronJob[]>({
path: '/api/cron/jobs'
path: '/api/cron/jobs',
timeoutMs: CRON_API_TIMEOUT_MS
})
}
@@ -507,7 +509,8 @@ export function getCronJob(jobId: string): Promise<CronJob> {
export async function getCronJobRuns(jobId: string, limit = 20): Promise<SessionInfo[]> {
const { runs } = await window.hermesDesktop.api<{ runs: SessionInfo[] }>({
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/runs?limit=${limit}`
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/runs?limit=${limit}`,
timeoutMs: CRON_API_TIMEOUT_MS
})
return runs ?? []

View File

@@ -5676,12 +5676,11 @@ async def list_cron_job_runs(job_id: str, profile: Optional[str] = None, limit:
db = _open_session_db_for_profile(selected)
try:
runs = db.list_sessions_rich(
runs = db.list_sessions_rich_by_id_prefix(
f"cron_{canonical}_",
source="cron",
id_query=f"cron_{canonical}_",
limit=limit_n,
offset=0,
order_by_last_active=True,
)
now = time.time()
for s in runs:

View File

@@ -1880,6 +1880,82 @@ class SessionDB:
s["preview"] = ""
return s
def list_sessions_rich_by_id_prefix(
self,
id_prefix: str,
*,
source: str = None,
limit: int = 20,
offset: int = 0,
include_archived: bool = False,
archived_only: bool = False,
) -> List[Dict[str, Any]]:
"""List rich session rows whose IDs start with ``id_prefix``.
Fast path for namespaces encoded directly in session IDs (for example
cron run sessions ``cron_<job_id>_<timestamp>``). Uses ``LIKE 'prefix%'``
so SQLite can leverage the primary-key index on ``sessions.id`` instead
of the heavier substring/chain path in ``list_sessions_rich(id_query=...)``.
"""
prefix = (id_prefix or "").strip()
if not prefix or limit <= 0:
return []
escaped_prefix = (
prefix
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
)
where_clauses = ["s.id LIKE ? ESCAPE '\\'"]
params: List[Any] = [f"{escaped_prefix}%"]
if source:
where_clauses.append("s.source = ?")
params.append(source)
if archived_only:
where_clauses.append("s.archived = 1")
elif not include_archived:
where_clauses.append("s.archived = 0")
where_sql = f"WHERE {' AND '.join(where_clauses)}"
query = f"""
SELECT s.*,
COALESCE(
(SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63)
FROM messages m
WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
ORDER BY m.timestamp, m.id LIMIT 1),
''
) AS _preview_raw,
COALESCE(
(SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id),
s.started_at
) AS last_active
FROM sessions s
{where_sql}
ORDER BY last_active DESC, s.started_at DESC, s.id DESC
LIMIT ? OFFSET ?
"""
params.extend([limit, offset])
with self._lock:
cursor = self._conn.execute(query, params)
rows = cursor.fetchall()
sessions: List[Dict[str, Any]] = []
for row in rows:
s = dict(row)
raw = s.pop("_preview_raw", "").strip()
if raw:
text = raw[:60]
s["preview"] = text + ("..." if len(raw) > 60 else "")
else:
s["preview"] = ""
sessions.append(s)
return sessions
# =========================================================================
# Message storage
# =========================================================================

View File

@@ -197,3 +197,41 @@ async def test_cron_profile_validation_errors(isolated_profiles):
with pytest.raises(HTTPException) as missing:
await web_server.list_cron_jobs(profile="missing_profile")
assert missing.value.status_code == 404
@pytest.mark.asyncio
async def test_list_cron_job_runs_filters_prefix_and_respects_limit(isolated_profiles):
from hermes_cli import web_server
from hermes_state import SessionDB
worker_job = web_server._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="worker run history",
schedule="every 30m",
name="worker-runs",
)
prefix = f"cron_{worker_job['id']}_"
worker_db = SessionDB(db_path=isolated_profiles["worker_alpha"] / "state.db")
default_db = SessionDB(db_path=isolated_profiles["default"] / "state.db")
try:
worker_db.create_session(f"{prefix}20260606_100001", source="cron")
worker_db.create_session("cron_other_job_20260606_100002", source="cron")
worker_db.create_session(f"{prefix}20260606_100003", source="cron")
# Same prefix in another profile must not leak into worker results.
default_db.create_session(f"{prefix}20260606_100004", source="cron")
finally:
worker_db.close()
default_db.close()
payload = await web_server.list_cron_job_runs(worker_job["id"], limit=2)
runs = payload["runs"]
assert payload["limit"] == 2
assert [run["id"] for run in runs] == [
f"{prefix}20260606_100003",
f"{prefix}20260606_100001",
]
assert all(run.get("profile") == "worker_alpha" for run in runs)