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
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue