ADR-0013: Scheduled one-shot runs — daemon-owned cron and schedule on [harness.*]
Context and Problem Statement
Harness supervises resident processes. SPEC-0003 REQ "Restart On Exit" was
originally explicit about it: "any exit — including a clean exit code 0 — SHALL
be followed by a restart per policy … agents and watchers are meant to be
long-lived." That is exactly right for a claude --remote-control that should
never die, and exactly wrong for the other half of the work: nightly sweeps,
weekly grooming, daily syncs — one-shot, non-interactive runs that are supposed
to finish.
Today that work lives outside Harness entirely, in launchd plists and hand-rolled scheduled tasks, which means the thing you most want a cockpit for — did the 3am agent run actually fire, and did it pass? — is the one thing the cockpit can't see. How do we run scheduled one-shot work under the daemon without breaking the resident-harness model, and without reintroducing the per-unit OS sprawl 📝 ADR-0005 deliberately collapsed?
Decision Drivers
- Terminal by design. A scheduled run must be allowed to finish. Restart-on-clean-exit is a feature for a resident harness and a bug for a scheduled one-shot.
- One init unit, still. 📝 ADR-0005 collapsed N per-harness systemd/launchd
units down to one.
systemdtimers on Linux versus launchdStartCalendarIntervalon macOS is precisely the OS branching that decision removed. Scheduling must not smuggle it back in. - The daemon stays agnostic. It runs a command and knows nothing about what is inside. A scheduler is a clock, not a domain — the daemon must not learn what a "sweep" is or how to deliver a notification.
- Non-interactive, but not invisible. A scheduled run runs unattended, yet hopping into a running one to watch it work is the signature Harness interaction. Scheduled execution must not become a blind pipe.
- A laptop is not a server. The machine is asleep at 03:00. Missed windows, suspend/resume, and clock jumps are the normal case here, not the exception.
- Smallest thing that removes the external timer. The concrete goal is that a
[harness.fleet-sweep]block replaces a launchd plist. Machinery beyond that is speculative until the basic loop is in use.
Considered Options
The decision has two orthogonal axes, and this ADR settles both.
Axis 1 — who owns the clock:
- 1A. Daemon-owned scheduler, plus a manual trigger verb (drafted as
harness run; shipped by #120 asharness trigger). - 1B. OS timers (systemd
.timer, launchdStartCalendarInterval) invokeharness trigger <job> --wait; the daemon stays ignorant of time. - 1C. Daemon-owned scheduler only — no new manual-trigger verb; the existing
harness start <name>is the manual path.
Axis 2 — how a scheduled unit is expressed in config:
- 2A. Distinct
[job.*]tables, sibling to[harness.*]. - 2B.
[harness.*]with aschedulekey. - 2C.
[schedule.*]tables referencing a harness by name — the systemd.timer/.servicesplit.
Decision Outcome
Chosen options: 1C (daemon-owned scheduler, no new trigger verb) and 2B
(schedule key on [harness.*]).
Revision note (2026-08-13). This ADR was drafted on 2026-07-26 proposing 1A + 2A (a
harness runverb and distinct[job.*]tables) and landed in that form asstatus: proposedvia #112. The feature that actually shipped in #122 implements 1C + 2B. This revision rewrites the Decision Outcome to record the decision as taken, and moves the unbuilt[job.*]machinery into Deferred. The original 2A rationale is preserved verbatim under Pros and Cons of the Options — it was not wrong, it was outvoted by a schema change that landed in between. See "Why 2B, given 2A's argument" below.Revision note (2026-09-11). #117 replaced the timer-driven
robfig/cronrunner with a wall-clock tick, added missed-window detection and thecatch_upkey, persisted each schedule's position instate.json, and specified time zones (CRON_TZ=/TZ=prefixes) and DST behavior. Those move out of Deferred and into Clock and Time zones and DST below. Run history (#119) and the protocol/CLI surface (#120) remain deferred.Revision note (2026-09-11, #119). Run history, per-run logs,
timeout, andon_overlap(skip, queue, replace) were built; see Runs below. They leave Deferred. The protocol/CLI surface that reads them (#120) remains.Revision note (2026-09-11, #120). The
jobs,triggerandrunscontrol ops, arunselector onlogs, thejob_run_*andjob_schedule_changedevents, and their CLI verbs were built. That adds option 1A's manual-trigger verb after all, namedtrigger; see Decision Outcome.
1C because the daemon already is the supervisor, the state store, and the thing
systemd/launchd keeps alive — giving it the clock costs one goroutine and keeps
📝 ADR-0005's "one init unit" property intact across both platforms. No new
trigger verb was needed because a scheduled harness is still a harness:
harness start <name> runs it now, through the identical code path the cron
firing uses, so triggering by hand is a genuine test of what fires at 03:00.
That verb arrived with #120 regardless, once run history made it worth having —
the reason option 1A's Neutral bullet gave for waiting. harness trigger <name>
enters through the same StartRun a firing does, so unlike start it honors
on_overlap; and --wait streams the run and exits with its exit code, which is
the seam that lets an external scheduler (option 1B) drive a job without the
daemon adopting that model. It is trigger rather than 1A's run because
harness run had meanwhile become 📝 ADR-0017's scratchpad verb. start and stop
keep their meaning.
2B because the structural argument for a separate table kind dissolved once
restart shipped as a user-facing key.
Why 2B, given 2A's argument
2A's case rested on one claim: a job and a harness have opposite lifecycles, so the difference must be a type rather than a key, or every downstream surface ends up branching on the presence of a key. That claim was sound when it was written. Two things changed before implementation:
restartbecame a real key on[harness.*](📝 ADR-0006), with valuesno/always/unless-stopped/on-failure. "Terminal by design" — the defining property of a job — stopped needing a new table to express it. It isrestart = "no", and it is already the default for a prompt harness.- 📝 ADR-0011 introduced the prompt harness, a one-shot agent run declared by
promptinstead ofcmd. The noun 2A wanted to create already existed as a harness kind, with the right restart default, the right spawn path, and the right ergonomics.
That left schedule as the one genuinely missing piece, and 2A's remaining
objection — that enabled and restart_delay become "silently ambiguous" on a
scheduled unit — is answerable with validation rather than a second table type.
The parser rejects every ambiguous combination outright:
| Rejected | Because |
|---|---|
schedule without prompt | A scheduled unit is a one-shot agent run, not an always-on cmd |
schedule with enabled = true | Autostart and schedule are distinct intents; this is the ambiguity 2A named |
schedule with profile membership | Profile autostart would fire the one-shot off-schedule, bypassing the enabled exclusion |
schedule with restart = "always" / "unless-stopped" | A respawning policy restarts the one-shot after a clean exit, making the schedule meaningless |
schedule in a project file | Project harnesses never enter the daemon's config view, so the schedule could never fire |
| An unparseable cron expression | A typo must fail the load with a located error, not silently never fire |
Each of these is a hard, located parse error. The ambiguity 2A predicted is real; the fix turned out to be cheaper than a second table kind, a second registry dimension, and a second state vocabulary across the protocol, CLI, and TUI.
The cost 2A named is not fully avoided, and is recorded in Consequences below:
consumers that want to render a scheduled harness distinctly do branch on
Schedule != "". That is a real ongoing tax, accepted deliberately.
The schema
# ~/.config/harness/harness.toml
[harness.claude-src] # resident: restart defaults to "always"
cmd = "claude"
args = ["--remote-control", "--dangerously-skip-permissions"]
[harness.fleet-sweep] # scheduled one-shot
prompt = "check all StumpCloud services and report anything unhealthy"
auto_accept = true
schedule = "CRON_TZ=UTC 0 */6 * * *" # 5-field cron, or @daily / @every 6h
catch_up = true # run once on wake if a window was missed
timeout = "45m" # bound each run (default 1h; "0" = none)
description = "scheduled sweep (every 6 hours)"
schedule is a 5-field cron expression (min hour dom mon dow), the
@daily/@hourly/@weekly/@monthly/@yearly descriptors, or
@every <duration> — validated at parse time by the same parser the scheduler
itself uses, so config and scheduler cannot disagree about what is valid. It
may carry a CRON_TZ=<zone> or TZ=<zone> prefix; see Time zones and DST.
catch_up (default false) is the missed-window policy described under
Clock below. It means nothing without schedule, so the parser rejects it
there, and in project files, on the same terms as the exclusions above. So are
timeout, on_overlap and keep_runs, the run keys described under Runs.
Everything else on the table means exactly what 📝 ADR-0006 and 📝 ADR-0011 already say
it means: same parser, same core.Harness, same env_file handling (📝 ADR-0008),
same prompt-harness argv synthesis.
enabled keeps its SPEC-0003 meaning of autostart intent and is simply
forbidden alongside schedule; it was not redefined to mean "armed."
Firing semantics
At each cron firing the daemon starts the harness if no run is in flight. A
firing that arrives while one is follows the harness's on_overlap policy (see
Runs); none of them ever stacks a second process. A firing landing during a
graceful stop is recorded skipped and cannot resurrect the harness. A fresh
scheduled attempt clears a failed latch through the ordinary start path.
The run exiting is terminal for that firing. The restart policy applies only to
abnormal exit, and only if the operator configured on-failure.
Clock: wall-clock evaluation, missed windows, catch-up
The daemon never arms a timer to a window. Once a second
(scheduler.TickInterval) it compares the wall clock against each armed
schedule's next window, and that one-second ticker is the longest timer it
holds. A timer armed for 03:00 and carried across a laptop suspend fires
whenever the platform decides — Go's timers and its monotonic clock do not
advance while macOS or Linux sleeps — so a wake at 08:00 would get an
OS-dependent outcome. The tick reaches a decision that is ours instead. The
wall-clock reading has its monotonic component stripped before any comparison,
or Go would compare on precisely the clock that stopped. Firing is dispatched
on its own goroutine, so a slow start cannot delay evaluating anything else.
A window first evaluated more than LateGrace (one minute) after it was due
was not seen on time: the machine slept, the daemon was down, or the clock
jumped forward. For those windows:
catch_up = trueruns the harness exactly once, however many windows elapsed.catch_up = false(the default) runs nothing and records exactly one missed entry covering all of them.
If the most recent elapsed window is itself inside the grace, it runs as an ordinary firing and only the older windows count as missed. Windows a slow tick lands a few seconds late on coalesce into a single on-time run.
Each scheduled harness's position — the last window decided, with the
expression it was decided under — is persisted in state.json (extending
📝 ADR-0007) and written synchronously before the run starts. So a daemon
started after an outage resumes from it and takes the same code path a wake
does, and a daemon that crashes just after firing does not fire that window
again on restart. A position recorded under a different expression is
discarded rather than used to invent misses. A wall clock that steps backwards
never re-decides a window already decided; it delays the next run rather than
repeating the last one.
Recording the miss is the point. Silently doing nothing is the classic laptop
cron failure, and a visible miss is what separates "it ran and passed" from
"it never fired." The scheduler reports it through a Recorder seam, and the
daemon records it as a missed entry in run history (see Runs) as well as a
warn-level log line.
Time zones and DST
An expression is evaluated in the zone its CRON_TZ=/TZ= prefix names —
robfig/cron's own syntax, so the validating parser already understood it — and
otherwise in the daemon's local zone. There is no separate timezone key: a
second place to state a zone is a second place for it to disagree with the
expression. The binary embeds the IANA database, so a named zone validates the
same on a host with no zoneinfo installed.
DST splits schedules the way ISC cron splits them:
- An expression that fires in every hour, or any
@everyinterval, is a cadence in real time. It runs once per real hour straight through both transitions. - An expression restricted to particular hours names wall-clock times, and
each wall-clock window runs exactly once. A window inside a spring-forward gap
runs at the instant the pre-transition offset names (
30 2 * * *runs at 03:30 EDT, 24 real hours after the previous day's run), and a window repeated by a fall-back runs only at its first occurrence. robfig/cron's ownNextskipped the first case outright and ran the second twice; the scheduler resolves these windows itself.
Runs: history, per-run logs, timeout, overlap
Every run of a scheduled harness leaves a record — {run_id, trigger, outcome, started_at, ended_at, exit_code\} — and a log of its own at
$XDG_STATE_HOME/harness/jobs/<name>/<run_id>.log, next to (not instead of)
📝 ADR-0007's rotating harness log. History records what the daemon decided, not
only what executed: success, failed and timed_out runs, but also skipped
and missed decisions that started nothing, runs replaced or cancelled by an
operator, and runs interrupted because the daemon went down under them. That
is what makes "it never fired" as visible as "it ran and failed".
- The supervisor records; the Manager stores. A run is opened and closed on
the harness's actor loop, the one goroutine that sees spawn, exit, timeout,
stop and shutdown in order — lifecycle bus events can be dropped under
backpressure, so they are not a foundation for history. The Manager hands out
ids, bounds the history to
keep_runs(default 20), deletes the logs of records that fall out, and persists it all instate.json, writing a run's id before the run starts. - Ids never repeat. They count up per harness across restarts, and are floored at the highest log on disk, so even a lost history cannot reissue an id whose log still exists.
- A crash is reconciled, not left running. A record still
runningat boot becomesinterruptedwith no end time — the true end is unknown — and its log gets a line saying so. timeout(default1h,"0"for none) bounds every run: SIGTERM to the process group, SIGKILL after the stop grace, recordedtimed_out, harness leftfailed. A hung agent no longer owns its schedule.on_overlapdecides a firing that arrives mid-run:skip(default) records it skipped;queueholds one and starts it when the run ends on its own terms;replacestops the run (recorded replaced) and starts the new one. The decision is made on the actor loop, atomically with the start, which also retires the daemon's old read-the-state-then-start guard and its race.
Records carry outcomes, times and exit codes — never environment, env_file
contents, prompt text or output (📝 ADR-0008). Run logs are created private to the
daemon's user. The histories are exposed to the rest of the daemon as exact run
windows (Manager.Runs), which is what #120's harness logs <job> --run N and
run correlation need.
Clients read them over the protocol (#120): jobs (schedule, next window, the
run in flight, the latest record, consecutive failures), runs (history,
newest first), trigger (a manual run through StartRun), a run selector on
logs, and job_run_started / job_run_finished / job_schedule_changed
events. The CLI mirrors them as harness jobs, harness runs,
harness trigger [--wait] and harness logs --run N.
Reconciliation, not rebuild
Schedules re-apply after every successful config reload — SIGHUP, the
fsnotify config watcher, and the reload control op all funnel through
Manager.Reload, which invokes a single reload hook. Reconciliation is
incremental: an entry whose spec is unchanged keeps its existing
registration, and therefore its phase. Changing only catch_up updates the
entry in place.
This is load-bearing, not an optimization. Rebuilding the cron on every reload
resets each @every interval's countdown, and this config file is rewritten
periodically by chezmoi and czu whether or not anything changed — so a
rebuild-on-reload scheduler would let an @every 6h sweep never fire at all.
Execution reuses the supervisor path verbatim
A scheduled run is an ordinary supervised PTY spawn: same internal/supervisor
spawn path, same env_file loading (📝 ADR-0008), same x/vt emulator and
scrollback ring (📝 ADR-0003). harness attach fleet-sweep lets you watch the
03:00 agent work while it works, and the run inherits the PTY semantics agent
CLIs generally need.
Consequences
- Good, because recurring work moves into the same cockpit as resident agents,
and a
[harness.*]block replaces a launchd plist. - Good, because zero new OS units are created; one systemd/launchd unit still supervises everything, and the Linux/macOS branching 📝 ADR-0005 removed stays removed.
- Good, because attach-a-running-job falls out of the shared PTY path for free — the signature "hop" interaction now applies to scheduled work.
- Good, because the config schema grew by exactly one key, and the manual path
(
harness start) is the scheduled path, so there is no parallel implementation to keep honest. - Good, because every ambiguous combination is a located parse error rather than a silently-ignored key.
- Bad, because consumers branch on
Schedule != ""rather than on a type — the exact cost 2A named. Every rendering surface pays it separately:ls,describeand the cockpit each carry their ownSchedule != ""arm so a scheduled harness does not read as an inert disabled one-shot (#160, #205; the shared phrasing lives ininternal/schedfmt, the branching does not). SPEC-0008 REQ "Schedule Visibility" is what holds them to the same answer. - Bad, because a firing goes through
Manager.Start, which persistsenabled = true— so an unclean daemon exit mid-run can autostart the one-shot off-schedule on the next boot, defeating theenabledexclusion the parser enforces. Tracked as #159; the fix is a supervisor-level "start without persisting intent" primitive. - Good, because a laptop that sleeps through a window reaches a deliberate, tested decision on wake — run once, or record the miss — rather than whatever the OS did to a long timer, and a missed window is never silent.
- Bad, because the daemon is now a scheduler, and clock correctness — DST,
suspend/resume, timezone data, wall-clock jumps — becomes our bug surface. It
is contained in
internal/scheduler, behind an injectable clock, with each of those cases pinned by a test that runs in milliseconds. - Bad, because the daemon wakes once a second for as long as it runs, whether or not anything is scheduled. A tick is one comparison per armed entry; the cost is the wakeup, not the work.
- Good, because "did last night's run pass, and what did it print?" has an answer: a bounded, restart-safe run history and one log per run, including records for the runs that never started.
- Bad, because on-disk footprint grows from O(harnesses) to O(scheduled
harnesses ×
keep_runs) run logs.keep_runsis a first-class key, and pruning deletes a log with its record, for that reason. - Good, because a job can be driven from outside the daemon:
harness trigger <job> --waitstreams the run and exits with its exit code (124 for a timeout, 75 when skipped), so it composes like the command it wraps. - Bad, because if
harnessdis down at 03:00, the run does not fire at 03:00. System cron would have. The daemon notices on its next boot —catch_up = trueruns it once,catch_up = falserecords amissedrun.
Confirmation
SPEC-0008 (scheduled-jobs) formalizes the schedule key, its exclusions, the
firing guard, and reload reconciliation as testable requirements and scenarios.
Acceptance tests, all present:
- A harness with
@every 1sfires repeatedly and never restarts on exit 0. - Re-applying an unchanged config preserves each entry's cron registration identity, so its phase survives a no-change config rewrite.
- Changing a spec re-registers the entry; removing
scheduleremoves it. - Every rejected combination in the table above fails config parsing with an error naming the harness and the offending key.
- The reload hook fires on
ReloadandReloadFromFile, and does not fire on a failed parse. schedulesurvives a TUI edit round-trip that touches an unrelated field.- Advancing a fake clock past five windows in one jump runs once with
catch_up = trueand records exactly one miss without it, and a daemon started after the same outage decides identically. - A restart just after a firing, and a backwards clock step, do not refire.
- A time-of-day schedule runs exactly once on both DST transition days; an every-hour cadence runs once per real hour through them.
CRON_TZ=/TZ=override the daemon's zone, including half-hour zones.- Outside
clock.gothe scheduler package neither reads the time nor arms a timer — enforced by a source-scan test. - Every run outcome is reachable through the real supervisor path and recorded,
including
skipped,missedand held-firingcancelledwith no process. - Run ids survive a restart and floor at the logs on disk;
keep_runsprunes records and logs together and never the run in flight. - A record left
runningby a crash boots asinterrupted; a malformed history costs only its own harness; a pre-historystate.jsonloads. - A run exceeding
timeoutis killed — including one that ignores SIGTERM — and recordedtimed_out; every run log opened is closed on every exit path. queueholds one firing and skips the next;replacerecords the old run replaced; a stop cancels both the run and the held firing.- No
env_filevalue reaches run records orstate.json.
Deferred
Everything below was specified in this ADR's original 2026-07-26 draft and is not implemented. It is retained as the roadmap it is, not as a description of current behavior:
scheduled/completedstates and aconsecutive_failurescounter.- Job rows in the TUI cockpit, and arming or disarming a schedule from a client.
tty = false,on_failurehooks and notifiers, scheduled units as[profile.*]members, retry-within-a-window, and a daemon-wide concurrent-run cap.- Project-scoped schedules — project files reject
scheduleoutright today, which sidesteps rather than solves 📝 ADR-0009's runtime-only registration problem.
Pros and Cons of the Options
1C — Daemon-owned scheduler, no new trigger verb (chosen)
The daemon carries an internal cron engine driven from config; the existing
harness start <name> is the manual trigger.
- Good, because it preserves 📝 ADR-0005's "one init unit" property on both Linux and macOS with no per-unit OS units.
- Good, because the daemon already owns state, PTYs, and logs.
- Good, because it adds no command surface at all — a scheduled harness is still
a harness, so
start,stop,attach, andlogsalready do the right thing. - Neutral, because it adds a scheduler goroutine to a daemon that previously only reacted to process exits.
- Bad, because a daemon that is down at the scheduled instant misses the
window. (Since #117 the miss is recorded on the next boot, and
catch_upcan run it once.) - Bad, because DST, timezone data, and suspend/resume correctness become our problem rather than systemd's.
1A — Daemon-owned scheduler + a manual trigger verb
As 1C, plus a dedicated manual trigger verb — drafted as harness run <name>,
shipped by #120 as harness trigger <name> — with --wait streaming output and
propagating the run's exit code.
- Good, because
--waitpropagating an exit code is the cheap seam that lets an external scheduler drive Harness if someone wants 1B's properties. - Good, because
triggernames the intent more precisely thanstartdoes for a one-shot. - Bad, as drafted, because
harness runcollided conceptually withharness daemon run, and later outright with 📝 ADR-0017's scratchpad verb. Shipping the verb astriggerremoves both collisions. - Neutral, because without run history there is nothing for
--waitto report beyond the exit code, whichstartplusattachalready approximates. Reconsider alongside run history. (#119 added run history; #120 then added the verb.)
1B — OS timers invoke the trigger verb
systemd .timer units on Linux and launchd StartCalendarInterval on macOS
fire harness trigger <name> --wait; the daemon never learns what time it is.
- Good, because timing correctness — including
Persistent=truecatch-up — is handled by software with far more hardening than ours. - Good, because a run still fires when the daemon happens to be down.
- Bad, because it reintroduces exactly the N-units-per-thing sprawl 📝 ADR-0005
collapsed, and revives the
systemctlversuslaunchctlbranching that decision deleted from the codebase. - Bad, because the schedule lives outside
harness.toml, so the config file stops being a complete description of what the daemon does — breaking 📝 ADR-0006's "it's a file in my dotfiles" property. - Bad, because the TUI cannot render "next run" without parsing systemd/launchd state, on two platforms, in two formats.
2B — [harness.*] with a schedule key (chosen)
Presence of schedule marks a harness as a daemon-fired one-shot.
- Good, because it is the smallest possible schema change: one parser, one registry, one mental model.
- Good, because 📝 ADR-0011's prompt harness already supplies the one-shot noun and
the
restart = "no"default, so "terminal by design" needs no new table. - Good, because the ambiguities 2A predicted are answered by six hard parse errors rather than by a type distinction.
- Bad, because consumers branch on
Schedule != ""to decide how to render, which is a type distinction wearing a key's clothing. - Bad, because a genuinely dual-purpose process cannot be both resident and scheduled without being declared twice — the same cost 2A carries.
2A — Distinct [job.*] tables
A job is its own noun, sharing the underlying Go types with Harness. This was
the original 2026-07-26 choice; the argument is preserved as written.
- Good, because the parser can reject nonsense (
restart_delayon a job) instead of silently ignoring it. - Good, because job-appropriate defaults (never restart, 1h timeout,
keep_runs) are natural on their own table rather than conditional on a key. - Good, because the TUI and
psbranch on a type, and[harness.*]stays byte-for-byte the schema 📝 ADR-0006 defined. - Neutral, because it adds a second table kind to the config parser and a second registry dimension in the daemon.
- Bad, because a genuinely dual-purpose process must be written twice if you want it both resident and scheduled.
- Outvoted because
restartand the 📝 ADR-0011 prompt harness landed in between, supplying the lifecycle distinction 2A wanted from a new type; and because its rejection-of-nonsense advantage is obtainable with validation on one table, at a fraction of the downstream cost.
2C — [schedule.*] referencing a harness
The systemd .timer/.service split: a schedule table names a harness to fire.
- Good, because it lets you attach a schedule to something already defined, and in principle put several schedules on one unit.
- Bad, because it splits one job across two tables that must be read together to understand either.
- Bad, because it requires defining a harness that must never autostart
(
enabled = false) and exists only to be referenced — a dangling unit that breaks the "a harness is a thing that runs" invariant. - Bad, because it inherits systemd's most-complained-about ergonomic for no benefit Harness actually needs.
Architecture Diagram
More Information
- Extends ADR-0005 — places the
scheduler inside the daemon so Layer 2 stays exactly one init unit. The
restart-policy axis this ADR originally called for shipped independently as
the
restartkey, and SPEC-0003 REQ "Restart On Exit" already reads "While a harness isenabledand its restart policy permits it" — so no SPEC-0003 amendment is required. - Extends ADR-0006 — adds
scheduleto the[harness.*]schema. Profiles are untouched, and profile membership is forbidden for a scheduled harness. - Related ADR-0011 — the scheduled unit is a
prompt one-shot;
schedulerequiresprompt, and therestart = "no"prompt default is what makes the run terminal. - Related ADR-0002 — adds the
jobs,triggerandrunscontrol ops (#120), mirrored 1:1 as CLI verbs;start/stop/attachon a scheduled harness remain the existing thin-client gestures. - Related ADR-0003 — scheduled runs use
the same owned PTY and
x/vtemulator, which is what makes attaching to an in-flight run work at all. - Extends ADR-0007 —
state.jsoncarries each schedule's position (the last window decided), which is what makes a missed window detectable across a daemon restart, and each scheduled harness's run history. Per-run logs live beside it underjobs/. - Related ADR-0008 — scheduled runs load
env_filethrough the same path. - Related ADR-0009 —
project files reject
schedule, because project harnesses never enter the daemon's config view and a project schedule could never fire. - Governs SPEC-0008 — the formal requirements and scenarios.
Related Artifacts
Direct relationships declared in YAML frontmatter (per ADR-0023 / SPEC-0018). Run /sdd:graph chain ADR-0013 for the transitive view.