Rename open to stalled and autopilot to auto-confirm; add pushed state; declare idempotency; propose maturity and projects model (RBT-WP-0008)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Work Records / validate (push) Successful in 14s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 295952@bnt-lap001
Assistant-Session: e93f64ad-516c-46eb-9666-aad8d300c477
This commit is contained in:
tegwick 2026-09-29 12:19:58 +02:00
parent f39d5cec3c
commit f5619e1a52
19 changed files with 429 additions and 105 deletions

View file

@ -114,7 +114,7 @@ def main(argv: list[str] | None = None) -> int:
for p in _packs(a.root):
if p.ok and p.data.get("engine") == "native" and p.spec == "runbook-pack/v0.1":
c = R.scorecard(p)
print(f"{p.id}\ttried {c['tried']}\tsucceeded {c['succeeded']}\tfailed {c['failed']}\tabandoned {c['abandoned']}\topen {c['open']}\t[{p.data['exercise_status']}]")
print(f"{p.id}\ttried {c['tried']}\tsucceeded {c['succeeded']}\tfailed {c['failed']}\tabandoned {c['abandoned']}\tstalled {c['stalled']}\t[{p.data['exercise_status']}]")
return 0
if a.cmd in ("list", "validate"):
packs = _packs(a.root)

View file

@ -67,3 +67,17 @@ class Flow:
for a in self.ancestors(id_):
refs += self.stages[a].get("requires", [])
return [r for r in (self.resolve(x) for x in refs) if r]
def pack_idempotency(data: dict) -> str:
"""Derived characteristic of the whole runbook: idempotent, non-idempotent, conditional or unknown."""
fl = Flow(data)
vals = []
for s in fl.steps.values():
parts = s.get("one_of") or [s]
vals += [p.get("idempotent", s.get("idempotent", "unknown")) for p in parts]
if not vals or "unknown" in vals:
return "unknown"
if "non-idempotent" in vals:
return "non-idempotent"
return "conditional" if "conditional" in vals else "idempotent"

View file

@ -244,8 +244,6 @@ def exercise_update(run: Run, today: date | None = None) -> tuple[str, str]:
raise RunError("every step must be done (not skipped) to exercise the pack")
if run.data.get("deviations"):
raise RunError("a run with deviations (overrides, waivers, push-through) cannot exercise the pack")
if any(v.get("pushed_through") for v in run.data["steps"].values()):
raise RunError("a run with pushed-through steps cannot exercise the pack")
today = today or date.today()
old = run.pack.path.read_text()
new = STATUS_LINE.sub(f"exercise_status: exercised {today.isoformat()} by {run.data['operator']}",
@ -346,8 +344,9 @@ def finalize(run: Run, outcome: str, note: str = "") -> None:
raise RunError("a run succeeds only when every step is done (skipped steps do not count)")
if outcome == "succeeded" and any(g["state"] not in ("passed", "waived") for g in run.data.get("gates", {}).values()):
raise RunError("a run succeeds only when every gate has passed or been waived")
if outcome == "failed" and "failed" not in states:
raise RunError("a run can be finished as failed only when a step has failed; otherwise abandon it")
gstates = [g["state"] for g in run.data.get("gates", {}).values()]
if outcome == "failed" and not ({"failed", "pushed"} & set(states) or {"failed", "pushed"} & set(gstates)):
raise RunError("a run can be finished as failed only when a step or gate has failed or was pushed through; otherwise abandon it")
run.data["outcome"] = outcome
run.data["finished"] = _now()
if note.strip():
@ -356,17 +355,17 @@ def finalize(run: Run, outcome: str, note: str = "") -> None:
def effective_outcome(run: Run, now: datetime | None = None, stale_days: int = STALE_DAYS) -> str:
"""finished outcome, or 'open', or 'abandoned' for an open run untouched for stale_days."""
"""finished outcome, or 'stalled' (unfinished), or 'abandoned' for an unfinished run untouched for stale_days."""
if run.data.get("outcome"):
return run.data["outcome"]
now = now or datetime.now(timezone.utc)
last = datetime.fromisoformat(run.data.get("updated") or run.data["created"])
return "abandoned" if (now - last).days >= stale_days else "open"
return "abandoned" if (now - last).days >= stale_days else "stalled"
def scorecard(pack: Pack, now: datetime | None = None, stale_days: int = STALE_DAYS) -> dict:
"""How often a runbook was tried and how it went, derived from its run records."""
card = {"tried": 0, "succeeded": 0, "failed": 0, "abandoned": 0, "open": 0,
card = {"tried": 0, "succeeded": 0, "failed": 0, "abandoned": 0, "stalled": 0,
"feedback": {"good": 0, "neutral": 0, "poor": 0}}
for rid in list_runs(pack):
r = load(pack, rid)

View file

@ -1,7 +1,7 @@
"""Runs of runbook-pack/v0.2 packs (record runbook-run/v0.2).
Adds to the v0.1 run: datapoint states (nil/set/err/ok), a dependency graph with a recommended
next action, alternatives, QA gates with waivers, manual and autopilot modes with timeouts, and
next action, alternatives, QA gates with waivers, manual and auto-confirm modes with timeouts, and
a deviation record. Shared helpers (feedback, finalize, captures, score) live in run.py.
"""
from __future__ import annotations
@ -18,8 +18,10 @@ from .run import Run, RunError, _clean, _now, runs_dir # noqa: F401 (re-export
DEFAULT_PHRASE = "I understand this cannot be undone"
VERIFY_TIMEOUT = 60
DONE_STEP = ("done", "skipped")
DONE_GATE = ("passed", "waived")
# "pushed" is its own state: the item was passed over by auto-confirm push-through without being verified.
# It lets dependents proceed but is neither done nor an error, and it is tracked separately.
DONE_STEP = ("done", "skipped", "pushed")
DONE_GATE = ("passed", "waived", "pushed")
def flow(run: Run) -> Flow:
@ -85,8 +87,8 @@ def create(pack: Pack, operator: str, values: dict[str, str] | None = None, mode
preauthorize: list[str] | None = None) -> Run:
if not pack.ok or pack.data.get("engine") != "native" or pack.spec != "runbook-pack/v0.2":
raise RunError("only valid native runbook-pack/v0.2 packs can be run here")
if mode not in ("manual", "autopilot"):
raise RunError("mode must be manual or autopilot")
if mode not in ("manual", "auto-confirm"):
raise RunError("mode must be manual or auto-confirm")
operator = _clean(operator.strip(), "operator")
if not re.fullmatch(r"\S+", operator):
raise RunError("operator must be one word (name or handle)")
@ -102,7 +104,7 @@ def create(pack: Pack, operator: str, values: dict[str, str] | None = None, mode
"spec": "runbook-run/v0.2", "run_id": time.strftime("%Y%m%d-%H%M%S-") + uuid.uuid4().hex[:6],
"pack_id": pack.id, "pack_path": str(pack.path), "operator": operator, "created": _now(),
"mode": mode, "params": {}, "deviations": [],
"autopilot": {"preauthorized": pre, "authorized_by": operator if pre else None},
"auto_confirm": {"preauthorized": pre, "authorized_by": operator if pre else None},
"datapoints": {i: {"state": "nil"} for i in fl.datapoints},
"steps": {i: {"state": "todo"} for i in fl.steps},
"gates": {i: {"state": "open", "results": {}} for i in fl.gates},
@ -188,7 +190,7 @@ def unmet(run: Run, id_: str) -> list[str]:
if fl.kind(id_) == "step":
for dp in fl.steps[id_].get("uses", []):
st = run.data["datapoints"][dp]["state"]
if st not in ("set", "ok"):
if st not in ("set", "ok", "pushed"):
out.append(f"datapoint {dp} is {st}")
elif fl.kind(id_) == "gate":
stage = fl.parent[id_]
@ -266,9 +268,9 @@ def start(run: Run, step_id: str, alt: str | None = None, phrase: str | None = N
raise RunError(f"step {step_id} has no alternatives")
eff = effective_step(run.pack, fl, step_id, st.get("alternative"))
if not eff.get("reversible", True): # irreversible: the typed phrase is enforced here
pre = run.data["autopilot"]["preauthorized"]
if step_id in pre and run.data["mode"] == "autopilot":
st["authorized_by"] = run.data["autopilot"]["authorized_by"]
pre = run.data["auto_confirm"]["preauthorized"]
if step_id in pre and run.data["mode"] == "auto-confirm":
st["authorized_by"] = run.data["auto_confirm"]["authorized_by"]
elif (phrase or "") != phrase_for(run, step_id):
raise RunError(f"step {step_id} is irreversible: type the confirmation phrase exactly: "
f"{phrase_for(run, step_id)!r}")
@ -298,7 +300,10 @@ def _check_result(run: Run, ck: dict, confirm: bool, manual_ok: bool | None, aut
elif exp in ("exit-0", "output-contains"):
if not confirm:
raise RunError("running a check command needs operator confirmation")
cmd = render(ck["command"], run.data["params"], run.pack)
try:
cmd = render(ck["command"], run.data["params"], run.pack)
except (KeyError, ValueError) as e: # e.g. a datapoint that was pushed through has no value
return {"passed": False, "kind": exp, "parts": {"command": False, "error": str(e)}, "at": _now()}
t0 = time.monotonic()
try:
cp = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=VERIFY_TIMEOUT,
@ -428,7 +433,7 @@ def waive(run: Run, gate_id: str, reason: str) -> None:
run.save()
# ---- autopilot ----------------------------------------------------------------------------------------------------------
# ---- auto-confirm ----------------------------------------------------------------------------------------------------------
def _timeout_for(run: Run, id_: str) -> dict | None:
return flow(run).node(id_).get("timeout") or run.pack.data.get("timeout")
@ -439,18 +444,18 @@ def _elapsed(started: str, now: datetime) -> float:
def _stop(run: Run, item: str, why: str) -> dict:
run.data["autopilot"]["stopped"] = {"item": item, "reason": why, "at": _now()}
run.data["auto_confirm"]["stopped"] = {"item": item, "reason": why, "at": _now()}
run.save()
return {"action": "stopped", "item": item, "detail": why}
def autopilot_tick(run: Run, now: datetime | None = None) -> dict:
"""One autopilot decision. Confirmation is applied when preconditions are met; a timeout stops
def auto_confirm_tick(run: Run, now: datetime | None = None) -> dict:
"""One auto-confirm decision. Confirmation is applied when preconditions are met; a timeout stops
the run or pushes through, recording a deviation. The engine never runs a step's own command."""
_writable(run)
if run.data["mode"] != "autopilot":
raise RunError("autopilot_tick needs an autopilot run")
ap = run.data["autopilot"]
if run.data["mode"] != "auto-confirm":
raise RunError("auto_confirm_tick needs an auto-confirm run")
ap = run.data["auto_confirm"]
if ap.get("stopped"):
return {"action": "stopped", "item": ap["stopped"]["item"], "detail": ap["stopped"]["reason"]}
now = now or datetime.now(timezone.utc)
@ -463,8 +468,8 @@ def autopilot_tick(run: Run, now: datetime | None = None) -> dict:
first = na["blocked"][0]
return _stop(run, first["id"], "nothing can advance: " + "; ".join(first["unmet"]))
if fl.kind(item) == "step":
return _autopilot_step(run, item, now)
return _autopilot_gate(run, item, now)
return _auto_confirm_step(run, item, now)
return _auto_confirm_gate(run, item, now)
def _on_timeout(run: Run, item: str, cfg: dict, detail: str) -> dict:
@ -473,17 +478,17 @@ def _on_timeout(run: Run, item: str, cfg: dict, detail: str) -> dict:
return {"action": "push-through", "item": item, "detail": detail}
def _autopilot_step(run: Run, sid: str, now: datetime) -> dict:
def _auto_confirm_step(run: Run, sid: str, now: datetime) -> dict:
fl = flow(run)
s = fl.steps[sid]
st = run.data["steps"][sid]
if s.get("autopilot") == "manual-only":
if s.get("auto_confirm") == "manual-only":
return {"action": "awaiting-operator", "item": sid, "detail": "step is manual-only"}
if st["state"] == "failed":
return {"action": "awaiting-operator", "item": sid, "detail": "step failed; retry or stop"}
alt = st.get("alternative") or recommended_alt(run, sid)
eff = effective_step(run.pack, fl, sid, alt)
if not eff.get("reversible", True) and sid not in run.data["autopilot"]["preauthorized"]:
if not eff.get("reversible", True) and sid not in run.data["auto_confirm"]["preauthorized"]:
return {"action": "awaiting-operator", "item": sid, "detail": "irreversible and not pre-authorized"}
if s.get("one_of") and not alt:
return {"action": "awaiting-operator", "item": sid, "detail": "no alternative is recommended; the operator must choose"}
@ -503,19 +508,20 @@ def _autopilot_step(run: Run, sid: str, now: datetime) -> dict:
if cfg and _elapsed(st["started"], now) >= cfg["seconds"]:
out = _on_timeout(run, sid, cfg, "verification did not pass")
if out["action"] == "push-through":
st.update(state="done", finished=_now(), pushed_through=True)
st.update(state="pushed", finished=_now())
_deviate(run, "push-through", sid, ["verification did not pass"],
f"autopilot timeout after {cfg['seconds']}s", _risk_text(run, sid), False)
f"auto-confirm timeout after {cfg['seconds']}s", _risk_text(run, sid), False)
for dp in s.get("produces", []):
rec = run.data["datapoints"].get(dp)
if rec and rec["state"] == "nil":
rec.update(state="err", error="pushed through without verification", at=_now())
if rec and rec["state"] in ("nil", "err"):
rec.pop("error", None)
rec.update(state="pushed", how="push-through", at=_now())
run.save()
return out
return {"action": "waiting", "item": sid, "detail": "verification did not pass yet"}
def _autopilot_gate(run: Run, gid: str, now: datetime) -> dict:
def _auto_confirm_gate(run: Run, gid: str, now: datetime) -> dict:
g = flow(run).gates[gid]
gs = run.data["gates"][gid]
gs.setdefault("started", _now())
@ -531,9 +537,9 @@ def _autopilot_gate(run: Run, gid: str, now: datetime) -> dict:
out = _on_timeout(run, gid, cfg, "gate did not pass")
if out["action"] == "push-through":
failed = [f"check {i} not passed" for i, r in gs["results"].items() if not r["passed"]]
gs.update(state="waived", waiver={"by_actor": "autopilot", "who": run.data["operator"],
gs.update(state="pushed", pushed={"who": run.data["operator"],
"reason": f"push-through after {cfg['seconds']}s", "at": _now()})
_deviate(run, "push-through", gid, failed, f"autopilot timeout after {cfg['seconds']}s", _risk_text(run, gid), False)
_deviate(run, "push-through", gid, failed, f"auto-confirm timeout after {cfg['seconds']}s", _risk_text(run, gid), False)
run.save()
return out
return {"action": "waiting", "item": gid, "detail": "gate did not pass yet"}

View file

@ -47,7 +47,7 @@ SMILEYS = (("good", "🙂", "Went well"), ("neutral", "😐", "So-so"), ("poor",
def score_line(c: dict) -> str:
return (f"tried {c['tried']} · succeeded {c['succeeded']} · failed {c['failed']} · "
f"abandoned {c['abandoned']} · open {c['open']}")
f"abandoned {c['abandoned']} · stalled {c['stalled']}")
class App:
@ -147,7 +147,7 @@ class App:
btns.append("<button name=outcome value=abandoned onclick=\"return confirm('Abandon this run?')\">Abandon run</button>")
return (f"<div id=close class=step><h2>Close this run</h2>"
"<p class=note>Finishing records the outcome in the pack's score. A finished run is read-only. "
"Runs left open for a week count as abandoned.</p>"
"Unfinished runs count as stalled, and as abandoned after a week.</p>"
f"<form method=post action='/run/{i}/{e(rid)}/finalize'>{self.hidden()}"
"<p><input name=note class=grow style='width:100%' placeholder='comment (optional)'></p>"
f"<p class=row>{' '.join(btns)}</p></form></div>")

View file

@ -128,6 +128,9 @@ def _check_refs(m: _Model, data: dict) -> None:
for v in vals:
if v not in dps[k]["values"]:
m.errs.append(f"step {pid}: recommended_when {k}={v} not in values")
for part, pid in parts:
if part.get("idempotent", node.get("idempotent")) == "conditional" and not (part.get("idempotent_note") or node.get("idempotent_note")):
m.errs.append(f"step {pid}: idempotent: conditional needs an idempotent_note saying when")
if node.get("one_of") and node.get("command"):
m.errs.append(f"step {id_}: a step with one_of must not also have its own command")
elif kind == "gate":