You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
There is no way to schedule a session to run at a future time. controller sessions start (#190) is fire-and-forget — it kicks off work now and exits. Users want to say "run this session tomorrow at 8am" or "every weekday at 9am, run this prompt in this worktree," and have the orchestrator make that happen without them being present.
A scheduler subsystem also needs a shared wakeup primitive that other deferred-work features will reuse. The most immediate reuse is issue #219's sessions wake --delay (a deferred follow-up message onto an existing session). Today #219 proposes advanceSessionQueue plus a "lightweight setTimeout set at insert time" — that doesn't survive restarts and doesn't compose. We want one wakeup loop, many consumers.
server/lib/scheduler.ts is ~30 lines: a setInterval, a registerConsumer(fn) registry, a runTick(now?) export for tests, and a startScheduler() / stopScheduler() lifecycle hooked into server/index.ts.
Consumers are synchronous. They receive now, kick off their work as a detached promise (fireAndForget), and return. The next tick is never blocked by a long session run.
Tick interval: 30 seconds by default, env-overridable via SCHEDULER_TICK_INTERVAL_MS. Fine-grained enough for "schedule in 2 minutes" UX; cheap enough to not matter. Cross-platform by virtue of being a Node timer.
Race protection (critical)
Two ticks can race on the same due schedule if fireSchedule() is slow. The defense:
The schedules consumer does lock-then-mark-then-detach:
Acquire the per-project schedules lock.
Re-read the schedule under the lock.
Update nextRunAt (recurring) or lastRunAt (one-shot) inside the lock.
Release the lock.
Only then start the session, outside the lock, as a detached promise.
A second tick that arrives during the session run sees the updated nextRunAt and skips.
If the session run throws, markScheduleFailed(projectId, scheduleId, error) records lastError on the schedule so the UI can surface it. The next nextRunAt is preserved (recurring) or the schedule is disabled (one-shot) depending on policy.
The per-project lock is the same file-lock pattern server/lib/session-queue.ts already uses — withLock(projectId, run).
Schedule model
Wire format: cron expression as the source of truth for repeat. The UI/CLI is structured-first and only exposes cron in the "custom" option.
typeScheduleSource="user"|"agent";interfaceSchedule{id: string;// uuidprojectId: string;worktreeId: string;prompt: string;provider?: string;model?: string;mode?: "default"|"plan";cron: string|null;// e.g. "0 8 * * 1-5"; null = one-shottimezone: string;// IANA; default = server tzrunAt: string|null;// ISO; set for one-shot, null for recurringnextRunAt: string;// ISO; updated after each firelastRunAt: string|null;lastRunSessionId: string|null;lastError: string|null;// most recent failure messagesource: ScheduleSource;// "user" | "agent"enabled: boolean;createdAt: string;createdBy: string|null;// free-form: "ui", "cli", agent id}
Storage: one JSON file per schedule under <orchestratorHome>/projects/<name>-<hash>/schedules/<id>.json. Index file <orchestratorHome>/projects/<name>-<hash>/schedules/index.json listing {id, nextRunAt, enabled} for fast cold-start scanning. Per-project lock (session-queue.ts pattern) for all reads/writes.
Eager materialization, one run at a time
When a schedule is created or fires:
Compute nextRunAt.
Pre-create the session record with status: "scheduled". The session list shows it inline with other sessions.
The tick fires the run; on completion, lastRunSessionId is set on the schedule.
For recurring schedules, after firing:
Recompute nextRunAt and create the next "scheduled" session record.
Editing a schedule does not touch already-materialized scheduled sessions — they're left to fire or be skipped manually.
Provenance — schedules vs. wakes
A schedule (source: "user") creates a new session at fire time. Persisted under <home>/projects/<id>/schedules/. Shows in the regular session list.
A wake (source: "agent", implemented in Monitor and wake agent sessions from the CLI (sessions watch / wake / list) #219) defers a follow-up message onto an existing session via the queue. Persisted under <home>/queues/<sessionId>.json with a new runAt field. Doesn't create a new session. Hidden from the regular session list under "Agent activity."
A user saying "monitor this process" → agent decides to wake itself later → that's a wake, source: "agent". A user saying "schedule this for tomorrow 6am" → that's a schedule, source: "user". Differentiation rule: who created it.
The two never share code paths but they share the wakeup loop. When #219 lands, sessions wake --delay becomes a consumer of the same scheduler.ts loop instead of inventing its own wakeup mechanism.
Foundation: paths + scheduler.ts (wakeup loop, no consumers yet) + startScheduler wired into server/index.ts. Tests for runTick semantics. Tick is a no-op.
Store + cron: schedules.ts CRUD + schedule-cron.ts wrapper. No consumer yet. Tests for persistence, locking, computeNextRunAt across DST boundaries in America/New_York, invalid cron.
Tick consumer + session creation: register the schedules consumer; tryFireSchedule with lock-then-mark-then-detach; markScheduleFailed. Extend SessionState.status with "scheduled". Tests with a fake clock driving runTick.
REST routes: /api/projects/:id/schedules[...] for the future UI.
Cross-project schedules (always one project per schedule).
Open questions
For one-shot schedules whose runAt is in the past when the schedule is created: should we fire immediately on the next tick (eager catch-up), or refuse to create? I'd say fire immediately — matches user intent of "run this now-ish, just delay it if I'm not ready yet." Easy to flip.
For recurring schedules when the server is down at fire time: we fire on the next tick after startup (up to 30s late). Is that acceptable, or do we need to materialize a missedRuns list? I'd start with "fire late, log it" and add missedRuns only if users complain.
Should enabled: false schedules still appear in listSchedules? Yes, with a flag. Disabled schedules are common state (user paused a recurring job).
Acceptance criteria
controller schedules add coding-orchestrator --worktree main --at 2026-06-26T08:00 --prompt "Run the morning health check" creates a one-shot schedule that fires on the next tick after the target time.
controller schedules add ... --every weekday --cron "0 9 * * 1-5" --timezone "America/New_York" creates a recurring schedule. Manual verification across a US DST transition shows nextRunAt updates correctly.
controller schedules list, show, enable, disable, remove, runs all work end-to-end.
Server restart preserves all schedules and the loop picks up due items on the next tick after restart.
Two ticks racing on the same due schedule produce exactly one session (verified by a unit test with a slow startScheduledSession stub).
A session run that throws records lastError on the schedule and does not silently break the schedule.
server/lib/scheduler.ts has no business logic about schedules or sessions — it only runs registered consumers.
Unit tests for scheduler.ts, schedules.ts, schedule-cron.ts, and request-level tests for the routes.
Problem
There is no way to schedule a session to run at a future time.
controller sessions start(#190) is fire-and-forget — it kicks off work now and exits. Users want to say "run this session tomorrow at 8am" or "every weekday at 9am, run this prompt in this worktree," and have the orchestrator make that happen without them being present.A scheduler subsystem also needs a shared wakeup primitive that other deferred-work features will reuse. The most immediate reuse is issue #219's
sessions wake --delay(a deferred follow-up message onto an existing session). Today #219 proposesadvanceSessionQueueplus a "lightweightsetTimeoutset at insert time" — that doesn't survive restarts and doesn't compose. We want one wakeup loop, many consumers.Design
One wakeup loop, many consumers
server/lib/scheduler.tsis ~30 lines: asetInterval, aregisterConsumer(fn)registry, arunTick(now?)export for tests, and astartScheduler()/stopScheduler()lifecycle hooked intoserver/index.ts.now, kick off their work as a detached promise (fireAndForget), and return. The next tick is never blocked by a long session run.SCHEDULER_TICK_INTERVAL_MS. Fine-grained enough for "schedule in 2 minutes" UX; cheap enough to not matter. Cross-platform by virtue of being a Node timer.Race protection (critical)
Two ticks can race on the same due schedule if
fireSchedule()is slow. The defense:nextRunAt(recurring) orlastRunAt(one-shot) inside the lock.nextRunAtand skips.markScheduleFailed(projectId, scheduleId, error)recordslastErroron the schedule so the UI can surface it. The nextnextRunAtis preserved (recurring) or the schedule is disabled (one-shot) depending on policy.The per-project lock is the same file-lock pattern
server/lib/session-queue.tsalready uses —withLock(projectId, run).Schedule model
Wire format: cron expression as the source of truth for repeat. The UI/CLI is structured-first and only exposes cron in the "custom" option.
Storage: one JSON file per schedule under
<orchestratorHome>/projects/<name>-<hash>/schedules/<id>.json. Index file<orchestratorHome>/projects/<name>-<hash>/schedules/index.jsonlisting{id, nextRunAt, enabled}for fast cold-start scanning. Per-project lock (session-queue.tspattern) for all reads/writes.Eager materialization, one run at a time
When a schedule is created or fires:
nextRunAt.status: "scheduled". The session list shows it inline with other sessions.lastRunSessionIdis set on the schedule.For recurring schedules, after firing:
nextRunAtand create the next "scheduled" session record.Provenance — schedules vs. wakes
source: "user") creates a new session at fire time. Persisted under<home>/projects/<id>/schedules/. Shows in the regular session list.source: "agent", implemented in Monitor and wake agent sessions from the CLI (sessions watch / wake / list) #219) defers a follow-up message onto an existing session via the queue. Persisted under<home>/queues/<sessionId>.jsonwith a newrunAtfield. Doesn't create a new session. Hidden from the regular session list under "Agent activity."source: "agent". A user saying "schedule this for tomorrow 6am" → that's a schedule,source: "user". Differentiation rule: who created it.The two never share code paths but they share the wakeup loop. When #219 lands,
sessions wake --delaybecomes a consumer of the samescheduler.tsloop instead of inventing its own wakeup mechanism.CLI surface
Server routes mirror these under
/api/projects/:id/schedules[...]for the future UI.Files (planned)
server/lib/paths.ts— addprojectSchedulesDir,projectScheduleFile,projectSchedulesIndexFile.server/lib/scheduler.ts— wakeup loop + consumer registry +runTick()for tests.server/lib/schedule-cron.ts—cron-parserwrapper:computeNextRunAt,humanizeCron,validateCron, presets.server/lib/schedules.ts— store, CRUD, tick consumer,tryFireSchedulewith lock-then-mark.server/lib/sessions.ts— extendSessionState.statusto include"scheduled".server/lib/session-start.ts— extract session-start logic fromroutes/sessions.tsso the scheduler can call it without going through HTTP.server/routes/schedules.ts— REST routes.server/index.ts— callstartScheduler()afterserver.listen.cli/controller— addschedulessubcommand surface.package.json— addcron-parserdependency.PR breakdown (reviewable chunks)
scheduler.ts(wakeup loop, no consumers yet) +startSchedulerwired intoserver/index.ts. Tests forrunTicksemantics. Tick is a no-op.schedules.tsCRUD +schedule-cron.tswrapper. No consumer yet. Tests for persistence, locking,computeNextRunAtacross DST boundaries inAmerica/New_York, invalid cron.tryFireSchedulewith lock-then-mark-then-detach;markScheduleFailed. ExtendSessionState.statuswith"scheduled". Tests with a fake clock drivingrunTick./api/projects/:id/schedules[...]for the future UI.controller schedules ...subcommand surface + parsing tests.controller sessions start ... --wake-in <duration>, the minimal agent-side bridge. Pairs with Monitor and wake agent sessions from the CLI (sessions watch / wake / list) #219.Non-goals (this issue)
--wake-in/--delayprimitive itself (Monitor and wake agent sessions from the CLI (sessions watch / wake / list) #219).Open questions
runAtis in the past when the schedule is created: should we fire immediately on the next tick (eager catch-up), or refuse to create? I'd say fire immediately — matches user intent of "run this now-ish, just delay it if I'm not ready yet." Easy to flip.missedRunslist? I'd start with "fire late, log it" and addmissedRunsonly if users complain.enabled: falseschedules still appear inlistSchedules? Yes, with a flag. Disabled schedules are common state (user paused a recurring job).Acceptance criteria
controller schedules add coding-orchestrator --worktree main --at 2026-06-26T08:00 --prompt "Run the morning health check"creates a one-shot schedule that fires on the next tick after the target time.controller schedules add ... --every weekday --cron "0 9 * * 1-5" --timezone "America/New_York"creates a recurring schedule. Manual verification across a US DST transition showsnextRunAtupdates correctly.controller schedules list,show,enable,disable,remove,runsall work end-to-end.startScheduledSessionstub).lastErroron the schedule and does not silently break the schedule.server/lib/scheduler.tshas no business logic about schedules or sessions — it only runs registered consumers.scheduler.ts,schedules.ts,schedule-cron.ts, and request-level tests for the routes.Related
sessions start).sessions wake --delayshould register a consumer of this loop instead of its own wakeup mechanism).