fix(cron): mark running cron sessions as active in sidebar (#6734)

* fix(cron): mark running cron sessions as active in sidebar

* fix(cron): include cron_running in effective-streaming predicate + test updates (#6734)

* fix(cron): longest-prefix-first cron job matching in session list cache (#6728)

* fix(cron): require exact run-timestamp suffix in cron_running prefix match

Gate re-review (Codex) found the longest-prefix-first match could misattribute:
cron_job_prefixes is built ONLY from RUNNING jobs, so when the true longer owner
(backup_full) is NOT running, a running shorter job (backup) swallowed a completed
cron_backup_full_YYYYMMDD_HHMMSS session (its prefix cron_backup_ still matched),
deferring that row's completion/unread transition. Now the text after a matched
cron_{jid}_ prefix must be EXACTLY a run timestamp (\d{8}_\d{6}), so a shorter job
id can't claim a longer job id's session regardless of which jobs are running.
Regression test added (fails without the guard).

Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>

---------

Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-08-09 02:40:05 -07:00
1 parent 1af7eb5b16
commit fa087c53d1
5 files changed
+468 -6

No files matched your search

+74
View File
@@ -2,6 +2,7 @@
import os
import copy
import re
import threading
import time
from collections import OrderedDict
@@ -12,6 +13,14 @@ from api.models import _active_state_db_path, _active_stream_ids
from api.profiles import _profiles_match
# Cron session ids are ``cron_{job_id}_{run_timestamp}`` where the run
# timestamp is ``YYYYMMDD_HHMMSS`` (e.g. cron_job6728_20260803_100000). Used to
# validate that the text after a matched ``cron_{jid}_`` prefix is EXACTLY a run
# timestamp, so a shorter job id (backup) cannot claim a longer job id's session
# (backup_full) when the longer job is not itself running (#6728 gate fix).
_CRON_RUN_TS_RE = re.compile(r"\d{8}_\d{6}")
_SESSIONS_CACHE_TTL_SECONDS = 2.5
# #4808: while a turn is actively streaming the frontend polls /api/sessions on a
# fixed cadence (static/sessions.js `_streamingPollMs`). With the idle TTL of
@@ -98,6 +107,27 @@ def _session_list_cache_active_stream_ids():
return _active_stream_ids()
def _session_list_cache_running_cron_jobs() -> dict[str, float]:
"""Return {job_id: start_epoch} for cron jobs currently tracked as running.
Cron liveness lives only in the in-memory ``_RUNNING_CRON_JOBS`` dict in
api.routes (#6728): the sidebar polls /api/sessions (not /api/crons/status),
so without this overlay a still-running cron job's session row looks
completed the moment it appends a message. Fail closed to an empty dict.
"""
try:
import api.routes as _routes
jobs = getattr(_routes, "_RUNNING_CRON_JOBS", None)
lock = getattr(_routes, "_RUNNING_CRON_LOCK", None)
if jobs is None or lock is None:
return {}
with lock:
return dict(jobs)
except Exception:
return {}
def _session_list_cache_resolved_source_stamp(key: tuple):
try:
import api.routes as _routes
@@ -426,6 +456,11 @@ def _session_list_cache_overlay_runtime_rows(rows: list[dict]) -> list[dict]:
active_stream_ids = _session_list_cache_active_stream_ids()
except Exception:
active_stream_ids = set()
try:
running_cron_jobs = _session_list_cache_running_cron_jobs()
except Exception:
running_cron_jobs = {}
cron_job_prefixes = [(jid, f"cron_{jid}_", started_at) for jid, started_at in running_cron_jobs.items()]
session_ids = [
str(row.get("session_id") or "").strip()
for row in rows
@@ -457,11 +492,50 @@ def _session_list_cache_overlay_runtime_rows(rows: list[dict]) -> list[dict]:
item[key] = raw_live_value
stream_id = item.get("active_stream_id")
item["is_streaming"] = bool(stream_id and stream_id in active_stream_ids)
# #6728: a still-running cron job's session row must not look completed
# in the sidebar. Cron liveness is only exposed via /api/crons/status,
# which the sidebar never polls — stamp the flag here so the client can
# defer its completion/unread transition until the job actually ends.
# Session ids are cron_{job_id}_{run_timestamp}: only the run started at
# (or after) the tracked start belongs to the live execution — older runs
# of the same job stay completed.
item["cron_running"] = _session_list_row_cron_running(
sid, item, cron_job_prefixes
)
overlaid.append(item)
overlaid.sort(key=_session_list_runtime_sort_key, reverse=True)
return overlaid
def _session_list_row_cron_running(
sid: str, row: dict, cron_job_prefixes: list[tuple[str, str, float]]
) -> bool:
if not cron_job_prefixes or not sid:
return False
created_at = _session_list_row_numeric_value(row.get("created_at"))
# Longest prefix first, no fall-through: job ids may nest (backup vs
# backup_full), and the shorter prefix is a valid prefix of the longer one.
# A session belongs to the longest matching job id — first-match in
# insertion order, or falling through to a shorter prefix after a time-miss,
# would let a running shorter-prefix job claim a completed longer-prefix
# session. Mirrors the max(matches, key=len) convention in
# api.routes._latest_cron_session_info_for_jobs.
#
# #6728 (gate fix): prefixes are built ONLY from RUNNING jobs, so if the
# true longer owner (backup_full) is not running, longest-prefix sorting
# never sees it and a running `backup` would otherwise swallow a
# `cron_backup_full_YYYYMMDD_HHMMSS` session (the leftover `full_...` still
# starts with nothing it should match). Require the text AFTER the prefix to
# be exactly a run-timestamp (YYYYMMDD_HHMMSS) so a shorter job id cannot
# claim a longer job id's session regardless of which jobs are running.
for _jid, prefix, started_at in sorted(
cron_job_prefixes, key=lambda item: len(item[1]), reverse=True
):
if sid.startswith(prefix) and _CRON_RUN_TS_RE.fullmatch(sid[len(prefix):]):
return created_at >= started_at
return False
def _session_list_row_numeric_value(value) -> float:
try:
numeric = float(value or 0)