Skip to content

fix(cron): read run history from the profile that owns the job - #87936

Open
jackulau wants to merge 1 commit into
NousResearch:mainfrom
jackulau:fix/desktop-cron-run-history-remote-gateway-87882
Open

fix(cron): read run history from the profile that owns the job#87936
jackulau wants to merge 1 commit into
NousResearch:mainfrom
jackulau:fix/desktop-cron-run-history-remote-gateway-87882

Conversation

@jackulau

Copy link
Copy Markdown
Contributor

What does this PR do?

GET /api/cron/jobs/{id}/runs reads run sessions out of a profile's state.db. It decides which profile that is with:

selected = profile or _find_cron_job_profile(job_id)

profile is the request scope, not the job's owner, and the desktop is structurally capable of disagreeing about the two: the cron view lists jobs across every profile (_list_cron_jobs_sync("all")) while getCronJobRuns tags its call with whichever profile is active. Open a job that lives in another profile and the endpoint reads a state.db that cannot hold a single one of that job's runs, and the panel says "No runs yet".

The mismatch is already detectable exactly where it happens. The endpoint looks the job up in the selected profile to canonicalise the id, and gets nothing back — at which point it knows the scope is wrong and queries anyway. This PR uses that as the signal to find the owning profile and read its store. A request whose scope already owns the job keeps precisely the path it had, and a job no profile owns still returns an empty history rather than guessing at a store.

A second, independent reason the same endpoint returned nothing

Resolution went through cron.jobs.get_job, which compares against the id alone:

def get_job(job_id: str) -> Optional[Dict[str, Any]]:
    for job in jobs:
        if job["id"] == job_id:

The call site's own comment says the reference may be a human name, and _find_cron_job_profile matches on one, so a name-referenced job resolved to no record and left the scan looking for run-session ids built from a name that never appears in one (they carry the canonical id). resolve_job_ref accepts either form, preferring an exact id match. An ambiguous name is reported as unresolved rather than arbitrated between the jobs it matches.

I found this because a test I wrote for the first bug failed for the second reason. It is a separate defect in the same function and the same user-visible symptom; happy to split it out if you would rather review them apart.

Related Issue

Refs #87882

I am deliberately not writing Fixes, because I cannot show that this is the mechanism the reporter hit, and I would rather say so than let the issue auto-close on a guess. What I can show:

  • The reporter's state.db-vs-state.db diagnosis is right, and their pointer at _list_cron_job_runs_sync is the right function.
  • The profile-scope mismatch above is a real, reproducible way for that function to read the wrong store, fixed here with tests that fail without the change.
  • Whether it is their way depends on their profile setup, which the report does not give. I have posted the architectural detail and the one discriminating question on the issue.

Two things I checked that are not the cause, so nobody re-treads them:

  • App-global remote mode is not affected for the primary profile. resolveProfileBackendRoute returns {backend: 'primary', scopePath: false} for it, startHermes() resolves to the remote descriptor, and pathWithGlobalRemoteProfile leaves the path alone. REST — including this endpoint — reaches the remote gateway and reads its store. For a non-primary profile the same route sets scopePath: true and appends ?profile=, which is one concrete way to land in the bug this PR fixes.
  • Registry gateway connections are a different story, and out of scope here. HermesApiRequest carries profile and no connection id, and activeConnection() — the only connection-id-aware resolver on the renderer — is used solely by pluginSocket. So activating a registered remote gateway moves sessions and WS traffic to it while every REST call, the whole cron surface included, stays on the local profile backend. That is an architectural gap, not a one-function bug, and it is not what this PR touches.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • hermes_cli/web_server.py
    • _list_cron_job_runs_sync: when the selected profile does not own the job, fall back to _find_cron_job_profile and read that profile's store. The fallback runs only on the path that previously returned an empty list, so it costs nothing on a scope that was already correct.
    • New _resolve_cron_job_in_profile(profile, ref): resolve_job_ref in place of get_job, with AmbiguousJobReference reported as unresolved.
  • tests/hermes_cli/test_web_server_cron_profiles.py: 6 tests plus two small helpers, built on the existing isolated_profiles fixture. These are end-to-end against real profile homes and a real state.db — the job is created through _call_cron_for_profile(..., "create_job", ...) and the run session is written with create_session/append_message/end_session — not mocks around the function under test.

How to Test

pytest tests/hermes_cli/test_web_server_cron_profiles.py -q     # 37 passed

Proof each half of the change is load-bearing:

  • Revert only the owner fallback: test_run_history_follows_the_owning_profile and test_run_history_resolves_a_job_named_rather_than_identified fail.
  • Revert only resolve_job_refget_job: test_run_history_resolves_a_job_named_rather_than_identified and test_run_history_resolves_a_name_on_the_unscoped_path_too fail.

test_run_history_is_empty_for_an_ambiguous_name passes either way and is a guardrail, not a proof: get_job also returns nothing for a name, so it only pins the behaviour against a future resolver that would pick one of the two matches. test_run_history_reads_the_requested_profile_when_it_owns_the_job and test_run_history_is_empty_for_a_job_no_profile_owns are likewise guardrails on the paths this change must not disturb.

Manually, with two profiles:

  1. Create a cron job under a non-default profile and let it fire once.
  2. Switch the Desktop to a different profile and open that job's Run History.
  3. Before: "No runs yet". After: the runs, each tagged with the owning profile.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass — ran the affected suites rather than the whole tree; see "Notes on verification"
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: Windows 11 Pro 26200

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A

Overlap with #52018 — please read before merging either

#52018 is open, unmerged, and changes the same two files, including this same function. It is a genuinely different bug and the two fixes compose, but they will conflict textually and I would rather flag that than have a maintainer discover it.

  • fix(cron): list_cron_job_runs opens job's own profile state.db #52018 is about a job whose jobs.json lives in profile A while the scheduler stamped it profile: B, so the runs are in B's store. _annotate_cron_job overwrites the raw profile field before anyone can read it; that PR preserves it as scheduler_profile and opens B.
  • This PR is about the request being scoped to a profile C that owns the job in neither sense.

Neither subsumes the other. With #52018's fix alone, a request scoped to C still fails: get_job on C returns nothing, so there is no record to read scheduler_profile off. With this PR alone, their case still fails: A genuinely owns the job, so the fallback never fires. I confirmed on upstream/main that _annotate_cron_job still drops the field, so their diagnosis stands as written.

If #52018 lands first I will rebase onto it; if this lands first their rebase is small (the lookup moves behind _resolve_cron_job_in_profile). Happy to fold both into one PR if that is easier to review.

Notes on verification

  • pytest tests/hermes_cli/test_web_server_cron_profiles.py -q: 37 passed.
  • pytest tests/hermes_cli/test_cron.py test_web_server_cron_profiles.py test_cron_dashboard_off_loop.py test_cron_fire_dashboard.py test_cron_profile_enumeration_lightweight.py -q: 65 passed.
  • pytest tests/hermes_cli/test_web_server.py -q: 156 passed, 4 skipped.
  • ruff check: all checks passed. ruff format --diff reports one more hunk on web_server.py than upstream/main does, and it is not a new violation: inserting _resolve_cron_job_in_profile split a pre-existing pair of adjacent hunks (the long _list_cron_job_runs_sync signature and some stray blank lines) into two. The added lines themselves are already formatter-clean.
  • scripts/check-windows-footguns.py --all: no footguns, 973 files scanned.

Refs NousResearch#87882

The desktop cron view lists jobs across every profile and then tags its REST
calls with whichever profile is currently active, so the two disagree the
moment the opened job lives somewhere else. The run-history endpoint treated
that request scope as the job's owner and opened a state.db that provably
cannot hold a single one of the runs, and the panel said "No runs yet".

The mismatch is already detectable at the point it happens: the endpoint looks
the job up in the selected profile to canonicalise the id, and gets nothing
back. Use that as the signal to find the profile that does own the job and
read its store instead. A request whose scope already owns the job keeps
exactly the path it had, and a job no profile owns still returns an empty
history rather than guessing at a store.

Resolving the job also went through cron.jobs.get_job, which compares against
the id alone. The call site's own comment says the reference may be a human
name, and _find_cron_job_profile matches on one, so a name-referenced job
resolved to no record and left the scan looking for run-session ids built from
a name that never appears in one. resolve_job_ref accepts either form. A name
matching two jobs is reported as unresolved rather than arbitrated, which
degrades to the same empty history the ambiguity already produced.
@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/cron Cron scheduler and job management comp/dashboard Web dashboard / control panel UI (dashboard/, landing) area/profiles Multi-profile isolation, HERMES_HOME scoping sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Aug 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/profiles Multi-profile isolation, HERMES_HOME scoping comp/cron Cron scheduler and job management comp/dashboard Web dashboard / control panel UI (dashboard/, landing) P3 Low — cosmetic, nice to have sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants