NousResearch/hermes-agent

Desktop cron job list scoped to active profile

Cluster 51520 · apps/desktop. Data as of

Nothing open on GitHub. Fix merged in #67615 on 19 Jul 2026. Check it covers your case.

The short answer

0open issues
0open PRs
4items grouped
Known?
Yes. 4 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #67615. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and fixes address a bug where the desktop app's cron sidebar displays jobs from all profiles instead of only the currently active one. Related work also covers scoping cron API calls and resolving providers using the job's effective model.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #67493 fix(cron): profile-scope desktop cron calls + resolve provider with the job's effective model

    merged to main

    files: apps/desktop/src/hermes.ts, cron/scheduler.py, hermes_cli/web_server.py, tests/cron/test_codex_execution_paths.py, tests/cron/test_cron_provider_pin.py, tests/hermes_cli/test_web_server_cron_profiles.py

    Show the diff
    • apps/desktop/src/hermes.ts
      @@ -958,6 +958,7 @@ export function testMessagingPlatform(platformId: string): Promise<MessagingPlat
       
       export function getCronJobs(): Promise<CronJob[]> {
         return window.hermesDesktop.api<CronJob[]>({
      +    ...profileScoped(),
           path: '/api/cron/jobs',
           timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
         })
      @@ -965,12 +966,14 @@ export function getCronJobs(): Promise<CronJob[]> {
       
       export function getCronJob(jobId: string): Promise<CronJob> {
         return window.hermesDesktop.api<CronJob>({
      +    ...profileScoped(),
           path: `/api/cron/jobs/${encodeURIComponent(jobId)}`
         })
       }
       
       export async function getCronJobRuns(jobId: string, limit = 20): Promise<SessionInfo[]> {
         const { runs } = await window.hermesDesktop.api<{ runs: SessionInfo[] }>({
      +    ...profileScoped(),
           path: `/api/cron/jobs/${encodeURIComponent(jobId)}/runs?limit=${limit}`
         })
       
      @@ -979,6 +982,7 @@ export async function getCronJobRuns(jobId: string, limit = 20): Promise<Session
       
       export function createCronJob(body: CronJobCreatePayload): Promise<CronJob> {
         return window.hermesDesktop.api<CronJob>({
      +    ...profileScoped(),
           path: '/api/cron/jobs',
           method: 'POST',
           body
      @@ -987,6 +991,7 @@ export function createCronJob(body: CronJobCreatePayload): Promise<CronJob> {
       
       export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise<CronJob> {
         return window.hermesDesktop.api<CronJob>({
      +    ...profileScoped(),
           path: `/api/cron/jobs/${encodeURIComponent(jobId)}`,
           method: 'PUT',
           body: { updates }
      @@ -995,6 +1000,7 @@ export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise<C
       
       export function pauseCronJob(jobId: string): Promise<CronJob> {
         return window.hermesDesktop.api<CronJob>({
      +    ...profileScoped(),
           path: `/api/cron/jobs/${encodeURIComponent(jobId)}/pause`,
           method: 'POST'
         })
      @@ -1002,6 +1008,7 @@ export function pauseCronJob(jobId: string): Promise<CronJob> {
       
       export function resumeCronJob(jobId: string): Promise<CronJob> {
         return window.hermesDesktop.api<CronJob>({
      +    ...profileScoped(),
           path: `/api/cron/jobs/${encodeURIComponent(jobId)}/resume`,
           method: 'POST'
         })
      @@ -1009,6 +1016,7 @@ export function resumeCronJob(jobId: string): Promise<CronJob> {
       
       export function triggerCronJob(jobId: string): Promise<CronJob> {
         return window.hermesDesktop.api<CronJob>({
      +    ...profileScoped(),
           path: `/api/cron/jobs/${encodeURIComponent(jobId)}/trigger`,
           method: 'POST'
         })
      @@ -1016,6 +1024,7 @@ export function triggerCronJob(jobId: string): Promise<CronJob> {
       
       export function deleteCronJob(jobId: string): Promise<{ ok: boolean }> {
         return window.hermesDesktop.api<{ ok: boolean }>({
      +    ...profileScoped(),
           path: `/api/cron/jobs/${encodeURIComponent(jobId)}`,
           method: 'DELETE'
         })
    • cron/scheduler.py
      @@ -3178,6 +3178,11 @@ def run_job(
                   # example DeepSeek) for cron jobs that do not pin provider/model.
                   runtime_kwargs = {
                       "requested": job.get("provider"),
      +                # Derive provider-specific api_mode from the model this job
      +                # will actually run (per-job pin > env > config default), not
      +                # the stale persisted default — mirrors the fallback path
      +                # below, which already passes its fb_model.
      +                "target_model": model,
                   }
                   if job.get("base_url"):
                       runtime_kwargs["explicit_base_url"] = job.get("base_url")
    • hermes_cli/web_server.py
      @@ -11096,11 +11096,31 @@ def _cron_profile_dicts() -> List[Dict[str, Any]]:
               return _fallback_profile_dicts(profiles_mod)
       
       
      +def _cron_default_profile() -> str:
      +    """Profile to target when a cron request carries no explicit ``profile``.
      +
      +    A desktop pool backend runs one process per profile (HERMES_HOME already
      +    scoped), but these cron endpoints deliberately route storage through the
      +    profiles tree via ``_cron_profile_home`` — so a hardcoded ``"default"``
      +    fallback would write a non-default profile's job into ``~/.hermes``.
      +    Resolve the process's own profile instead. ``custom`` (an unrecognized
      +    HERMES_HOME outside the profiles tree) has no profile-dir equivalent, so
      +    it keeps the legacy ``default`` fallback.
      +    """
      +    try:
      +        from hermes_cli.profiles import get_active_profile_name
      +
      +        name = get_active_profile_name()
      +    except Exception:
      +        return "default"
      +    return "default" if name in ("default", "custom") else name
      +
      +
       def _cron_profile_home(profile: Optional[str]) -> Tuple[str, Path]:
           """Resolve a profile query value to (profile_name, HERMES_HOME)."""
           from hermes_cli import profiles as profiles_mod
       
      -    raw = (profile or "default").strip() or "default"
      +    raw = (profile or _cron_default_profile()).strip() or "default"
           try:
               canon = profiles_mod.normalize_profile_name(raw)
               profiles_mod.validate_profile_name(canon)
      @@ -11256,7 +11276,7 @@ async def list_cron_job_runs(job_id: str, profile: Optional[str] = None, limit:
           return await _run_cron_dashboard_io(_list_cron_job_runs_sync, job_id, profile, limit)
       
       
      -def _create_cron_job_sync(body: CronJobCreate, profile: str = "default"):
      +def _create_cron_job_sync(body: CronJobCreate, profile: Optional[str] = None):
           try:
               profile_name, profile_home = _cron_profile_home(profile)
               script = _normalize_dashboard_cron_script(body.script, profile_home)
      @@ -11295,7 +11315,7 @@ def _create_cron_job_sync(body: CronJobCreate, profile: str = "default"):
       
       
       @app.post("/api/cron/jobs")
      -async def create_cron_job(body: CronJobCreate, profile: str = "default"):
      +async def create_cron_job(body: CronJobCreate, profile: Optional[str] = None):
           return await _run_cron_dashboard_io(_create_cron_job_sync, body, profile)
       
       
    • tests/cron/test_codex_execution_paths.py
      @@ -98,7 +98,7 @@ def test_cron_run_job_codex_path_handles_internal_401_refresh(monkeypatch):
           monkeypatch.setattr(run_agent, "AIAgent", _Codex401ThenSuccessAgent)
           monkeypatch.setattr(
               "hermes_cli.runtime_provider.resolve_runtime_provider",
      -        lambda requested=None: {
      +        lambda requested=None, **kwargs: {
                   "provider": "openai-codex",
                   "api_mode": "codex_responses",
                   "base_url": "https://chatgpt.com/backend-api/codex",
    • tests/cron/test_cron_provider_pin.py
      @@ -334,3 +334,42 @@ def test_no_model_snapshot_backcompat(self, tmp_path):
                   )
               assert agent_constructed is True
               assert success is True
      +
      +
      +class TestRuntimeResolutionTargetModel:
      +    """run_job must resolve the primary provider against the model the job
      +    will actually run (per-job pin > env > config default), so providers with
      +    model-specific api_mode routing (e.g. OpenCode Zen/Go) pick the mode for
      +    the pinned model instead of the stale persisted default."""
      +
      +    def test_primary_resolution_passes_effective_model(self, tmp_path):
      +        job = _base_job(model="my-pinned-model", provider="openrouter")
      +        captured = {}
      +
      +        def _capture(**kwargs):
      +            captured.update(kwargs)
      +            return {
      +                "api_key": "[redacted:secret-assignment]",
      +                "base_url": "https://example.invalid/v1",
      +                "provider": "openrouter",
      +                "api_mode": "chat_completions",
      +            }
      +
      +        fake_db = MagicMock()
      +        with patch("cron.scheduler._hermes_home", tmp_path), \
      +             patch("cron.scheduler._resolve_origin", return_value=None), \
      +             patch("hermes_cli.env_loader.load_hermes_dotenv"), \
      +             patch("hermes_cli.env_loader.reset_secret_source_cache"), \
      +             patch("hermes_state.SessionDB", return_value=fake_db), \
      +             patch(
      +                 "hermes_cli.runtime_provider.resolve_runtime_provider",
      +                 side_effect=_capture,
      +             ), \
      +             patch("run_agent.AIAgent") as mock_agent_cls:
      +            mock_agent = MagicMock()
      +            mock_agent.run_conversation.return_value = {"final_response": "ok"}
      +            mock_agent_cls.return_value = mock_agent
      +            run_job(job)
      +
      +        assert captured.get("target_model") == "my-pinned-model"
      +        assert captured.get("requested") == "openrouter"
    • tests/hermes_cli/test_web_server_cron_profiles.py
      @@ -735,3 +735,53 @@ 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
      +
      +
      [redacted:email]
      +async def test_create_cron_job_without_profile_uses_backend_own_profile(
      +    isolated_profiles, monkeypatch
      +):
      +    """A pool backend scoped to a named profile must not default creates to
      +    ``~/.hermes`` when the request carries no explicit ``profile`` (the
      +    Desktop app's pre-profileScoped clients sent none)."""
      +    from hermes_cli import web_server
      +
      +    monkeypatch.setenv(
      +        "HERMES_HOME", str(isolated_profiles["worker_alpha"])
      +    )
      +
      +    job = await web_server.create_cron_job(
      +        web_server.CronJobCreate(
      +            prompt="runs in my own profile",
      +            schedule="every 1h",
      +            name="own-profile-job",
      +        ),
      +        profile=None,
      +    )
      +
      +    assert job["profile"] == "worker_alpha"
      +    assert (isolated_profiles["worker_alpha"] / "cron" / "jobs.json").exists()
      +    assert not (isolated_profiles["default"] / "cron" / "jobs.json").exists()
      +
      +
      [redacted:email]
      +async def test_create_cron_job_without_profile_defaults_when_unscoped(
      +    isolated_profiles, monkeypatch
      +):
      +    """HERMES_HOME at the default home (or unrecognized) keeps the legacy
      +    ``default`` fallback."""
      +    from hermes_cli import web_server
      +
      +    monkeypatch.setenv("HERMES_HOME", str(isolated_profiles["default"]))
      +
      +    job = await web_server.create_cron_job(
      +        web_server.CronJobCreate(
      +            prompt="runs in default",
      +            schedule="every 1h",
      +            name="default-job",
      +        ),
      +        profile=None,
      +    )
      +
      +    assert job["profile"] == "default"
      +    assert (isolated_profiles["default"] / "cron" / "jobs.json").exists()
  • #67615 fix(desktop): scope the cron jobs list to the active profile

    merged to main

    files: apps/desktop/src/app/cron/index.tsx, apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx, apps/desktop/src/app/session/hooks/use-session-list-actions.ts, apps/desktop/src/hermes-cron-scope.test.ts, apps/desktop/src/hermes.ts, contributors/emails/[redacted:email]

    Show the diff
    • apps/desktop/src/app/cron/index.tsx
      @@ -43,6 +43,7 @@ import { requestModelOptions } from '@/lib/model-options'
       import { asText } from '@/lib/text'
       import { $cronFocusJobId, $cronJobs, setCronFocusJobId, setCronJobs, updateCronJobs } from '@/store/cron'
       import { notify, notifyError } from '@/store/notifications'
      +import { $profileScope, ALL_PROFILES } from '@/store/profile'
       
       import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
       import {
      @@ -293,15 +294,20 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
         const [pendingDelete, setPendingDelete] = useState<CronJob | null>(null)
         const [deleting, setDeleting] = useState(false)
       
      +  // Jobs live per-profile on disk and the list endpoint aggregates 'all' by
      +  // default — scope the fetch to the sidebar's profile scope so this overlay
      +  // and the sidebar (which share the $cronJobs atom) agree on what's shown.
      +  const profileScope = useStore($profileScope)
      +
         const refresh = useCallback(async () => {
           try {
      -      setCronJobs(await getCronJobs())
      +      setCronJobs(await getCronJobs(profileScope === ALL_PROFILES ? 'all' : profileScope))
           } catch (err) {
             notifyError(err, c.failedLoad)
           } finally {
             setLoading(false)
           }
      -  }, [c])
      +  }, [c, profileScope])
       
         useRefreshHotkey(refresh)
       
    • apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx
      @@ -204,4 +204,25 @@ describe('refreshSessions batches slices into one request', () => {
             })
           )
         })
      +
      +  it('scopes the cron-jobs fetch to the active profile (all → unified view)', async () => {
      +    const { getCronJobs } = await import('@/hermes')
      +    listSidebarSessions.mockResolvedValue(sidebar({ sessions: [], total: 0, profile_totals: {} }))
      +
      +    const scoped = renderHook(() => useSessionListActions({ profileScope: 'work' }))
      +
      +    await act(async () => {
      +      await scoped.result.current.refreshCronJobs()
      +    })
      +
      +    expect(getCronJobs).toHaveBeenLastCalledWith('work')
      +
      +    const unified = renderHook(() => useSessionListActions({ profileScope: '__all__' }))
      +
      +    await act(async () => {
      +      await unified.result.current.refreshCronJobs()
      +    })
      +
      +    expect(getCronJobs).toHaveBeenLastCalledWith('all')
      +  })
       })
    • apps/desktop/src/app/session/hooks/use-session-list-actions.ts
      @@ -123,16 +123,19 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg
         // Cron *jobs* drive the sidebar "Cron jobs" section. Jobs are created
         // synchronously (agent tool call or the cron UI), so refreshing here right
         // after an agent turn surfaces a new job immediately; the interval poll keeps
      -  // next-run/state fresh as the scheduler advances them.
      +  // next-run/state fresh as the scheduler advances them. Jobs live per-profile
      +  // on disk and the list endpoint aggregates 'all' by default, so scope the
      +  // fetch to the sidebar's profile scope — a concrete profile sees only its
      +  // own jobs; ALL_PROFILES keeps the unified view.
         const refreshCronJobs = useCallback(async () => {
           try {
      -      const jobs = await getCronJobs()
      +      const jobs = await getCronJobs(profileScope === ALL_PROFILES ? 'all' : profileScope)
       
             setCronJobs(jobs)
           } catch {
             // Non-fatal: the cron section just keeps its last-known jobs.
           }
      -  }, [])
      +  }, [profileScope])
       
         const refreshSessions = useCallback(async () => {
           const requestId = refreshSessionsRequestRef.current + 1
    • apps/desktop/src/hermes-cron-scope.test.ts
      @@ -56,4 +56,19 @@ describe('cron helpers are profile-scoped', () => {
             expect(call[0].profile).toBe('coder')
           }
         })
      +
      +  it('list accepts an explicit ?profile= for endpoint-level filtering', () => {
      +    // profileScoped() routes the backend process; the list endpoint ALSO
      +    // aggregates 'all' by default, so callers pass an explicit profile to
      +    // filter what the endpoint returns (sidebar / cron overlay scoping).
      +    void getCronJobs('worker_alpha')
      +    expect(api.mock.calls.at(-1)?.[0].path).toBe('/api/cron/jobs?profile=worker_alpha')
      +
      +    void getCronJobs('all')
      +    expect(api.mock.calls.at(-1)?.[0].path).toBe('/api/cron/jobs?profile=all')
      +
      +    // Omitting the arg keeps the legacy unfiltered path.
      +    void getCronJobs()
      +    expect(api.mock.calls.at(-1)?.[0].path).toBe('/api/cron/jobs')
      +  })
       })
    • apps/desktop/src/hermes.ts
      @@ -956,10 +956,17 @@ export function testMessagingPlatform(platformId: string): Promise<MessagingPlat
         })
       }
       
      -export function getCronJobs(): Promise<CronJob[]> {
      +// Cron jobs are stored per-profile (<HERMES_HOME>/cron/jobs.json), and the
      +// backend's list endpoint defaults to 'all'. Pass a concrete profile key to
      +// list just that profile's jobs, or 'all' for the unified cross-profile view.
      +// Omitting the arg keeps the legacy 'all' default for non-profile callers.
      +// profileScoped() still rides along for backend-process routing.
      +export function getCronJobs(profile?: string): Promise<CronJob[]> {
      +  const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''
      +
         return window.hermesDesktop.api<CronJob[]>({
           ...profileScoped(),
      -    path: '/api/cron/jobs',
      +    path: `/api/cron/jobs${suffix}`,
           timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
         })
       }
    • contributors/emails/[redacted:email]
      @@ -0,0 +1 @@
      +digitalbase

1 PR closed without merging

From the close comments: done elsewhere 1

  • #51531 fix(desktop): scope cron job list to the active profile · done elsewhere → #67493, #67602, #67615
Closed issues · 1
  • Closed#51520[Bug]: Desktop app cron sidebar lists every profile's jobs regardless of the selected profile"
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.50: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
apps/desktop
Merged PRs
#67493, #67615
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
19 Jul 2026

For your agent

The same facts, typed.