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

@ -196,25 +196,25 @@ def test_irreversible_step_needs_the_typed_phrase(tmp_path):
def test_preauthorization_only_for_irreversible_steps(tmp_path):
with pytest.raises(R.RunError, match="only irreversible"):
new(tmp_path, mode="autopilot", preauthorize=["light-lamp"])
new(tmp_path, mode="auto-confirm", preauthorize=["light-lamp"])
# ---- autopilot ------------------------------------------------------------------------------------------------
# ---- auto-confirm ------------------------------------------------------------------------------------------------
def drive(r, ticks=60, now=None, operator=None):
log = []
for _ in range(ticks):
out = R.autopilot_tick(r, now)
out = R.auto_confirm_tick(r, now)
log.append(out)
if out["action"] in ("finished", "stopped", "awaiting-operator", "waiting"):
break
if out["action"] == "started" and operator:
operator(out["item"]) # the operator runs the step's command while autopilot confirms
operator(out["item"]) # the operator runs the step's command while auto-confirm confirms
return log
def test_autopilot_confirms_when_preconditions_are_met_and_waits_for_the_operator_on_manual_only(tmp_path):
r = new(tmp_path, mode="autopilot")
def test_auto_confirm_confirms_when_preconditions_are_met_and_waits_for_the_operator_on_manual_only(tmp_path):
r = new(tmp_path, mode="auto-confirm")
def operator(sid):
cmd = R.command_for(r, sid)
@ -232,37 +232,37 @@ def test_autopilot_confirms_when_preconditions_are_met_and_waits_for_the_operato
R.start(r, "close-log", phrase=PHRASE) # the operator takes over the last step
subprocess.run(R.command_for(r, "close-log"), shell=True, check=True, capture_output=True)
R.verify(r, "close-log", confirm=True); R.complete(r, "close-log")
assert R.autopilot_tick(r)["action"] == "completed" # gate night-on-record
assert R.autopilot_tick(r)["action"] == "finished"
assert R.auto_confirm_tick(r)["action"] == "completed" # gate night-on-record
assert R.auto_confirm_tick(r)["action"] == "finished"
R1.finalize(r, "succeeded")
assert r.data["outcome"] == "succeeded" and not r.data["deviations"]
def test_autopilot_timeout_stop(tmp_path):
def test_auto_confirm_timeout_stop(tmp_path):
def m(d):
d["timeout"] = {"seconds": 30, "on_timeout": "stop"}
r = new(tmp_path, m, mode="autopilot")
r = new(tmp_path, m, mode="auto-confirm")
log = drive(r) # write-logbook's file check cannot pass: nobody ran its command
assert log[-1]["action"] == "waiting" and log[-1]["item"] == "write-logbook"
late = datetime.now(timezone.utc) + timedelta(seconds=31)
out = R.autopilot_tick(r, late)
out = R.auto_confirm_tick(r, late)
assert out["action"] == "stopped" and "timed out after 30s" in out["detail"]
assert R.autopilot_tick(r)["action"] == "stopped" # stays stopped
assert R.auto_confirm_tick(r)["action"] == "stopped" # stays stopped
assert r.data["deviations"] == [] # stopping is not a deviation
def test_autopilot_push_through_records_deviation_and_moves_on(tmp_path):
def test_auto_confirm_push_through_records_deviation_and_moves_on(tmp_path):
def m(d):
d["timeout"] = {"seconds": 30, "on_timeout": "push-through"}
r = new(tmp_path, m, mode="autopilot")
r = new(tmp_path, m, mode="auto-confirm")
drive(r)
late = datetime.now(timezone.utc) + timedelta(seconds=31)
out = R.autopilot_tick(r, late)
out = R.auto_confirm_tick(r, late)
assert out["action"] == "push-through" and out["item"] == "write-logbook"
assert r.data["steps"]["write-logbook"]["state"] == "done" and r.data["steps"]["write-logbook"]["pushed_through"]
assert r.data["steps"]["write-logbook"]["state"] == "pushed" # its own state: not done, not failed
dev = r.data["deviations"][0]
assert dev["kind"] == "push-through" and dev["mode"] == "autopilot" and dev["risk_acknowledged"] is False
nxt = R.autopilot_tick(r, late) # continues to the manual-only step
assert dev["kind"] == "push-through" and dev["mode"] == "auto-confirm" and dev["risk_acknowledged"] is False
nxt = R.auto_confirm_tick(r, late) # continues to the manual-only step
assert nxt["item"] == "close-log" and nxt["action"] == "awaiting-operator"
with pytest.raises(R1.RunError, match="deviations"):
for k in r.data["steps"]:
@ -270,37 +270,37 @@ def test_autopilot_push_through_records_deviation_and_moves_on(tmp_path):
R1.exercise_update(r)
def test_autopilot_gate_push_through_and_stop(tmp_path):
def test_auto_confirm_gate_push_through_and_stop(tmp_path):
def m(d):
d["stages"][0]["gates"][0]["checks"].append({"done_when": "always fails", "command": "false", "expect": "exit-0"})
d["stages"][0]["gates"][0]["timeout"] = {"seconds": 10, "on_timeout": "push-through"}
r = new(tmp_path, m, mode="autopilot")
r = new(tmp_path, m, mode="auto-confirm")
drive(r)
assert r.data["gates"]["lamp-check"]["state"] == "failed" # all checks evaluated, one failing
out = R.autopilot_tick(r, datetime.now(timezone.utc) + timedelta(seconds=11))
assert out["action"] == "push-through" and r.data["gates"]["lamp-check"]["state"] == "waived"
out = R.auto_confirm_tick(r, datetime.now(timezone.utc) + timedelta(seconds=11))
assert out["action"] == "push-through" and r.data["gates"]["lamp-check"]["state"] == "pushed" # not "waived": nobody approved it
assert r.data["deviations"][0]["item"] == "lamp-check" and r.data["deviations"][0]["kind"] == "push-through"
def test_autopilot_never_confirms_irreversible_steps_unless_preauthorized(tmp_path):
def test_auto_confirm_never_confirms_irreversible_steps_unless_preauthorized(tmp_path):
def m(d):
for st in d["stages"]:
for s in st.get("steps", []):
s.pop("autopilot", None) # allow automation of close-log
r = new(tmp_path, m, mode="autopilot")
s.pop("auto_confirm", None) # allow automation of close-log
r = new(tmp_path, m, mode="auto-confirm")
drive(r, operator=lambda sid: (subprocess.run(R.command_for(r, sid), shell=True, check=True, capture_output=True) if R.command_for(r, sid) else None))
last = R.autopilot_tick(r)
last = R.auto_confirm_tick(r)
assert last["action"] == "awaiting-operator" and "not pre-authorized" in last["detail"]
r2 = new(tmp_path, m, mode="autopilot", preauthorize=["close-log"])
r2 = new(tmp_path, m, mode="auto-confirm", preauthorize=["close-log"])
drive(r2, operator=lambda sid: (subprocess.run(R.command_for(r2, sid), shell=True, check=True, capture_output=True) if R.command_for(r2, sid) else None))
assert r2.data["steps"]["close-log"]["state"] in ("running", "done")
assert r2.data["steps"]["close-log"]["authorized_by"] == "tester"
def test_manual_mode_rejects_autopilot_and_autopilot_rejects_overrides(tmp_path):
with pytest.raises(R.RunError, match="autopilot run"):
R.autopilot_tick(new(tmp_path))
r = new(tmp_path, mode="autopilot")
def test_manual_mode_rejects_auto_confirm_and_auto_confirm_rejects_overrides(tmp_path):
with pytest.raises(R.RunError, match="auto-confirm run"):
R.auto_confirm_tick(new(tmp_path))
r = new(tmp_path, mode="auto-confirm")
with pytest.raises(R.RunError, match="manual mode"):
R.start(r, "warn", alt="sound-horn", override_reason="x", acknowledge_risk=True)
@ -314,3 +314,32 @@ def test_finished_v02_run_is_read_only_and_scores_count_it(tmp_path):
R.set_datapoint(r, "weather", "clear")
assert R1.scorecard(r.pack)["abandoned"] == 1
assert R1.load(r.pack, r.data["run_id"]).data["spec"] == "runbook-run/v0.2" # reloads through the shared loader
def test_pushed_items_are_a_distinct_state_that_lets_the_run_continue_but_never_succeed(tmp_path):
def m(d):
d["timeout"] = {"seconds": 5, "on_timeout": "push-through"}
light = next(s for st in d["stages"] for s in st.get("steps", []) if s["id"] == "light-lamp")
light["verify"] = {"done_when": "never true here", "command": "false", "expect": "exit-0"}
d["stages"][0]["gates"][0]["timeout"] = {"seconds": 5, "on_timeout": "push-through"} # the gate's own timeout wins
r = new(tmp_path, m, mode="auto-confirm")
late = datetime.now(timezone.utc) + timedelta(seconds=6)
seen = []
for _ in range(40):
out = R.auto_confirm_tick(r, late)
seen.append(out["action"])
if out["action"] in ("stopped", "awaiting-operator", "finished"):
break
late = late + timedelta(seconds=6)
assert r.data["steps"]["light-lamp"]["state"] == "pushed"
dp = r.data["datapoints"]["lamp_lit"]
assert dp["state"] == "pushed" and "value" not in dp and "error" not in dp # not set, not err
assert "lamp_lit" not in r.data["params"] # never usable as a value
assert R.is_complete(r, "light-lamp") # dependents may proceed
assert r.data["gates"]["lamp-check"]["state"] == "pushed" # datapoints_ok needs 'ok', so the gate was pushed too
kinds = [d["kind"] for d in r.data["deviations"]]
assert kinds.count("push-through") >= 2 and "override" not in kinds
with pytest.raises(R1.RunError, match="succeeds only when every step is done"):
R1.finalize(r, "succeeded")
R1.finalize(r, "failed", "pushed through to collect problems") # a clean-completion failure, finishable as failed
assert r.data["outcome"] == "failed"

View file

@ -54,12 +54,12 @@ def test_scorecard_counts_every_outcome_and_stale_open_runs(pk):
ok = R.create(pk, "a"); play_all(ok); R.finalize(ok, "succeeded"); R.add_feedback(ok, "good")
bad = R.create(pk, "b"); R.start(bad, "read-the-sign"); R.fail(bad, "read-the-sign", "x"); R.finalize(bad, "failed")
gone = R.create(pk, "c"); R.finalize(gone, "abandoned")
open_ = R.create(pk, "d")
stalled = R.create(pk, "d")
stale = R.create(pk, "e")
stale.data["updated"] = (datetime.now(timezone.utc) - timedelta(days=9)).isoformat()
stale.path.write_text(__import__("json").dumps(stale.data))
c = R.scorecard(pk)
assert (c["tried"], c["succeeded"], c["failed"], c["abandoned"], c["open"]) == (5, 1, 1, 2, 1)
assert (c["tried"], c["succeeded"], c["failed"], c["abandoned"], c["stalled"]) == (5, 1, 1, 2, 1)
assert c["feedback"]["good"] == 1

View file

@ -236,7 +236,7 @@ def test_score_runs_list_and_finalize_flow(srv):
base = loc.split("#")[0]
i = int(base.split("/")[2])
_, _, body = req(srv, "GET", f"/pack/{i}")
assert "tried 1 · succeeded 0" in body and "open 1" in body and "Runs" in body
assert "tried 1 · succeeded 0" in body and "stalled 1" in body and "Runs" in body
_, _, body = req(srv, "GET", base)
assert "Abandon run" in body and "Finish: succeeded" not in body # cannot succeed before every step is done
_finish_showcase(srv, t, base)

View file

@ -61,7 +61,7 @@ CASES = [
("gate without goal", lambda d: gate(d, "lamp-check").pop("goal"), "goal"),
("waiver must need a reason", lambda d: gate(d, "lamp-check").update(waivable={"by": "harbourmaster", "needs_reason": False}), "needs_reason"),
("bad timeout action", lambda d: gate(d, "lamp-check").update(timeout={"seconds": 5, "on_timeout": "explode"}), "on_timeout"),
("bad autopilot value", lambda d: step(d, "close-log").update(autopilot="always"), "autopilot"),
("bad auto_confirm value", lambda d: step(d, "close-log").update(auto_confirm="always"), "auto_confirm"),
]
@ -110,3 +110,23 @@ def test_legacy_v02_needs_only_schema():
def test_gate_cannot_confirm_a_datapoint_it_requires():
out = errs(lambda d: next(x for x in d["datapoints"] if x["id"] == "lamp_lit").update(confirmed_by="lamp-check"))
assert any("circular" in e for e in out), out
def test_idempotency_is_declared_validated_and_summarized():
from rtut import flow
assert P.load(FILE).ok
assert flow.pack_idempotency(GOOD) == "non-idempotent" # write-logbook appends
d = copy.deepcopy(GOOD)
for st in d["stages"]:
for s in st.get("steps", []):
s["idempotent"] = "idempotent"
s.pop("idempotent_note", None)
assert flow.pack_idempotency(d) == "idempotent" and v2.validate(d) == []
step(d, "close-log")["idempotent"] = "conditional"
assert any("idempotent_note" in e for e in v2.validate(d))
step(d, "close-log")["idempotent_note"] = "only while the log file exists"
assert v2.validate(d) == [] and flow.pack_idempotency(d) == "conditional"
step(d, "close-log").pop("idempotent")
assert flow.pack_idempotency(d) == "unknown" # one undeclared step makes the whole unknown
assert errs(lambda x: step(x, "warn").update(idempotent="sometimes"))
assert errs(lambda x: step(x, "warn").update(idempotent=True)) # a YAML "yes" is refused, not silently accepted