Conversation
nesquena
left a comment
There was a problem hiding this comment.
Review — end-to-end ✅ (clean approve, contributor PR)
External contributor (@NocGeek). Fixes a real defect in the manual cron-run path: clicking "Run Now" in the WebUI executes the cron job's agent run but discards the result — output is never written, last_run_at / last_status never updates. The fix mirrors the scheduled cron path's persistence handling exactly.
What this ships
PR adds 18 lines to _run_cron_tracked at api/routes.py:95-119:
def _run_cron_tracked(job):
from cron.scheduler import run_job
from cron.jobs import mark_job_run, save_job_output
job_id = job.get("id", "")
try:
success, output, final_response, error = run_job(job)
save_job_output(job_id, output)
if success and not final_response:
success = False
error = "Agent completed but produced empty response (model error, timeout, or misconfiguration)"
mark_job_run(job_id, success, error)
except Exception as e:
logger.exception("Manual cron run failed for job %s", job_id)
try:
mark_job_run(job_id, False, str(e))
except Exception:
logger.debug("Failed to mark manual cron run failure for %s", job_id)
finally:
_mark_cron_done(job_id)Pre-fix behaviour: _run_cron_tracked called run_job(job) and discarded the 4-tuple return value, then cleared the in-memory running flag. No persistence. Output gone. Job metadata stale. Confirmed by tracing.
Traced against upstream hermes-agent
Verified upstream signatures all match:
cron.scheduler.run_job(job)at /tmp/hermes-agent-fresh/cron/scheduler.py:798 returnstuple[bool, str, str, Optional[str]]=(success, full_output_doc, final_response, error_message). ✅ PR's tuple unpack matches.cron.jobs.save_job_output(job_id, output)at /tmp/hermes-agent-fresh/cron/jobs.py:859. Writes timestamped.mdfile intoOUTPUT_DIR/job_id/. ✅cron.jobs.mark_job_run(job_id, success, error=None, delivery_error=None)at /tmp/hermes-agent-fresh/cron/jobs.py:678. Updateslast_run_at,last_status,last_error. Holds_jobs_file_lockinternally. ✅ PR uses positional args (nodelivery_error— correct, manual runs don't deliver).
Verified that run_job itself does NOT internally call save_job_output or mark_job_run — those calls live in _process_job (/tmp/hermes-agent-fresh/cron/scheduler.py:1334-1364) which the manual-run path bypasses. So the PR's persistence calls are necessary, not redundant.
End-to-end trace
- User clicks "Run Now" on a cron job in the UI → POST
/api/crons/runwith{"job_id": "..."}. _handle_cron_runat api/routes.py:3736-3752:get_job(job_id)from upstreamcron.jobs.- Prevent double-run via
_is_cron_running. _mark_cron_running(job_id)(in-memory flag).threading.Thread(target=_run_cron_tracked, args=(job,), daemon=True).start().
_run_cron_trackedruns in worker thread:run_job(job)invokes the agent pipeline; returns(success, full_doc, final_response, error).save_job_output(job_id, full_doc)writes the timestamped.mdtoOUTPUT_DIR/<job_id>/. The file shows up in the UI's output history.- Empty-response demotion (mirrors scheduled path at scheduler.py:1360-1362): if
success && !final_response, mark as failure with the same canonical error string. Without this, an agent that ran but produced nothing would falsely show "ok" in the UI. mark_job_run(job_id, success, error)writes to the jobs JSON via_jobs_file_lock— updateslast_run_at,last_status,last_error,next_run_at.
_mark_cron_done(job_id)infinallyclears the in-memory running flag — always fires.
Comparison with scheduled cron path
| Step | Scheduled _process_job |
Manual _run_cron_tracked (PR) |
|---|---|---|
| Run agent | run_job(job) |
run_job(job) ✅ same |
| Save output to disk | save_job_output(...) |
save_job_output(...) ✅ same |
| Deliver final_response to chat | yes | NO (UI is the surface) ✅ correct |
| Track delivery_error | yes | omitted ✅ correct (no delivery) |
| Empty-response demotion | yes | yes ✅ matches text + logic |
mark_job_run |
yes | yes ✅ |
| Exception path | mark_job_run(False, str(e)) |
mark_job_run(False, str(e)) + nested guard ✅ |
| Cleanup | (n/a) | _mark_cron_done in finally ✅ |
Cleanly mirrors the scheduled path's persistence semantics while correctly omitting delivery (which only makes sense for scheduled runs that deliver to chat platforms).
Race / lock analysis
mark_job_runacquires_jobs_file_lockinternally (cron/jobs.py:689). The scheduled cron driver and the manual-run worker thread serialize through this lock when updating jobs JSON. ✅save_job_outputwrites a unique timestamped file per call (OUTPUT_DIR/job_id/<YYYY-MM-DD_HH-MM-SS>.md). Two manual runs of the SAME job within the same second could collide on filename — but_handle_cron_runalready prevents double-running the same job (the_is_cron_runningcheck at line 3746). ✅_run_cron_trackedruns in a daemon thread (line 3751). The HTTP handler returns immediately. The worker has its own stack; no shared mutable state with the handler beyond the in-memory_cron_runningdict (which has its own lock).run_jobmay mutateos.environ["TERMINAL_CWD"]for workdir-bearing jobs (scheduler.py:1372-1377 comment). Two concurrent manual runs of DIFFERENT workdir jobs would race on this. Pre-existing concern, not introduced by this PR. The webui also doesn't gate on this — but_is_cron_runningonly blocks per-job, not globally.
Cross-tool consistency
- ✅ All persistence goes through upstream
cron.jobsfunctions — no webui-internal markers or schemas. - ✅ The output file produced is the same format that the scheduled path writes; the WebUI's cron output viewer reads the same
OUTPUT_DIR/job_id/*.mdfiles (same code path as for scheduled runs). - ✅ No
config.yamlwrites. - ✅ No new endpoints, no new env vars.
Security audit
- ✅
job_idcomes fromjob.get("id", "")wherejobwas fetched viacron.jobs.get_job(job_id)validated at line 3742. Trusted. - ✅ No string interpolation of user data into shell, SQL, or paths.
- ✅ No new file-serving surface.
- ✅ The
error = "Agent completed but produced empty response (...)"literal is hardcoded — no user data in it. - ✅
logger.exceptionandlogger.debuguse%sformatting safely.
Edge-case matrix
| Scenario | Pre-fix | Post-fix |
|---|---|---|
| Manual run, agent succeeds with text response | Output dropped, status not updated | save + mark(ok) ✅ |
Manual run, agent succeeds with empty final_response |
Output dropped, status not updated | save + mark(error="empty response") ✅ |
| Manual run, agent fails with error | Output dropped, status not updated | save + mark(error) ✅ |
Manual run, run_job itself raises (e.g. import error) |
Worker thread crashes, status never updated, running flag stuck until process restart | except → mark(False, exc); finally clears running flag ✅ |
save_job_output raises (disk full) |
n/a | except → mark(False, exc); slight inaccuracy (run was OK, save failed) but no stuck-running |
mark_job_run raises |
n/a | nested except logs and falls through to finally ✅ |
| Two manual runs of same job concurrently | _is_cron_running blocks the second |
unchanged ✅ |
| SILENT_MARKER in final_response | not detected (no delivery anyway) | not detected — manual runs never deliver, so no behavioral concern. The output is still saved verbatim ✅ |
Job missing id field |
_mark_cron_done("") no-op |
mark_job_run("", ...) would try to find job with empty id (pre-existing) |
Tests
- PR's tests: 2/2 pass —
test_manual_cron_run_saves_output_and_marks_jobandtest_manual_cron_run_marks_empty_response_as_failure. They usemonkeypatch.setitem(sys.modules, "cron", ...)to inject mockcron.jobsandcron.schedulermodules, then assert the exact call sequence and arg shapes. Confirmssave → markorder, empty-response demotion, and_is_cron_runningcleanup. - Full suite: 3409 passed, 54 skipped, 3 xpassed, 0 failed in 16.08s on
8d3f005. - No CI run yet — branch was just pushed and there's no scheduled workflow trigger.
Minor observations (non-blocking)
- Exception path not covered by tests. The
except Exception as e: mark_job_run(job_id, False, str(e))block is a real safety net but isn't exercised. A test that makesrun_jobraise would lock this behavior. Easy follow-up if the contributor or maintainer chooses. - The PR uses
mark_job_run(job_id, success, error)positionally, notmark_job_run(job_id, success, error=error). Positional binding is fine because the signaturemark_job_run(job_id, success, error=None, delivery_error=None)putserroras the 3rd positional arg. Verified. save_job_outputreturn value discarded. Scheduled path uses it forif verbose: logger.info("Output saved to: %s", output_file). Manual path could log the same — minor UX nicety, not a defect.- Manual runs of
workdir-bearing jobs racing onos.environ["TERMINAL_CWD"]is pre-existing. Out of scope; mention only because the PR description's "keeps_run_cron_trackedself-contained for worker-thread execution" claim makes me check that the function doesn't introduce new shared state — it doesn't. ✅ get_jobis called BEFORE_is_cron_running— if the user spam-clicks, multiple HTTP handlers could each pass the existence check, then race on_is_cron_running. Only one wins (others see "already_running"). The first to call_mark_cron_runningwins. Fine.
Recommendation
✅ Approved. Mirrors the scheduled cron path's persistence pattern exactly, with the correct omissions (no delivery, no delivery_error). All upstream signatures verified. Tests cover happy path + empty-response demotion. Defensive nested try-except around the failure-path mark_job_run prevents stuck-running state on cascading errors.
Thank you @NocGeek for the clean focused PR — straightforward bug fix with solid regression coverage. Parked at approval — ready for the release agent's merge/tag pipeline.
Manual WebUI cron runs previously called cron.scheduler.run_job(job) and then only cleared the in-memory running flag. That meant output could be dropped and job metadata like last_run_at / last_status was not updated after a manual run. This PR matches the scheduled cron path (cron/scheduler.py:1334-1364) exactly: - Save manual-run output via save_job_output - Mark manual runs complete via mark_job_run - Treat empty final_response as a soft failure with the same error string as the scheduled path - Record manual-run failures in job metadata via mark_job_run(False) - Keep _run_cron_tracked self-contained for worker-thread execution Includes 2 behavioral regression tests using monkeypatch.setitem on sys.modules to mock cron.scheduler.run_job + cron.jobs helpers — the right test pattern (exercises the real _run_cron_tracked code path). Split out from #1352 (the larger profile-aware-cron-panel PR that's on hold) per pre-release-review feedback. Self-contained, doesn't touch the held PR's profile-filtering scope. Co-authored-by: NocGeek <NocGeek@users.noreply.github.com>
Opus pre-release findings on #1370 applied: SHOULD-FIX 1: Tightened parent_session_id exposure to only emit when the parent's end_reason is in {compression, cli_close}. Without this, two distinct WebUI sessions sharing a non-continuation parent (e.g. 'user_stop') would get clustered by frontend's _sessionLineageKey (which falls through to parent_session_id when _lineage_root_id is missing) and incorrectly collapsed into a single sidebar row. Updated assertions in: - tests/test_session_lineage_metadata_api.py:: test_non_compression_state_db_parent_does_not_create_sidebar_lineage - tests/test_pr1370_lineage_metadata_perf_and_orphan.py:: test_non_compression_parent_does_not_extend_lineage SHOULD-FIX 2: Chunked the IN-clause to 500 vars to stay under SQLITE_MAX_VARIABLE_NUMBER. Python 3.9 ships sqlite 3.31 with the default limit of 999. A power user with 2000+ sessions in the sidebar would hit OperationalError, the silent except-wrapper would swallow it, and lineage collapse would never work. Added test_in_clause_chunked_for_large_session_set with SQL interception to lock the invariant in source. PR addition (per user directive — Opus + my review, no second independent review round needed for combined batch): #1372 from @NocGeek — fix: persist manual cron run results. Self-contained 89 LOC fix split out from the held #1352. Mirrors the scheduled-cron path (cron/scheduler.py:1334-1364) exactly: saves output, marks job complete, treats empty response as soft failure with matching error string. 2 behavioral tests using sys.modules monkeypatch to mock cron.scheduler.run_job. CI not yet attached because branch is brand-new; ran the new tests + adjacent suites locally — all pass. Final test count: 3471 passing, 0 failed. Also adds 2 more regression tests for the perf-fix invariants: - test_in_clause_chunked_for_large_session_set - test_two_children_sharing_non_continuation_parent_not_collapsed
|
Shipped in v0.50.251 (merge Live at https://github.com/nesquena/hermes-webui/releases/tag/v0.50.251. Your manual-cron persistence fix lands clean — verified the API signatures match upstream What's next for #1352#1352 is now superseded — your #1372 shipped the manual-run-output fix, and #1374 (the resubmit) carries the architectural profile-aware-cron-panel piece. I'm closing #1352 with a pointer to both successors. #1374 is on hold pending the architectural review I outlined in its hold comment. |
Manual WebUI cron runs previously called cron.scheduler.run_job(job) and then only cleared the in-memory running flag. That meant output could be dropped and job metadata like last_run_at / last_status was not updated after a manual run. This PR matches the scheduled cron path (cron/scheduler.py:1334-1364) exactly: - Save manual-run output via save_job_output - Mark manual runs complete via mark_job_run - Treat empty final_response as a soft failure with the same error string as the scheduled path - Record manual-run failures in job metadata via mark_job_run(False) - Keep _run_cron_tracked self-contained for worker-thread execution Includes 2 behavioral regression tests using monkeypatch.setitem on sys.modules to mock cron.scheduler.run_job + cron.jobs helpers — the right test pattern (exercises the real _run_cron_tracked code path). Split out from nesquena#1352 (the larger profile-aware-cron-panel PR that's on hold) per pre-release-review feedback. Self-contained, doesn't touch the held PR's profile-filtering scope. Co-authored-by: NocGeek <NocGeek@users.noreply.github.com>
…rsistence Opus pre-release findings on nesquena#1370 applied: SHOULD-FIX 1: Tightened parent_session_id exposure to only emit when the parent's end_reason is in {compression, cli_close}. Without this, two distinct WebUI sessions sharing a non-continuation parent (e.g. 'user_stop') would get clustered by frontend's _sessionLineageKey (which falls through to parent_session_id when _lineage_root_id is missing) and incorrectly collapsed into a single sidebar row. Updated assertions in: - tests/test_session_lineage_metadata_api.py:: test_non_compression_state_db_parent_does_not_create_sidebar_lineage - tests/test_pr1370_lineage_metadata_perf_and_orphan.py:: test_non_compression_parent_does_not_extend_lineage SHOULD-FIX 2: Chunked the IN-clause to 500 vars to stay under SQLITE_MAX_VARIABLE_NUMBER. Python 3.9 ships sqlite 3.31 with the default limit of 999. A power user with 2000+ sessions in the sidebar would hit OperationalError, the silent except-wrapper would swallow it, and lineage collapse would never work. Added test_in_clause_chunked_for_large_session_set with SQL interception to lock the invariant in source. PR addition (per user directive — Opus + my review, no second independent review round needed for combined batch): nesquena#1372 from @NocGeek — fix: persist manual cron run results. Self-contained 89 LOC fix split out from the held nesquena#1352. Mirrors the scheduled-cron path (cron/scheduler.py:1334-1364) exactly: saves output, marks job complete, treats empty response as soft failure with matching error string. 2 behavioral tests using sys.modules monkeypatch to mock cron.scheduler.run_job. CI not yet attached because branch is brand-new; ran the new tests + adjacent suites locally — all pass. Final test count: 3471 passing, 0 failed. Also adds 2 more regression tests for the perf-fix invariants: - test_in_clause_chunked_for_large_session_set - test_two_children_sharing_non_continuation_parent_not_collapsed
Manual WebUI cron runs previously called cron.scheduler.run_job(job) and then only cleared the in-memory running flag. That meant output could be dropped and job metadata like last_run_at / last_status was not updated after a manual run. This PR matches the scheduled cron path (cron/scheduler.py:1334-1364) exactly: - Save manual-run output via save_job_output - Mark manual runs complete via mark_job_run - Treat empty final_response as a soft failure with the same error string as the scheduled path - Record manual-run failures in job metadata via mark_job_run(False) - Keep _run_cron_tracked self-contained for worker-thread execution Includes 2 behavioral regression tests using monkeypatch.setitem on sys.modules to mock cron.scheduler.run_job + cron.jobs helpers — the right test pattern (exercises the real _run_cron_tracked code path). Split out from nesquena#1352 (the larger profile-aware-cron-panel PR that's on hold) per pre-release-review feedback. Self-contained, doesn't touch the held PR's profile-filtering scope. Co-authored-by: NocGeek <NocGeek@users.noreply.github.com>
…rsistence Opus pre-release findings on nesquena#1370 applied: SHOULD-FIX 1: Tightened parent_session_id exposure to only emit when the parent's end_reason is in {compression, cli_close}. Without this, two distinct WebUI sessions sharing a non-continuation parent (e.g. 'user_stop') would get clustered by frontend's _sessionLineageKey (which falls through to parent_session_id when _lineage_root_id is missing) and incorrectly collapsed into a single sidebar row. Updated assertions in: - tests/test_session_lineage_metadata_api.py:: test_non_compression_state_db_parent_does_not_create_sidebar_lineage - tests/test_pr1370_lineage_metadata_perf_and_orphan.py:: test_non_compression_parent_does_not_extend_lineage SHOULD-FIX 2: Chunked the IN-clause to 500 vars to stay under SQLITE_MAX_VARIABLE_NUMBER. Python 3.9 ships sqlite 3.31 with the default limit of 999. A power user with 2000+ sessions in the sidebar would hit OperationalError, the silent except-wrapper would swallow it, and lineage collapse would never work. Added test_in_clause_chunked_for_large_session_set with SQL interception to lock the invariant in source. PR addition (per user directive — Opus + my review, no second independent review round needed for combined batch): nesquena#1372 from @NocGeek — fix: persist manual cron run results. Self-contained 89 LOC fix split out from the held nesquena#1352. Mirrors the scheduled-cron path (cron/scheduler.py:1334-1364) exactly: saves output, marks job complete, treats empty response as soft failure with matching error string. 2 behavioral tests using sys.modules monkeypatch to mock cron.scheduler.run_job. CI not yet attached because branch is brand-new; ran the new tests + adjacent suites locally — all pass. Final test count: 3471 passing, 0 failed. Also adds 2 more regression tests for the perf-fix invariants: - test_in_clause_chunked_for_large_session_set - test_two_children_sharing_non_continuation_parent_not_collapsed
Summary
Fixes manual WebUI cron runs so they persist results the same way scheduled cron ticks do.
Manual runs previously called
cron.scheduler.run_job(job)and then only cleared the in-memory running flag. That meant output could be dropped and job metadata likelast_run_at/last_statuswas not updated after a manual run.This PR:
save_job_outputmark_job_run_run_cron_trackedself-contained for worker-thread executionCron Jobs Project Note
This does not change project scoping or filtering.
Manual runs still execute through
cron.scheduler.run_job, so the v0.50.247 Cron Jobs project/session metadata behavior remains independent of this fix. This patch only persists cron output and job run metadata.Tests
python3 -m py_compile api/routes.py tests/test_cron_manual_run_persistence.pypython -m pytest tests/test_cron_manual_run_persistence.py tests/test_cron_run_job_import.py tests/test_sprint3.py::test_crons_run_nonexistent tests/test_sprint10.py::test_crons_output_limit_paramresult:
7 passed