# NousResearch/hermes-agent · Cron dashboard tab blank content bug fixes

## Open now

- 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.

## TL;DR

- 5 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 1 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #22389. Verify it addresses your case.
- **Recommended:** read PR #22389 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-22389`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.44`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #21038: Dashboard: Cron tab displays blank content (all other tabs render correctly)
- #21043: \[Bug\]:Dashboard: Cron tab displays blank content (all other tabs render correctly)

## Prior & competing PRs

- **#22389: fix(cron): blank Cron dashboard tab + partial-record crashes (salvage #21042 + #22330)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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.tsx`
  - fix hunks:
  - `cron/jobs.py`

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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.
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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",
    ```

    ```diff
    @@ -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",
             );
           }
    ```

    ```diff
    @@ -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();
    ```

    ```diff
    @@ -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();
    ```

    ```diff
    @@ -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}
    ```

    ```diff
    @@ -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" />
    ```
- **#21042: Fix cron dashboard rendering for partial jobs**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `e57273727`, `#22389`, `#22330`, `#22389`
- **#21460: fix(dashboard): wire cron page to jobs API**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#22389`, `#21042`, `#22330`

## Fix status

- Merged to main in PR #22389 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-09`.
