fix(webui): bound /api/crons/recent so the 30s cron poll cannot time out - #7830
Captain-Slaphead wants to merge 2 commits into
Conversation
|
SummaryI read the full diff at head Code reference
worker = threading.Thread(
target=_work, name="cron-recent-session-info", daemon=True
)
worker.start()
worker.join(budget_s)
return resultA new thread has no There is a second lifecycle issue at The overrun helper test at Diagnosis and recommendationPlease avoid resolving profile-scoped state inside an unscoped detached thread. The smallest reliable shape is to capture the active profile on the request thread and pass it explicitly into the lookup, resolving the database with The completion cursor also needs retry semantics. If session enrichment is pending, keep that completion eligible for a later poll while deduplicating its toast by Test planAdd regressions that:
|
4425bfb to
b4aed07
Compare
|
Reworked at
Verification: 68 passed across the seven cron/session/state-db files; the new test file is 4 failed / 1 passed against the threaded head. |
…page-side retry The poll's optional session lookup was bounded only by SQLite's lock-wait timeout, so a slow scan could still outrun the 30s client budget. The client also held its cursor whenever that lookup failed, which replayed completions on every poll: an opened job's unread badge came back on the next cycle, and old toasts reappeared once the toast memory evicted them. Bound the read by wall clock with a sqlite3 progress-handler deadline plus a row cap, and move the retry to the page: the cursor always advances, an un-enriched completion is remembered client-side, its detail is re-fetched in a separate narrow request, and the entry is abandoned after a bounded number of attempts. The existing nesquena#5960 tests cover the profile-switch path and now pass unchanged.
b4aed07 to
17530a9
Compare
|
Reworked at the new head along the lines both rounds pointed to, plus the test failure. The failing project test is ours, and it is fixed. P1 — the poll still lacked a deadline. Fixed with a real one. P1 — failed lookups replayed completions. Fixed by moving the retry to the page. The cursor now advances past every completion it is given, enriched or not. A completion whose lookup failed is recorded client-side in a bounded map, its detail is re-fetched in a separate narrow request over that window, and the entry is dropped after 5 attempts. Two Regressions asked for: named-profile lookup ( Evidence, same two files:
No index added. Bounding the read by wall clock removes the need, and a schema change is a large ask for a first contribution. Happy to add one as a follow-up if you would rather have it. Not verified: the sidebar marker was not exercised in a browser; there is no browser harness for this panel, so the page change rests on the |
| FROM sessions s | ||
| WHERE LOWER(COALESCE(s.source, '')) = 'cron' | ||
| ORDER BY COALESCE(s.started_at, 0) DESC, s.id DESC -- newest start, not last activity | ||
| LIMIT {row_cap} |
There was a problem hiding this comment.
Session lookup silently drops jobs If other jobs have more than 200 newer cron sessions, this global limit excludes a completed job’s session before the code matches rows to jobs. No error is raised, so the response says the lookup succeeded. The client then advances its cursor without retrying, permanently losing that job’s session unread marker. The same limit also appears in the alternate query.
| entry.attempts=Number(entry.attempts||0)+1; | ||
| if(entry.attempts>=_CRON_PENDING_ATTEMPTS) _cronPendingDetails.delete(key); |
There was a problem hiding this comment.
| if(typeof _markSessionCompletionUnreadIfBackground==='function'){ | ||
| const activeProfile=(typeof S!=='undefined'&&S&&S.activeProfile)||'default'; | ||
| _markSessionCompletionUnreadIfBackground(c.session_id, c.message_count, { | ||
| source:'cron', | ||
| profile:activeProfile, | ||
| }); |
There was a problem hiding this comment.
| # lets the client remember the un-enriched completion and re-fetch | ||
| # its detail separately instead of discarding it. | ||
| latest_session_info = _latest_cron_session_info_for_jobs( | ||
| [job.get("id", "") for job in jobs], | ||
| [c["job_id"] for c in completions], | ||
| deadline_s=0.25, |
There was a problem hiding this comment.
Runtime change lacks documentation The endpoint now signals failed enrichment so the browser can retry details separately after advancing its cursor. That changes runtime behavior and the user-facing polling workflow, but no documentation was updated. AGENTS.md requires documentation updates for those changes; this repository requirement needs to be satisfied before merging.
Context Used: AGENTS.md (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
SummaryI reviewed the current Code reference
cur.execute(query)
results = {
jid: {"session_id": "", "message_count": None} for jid in requested
}
requested_ids = set(requested)The queries at The test at Diagnosis / recommendationPlease make cap exhaustion with unresolved requested ids explicit. A minimal safe shape is to fetch up to Do not treat every empty result as failure: a completed job may genuinely have no persisted session. The signal needs to distinguish a fully scanned result from a truncated candidate window. Test planAdd a fixture with one target completion older than 200 newer cron rows belonging to different jobs. Assert that the first capped response is marked incomplete, that the client remembers the target, and that a later successful detail lookup clears it without replaying the badge or toast. Also keep a fully scanned no-session case that returns an empty |
nesquena-hermes
left a comment
There was a problem hiding this comment.
Thanks, @Captain-Slaphead. This round fixes the earlier review points well: the enrichment now has a real read deadline on the request thread, and the retry list closes the replay-on-held-cursor problem. Gate review at exact head 17530a948e25, rebased clean onto current master (exp-v0.52.383); threat scan clean. Two new issues from the tightening, both verified.
-
SILENT: a global row cap can silently drop a completed job's session. Both lookup queries (
api/routes.py:~354-370) now end inLIMIT {row_cap}, withrow_cap = max(200, 8 * len(requested)), applied to all cron sessions before filtering to the requested jobs. On a box where other cron jobs produced 200+ newer sessions, the target session is outside the window, the lookup returnssession_id: ""withsession_lookup_failed: false, and the page never retries, so that completion never gets its unread marker. Codex reproduced it: master finds the target behind 201 newer rows, this head returns empty. Fix: drop the globalLIMITand let the read deadline you added be what bounds the query (on timeout, reportsession_lookup_failed: trueso the page retries), or restrict to the requested job IDs before limiting. Either way, an incomplete lookup must not report success. -
SILENT: a delayed retry can bring back an unread dot the user already cleared.
_cronRetryPendingDetails()(static/panels.js:~13138) calls_markSessionCompletionUnreadIfBackground()with the message count from the retry response. That helper checks whether the session is currently open, but not whether the user already saw that count: open the cron session, leave it, and the next retry marks it unread again. Codex reproduced this with a Node probe of the real functions. Fix: before marking on retry, compare the returnedmessage_countwith the session's acknowledged/read count and skip when it's already been seen. Add a visit-then-leave-then-retry test.
Re-push and I'll re-gate.
A global LIMIT over all cron rows was applied before matching the requested jobs, so a completed job whose session sat behind newer rows from other jobs came back with an empty session_id while the lookup still reported success. The page only retries when session_lookup_failed is true, so that completion never got its unread marker. Drop the cap and let the wall-clock read deadline bound the scan: a scan too slow to finish aborts and re-raises, so the caller reports session_lookup_failed and the page retries instead of silently dropping the completion. Also stop a delayed retry restoring an unread dot the user already cleared. _markSessionCompletionUnreadIfBackground checked only whether the session was currently open; it now skips the mark when the session's viewed count already acknowledges that message count in the same transcript generation. Tests: a target behind 200+ newer rows of other jobs is still found; a fully scanned lookup with no session reports success, not failure; and a visit-then-leave-then-retry node test proves the cleared dot stays cleared while a genuinely newer completion is still flagged.
|
Reworked at Global cap dropped. The candidate window is no longer capped. A A delayed retry no longer restores a cleared dot. Tests: Not verified: ruff and eslint are not installed in my environment (CI installs them), so lint rests on the CI run. |
nesquena-hermes
left a comment
There was a problem hiding this comment.
Re-gated ae6e253ce3d4 rebased onto master 8ac497df3, against my 09-29 review.
Prior findings: resolved. A target behind 5,000 newer rows is found, and a delayed retry no longer restores a dot the user has already viewed. 7 targeted tests pass and both changed JS files pass node --check.
Two ways a cron completion can still lose its session unread marker:
1. (SILENT) A slow lookup gets discarded after five tries. The new per-request deadline (api/routes.py ~22777) is good for the poll, but the lookup it bounds still scans and sorts the session table before filtering by job. On a large state.db (an isolated Agent-shaped database with 200,000 newer sessions from other jobs) the target is found in ~0.75 s without the deadline, and every 0.25 s attempt aborts. After _CRON_PENDING_ATTEMPTS (5) failures, static/panels.js ~13153 deletes the pending detail, so that completion never gets its dot. Fix: narrow the SQL to the requested job ids first, so the index does the work and the scan stays inside the deadline. Don't discard unresolved details after a fixed number of tries; back off instead.
2. (SILENT) More than 50 failed enrichments evicts one for good. _cronPendingDetails is capped at _CRON_PENDING_MAX (50) and evicts the oldest (static/panels.js ~13114). The poll cursor has already advanced past those completions, so an evicted entry is never retried. 51 failed completions drops the first one; list_jobs() has no 50-job cap. Fix: keep every unresolved completion and bound the retry request instead (process them in batches of N).
Thinking Path
startCronPollinginstatic/panels.jspolls/api/crons/recent?since=...every 30 s withapi()'s default 30 s client timeout._handle_cron_recentenriches each completion with its cron session id by reading the agentstate.db, and the page uses that id to raise the sidebar unread marker. If the enrichment is unavailable, the marker is the only thing lost — the completions list itself is built before enrichment and returned either way.Three failure modes have now been reviewed:
join(). A fresh thread has no entry inapi/profiles.py's thread-local, soget_active_hermes_home()resolved against the process profile, and a detached thread cannot be guaranteed to stop.timeouttosqlite3.connect. Review correctly noted that this bounds only the wait for the write lock: if the state database holds many cron sessions or the read itself is slow, the statement still runs past the client's 30 s budget, and deleting the worker removed its deadline without an equivalent replacement._cronPollSinceuntouched whilesession_lookup_failedwas set. That keeps the completion eligible, but it re-serves it on every poll: opening a job to clear its unread badge only clears it until the next poll, and once the 50-entry toast memory evicts the older keys, their toasts appear again.The two review rounds pull in opposite directions — round one requires not dropping a completion whose lookup failed, round three requires not holding it forever. The resolution is the middle path a maintainer suggested: advance the cursor, remember the un-enriched completions on the page, fetch their detail separately, and give up after a bounded number of attempts.
What Changed
Reworked again: the read gets a real wall-clock deadline, and the retry moves to the client.
api/agent_sessions.py:open_state_db_readonlytakes an optionaldeadline_s. It is installed as asqlite3progress handler that aborts the statement withsqlite3.OperationalErroronce the wall-clock budget is spent, so the read is bounded whatever the table size and whatever the lock contention. This is the piece the previous revision lacked.timeoutstill bounds the lock wait and is now set from the same budget.api/routes.py:_latest_cron_session_info_for_jobstakesdeadline_sinstead ofbusy_timeout_s, passes it to the connection, and adds a row cap to the scan (max(200, 8 × requested jobs)). A bounded failure still re-raises, so the handler can reportsession_lookup_failed; the unbounded default path is unchanged and still degrades to an empty result.static/panels.js: the poll now advances_cronPollSincepast every completion it is handed, enriched or not. A completion whose lookup failed is recorded in_cronPendingDetails(bounded to 50 entries)._cronRetryPendingDetailsre-requests that narrow window separately, writes the sidebar marker when the detail finally resolves, and drops the entry after 5 attempts. The toast dedupe set is gone: with a cursor that always advances, a completion is delivered once.tests/test_cron_recent_poll_timeout.py: rewritten around the new shape. Adds a proof that the progress-handler deadline aborts a slow scan, a proof that a fast read is unaffected, a row-cap assertion using a trace callback, twonodeharness tests for the page-side pending set (badge not resurrected, toast not repeated, give-up after the bound), and keeps the three regressions review asked for — named-profile lookup, timeout-then-success recovery, and no leftover threads.tests/test_cron_toast_notifications.py: stub signature follows the renamed parameter.Why It Matters
The poll drives cron-completion toasts and the sidebar unread marker. A long cron job should not produce a spurious error toast, should not lose the completion it reports, and should not resurrect an unread badge the user has already cleared. The previous revision fixed the first and broke the third.
Verification
The pre-fix failures are the bug reproduced, not a harness artefact. All four
tests/test_issue5960_cron_unread_profile_scope.pycases fail on the previous revision —_resetCronUnreadForProfileSwitchcleared a toast-dedupe set that the test's extracted-function harness does not declare, so it raisedReferenceErrorbefore running. That is the same failure the project's ownTestscheck reported onb4aed07. The remaining seven are the new regressions, which cannot run against a revision that has neitherdeadline_snor the page-side pending set.The two
nodetests run the real extracted functions fromstatic/panels.jsand assert the two round-3 findings directly: the cursor advances to the completion's timestamp even when the lookup failed, a badge cleared by the user is not restored by the retry, and the toast count stays at 1 across the replay window.Test state was isolated via
HERMES_WEBUI_TEST_STATE_DIRoutside any production home; no live state directory was touched.Not verified: the browser rendering of the sidebar marker was not exercised in a browser. The page change is confined to the poll callback and its two helpers; it is covered by the
nodeharness andnode --check static/panels.js, and the repository has no browser harness for this panel.Contract Routing
No contract family is touched.
/api/crons/recentis not listed indocs/CONTRACTS.md. The response shape is extended additively withsession_lookup_failed(boolean);completions,since,session_idandmessage_countkeep their existing names and types.Risks / Follow-ups
session_lookup_failed, the page remembers the completion and retries separately, and gives up after 5 polls. Worst case is a missing sidebar unread marker, never a lost completion or a repeated toast.deadline_s=0.25sits far inside the 30 s client budget. A future caller wanting a different bound should pass one rather than edit the call site.Release Note Wording
Fix a spurious "Request timed out" toast that appeared while a long cron job was running, and stop a cron completion from re-raising its unread badge and toast on every poll when its session detail could not be read.
Model Used
Implemented by the maintainer-side agent (deepseek-v4.1-flash via Hermes Agent) working from the two review rounds' recommendations; the before/after regression proof and the full-file suite run were produced in the same session. AI usage disclosed per
CONTRIBUTING.md.