Skip to content

Schedule future sessions with optional repeat (user-facing scheduler subsystem) #243

Description

@germanescobar

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

Design

One wakeup loop, many consumers

                server/lib/scheduler.ts (the loop)
                ┌───────────────────────────────┐
                │  setInterval(TICK_INTERVAL)   │
                │           │                   │
                │           ▼                   │
                │      runTick(now)             │
                └───────────┬───────────────────┘
                            │
                ┌───────────┼───────────────────┐
                ▼           ▼                   ▼
        schedules       wakes (#219)        future consumers
        consumer        consumer            (scripts, focus-state, …)
                │           │
                ▼           ▼
       spawn new       enqueue onto
       session         existing session
  • 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.
  • One consumer per subsystem is the convention. Schedules have one process-wide consumer; wakes (from Monitor and wake agent sessions from the CLI (sessions watch / wake / list) #219) will have another.
  • 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:

  1. 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.
  2. A second tick that arrives during the session run sees the updated nextRunAt and skips.
  3. 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.

type ScheduleSource = "user" | "agent";

interface Schedule {
  id: string;                     // uuid
  projectId: string;
  worktreeId: string;
  prompt: string;
  provider?: string;
  model?: string;
  mode?: "default" | "plan";

  cron: string | null;            // e.g. "0 8 * * 1-5"; null = one-shot
  timezone: string;               // IANA; default = server tz
  runAt: string | null;           // ISO; set for one-shot, null for recurring

  nextRunAt: string;              // ISO; updated after each fire
  lastRunAt: string | null;
  lastRunSessionId: string | null;
  lastError: string | null;       // most recent failure message

  source: 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.

CLI surface

controller schedules list <project>
controller schedules show <project> <scheduleId>
controller schedules add <project> --worktree <id> --prompt <text> \
    [--at <iso>] [--cron <expr>] [--timezone <tz>] \
    [--every minute|hour|day|weekday] [--weekly --on-day <0-6>] \
    [--provider <id>] [--model <id>] [--mode default|plan] \
    [--enabled true|false] [--source user|agent]
controller schedules enable  <project> <scheduleId>
controller schedules disable <project> <scheduleId>
controller schedules remove <project> <scheduleId>
controller schedules runs <project> <scheduleId>     # materialized sessions

Server routes mirror these under /api/projects/:id/schedules[...] for the future UI.

Files (planned)

  • server/lib/paths.ts — add projectSchedulesDir, projectScheduleFile, projectSchedulesIndexFile.
  • server/lib/scheduler.ts — wakeup loop + consumer registry + runTick() for tests.
  • server/lib/schedule-cron.tscron-parser wrapper: computeNextRunAt, humanizeCron, validateCron, presets.
  • server/lib/schedules.ts — store, CRUD, tick consumer, tryFireSchedule with lock-then-mark.
  • server/lib/sessions.ts — extend SessionState.status to include "scheduled".
  • server/lib/session-start.ts — extract session-start logic from routes/sessions.ts so the scheduler can call it without going through HTTP.
  • server/routes/schedules.ts — REST routes.
  • server/index.ts — call startScheduler() after server.listen.
  • cli/controller — add schedules subcommand surface.
  • package.json — add cron-parser dependency.

PR breakdown (reviewable chunks)

  1. Foundation: paths + scheduler.ts (wakeup loop, no consumers yet) + startScheduler wired into server/index.ts. Tests for runTick semantics. Tick is a no-op.
  2. 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.
  3. 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.
  4. REST routes: /api/projects/:id/schedules[...] for the future UI.
  5. CLI: controller schedules ... subcommand surface + parsing tests.
  6. Follow-up (separate issue): 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.
  7. Follow-up (separate issue): UI for managing schedules.

Non-goals (this issue)

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.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions