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
- 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
Open PRs0
Fixes on record
-
#67493 fix(cron): profile-scope desktop cron calls + resolve provider with the job's effective model
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.pyShow 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
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.00means 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