Nothing open on GitHub. Fix merged in #22389. Check it covers your case.
The short answer
- Known?
- Yes. 5 related items grouped here, 2 issues already closed.
- Fix event
- A fix was merged to main in PR #22389. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #22389. That is separate from the fix event. - Still open
- Nothing open on GitHub.
Multiple reports and fixes addressing a blank Cron tab in the dashboard, including wiring the cron page to the jobs API and handling partial job records that caused crashes.
Open issues0
Open PRs0
Fixes on record
-
#22389 fix(cron): blank Cron dashboard tab + partial-record crashes (salvage #21042 + #22330)
files:
cron/jobs.py,cron/scheduler.py,tests/cron/test_jobs.py,tests/cron/test_scheduler.py,tests/tools/test_cronjob_tools.py,tools/cronjob_tools.py,web/src/lib/api.ts,web/src/pages/CronPage.tsxShow the diff
-
cron/jobs.py@@ -72,6 +72,65 @@ def _apply_skill_fields(job: Dict[str, Any]) -> Dict[str, Any]: return normalized +def _coerce_job_text(value: Any, fallback: str = "") -> str: + """Coerce legacy/hand-edited nullable cron fields to strings for readers.""" + if value is None: + return fallback + return str(value) + + +def _schedule_display_for_job(job: Dict[str, Any]) -> str: + display = _coerce_job_text(job.get("schedule_display")).strip() + if display: + return display + + schedule = job.get("schedule") + if isinstance(schedule, dict): + for key in ("display", "value", "expr", "run_at"): + text = _coerce_job_text(schedule.get(key)).strip() + if text: + return text + elif schedule is not None: + return str(schedule) + + return "?" + + +def _normalize_job_record(job: Dict[str, Any]) -> Dict[str, Any]: + """Return a read-safe cron job shape for UI/API/tool/scheduler consumers. + + Older or hand-edited jobs can have nullable fields like ``prompt``, + ``name``, or ``schedule_display``. Keep storage untouched on read, but + ensure consumers never crash while formatting or running those records. + """ + normalized = _apply_skill_fields(job) + job_id = _coerce_job_text(normalized.get("id"), "unknown") + prompt = _coerce_job_text(normalized.get("prompt")) + normalized["id"] = job_id + normalized["prompt"] = prompt + + name = _coerce_job_text(normalized.get("name")).strip() + if not name: + script = _coerce_job_text(normalized.get("script")).strip() + label_source = ( + prompt + or (normalized["skills"][0] if normalized.get("skills") else "") + or script + or job_id + or "cron job" + ) + name = label_source[:50].strip() or "cron job" + normalized["name"] = name + normalized["schedule_display"] = _schedule_display_for_job(normalized) + + state = _coerce_job_text(normalized.get("state")).strip() + if not state: + state = "scheduled" if normalized.get("enabled", True) else "paused" + normalized["state"] = state + + return normalized + + def _secure_dir(path: Path): """Set directory to owner-only access (0700). No-op on Windows.""" try: @@ -533,11 +592,12 @@ def create_job( else: context_from = None - label_source = (prompt or (normalized_skills[0] if normalized_skills else None) or (normalized_script if normalized_no_agent else None)) or "cron job" + prompt_text = _coerce_job_text(prompt) + label_source = (prompt_text or (normalized_skills[0] if normalized_skills else None) or (normalized_script if normalized_no_agent else None)) or "cron job" job = { "id": job_id, "name": name or label_source[:50].strip(), - "prompt": prompt, + "prompt": prompt_text, "skills": normalized_skills, "skill": normalized_skills[0] if normalized_skills else None, "model": normalized_model, @@ -581,13 +641,13 @@ def get_job(job_id: str) -> Optional[Dict[str, Any]]: jobs = load_jobs() for job in jobs: if job["id"] == job_id: - return _apply_skill_fields(job) + return _normalize_job_record(job) return None def list_jobs(include_disabled: bool = False) -> List[Dict[str, Any]]: """List all jobs, optionally including disabled ones.""" - jobs = [_apply_skill_fields(j) for j in load_jobs()] + jobs = [_normalize_job_record(j) for j in load_jobs()] if not include_disabled: jobs = [j for j in jobs if j.get("enabled", True)] return jobs @@ -637,7 +697,7 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]] jobs[i] = updated save_jobs(jobs) - return _apply_skill_fields(jobs[i]) + return _normalize_job_record(jobs[i]) return None -
cron/scheduler.py@@ -845,7 +845,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str: result is used for prompt injection. When omitted, the script (if any) runs inline as before. """ - prompt = job.get("prompt", "") + prompt = str(job.get("prompt") or "") skills = job.get("skills") # Run data-collection script if configured, inject output as context. @@ -933,6 +933,8 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str: if skills is None: legacy = job.get("skill") skills = [legacy] if legacy else [] + elif isinstance(skills, str): + skills = [skills] skill_names = [str(name).strip() for name in skills if str(name).strip()] if not skill_names: @@ -1015,7 +1017,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: Tuple of (success, full_output_doc, final_response, error_message) """ job_id = job["id"] - job_name = job["name"] + job_name = str(job.get("name") or job.get("prompt") or job_id or "cron job") # --------------------------------------------------------------- # no_agent short-circuit — the script IS the job, no LLM involvement. -
tests/cron/test_jobs.py@@ -207,6 +207,26 @@ def test_list_jobs(self, tmp_cron_dir): jobs = list_jobs() assert len(jobs) == 2 + def test_list_jobs_normalizes_partial_legacy_records(self, tmp_cron_dir): + save_jobs([ + { + "id": "abc123deadbe", + "name": None, + "prompt": None, + "schedule_display": None, + "schedule": {"kind": "interval", "minutes": 60, "display": "every 60m"}, + "enabled": True, + } + ]) + + jobs = list_jobs() + + assert jobs[0]["id"] == "abc123deadbe" + assert jobs[0]["name"] == "abc123deadbe" + assert jobs[0]["prompt"] == "" + assert jobs[0]["schedule_display"] == "every 60m" + assert jobs[0]["state"] == "scheduled" + def test_remove_job(self, tmp_cron_dir): job = create_job(prompt="Temp job", schedule="30m") assert remove_job(job["id"]) is True -
tests/cron/test_scheduler.py@@ -1788,6 +1788,11 @@ def test_hint_present_even_without_prompt(self): result = _build_job_prompt(job) assert "[SILENT]" in result + def test_hint_present_when_legacy_prompt_is_null(self): + job = {"id": "abc123deadbe", "name": None, "prompt": None} + result = _build_job_prompt(job) + assert "[SILENT]" in result + def test_delivery_guidance_present(self): """Cron hint tells agents their final response is auto-delivered.""" job = {"prompt": "Generate a report"} -
tests/tools/test_cronjob_tools.py@@ -122,6 +122,28 @@ def test_create_and_list(self): assert listing["jobs"][0]["name"] == "Server Check" assert listing["jobs"][0]["state"] == "scheduled" + def test_list_handles_partial_legacy_job_records(self): + from cron.jobs import save_jobs + + save_jobs([ + { + "id": "abc123deadbe", + "name": None, + "prompt": None, + "schedule_display": None, + "schedule": {"kind": "interval", "minutes": 60, "display": "every 60m"}, + "repeat": {"times": None, "completed": 0}, + "enabled": True, + } + ]) + + listing = json.loads(cronjob(action="list")) + + assert listing["success"] is True + assert listing["jobs"][0]["name"] == "abc123deadbe" + assert listing["jobs"][0]["prompt_preview"] == "" + assert listing["jobs"][0]["schedule"] == "every 60m" + def test_pause_and_resume(self): created = json.loads(cronjob(action="create", prompt="Check", schedule="every 1h")) job_id = created["job_id"] -
tools/cronjob_tools.py@@ -220,18 +220,20 @@ def _validate_cron_script_path(script: Optional[str]) -> Optional[str]: def _format_job(job: Dict[str, Any]) -> Dict[str, Any]: - prompt = job.get("prompt", "") + prompt = str(job.get("prompt") or "") skills = _canonical_skills(job.get("skill"), job.get("skills")) + job_id = str(job.get("id") or "unknown") + name = str(job.get("name") or prompt[:50] or (skills[0] if skills else "") or job_id or "cron job") result = { - "job_id": job["id"], - "name": job["name"], + "job_id": job_id, + "name": name, "skill": skills[0] if skills else None, "skills": skills, "prompt_preview": prompt[:100] + "..." if len(prompt) > 100 else prompt, "model": job.get("model"), "provider": job.get("provider"), "base_url": job.get("base_url"), - "schedule": job.get("schedule_display"), + "schedule": job.get("schedule_display") or "?", "repeat": _repeat_display(job), "deliver": job.get("deliver", "local"), "next_run_at": job.get("next_run_at"), -
web/src/lib/api.ts@@ -553,13 +553,14 @@ export interface ModelsAnalyticsResponse { export interface CronJob { id: string; - name?: string; - prompt: string; - schedule: { kind: string; expr: string; display: string }; - schedule_display: string; + name?: string | null; + prompt?: string | null; + script?: string | null; + schedule?: { kind?: string; expr?: string; display?: string }; + schedule_display?: string | null; enabled: boolean; - state: string; - deliver?: string; + state?: string | null; + deliver?: string | null; last_run_at?: string | null; next_run_at?: string | null; last_error?: string | null; -
web/src/pages/CronPage.tsx@@ -23,6 +23,50 @@ function formatTime(iso?: string | null): string { return d.toLocaleString(); } +function asText(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function truncateText(value: string, maxLength: number): string { + return value.length > maxLength + ? value.slice(0, maxLength) + "..." + : value; +} + +function getJobPrompt(job: CronJob): string { + return asText(job.prompt); +} + +function getJobName(job: CronJob): string { + return asText(job.name).trim(); +} + +function getJobTitle(job: CronJob): string { + const name = getJobName(job); + if (name) return name; + + const prompt = getJobPrompt(job); + if (prompt) return truncateText(prompt, 60); + + const script = asText(job.script); + if (script) return truncateText(script, 60); + + return job.id || "Cron job"; +} + +function getJobScheduleDisplay(job: CronJob): string { + return ( + asText(job.schedule_display) || + asText(job.schedule?.display) || + asText(job.schedule?.expr) || + "—" + ); +} + +function getJobState(job: CronJob): string { + return asText(job.state) || (job.enabled === false ? "disabled" : "scheduled"); +} + const STATUS_TONE: Record<string, "success" | "warning" | "destructive"> = { enabled: "success", scheduled: "success", @@ -84,17 +128,17 @@ export default function CronPage() { const handlePauseResume = async (job: CronJob) => { try { - const isPaused = job.state === "paused"; + const isPaused = getJobState(job) === "paused"; if (isPaused) { await api.resumeCronJob(job.id); showToast( - `${t.cron.resume}: "${job.name || job.prompt.slice(0, 30)}"`, + `${t.cron.resume}: "${truncateText(getJobTitle(job), 30)}"`, "success", ); } else { await api.pauseCronJob(job.id); showToast( - `${t.cron.pause}: "${job.name || job.prompt.slice(0, 30)}"`, + `${t.cron.pause}: "${truncateText(getJobTitle(job), 30)}"`, "success", ); } @@ -108,7 +152,7 @@ export default function CronPage() { try { await api.triggerCronJob(job.id); showToast( - `${t.cron.triggerNow}: "${job.name || job.prompt.slice(0, 30)}"`, + `${t.cron.triggerNow}: "${truncateText(getJobTitle(job), 30)}"`, "success", ); loadJobs(); @@ -124,7 +168,7 @@ export default function CronPage() { try { await api.deleteCronJob(id); showToast( - `${t.common.delete}: "${job?.name || (job?.prompt ?? "").slice(0, 30) || id}"`, + `${t.common.delete}: "${job ? truncateText(getJobTitle(job), 30) : id}"`, "success", ); loadJobs(); @@ -161,7 +205,9 @@ export default function CronPage() { title={t.cron.confirmDeleteTitle} description={ pendingJob - ? `"${pendingJob.name || pendingJob.prompt.slice(0, 40)}" — ${t.cron.confirmDeleteMessage}` + ? `"${truncateText(getJobTitle(pendingJob), 40)}" — ${ + t.cron.confirmDeleteMessage + }` : t.cron.confirmDeleteMessage } loading={jobDelete.isDeleting} @@ -265,85 +311,90 @@ export default function CronPage() { </Card> )} - {jobs.map((job) => ( - <Card key={job.id}> - <CardContent className="flex items-center gap-4 py-4"> - <div className="flex-1 min-w-0"> - <div className="flex items-center gap-2 mb-1"> - <span className="font-medium text-sm truncate"> - {job.name || - job.prompt.slice(0, 60) + - (job.prompt.length > 60 ? "..." : "")} - </span> - <Badge tone={STATUS_TONE[job.state] ?? "secondary"}> - {job.state} - </Badge> - {job.deliver && job.deliver !== "local" && ( - <Badge tone="outline">{job.deliver}</Badge> + {jobs.map((job) => { + const state = getJobState(job); + const promptText = getJobPrompt(job); + const title = getJobTitle(job); + const hasName = Boolean(getJobName(job)); + const deliver = asText(job.deliver); + + return ( + <Card key={job.id}> + <CardContent className="flex items-center gap-4 py-4"> + <div className="flex-1 min-w-0"> + <div className="flex items-center gap-2 mb-1"> + <span className="font-medium text-sm truncate"> + {title} + </span> + <Badge tone={STATUS_TONE[state] ?? "secondary"}> + {state} + </Badge> + {deliver && deliver !== "local" && ( + <Badge tone="outline">{deliver}</Badge> + )} + </div> + {hasName && promptText && ( + <p className="text-xs text-muted-foreground truncate mb-1"> + {truncateText(promptText, 100)} + </p> + )} + <div className="flex items-center gap-4 text-xs text-muted-foreground"> + <span className="font-mono">{getJobScheduleDisplay(job)}</span> + <span> + {t.cron.last}: {formatTime(job.last_run_at)} + </span> + <span> + {t.cron.next}: {formatTime(job.next_run_at)} + </span> + </div> + {job.last_error && ( + <p className="text-xs text-destructive mt-1"> + {job.last_error} + </p> )} </div> - {job.name && ( - <p className="text-xs text-muted-foreground truncate mb-1"> - {job.prompt.slice(0, 100)} - {job.prompt.length > 100 ? "..." : ""} - </p> - )} - <div className="flex items-center gap-4 text-xs text-muted-foreground"> - <span className="font-mono">{job.schedule_display}</span> - <span> - {t.cron.last}: {formatTime(job.last_run_at)} - </span> - <span> - {t.cron.next}: {formatTime(job.next_run_at)} - </span> - </div> - {job.last_error && ( - <p className="text-xs text-destructive mt-1"> - {job.last_error} - </p> - )} - </div> - <div className="flex items-center gap-1 shrink-0"> - <Button - ghost - size="icon" - title={job.state === "paused" ? t.cron.resume : t.cron.pause} - aria-label={ - job.state === "paused" ? t.cron.resume : t.cron.pause - } - onClick={() => handlePauseResume(job)} - className={ - job.state === "paused" ? "text-success" : "text-warning" - } - > - {job.state === "paused" ? <Play /> : <Pause />} - </Button> + <div className="flex items-center gap-1 shrink-0"> + <Button + ghost + size="icon" + title={state === "paused" ? t.cron.resume : t.cron.pause} + aria-label={ + state === "paused" ? t.cron.resume : t.cron.pause + } + onClick={() => handlePauseResume(job)} + className={ + state === "paused" ? "text-success" : "text-warning" + } + > + {state === "paused" ? <Play /> : <Pause />} + </Button> - <Button - ghost - size="icon" - title={t.cron.triggerNow} - aria-label={t.cron.triggerNow} - onClick={() => handleTrigger(job)} - > - <Zap /> - </Button> + <Button + ghost + size="icon" + title={t.cron.triggerNow} + aria-label={t.cron.triggerNow} + onClick={() => handleTrigger(job)} + > + <Zap /> + </Button> - <Button - ghost - destructive - size="icon" - title={t.common.delete} - aria-label={t.common.delete} - onClick={() => jobDelete.requestDelete(job.id)} - > - <Trash2 /> - </Button> - </div> - </CardContent> - </Card> - ))} + <Button + ghost + destructive + size="icon" + title={t.common.delete} + aria-label={t.common.delete} + onClick={() => jobDelete.requestDelete(job.id)} + > + <Trash2 /> + </Button> + </div> + </CardContent> + </Card> + ); + })} </div> <PluginSlot name="cron:bottom" />
-
2 PRs closed without merging
From the close comments: done elsewhere 1 · merged via another change 1
Closed issues · 2
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.44: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Merged PRs
- #22389
- Closed issues
- 2
- Attempts
- 2 PRs closed without merging
- Fix link checked
- 9 May 2026