Sunday auto-fix (gentle fixes only), reviewed; PROPRESENTER key label
Sundays 06:30-13:30, with ALARMS on, a fault that has lasted 20 s is fixed by itself if the fix is gentle (reconnects, HyperDeck off/on, Tascam, MIDI, rtpMIDI, starting a closed Vista/Resolume/OBS/Ableton/Translate, DVS on one Mac). At most 2 tries 90 s apart; key shows AUTO-FIXING. Anything that closes a running program stays a hold (load_config refuses it). A review before going live (2 lenses, each finding verified) confirmed and this fixes: a Dante-wide "gone" (Ingest's view) would restart DVS on every Mac every minute - several devices gone at once now means hands off; the try limit reset whenever a fix made its fault blink - tries are kept until the fault has been gone 10 min; DVS on Ingest kills ProdCom - removed from the auto list; restarting dead connections looked like a network burst - only a connection good in the last 30 s counts; a restart key could kill an app auto-fix was starting; concurrent MIDI reconnects overwrote each other - now one worker. Auto-fix errors are caught so the panel keeps running. 105 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+116
-6
@@ -580,6 +580,13 @@ class Tracker:
|
||||
DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
||||
|
||||
|
||||
def in_windows(windows, now):
|
||||
"""Is `now` inside one of [{"days": [...], "start": "HH:MM", "end": "HH:MM"}]?"""
|
||||
dt = datetime.fromtimestamp(now)
|
||||
hm, day = dt.strftime("%H:%M"), DAYS[dt.weekday()]
|
||||
return any(day in w["days"] and w["start"] <= hm < w["end"] for w in windows)
|
||||
|
||||
|
||||
class ServiceMode:
|
||||
"""On inside a scheduled window; a button press overrides until the next window edge."""
|
||||
|
||||
@@ -644,6 +651,7 @@ class Engine:
|
||||
self.group_of[m] = t["id"]
|
||||
self.detail_ids = [t["id"] for t in cfg["tiles"] if "checks" in t or t.get("kind") == "network"]
|
||||
self.prev_conn_bad = {}
|
||||
self.conn_good_at = {} # connection -> last time it was "good"
|
||||
self.conn_drops = deque() # (time, conn label, group)
|
||||
self.burst = None # {"start", "conns", "cleared"}
|
||||
self.events = []
|
||||
@@ -654,6 +662,10 @@ class Engine:
|
||||
self.notes = {} # tile id -> hold feedback {"text", "level", "until"}
|
||||
# The Restart page: one key per thing, hold 2 s = restart it even when it looks fine.
|
||||
self.restart_keys = {k["id"]: k for k in cfg.get("restart_page", {}).get("keys", [])}
|
||||
self.auto = cfg.get("auto_fix") or {}
|
||||
self.auto_tries = {} # fix identity -> [tries, last try time] for the fault now going on
|
||||
self.auto_seen = {} # fix identity -> last time it was in an auto plan
|
||||
self.auto_last = 0.0
|
||||
self.deck_ips = {} # HyperDeck connection -> last address Companion reported
|
||||
self.now = 0.0
|
||||
|
||||
@@ -700,7 +712,11 @@ class Engine:
|
||||
bad = bool(c.get("enabled")) and c.get("category") == "error"
|
||||
if bad:
|
||||
bad_now.add(label)
|
||||
if bad and not self.prev_conn_bad.get(label):
|
||||
if c.get("category") == "good":
|
||||
self.conn_good_at[label] = now
|
||||
# Only a connection that was fine moments ago counts as dropping: one that a fix just
|
||||
# restarted goes error -> starting -> error, and is not the network (2026-10-11 review).
|
||||
if bad and not self.prev_conn_bad.get(label) and now - self.conn_good_at.get(label, -1e9) <= 30:
|
||||
self.conn_drops.append((now, label, self.group_for_conn(label)))
|
||||
self.prev_conn_bad[label] = bad
|
||||
window = self.net.get("burst_window_seconds", 10)
|
||||
@@ -791,7 +807,7 @@ class Engine:
|
||||
tile = self.tiles.get(tile_id, {})
|
||||
return [c["conn"] for c in tile.get("checks", []) if c["type"] in RESTARTABLE and c.get("conn")]
|
||||
|
||||
def fix_plan(self, tile_id, snap):
|
||||
def fix_plan(self, tile_id, snap, now=None, min_age=0, errors_only=False):
|
||||
"""What a hold on this button would do: fix only what is failing right now.
|
||||
|
||||
[(identity, "fix", fix id, check) or (identity, "restart", connection label, check)]
|
||||
@@ -806,6 +822,8 @@ class Engine:
|
||||
tr = self.trackers.get(key)
|
||||
if tid not in ids or not tr or not tr.confirmed or tr.result.level not in (WARN, ERR):
|
||||
continue
|
||||
if errors_only and (tr.result.level != ERR or now - (tr.since or now) < min_age):
|
||||
continue # auto-fix: only real faults that have lasted
|
||||
# A connection switched off in Companion is fixed by switching it on - never by
|
||||
# acting on the device (2026-10-08: a disabled HyperDeck connection got a deck reboot).
|
||||
label = chk.get("conn")
|
||||
@@ -927,7 +945,11 @@ class Engine:
|
||||
log.info("HOLD %s: nothing failing, nothing to fix", tile_id)
|
||||
self.note(tile_id, "NOTHING\nTO FIX", "idle", now, 4)
|
||||
return plan
|
||||
started, names, waited = [], [], False
|
||||
return self.run_plan(tile_id, plan, snap, comp, now, cooldown)
|
||||
|
||||
def run_plan(self, tile_id, plan, snap, comp, now, cooldown=60, auto=False):
|
||||
"""Carry out fix_plan() items for one button (a hold, or auto-fix during service)."""
|
||||
started, names, waited, midi = [], [], False, []
|
||||
for ident, kind, what, chk in plan:
|
||||
if now - self.fix_last.get(ident, 0) < cooldown:
|
||||
log.info("HOLD %s: %s %s ran less than %ds ago, skipped", tile_id, kind, what, cooldown)
|
||||
@@ -954,15 +976,25 @@ class Engine:
|
||||
fix = self.cfg["fixes"][what]
|
||||
log.info("FIX %s: %s", tile_id, fix.get("label", what))
|
||||
names.append(fix.get("short", what.upper()))
|
||||
started.append((self.executor, (self.cfg, what, chk), fix.get("short", what.upper())))
|
||||
if fix.get("kind") == "midi_connect":
|
||||
midi.append((what, chk)) # one worker for all of them (see below)
|
||||
else:
|
||||
started.append((self.executor, (self.cfg, what, chk), fix.get("short", what.upper())))
|
||||
if midi:
|
||||
# One after another: two reconnects writing the same session at once overwrite each other.
|
||||
def midi_worker(batch):
|
||||
rs = [self.executor(self.cfg, f, c) for f, c in batch]
|
||||
return next((r for r in rs if isinstance(r, str)), False if False in rs else True)
|
||||
started.append((midi_worker, (midi,), self.cfg["fixes"][midi[0][0]].get("short", "MIDI")))
|
||||
if not names:
|
||||
self.note(tile_id, "JUST TRIED\nWAIT 1 MIN" if waited else "NOTHING\nTO FIX", "idle", now, 4)
|
||||
if not auto:
|
||||
self.note(tile_id, "JUST TRIED\nWAIT 1 MIN" if waited else "NOTHING\nTO FIX", "idle", now, 4)
|
||||
return plan
|
||||
summary = names[0] if len(set(names)) == 1 else "%d FIXES" % len(names)
|
||||
if not started: # only Companion reconnects: quick
|
||||
self.note(tile_id, "CHECKING\n" + summary, "fixing", now, 15)
|
||||
return plan
|
||||
self.note(tile_id, "FIXING\n" + summary, "fixing", now, 180)
|
||||
self.note(tile_id, ("AUTO-FIXING\n" if auto else "FIXING\n") + summary, "fixing", now, 180)
|
||||
results = []
|
||||
|
||||
group = "members" in self.tiles.get(tile_id, {}) or len(started) > 1
|
||||
@@ -987,6 +1019,68 @@ class Engine:
|
||||
threading.Thread(target=work, args=(fn, args, short), daemon=True).start()
|
||||
return plan
|
||||
|
||||
def auto_fix(self, snap, comp, now):
|
||||
"""During the auto-fix windows (Sunday service), fix by itself what a hold would fix - but
|
||||
only gentle fixes (reconnects, starting a closed app, DVS for a Mac that dropped), only for
|
||||
real faults that have lasted "after_seconds", at most "max_tries" times each, never while
|
||||
the network is having a blip, and never while ALARMS has muted the panel (maintenance).
|
||||
Restarting a running program stays a hold."""
|
||||
a = self.auto
|
||||
if not a or now - self.auto_last < 5:
|
||||
return []
|
||||
self.auto_last = now
|
||||
if not in_windows(a.get("windows", []), now) or not self.service.on(now):
|
||||
self.auto_tries.clear()
|
||||
self.auto_seen.clear()
|
||||
return []
|
||||
net_down = any(self.trackers.get("net:" + k) and self.trackers["net:" + k].confirmed
|
||||
and self.trackers["net:" + k].result.level == ERR for k in ("link", "gateway"))
|
||||
if net_down or (self.burst and (self.burst["cleared"] is None
|
||||
or now - self.burst["cleared"] < a.get("pause_after_network_seconds", 60))):
|
||||
return [] # everything looks broken at once: wait it out
|
||||
gentle = set(a.get("fixes", []))
|
||||
done = []
|
||||
for t in self.cfg["tiles"]:
|
||||
if "checks" not in t:
|
||||
continue # groups repeat their members
|
||||
gone = [k for k in self.keys_for(t["id"]) if self.meta.get(k) and self.meta[k][1]["type"] == "dante"
|
||||
and self.meta[k][2] != "conn" and self.trackers[k].confirmed and self.trackers[k].result.level == ERR]
|
||||
if len(gone) > 1:
|
||||
# Several Dante devices "gone" at once is NA Ingest's view of Dante or the Dante network,
|
||||
# not those Macs: restarting their DVS would cut audio that is working. Leave it to a hold.
|
||||
if self.auto_seen.get(("dante-wide", t["id"]), 0) < now - 600:
|
||||
log.info("AUTO %s: %d Dante devices gone at once - not touching them, hold DANTE if needed",
|
||||
t["id"], len(gone))
|
||||
self.auto_seen[("dante-wide", t["id"])] = now
|
||||
continue
|
||||
plan = []
|
||||
for ident, kind, what, chk in self.fix_plan(t["id"], snap, now, a.get("after_seconds", 20), True):
|
||||
if not ((kind == "fix" and what in gentle) or (kind == "restart" and a.get("restart_connections"))):
|
||||
continue # not gentle: stays a hold
|
||||
self.auto_seen[ident] = now
|
||||
tries, last = self.auto_tries.get(ident, (0, 0.0))
|
||||
if tries >= a.get("max_tries", 2):
|
||||
if tries == a.get("max_tries", 2):
|
||||
log.info("AUTO %s: gave up on %s %s after %d tries - hold it to try again", t["id"], kind, what, tries)
|
||||
self.auto_tries[ident] = (tries + 1, last)
|
||||
continue
|
||||
if now - last < a.get("retry_seconds", 90) or now - self.fix_last.get(ident, 0) < 60:
|
||||
continue
|
||||
self.auto_tries[ident] = (tries + 1, now)
|
||||
plan.append((ident, kind, what, chk))
|
||||
if plan:
|
||||
log.info("AUTO %s: fixing %s (try %d)", t["id"], ", ".join("%s %s" % (k, w) for _, k, w, _ in plan),
|
||||
max(self.auto_tries[i][0] for i, _, _, _ in plan))
|
||||
self.run_plan(t["id"], plan, snap, comp, now, auto=True)
|
||||
done.append(t["id"])
|
||||
forget = a.get("forget_seconds", 600)
|
||||
for ident in list(self.auto_tries):
|
||||
# Forget the tries only once the fault has been gone a long while: a fix makes its own
|
||||
# fault blink out for a moment (restarting, re-confirming), which must not reset the count.
|
||||
if now - self.auto_seen.get(ident, 0) >= forget:
|
||||
del self.auto_tries[ident]
|
||||
return done
|
||||
|
||||
def run_force(self, key_id, snap, comp, now, cooldown=60, fix_id=None, chk=None):
|
||||
"""Run one fix whatever the status says: a Restart page key, or a button's "hold_always".
|
||||
Shares the 60 s guard with holds, so a hold and a restart key never double up."""
|
||||
@@ -996,6 +1090,12 @@ class Engine:
|
||||
log.warning("FORCE %s: no such restart key", key_id)
|
||||
return None
|
||||
fix_id = key["fix"]
|
||||
busy = [w for w in key.get("watch", []) if not w.startswith("conn:")
|
||||
and now - self.fix_last.get(("fix", w, ""), 0) < cooldown]
|
||||
if busy: # e.g. auto-fix is starting Ableton right now
|
||||
log.info("FORCE %s: %s ran less than %ds ago, skipped", key_id, busy[0], cooldown)
|
||||
self.note(key_id, "STARTING\nWAIT 1 MIN", "idle", now, 4)
|
||||
return None
|
||||
per_deck = self.cfg["fixes"][fix_id].get("kind") == "hyperdeck" and chk and chk.get("conn")
|
||||
ident = ("fix", fix_id, chk["conn"] if per_deck else "") # the same guard a failing hold uses
|
||||
if now - self.fix_last.get(ident, 0) < cooldown:
|
||||
@@ -1728,6 +1828,11 @@ def load_config(path):
|
||||
raise ValueError("tile %s: unknown host %s" % (t["id"], c["host"]))
|
||||
if t.get("hold_always") and t["hold_always"] not in cfg.get("fixes", {}):
|
||||
raise ValueError("tile %s: unknown hold_always fix %s" % (t["id"], t["hold_always"]))
|
||||
for f in (cfg.get("auto_fix") or {}).get("fixes", []):
|
||||
if f not in cfg.get("fixes", {}):
|
||||
raise ValueError("auto_fix: unknown fix %s" % f)
|
||||
if re.search(r"pkill|taskkill|killall", cfg["fixes"][f].get("run", "")):
|
||||
raise ValueError("auto_fix: %s closes a running program - that stays a hold" % f)
|
||||
for k in cfg.get("restart_page", {}).get("keys", []):
|
||||
if k["id"] in ids:
|
||||
raise ValueError("restart key %s has a tile's id" % k["id"])
|
||||
@@ -1855,6 +1960,11 @@ def run(cfg, data_dir, dry_run=False, stop=None):
|
||||
if now - last_eval >= 1.0:
|
||||
last_eval = now
|
||||
tiles = engine.step(snap, now)
|
||||
if not dry_run:
|
||||
try:
|
||||
engine.auto_fix(snap, comp, now)
|
||||
except Exception: # a bug here must never take the panel down
|
||||
log.exception("auto-fix failed; the panel carries on without it this round")
|
||||
for kind, text in engine.events:
|
||||
log.info("%s %s", kind, text)
|
||||
engine.events = []
|
||||
|
||||
Reference in New Issue
Block a user