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:
+29
-10
@@ -594,16 +594,24 @@ func runLink(ctx context.Context, opts options) error {
|
||||
|
||||
fmt.Printf("node %s, linking to %s\n", mine.Node, mine.Membership.Broker)
|
||||
|
||||
apply := func(ctx context.Context, raw, signature []byte) link.Report {
|
||||
return applyAndKeep(ctx, opts, raw, &store.Declared{Declaration: raw, Signature: signature})
|
||||
}
|
||||
say := func(line string) { fmt.Println(line) }
|
||||
|
||||
// One scheduler for the life of the process, re-established from each applied declaration
|
||||
// (novox/hq ADR 0053). It fires scheduled steps on their cadence, surviving across applies and
|
||||
// across the reconcile loop; a host restart rebuilds it from the declaration the node kept, the
|
||||
// first time either path applies. Its own loop is the thing on the clock — no system timer.
|
||||
sched := apply.NewScheduler(apply.SystemClock(), apply.ExecRunner, say)
|
||||
go sched.Run(ctx)
|
||||
|
||||
applier := func(ctx context.Context, raw, signature []byte) link.Report {
|
||||
return applyAndKeep(ctx, opts, raw, &store.Declared{Declaration: raw, Signature: signature}, sched)
|
||||
}
|
||||
|
||||
// Two things at once, and the second is what makes disconnection ordinary. The link brings
|
||||
// new declarations; this holds the machine in the last one whether the link is up or not. A
|
||||
// laptop shut for a week comes back and reconciles — it does not come back and ask what it is
|
||||
// (novox/hq ADR 0004).
|
||||
go holdTheMachine(ctx, opts, mine, say)
|
||||
go holdTheMachine(ctx, opts, mine, say, sched)
|
||||
|
||||
return link.HoldRoused(ctx, link.Membership{
|
||||
Node: mine.Node,
|
||||
@@ -611,7 +619,7 @@ func runLink(ctx context.Context, opts options) error {
|
||||
Fingerprint: mine.Membership.Fingerprint,
|
||||
Password: mine.Membership.Password,
|
||||
Signer: mine.Membership.Signer,
|
||||
}, apply, say, opts.timeout, rousedBySignal(ctx))
|
||||
}, applier, say, opts.timeout, rousedBySignal(ctx))
|
||||
}
|
||||
|
||||
// rousedBySignal is the machine telling this process that its link is probably stale.
|
||||
@@ -658,7 +666,8 @@ func rousedBySignal(ctx context.Context) link.Roused {
|
||||
// changed it, and then for ever.
|
||||
const ReconcileEvery = 5 * time.Minute
|
||||
|
||||
func holdTheMachine(ctx context.Context, opts options, mine identity.Identity, say link.Announce) {
|
||||
func holdTheMachine(ctx context.Context, opts options, mine identity.Identity, say link.Announce,
|
||||
sched *apply.Scheduler) {
|
||||
ticker := time.NewTicker(ReconcileEvery)
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -680,7 +689,7 @@ func holdTheMachine(ctx context.Context, opts options, mine identity.Identity, s
|
||||
continue
|
||||
}
|
||||
|
||||
report := applyDeclared(ctx, opts, declared)
|
||||
report := applyDeclared(ctx, opts, declared, sched)
|
||||
switch {
|
||||
case report.Refused != "":
|
||||
say("what this node was last told no longer applies: " + report.Refused)
|
||||
@@ -695,13 +704,14 @@ func holdTheMachine(ctx context.Context, opts options, mine identity.Identity, s
|
||||
// Signature checking happens before this is called, in the link. By the time anything here runs,
|
||||
// the question "is this from the mesh I joined" is settled — which is why this can treat the
|
||||
// bytes as instructions.
|
||||
func applyDeclared(ctx context.Context, opts options, raw []byte) link.Report {
|
||||
return applyAndKeep(ctx, opts, raw, nil)
|
||||
func applyDeclared(ctx context.Context, opts options, raw []byte, sched *apply.Scheduler) link.Report {
|
||||
return applyAndKeep(ctx, opts, raw, nil, sched)
|
||||
}
|
||||
|
||||
// applyAndKeep applies a declaration and, when it came from the mesh, keeps it so this node can
|
||||
// go on obeying it while disconnected.
|
||||
func applyAndKeep(ctx context.Context, opts options, raw []byte, signed *store.Declared) link.Report {
|
||||
func applyAndKeep(ctx context.Context, opts options, raw []byte, signed *store.Declared,
|
||||
sched *apply.Scheduler) link.Report {
|
||||
declared, err := declaration.Parse(raw)
|
||||
if err != nil {
|
||||
return link.Report{Refused: err.Error()}
|
||||
@@ -736,6 +746,15 @@ func applyAndKeep(ctx context.Context, opts options, raw []byte, signed *store.D
|
||||
saveErr.Error()}
|
||||
}
|
||||
|
||||
// Re-establish the scheduled steps from the declaration just applied (novox/hq ADR 0053). Done
|
||||
// from the parsed declaration, which is the source of truth (ADR 0018): a schedule newly declared
|
||||
// is armed, one whose image, environment or cadence changed is re-armed, and one no longer
|
||||
// declared is forgotten — and after a host restart the first apply rebuilds them all. A nil
|
||||
// scheduler is the one-shot CLI path, which exits rather than staying up to fire anything.
|
||||
if sched != nil {
|
||||
sched.Sync(declared)
|
||||
}
|
||||
|
||||
report := link.Report{Carried: carriedPorts(updated), Declared: digestOf(raw)}
|
||||
for _, change := range outcome.Outcomes {
|
||||
report.Applied = append(report.Applied, change.ID)
|
||||
|
||||
Reference in New Issue
Block a user