fix(t21.5): pending-class re-arm — residual nonterminal-return generator closed #331

Merged
conductor merged 2 commits from t21.5-pending-class-fix into master 2026-09-17 17:50:50 +01:00
3 changed files with 85 additions and 20 deletions

No files matched your search

+2 -2
View File
@@ -1,5 +1,5 @@
# This file was autogenerated by uv via the following command:
# uv pip compile pyproject.toml -o requirements-ci.txt --extra dev --extra memory --python-version 3.11
# uv pip compile pyproject.toml -o /tmp/regen-req.txt --extra dev --extra memory --python-version 3.11
aiohappyeyeballs==2.7.1
# via aiohttp
aiohttp==3.14.3
@@ -34,7 +34,7 @@ httpcore==1.0.9
# via httpx
httpx==0.28.1
# via rune (pyproject.toml)
idna==3.19
idna==3.20
# via
# anyio
# httpx
+33 -18
View File
@@ -134,6 +134,17 @@ OPERATION_SPECS = {
DEFAULT_OP_TYPE = "master_ci"
def normalize_verdict(verdict: str) -> str:
"""T21.5 §18 audit fix: pending-class verdicts are ALL nonterminal.
deploy_qualify emits "pending (probe failed)" for a transient probe
failure — that is pending, never a terminal answer. The wait loop
must not return control to the model over a string variant."""
v = (verdict or "").strip().lower()
if v.startswith("pending"):
return "pending"
return (verdict or "").strip()
def op_spec(op_type: str) -> dict:
spec = OPERATION_SPECS.get(op_type)
if spec is None:
@@ -290,9 +301,9 @@ def run(*, sha: str, deadline_secs: int,
"%Y-%m-%dT%H:%M:%SZ", time.gmtime())
resumed_at = event_at
# the event is a WAKE, never truth — canonical authority decides
verdict = authority_after_wake(op_type, sha)
verdict = normalize_verdict(authority_after_wake(op_type, sha))
if verdict == "pending" and not op_ci_alive(op_type, sha):
verdict = op_authority_verdict(op_type, sha)
verdict = normalize_verdict(op_authority_verdict(op_type, sha))
mode = ("event_replayed" if verdict != "pending"
else "authority_unresolved")
verdict_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
@@ -340,7 +351,7 @@ def run(*, sha: str, deadline_secs: int,
time.gmtime())
# WAKE received — canonical authority decides, never the
# event payload
verdict = authority_after_wake(op_type, sha)
verdict = normalize_verdict(authority_after_wake(op_type, sha))
if verdict != "pending":
mode = "event"
break
@@ -360,7 +371,7 @@ def run(*, sha: str, deadline_secs: int,
"%Y-%m-%dT%H:%M:%SZ", time.gmtime())
resumed_at = time.strftime("%Y-%m-%dT%H:%M:%SZ",
time.gmtime())
verdict = authority_after_wake(op_type, sha)
verdict = normalize_verdict(authority_after_wake(op_type, sha))
if verdict != "pending":
mode = "event_recovered_from_durable_record"
break
@@ -374,26 +385,30 @@ def run(*, sha: str, deadline_secs: int,
rearm_reason = ("watchdog expiry; op runs non-terminal "
"in Gitea — internal re-arm")
continue
# no live runs for THIS op's authority: terminal-in-Gitea but
# possibly aggregator-lagging — bounded re-read, then resolve
verdict = op_authority_verdict(op_type, sha)
if verdict == "pending":
# T21.4 cause C fixed: a pending verdict with no live runs
# may still be a lagging aggregator; one more bounded
# re-read before resolving truthfully. Still no control
# returned to the model — deadline governs.
verdict = op_authority_verdict(op_type, sha)
if verdict == "pending":
mode = "watchdog_lost_event_no_verdict"
else:
# no live runs visible for THIS op's authority: either the
# operation has not started yet (EXPECTED nonterminal — the
# §18-audit generator: this must re-arm, never return to the
# model) or it finished between probes. Bounded re-read.
verdict = normalize_verdict(op_authority_verdict(op_type, sha))
if verdict != "pending":
mode = "watchdog_authority_recovered"
break
break
verdict = normalize_verdict(op_authority_verdict(op_type, sha))
if verdict != "pending":
mode = "watchdog_authority_recovered"
break
# still pending: runs not started OR lagging aggregator —
# internal re-arm; the deadline is the only other exit
rearm_count += 1
rearm_reason = ("watchdog expiry; op pending (not started or "
"lagging) — internal re-arm")
continue
finally:
os.close(fifo_fd)
if verdict == "pending":
if mode == "event":
mode = "deadline_pending"
verdict = op_authority_verdict(op_type, sha) # final one-shot truth
verdict = normalize_verdict(op_authority_verdict(op_type, sha)) # final one-shot truth
verdict_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
out = {
"sha": sha,
+50
View File
@@ -134,6 +134,56 @@ def test_single_logical_wait_survives_nonterminal_wakes(wr_daemon, monkeypatch):
assert n >= 3 # authority re-read internally after each wake
def test_pending_probe_failed_is_nonterminal(wr_daemon, monkeypatch):
"""§18 audit fix: "pending (probe failed)" is a pending-class verdict —
the tool must re-arm internally, never return it as terminal."""
mod = _load()
assert mod.normalize_verdict("pending (probe failed)") == "pending"
assert mod.normalize_verdict("pending") == "pending"
assert mod.normalize_verdict("success") == "success"
# live: authority alternates probe-failure and pending, then success;
# ONE run() call absorbs all of it
rc, n = _single_wait_scenario(
wr_daemon, monkeypatch,
["pending (probe failed)", "pending", "success"])
assert rc == 0
assert n >= 3
def test_not_started_operation_rearms_internally(wr_daemon, monkeypatch):
"""§18 audit fix: runs not visible yet is EXPECTED nonterminal —
internal re-arm, no model return."""
mod = _load()
seq = {"n": 0}
def authority(op_type, sha):
seq["n"] += 1
return "pending" if seq["n"] <= 2 else "success"
monkeypatch.setattr(mod, "op_authority_verdict", authority)
monkeypatch.setattr(mod, "op_ci_alive", lambda t, sha: False)
def post_wakes():
for _ in range(3):
deadline = time.time() + 10
while time.time() < deadline:
if ew.operation_wait(OP) is not None:
wr_daemon.post_event({"operation_id": OP,
"provider": "gitea",
"event_type": "ci_completed",
"sha": SHA, "exit_code": 0,
"completed_at": time.time()})
break
time.sleep(0.05)
time.sleep(1.0)
threading.Thread(target=post_wakes, daemon=True).start()
rc = mod.run(sha=SHA, deadline_secs=30, watchdog_secs=3,
op_type="branch_ci", daemon_url=wr_daemon.url())
assert rc == 0
assert seq["n"] >= 3
def test_deadline_is_the_only_model_return_for_pending(wr_daemon,
monkeypatch):
"""Authority stays pending the whole time: run() returns only at real