SPEC-0012: Operating Hours
Overview
A resident harness may carry operating_hours: weekly windows during which it
is allowed to run. Outside them the daemon holds the harness down: it shuts the
process down without clearing enabled (by default letting the agent finish its
turn first), and starts it again when a window opens.
The purpose is cost. An agent that is not running does not spend tokens. See
📝 ADR-0019 for the decision and the design notes for the implementation shape.
This spec amends SPEC-0003 REQ "Autostart", REQ "Restart On Exit" and REQ
"Graceful Stop" (the last so that an hours stop leaves enabled alone, while an
operator stop still clears it), and SPEC-0002 REQ "Control Operations" and REQ
"Event Subscription" (the start op's for, and the harness_hours_changed
event). It reuses the SPEC-0008 scheduler tick and zone handling (REQ
"Suspend-Safe Schedule Evaluation", REQ "Schedule Time Zone").
Terms used throughout:
- Gated harness: a harness whose
operating_hoursis set. - In hours: the wall clock, read in the expression's zone, falls inside at least one window.
- Held: a gated harness that is out of hours,
enabled, and down because of its hours rather than an operator stop or a restart policy — that is, a harness the daemon shut down under REQ "Gate Enforcement" and has not yet started again. It isstoppedonce the hold has completed. - Closing: a gated harness that is out of hours, still running (or
degraded), and being shut down gracefully (REQ "Graceful Shutdown"). - Turn state: whether the agent inside a harness is mid-turn or between turns, as read from agent-trace (SPEC-0006 REQ "Live Turn State").
- Lease: an after-hours permission to run, created by a manual start outside hours, with a persisted end time.
Requirements
Requirement: Operating Hours Key
The daemon SHALL accept an optional string key operating_hours on a
[harness.*] table in the daemon config of record. Its grammar SHALL be:
operating_hours = [ zone-prefix " " ] window *( ";" window )
zone-prefix = ( "TZ=" / "CRON_TZ=" ) zone-name
window = [ day-spec " " ] time "-" time
day-spec = day-item *( "," day-item )
day-item = day / day "-" day
day = "Mon" / "Tue" / "Wed" / "Thu" / "Fri" / "Sat" / "Sun" ; case-insensitive
time = HH ":" MM ; 00:00 through 24:00
Whitespace around ; and , SHALL be ignored. A window without a day spec
SHALL apply to every day. A day range SHALL be inclusive and MAY wrap the week
(Fri-Mon is Fri, Sat, Sun, Mon). A window's start is inclusive and its end is
exclusive. 24:00 SHALL be valid only as an end. A window whose end is at or
before its start, other than an end of 24:00, is an overnight window. It
SHALL run from its start on each listed day until its end on the following day.
Windows MAY overlap, and the harness is in hours during their union.
The zone prefix SHALL be resolved exactly as SPEC-0008 REQ "Schedule Time Zone" resolves a schedule's prefix, including the embedded IANA database. Without a prefix, the expression SHALL be evaluated in the daemon's local zone.
The daemon SHALL validate the value when parsing the config. A blank value, an unknown day or zone, a malformed or out-of-range time, and a window whose start equals its end SHALL each be a parse error naming the harness and the key.
Scenario: Weekday window in a named zone
- WHEN a harness sets
operating_hours = "TZ=America/Los_Angeles Mon-Fri 09:00-13:00" - THEN it is in hours from 09:00 up to 13:00 Pacific on Monday through Friday, whatever zone the daemon runs in
Scenario: Lunch break
- WHEN
operating_hours = "Mon-Fri 09:00-12:00; Mon-Fri 13:00-17:00" - THEN a weekday 12:30 is out of hours and 13:00 is in hours
Scenario: Overnight window
- WHEN
operating_hours = "Fri 22:00-02:00" - THEN Friday 23:00 and Saturday 01:59 are in hours, and Saturday 02:00 and Friday 21:59 are out of hours
Scenario: Wrapping day range
- WHEN
operating_hours = "Fri-Mon 10:00-11:00" - THEN 10:30 on Friday, Saturday, Sunday and Monday is in hours, and 10:30 on Tuesday is not
Scenario: Equal start and end
- WHEN
operating_hours = "Mon 09:00-09:00" - THEN config parsing fails with an error naming the harness and
operating_hours
Scenario: Unknown zone
- WHEN
operating_hours = "TZ=Mars/Olympus 09:00-13:00" - THEN config parsing fails with an error naming the harness and the zone
Requirement: Operating Hours Exclusions
The daemon SHALL reject each combination below as a parse error naming the harness and the offending key.
| Rejected combination | Rationale |
|---|---|
operating_hours with schedule | A scheduled one-shot is already time-gated by its cron expression |
operating_hours in a project harness.toml | Reload reconciliation and lease persistence are defined only for the config of record (📝 ADR-0019 Deferred) |
hours_shutdown or hours_shutdown_timeout without operating_hours | A shutdown mode with no hours to close does nothing |
hours_shutdown or hours_shutdown_timeout on a harness with triggers | A triggered run is bounded by timeout, not closed at the window's end (SPEC-0014) |
operating_hours SHALL be accepted alongside enabled, profile membership, any
restart policy, cmd, and a prompt harness without schedule. On a harness
that sets triggers, the gate decides whether a firing may start a run, not
whether a process is held (SPEC-0014 REQ "Operating Hours On Triggered
Harnesses").
Scenario: Hours on a scheduled harness
- WHEN a harness sets both
scheduleandoperating_hours - THEN config parsing fails naming the harness and
operating_hours
Scenario: Hours on a project harness
- WHEN a project
harness.tomlgives a harnessoperating_hours - THEN that project fails to load with an error naming the harness and
operating_hours
Scenario: Hours on a profile member
- WHEN a harness with
operating_hoursis a member of an autostart profile - THEN the config loads, and the profile decides
enabledwhile the hours decide when it runs
Scenario: Shutdown mode with no hours
- WHEN a harness sets
hours_shutdown = "immediate"withoutoperating_hours - THEN config parsing fails naming the harness and
hours_shutdown
Requirement: Gate Evaluation
The daemon SHALL evaluate every gated harness on the scheduler's wall-clock tick (SPEC-0008 REQ "Suspend-Safe Schedule Evaluation"), comparing the wall clock with its monotonic component removed. Evaluation SHALL be level-triggered: each tick SHALL decide from the current time alone whether the harness is in hours, and SHALL NOT depend on having observed a window boundary. The daemon SHALL NOT arm a timer to a window boundary.
In-hours membership SHALL be decided on local wall-clock time in the expression's zone. On a DST transition day, a window SHALL cover whatever local times the day actually has: a spring-forward gap shortens a window that spans it, a fall-back repeat lengthens one, and neither case runs a window twice or skips one.
Scenario: Sleeping through a close
- WHEN a gated harness is running at 12:50 inside a window ending 13:00, the host suspends, and it resumes at 15:00
- THEN the first tick after resume finds the harness out of hours with its close deadline (13:15 by default) already past, and stops it at once
Scenario: Waking inside a window
- WHEN the host suspends at 08:00 with a held harness and resumes at 10:00 inside its window
- THEN the first tick after resume starts it
Scenario: Spring forward
- WHEN
operating_hours = "TZ=America/New_York 01:30-02:30"and the clock springs from 01:59:59 EST to 03:00:00 EDT - THEN the harness is in hours at 01:59:59 and out of hours at 03:00:00
Scenario: Fall back
- WHEN
operating_hours = "TZ=America/New_York 01:00-02:00"and 01:00–01:59 local time occurs twice - THEN the harness is in hours during both occurrences
Requirement: Gate Enforcement
When a tick finds a gated harness out of hours and not covered by a valid lease
(REQ "After-Hours Lease"), and the harness is starting, running, degraded
or restarting, the daemon SHALL hold it:
- cancel any pending respawn;
- under
hours_shutdown = "graceful"on arunningharness, enter closing and wait as REQ "Graceful Shutdown" specifies; otherwise proceed at once; - run the SPEC-0003 REQ "Graceful Stop" sequence (SIGTERM, stop grace, SIGKILL,
PTY teardown) and transition to
stopped, leavingenabledas that requirement's operating-hours carve-out specifies; - leave
enabledunchanged; - treat the resulting exit as neither a crash nor a policy exit: no restart (overriding SPEC-0003 REQ "Restart On Exit"), no restart-count increment, and crash-loop bookkeeping reset.
A harness that is starting or restarting has no live process to finish a
turn in, so it SHALL be stopped at once whatever the shutdown mode. A degraded
harness has a live process but is waiting out a backoff rather than running a
turn, so it SHALL also be stopped at once rather than enter closing.
When a tick finds a gated harness in hours and the harness is held, the daemon
SHALL start it without modifying enabled. The daemon SHALL NOT start a gated
harness that is failed, or one whose enabled is false, when hours open.
On daemon start, SPEC-0003 REQ "Autostart" SHALL start an enabled gated
harness at once only if it is covered by a valid lease. Every other enabled
gated harness SHALL begin held, and the first gate evaluation, which runs as
soon as the scheduler starts, SHALL release it if it is in hours. The in-hours
decision is then made on the scheduler's clock like every other gate decision.
An in-hours harness comes up a moment after boot rather than inside Autostart,
and a harness booted out of hours is never started only to be shut again.
The same rule applies whenever enabled intent is set on a gated harness that
is down: a reload that introduces it (📝 ADR-0014) and harness use-profile each
record the intent and begin it held.
Scenario: Close
- WHEN a running, enabled harness with
operating_hours = "09:00-13:00"andhours_shutdown = "immediate"reaches 13:00 - THEN it transitions through
stoppingtostopped,enabledis still true, its restart count is unchanged, and it is not respawned
Scenario: Open
- WHEN that harness is held and the clock reaches 09:00 the next day
- THEN it transitions to
startingandenabledis still true
Scenario: Close during a restart delay
- WHEN a gated harness is
restarting(waiting outrestart_delay) at the close - THEN the pending respawn is cancelled and the harness is held
Scenario: Close a degraded harness
- WHEN a gated harness is
degraded(crash-looping with backoff pending) when its window closes - THEN it is stopped at once whatever the shutdown mode, its backoff is reset, and it is held
Scenario: Failed harness at open
- WHEN a gated harness is
failedwhen its window opens - THEN it stays
failed
Scenario: Operator-stopped harness at open
- WHEN a gated harness was stopped with
harness stopinside hours (enabled = false) and the next window opens - THEN it stays
stopped
Scenario: Boot out of hours
- WHEN the daemon starts at 20:00 and an enabled harness has
operating_hours = "09:00-13:00"and no lease - THEN the harness is not started and begins held
Scenario: Boot in hours
- WHEN the daemon starts at 10:00 and an enabled harness has
operating_hours = "09:00-13:00" - THEN it begins held and the first gate evaluation starts it
Requirement: Shutdown Mode
A gated harness MAY set hours_shutdown to "graceful" or "immediate". An
omitted key SHALL mean "graceful". It MAY set hours_shutdown_timeout, a
positive duration string, default "15m", bounding how long a graceful close
may run past the close. Any other hours_shutdown value, and a zero, negative
or unparseable timeout, SHALL be a parse error naming the harness and the key.
Both keys are supervision keys: a change SHALL apply to the next close, and to a
close in progress, without restarting the harness.
Scenario: Default mode
- WHEN a harness sets
operating_hoursand nohours_shutdown - THEN its closes are graceful with a 15-minute cap
Scenario: Immediate mode
- WHEN a harness sets
hours_shutdown = "immediate" - THEN a close stops it without waiting for its turn state
Scenario: Invalid timeout
- WHEN a harness sets
hours_shutdown_timeout = "0" - THEN config parsing fails naming the harness and
hours_shutdown_timeout
Scenario: Mode changes during a close
- WHEN a reload changes
hours_shutdownto"immediate"while the harness is closing, or shortenshours_shutdown_timeoutto a deadline already past - THEN the close stops the harness on the next tick without restarting it
Scenario: Timeout lengthened during a close
- WHEN a reload lengthens
hours_shutdown_timeoutwhile the harness is closing, leaving the new deadline in the future - THEN the close keeps waiting, bounded by the new deadline
Requirement: Graceful Shutdown
When a graceful close begins, the daemon SHALL mark the harness closing, record its deadline, and on each tick stop it (REQ "Gate Enforcement" steps 3–5) at the first of:
- Turn ended. The harness's turn state is available and reports the agent between turns: its latest turn has ended, and no trace event has arrived for the settle period (10 seconds).
- Quiet. A trace is attributed to the run but its reader reports no turn boundaries, and no trace event has arrived for the quiet period (2 minutes).
- Cap. The deadline has passed.
The deadline SHALL be the instant the harness went out of hours (the window's
end, or the lease's end) plus hours_shutdown_timeout, measured from that
instant and not from when the daemon noticed it. A host that wakes after the
deadline has passed therefore stops the harness on its first tick.
When no trace can be attributed to the run at all (a generic harness, a
harness with no workdir, or a session SPEC-0006 excludes as ambiguous), the
daemon SHALL stop the harness at once and log that graceful shutdown was
unavailable, with the reason.
The tick that begins a close SHALL also evaluate these conditions, so a host that wakes past the deadline, a turn that has already ended, or a run with nothing attributable stops on that first tick. An attributed run with no trace event in its run window yet counts as quiet.
The daemon SHALL cancel a close in progress, and leave the harness running, when hours open again, a lease starts (REQ "After-Hours Lease"), or an operator restarts the harness. A restarted harness is decided afresh by the next tick, which closes it again against the same boundary if it is still out of hours. A process that exits on its own while closing SHALL be held without a restart. The durable log SHALL record which of the conditions above ended each close.
A closing harness keeps receiving whatever its agent receives. A prompt that starts a new turn during a close SHALL NOT extend the deadline.
Scenario: Turn finishes after the close
- WHEN a graceful harness is mid-turn at 13:00, its turn state reports the turn ended at 13:04, and nothing arrives for the settle period
- THEN it is stopped at about 13:04:10,
enabledis still true, and the log says the close ended on a turn end
Scenario: Turn never finishes
- WHEN a graceful harness with
hours_shutdown_timeout = "15m"is still mid-turn at 13:15 - THEN it is stopped at 13:15 and the log records a forced stop at the cap
Scenario: No turn markers
- WHEN a graceful harness's attributed session has no turn boundaries and its last trace event was at 13:01
- THEN it is stopped at 13:03
Scenario: Generic harness
- WHEN a harness with no adapter and
hours_shutdown = "graceful"reaches its close - THEN it is stopped at once and the log says graceful shutdown was unavailable because nothing can be attributed to it
Scenario: Reopened during a close
- WHEN
operating_hours = "Mon-Fri 09:00-12:00; Mon-Fri 12:05-17:00"and a close that began at 12:00 is still waiting at 12:05 - THEN the close is cancelled and the harness keeps running
Scenario: A new prompt during a close
- WHEN a doorbell starts a new turn at 13:08 during a close that began at 13:00 with a 15-minute cap
- THEN the harness is stopped no later than 13:15
Scenario: A DST change inside a close window
- WHEN a close begins at 01:30 in a zone whose clocks fall back at 02:00 within its 15-minute cap
- THEN the deadline is 01:45 on the real timeline the close began on, and the harness stops by then rather than an hour later
Scenario: Hours reopen on a DST transition
- WHEN a close is in progress and a window opens at a wall-clock time that the transition repeats or skips
- THEN the close is cancelled on the first tick that evaluates in hours, and no window is counted twice or missed
Requirement: Turn State Signal
For graceful shutdown the daemon SHALL keep a live turn state for every running gated harness whose adapter has a trace reader: the time of the latest attributed trace event, whether the reader reports turn boundaries for this agent, and, when it does, whether the latest turn has ended. The state SHALL come from the daemon's own live watch of the harness's attributed sessions (SPEC-0006 REQ "Live Turn State"), not from the TUI, and SHALL follow SPEC-0006 REQ "Run Correlation": a session that correlation would exclude SHALL contribute no turn state.
Scenario: Resumed session
- WHEN a claude-code harness runs with
--continueand resumes a session that started before the current run - THEN that session's new events contribute turn state to the current run, as SPEC-0006 REQ "Run Correlation" credits a resumed session
Scenario: Ambiguous session
- WHEN two harnesses share a workdir and the same adapter
- THEN neither gets turn state from the shared session, and a graceful close of either falls back as REQ "Graceful Shutdown" specifies
Requirement: After-Hours Lease
A start control op (SPEC-0002) on a gated harness that is out of hours SHALL
create a lease and start the harness. The op SHALL accept an optional for
duration setting the lease length. The default SHALL be one hour, and a
non-positive or unparseable for SHALL be rejected. The CLI SHALL expose it as
harness start NAME --for DURATION. for on a harness that is ungated or in
hours SHALL be rejected with an error saying no lease applies.
The daemon SHALL persist the lease's absolute end time in state.json before
starting the harness, and SHALL restore it on boot. A crash between the write
and the start therefore leaves a bounded lease: boot starts the harness under
it if its recorded intent is enabled, and otherwise the lease expires unused.
It is never an unbounded run. A restored lease on a harness that is no longer
gated SHALL be dropped. The lease SHALL be judged on the same tick clock as the
hours (REQ "Gate Evaluation"): a lease ends at its end instant, and it is
discarded on the first tick that finds its hours open. While a lease is valid,
Gate Enforcement SHALL NOT hold the harness. When the lease ends, the next tick SHALL
enforce the gate as for a close. When hours open before the lease ends, the
daemon SHALL discard the lease, and the harness continues as an in-hours
harness.
A start on a harness that already holds a valid lease SHALL replace the lease
end with a new one computed from the current time and the given (or default)
for.
A stop control op on a gated harness SHALL keep its SPEC-0003 meaning in
every case, whether in hours, out of hours, under a lease, closing, or held: it
SHALL stop the harness at once if it is up, discard any lease, and clear
enabled. A stopped gated harness SHALL NOT be started by a later window until
an operator starts it.
Scenario: Late-night start
- WHEN an operator runs
harness start claude-srcat 20:00 outside its hours - THEN it starts,
enabledis true, and it is held at 21:00
Scenario: Longer lease
- WHEN the operator runs
harness start claude-src --for 3hat 20:00 - THEN it is held at 23:00
Scenario: Restart mid-lease
- WHEN the daemon restarts at 20:30 during a lease ending 21:00
- THEN on boot the harness starts and is held at 21:00, not later
Scenario: Stopping a leased harness
- WHEN the operator runs
harness stop claude-srcat 20:15 during a lease - THEN it stops, the lease is discarded,
enabledbecomes false, and the next window does not start it
Scenario: Stopping a held harness
- WHEN the operator runs
harness stop claude-srcat 20:00 while it is held - THEN
enabledbecomes false, and the next window does not start it
Scenario: Stopping a closing harness
- WHEN the operator runs
harness stop claude-srcduring a graceful close - THEN it stops at once without waiting for its turn state, and
enabledbecomes false
Scenario: Lease runs into hours
- WHEN a lease started at 08:30 would end at 09:30 and the window opens at 09:00
- THEN the lease is discarded at 09:00 and the harness runs until the window closes
Scenario: for on an ungated harness
- WHEN
harness start plain --for 2htargets a harness withoutoperating_hours - THEN the op fails with an error saying no lease applies, and the harness is not started
Requirement: Operating Hours Reload
operating_hours SHALL be applied on the first tick after a successful reload,
without waiting for the harness to restart. Changing it SHALL NOT bounce a
harness that remains in hours. Reconciliation SHALL follow SPEC-0008's
incremental model: an unchanged value keeps its state.
- Adding
operating_hoursto a harness that is out of hours SHALL hold it. - Removing
operating_hoursfrom a held harness SHALL start it (it isenabled), and SHALL discard any lease. - A lease SHALL survive a reload that changes but does not remove
operating_hours. - A newly introduced harness (📝 ADR-0014) that is
enabledand out of hours SHALL have its intent recorded as true and SHALL begin held.
Scenario: Adding hours in the evening
- WHEN at 20:00 a reload adds
operating_hours = "09:00-13:00"to a running enabled harness - THEN on the next tick the harness is held
Scenario: Removing hours
- WHEN a reload removes
operating_hoursfrom a held harness - THEN on the next tick it starts
Scenario: A profile switch targets a held harness
- WHEN
harness use-profilemakes a gated, held harness a member of the active profile while it is still out of hours - THEN its
enabledintent becomes true, and the gate keeps it held: a profile sets intent, and hours still decide whether the process exists
Scenario: A profile switch at the close
- WHEN
harness use-profileenables a gated harness that is already running in hours - THEN it keeps running and closes at the window's end as usual
Scenario: A new harness introduced out of hours
- WHEN a reload introduces an
enabledharness carryingoperating_hours = "09:00-13:00"at 20:00 - THEN 📝 ADR-0014 records its intent as true and it begins held rather than started
Requirement: Operating Hours Visibility
The harness projection returned by list and describe (SPEC-0002) SHALL
carry, for a gated harness:
operating_hours: the expression as configured;in_hours: whether the gate is open now;hours_next: the next open (out of hours) or close (in hours), RFC 3339, omitted when the expression covers the whole week;held: whether the harness is held;closing_until: the close deadline, RFC 3339, while a graceful close is in progress;hours_shutdown: the effective shutdown mode;lease_until: the lease end, RFC 3339, when a lease is valid.
The daemon SHALL emit harness_hours_changed { name, in_hours, hours_next }
when a gated harness's in_hours changes. It SHALL write a lifecycle line to
the harness's durable log (📝 ADR-0007) for each close start, hold, open, lease
start, and lease end, stating the reason and the next transition.
Every listing surface (harness list, describe, the TUI) SHALL show a held
harness as off-hours rather than stopped, in the not-failed styling used for
an armed scheduled harness (SPEC-0008 REQ "Schedule Visibility"). The NEXT
column SHALL show when it opens, so no new column is added. A gated harness
running in hours SHALL show when it closes, one under a lease SHALL show the
lease end, and a closing harness SHALL read closing with its deadline.
harness doctor SHALL warn when a gated harness has enabled = false (hours
will never start it), when an expression covers the whole week (it gates
nothing), and when a harness uses graceful shutdown but nothing can ever be
attributed to it (a generic adapter or no workdir), so every close would be
immediate.
Scenario: Listing a held harness
- WHEN
harness listruns at 20:00 withclaude-srcheld and a window opening Monday 09:00 - THEN its STATE reads
off-hoursand its NEXT shows the Monday 09:00 open
Scenario: Log explains the stop
- WHEN a harness is held at 13:00
- THEN its durable log gains a line saying it stopped for operating hours and when it next opens
Out Of Scope
Tracked in 📝 ADR-0019 Deferred:
- Project-scoped operating hours.
- A daemon-wide or per-profile default.
- Holidays and one-off closures.
- Token or cost budgets.
- Holding or replaying push events and doorbells that arrive while a harness is held or closing. That belongs to the sender (for example Switchboard endpoint presence), not the daemon.
- Turn detection without agent-trace (PTY heuristics).
Related Artifacts
Direct relationships declared in YAML frontmatter (per the SDD plugin's artifact-graph convention). Run /sdd:graph chain SPEC-0012 for the transitive view.