feat: add CronCreate, CronList, and CronDelete scheduled-task tools - #3466
Open
joestump wants to merge 7 commits into
Open
feat: add CronCreate, CronList, and CronDelete scheduled-task tools#3466joestump wants to merge 7 commits into
joestump wants to merge 7 commits into
Conversation
Contributor
meowgorithm
self-requested a review
August 11, 2026 10:39
…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
force-pushed
the
feat/cron-scheduled-tasks
branch
from
August 16, 2026 05:49
65e842f to
004efb2
Compare
Main grew a workingDir parameter on NewToolMessageItem; update the cron tool-routing test to the new signature.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
Adds agent-managed scheduled tasks: three new tools —
CronCreate,CronList, andCronDelete— 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.jsonin the data directory), a minute-ticker scheduler, and cron parsing viarobfig/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.CronCreate(5-field cron expression or "N minutes from now" one-shot),CronList, andCronDelete, wired through the coordinator with the store and scheduler started at agent startup.<env>block now renders aCurrent time:line alongside the date, so the model can compute relative cron expressions without shelling out todate(which is a banned command).CronCreate's instructions cover minute arithmetic with rollover, one-shot vs. recurring patterns, and using the env time.NextRunAtis 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
TestCoderAgentVCR cassettes updated for the newCurrent timeprompt line (tests pin the clock to1/1/2025, so replays are deterministic).go build ./...,go teston all affected packages,gofumpt, andgo vetare clean.Provenance
Reviewed and merged on our fork as:
🤖 Posted on behalf of
@joestumpbyclaude-opus-5using Crush.