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
schedulestable (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, nullablebudget_minutes/model/effort/provideroverrides,consecutive_failures,paused,last_fired_at, timestamps.runsgains a nullableschedule_id(ON DELETE SET NULL— deleting a Schedule never kills its live run) and thekindCHECK widens to admitscheduled. The run link is identity, likeissue_numberfor 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 newSpawnStageconst betweenStageFixandStageNewWork, one new producer appended inSpawnOnce; 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.Launchlike every sibling: worktree and branch from freshly-fetchedorigin/<default>, run token, skills bundle + context-file seeding, the repo's Runner/dev image, incogni screening. Label grammarsched-<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/provideroverride, 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_deadlinepersisted 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.Classifystays kind-blind; the promotion (timeout → successfor 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-agentvialabctl— and stop; the normal AFK pipeline claims them and, where Autoland is on, lands them) and the human-triage flow (same, labeledneeds-triagefor 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) solabctlverbs 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-agentissues 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/cronxwith 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: theschedulestable;runs.kindCHECK widened to admitscheduled(sqlite table rebuild per the 0014 precedent, postgres constraint swap); nullableruns.schedule_idwithON 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;scheduledjoins theActiveAFKRunskind 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 andlaunchScheduled(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 → successpromotion at the claim site, per-schedule strikes with the pause push, repo counter untouched; theschedulePausednotification 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, whichinstanceBranchalready 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 viacronx, 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;scheduledjoinsRunKindand 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.