ouroboros/tests/test_acceptance_source_ack_truth.py
Ouroboros 5203d010bf Carry the fence answer typed through the loop: ok, refused or unknown
`_begin_task_acceptance_fence` / `_end_task_acceptance_fence` collapsed three
different facts - the supervisor said yes, said no, or never answered - into a
bool with a DEBUG line, so a stalled supervisor read downstream as a refusal and
left no trace in the task's Logs. Both now return a frozen `FenceOutcome`
(truthy iff ok; refused carries the reason, unknown the seconds waited), expose
it on the context for the call sites that still treat a failed begin as before,
and every refusal or gap leaves ONE durable worker-side row
`supervisor_ack_unavailable {task_id, root_task_id, op, outcome, reason,
waited_sec}`. A healthy path writes none.

A refused inspection drops the stale binding and begins afresh (the supervisor
re-adopts or reopens); an unanswered one keeps the token and its known
generation and stacks no second request on a silent supervisor. A failed end
drops the binding. `end` always carries the known generation.

Only `sealed` is a seal. A `released` answer to a terminal end with no known
generation mismatch may be the echo of a lost `released + generation_mismatch`
ack (the re-send then finds no row): owner mail is durably written before the
generation moves, so the local mailbox decides and the caller revises instead of
sealing blind (#406).

`loop_acceptance.py` pays its cap by reusing `_resolve_ctx_lineage` for two
duplicated lineage reads. Two tests pinned the bool return and are rewritten to
the typed truth.
2026-09-21 05:11:55 +03:00

418 lines
23 KiB
Python

"""The finalization control tells the truth and a host-owed round never parks.
The 2026-09-19 night (task 549a5405): a round-top re-capture of the acceptance
observation hit an inspect ``TimeoutError``, stored ``{}`` over the good
observation Main had been shown, the host called Main's VALID control "stale",
spent the single repair and then parked the repair round behind a panel that
never settled — three hours of silence and a lost answer. These tests pin the
cure from both directions: an unknown queue state is disclosed, never read as a
refusal; only known facts are compared and the queue's compare-and-seal keeps
the fail-closed authority; every refusal carries its typed cause in a durable
worker-side row; a never-shown selector is rendered; and a turn in which the
host has just spoken to Main never parks.
"""
from __future__ import annotations
import copy
import json
import queue
import pytest
from ouroboros import loop
from ouroboros.loop_acceptance import _begin_task_acceptance_fence, _end_task_acceptance_fence
from ouroboros.loop_acceptance_review import prepare_acceptance_observation
from ouroboros.loop_delivery import apply_delivery_subject_decision
from ouroboros.loop_messages import (
_record_owner_directive,
acceptance_observation_prompt,
acknowledge_acceptance_observation,
capture_acceptance_observation,
owner_source_sha256,
)
from ouroboros.owner_mailbox import write_owner_message, write_task_message
from tests.test_acceptance_async_loop import ANSWER, call, keep
from tests.test_acceptance_async_loop import full_loop as _full_loop
from tests.test_acceptance_semantic_subject import case as _semantic_case
from tests.test_delivery_control_lineage import _start_control_episode
from tests.test_delivery_forced_finalization import _forced_test_context
full_loop = _full_loop # noqa: F811 - pytest fixture re-export
case = _semantic_case # noqa: F811 - pytest fixture re-export
INSPECT_FAILURE = TimeoutError("supervisor did not acknowledge acceptance fence")
def _ack_rows(root):
path = root / "logs" / "events.jsonl"
if not path.exists():
return []
rows = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
return [row for row in rows if row.get("type") == "acceptance_source_ack"]
def _queue_fence(tool_ctx, trace, *, generation=0):
"""A queue-owned fence whose inspect can be switched to failing."""
state = {"owner_message_generation": generation, "fail": False}
def inspect(**_kwargs):
if state["fail"]:
raise INSPECT_FAILURE
return {"ok": True, "status": "active", "owner_message_generation": state["owner_message_generation"]}
tool_ctx._task_acceptance_fence_token = "queue-fence"
tool_ctx._task_acceptance_fence_generation = generation
tool_ctx.inspect_acceptance_fence = inspect
tool_ctx._execution_trace = trace
return state
# T1 -----------------------------------------------------------------------------
def test_unknown_queue_inspection_does_not_refuse_a_valid_source_ack(case):
tool_ctx, _tools, ctx, trace, _candidate, _run = case
state = _queue_fence(tool_ctx, trace)
tool_ctx._current_llm_call_meta = {"round": 7}
observed = capture_acceptance_observation(tool_ctx, trace, ctx.incoming_messages)
assert observed["queue_generation"] == 0
state["fail"] = True
ack = acknowledge_acceptance_observation(tool_ctx, observed["owner_source_sha256"])
assert ack and ack.ok and ack.cause == ""
assert ack.unknown == "queue_state_unknown"
assert tool_ctx._acceptance_ack_source_sha256 == observed["owner_source_sha256"]
# The fence generation advances only from a KNOWN value: it keeps begin's value, never None.
assert tool_ctx._task_acceptance_fence_generation == 0
assert trace["review_decision"]["admission_inspection"] == {
"status": "unknown", "reason": "queue_inspection_failed", "error_type": "TimeoutError",
}
row = _ack_rows(tool_ctx.drive_root)[-1]
assert row["task_id"] == "root" and row["round"] == 7 and row["ok"] is True and row["cause"] == ""
assert row["unknown"] == "queue_state_unknown" and row["error_type"] == "TimeoutError"
assert row["observed"]["queue_generation"] == 0 and row["current"]["queue_generation"] is None
assert row["observed"]["owner_source_sha256"] == observed["owner_source_sha256"]
assert row["pending_kinds"] == []
# T2 -----------------------------------------------------------------------------
def test_failed_recapture_keeps_the_last_good_observation(case):
"""Scenario S2 of the night: the park-time arm captured a good observation and
showed it to Main; the round-top re-capture fails on inspect; Main names the
sha it was shown; the supervisor may even be healthy again at ack time."""
tool_ctx, _tools, ctx, trace, _candidate, _run = case
state = _queue_fence(tool_ctx, trace)
shown = capture_acceptance_observation(tool_ctx, trace, ctx.incoming_messages)
shown_note = acceptance_observation_prompt(tool_ctx, shown)
assert shown["owner_source_sha256"] in shown_note
state["fail"] = True
again = capture_acceptance_observation(tool_ctx, trace, ctx.incoming_messages)
assert again["owner_source_sha256"] == shown["owner_source_sha256"], "a failed re-capture clobbered the good observation"
assert again["queue_generation"] is None and again["fence_token"] == "queue-fence"
assert again["admission_inspection"]["error_type"] == "TimeoutError"
note = acceptance_observation_prompt(tool_ctx, again)
assert "admission_inspection" not in note and "tool_count" not in note, "the unknown mark is never rendered"
assert shown["owner_source_sha256"] in note
state["fail"] = False # the supervisor is healthy again
ack = acknowledge_acceptance_observation(tool_ctx, shown["owner_source_sha256"])
assert ack and ack.unknown == "queue_state_unknown"
assert tool_ctx._task_acceptance_fence_generation == 0
row = _ack_rows(tool_ctx.drive_root)[-1]
assert row["ok"] is True and row["error_type"] == "TimeoutError"
assert row["observed"]["queue_generation"] is None and row["current"]["queue_generation"] == 0
def test_a_pending_owner_signal_still_yields_an_empty_observation(case):
"""The other direction of the capture change: unread owner input is still {}."""
tool_ctx, _tools, ctx, trace, _candidate, _run = case
assert write_owner_message(tool_ctx.drive_root, "Owner input.", "root", msg_id="unread")
assert capture_acceptance_observation(tool_ctx, trace, ctx.incoming_messages) == {}
# T3 -----------------------------------------------------------------------------
def test_unknown_at_capture_and_ack_still_fails_closed_at_the_end_seal(case):
tool_ctx, _tools, ctx, trace, _candidate, _run = case
queue_side = {"owner_message_generation": 0}
tool_ctx.begin_acceptance_fence = lambda **_kw: {"token": "final", "owner_message_generation": 0}
calls = []
def end(**kwargs):
calls.append(dict(kwargs))
expected = kwargs.get("expected_generation")
if expected is not None and int(expected) != queue_side["owner_message_generation"]:
return {"ok": True, "status": "released", "generation_mismatch": True}
return {"ok": True, "status": "sealed"} # a blind seal when no generation is named
tool_ctx.end_acceptance_fence = end
tool_ctx.inspect_acceptance_fence = lambda **_kw: (_ for _ in ()).throw(INSPECT_FAILURE)
tool_ctx._execution_trace = trace
opened, token = _begin_task_acceptance_fence(tool_ctx, "root")
assert opened and token == "final"
assert tool_ctx._task_acceptance_fence_generation == 0
observed = capture_acceptance_observation(tool_ctx, trace, ctx.incoming_messages)
assert observed["queue_generation"] is None
ack = acknowledge_acceptance_observation(tool_ctx, observed["owner_source_sha256"])
assert ack and ack.unknown == "queue_state_unknown"
assert tool_ctx._task_acceptance_fence_generation == 0, "an unknown read must not erase the known generation"
queue_side["owner_message_generation"] = 1 # a real owner message admitted by the supervisor
assert _end_task_acceptance_fence(tool_ctx, outcome="terminal")
assert calls[-1]["outcome"] == "terminal" and calls[-1]["expected_generation"] == 0
assert tool_ctx._task_acceptance_fence_generation_mismatch is True
assert tool_ctx._task_acceptance_sealed_fence_token is None
# T4 -----------------------------------------------------------------------------
def test_real_unread_owner_text_is_refused_with_its_typed_cause(case):
tool_ctx, tools, ctx, trace, candidate, _run = case
observed = capture_acceptance_observation(tool_ctx, trace, ctx.incoming_messages)
assert write_owner_message(tool_ctx.drive_root, "Actually add X.", "root", msg_id="owner-1")
ack = acknowledge_acceptance_observation(tool_ctx, observed["owner_source_sha256"])
assert not ack and ack.cause == "owner_input_unread" and ack.unknown == ""
assert ack.facts["pending_kinds"] == ["owner_text"]
row = _ack_rows(tool_ctx.drive_root)[-1]
assert row["ok"] is False and row["cause"] == "owner_input_unread" and row["pending_kinds"] == ["owner_text"]
ok, error = apply_delivery_subject_decision(tools, ctx, trace, {"owner_source_sha256": observed["owner_source_sha256"]})
assert not ok and error.startswith("owner_input_unread") and "owner_text" in error
assert "stale" not in error and "consume the current message" not in error
tool_ctx._delivery_control_required = True
candidate.finalization_control = "acceptance_feedback"
status, text = loop._resolve_delivery_control(json.dumps({
"delivery_control": "keep", "acceptance_subject": {"owner_source_sha256": observed["owner_source_sha256"]},
}), tools, ctx, trace)
assert (status, text) == ("retry", "")
repair = ctx.messages[-1]["content"]
assert "[DELIVERY_CONTROL_REPAIR]" in repair and "owner_input_unread" in repair and "owner_text" in repair
assert "Invalid finalization control" not in repair, "a valid control overtaken by owner input is not Main's error"
assert candidate.repair_attempted is False, "a host-caused refusal spends no repair"
assert candidate.finalization_control == "acceptance_feedback_repair_requested"
def test_a_stale_sha_after_a_rendered_selector_is_still_main_error(tmp_path):
"""The model-caused direction: the selector WAS shown and Main named another sha."""
loop_mod, registry, ctx, trace, candidate = _start_control_episode(tmp_path)
loop_mod._arm_delivery_control(registry, ctx, trace)
assert "[ACCEPTANCE_SUBJECT_OBSERVATION]" in str(ctx.messages)
status, text = loop_mod._resolve_delivery_control(json.dumps({
"delivery_control": "keep", "acceptance_subject": {"owner_source_sha256": "0" * 64},
}), registry, ctx, trace)
assert (status, text) == ("retry", "")
assert "Invalid finalization control: source_not_observed" in ctx.messages[-1]["content"]
assert candidate.repair_attempted is True
rows = _ack_rows(tmp_path)
assert rows[-1]["cause"] == "source_not_observed" and rows[-1]["ok"] is False
# T5 -----------------------------------------------------------------------------
def _advisory(monkeypatch):
monkeypatch.setenv("OUROBOROS_REVIEW_ENFORCEMENT", "advisory")
def _fence_with_inspect(f, failing):
def inspect(**_kwargs):
if failing["armed"]:
failing["count"] += 1
raise INSPECT_FAILURE
return {"ok": True, "status": "active", "owner_message_generation": 0}
f.ctx.begin_acceptance_fence = lambda **_kw: {"token": "fence-1", "owner_message_generation": 0}
f.ctx.inspect_acceptance_fence = inspect
f.ctx.end_acceptance_fence = lambda **kw: {
"ok": True, "status": "released" if kw["outcome"] == "revision" else "sealed",
}
def test_full_loop_unknown_inspect_at_round_start_delivers_a_valid_finish_without_repair_or_park(full_loop, monkeypatch):
"""The night, replayed: panel NOT settled, the quorum letter read, inspect fails
once at the round start, Main sends a valid replace+finish naming the sha it
was shown → delivered with author_finish + review_pending, no REPAIR, no park."""
f = full_loop
_advisory(monkeypatch)
f.ctx.owner_wait_callback = lambda *_a, **_kw: pytest.fail("a valid finish must not park behind the panel")
failing = {"armed": False, "count": 0}
_fence_with_inspect(f, failing)
original_prepare = loop.prepare_acceptance_observation
def prepare(ctx, *args, **kwargs):
failing["armed"] = f.model_step == 1 # only the round-top re-capture after the nomination round
try:
return original_prepare(ctx, *args, **kwargs)
finally:
failing["armed"] = False
monkeypatch.setattr(loop, "prepare_acceptance_observation", prepare)
rewritten = ANSWER + " Budget: $12."
def main(_llm, messages, *_a, **_kw):
f.model_inputs.append(copy.deepcopy(messages))
f.model_step += 1
if f.model_step == 1:
assert write_task_message(f.ctx.drive_root, "Acceptance review: 1 of 2 reviewer slot(s) have answered.",
f.ctx.task_id, source_task_id=f.ctx.task_id, provenance="system", msg_id="quorum")
return {"content": "", "tool_calls": [call("task_acceptance_review", {"claim": ANSWER}, "nominate")]}, 0.0
assert f.model_step == 2 and f.entered.wait(5) and not f.release.is_set(), f.progress
assert failing["count"] == 1, "the round-top re-capture did not hit the inspect failure"
assert "1 of 2 reviewer slot(s)" in str(messages)
observation = f.ctx._acceptance_observation
assert observation["owner_source_sha256"] in str(messages)
return {"content": json.dumps({"delivery_control": "replace", "full_answer": rewritten, "pending_review": "finish",
"acceptance_subject": {"owner_source_sha256": observation["owner_source_sha256"]}})}, 0.0
monkeypatch.setattr(loop, "call_llm_with_retry", main)
result, _usage, trace = f.run()
assert result == rewritten and f.model_step == 2 and f.waits == []
assert not any("[DELIVERY_CONTROL_REPAIR]" in str(inputs) for inputs in f.model_inputs)
assert trace["acceptance_decision"]["reason"] == "author_finish"
assert trace["review_decision"]["review_pending"] is True
# The durable worker-side row is the record; the in-trace stamp is rebuilt by the review path.
rows = _ack_rows(f.ctx.drive_root)
assert rows and rows[-1]["ok"] is True and rows[-1]["unknown"] == "queue_state_unknown"
assert rows[-1]["error_type"] == "TimeoutError" and "round" in rows[-1]
def test_full_loop_owner_message_during_the_call_is_refused_typed_and_delivered_next_round_without_a_park(full_loop, monkeypatch):
"""The mirror: a REAL owner message lands during Main's call. The valid control
is refused ``owner_input_unread`` (no repair spent), the message is delivered
next round, and nothing parks between the REPAIR and the repair round."""
f = full_loop
owner_text = "Also add a timeline section to the report."
rewritten = ANSWER + " Timeline: two weeks."
def main(_llm, messages, *_a, **_kw):
f.model_inputs.append(copy.deepcopy(messages))
f.model_step += 1
if f.model_step == 1:
return {"content": "", "tool_calls": [call("task_acceptance_review", {"claim": ANSWER}, "first-review")]}, 0.0
if f.model_step == 2:
assert f.entered.wait(5) and not f.release.is_set()
observation = f.ctx._acceptance_observation
assert write_owner_message(f.ctx.drive_root, owner_text, f.ctx.task_id, msg_id="late-owner")
return {"content": json.dumps({"delivery_control": "keep",
"acceptance_subject": {"owner_source_sha256": observation["owner_source_sha256"]}})}, 0.0
if f.model_step == 3:
assert f.waits == [], "the host-owed repair round was parked behind the panel"
repair = next(str(row.get("content") or "") for row in reversed(messages)
if "[DELIVERY_CONTROL_REPAIR]" in str(row.get("content") or ""))
assert "owner_input_unread" in repair and '"pending_kinds": ["owner_text"]' in repair
assert "Invalid finalization control" not in repair and "stale" not in repair
assert owner_text in str(messages), "the owner message must reach Main in the repair round"
assert f.ctx._delivery_candidate.repair_attempted is False
observation = f.ctx._acceptance_observation
assert observation["owner_source_sha256"] in str(messages[-1]), "the current selector is shown"
return {"content": json.dumps({"delivery_control": "replace", "full_answer": rewritten,
"acceptance_subject": {"owner_source_sha256": observation["owner_source_sha256"]}})}, 0.0
assert f.model_step < 7, f.progress
return keep(f), 0.0
monkeypatch.setattr(loop, "call_llm_with_retry", main)
result, _usage, trace = f.run()
assert result == rewritten
assert len(f.review_sends) == 2, ("the rewrite for the new premises got its own panel", f.progress)
assert [wait["reason"] for wait in f.waits] == ["review"], "only the second panel's own wait parks"
assert trace["acceptance_decision"]["status"] == "accepted"
refusals = [row for row in _ack_rows(f.ctx.drive_root) if row["ok"] is False]
assert refusals and refusals[0]["cause"] == "owner_input_unread" and refusals[0]["pending_kinds"] == ["owner_text"]
# T6 -----------------------------------------------------------------------------
def test_an_envelope_error_is_not_masked_by_the_owner_generation_notice(tmp_path):
loop_mod, registry, ctx, trace, candidate = _start_control_episode(tmp_path)
loop_mod._arm_delivery_control(registry, ctx, trace)
_record_owner_directive(registry._ctx, source="direct_incoming", content="Also cover Q3.", msg_id="followup")
assert loop_mod._task_acceptance_owner_generation_changed(registry._ctx)
status, text = loop_mod._resolve_delivery_control(json.dumps({
"delivery_control": "keep",
"acceptance_subject": {"owner_source_sha256": owner_source_sha256(registry._ctx)},
"effective_criteria": "misplaced at the top level",
}), registry, ctx, trace)
assert (status, text) == ("retry", "")
repair = ctx.messages[-1]["content"]
assert "control must be one exact JSON object" in repair
assert "owner input has not been acknowledged" not in repair
assert candidate.repair_attempted is True
# T7 -----------------------------------------------------------------------------
@pytest.mark.parametrize("shape", ["subagent", "review_off"])
def test_a_never_shown_selector_is_rendered_once_the_control_is_armed(tmp_path, monkeypatch, shape):
"""Finding A: a subagent has no task_acceptance_review tool, a root under review
mode ``off`` is not eligible either — yet both are armed with the control after a
principal letter changes the owner source. The observation row must be rendered
so a valid control can name it; no degraded_preserve."""
loop_mod, registry, ctx, trace = _forced_test_context(tmp_path)
tool_ctx = registry._ctx
if shape == "subagent":
tool_ctx.task_metadata.update({"root_task_id": "root0", "delegation_role": "subagent", "parent_task_id": "root0"})
tool_ctx.parent_task_id, tool_ctx.root_task_id, tool_ctx.delegation_role = "root0", "root0", "subagent"
schemas = []
else:
monkeypatch.setattr(loop, "get_task_review_mode", lambda: "off")
schemas = [{"function": {"name": "task_acceptance_review"}}]
_record_owner_directive(tool_ctx, source="initial_user", content="Do the child work.", msg_id="first")
ctx.messages[:] = [{"role": "user", "content": "Do the child work."}]
seen = set()
assert write_task_message(tmp_path, "Wrap up now.", "parent1", source_task_id="root0",
provenance="ancestor_task", msg_id="parent-1")
candidate = loop_mod._replace_delivery_candidate(registry, ctx, trace, "Child answer.", control="candidate")
loop_mod._arm_delivery_control(registry, ctx, trace) # the letter is unread: no selector row yet
assert "[ACCEPTANCE_SUBJECT_OBSERVATION]" not in str(ctx.messages)
before = owner_source_sha256(tool_ctx)
loop_mod._drain_incoming_messages(ctx.messages, queue.Queue(), tmp_path, "parent1", None, seen, owner_ctx=tool_ctx)
assert owner_source_sha256(tool_ctx) != before, "the principal letter entered the owner corpus"
prepare_acceptance_observation(tool_ctx, trace, queue.Queue(), ctx.messages, schemas)
rows = [row for row in ctx.messages if row.get("acceptance_observation")]
assert len(rows) == 1 and tool_ctx._acceptance_observation["owner_source_sha256"] in rows[0]["content"]
status, text = loop_mod._resolve_delivery_control(json.dumps({
"delivery_control": "keep",
"acceptance_subject": {"owner_source_sha256": tool_ctx._acceptance_observation["owner_source_sha256"]},
}), registry, ctx, trace)
assert (status, text) == ("resolved", "Child answer.")
assert candidate.finalization_control != "degraded_preserve" and not candidate.degraded
def test_an_unarmed_ineligible_turn_still_gets_no_selector(tmp_path, monkeypatch):
"""The gate's working direction: without an armed control, review mode ``off``
keeps the internal selector protocol out of an ordinary conversation."""
loop_mod, registry, ctx, trace = _forced_test_context(tmp_path)
monkeypatch.setattr(loop, "get_task_review_mode", lambda: "off")
_record_owner_directive(registry._ctx, source="initial_user", content="Chat.", msg_id="first")
loop_mod._replace_delivery_candidate(registry, ctx, trace, "An answer.", control="candidate")
assert not getattr(registry._ctx, "_delivery_control_required", False)
prepare_acceptance_observation(registry._ctx, trace, queue.Queue(), ctx.messages,
[{"function": {"name": "task_acceptance_review"}}])
assert not any(row.get("acceptance_observation") for row in ctx.messages)
# T8 -----------------------------------------------------------------------------
@pytest.mark.parametrize("spoke", ["append", "merge", "nothing"])
def test_a_turn_parks_only_when_the_host_appended_nothing(tmp_path, monkeypatch, spoke):
from ouroboros.transcript_prefix import observe_send
loop_mod, registry, ctx, trace = _forced_test_context(tmp_path)
ctx.llm_trace, ctx.tool_schemas, ctx.incoming_messages, ctx.owner_msg_seen = trace, [], queue.Queue(), set()
registry._ctx._task_acceptance_pending = "paid-binding"
observe_send(registry._ctx, ctx.messages, round_idx=1)
ctx.messages.append({"role": "user", "content": "an unsent tail"})
parked = []
monkeypatch.setattr(loop, "wait_for_acceptance_feedback", lambda *_a, **_k: parked.append(True))
def final(_content, limit_ctx, *_a, **_k):
if spoke == "append":
loop_mod._append_or_merge_user_message(limit_ctx.messages, "[DELIVERY_CONTROL_REPAIR] not applied.")
elif spoke == "merge":
loop_mod._append_or_merge_user_message(limit_ctx.messages, "merged into the unsent tail", slot=registry._ctx)
return None
monkeypatch.setattr(loop, "_no_tool_final_answer", final)
rows_before = len(ctx.messages)
assert loop_mod._finalize_loop_candidate("answer", ctx, registry, lambda *_a, **_k: None) is None
assert (len(ctx.messages) == rows_before) is (spoke != "append")
assert bool(parked) is (spoke == "nothing"), "park only when nothing was appended for the model in this pass"