Skip to content

Add scheduled recurring agent runs - #386

Open
0x92 wants to merge 2 commits into
dcouple:mainfrom
0x92:feature/scheduled-runs
Open

Add scheduled recurring agent runs#386
0x92 wants to merge 2 commits into
dcouple:mainfrom
0x92:feature/scheduled-runs

Conversation

@0x92

@0x92 0x92 commented Aug 11, 2026

Copy link
Copy Markdown

Description

Let a project start agent sessions on a schedule: a fixed prompt, at a fixed time, in a fresh worktree.

image

Pane already knows how to create a session from a prompt — this makes that repeatable without a person present. Nightly bug sweeps, a weekly dependency check, a "review yesterday's diffs" pass every morning: the work that is worth doing regularly but never worth remembering.

A new Scheduled runs entry in the project's menu manages them.

What it does

  • Three schedule shapes rather than cron syntax: every N minutes, daily at HH:MM, weekly on a weekday at HH:MM. Each one can be stated in a sentence, which is what the dialog shows back to you (Every day at 03:00 — next run in 4h 12m). Cron is more expressive than anyone actually needs here and impossible to render as a sentence.
  • Each run creates a real session, exactly as the create dialog would: prompt, agent (claude, or none for a plain terminal) and an optional worktree/branch template.
  • Per-run state is visible: last run, its outcome, the error if it failed, and a link to the session it created.
  • Enable/disable without deleting, and Run now — which starts one immediately without moving the cadence, so testing a schedule at 15:00 does not shift the nightly run.
  • Missed runs are skipped, not caught up. Waking a laptop after a weekend must not start three days of nightly sweeps at once; a run more than 15 minutes late is recorded as skipped and the schedule moves on.
  • Deleting a project deletes its schedules (ON DELETE CASCADE).

How it works

  • scheduleCalculator.ts is pure and holds every decision about when: computeNextRun, isDue, isMissed. Time zones, daylight saving and "the next weekly run when today already passed" are the parts that break silently, so they are unit-tested directly rather than through the scheduler.
  • ScheduleManager ticks every 30s and asks the calculator; it talks to storage through a small ScheduleStore interface, so its behaviour is tested without a database.
  • Sessions are created through the existing task queue with createSessionAndWait, so lastSessionId is the real session id rather than a queue job id — the difference only shows up when you click the link.
  • Persistence is one table, scheduled_runs, plus an index on (enabled, next_run_at_ms) so the tick is a single indexed lookup.
  • Channels are daemon-owned (schedules:*): the schedule belongs to the machine that runs the agents.

Type of Change

  • New feature (non-breaking change which adds functionality)

Checklist

  • I have read the CONTRIBUTING.md guidelines
  • My code follows the code style of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have run pnpm typecheck and pnpm lint locally
  • I have tested the Electron app locally with pnpm electron-dev

Critical Areas Modified

  • State management/IPC events

Additional Notes

Schema note: schema.sql is split on the statement separator at startup, so a ; inside a comment breaks the database. The new block is written accordingly.

Tested in the running app, not only in unit tests: a schedule was created, fired on its own tick, produced a real session in a fresh worktree, recorded its outcome, and was then disabled and deleted. Missed-run handling was exercised by moving a schedule's due time into the past.

0x92 added 2 commits August 11, 2026 14:54
start() called rescheduleAll(), which recomputed every enabled schedule's
next run from now. A stored time in the past was therefore replaced with
a future one before the first tick ever looked at it, so the tick had
nothing overdue to find: lastRunStatus never became 'skipped' and the
history showed no sign that the run had been due at all.

The same erasure took out runs that were meant to happen. A schedule two
minutes late is inside SCHEDULE_MISS_GRACE_MS and should start; instead
it was moved to the next occurrence and dropped. rescheduleAll also
rewrote the next run of schedules that were not overdue in any sense,
including one a user had just saved.

reconcileOnStart() now decides only the two things startup can decide: a
schedule with no next run at all gets one, and one that is late beyond
the grace period is written off as missed. Everything else keeps its
stored time and the tick decides, which is where that belongs. The
missed-run bookkeeping tick() already did moves into recordMissed() so
both paths write the same thing — one skipped entry per schedule, not one
per interval of downtime, because computeNextRun counts from now.

The existing tests drove tick() with a hand-built overdue row and never
called start(), which is exactly where the bug lived.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant