apply: a scheduled step is a container run on a cadence (ADR 0053)

The recurring twin of run-once, one modifier over: a container marked
schedule: "<cron>" is run to completion on its cadence, not started as a
service and not run once as a gate.

The gating rule is deliberately reversed. Installing a schedule records it
as present state and reports the node current at once (applySchedule) --
it never runs the container and does not gate what follows. A Scheduler,
held for the life of the daemon and re-established from each applied
declaration (the declaration is the source of truth, ADR 0018), fires the
container off an injected clock. A run that exits non-zero is logged and
never fails the apply or flips the node's state, because it happens
outside the apply and the store entirely. Runs never stack: a run still
going when the next is due is skipped, not started as a second copy.

No new host shape and no new action -- schedule is a string on the
container the host already has, and the host process runs the container
itself rather than installing a system timer (the rejected option 1). A
minimal five-field cron (declaration/cron.go) validates on arrival and
computes the next due minute; time is injected so the scheduler is tested
without the wall clock.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
2026-09-06 14:08:44 +02:00
parent 24e9ae4065
commit 9d1f001dcc
8 changed files with 1076 additions and 22 deletions
+29
View File
@@ -533,6 +533,18 @@ type Container struct {
// the digest of this declaration, so a re-apply does not re-run it unless the declaration
// changed.
RunOnce bool `json:"run-once,omitempty"`
// Schedule marks a container the host runs on a recurring cadence — a five-field cron
// expression (novox/hq ADR 0053). It is the recurring twin of RunOnce: the same container, run
// to completion, but again and again on the clock rather than once. Installing it does not run
// it — the schedule is state that is present, like a running service, so the apply is current as
// soon as it is recorded and does NOT gate what follows. The host's scheduler fires the
// container when the cron is due, re-established from this declaration each apply because the
// declaration is the source of truth (ADR 0018). A run that exits non-zero is recorded and never
// fails the apply or flips the node's state; a run still going when the next is due is skipped
// rather than stacked. It is exclusive with RunOnce and with restart-on: a container runs once
// and gates, runs on a cadence, or stays up — never two of these.
Schedule string `json:"schedule,omitempty"`
}
func (c *Container) Identity() string { return c.ID }
@@ -551,6 +563,23 @@ func (c *Container) validate(where string, _ bool) []string {
problems = append(problems, where+": a run-once container cannot also declare restart-on; "+
"it runs to completion rather than staying running to be restarted")
}
// A container runs once and gates, on a cadence, or stays up — never two of these
// (novox/hq ADR 0053). run-once and schedule are the two "runs to completion" lifecycles and
// contradict each other, and a scheduled step does not stay running to be brought back by
// restart-on either. Refused here on arrival, as the control plane refuses it near its author.
if c.RunOnce && c.Schedule != "" {
problems = append(problems, where+": a container is run-once or scheduled, not both; "+
"run-once runs once and gates what follows, a schedule runs it again on a cadence")
}
if c.Schedule != "" && len(c.RestartOn) > 0 {
problems = append(problems, where+": a scheduled container cannot also declare restart-on; "+
"it runs to completion on its cadence rather than staying running to be restarted")
}
if c.Schedule != "" {
if _, err := ParseCron(c.Schedule); err != nil {
problems = append(problems, where+": "+err.Error())
}
}
return append(problems, checkImage(where, c.Image)...)
}