{"repo":"NousResearch/hermes-agent","public_id":"21038","display_label":"Cron dashboard tab blank content bug fixes","summary":"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.","subsystem":null,"counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":22389},"resolution":{"state":"resolved","by":"pr-22389"},"freshness":{"state":"verified","last_verified_at":"2026-05-09T08:11:42+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.43599999999999994},"fix_edges":[{"issue_number":21038,"pr_number":22389,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":21043,"pr_number":22389,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":21038,"title":"Dashboard: Cron tab displays blank content (all other tabs render correctly)","url":null,"state":"closed"},{"kind":"issue","number":21043,"title":"[Bug]:Dashboard: Cron tab displays blank content (all other tabs render correctly)","url":null,"state":"closed"},{"kind":"pr","number":21042,"title":"Fix cron dashboard rendering for partial jobs","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"e57273727","number":0},{"kind":"pr","value":"#22389","number":22389},{"kind":"pr","value":"#22330","number":22330},{"kind":"pr","value":"#22389","number":22389}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":21460,"title":"fix(dashboard): wire cron page to jobs API","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#22389","number":22389},{"kind":"pr","value":"#21042","number":21042},{"kind":"pr","value":"#22330","number":22330}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":22389,"title":"fix(cron): blank Cron dashboard tab + partial-record crashes (salvage #21042 + #22330)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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.tsx"],"fix_hunks":[{"file":"cron/jobs.py","hunks":["@@ -72,6 +72,65 @@ def _apply_skill_fields(job: Dict[str, Any]) -> Dict[str, Any]:\n     return normalized\n \n \n+def _coerce_job_text(value: Any, fallback: str = \"\") -> str:\n+    \"\"\"Coerce legacy/hand-edited nullable cron fields to strings for readers.\"\"\"\n+    if value is None:\n+        return fallback\n+    return str(value)\n+\n+\n+def _schedule_display_for_job(job: Dict[str, Any]) -> str:\n+    display = _coerce_job_text(job.get(\"schedule_display\")).strip()\n+    if display:\n+        return display\n+\n+    schedule = job.get(\"schedule\")\n+    if isinstance(schedule, dict):\n+        for key in (\"display\", \"value\", \"expr\", \"run_at\"):\n+            text = _coerce_job_text(schedule.get(key)).strip()\n+            if text:\n+                return text\n+    elif schedule is not None:\n+        return str(schedule)\n+\n+    return \"?\"\n+\n+\n+def _normalize_job_record(job: Dict[str, Any]) -> Dict[str, Any]:\n+    \"\"\"Return a read-safe cron job shape for UI/API/tool/scheduler consumers.\n+\n+    Older or hand-edited jobs can have nullable fields like ``prompt``,\n+    ``name``, or ``schedule_display``.  Keep storage untouched on read, but\n+    ensure consumers never crash while formatting or running those records.\n+    \"\"\"\n+    normalized = _apply_skill_fields(job)\n+    job_id = _coerce_job_text(normalized.get(\"id\"), \"unknown\")\n+    prompt = _coerce_job_text(normalized.get(\"prompt\"))\n+    normalized[\"id\"] = job_id\n+    normalized[\"prompt\"] = prompt\n+\n+    name = _coerce_job_text(normalized.get(\"name\")).strip()\n+    if not name:\n+        script = _coerce_job_text(normalized.get(\"script\")).strip()\n+        label_source = (\n+            prompt\n+            or (normalized[\"skills\"][0] if normalized.get(\"skills\") else \"\")\n+            or script\n+            or job_id\n+            or \"cron job\"\n+        )\n+        name = label_source[:50].strip() or \"cron job\"\n+    normalized[\"name\"] = name\n+    normalized[\"schedule_display\"] = _schedule_display_for_job(normalized)\n+\n+    state = _coerce_job_text(normalized.get(\"state\")).strip()\n+    if not state:\n+        state = \"scheduled\" if normalized.get(\"enabled\", True) else \"paused\"\n+    normalized[\"state\"] = state\n+\n+    return normalized\n+\n+\n def _secure_dir(path: Path):\n     \"\"\"Set directory to owner-only access (0700). No-op on Windows.\"\"\"\n     try:","@@ -533,11 +592,12 @@ def create_job(\n     else:\n         context_from = None\n \n-    label_source = (prompt or (normalized_skills[0] if normalized_skills else None) or (normalized_script if normalized_no_agent else None)) or \"cron job\"\n+    prompt_text = _coerce_job_text(prompt)\n+    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\"\n     job = {\n         \"id\": job_id,\n         \"name\": name or label_source[:50].strip(),\n-        \"prompt\": prompt,\n+        \"prompt\": prompt_text,\n         \"skills\": normalized_skills,\n         \"skill\": normalized_skills[0] if normalized_skills else None,\n         \"model\": normalized_model,","@@ -581,13 +641,13 @@ def get_job(job_id: str) -> Optional[Dict[str, Any]]:\n     jobs = load_jobs()\n     for job in jobs:\n         if job[\"id\"] == job_id:\n-            return _apply_skill_fields(job)\n+            return _normalize_job_record(job)\n     return None\n \n \n def list_jobs(include_disabled: bool = False) -> List[Dict[str, Any]]:\n     \"\"\"List all jobs, optionally including disabled ones.\"\"\"\n-    jobs = [_apply_skill_fields(j) for j in load_jobs()]\n+    jobs = [_normalize_job_record(j) for j in load_jobs()]\n     if not include_disabled:\n         jobs = [j for j in jobs if j.get(\"enabled\", True)]\n     return jobs","@@ -637,7 +697,7 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]\n \n         jobs[i] = updated\n         save_jobs(jobs)\n-        return _apply_skill_fields(jobs[i])\n+        return _normalize_job_record(jobs[i])\n     return None\n \n "]},{"file":"cron/scheduler.py","hunks":["@@ -845,7 +845,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:\n             result is used for prompt injection. When omitted, the script\n             (if any) runs inline as before.\n     \"\"\"\n-    prompt = job.get(\"prompt\", \"\")\n+    prompt = str(job.get(\"prompt\") or \"\")\n     skills = job.get(\"skills\")\n \n     # 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:\n     if skills is None:\n         legacy = job.get(\"skill\")\n         skills = [legacy] if legacy else []\n+    elif isinstance(skills, str):\n+        skills = [skills]\n \n     skill_names = [str(name).strip() for name in skills if str(name).strip()]\n     if not skill_names:","@@ -1015,7 +1017,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:\n         Tuple of (success, full_output_doc, final_response, error_message)\n     \"\"\"\n     job_id = job[\"id\"]\n-    job_name = job[\"name\"]\n+    job_name = str(job.get(\"name\") or job.get(\"prompt\") or job_id or \"cron job\")\n \n     # ---------------------------------------------------------------\n     # no_agent short-circuit — the script IS the job, no LLM involvement."]},{"file":"tests/cron/test_jobs.py","hunks":["@@ -207,6 +207,26 @@ def test_list_jobs(self, tmp_cron_dir):\n         jobs = list_jobs()\n         assert len(jobs) == 2\n \n+    def test_list_jobs_normalizes_partial_legacy_records(self, tmp_cron_dir):\n+        save_jobs([\n+            {\n+                \"id\": \"abc123deadbe\",\n+                \"name\": None,\n+                \"prompt\": None,\n+                \"schedule_display\": None,\n+                \"schedule\": {\"kind\": \"interval\", \"minutes\": 60, \"display\": \"every 60m\"},\n+                \"enabled\": True,\n+            }\n+        ])\n+\n+        jobs = list_jobs()\n+\n+        assert jobs[0][\"id\"] == \"abc123deadbe\"\n+        assert jobs[0][\"name\"] == \"abc123deadbe\"\n+        assert jobs[0][\"prompt\"] == \"\"\n+        assert jobs[0][\"schedule_display\"] == \"every 60m\"\n+        assert jobs[0][\"state\"] == \"scheduled\"\n+\n     def test_remove_job(self, tmp_cron_dir):\n         job = create_job(prompt=\"Temp job\", schedule=\"30m\")\n         assert remove_job(job[\"id\"]) is True"]},{"file":"tests/cron/test_scheduler.py","hunks":["@@ -1788,6 +1788,11 @@ def test_hint_present_even_without_prompt(self):\n         result = _build_job_prompt(job)\n         assert \"[SILENT]\" in result\n \n+    def test_hint_present_when_legacy_prompt_is_null(self):\n+        job = {\"id\": \"abc123deadbe\", \"name\": None, \"prompt\": None}\n+        result = _build_job_prompt(job)\n+        assert \"[SILENT]\" in result\n+\n     def test_delivery_guidance_present(self):\n         \"\"\"Cron hint tells agents their final response is auto-delivered.\"\"\"\n         job = {\"prompt\": \"Generate a report\"}"]},{"file":"tests/tools/test_cronjob_tools.py","hunks":["@@ -122,6 +122,28 @@ def test_create_and_list(self):\n         assert listing[\"jobs\"][0][\"name\"] == \"Server Check\"\n         assert listing[\"jobs\"][0][\"state\"] == \"scheduled\"\n \n+    def test_list_handles_partial_legacy_job_records(self):\n+        from cron.jobs import save_jobs\n+\n+        save_jobs([\n+            {\n+                \"id\": \"abc123deadbe\",\n+                \"name\": None,\n+                \"prompt\": None,\n+                \"schedule_display\": None,\n+                \"schedule\": {\"kind\": \"interval\", \"minutes\": 60, \"display\": \"every 60m\"},\n+                \"repeat\": {\"times\": None, \"completed\": 0},\n+                \"enabled\": True,\n+            }\n+        ])\n+\n+        listing = json.loads(cronjob(action=\"list\"))\n+\n+        assert listing[\"success\"] is True\n+        assert listing[\"jobs\"][0][\"name\"] == \"abc123deadbe\"\n+        assert listing[\"jobs\"][0][\"prompt_preview\"] == \"\"\n+        assert listing[\"jobs\"][0][\"schedule\"] == \"every 60m\"\n+\n     def test_pause_and_resume(self):\n         created = json.loads(cronjob(action=\"create\", prompt=\"Check\", schedule=\"every 1h\"))\n         job_id = created[\"job_id\"]"]},{"file":"tools/cronjob_tools.py","hunks":["@@ -220,18 +220,20 @@ def _validate_cron_script_path(script: Optional[str]) -> Optional[str]:\n \n \n def _format_job(job: Dict[str, Any]) -> Dict[str, Any]:\n-    prompt = job.get(\"prompt\", \"\")\n+    prompt = str(job.get(\"prompt\") or \"\")\n     skills = _canonical_skills(job.get(\"skill\"), job.get(\"skills\"))\n+    job_id = str(job.get(\"id\") or \"unknown\")\n+    name = str(job.get(\"name\") or prompt[:50] or (skills[0] if skills else \"\") or job_id or \"cron job\")\n     result = {\n-        \"job_id\": job[\"id\"],\n-        \"name\": job[\"name\"],\n+        \"job_id\": job_id,\n+        \"name\": name,\n         \"skill\": skills[0] if skills else None,\n         \"skills\": skills,\n         \"prompt_preview\": prompt[:100] + \"...\" if len(prompt) > 100 else prompt,\n         \"model\": job.get(\"model\"),\n         \"provider\": job.get(\"provider\"),\n         \"base_url\": job.get(\"base_url\"),\n-        \"schedule\": job.get(\"schedule_display\"),\n+        \"schedule\": job.get(\"schedule_display\") or \"?\",\n         \"repeat\": _repeat_display(job),\n         \"deliver\": job.get(\"deliver\", \"local\"),\n         \"next_run_at\": job.get(\"next_run_at\"),"]},{"file":"web/src/lib/api.ts","hunks":["@@ -553,13 +553,14 @@ export interface ModelsAnalyticsResponse {\n \n export interface CronJob {\n   id: string;\n-  name?: string;\n-  prompt: string;\n-  schedule: { kind: string; expr: string; display: string };\n-  schedule_display: string;\n+  name?: string | null;\n+  prompt?: string | null;\n+  script?: string | null;\n+  schedule?: { kind?: string; expr?: string; display?: string };\n+  schedule_display?: string | null;\n   enabled: boolean;\n-  state: string;\n-  deliver?: string;\n+  state?: string | null;\n+  deliver?: string | null;\n   last_run_at?: string | null;\n   next_run_at?: string | null;\n   last_error?: string | null;"]},{"file":"web/src/pages/CronPage.tsx","hunks":["@@ -23,6 +23,50 @@ function formatTime(iso?: string | null): string {\n   return d.toLocaleString();\n }\n \n+function asText(value: unknown): string {\n+  return typeof value === \"string\" ? value : \"\";\n+}\n+\n+function truncateText(value: string, maxLength: number): string {\n+  return value.length > maxLength\n+    ? value.slice(0, maxLength) + \"...\"\n+    : value;\n+}\n+\n+function getJobPrompt(job: CronJob): string {\n+  return asText(job.prompt);\n+}\n+\n+function getJobName(job: CronJob): string {\n+  return asText(job.name).trim();\n+}\n+\n+function getJobTitle(job: CronJob): string {\n+  const name = getJobName(job);\n+  if (name) return name;\n+\n+  const prompt = getJobPrompt(job);\n+  if (prompt) return truncateText(prompt, 60);\n+\n+  const script = asText(job.script);\n+  if (script) return truncateText(script, 60);\n+\n+  return job.id || \"Cron job\";\n+}\n+\n+function getJobScheduleDisplay(job: CronJob): string {\n+  return (\n+    asText(job.schedule_display) ||\n+    asText(job.schedule?.display) ||\n+    asText(job.schedule?.expr) ||\n+    \"—\"\n+  );\n+}\n+\n+function getJobState(job: CronJob): string {\n+  return asText(job.state) || (job.enabled === false ? \"disabled\" : \"scheduled\");\n+}\n+\n const STATUS_TONE: Record<string, \"success\" | \"warning\" | \"destructive\"> = {\n   enabled: \"success\",\n   scheduled: \"success\",","@@ -84,17 +128,17 @@ export default function CronPage() {\n \n   const handlePauseResume = async (job: CronJob) => {\n     try {\n-      const isPaused = job.state === \"paused\";\n+      const isPaused = getJobState(job) === \"paused\";\n       if (isPaused) {\n         await api.resumeCronJob(job.id);\n         showToast(\n-          `${t.cron.resume}: \"${job.name || job.prompt.slice(0, 30)}\"`,\n+          `${t.cron.resume}: \"${truncateText(getJobTitle(job), 30)}\"`,\n           \"success\",\n         );\n       } else {\n         await api.pauseCronJob(job.id);\n         showToast(\n-          `${t.cron.pause}: \"${job.name || job.prompt.slice(0, 30)}\"`,\n+          `${t.cron.pause}: \"${truncateText(getJobTitle(job), 30)}\"`,\n           \"success\",\n         );\n       }","@@ -108,7 +152,7 @@ export default function CronPage() {\n     try {\n       await api.triggerCronJob(job.id);\n       showToast(\n-        `${t.cron.triggerNow}: \"${job.name || job.prompt.slice(0, 30)}\"`,\n+        `${t.cron.triggerNow}: \"${truncateText(getJobTitle(job), 30)}\"`,\n         \"success\",\n       );\n       loadJobs();","@@ -124,7 +168,7 @@ export default function CronPage() {\n         try {\n           await api.deleteCronJob(id);\n           showToast(\n-            `${t.common.delete}: \"${job?.name || (job?.prompt ?? \"\").slice(0, 30) || id}\"`,\n+            `${t.common.delete}: \"${job ? truncateText(getJobTitle(job), 30) : id}\"`,\n             \"success\",\n           );\n           loadJobs();","@@ -161,7 +205,9 @@ export default function CronPage() {\n         title={t.cron.confirmDeleteTitle}\n         description={\n           pendingJob\n-            ? `\"${pendingJob.name || pendingJob.prompt.slice(0, 40)}\" — ${t.cron.confirmDeleteMessage}`\n+            ? `\"${truncateText(getJobTitle(pendingJob), 40)}\" — ${\n+                t.cron.confirmDeleteMessage\n+              }`\n             : t.cron.confirmDeleteMessage\n         }\n         loading={jobDelete.isDeleting}","@@ -265,85 +311,90 @@ export default function CronPage() {\n           </Card>\n         )}\n \n-        {jobs.map((job) => (\n-          <Card key={job.id}>\n-            <CardContent className=\"flex items-center gap-4 py-4\">\n-              <div className=\"flex-1 min-w-0\">\n-                <div className=\"flex items-center gap-2 mb-1\">\n-                  <span className=\"font-medium text-sm truncate\">\n-                    {job.name ||\n-                      job.prompt.slice(0, 60) +\n-                        (job.prompt.length > 60 ? \"...\" : \"\")}\n-                  </span>\n-                  <Badge tone={STATUS_TONE[job.state] ?? \"secondary\"}>\n-                    {job.state}\n-                  </Badge>\n-                  {job.deliver && job.deliver !== \"local\" && (\n-                    <Badge tone=\"outline\">{job.deliver}</Badge>\n+        {jobs.map((job) => {\n+          const state = getJobState(job);\n+          const promptText = getJobPrompt(job);\n+          const title = getJobTitle(job);\n+          const hasName = Boolean(getJobName(job));\n+          const deliver = asText(job.deliver);\n+\n+          return (\n+            <Card key={job.id}>\n+              <CardContent className=\"flex items-center gap-4 py-4\">\n+                <div className=\"flex-1 min-w-0\">\n+                  <div className=\"flex items-center gap-2 mb-1\">\n+                    <span className=\"font-medium text-sm truncate\">\n+                      {title}\n+                    </span>\n+                    <Badge tone={STATUS_TONE[state] ?? \"secondary\"}>\n+                      {state}\n+                    </Badge>\n+                    {deliver && deliver !== \"local\" && (\n+                      <Badge tone=\"outline\">{deliver}</Badge>\n+                    )}\n+                  </div>\n+                  {hasName && promptText && (\n+                    <p className=\"text-xs text-muted-foreground truncate mb-1\">\n+                      {truncateText(promptText, 100)}\n+                    </p>\n+                  )}\n+                  <div className=\"flex items-center gap-4 text-xs text-muted-foreground\">\n+                    <span className=\"font-mono\">{getJobScheduleDisplay(job)}</span>\n+                    <span>\n+                      {t.cron.last}: {formatTime(job.last_run_at)}\n+                    </span>\n+                    <span>\n+                      {t.cron.next}: {formatTime(job.next_run_at)}\n+                    </span>\n+                  </div>\n+                  {job.last_error && (\n+                    <p className=\"text-xs text-destructive mt-1\">\n+                      {job.last_error}\n+                    </p>\n                   )}\n                 </div>\n-                {job.name && (\n-                  <p className=\"text-xs text-muted-foreground truncate mb-1\">\n-                    {job.prompt.slice(0, 100)}\n-                    {job.prompt.length > 100 ? \"...\" : \"\"}\n-                  </p>\n-                )}\n-                <div className=\"flex items-center gap-4 text-xs text-muted-foreground\">\n-                  <span className=\"font-mono\">{job.schedule_display}</span>\n-                  <span>\n-                    {t.cron.last}: {formatTime(job.last_run_at)}\n-                  </span>\n-                  <span>\n-                    {t.cron.next}: {formatTime(job.next_run_at)}\n-                  </span>\n-                </div>\n-                {job.last_error && (\n-                  <p className=\"text-xs text-destructive mt-1\">\n-                    {job.last_error}\n-                  </p>\n-                )}\n-              </div>\n \n-              <div className=\"flex items-center gap-1 shrink-0\">\n-                <Button\n-                  ghost\n-                  size=\"icon\"\n-                  title={job.state === \"paused\" ? t.cron.resume : t.cron.pause}\n-                  aria-label={\n-                    job.state === \"paused\" ? t.cron.resume : t.cron.pause\n-                  }\n-                  onClick={() => handlePauseResume(job)}\n-                  className={\n-                    job.state === \"paused\" ? \"text-success\" : \"text-warning\"\n-                  }\n-                >\n-                  {job.state === \"paused\" ? <Play /> : <Pause />}\n-                </Button>\n+                <div className=\"flex items-center gap-1 shrink-0\">\n+                  <Button\n+                    ghost\n+                    size=\"icon\"\n+                    title={state === \"paused\" ? t.cron.resume : t.cron.pause}\n+                    aria-label={\n+                      state === \"paused\" ? t.cron.resume : t.cron.pause\n+                    }\n+                    onClick={() => handlePauseResume(job)}\n+                    className={\n+                      state === \"paused\" ? \"text-success\" : \"text-warning\"\n+                    }\n+                  >\n+                    {state === \"paused\" ? <Play /> : <Pause />}\n+                  </Button>\n \n-                <Button\n-                  ghost\n-                  size=\"icon\"\n-                  title={t.cron.triggerNow}\n-                  aria-label={t.cron.triggerNow}\n-                  onClick={() => handleTrigger(job)}\n-                >\n-                  <Zap />\n-                </Button>\n+                  <Button\n+                    ghost\n+                    size=\"icon\"\n+                    title={t.cron.triggerNow}\n+                    aria-label={t.cron.triggerNow}\n+                    onClick={() => handleTrigger(job)}\n+                  >\n+                    <Zap />\n+                  </Button>\n \n-                <Button\n-                  ghost\n-                  destructive\n-                  size=\"icon\"\n-                  title={t.common.delete}\n-                  aria-label={t.common.delete}\n-                  onClick={() => jobDelete.requestDelete(job.id)}\n-                >\n-                  <Trash2 />\n-                </Button>\n-              </div>\n-            </CardContent>\n-          </Card>\n-        ))}\n+                  <Button\n+                    ghost\n+                    destructive\n+                    size=\"icon\"\n+                    title={t.common.delete}\n+                    aria-label={t.common.delete}\n+                    onClick={() => jobDelete.requestDelete(job.id)}\n+                  >\n+                    <Trash2 />\n+                  </Button>\n+                </div>\n+              </CardContent>\n+            </Card>\n+          );\n+        })}\n       </div>\n \n       <PluginSlot name=\"cron:bottom\" />\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}