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"