Skip to content

Schedules: per-repo cron cadences fire scheduled runs — a fourth unattended run kind gathered by the one spawn pass, terminated by the budget clock alone, striking only themselves

The fleet has three unattended run kinds — AFK, lander, fix (plus the lander-class escalate) — and every one of them is anchored to an issue or a claim PR. Nothing in lab can act on a cadence: "every Monday at 06:00, investigate dependency updates and hand the findings to the pipeline" has no home. The operator either remembers to start a manual instance or the check never happens. The motivating shape is a weekly update-check that investigates and then files fully-specified issues for the existing AFK + Autoland pipeline to claim and land — recurring investigation feeding the machinery that already knows how to do work, not a second way of doing work.

A Schedule is the per-repo domain object that closes this: a name, a cadence stored as a cron expression, a freeform prompt, an ordered selection of built-in flows (routing instruction blocks appended to the prompt), an enabled flag, optional budget/model/effort/provider overrides, and its own consecutive-failure counter with a paused state. A due firing spawns a scheduled run — mechanically an AFK-run sibling (own worktree from freshly-fetched origin/<default>, run token, skills-bundle and context-file seeding, the repo's Runner and dev image, incogni screening), but with no issue, no claim, and no done-signal: it runs until its budget clock expires, and expiry is its completion. Everything launches through ADR-0049's one spawn pass — a Schedule producer is a fourth candidate gather, never an independent launch path, because a second launch path is exactly the cap race that ADR removed.

The decisions, pinned (settled in a full grill session with the operator via issue #247 — do not re-litigate):

  • A Schedule is a per-repo store row; a firing links its run by id. New schedules table (migration 0020, sqlite + postgres): id (sched_ prefix), repo_id (cascade), name (unique per repo), cadence (cron expression text), prompt, flows (JSON array of catalog keys), enabled, nullable budget_minutes/model/effort/provider overrides, consecutive_failures, paused, last_fired_at, timestamps. runs gains a nullable schedule_id (ON DELETE SET NULL — deleting a Schedule never kills its live run) and the kind CHECK widens to admit scheduled. The run link is identity, like issue_number for an AFK run: skip-on-overlap and the per-schedule counter both need to attribute a run to its Schedule, and a label-grammar parse would be a second source of truth for a fact the store can carry directly.
  • Firing rides the spawn pass at stage rank lander > fix > scheduled > new AFK. One new SpawnStage const between StageFix and StageNewWork, one new producer appended in SpawnOnce; the sort owns the order and the pass is unchanged (ADR-0049: priority is data, not control flow). Drain-before-fill still holds — in-flight claim work outranks a firing — but a due firing outranks fresh AFK selection because it is time-anchored: its slot has already passed, while a claimable issue has no clock and is still there next tick. The cap is respected; an at-cap candidate retries every pass until launched or superseded by its own next due time, at which point the stale firing is dropped loudly and the newer one stands in its place.
  • Skip-on-overlap, never queue. If the Schedule's previous run is still live when a firing comes due, the firing is skipped and logged loudly — consumed, not deferred. A queue of pending firings behind a slow run is auto-requeue-shaped runaway cost, the thing this repo has rejected twice already.
  • No catch-up. Firings whose cron slot passed while the server was down are gone; the next cron match fires normally. Startup logs loudly, per Schedule, when slots were missed (derived from last_fired_at). Due-ness is observed by the pass crossing a cron match while running — matches nobody was awake to observe never become candidates, so downtime loses firings by construction instead of by bookkeeping. The pending-firing memo is in-memory (a restart drops an at-cap pending firing — the no-catch-up rule, applied consistently); everything durable lives on the Schedule row.
  • Full spawn parity with AFK runs — inherited, no exceptions. A scheduled run goes through instance.Launch like every sibling: worktree and branch from freshly-fetched origin/<default>, run token, skills bundle + context-file seeding, the repo's Runner/dev image, incogni screening. Label grammar sched-<id-suffix>-<stamp>; the branch is exactly the repo's manual branch prefix + label, which is the branch reconciliation already derives for a non-AFK label — restart re-adoption and parked-work handling work unchanged, by construction rather than by new code.
  • The composed initial prompt is the Schedule's freeform prompt plus the selected flows' instruction blocks, appended in fixed catalog order. Same mechanism precedent as the ultracode spawn option (ADR-0021): prompt text composed at spawn, provider-agnostic, delivered as SeedPrompt. Zero flows is legal — a pure-prompt Schedule. Flow order is the catalog's, not the selection's: composition must be deterministic and catalog-owned, or two Schedules selecting the same flows could brief their runs differently.
  • Knob resolution: per-schedule overrides sit as one more default layer above the AFK overrides, skip-layer. A Schedule's model/effort/provider override, when set and present in the effective catalog, wins; when empty or stale it falls through to the AFK-default layering (ADR-0021/0030) exactly like an AFK run — scheduled runs join the AFK side of the resolver (options bag and remote included). Skip-layer, not strict: a stored override that a provider switch orphaned must degrade a firing to the inherited chain, not break every firing at 06:00 forever (ADR-0030's exact rationale). Explicit API writes stay strict — an unknown provider is a 400 at save.
  • Termination is the budget clock only — expiry is completion, not timeout-failure. Default 30 minutes, per-schedule override; budget_deadline persisted on the run row at launch, restart-safe (D12b). There is no done-signal: a PR is not expected, so the reaper never reads the pull listing for a scheduled run. At expiry the reaper reaps through guarded teardown and classifies the run success. Classify stays kind-blind; the promotion (timeout → success for the scheduled kind) happens at the claim site beside the existing escalated promotion.
  • Session death before budget expiry is a failure — and it strikes only the Schedule. Per-schedule three-strikes: three consecutive deaths pause that Schedule until a human re-enables it from the UI. Budget-expiry completions reset the counter; a neutral Stop touches nothing. Scheduled outcomes never move the repo's AFK three-strikes counter in either direction, and the repo's AFK pause never stops Schedules — the same independence the lander earned in ADR-0049, for the same reason: a broken Schedule pausing unrelated AFK work (or a healthy Schedule's completions clearing AFK strikes) couples counters that measure different things.
  • Flows are a built-in, lab-vendored catalog — pure prompt routing, versioned with the binary. Two ship: the autolander flow (investigate per the prompt, then file fully-specified issue(s) labeled ready-for-agent via labctl — and stop; the normal AFK pipeline claims them and, where Autoland is on, lands them) and the human-triage flow (same, labeled needs-triage for the triage state machine). Both bake in dedup: search for this Schedule's existing open issue first and update it instead of filing a duplicate. Flow texts live in lab's source (skills-bundle spirit) so labctl verbs and label names stay correct as lab evolves; no Autoland or lander changes — flows are text, not machinery. Example prompts ("check for dependency updates", "security audit") are UI-only starters in the form's example picker; the catalog stays pure routing.
  • Cadence is stored as cron, edited phone-first, previewed server-side. The form offers daily/weekly/monthly presets (HH:MM, weekday picker) that render to cron internally, plus a raw-cron advanced escape hatch with a human-readable preview. Server-local time; no per-schedule timezones, no one-off Schedules ("run once" is a manual instance — it already exists). Cron parsing/matching is a new small hand-rolled internal/cronx (five-field, minute granularity) — no dependency for a well-bounded grammar, matching the house discipline — and the preview is served by the API from the same parser, so the SPA never re-implements cron and never disagrees with the server about the next firing.
  • Exactly one new push trigger: Schedule paused. "Schedule paused after 3 failures", riding the existing Web Push machinery through the afk notification seam (ADR-0038 boundary), fired at the pause transition so it sends once. Firings and completions stay silent — a healthy weekly Schedule must cost zero notifications.
  • Vocabulary. Schedule, scheduled run, and flow join CONTEXT.md's Language section with avoid-lists; the globally-banned words stay banned in identifiers, UI copy, and docs.

Deferred, not foreclosed: a land-my-own-PR flow (needs Autoland to recognize non-afk/<N> claim branches), per-repo custom flows, per-schedule timezones, one-off Schedules, catch-up-once after downtime, per-firing push notifications.

Status

Accepted. Settled via issue #247. Extends ADR-0049: the Schedule producer is a fourth candidate gather feeding the one spawn pass, at a new stage rank between fix and new AFK work; the pass itself is untouched, which is exactly the extension point that ADR built. Rides ADR-0021/0030 unchanged: per-schedule knob overrides are one more skip-layer default rung, and the composed-prompt mechanism follows the ultracode precedent. Inherits the launch core (worktree, run token, seeding, Runner, incogni — ADR-0033/0035/0052/0053) with no exceptions, and the notification seam (ADR-0038/0039/0044) for the single new trigger. Leaves the AFK three-strikes semantics (ADR-0007/0010) untouched — the scheduled kind gets its own per-Schedule counter, mirroring the lander's independence from ADR-0049.

Considered options

  • A firing does the work directly (spawn an implementation run per finding). Rejected: it reinvents the claim and the done-signal for a run kind that has neither, and it bypasses the pipeline that already owns selection, claiming, validation, and landing. Filing ready-for-agent issues hands the work to machinery whose invariants are proven.
  • A do-work-and-PR flow. Rejected for now: Autoland only recognizes afk/<N> claim branches, so a scheduled run's PR would be invisible to the lander pipeline — a silent dead end. Deferred, not foreclosed; it becomes viable when Autoland learns non-AFK claim branches.
  • Queue or catch up missed firings. Rejected: auto-requeue-shaped runaway cost. A Schedule that stacks firings behind a slow run or replays a weekend of missed slots on Monday boot is the unattended-cost nightmare this repo has rejected twice; skip loudly and fire on the next match.
  • Feed scheduled outcomes into the repo's AFK strike counter. Rejected: a broken Schedule would pause unrelated AFK work, and a healthy Schedule's budget completions would clear strikes that broken AFK runs earned — the same wrong coupling ADR-0049 cut for landers.
  • A cron library dependency. Rejected: the grammar lab needs (five fields, minute granularity, server-local) is small and closed; a hand-rolled internal/cronx with exhaustive tests matches the house discipline, and the SPA needs no cron at all because the server serves the preview.
  • The word "task" (or "job") for any of this. Rejected: both are on CONTEXT.md's global avoid-list. The object is a Schedule; a firing spawns a scheduled run.

Consequences

  • migrations/{sqlite,postgres}/0020_schedules.sql: the schedules table; runs.kind CHECK widened to admit scheduled (sqlite table rebuild per the 0014 precedent, postgres constraint swap); nullable runs.schedule_id with ON DELETE SET NULL.
  • internal/cronx: new five-field cron package — parse, validate, match-at-minute, next-match — used by the engine, write-time validation, and the preview endpoint.
  • internal/store/{runs.go,afk.go,schedules.go}: RunKindScheduled; scheduled joins the ActiveAFKRuns kind list (or the reaper never sees the runs); Schedule CRUD, ActiveRunForSchedule, MarkScheduleFired, per-schedule failure counter writes and pause/re-enable.
  • internal/afk/{decide.go,spawn.go,schedule.go,flows.go,reaper.go,notify.go}: StageScheduled; the Schedule producer and launchScheduled (AFK-launch shape: engine lock, clone-ready check, fresh cap guard, knob resolution, budget deadline + token slack, instance.Launch); the flow catalog and prompt composition; reaper — no pull reads for the kind, timeout → success promotion at the claim site, per-schedule strikes with the pause push, repo counter untouched; the schedulePaused notification builder.
  • internal/instance/credential.go: the scheduled kind joins the AFK side of knob resolution; per-schedule overrides resolve as the topmost skip-layer default rung.
  • internal/reconcile: no production change — the scheduled branch is exactly manual-prefix + label, which instanceBranch already derives for unknown labels; re-adoption is kind-agnostic. One naming test case added to pin the derivation.
  • internal/httpapi/{schedules.go,server.go}: repo-scoped Schedule CRUD (labels.go shape, cross-repo 404 guard), re-enable action, flow-catalog listing, cron preview endpoint; write-time validation — cron via cronx, provider against the registry, model/effort deliberately unvalidated at write (spawn-time skip-layer, the repo-settings precedent).
  • web/src: api/schedules.ts; repo settings gains a Schedules section (categories entry, section component, harness stubs, probe) — preset cadence editor storing cron, raw-cron escape hatch with server-rendered preview, flow multiselect, example picker filling the prompt, override selects with inherit rows, enable/disable, paused banner with re-enable; scheduled joins RunKind and History's kind labels.
  • CONTEXT.md: Schedule, scheduled run, and flow entries with avoid-lists; Relationships updated; the Notifications section records the schedule-paused trigger beside the existing two.