6ebe95c13e
gates / gates (push) Successful in 41s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
112 lines
5.9 KiB
Python
112 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
|
"""unattended_caller_ladder.py — Part C step 2 (night 2026-09-24): the update leg as it would run, updated for the
|
|
LADDER (`09` §3 decisions 11/12/14/15/20). Adapted from audits/update-arc-gaps-2026-09-21/unattended-caller.py.
|
|
|
|
EVIDENCE, NOT PRODUCT. It presses exactly the guarded Update a person presses (POST /api/stacks/<n>/update) and
|
|
nothing else, against scratch guest 9202 only.
|
|
|
|
What changed from the 2026-09-21 caller:
|
|
- no "within a major" judgement: decision 13 made the LADDER the only authority — the box refuses or climbs;
|
|
- ONE APP AT A TIME, ONE STEP PER PRESS: an app is pressed again only after its previous step ended `done`,
|
|
until `ladder_steps_left` is 0 and the badge no longer says behind;
|
|
- a failed step (`undone`, `failed`, a hold) is NEVER pressed again tonight (decision 15);
|
|
- the refusal's REASON decides (R-609): transient → next pass; held/downgrade → never; memory/disk/no_backup →
|
|
for a human; unknown (incl. not_found/not_deployed/guards_unwired) → never.
|
|
Measures per step: wall-clock, phases, outcome; per app: the page's hold/badge in hu+en and the events logged.
|
|
|
|
Usage: python3 unattended_caller_ladder.py --apps wishlist,navidrome,romm,vikunja --passes 8
|
|
"""
|
|
import argparse, json, sys, time
|
|
sys.path.insert(0, ".")
|
|
import walk as w
|
|
|
|
TRANSIENT = {"busy", "updating", "deploying", "migrating", "self_updating"}
|
|
TERMINAL = {"held", "downgrade"}
|
|
FOR_A_HUMAN = {"memory", "disk", "no_backup"}
|
|
|
|
|
|
def behind(name):
|
|
st = w.stack(name)
|
|
left = st.get("ladder_steps_left") or 0
|
|
b = w.badges(name)
|
|
txt = (b.get("en") or [{}])[0].get("text", "") if b.get("en") else ""
|
|
return st, left, ("Update available" in txt) or left > 0, b
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--apps", required=True)
|
|
ap.add_argument("--passes", type=int, default=8)
|
|
ap.add_argument("--out", default="../C/30-night")
|
|
ap.add_argument("--every", type=int, default=60, help="seconds between passes")
|
|
a = ap.parse_args()
|
|
apps = a.apps.split(",")
|
|
w.login()
|
|
never, steps, t_night = {}, [], time.time()
|
|
for p in range(1, a.passes + 1):
|
|
w.say(f"== pass {p}")
|
|
w.sync_rescan()
|
|
pressed_any = False
|
|
for name in apps: # one app at a time
|
|
if name in never:
|
|
continue
|
|
while True: # one step per press, climb while each step ends done
|
|
st, left, is_behind, b = behind(name)
|
|
if not is_behind:
|
|
w.say(f" {name}: current (steps_left={left}) — nothing to press")
|
|
break
|
|
t0 = time.time()
|
|
r = w.press_update(name)
|
|
rec = {"pass": p, "app": name, "steps_left_before": left, "from": (st.get("app_config") or {}).get("pinned_images"),
|
|
"wall_s": round(time.time() - t0, 1), **r}
|
|
if not r.get("accepted"):
|
|
reason = ((r.get("refusal") or {}).get("data") or {}).get("reason", "?")
|
|
rec["reason"] = reason
|
|
steps.append(rec)
|
|
if reason in TRANSIENT:
|
|
w.say(f" {name}: refused ({reason}) — transient, next pass")
|
|
else:
|
|
never[name] = f"refused:{reason}"
|
|
w.say(f" {name}: refused ({reason}) — never again tonight")
|
|
break
|
|
pressed_any = True
|
|
# MEASURED 2026-09-24 (first run, C/30-night-run1.*): `ladder_steps_left` and the badge are STALE
|
|
# after a step ends `done` until the next scan — the first caller re-pressed a current app
|
|
# four times (each a full guarded update: dump, pull, restart). So: rescan before reading.
|
|
w.sync_rescan()
|
|
st2 = w.stack(name)
|
|
rec["to"] = (st2.get("app_config") or {}).get("pinned_images")
|
|
rec["steps_left_after"] = st2.get("ladder_steps_left")
|
|
steps.append(rec)
|
|
w.say(f" {name}: step {rec['final_phase']} in {rec['wall_s']} s, steps_left {left} -> {rec['steps_left_after']}")
|
|
if rec["final_phase"] == "done" and rec["to"] == rec["from"]:
|
|
never[name] = "noop"
|
|
w.say(f" {name}: the press changed nothing (already current) — stop")
|
|
break
|
|
if rec["final_phase"] != "done" or rec.get("hold_reason"):
|
|
never[name] = f"step:{rec['final_phase']}"
|
|
w.say(f" {name}: a failed step — never pressed again tonight")
|
|
break
|
|
if not pressed_any and all(n in never or not behind(n)[2] for n in apps):
|
|
w.say("all apps current or set aside — the leg ends")
|
|
break
|
|
time.sleep(a.every)
|
|
pages = {}
|
|
for name in apps:
|
|
st, left, is_b, b = behind(name)
|
|
pages[name] = {"state": st.get("state"), "steps_left": left, "badges": b, "hold_reason": st.get("hold_reason"),
|
|
"update_phase": st.get("update_phase"), "update_error": st.get("update_error"),
|
|
"pinned": (st.get("app_config") or {}).get("pinned_images")}
|
|
for lang in ("hu", "en"):
|
|
code, d = w.ctl("GET", f"/api/stacks/{name}?lang={lang}")
|
|
pages[name][f"update_error_{lang}"] = ((d or {}).get("data") or {}).get("update_error")
|
|
events = w.guest("docker logs --since 3h felhom-controller 2>&1 | grep -E 'event app_update_|DROPPED event app_update|dropped event app_update' | tail -30")
|
|
res = {"total_s": round(time.time() - t_night, 1), "steps": steps, "never": never, "pages": pages, "events": events}
|
|
w.say(f"night total {res['total_s']} s; never={never}")
|
|
json.dump(res, open(a.out + ".json", "w"), indent=2, ensure_ascii=False, default=str)
|
|
open(a.out + ".log", "w").write("\n".join(w.LOG) + "\n")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|