diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts index 6f971c3165be..a8745801a6a6 100644 --- a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts @@ -47,7 +47,16 @@ const MESSAGING_EXCLUDED_SOURCES = ['cron', ...LOCAL_SESSION_SOURCE_IDS] // sees the persisted row), and sessions whose turn just settled (same race, but // for a chat the user has already navigated away from). Pass `scope` to only // keep the active row when it belongs to the profile being paged. -function sessionsToKeep(scope?: string): Set { +// +// `excludedSources`: the active row must NOT be kept if its source is one the +// caller's aggregator query already excludes (e.g. 'cron'). Without this check, +// opening a cron run from the Cron panel's run-history list (which navigates to +// /session/ and sets $selectedStoredSessionId) makes that cron session +// "the active session" — and this function would then force it back into the +// main recents list on every refresh, permanently bypassing the +// SIDEBAR_EXCLUDED_SOURCES filter the aggregator query applies to freshly +// fetched rows (#). +function sessionsToKeep(scope?: string, excludedSources?: readonly string[]): Set { const keep = new Set([ ...$workingSessionIds.get(), ...$pinnedSessionIds.get(), @@ -57,9 +66,11 @@ function sessionsToKeep(scope?: string): Set { const active = $selectedStoredSessionId.get() if (active) { - const session = scope ? $sessions.get().find(s => s.id === active) : null + const session = $sessions.get().find(s => s.id === active) + const sourceExcluded = excludedSources?.length ? excludedSources.includes(normalizeSessionSource(session?.source) ?? '') : false + const inScope = !scope || !session || normalizeProfileKey(session.profile) === scope - if (!scope || !session || normalizeProfileKey(session.profile) === scope) { + if (!sourceExcluded && inScope) { keep.add(active) } } @@ -178,7 +189,7 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg }) if (refreshSessionsRequestRef.current === requestId) { - setSessions(prev => mergeSessionPage(prev, result.sessions, sessionsToKeep())) + setSessions(prev => mergeSessionPage(prev, result.sessions, sessionsToKeep(undefined, SIDEBAR_EXCLUDED_SOURCES))) setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length) setSessionProfileTotals(result.profile_totals ?? {}) } @@ -209,7 +220,7 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg excludeSources: SIDEBAR_EXCLUDED_SOURCES }) - const keep = sessionsToKeep(key) + const keep = sessionsToKeep(key, SIDEBAR_EXCLUDED_SOURCES) setSessions(prev => [ ...prev.filter(s => !inKey(s)), diff --git a/cron/scheduler.py b/cron/scheduler.py index d75398d19451..73965428180e 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -2511,9 +2511,13 @@ def run_job( # stdout to telegram" watchdog pattern. The agent path is skipped # entirely: no AIAgent, no prompt, no tool loop, no token spend. # - # We check this BEFORE importing run_agent / constructing SessionDB so - # a pure-script tick never pays for the agent machinery it isn't going - # to use. Keep this block self-contained. + # We check this BEFORE importing run_agent so a pure-script tick never + # pays for the agent machinery it isn't going to use. The run is still + # recorded as a ``cron_{job_id}_{ts}`` session (a cheap sqlite write, + # same shape as agent runs) so it appears in the run-history endpoint + # (``/api/cron/jobs/{id}/runs``) and the desktop GUI — without it, + # no_agent runs are invisible after a manual trigger (#44080). Keep + # this block self-contained. # # Semantics: # - script stdout (trimmed) → delivered verbatim as the final message @@ -2529,6 +2533,61 @@ def run_job( logger.error("Job '%s': %s", job_id, err) return False, "", "", err + # Record this run as a session BEFORE executing the script: the run + # session's open/ended state is what the desktop run-history endpoint + # uses for its "running" indicator (``is_active``), and the session + # row is the run record itself. Best-effort — a missing/broken state + # store degrades to the old no-record behaviour, never blocks the run. + _session_db = None + _run_session_id = None + try: + from hermes_state import SessionDB + _session_db = SessionDB() + _run_session_id = f"cron_{job_id}_{_hermes_now().strftime('%Y%m%d_%H%M%S')}" + _session_db.create_session(_run_session_id, source="cron") + # First user message becomes the run's preview in run history. + _session_db.append_message( + _run_session_id, "user", f"no_agent script: {script_path}" + ) + except (Exception, KeyboardInterrupt) as e: + logger.debug( + "Job '%s': SQLite session store not available for no_agent run: %s", + job_id, e, + ) + _session_db = None + + def _record_run(run_doc: str) -> None: + """Persist the outcome and close out this run's session record.""" + if not _session_db: + return + try: + _session_db.append_message(_run_session_id, "assistant", run_doc) + except (Exception, KeyboardInterrupt) as e: + logger.debug( + "Job '%s': failed to record no_agent run output: %s", job_id, e + ) + # Same titling scheme as the agent path so sidebars/history show a + # meaningful label; the run-time suffix keeps it unique against + # the sessions.title index across runs. + try: + _title_base = " ".join(job_name.split())[:60].strip() or f"cron {job_id}" + _session_db.set_session_title( + _run_session_id, + f"{_title_base} · {_hermes_now().strftime('%b %d %H:%M')}", + ) + except (Exception, KeyboardInterrupt) as e: + logger.debug("Job '%s': failed to set cron session title: %s", job_id, e) + try: + _session_db.end_session(_run_session_id, "cron_complete") + except (Exception, KeyboardInterrupt) as e: + logger.debug("Job '%s': failed to end session: %s", job_id, e) + try: + _session_db.close() + except (Exception, KeyboardInterrupt) as e: + logger.debug( + "Job '%s': failed to close SQLite session store: %s", job_id, e + ) + # Apply workdir if configured — lets scripts use predictable relative # paths. For no_agent jobs this is just the subprocess cwd (not an # agent TERMINAL_CWD bridge). @@ -2569,6 +2628,7 @@ def run_job( f"**Status:** script failed\n\n" f"{output}\n" ) + _record_run(doc) return False, doc, alert, output # Honour the wakeAgent gate as a silent signal — `wakeAgent: false` @@ -2584,6 +2644,7 @@ def run_job( f"**Mode:** no_agent (script)\n" f"**Status:** silent (wakeAgent=false)\n" ) + _record_run(silent_doc) return True, silent_doc, SILENT_MARKER, None if not output.strip(): @@ -2595,6 +2656,7 @@ def run_job( f"**Mode:** no_agent (script)\n" f"**Status:** silent (empty output)\n" ) + _record_run(silent_doc) return True, silent_doc, SILENT_MARKER, None doc = ( @@ -2605,6 +2667,7 @@ def run_job( f"---\n\n" f"{output}\n" ) + _record_run(doc) return True, doc, output, None # --------------------------------------------------------------- diff --git a/tests/cron/test_cron_no_agent.py b/tests/cron/test_cron_no_agent.py index af94713868be..9c7017c86d1e 100644 --- a/tests/cron/test_cron_no_agent.py +++ b/tests/cron/test_cron_no_agent.py @@ -31,6 +31,8 @@ def hermes_env(tmp_path, monkeypatch): import importlib import hermes_constants importlib.reload(hermes_constants) + import hermes_state + importlib.reload(hermes_state) # DEFAULT_DB_PATH binds at import time import cron.jobs importlib.reload(cron.jobs) import cron.scheduler @@ -280,6 +282,129 @@ def test_run_job_no_agent_never_invokes_aiagent(hermes_env): ai_mock.assert_not_called() +# --------------------------------------------------------------------------- +# run_job: no_agent runs are recorded as run-history sessions (#44080) +# --------------------------------------------------------------------------- + + +def _job_runs(job_id): + from hermes_state import SessionDB + + db = SessionDB() + try: + return db.list_cron_job_runs(job_id) + finally: + db.close() + + +def _session_messages(session_id): + from hermes_state import SessionDB + + db = SessionDB() + try: + return db.get_messages(session_id) + finally: + db.close() + + +def test_run_job_no_agent_success_records_run_session(hermes_env): + """A successful no_agent run must appear in the job's run history.""" + from cron.jobs import create_job + from cron.scheduler import run_job + + script_path = hermes_env / "scripts" / "alert.sh" + script_path.write_text("#!/bin/bash\necho 'RAM 92% on host'\n") + + job = create_job( + prompt=None, schedule="every 5m", script="alert.sh", no_agent=True, deliver="local" + ) + success, _, _, _ = run_job(job) + assert success is True + + runs = _job_runs(job["id"]) + assert len(runs) == 1 + run = runs[0] + assert run["id"].startswith(f"cron_{job['id']}_") + assert run["source"] == "cron" + # The run finished, so the GUI must not show it as still active. + assert run["ended_at"] is not None + assert run["end_reason"] == "cron_complete" + + # Script output is persisted so the GUI can show what the run produced. + messages = _session_messages(run["id"]) + roles = [m["role"] for m in messages] + assert "user" in roles and "assistant" in roles + assistant_text = " ".join( + str(m.get("content") or "") for m in messages if m["role"] == "assistant" + ) + assert "RAM 92% on host" in assistant_text + + +def test_run_job_no_agent_failure_records_run_session(hermes_env): + """A failed script run must be visible in run history, not silent.""" + from cron.jobs import create_job + from cron.scheduler import run_job + + script_path = hermes_env / "scripts" / "broken.sh" + script_path.write_text("#!/bin/bash\necho oops >&2\nexit 3\n") + + job = create_job( + prompt=None, schedule="every 5m", script="broken.sh", no_agent=True, deliver="local" + ) + success, _, _, _ = run_job(job) + assert success is False + + runs = _job_runs(job["id"]) + assert len(runs) == 1 + assert runs[0]["ended_at"] is not None + + messages = _session_messages(runs[0]["id"]) + assistant_text = " ".join( + str(m.get("content") or "") for m in messages if m["role"] == "assistant" + ) + assert "script failed" in assistant_text + + +def test_run_job_no_agent_silent_run_records_run_session(hermes_env): + """Silent runs (empty stdout) still leave a run record.""" + from cron.jobs import create_job + from cron.scheduler import run_job, SILENT_MARKER + + script_path = hermes_env / "scripts" / "quiet.sh" + script_path.write_text("#!/bin/bash\n# nothing to say\n") + + job = create_job( + prompt=None, schedule="every 5m", script="quiet.sh", no_agent=True, deliver="local" + ) + success, _, final_response, _ = run_job(job) + assert success is True + assert final_response == SILENT_MARKER + + runs = _job_runs(job["id"]) + assert len(runs) == 1 + assert runs[0]["ended_at"] is not None + + +def test_run_job_no_agent_survives_broken_session_store(hermes_env): + """Run recording is best-effort: a broken state store must not break runs.""" + from cron.jobs import create_job + from cron.scheduler import run_job + + script_path = hermes_env / "scripts" / "alert.sh" + script_path.write_text("#!/bin/bash\necho 'still works'\n") + + job = create_job( + prompt=None, schedule="every 5m", script="alert.sh", no_agent=True, deliver="local" + ) + + with patch("hermes_state.SessionDB", side_effect=RuntimeError("db locked")): + success, _, final_response, error = run_job(job) + + assert success is True + assert error is None + assert "still works" in final_response + + # --------------------------------------------------------------------------- # _run_job_script: shell-script support # ---------------------------------------------------------------------------