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:
5 files changed
+468
-6
No files matched your search
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user