Skip to content

feat(core): scheduled tasks storage and time triggers (experimental) - #59401

Open
kitlangton wants to merge 7 commits into
devfrom
schedules-core
Open

kitlangton wants to merge 7 commits into
devfrom
schedules-core

Conversation

@kitlangton

@kitlangton kitlangton commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

A schedule can now wake a session with a prompt: once, at a set time, every N minutes, or on a cron, behind an experimental flag.
This PR adds only the storage and the timer. Agents and apps can't create schedules yet; that comes in the next PR.

💡 Why
Long-running work like "check error rates every 10 minutes for the next 8 hours" needs the session to wake on its own, not wait for someone to type.

🆕 Now you can

  • Create a schedule for a session with when (in, at, every, cron, with an optional time zone), ends (after, at, times) and a prompt to send.
  • List, get, pause, resume and remove schedules from server code.
  • Trust that each firing reaches the session exactly once, even if the server crashes mid-firing or was down when it came due (one catch-up firing, marked late).

🚦 How it ships
Off by default. Turn on experimental.schedules in config. With it off, creating a schedule fails and the timer never fires. An optional top-level timezone setting picks the zone when a schedule doesn't name one; otherwise the server's own zone is used.

+ server start -> one timer loop: sleep until the next due schedule (at most 30s)
+ due -> skip if the last prompt is still unread
+      -> send prompt with id "msg_<schedule>_<run>" (a repeat id is a no-op)
+      -> then advance the schedule to its next time, or mark it done
+ prompt fails -> mark it "failed" and try again in 5 minutes
+ paused while firing -> the run still counts, so resuming sends the next run, not a repeat id

🕐 Clocks falling back: a cron slot never fires twice in the repeated hour. Cron.next picks the first pass of a repeated time; if that's already behind us, we step forward an hour until a slot is ahead. So */10 runs through the first 1:00–1:59 and resumes at 2:00, rather than firing through both.

↩️ Undo: revert isn't enough, because the migration has already created the schedule table in every database that ran this build. The table is harmless and empty unless the flag was turned on, but removing it needs a follow-up migration.

👀 Read: packages/core/src/schedule.ts, the fire and step functions. The schedule row is updated only after the prompt is admitted, and the prompt id comes from the run number, so a crash in between re-sends the same id instead of a duplicate. The rest is routine.

Planned feature · no user reports · not measured

Checks run
  • bun run script/test.ts test/schedule.test.ts (core): 24 pass, covering a pause during firing, at times with a space before the offset, one-shot in and at, every with ends.times and ends.at, cron across the Nov 1 DST change and inside the repeated hour, one late catch-up, the 5-minute backoff after a failed prompt, rejecting a zero duration and ends.times below 1, overlap skip, crash between admit and update, session delete cascade, flag off, pause/resume, and the loop waking on its timer
  • bun typecheck in core, schema and server: pass
  • pre-push lint and typecheck: pass

Added by the opencode bot (AI-generated).

Related: #41906, #53960

Public API: adds optional config keys timezone and experimental.schedules (backwards compatible)

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find anything that should block this. The 12 tests in test/schedule.test.ts pass, and core, schema and server type-check (with tsc directly, because this Bun version has no bun check). I couldn't drive it live: nothing outside the service can create schedules until PR 2. I also checked the dedupe path: re-sending the same msg_${scheduleID}_${run} id after a crash does nothing, even if the first prompt was already moved into the transcript.

Public API: two new optional config keys, top-level timezone (string) and experimental.schedules (boolean, off by default). They appear in schema.json, openapi.json and the generated client types. Also added: the @opencode/schema/schedule module and a schedule table migration. No HTTP routes, SDK methods, events or CLI flags change, so this is backwards compatible.

One thing worth fixing, inline: due schedules keep booting their session's location on every tick after experimental.schedules is turned off.

Related: #41906, #53960

Comment thread packages/core/src/schedule.ts Outdated

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new commit fixes the problems its title names: the last slot after ends_at, the repeated hour when clocks go back, crons that can never fire, [Zone] suffixes on at times, pausing after the tick read the row, lost wakes (on start and on each skip), and schedules that fall due mid-firing. I found no bugs in them. 19 tests in test/schedule.test.ts pass, and tsc -b passes in core and server.

The earlier point about settings() booting the location on every tick while the flag is off isn't addressed, so that thread stays open. New: one test depends on a fixed number of scheduler turns (inline). Public API: no change since the last review.

Comment thread packages/core/test/schedule.test.ts Outdated

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new commit makes cron timing around clock changes match standard cron: wildcard and step crons keep firing through the repeated hour, fixed-time crons fire once, and a slot in the skipped hour moves forward. I found no problems with it. 21 tests in test/schedule.test.ts pass, and tsc -b passes in core.

Neither earlier thread is addressed: the per-tick location boot while the flag is off, and the 50-yieldNow wait in the re-tick test. Public API: no change since the last review.

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both earlier points are addressed. Due schedules in a location with the flag off are now paused instead of booting the location every tick. The re-tick test now waits until both prompts are pending instead of counting scheduler turns. 22 tests in test/schedule.test.ts pass, and tsc -b passes in core. Public API: no change since the last review.

One side effect of the new pause is noted inline.

Comment thread packages/core/src/schedule.ts Outdated

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flag-off pause point is addressed. Schedules now stay active with their due time, are rechecked hourly through retry_at, and catch up once the flag is back on. The trade-off is up to an hour's delay after re-enabling, which seems fine for an experimental flag. 22 tests in test/schedule.test.ts pass; tsc passes in core and schema.

Public API: no config or HTTP change since the last review. Schedule.Outcome in @opencode/schema/schedule gains "disabled" (backwards compatible). The migration was renamed to 20261026223005_schedule and adds a retry_at column. Only databases that ran this branch's earlier build would notice the rename.

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking in the new commit. The repeated-hour handling now never fires a slot twice; this differs from standard cron, but the description explains it. The step-forward loop always makes progress and stops. A failed prompt backs off for 5 minutes, and the retry reuses the same message id, so a prompt admitted before the failure isn't sent twice. Zero durations and ends.times below 1 are now rejected. Without the re-read before firing, a pause or remove that lands mid-tick can still let one firing through; the window is short, so that seems fine. 23 tests in test/schedule.test.ts pass; tsc passes in core and schema.

Public API: config keys unchanged. Schedule.Outcome in @opencode/schema/schedule swaps "expired" for "failed"; neither has shipped, so nothing outside this branch is affected.

This branch has not been deployed

No deployments
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.

1 participant