fix(t21.5): pending-class re-arm — residual nonterminal-return generator closed #331
No files matched your search
+2
-2
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user