From eb83778f504c4244948f8edad5b8dd21c4b8b4bd Mon Sep 17 00:00:00 2001 From: PJ Hoberman Date: Mon, 16 Mar 2026 16:54:38 +0000 Subject: [PATCH] fix(cron): show schedule details and run state in _list_jobs() output _list_jobs() only displayed job name, id, and schedule kind (e.g. "cron"), omitting the actual timing and run state. The agent couldn't answer "when does this run?" or "did it run?" even though CronSchedule and CronJobState had all the data. Now surfaces: - Cron expression + timezone for cron jobs - Human-readable interval for every jobs - ISO timestamp for one-shot at jobs - Enabled/disabled status - Last run time + status (ok/error/skipped) + error message - Next scheduled run time Fixes #1496 Co-Authored-By: Claude Opus 4.6 (1M context) --- nanobot/agent/tools/cron.py | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index f8e737b..6efccf0 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -147,7 +147,41 @@ class CronTool(Tool): jobs = self._cron.list_jobs() if not jobs: return "No scheduled jobs." - lines = [f"- {j.name} (id: {j.id}, {j.schedule.kind})" for j in jobs] + lines = [] + for j in jobs: + s = j.schedule + if s.kind == "cron": + timing = f"cron: {s.expr}" + if s.tz: + timing += f" ({s.tz})" + elif s.kind == "every" and s.every_ms: + secs = s.every_ms // 1000 + if secs >= 3600: + timing = f"every {secs // 3600}h" + elif secs >= 60: + timing = f"every {secs // 60}m" + else: + timing = f"every {secs}s" + elif s.kind == "at" and s.at_ms: + from datetime import datetime, timezone + dt = datetime.fromtimestamp(s.at_ms / 1000, tz=timezone.utc) + timing = f"at {dt.isoformat()}" + else: + timing = s.kind + status = "enabled" if j.enabled else "disabled" + parts = [f"- {j.name} (id: {j.id}, {timing}, {status})"] + if j.state.last_run_at_ms: + from datetime import datetime, timezone + last_dt = datetime.fromtimestamp(j.state.last_run_at_ms / 1000, tz=timezone.utc) + last_info = f" Last run: {last_dt.isoformat()} — {j.state.last_status or 'unknown'}" + if j.state.last_error: + last_info += f" ({j.state.last_error})" + parts.append(last_info) + if j.state.next_run_at_ms: + from datetime import datetime, timezone + next_dt = datetime.fromtimestamp(j.state.next_run_at_ms / 1000, tz=timezone.utc) + parts.append(f" Next run: {next_dt.isoformat()}") + lines.append("\n".join(parts)) return "Scheduled jobs:\n" + "\n".join(lines) def _remove_job(self, job_id: str | None) -> str: