Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions apps/desktop/src/app/session/hooks/use-session-list-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
//
// `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/<cron_id> 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 (#<issue-number-tbd>).
function sessionsToKeep(scope?: string, excludedSources?: readonly string[]): Set<string> {
const keep = new Set<string>([
...$workingSessionIds.get(),
...$pinnedSessionIds.get(),
Expand All @@ -57,9 +66,11 @@ function sessionsToKeep(scope?: string): Set<string> {
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)
}
}
Expand Down Expand Up @@ -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 ?? {})
}
Expand Down Expand Up @@ -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)),
Expand Down
69 changes: 66 additions & 3 deletions cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
Expand Down Expand Up @@ -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`
Expand All @@ -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():
Expand All @@ -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 = (
Expand All @@ -2605,6 +2667,7 @@ def run_job(
f"---\n\n"
f"{output}\n"
)
_record_run(doc)
return True, doc, output, None

# ---------------------------------------------------------------
Expand Down
125 changes: 125 additions & 0 deletions tests/cron/test_cron_no_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down