fix(cron): deduplicate cross-profile jobs in GET /api/cron/jobs

profile=all aggregated every profile's list without deduplication, so a job
copied into a second profile's cron/jobs.json during profile creation appeared
twice — inflating the desktop sidebar count and rendering duplicate rows.

Collect all jobs first, then resolve duplicates by id with default-profile
priority, instead of keeping whichever copy the profile loop happened to append
first. cron.jobs._normalize_job_record() fills a missing id with the literal
sentinel string "unknown", which is truthy — treat it like no id so two
genuinely different id-less legacy records from different profiles are never
collapsed into one.

Fixes #51721

Salvaged from #69132 by @ygd58 (kept its dedup semantics and regression tests,
ported onto the web_routers/cron.py seam after the web_server refactor).

Co-authored-by: ygd58 <buraysandro9@gmail.com>
This commit is contained in:
Hermes Agent
2026-09-25 11:06:00 -05:00
committed by brooklyn!
parent f8dddd7151
commit f84db42a32
2 changed files with 123 additions and 3 deletions

View File

@@ -107,16 +107,42 @@ def _list_cron_jobs_sync(profile: str = "all"):
if requested.lower() != "all":
return _call_cron_for_profile(requested, "list_jobs", True)
jobs: List[Dict[str, Any]] = []
# Aggregating across profiles can surface the SAME job id more than once —
# e.g. a job copied into a second profile's cron/jobs.json during profile
# creation. Deduplicate by id, deterministically preferring the default
# profile's copy over per-iteration order (#51721): collect all jobs first,
# then resolve duplicates by id with default-profile priority, rather than
# keeping whichever copy happened to be seen first during the profile loop.
all_jobs: List[Dict[str, Any]] = []
for item in _cron_profile_dicts():
name = str(item.get("name") or "")
if not name:
continue
try:
jobs.extend(_call_cron_for_profile(name, "list_jobs", True))
all_jobs.extend(_call_cron_for_profile(name, "list_jobs", True))
except Exception:
_log.exception("Failed to list cron jobs for profile %s", name)
return jobs
by_id: Dict[str, Dict[str, Any]] = {}
unkeyed: List[Dict[str, Any]] = []
for job in all_jobs:
if not isinstance(job, dict):
continue
jid = job.get("id") or job.get("job_id")
# cron.jobs._normalize_job_record() fills a missing id with the literal
# sentinel string "unknown" — which is truthy, so a plain `if not jid`
# check would NOT catch it and two genuinely different id-less jobs from
# different profiles would collapse into one under this shared sentinel
# key. Treat the sentinel the same as no id: never deduplicated.
if not jid or jid == "unknown":
unkeyed.append(job)
continue
existing = by_id.get(jid)
if existing is None or (
job.get("is_default_profile") and not existing.get("is_default_profile")
):
by_id[jid] = job
return list(by_id.values()) + unkeyed
def _get_cron_job_sync(job_id: str, profile: Optional[str] = None):