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

@ -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"}