controller v0.271.0: automatic app updates — the update leg after the off-site copy, the backup gate waits, the switch (09 6.4 part 7; R-680, R-678, R-643)
gates / gates (push) Successful in 24s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-24 22:07:24 +02:00
parent 1495ca43fb
commit 9cf13a3add
33 changed files with 1672 additions and 14 deletions
+46 -1
View File
@@ -79,6 +79,11 @@ type Options struct {
// Cadence is the agent's backup cadence, used only by the gate's safety valve (run regardless of
// the window once the last successful backup is older than Cadence+24h). Defaults to 24h.
Cadence time.Duration
// UpdateLegFn (v0.271.0, `09` §3 decision 20) reports whether the automatic update leg is running
// and whether one of its steps is in flight. A SCHEDULED cycle defers while the leg runs, until
// W+5h; after W+5h it waits only for a step already in flight, and never past W+5h30m. nil = no
// interlock (pre-v0.271.0 behaviour). Read with WindowStartFn; without a window it is not consulted.
UpdateLegFn func() (active, stepRunning bool)
}
// Loop is the quiesce background loop.
@@ -94,6 +99,7 @@ type Loop struct {
// windowStartFn (nil = gate disabled) + cadence drive the scheduled-cycle window gate (Part 3).
windowStartFn func() string
cadence time.Duration
updateLegFn func() (active, stepRunning bool)
// mu single-flights the quiesce cycle across the scheduled loop AND the manual trigger, so the
// two can never stop the same stacks concurrently (the persisted marker covers crash-safety across
// restarts; this covers concurrency within the process — which a manual trigger introduces).
@@ -152,7 +158,7 @@ func New(o Options) *Loop {
backend: o.Backend, stacks: o.Stacks, markerPath: o.MarkerPath,
poll: o.Poll, statusPoll: o.StatusPoll, maxQuiesce: o.MaxQuiesce,
logger: o.Logger, now: time.Now,
windowStartFn: o.WindowStartFn, cadence: o.Cadence,
windowStartFn: o.WindowStartFn, cadence: o.Cadence, updateLegFn: o.UpdateLegFn,
breaker: newFailureBreaker(),
contention: newContentionTracker(),
}
@@ -242,6 +248,13 @@ func (l *Loop) runOnce(ctx context.Context) error {
l.logger.Printf("[DEBUG] [quiesce] scheduled backup due but outside the backup window [%s–%s) — deferring to the next poll inside it", from, to)
return nil
}
// Decision 20 (v0.271.0): on an update night the full-system backup WAITS for the update leg,
// inside its own window — the leg starts no step at W+5h, so this backup keeps an hour.
// COMPANION RED-PROOF (REPORT.md): drop this block — TestD20_GateWaitsForTheLeg fails.
if wait, why := updateLegDefers(l.updateLegFn, l.now().In(budapestLocation()), window); wait {
l.logger.Printf("[INFO] [quiesce] full-system backup due and inside its window, but %s — deferring to the next poll (`09` decision 20)", why)
return nil
}
}
return l.quiesceAndPollTiers(ctx, dueTiers)
@@ -785,3 +798,35 @@ func (l *Loop) clearMarker() error {
}
return err
}
// updateLegDefers is decision 20's interlock, pure: the full-system backup waits while the automatic
// update leg runs and it is before W+5h; from W+5h it waits only for a step already in flight, and never
// at or past W+5h30m (a step is bounded by its own health timeouts; the cap keeps a stuck flag from
// eating the backup's hour). The offsets come from backupwindow, the constant the leg's own deadline
// reads, so the two stop at the same minute (TestLegDeadlineAndGateShareW5h).
func updateLegDefers(fn func() (bool, bool), now time.Time, windowStart string) (bool, string) {
if fn == nil {
return false, ""
}
active, stepRunning := fn()
if !active && !stepRunning {
return false, ""
}
startMin, err := backupwindow.ParseHHMM(windowStart)
if err != nil {
return false, "" // an unreadable window never blocks the backup
}
nowMin := now.Hour()*60 + now.Minute()
stopMin := backupwindow.UpdateLegStopOffsetMin
if active && within(nowMin, startMin, stopMin) {
return true, fmt.Sprintf("the automatic update leg is running (it starts no step after %s)", backupwindow.FmtHHMM(mod1440(startMin+stopMin)))
}
if stepRunning && within(nowMin, startMin, stopMin+legStepGraceMin) {
return true, fmt.Sprintf("an automatic update step started before %s is still running (waiting at most until %s)",
backupwindow.FmtHHMM(mod1440(startMin+stopMin)), backupwindow.FmtHHMM(mod1440(startMin+stopMin+legStepGraceMin)))
}
return false, ""
}
// legStepGraceMin bounds how long past W+5h the gate waits for a step already in flight.
const legStepGraceMin = 30