Skip to content

feat: add CronCreate, CronList, and CronDelete scheduled-task tools - #3466

Open
joestump wants to merge 7 commits into
charmbracelet:mainfrom
joestump-agent:feat/cron-scheduled-tasks
Open

feat: add CronCreate, CronList, and CronDelete scheduled-task tools#3466
joestump wants to merge 7 commits into
charmbracelet:mainfrom
joestump-agent:feat/cron-scheduled-tasks

Conversation

@joestump

Copy link
Copy Markdown
Contributor

Summary

Adds agent-managed scheduled tasks: three new tools — CronCreate, CronList, and CronDelete — backed by a persistent scheduler that fires prompts into sessions on a cron expression or a one-shot delay, mimicking the CronCreate tool in Claude Code and Codex.

What's included

  • internal/scheduler: a new package with a JSON-backed task store (scheduled_tasks.json in the data directory), a minute-ticker scheduler, and cron parsing via robfig/cron/v3. Tasks fire by injecting their prompt as a normal user turn, so they respect the session's busy queue and never interrupt a mid-response turn.
  • Agent tools: CronCreate (5-field cron expression or "N minutes from now" one-shot), CronList, and CronDelete, wired through the coordinator with the store and scheduler started at agent startup.
  • System prompt: the <env> block now renders a Current time: line alongside the date, so the model can compute relative cron expressions without shelling out to date (which is a banned command). CronCreate's instructions cover minute arithmetic with rollover, one-shot vs. recurring patterns, and using the env time.
  • UI: scheduled-task tool calls render as cron line items in chat, and a scheduled-tasks pill in the pills panel shows upcoming firings, refreshing when a task fires.
  • Correctness guards: one-shot tasks whose fire time has already passed today are rejected rather than silently scheduled for a past minute; NextRunAt is truncated to the minute boundary; tasks that can never run are not scheduled; fired one-shots are cleared from the pill; tasks bound to a deleted session are dropped instead of retried forever.

Verification

  • Unit tests across the scheduler (parsing, one-shot rejection, minute truncation, session-drop), the tools, the coordinator wiring, and the chat cron line items.
  • TestCoderAgent VCR cassettes updated for the new Current time prompt line (tests pin the clock to 1/1/2025, so replays are deterministic).
  • go build ./..., go test on all affected packages, gofumpt, and go vet are clean.

Provenance

Reviewed and merged on our fork as:

🤖 Posted on behalf of @joestump by claude-opus-5 using Crush.

@joestump-agent

Copy link
Copy Markdown
Contributor
image

@meowgorithm
meowgorithm self-requested a review August 11, 2026 10:39
joestump and others added 6 commits August 16, 2026 06:46
…191)

* feat: add CronCreate, CronList, and CronDelete scheduled-task tools

Adds session-scoped scheduled tasks that re-run a prompt automatically
on a cron schedule, mirroring the cron tools in Claude Code and the
Codex scheduled-tasks proposal so prompts and habits transfer across
harnesses. The agent can schedule a recurring or one-shot prompt, list
its session's tasks, and cancel them by ID.

- internal/scheduler: strict 5-field local-time cron parser (wildcards,
  values, steps, ranges, lists; DOM/DOW OR semantics; 0 and 7 = Sunday),
  a session-scoped store capped at 50 tasks per session with 8-char IDs,
  and a 1-second ticker that fires due tasks between turns with no
  catch-up for missed fires.
- CronCreate (cron, prompt, recurring=true, durable=false), CronList
  (no params), CronDelete (id), registered for the coder agent and
  manageable from the root session only. Fired prompts run as normal
  user turns prefixed with the scheduled-task marker, so they respect
  the session's busy queue.
- durable: true persists tasks to .crush/scheduled_tasks.json (atomic
  0600 writes) and reloads them on startup; session tasks stay in
  memory and are dropped when their session disappears.

* fix(scheduler): replace hand-rolled cron parser with robfig/cron/v3

The hand-rolled parser looped forever on daylight-saving spring-forward
days. It advanced hours with time.Date(y, m, d, hour+1, ...), but on a
spring-forward day the target wall clock time does not exist and Go maps
it backwards: in America/New_York, time.Date(2026, 3, 8, 2, ...) returns
01:00 EST. So "advance past 01:00" produced 01:00 again, and Next spun
forever while holding the store's write mutex.

"0 0 * * 0" — weekly, Sunday midnight — was enough to trigger it. The
package's own test suite hung on any machine whose TZ observed DST; it
passed in CI only because CI runs UTC, and the existing DST test asserted
only that Next returned "some future time" while Next itself discarded
the zone it was handed via from.Local().

Delegate parsing to robfig/cron/v3 (zero-dependency, MIT, what Kubernetes
uses for CronJobs) and keep two behaviours it does not provide:

- day-of-week 7 as a Sunday alias, which standard cron allows and the
  tool descriptions promise, but which robfig rejects outright;
- rejection of empty list entries ("," or "1,,2"), which robfig drops
  silently via FieldsFunc, yielding a schedule that never fires.

Next now reports an impossible schedule as the zero time rather than a
bogus far-future one. Tests cover both DST directions, termination across
every documented expression shape, and 7-vs-0 equivalence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scheduler): stop scheduling tasks that can never run

Three ways a task could wedge the scheduler or the task list:

A next-run time of zero. Next reports an unschedulable expression
("0 0 30 2 *", February 30th) as the zero time, which is always in the
past, so DueTasks would hand the task back on every tick and the
scheduler would fire it in a hot loop. Create now rejects such an
expression with ErrNeverFires, and the rescheduling path deletes a
recurring task that can no longer fire instead of storing a zero. Load
applies the same guard to the durable file, which is the one input that
can be hand-edited or written by an older build.

A durable task pinned to a deleted session. fireScheduledTask called
DropSession, but DropSession deliberately kept durable tasks — so the
fire failed, MarkError rescheduled it because it was recurring, and the
next tick tried again: an error logged on every fire, forever, re-armed
on each restart. Its comment claimed it dropped the task "rather than
failing it forever", which was exactly backwards. DropSession now
removes the session's durable tasks too and persists the removal.

Nondeterministic list order. Tasks live in a map and everything created
in the same minute shares a NextRunAt, so CronList printed a different
order on each call. Sorting now breaks ties on ID, via slices.SortFunc
rather than a hand-written insertion sort.

Also: a successful fire clears the previously recorded error instead of
showing it forever; a failed persist during Delete restores the task so
memory and disk agree; persist failures are logged rather than dropped;
Load enforces the per-session cap; and the unused Task.workingDir field
is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(scheduler): skip the permission assertion on Windows

Windows has no Unix permission bits: os.Chmod there only toggles the
read-only attribute, and Stat reports 0666, so asserting 0600 fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(ui): render scheduled-task tools as cron line items

CronCreate, CronList and CronDelete fell through to the generic tool
renderer, so a scheduled task showed up as an undifferentiated blob of
the tool's own prose. Give them a renderer modeled on the To-Do one:
the tasks the tools already attach as result metadata are rendered as
line items instead of re-read from the text.

Collapsed, each task is a single row — recurrence icon, ID, schedule,
next fire, prompt — with the schedule column padded so the columns to
its right line up, capped at five rows so one CronList on a session
holding up to fifty tasks cannot bury the conversation. Expanded (the
existing per-item toggle) lists every task with its prompt wrapped and
a metadata line carrying recurrence, durability, run count, last run,
and the last error when there is one.

Headers differ per tool: Schedule shows the expression plus next fire
and ID, Scheduled shows the task count plus the soonest fire, and
Unschedule shows the cancelled ID plus the schedule it had. Results
written without task metadata still fall back to the tool's prose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(agent): wire cron store through CoordinatorOptions after rebase

The branch predated main's NewCoordinator refactor to a CoordinatorOptions
struct; the free-standing cfg variable it referenced no longer exists.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): count calendar days, not wall-clock hours, in cron times

Across a DST spring-forward the midnight-to-midnight gap is 23 hours,
so hours/24 truncated tomorrow into today's bare-clock format and a
week out into the ambiguous weekday form. Comparing the two dates as
UTC midnights makes the day count exact regardless of transitions.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Joe Stump <joestump@kitt.stump.rocks>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: joestump-agent <agent@stump.wtf>
The [SCHEDULED TASK - AUTOMATED FIRING OF A CONFIGURED PROMPT] marker
was injected into the user-turn text, producing a confusing header the
agent echoed back. The marker was not used for routing, logging, or UI
— it was harness metadata leaking into the prompt.

Fired prompts now pass through verbatim.

Closes #203
The cron parser has minute-level granularity but the ticker polls every
second, so a task created at HH:MM:50 for minute HH:MM would fire 10
seconds later instead of at the top of the next minute. This was
confusing: the user is told sub-minute scheduling is not possible, then
the task fires sub-minute anyway.

NextRunAt is now truncated to the start of the minute in Create(),
Load(), and rescheduleLocked(). The CronCreate tool description now
explicitly states that tasks fire at the top of the specified minute
and that sub-minute precision is not supported.

Closes #202
* feat(ui): add scheduled-task pill to the pills panel

Add a third pill section for scheduled tasks alongside the existing
To-Do and Queue pills. The pill shows a clock icon and task count when
collapsed, and an expanded list with each task's ID, next-run time,
recurrence indicator, and prompt.

The pill data flows through the Workspace interface: a new
AgentListCronTasks method exposes the coordinator's cron store to the
UI. The UI refreshes the task list on session load and after every
CronCreate/CronList/CronDelete tool result.

Tab navigation cycles between all three sections that have content.
In client/server mode, AgentListCronTasks returns nil (no cron API
exposed over the wire yet).

Closes #201

* fix(test): add ListCronTasks stub to coordinator mocks for interface conformance

The new ListCronTasks method on agent.Coordinator was missing from six
test stubs in internal/backend and internal/server, breaking CI builds.

* fix(config): use exported AtomicWriteFile in windows regression test

The test referenced the unexported atomicWriteFile but PR #175 renamed
it to the exported AtomicWriteFile, breaking the Windows CI build.
…ructions (#209)

* feat(prompts): inject current time into system prompt, improve CronCreate instructions

The system prompt only included the date, not the time of day. This forced
the model to call `date` via bash (which is banned) to compute cron
expressions for relative scheduling like "in 5 minutes". Now all three
agent templates (coder, task, agentic_fetch) render a "Current time" line
in the <env> block.

Also improved CronCreate tool instructions with explicit guidance on:
- Using the env time instead of shell commands
- Computing relative cron expressions with minute arithmetic + rollover
- Common scheduling patterns (one-shot, recurring, daily)

* test: update TestCoderAgent cassettes for new Current time prompt line

The system prompt now renders a "Current time:" line in the <env> block.
Updated all 13 recorded cassettes to include the new field (empty time
value matching the test's zero-value time.Time{}).
…the pill (#214)

Create now rejects a one-shot whose schedule already matched earlier today (ErrOneShotInPast, naming the actual next match) — the signature of cron fields computed against a stale clock, where robfig would silently schedule tomorrow or next year. The CronCreate doc now tells agents to run date for the real current time. The scheduled pill re-lists on incoming user messages so fired one-shots disappear immediately.
@joestump-agent
joestump-agent force-pushed the feat/cron-scheduled-tasks branch from 65e842f to 004efb2 Compare August 16, 2026 05:49
Main grew a workingDir parameter on NewToolMessageItem; update the cron
tool-routing test to the new signature.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants