Rename open to stalled and autopilot to auto-confirm; add pushed state; declare idempotency; propose maturity and projects model (RBT-WP-0008)
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:
parent
f39d5cec3c
commit
f5619e1a52
19 changed files with 429 additions and 105 deletions
66
rtut/run2.py
66
rtut/run2.py
|
|
@ -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"}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue