Cron dashboard tab blank content bug fixes

NousResearch/hermes-agent · cluster 21038

Data as of

Nothing open on GitHub. Fix merged in #22389. Check it covers your case.

The short answer

0open issues
0open PRs
5items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #22389 fix(cron): blank Cron dashboard tab + partial-record crashes (salvage #21042 + #22330)

    merged to main

    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

    Show 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

  • #21042 Fix cron dashboard rendering for partial jobs · merged via another change → e572737, #22389, #22330
  • #21460 fix(dashboard): wire cron page to jobs API · done elsewhere → #22389, #21042, #22330
Closed issues · 2
  • Closed#21038Dashboard: Cron tab displays blank content (all other tabs render correctly)
  • Closed#21043[Bug]:Dashboard: Cron tab displays blank content (all other tabs render correctly)
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.00 means 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 Lab

At a glance

Merged PRs
#22389
Closed issues
2
Attempts
2 PRs closed without merging
Fix link checked
9 May 2026

For your agent

The same facts, typed.