ouroboros/tests/test_gate_round5_fixes.py
Ouroboros ff64a45c94 fix(custody): a task's own reviewer is not its delegation; host facts are card rows
A review slot's run registered under the reviewed task's id was treated as
that task's delegation: the loop-exit audit listed it as open delegated
execution (Done with warnings plus a standalone System bubble) and the
periodic orphan sweep cancelled the live reviewer once the task's durable
result read completed (issue #1006).

Custody kinds: `RunCustody.review_owned` (the durable `source` under the
review substrate, consolidated from the private recovery predicate) is now
read by the orphan/kill reconcile (a review-owned row is cancelled only
behind an owner cancellation, otherwise left live with a typed reason), the
terminal custody audit, the execution-evidence counters, the nanny hold, the
crash-recovery candidate lists and the pending-invocation recovery (a review
invocation is retained, never re-posted). SETTLED rows carry `source` and
`category`. Physical custody keeps seeing every run. The consumer matrix in
tests/test_custody_owner_kinds.py is the surface a new reader joins.

Card rows: a host fact about a task is a row of that task's card. The
producer stamps `card_row` (timeline | reviews) and `card_row_id` on the chat
row (persisted by log_chat, replayed by history, mirrored in ChatOutbound).
The custody audit becomes its own typed row (`custody_notice`, its own owed
delivery id; the outbox rebuild no longer appends a host line into the
assistant answer). The late acceptance settlement stamps a host-composed
`late_settlement` note on the panel projection and its row is placed in the
card's Reviews group, which prints the note verbatim. The browser attaches a
stamped row to the task's card record as one timeline item, live and on
replay, and falls back to the standalone System row only when no card record
exists. Single-body transports keep the joined host notice text.

Docs: ARCHITECTURE 01/03/06/11, DESIGN §4/§5, DEVELOPMENT 06/11 state both
rules; generated inventories regenerated.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
2026-09-17 01:35:18 +03:00

531 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""GATE ROUND-5 (v6.98.0 phase A) — owner-audit-driven micro-fix regression tests.
GR5-1 a FAILED ``/evolve start`` / ``toggle_evolution(True)`` restores the
CAPTURED prior ``evolution_owner_stopped`` value (both ingresses) —
the GR4-6 pre-mint clear must not outlive a start that never happened,
or the post-task promotion pipeline can re-arm evolution the owner
believes is off;
GR5-2 the timeout reaper reconciles the killed task's open DELEGATED runs
(same custody seam as the cancel kill path) and discloses what stayed
open on the reap outcome;
GR5-3 the fast already-settled and finalize-on-miss cancel paths run the
same open-run audit and thread ``unreconciled_runs`` into the
miss-lane delivery — a cancel over a dead task with live delegated
runs must not read as a clean completion;
GR5-4 cascade digest MEMBERSHIP comes from the durable tree merged with the
sweep outcomes — a watchdog replay after the children terminalized
(empty outcomes) still lists them;
GR5-5 project deletion cascades only the lineage ROOTS of the live set —
descendants fall with their trees (one cascade, one summary per
tree); an orphan child without a live root still gets its own;
GR5-6 nested registry strictness, cheap form: a present-but-non-dict
``pending`` / ``intents`` under a valid top-level dict refuses the
mutation exactly like top-level corruption (no overwrite), and the
read paths distinguish "file absent" (empty, silent) from
"unreadable/malformed" (loud typed disclosure, then empty).
"""
from __future__ import annotations
import json
import logging
import pathlib
import types
import pytest
from ouroboros import cancel_intents as ci
from ouroboros.task_results import (
STATUS_CANCELLED,
STATUS_COMPLETED,
STATUS_RUNNING,
load_task_result,
write_task_result,
)
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
class _CaptureQueue:
def __init__(self):
self.events = []
def put(self, evt):
self.events.append(evt)
def _event_rows(drive, log_name="events.jsonl"):
path = pathlib.Path(drive) / "logs" / log_name
if not path.exists():
return []
return [
json.loads(line)
for line in path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
@pytest.fixture()
def qenv(tmp_path, monkeypatch):
import supervisor.queue as q
from supervisor import task_lifecycle, workers
monkeypatch.setattr(q, "DRIVE_ROOT", tmp_path)
monkeypatch.setattr(q, "PENDING", [])
monkeypatch.setattr(q, "RUNNING", {}, raising=False)
monkeypatch.setattr(workers, "WORKERS", {}, raising=False)
monkeypatch.setattr(workers, "respawn_worker", lambda wid: None, raising=False)
monkeypatch.setattr(q, "persist_queue_snapshot", lambda reason="": None)
monkeypatch.setattr(task_lifecycle, "CANCELLED_ROOT_FENCES", {}, raising=False)
monkeypatch.setattr(task_lifecycle, "_ACTIVE_CASCADE_FENCES", {}, raising=False)
return types.SimpleNamespace(q=q, tl=task_lifecycle, workers=workers, drive=tmp_path)
def _patch_open_delegated_run(monkeypatch, task_id: str, run_id: str = "run-open"):
"""Custody rows say one delegated run is still open for ``task_id``."""
from ouroboros.delegate_custody import RunCustody
calls: list = []
monkeypatch.setattr(
"ouroboros.delegate_custody.reconcile_task_runs",
lambda root, tid, **kw: calls.append(str(tid)) or [],
)
# `state` is part of these projections' contract: the terminal audit shares
# one custody replay across them, so a double must accept the keyword. The
# row is a REAL RunCustody: the audit reads its owner kind (`review_owned`)
# as well as its ids, and a partial double answers for neither.
monkeypatch.setattr(
"ouroboros.delegate_custody.open_runs",
lambda root, state=None: [RunCustody(task_id=task_id, run_id=run_id)],
)
monkeypatch.setattr(
"ouroboros.delegate_custody.pending_invocations", lambda root, rows=None: [],
)
return calls
# --------------------------------------------------------------------------
# GR5-1 — a failed evolution start restores the captured owner-stop flag
# --------------------------------------------------------------------------
def _toggle_ctx(state, sent):
return types.SimpleNamespace(
load_state=state.load_state,
send_with_budget=lambda cid, text, **kw: sent.append(text),
)
def test_gr5_1_toggle_start_failure_restores_a_prior_owner_stop(tmp_path, monkeypatch):
"""Prior flag True + failed start → the flag is True again afterwards, so
``apply_pending_request`` keeps refusing the autonomous re-arm."""
import supervisor.state as state
from supervisor import events as events_mod
from supervisor import evolution_lifecycle as el
state.init(tmp_path)
state.update_state(lambda live: live.update(
owner_chat_id=7, evolution_owner_stopped=True,
))
monkeypatch.setattr(el, "evolution_block_reason", lambda: "")
monkeypatch.setattr(
el, "start_evolution_campaign",
lambda objective, source="": (_ for _ in ()).throw(OSError("campaign store io")),
)
sent: list = []
# An OWNER start reaches the mint (В12: the agent tool is refused outright
# while the flag stands — pinned in test_post_task_evolution).
events_mod._handle_toggle_evolution(
{"enabled": True, "objective": "x", "source": "owner_chat"}, _toggle_ctx(state, sent),
)
assert bool(state.load_state().get("evolution_owner_stopped")) is True, (
"GR5-1: a failed start must restore the captured owner-stop flag"
)
assert any("stayed OFF" in text for text in sent)
def test_gr5_1_toggle_start_failure_with_no_prior_stop_stays_false(tmp_path, monkeypatch):
"""Prior flag False + refused start ("Evolution stayed OFF" branch) → the
flag stays False: an unconditional True would invent an owner stop."""
import supervisor.state as state
from supervisor import events as events_mod
from supervisor import evolution_lifecycle as el
state.init(tmp_path)
state.update_state(lambda live: live.update(
owner_chat_id=7, evolution_owner_stopped=False,
))
monkeypatch.setattr(el, "evolution_block_reason", lambda: "")
monkeypatch.setattr(el, "start_evolution_campaign", lambda objective, source="": {})
sent: list = []
events_mod._handle_toggle_evolution({"enabled": True, "objective": "x"}, _toggle_ctx(state, sent))
assert bool(state.load_state().get("evolution_owner_stopped")) is False, (
"GR5-1: no owner stop preceded — the restore must not fabricate one"
)
assert any("stayed OFF" in text for text in sent)
def test_gr5_1_server_evolve_start_failure_restores_the_captured_flag():
"""The owner-chat `/evolve` ingress runs inside the bridge drain loop; the
capture → clear → (failure) restore ordering is pinned at the source (the
same style as the GR4-6 ordering pin)."""
src = (REPO_ROOT / "server.py").read_text(encoding="utf-8")
capture_at = src.index(
'_prior_owner_stop = bool(ctx.load_state().get("evolution_owner_stopped"))'
)
clear_at = src.index(
'_evo_update_state(lambda live: live.__setitem__("evolution_owner_stopped", False))'
)
fail_at = src.index('log.warning("Failed to start evolution campaign", exc_info=True)')
restore_at = src.index(
'_evo_update_state(lambda live, _v=_prior_owner_stop: live.__setitem__('
)
stayed_off_at = src.index("Evolution stayed OFF: campaign state could not be created.")
assert capture_at < clear_at < fail_at < restore_at < stayed_off_at, (
"GR5-1: /evolve start must capture the flag before the clear and restore "
"the CAPTURED value inside the failure branch"
)
# --------------------------------------------------------------------------
# GR5-2 — the timeout reaper reconciles delegated runs and discloses
# --------------------------------------------------------------------------
def test_gr5_2_reap_reconciles_delegated_runs_and_discloses(qenv, monkeypatch):
import ouroboros.task_results as task_results_mod
from supervisor import task_reaper, workers
queue = _CaptureQueue()
monkeypatch.setattr(workers, "get_event_q", lambda: queue, raising=False)
monkeypatch.setattr(task_reaper, "send_with_budget", lambda *a, **kw: None)
calls = _patch_open_delegated_run(monkeypatch, "rp5")
disclosure_writes: list = []
real_write = task_results_mod.write_task_result
def _counting_write(root, tid, status, **kwargs):
if str(tid) == "rp5" and (
"delegated_runs_unreconciled" in kwargs
or "delegate_terminal_reconciliation" in kwargs
):
disclosure_writes.append(dict(kwargs))
return real_write(root, tid, status, **kwargs)
monkeypatch.setattr(task_results_mod, "write_task_result", _counting_write)
task_reaper.reap_timed_out_task({
"worker_id": 1, "proc": None, "task_id": "rp5",
"task": {"id": "rp5", "chat_id": 3}, "task_type": "chat",
"terminal_reason": "idle_timeout", "attempt": 3, "owner_chat_id": 3,
"runtime_sec": 100.0, "will_retry": False, "retry_task_id": "",
})
assert calls == ["rp5"], (
"GR5-2: the reaper must call the delegate-custody reconcile seam"
)
row = load_task_result(qenv.drive, "rp5")
assert row["status"] == "failed"
assert row["delegated_runs_unreconciled"] == ["run-open"], (
"GR5-2: the reap outcome discloses the still-open runs"
)
# R2: the audit envelope rides the SAME single terminal write as the flat
# list, stamped with the reaper's own trigger.
envelope = row["delegate_terminal_reconciliation"]
assert envelope["trigger"] == "reaper_idle_timeout"
assert envelope["open_run_ids"] == ["run-open"]
(only_write,) = disclosure_writes
assert only_write["delegated_runs_unreconciled"] == ["run-open"]
assert only_write["delegate_terminal_reconciliation"]["trigger"] == "reaper_idle_timeout"
assert any(
r.get("type") == "delegated_runs_unreconciled" and r.get("task_id") == "rp5"
for r in _event_rows(qenv.drive)
), "the same typed event vocabulary as the cancel kill path"
sends = [e for e in queue.events if e.get("type") == "send_message"]
assert sends and "DELEGATED RUNS NOT RECONCILED" in sends[0]["text"]
assert "run-open" in sends[0]["text"]
# --------------------------------------------------------------------------
# GR5-3 — miss/fast cancel paths audit + disclose delegated runs
# --------------------------------------------------------------------------
def test_gr5_3_already_settled_fast_path_discloses_open_delegated_runs(qenv, monkeypatch):
"""A cancel of a task that already settled (leaving open delegated runs)
must not deliver a clean completion: the fast path audits custody and the
owner message carries the same disclosure as the kill path."""
from supervisor import workers
queue = _CaptureQueue()
monkeypatch.setattr(workers, "get_event_q", lambda: queue, raising=False)
_patch_open_delegated_run(monkeypatch, "fs5")
write_task_result(qenv.drive, "fs5", STATUS_RUNNING, chat_id=4, result="working")
ci.request_cancel(qenv.drive, "fs5", reason="stop")
# An earlier actor settled the task before this custody arrived.
write_task_result(qenv.drive, "fs5", STATUS_CANCELLED, chat_id=4, result="killed elsewhere")
assert qenv.tl.cancel_task_custody("fs5") == qenv.tl.CANCEL_ALREADY_SETTLED
sends = [e for e in queue.events if e.get("type") == "send_message"]
assert sends, "the settled answer is still delivered on the miss lane"
assert "DELEGATED RUNS NOT RECONCILED" in sends[0]["text"], (
"GR5-3: the fast already-settled path discloses the open delegated runs"
)
assert "run-open" in sends[0]["text"]
assert any(
r.get("type") == "delegated_runs_unreconciled" and r.get("task_id") == "fs5"
for r in _event_rows(qenv.drive)
)
def test_gr5_3_finalize_on_miss_discloses_open_delegated_runs(qenv, monkeypatch):
"""The finalize-on-miss lane (neither queued nor running) runs the same
audit and threads the disclosure into its delivery."""
from supervisor import workers
queue = _CaptureQueue()
monkeypatch.setattr(workers, "get_event_q", lambda: queue, raising=False)
monkeypatch.setattr(qenv.q, "_emit_cancel_task_done", lambda *a, **kw: None)
_patch_open_delegated_run(monkeypatch, "ml5")
write_task_result(qenv.drive, "ml5", STATUS_RUNNING, chat_id=5, result="was working")
ci.request_cancel(qenv.drive, "ml5", reason="stop")
assert qenv.tl.cancel_task_custody("ml5") == qenv.tl.CANCEL_CANCELLED
assert load_task_result(qenv.drive, "ml5")["status"] == STATUS_CANCELLED
sends = [e for e in queue.events if e.get("type") == "send_message"]
assert sends and "DELEGATED RUNS NOT RECONCILED" in sends[0]["text"], (
"GR5-3: the finalize-on-miss delivery carries the disclosure"
)
assert "run-open" in sends[0]["text"]
# --------------------------------------------------------------------------
# GR5-4 — replay-shape digest membership comes from the durable tree
# --------------------------------------------------------------------------
def test_gr5_4_replay_digest_lists_children_from_the_durable_tree(tmp_path, monkeypatch):
"""A watchdog replay after the children already terminalized arrives with
EMPTY sweep outcomes — the digest must still list every durable
descendant with its CURRENT status (GR4-4 rule per line)."""
from supervisor import terminal_delivery as td
from supervisor import workers
queue = _CaptureQueue()
monkeypatch.setattr(workers, "get_event_q", lambda: queue, raising=False)
write_task_result(tmp_path, "r5", STATUS_CANCELLED, chat_id=9, result="root down")
write_task_result(
tmp_path, "c5a", STATUS_CANCELLED, result="child down",
parent_task_id="r5", root_task_id="r5", delegation_role="subagent",
)
write_task_result(
tmp_path, "c5b", STATUS_COMPLETED, result="child done",
parent_task_id="c5a", root_task_id="r5", delegation_role="subagent",
)
owed = td.deliver_cascade_summary(tmp_path, "r5", {"id": "r5", "chat_id": 9}, {})
assert owed is True
(event,) = [e for e in queue.events if e.get("type") == "send_message"]
assert "2 descendant task(s) were settled with it" in event["text"]
outcomes = {
row["task_id"]: row["outcome"]
for row in load_task_result(tmp_path, "r5")["cancel_receipt"]["children"]
}
assert outcomes["c5a"] == "cancelled", (
"GR5-4: the replay digest enumerates the durable tree, not only the "
"(empty) sweep outcomes"
)
assert outcomes["c5b"] == "completed"
def test_gr5_4_sweep_outcome_still_wins_for_a_child_with_no_durable_row(tmp_path, monkeypatch):
from supervisor import terminal_delivery as td
from supervisor import workers
queue = _CaptureQueue()
monkeypatch.setattr(workers, "get_event_q", lambda: queue, raising=False)
write_task_result(tmp_path, "r5w", STATUS_CANCELLED, chat_id=9, result="root down")
owed = td.deliver_cascade_summary(
tmp_path, "r5w", {"id": "r5w", "chat_id": 9}, {"c5w": "failed"},
)
assert owed is True
(event,) = [e for e in queue.events if e.get("type") == "send_message"]
outcomes = {
row["task_id"]: row["outcome"]
for row in load_task_result(tmp_path, "r5w")["cancel_receipt"]["children"]
}
assert outcomes["c5w"] == "failed", (
"a child with no durable row yet keeps the sweep's honest outcome"
)
# --------------------------------------------------------------------------
# GR5-5 — project deletion cascades lineage roots only
# --------------------------------------------------------------------------
def test_gr5_5_live_project_ids_roots_only_filters_covered_descendants(tmp_path, monkeypatch):
import supervisor.queue as q
from supervisor import queue_transitions as qt
monkeypatch.setattr(q, "DRIVE_ROOT", tmp_path)
monkeypatch.setattr(q, "PENDING", [
{"id": "root-a", "root_task_id": "root-a", "project_id": "p5"},
{"id": "child-a", "parent_task_id": "root-a", "root_task_id": "root-a"},
# Orphan: its recorded root/parent is NOT live any more.
{"id": "orphan-b", "parent_task_id": "gone-root", "root_task_id": "gone-root",
"project_id": "p5"},
])
monkeypatch.setattr(q, "RUNNING", {
"grand-a": {"task": {
"id": "grand-a", "parent_task_id": "child-a", "root_task_id": "root-a",
}},
}, raising=False)
assert set(qt._live_project_task_ids(tmp_path, "p5")) == {
"root-a", "child-a", "grand-a", "orphan-b",
}
assert set(qt._live_project_task_ids(tmp_path, "p5", roots_only=True)) == {
"root-a", "orphan-b",
}, (
"GR5-5: descendants covered by a live root fall with its cascade; an "
"orphan without a live ancestor keeps its own"
)
def test_gr5_5_mid_tree_live_ancestor_covers_its_branch(tmp_path, monkeypatch):
"""The recorded root already settled, but a mid-tree parent is live: the
parent-chain walk must still cover the grandchild (one cascade for the
live branch, rooted at the mid-tree ancestor)."""
import supervisor.queue as q
from supervisor import queue_transitions as qt
monkeypatch.setattr(q, "DRIVE_ROOT", tmp_path)
monkeypatch.setattr(q, "PENDING", [
{"id": "mid", "parent_task_id": "settled-root", "root_task_id": "settled-root",
"project_id": "p5m"},
{"id": "leaf", "parent_task_id": "mid", "root_task_id": "settled-root"},
])
monkeypatch.setattr(q, "RUNNING", {}, raising=False)
assert set(qt._live_project_task_ids(tmp_path, "p5m", roots_only=True)) == {"mid"}
# --------------------------------------------------------------------------
# GR5-6 — nested registry strictness (cheap form)
# --------------------------------------------------------------------------
def test_gr5_6_nested_corrupt_pending_refuses_the_mutation(tmp_path):
"""A valid top-level dict whose ``pending`` is not an object used to be
coerced to {} — the next mutation overwrote every owed row silently. The
mutators now refuse it exactly like top-level corruption: no overwrite,
typed disclosure."""
from supervisor import terminal_delivery as td
store = tmp_path / "state" / "terminal_deliveries.json"
store.parent.mkdir(parents=True, exist_ok=True)
store.write_text(
json.dumps({"schema_version": 2, "delivered": ["final:old:aa"], "pending": [1, 2]}),
encoding="utf-8",
)
before = store.read_text(encoding="utf-8")
assert td.register_pending_delivery(tmp_path, {
"type": "send_message", "chat_id": 1, "task_id": "n6",
"text": "x", "delivery_id": "final:n6:abc",
}) is False
assert store.read_text(encoding="utf-8") == before, "no overwrite"
assert td.register_delivery(tmp_path, "final:n6:abc") is True, (
"register_delivery keeps its fail-open contract on a refused mutation"
)
assert store.read_text(encoding="utf-8") == before, "no overwrite"
corrupt_events = [
r for r in _event_rows(tmp_path)
if r.get("type") == "terminal_delivery_registry_corrupt"
]
assert len(corrupt_events) >= 2, "each refused mutation is a loud typed event"
def test_gr5_6_nested_corrupt_intents_refuses_the_mint(tmp_path):
path = tmp_path / "state" / "cancel_intents.json"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps({"schema_version": 1, "intents": ["not-a-dict"]}), encoding="utf-8",
)
before = path.read_text(encoding="utf-8")
with pytest.raises(ci.CancelIntentProjectionCorrupt):
ci.request_cancel(tmp_path, "n6i", reason="stop")
assert path.read_text(encoding="utf-8") == before, "no overwrite"
rows = _event_rows(tmp_path, log_name="supervisor.jsonl")
assert any(
r.get("event") == "projection_corrupt_refused" and r.get("task_id") == "n6i"
for r in rows
)
def test_gr5_6_corrupt_intent_projection_read_is_loud_and_empty(tmp_path, caplog):
path = tmp_path / "state" / "cancel_intents.json"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("[1, 2, 3]", encoding="utf-8")
with caplog.at_level(logging.ERROR, logger="ouroboros.cancel_intents"):
assert ci.active_intents(tmp_path) == {}
assert any(
"unreadable/malformed" in record.message for record in caplog.records
), "GR5-6: a malformed projection read is a loud log.error, never silent"
# The watchdog's enforcement read additionally emits the typed forensic row.
assert ci.active_intents(tmp_path, disclose_corruption=True) == {}
rows = _event_rows(tmp_path, log_name="supervisor.jsonl")
assert any(
r.get("event") == "projection_corrupt_refused" and r.get("op") == "active_intents"
for r in rows
), "enforcement degradation is owner-visible"
# Nested corruption under a valid top-level dict discloses the same way.
path.write_text(json.dumps({"intents": ["bad"]}), encoding="utf-8")
assert ci.active_intents(tmp_path) == {}
def test_gr5_6_corrupt_outbox_read_is_loud_and_empty(tmp_path, caplog):
from supervisor import terminal_delivery as td
store = tmp_path / "state" / "terminal_deliveries.json"
store.parent.mkdir(parents=True, exist_ok=True)
store.write_text("[1, 2, 3]", encoding="utf-8")
with caplog.at_level(logging.ERROR, logger="supervisor.terminal_delivery"):
assert td.pending_deliveries(tmp_path) == []
assert any(
"unreadable/malformed" in record.message for record in caplog.records
)
# The replay (watchdog) read discloses through the existing typed event.
assert td.replay_pending_deliveries(tmp_path, event_queue=_CaptureQueue()) == []
assert any(
r.get("type") == "terminal_delivery_registry_corrupt"
and r.get("op") == "pending_deliveries_read"
for r in _event_rows(tmp_path)
)
def test_gr5_6_absent_registries_read_empty_and_silent(tmp_path, caplog):
from supervisor import terminal_delivery as td
with caplog.at_level(logging.ERROR):
assert ci.active_intents(tmp_path) == {}
assert td.pending_deliveries(tmp_path) == []
assert not [r for r in caplog.records if r.levelno >= logging.ERROR], (
"GR5-6: an ABSENT file is an ordinary empty projection, not corruption"
)