From 086241ed5194071e5dfa65a072d75b4c5b37bd65 Mon Sep 17 00:00:00 2001 From: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:11:33 +0300 Subject: [PATCH] Plan review: standing findings after parse, terminal absence only, predecessor kept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1 (three independent reviewers, one batch) on the open-set verdict: - A seat's standing findings are judged once its parse result is final and only when its absence is terminal. An unparseable reply (prose, no findings array) is a non-answer that keeps the seat's earlier finding listed; a seat still awaiting the dispatch barrier, in flight or late-pending is a gap and carries nothing (06 §Plan construction and review: an answer that has not arrived is never a verdict). Before this, two clean seats plus one unparseable objector closed GREEN, and a barrier wave stamped "did not answer" on seats that were merely awaited. - A same-fingerprint re-dispatch (a changed reviewer_effort on an open review) replaces its predecessor in the hot index; the replacing wave now keeps that predecessor's exact artifact reference (previous_wave_artifact, also kept by compact_wave), and free collection resolves the predecessor through it, so the standing seam and the prior-cycle packet see the real predecessor instead of the in-flight wave itself. - A closed predecessor carries nothing (plan_standing_findings now reads closed, as its contract promised): a finding closed by a reasoned advisory reject is earned authority and is not resurrected on a later same-spec wave. - Notes are neutral for the only-awaited stamp: a quorum whose only findings are notes, held open by an awaited seat, is still a mere wait (D4). - Verdict text: the advisory NOTE states the cap fact like the blocking one; a $0 not-sent seat with a carried finding says "not sent; its earlier finding is still listed"; the Reviews card prints the weaker-order line on compact waves too and the carried explanation on every terminal branch; skipped rows carry the seat's effort facts; the trace meta labels the historical critic pair. - Docs: the closure sentence names the CONFIGURED enforcement against the hurry-projected advisory (06, 07); CHECKLISTS keys the changed-spec clause on the spec hash (D4 hunk, protected path: authority D4 / Q-v as before). Tests: two-sided pins for the unparseable objector, the awaiting-then-refused seat through the barrier and collection, the reject-closed predecessor, the changed-spec subcase from an open objection, the note-neutral wait, and the two card branches. Size: plan_review_runtime.py leaves the 1001-1500 band (1507, manifest regenerated); task_results.py 1597; review_presentation.js 1599. Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com> --- docs/CHECKLISTS.md | 4 +- docs/architecture/06-agent-core.md | 2 +- docs/architecture/07-configuration.md | 2 +- ouroboros/loop_tool_execution.py | 1 + ouroboros/owner_hurry.py | 12 ++-- ouroboros/size_ratchet_manifest.py | 1 - ouroboros/task_results.py | 6 ++ ouroboros/tools/plan_render.py | 4 +- ouroboros/tools/plan_review_artifacts.py | 10 ++- ouroboros/tools/plan_review_runtime.py | 13 +++- ouroboros/tools/plan_spec.py | 2 +- tests/test_awaited_review_not_degraded.py | 4 +- tests/test_plan_review_epoch.py | 88 ++++++++++++++++++++++- tests/test_reference_book_budgets.py | 6 +- web/modules/review_presentation.js | 26 +++---- web/tests/review_findings_detail.test.js | 45 ++++++++++++ 16 files changed, 188 insertions(+), 38 deletions(-) diff --git a/docs/CHECKLISTS.md b/docs/CHECKLISTS.md index d09c799d0..2420ca831 100644 --- a/docs/CHECKLISTS.md +++ b/docs/CHECKLISTS.md @@ -882,8 +882,8 @@ authority, and prose outside the array is not parsed. paid cycle. Under advisory enforcement a reject with its rationale also closes a below-quorum blocking finding (per finding; accept or defer keeps it open until a changed spec is reviewed). Under blocking enforcement a below-quorum blocking finding stays OPEN whatever the - disposition says: it closes only through a changed spec (a new fingerprint, the next paid - cycle) or the slot that raised it no longer raising it in a later paid cycle. A + disposition says: it closes only through a changed spec (a new spec hash reviewed in the next + paid cycle) or the slot that raised it no longer raising it in a later paid cycle. A REVIEW_REQUIRED wave whose open set empties is recorded GREEN. - **REVISE_PLAN** — blocking findings at quorum. A disposition can never close it: the agent either changes the spec (a new fingerprint, the next paid cycle) or rejects a blocking finding diff --git a/docs/architecture/06-agent-core.md b/docs/architecture/06-agent-core.md index 95e8e5efe..e0a441601 100644 --- a/docs/architecture/06-agent-core.md +++ b/docs/architecture/06-agent-core.md @@ -500,7 +500,7 @@ Collection is the existing `review_disposition` mode addressed at the recorded w An answer that has not arrived is a gap, never a failure or a verdict. The STORED wave is the fail-closed floor: an unanswered slot stays an `ok=False` row no quorum counts — on a same-spec cycle (equal `spec_hash`, whatever the roster, order or effort) it keeps the seat's still-open findings from the predecessor listed on the wave, stamped `carried_absent_answer` (`plan_spec.plan_standing_findings`), so silence never manufactures GREEN and casts no blocking-slot vote — and the open `DEGRADED` + `custody_pending` pair holds every gate with no fifth aggregate for older builds to refuse. Renderers read `plan_review_runtime.plan_wave_slot_census` before wording a slot: `awaiting` is `operation_state=pending_dispatch` only; `unresolved` is `in_flight`/`custody_lost`, named as unresolved on the line and by raw state in Reviews, never called waiting (`late_result_pending` is true for both); `uncollected` has a settled supplement not collected yet; `not_dispatched` is a $0 refusal, not a failure. Verdict and failure wording and the task's `degraded` stamp belong to collected slots only (the stamp keys on an open review, and a verdict-bearing wave is open only while its open set is non-empty): a finish over an only-awaited wave or panel keeps its disclosure and records `awaiting` (`execution.plan_review`, review axis; `owner_hurry.plan_wave_only_awaited`, `_outcome_receipts.review_runs_only_awaited`). `owner_hurry.force_plan_decision` makes ONE free collection of the current pending wave in every enforcement mode, hurry too, then projects the returned state (`plan_review_collect.collect_before_gate`). Advisory and hurry keep their local release; collection updates disclosed facts, never permission to proceed. Context health reads the canonical wave, never collecting: `PLAN REVIEW WAVE OPEN` names its fingerprint, pending-slot count and snapshot timestamp (not a physical start, not a liveness lease) and disappears once collection clears custody. -Closure follows the finding class over the wave's open set (`plan_spec.closure_after_disposition`, the ONE closure table, at initial synthesis and at later dispositions): GREEN — no blocking finding and no `need_evidence` without a disposition — closes immediately in either enforcement mode, so a note-only wave is GREEN; outstanding `need_evidence` closes through a disposition-only `plan_task` call (no model call, no cost: accept = answered, reject, defer = deferred openly; an answer reaches the reviewers on the next paid cycle, a revised envelope supersedes the wave with its open requests); a below-quorum blocking finding closes, per finding, under advisory enforcement by a reject with its rationale (accept or defer keep it open) and under blocking enforcement stays open until a changed spec is reviewed or the reviewer retires it in a later paid cycle; a REVIEW_REQUIRED wave whose open set empties is written GREEN on every write path with the closure note `closed_by_disposition`, while the four control validators keep accepting older closed REVIEW_REQUIRED rows; REVISE_PLAN is never closed by disposition — a subsequent paid delta review may evaluate a changed spec or a justified rejection when another cycle is available. A closed note-only wave still accepts voluntary `review_disposition` annotations through the same writer, spec/verdict and paid-cycle count fixed and the immutable predecessor retained: optional reasoning history, not a new plan gate, never another panel. +Closure follows the finding class over the wave's open set (`plan_spec.closure_after_disposition`, the ONE closure table, at initial synthesis and at later dispositions): GREEN — no blocking finding and no `need_evidence` without a disposition — closes immediately in either enforcement mode, so a note-only wave is GREEN; outstanding `need_evidence` closes through a disposition-only `plan_task` call (no model call, no cost: accept = answered, reject, defer = deferred openly; an answer reaches the reviewers on the next paid cycle, a revised envelope supersedes the wave with its open requests); a below-quorum blocking finding closes, per finding, under advisory enforcement by a reject with its rationale (accept or defer keep it open) and under blocking enforcement stays open until a changed spec is reviewed or the reviewer retires it in a later paid cycle (closure reads the CONFIGURED enforcement: an armed owner hurry projects advisory for the finalization gate only, never for closure); a REVIEW_REQUIRED wave whose open set empties is written GREEN on every write path with the closure note `closed_by_disposition`, while the four control validators keep accepting older closed REVIEW_REQUIRED rows; REVISE_PLAN is never closed by disposition — a subsequent paid delta review may evaluate a changed spec or a justified rejection when another cycle is available. A closed note-only wave still accepts voluntary `review_disposition` annotations through the same writer, spec/verdict and paid-cycle count fixed and the immutable predecessor retained: optional reasoning history, not a new plan gate, never another panel. Paid cycles are bounded by the shared `OUROBOROS_REVIEW_MAX_CYCLES`; `cycles_paid` and the typed `cycles_exhausted` state count PAID cycles only. A wave is paid iff at least one reviewer slot was physically dispatched, and the proof is a settled row: a slot released at the barrier is $0 until its row proves the send, so the cycle is paid at collection, never at the barrier, and a nothing-dispatched wave of typed $0 skip rows stays unpaid and never replaces a paid predecessor. A barrier-dispatched panel is nevertheless committed money: while its wave is custody-pending and the cap has no room for another panel, a revised envelope first collects EVERY other custody-pending wave at $0 (`plan_review_collect.collect_before_supersede`) and, if the cap still has no room, is HELD with the typed `PLAN_REVIEW_IN_FLIGHT` refusal (`plan_review_collect.in_flight_hold`) so the current wave stays collectible (the per-case cap arithmetic: `plan_review_collect.py` docstrings). An identical envelope replays the recorded wave free, with one exception: an open wave whose blocking findings all carry valid reject dispositions may dispatch exactly one subsequent paid delta panel when another cycle is available. diff --git a/docs/architecture/07-configuration.md b/docs/architecture/07-configuration.md index 1f55f78e2..a0b32024c 100644 --- a/docs/architecture/07-configuration.md +++ b/docs/architecture/07-configuration.md @@ -156,7 +156,7 @@ A registry of `config.SETTINGS_DEFAULTS` (exact defaults canonical in `settings_ | OUROBOROS_OBSERVABILITY_RETENTION_DAYS | (retired) | Retired (`RETIRED_SETTING_KEYS`): rows are kept indefinitely and the reader never deletes, so the knob had no reader; stored value stripped at load, env inert | | OUROBOROS_REVIEW_MODEL_TIMEOUT_SEC | (unset) | Env-only: logical review timeout (absent = route-owned; late in-flight results stay in custody) | | OUROBOROS_REVIEW_MAX_TOKENS | 65536 | Env-only: reviewer output budget, clamped to the 8192 floor | -| OUROBOROS_REVIEW_ENFORCEMENT | advisory | Review enforcement: advisory/blocking (closed enum; anything else coerces to the default) | +| OUROBOROS_REVIEW_ENFORCEMENT | advisory | Review enforcement: advisory/blocking (closed enum; anything else coerces to the default); plan-review closure of a below-quorum blocking finding by a reasoned reject reads this configured value, never the hurry-projected advisory the finalization gate uses | | OUROBOROS_PREFLIGHT_TIMEOUT_SEC | 1800 | Env-only: TOTAL wall-clock budget for the hermetic test preflight (node lane + both passes); Android entry defaults to 3600, explicit override wins; teardown/containment remain in `preflight_runner.py`/`process_containment.py` | | OUROBOROS_PREFLIGHT_SERIAL | unset | Env-only: `1` selects one serial pytest pass; scrubbed from the candidate environment | | OUROBOROS_PREFLIGHT_TEST_WORKERS | (unset) | Env-only: xdist workers for the hermetic parallel pass (floor 2, else `os.cpu_count()`); read from the OPERATOR environment, scrubbed from the candidate | diff --git a/ouroboros/loop_tool_execution.py b/ouroboros/loop_tool_execution.py index e0a8ee7cb..feb615962 100644 --- a/ouroboros/loop_tool_execution.py +++ b/ouroboros/loop_tool_execution.py @@ -517,6 +517,7 @@ def _typed_result_metadata( ): meta["plan_review_outcome"] = plan_outcome meta["plan_review_closed"] = plan_closed + meta["plan_review_historical_critic"] = bool(tool_result.meta.get("plan_review_historical_critic")) if isinstance(tool_result, ToolResult) and tool_result.meta.get("post_commit_tests") == "failed": # A preserved commit whose post-commit tests failed is a SUCCESS that # still holds a failure the reflection triggers must see. The producer diff --git a/ouroboros/owner_hurry.py b/ouroboros/owner_hurry.py index e711fb9a2..9514d0c6b 100644 --- a/ouroboros/owner_hurry.py +++ b/ouroboros/owner_hurry.py @@ -464,9 +464,9 @@ def plan_wave_only_awaited(wave: Any) -> bool: (the census names no unresolved, uncollected, refused or failed slot), and the recorded answers hold no verdict of their own beneath the stored placeholder. A collected blocking or ``need_evidence`` finding keeps the wave open whatever the - awaited slots answer, and a quorum of answers that raised findings IS a critic - verdict the wait merely postpones — neither reads as a mere wait. A roster or a - quorum the typed facts cannot vouch for is never a mere wait either.""" + awaited slots answer, so it never reads as a mere wait; notes are neutral (they + never move the verdict), so a quorum whose only findings are notes is still a mere + wait. A roster or a quorum the typed facts cannot vouch for is never a mere wait.""" from ouroboros.tools.plan_review_runtime import plan_wave_slot_census if not isinstance(wave, dict) or not wave.get("custody_pending"): @@ -480,10 +480,8 @@ def plan_wave_only_awaited(wave: Any) -> bool: return False counts = wave.get("counts") if isinstance(wave.get("counts"), dict) else {} quorum = counts.get("quorum") - if (type(quorum) is not int or quorum <= 0 or not isinstance(findings, list) - or any(not isinstance(item, dict) or item.get("class") != "note" for item in findings)): - return False - return len(census["answered"]) < quorum or not findings + return not (type(quorum) is not int or quorum <= 0 or not isinstance(findings, list) + or any(not isinstance(item, dict) or item.get("class") != "note" for item in findings)) def plan_review_class_facts(wave: Any, *, awaited: bool) -> Dict[str, Any]: diff --git a/ouroboros/size_ratchet_manifest.py b/ouroboros/size_ratchet_manifest.py index 88fd044d0..d8d2d9aea 100644 --- a/ouroboros/size_ratchet_manifest.py +++ b/ouroboros/size_ratchet_manifest.py @@ -159,7 +159,6 @@ BAND_PATHS = { "ouroboros/tools/control_task_results.py": "serial addressed turns: await_messages (the mailbox wait, its window bounds and catalog entry) lives beside wait_task/wait_tasks, whose transport-wait peek and cache-horizon note it shares; splitting the three waits would separate one reader from its consumers", "ouroboros/tools/core.py": "D05 ledger split (rows 311-349): read/list and owner-chat delivery spans moved to core_file_tools/core_artifacts; facade re-enters the band from above (2283 -> 1373) and shrinks further when the residual catalog split lands", "ouroboros/tools/plan_review.py": "Entered the band from 999 lines: the required-affected_paths form (owner 9=A) added the schema field and the PLAN_RESOURCE_FORM_REQUIRED refusal, which must name the task's open wave and the $0 disposition exit \u2014 it belongs beside the one preamble both the paid and dry-run paths share, not in the pure plan_spec companion that owns no task state.", - "ouroboros/tools/plan_review_runtime.py": "Entered the band from 986 lines: timeout custody synthesis joined the existing plan-review runtime owner while preserving profile-continuity disclosures and typed health facts during target integration.", "ouroboros/tools/registry_core.py": "ToolRegistry owns the registry class behind the protected facade; guard and dispatch implementations live in sibling leaves.", "ouroboros/tools/review.py": "D06 F2.3a re-entry by extraction: the multi-model fan-out moved to review_multi_model.py (1550->1269); the remaining single-owner review cycle machinery lands in the 1001-1500 band with headroom", "ouroboros/tools/skill_exec.py": None, diff --git a/ouroboros/task_results.py b/ouroboros/task_results.py index 0a23076bd..94cab39e9 100644 --- a/ouroboros/task_results.py +++ b/ouroboros/task_results.py @@ -1478,6 +1478,12 @@ def record_plan_review_wave( return state waves = [w for w in state.get("waves") or [] if str(w.get("request_fingerprint") or "") != fingerprint] recorded = copy.deepcopy(wave) + # A paid re-dispatch that REPLACES its same-fingerprint predecessor keeps that + # predecessor's exact artifact reachable (standing findings, prior cycles, facts). + for prior in previous: + ref = prior.get("wave_artifact") if prior.get("cycle_index") != wave.get("cycle_index") else prior.get("previous_wave_artifact") + if isinstance(ref, dict) and ref and not recorded.get("previous_wave_artifact"): + recorded["previous_wave_artifact"] = copy.deepcopy(ref) history = [item for prior in previous for item in prior.get("historical_supplements") or []] if history: recorded["historical_supplements"] = copy.deepcopy(history) diff --git a/ouroboros/tools/plan_render.py b/ouroboros/tools/plan_render.py index 0332c047d..65ad92830 100644 --- a/ouroboros/tools/plan_render.py +++ b/ouroboros/tools/plan_render.py @@ -278,6 +278,7 @@ def _next_step(wave: dict, *, enforcement: str, cap: Optional[int], cycles_paid: text += ( f"NOTE: {len(blocking)} BLOCKING finding(s) below quorum ({ids}): a reject with its " "rationale closes each one; accept or defer keeps it open until a changed spec is reviewed. " + + ("" if not at_cap else "The cycle cap is reached; no further paid panel is available. ") ) else: text += ( @@ -415,7 +416,8 @@ def _render_wave( + (f" · room snapshot read {a['room_read_coverage'].get('covered_chars')}/{a['room_read_coverage'].get('complete_chars')} " f"chars ({a['room_read_coverage'].get('provenance')})" if isinstance(a.get("room_read_coverage"), dict) else "") + f" · {_actor_outcome(a, slot_class.get(id(a), ''))}" - + (" · did not answer; its earlier finding is still listed" if a.get("carried_findings") else "") + + ((" · not sent" if a.get("operation_state") == "not_dispatched" else " · did not answer") + + "; its earlier finding is still listed" if a.get("carried_findings") else "") + (f" · disclosures: {', '.join(a['disclosures'])}" if a.get("disclosures") else "") for a in wave.get("actors") or [] ] or ["(no actor records)"] diff --git a/ouroboros/tools/plan_review_artifacts.py b/ouroboros/tools/plan_review_artifacts.py index 9753820fd..ade13b7f1 100644 --- a/ouroboros/tools/plan_review_artifacts.py +++ b/ouroboros/tools/plan_review_artifacts.py @@ -277,7 +277,13 @@ def in_flight_resume_inputs( )} previous = None previous_fingerprint = str(existing.get("previous_fingerprint") or "") - if previous_fingerprint: + replaced = existing.get("previous_wave_artifact") if isinstance(existing.get("previous_wave_artifact"), dict) else {} + if replaced: # a same-fingerprint re-dispatch replaced its predecessor in the hot index: read the exact copy + try: + previous = read_wave(state_root, task_id, replaced) + except (OSError, ValueError, json.JSONDecodeError): + return {"error": "Prior exact plan-review authority is unreadable; in-flight reconciliation is refused."} + elif previous_fingerprint: from ouroboros.task_results import plan_review_wave previous = plan_review_wave(state, previous_fingerprint) @@ -663,7 +669,7 @@ def compact_wave(wave: Dict[str, Any]) -> Dict[str, Any]: "closed": bool(wave.get("closed")), "paid": bool(wave.get("paid")), "wave_artifact": copy.deepcopy(wave.get("wave_artifact") or {}), - **{key: copy.deepcopy(wave[key]) for key in ("historical_supplements", "retry_key", "custody_pending", "ordered_weaker") if key in wave}, + **{key: copy.deepcopy(wave[key]) for key in ("historical_supplements", "retry_key", "custody_pending", "ordered_weaker", "previous_wave_artifact") if key in wave}, **({"author_disposition": copy.deepcopy(wave["author_disposition"])} if isinstance(wave.get("author_disposition"), dict) else {}), **({"spec_source_ref": copy.deepcopy(wave["spec_source_ref"])} if wave.get("spec_source_ref") else {}), diff --git a/ouroboros/tools/plan_review_runtime.py b/ouroboros/tools/plan_review_runtime.py index 0ae623063..a9e3dface 100644 --- a/ouroboros/tools/plan_review_runtime.py +++ b/ouroboros/tools/plan_review_runtime.py @@ -631,9 +631,6 @@ def synthesize_plan_review_wave( slot = slots_by_id.get(sid) if reviewer_effort and slot is not None and not str(getattr(slot, "declared_effort", "") or ""): disclosures.append("reviewer_effort_not_applied") # a compound route slug kept its encoded effort - carried = [] if ok else [dict(f) for f in (standing or {}).get(sid) or [] if isinstance(f, Mapping)] - if carried: - disclosures.append(f"findings_carried_absent_answer:{len(carried)}") if ok: parsed, parse_error = plan_spec.parse_findings(str(row.get("text") or "")) if parse_error: @@ -645,6 +642,14 @@ def synthesize_plan_review_wave( ) disclosures += finding_disclosures seen_after |= set(slot_seen) + # Standing findings ride only a TERMINAL absence, judged once ``ok`` is final: an + # unparseable reply is a non-answer, while a seat still awaiting the barrier, in + # flight or late-pending is a gap, never an answer (06 §Plan construction and review). + pending = bool(row.get("late_result_pending")) or str(row.get("operation_state") or "settled") in ( + "pending_dispatch", "in_flight", "custody_lost") + carried = [] if ok or pending else [dict(f) for f in (standing or {}).get(sid) or [] if isinstance(f, Mapping)] + if carried: + disclosures.append(f"findings_carried_absent_answer:{len(carried)}") slot_results.append({"slot": row.get("slot_id"), "model": row.get("model"), "ok": ok, "findings": findings, "error": error or None, **({"carried": carried} if carried else {})}) @@ -1163,6 +1168,7 @@ def plan_health_skip_rows(slots: list, evidence: Optional[Dict[str, Dict[str, st "model": str(getattr(slot, "model", "") or ""), "request_model": str(getattr(slot, "model", "") or ""), "route": "agent_session", "host_file_read_attestation": None, "text": "", + "effort": str(getattr(slot, "effort", "") or ""), "declared_effort": str(getattr(slot, "declared_effort", "") or ""), "error": ( f"health_skip[{code}]: the pre-fan-out panel health snapshot shows this " f"slot's delegated route window spent{f' (resets {reset})' if reset else ''}; " @@ -1448,6 +1454,7 @@ def plan_slot_fit(slots: list, *, prompt_chars: int, quorum: int, slot_prompt_ch "request_model": str(getattr(slot, "model", "") or ""), "route": "agent_session" if slot_is_session(slot) else "api_chat", "host_file_read_attestation": None, "text": "", + "effort": str(getattr(slot, "effort", "") or ""), "declared_effort": str(getattr(slot, "declared_effort", "") or ""), "error": (f"preflight_oversize: assembled packet ~{estimated:,} estimated tokens exceeds " f"this slot's calibrated input cap {cap:,}"), "prompt_ref": {}, "response_ref": {}, "tokens_in": 0, "tokens_out": 0, "cost": 0.0, diff --git a/ouroboros/tools/plan_spec.py b/ouroboros/tools/plan_spec.py index 38d5ce114..98b2a1177 100644 --- a/ouroboros/tools/plan_spec.py +++ b/ouroboros/tools/plan_spec.py @@ -960,7 +960,7 @@ def plan_standing_findings(previous: Optional[dict], spec: dict, enforcement: st order, so a seat that fails to answer a same-spec cycle keeps its objection listed (``plan_review_runtime.synthesize_plan_review_wave``). A changed spec, a closed predecessor or no predecessor carries nothing.""" - if not isinstance(previous, dict) or not isinstance(previous.get("spec"), dict): + if not isinstance(previous, dict) or not isinstance(previous.get("spec"), dict) or previous.get("closed"): return {} if str(previous.get("spec_hash") or spec_hash(previous["spec"])) != spec_hash(spec): return {} diff --git a/tests/test_awaited_review_not_degraded.py b/tests/test_awaited_review_not_degraded.py index b3a732974..8b11894d1 100644 --- a/tests/test_awaited_review_not_degraded.py +++ b/tests/test_awaited_review_not_degraded.py @@ -70,10 +70,10 @@ ONLY_AWAITED = { [_row("s1", ok=True), _awaiting("s2"), _awaiting("s3")], answered=1, findings=[_NOTE]), "a clean quorum held open by the last slot": _wave( [_row("s1", ok=True), _row("s2", ok=True), _awaiting("s3")], answered=2), + "a quorum whose only findings are notes: notes never move the verdict": _wave( + [_row("s1", ok=True), _row("s2", ok=True), _awaiting("s3")], answered=2, findings=[_NOTE]), } NOT_ONLY_AWAITED = { - "a quorum of answers raised findings: a critic verdict sits beneath the placeholder": _wave( - [_row("s1", ok=True), _row("s2", ok=True), _awaiting("s3")], answered=2, findings=[_NOTE]), "a wait beside a real failure": _wave( [_row("s1", failure_code="run_failed"), _awaiting("s2"), _awaiting("s3")]), "a wait beside a typed $0 refusal": _wave( diff --git a/tests/test_plan_review_epoch.py b/tests/test_plan_review_epoch.py index 402ce0e00..a0b6d01ae 100644 --- a/tests/test_plan_review_epoch.py +++ b/tests/test_plan_review_epoch.py @@ -868,6 +868,84 @@ def test_a_compound_route_slug_keeps_its_effort_and_discloses_the_unapplied_orde assert all(a["declared_effort"] == "" for a in _state(harness, "task-plain")["waves"][-1]["actors"]) +def test_a_reject_closed_predecessor_carries_nothing_on_a_later_same_spec_wave(harness, monkeypatch): + """A below-quorum blocking finding closed by a reasoned reject under advisory is earned + authority: a later same-spec re-dispatch with its objecting seat silent carries nothing + from that CLOSED predecessor (an OPEN one carries it: the tests below).""" + from tests.test_plan_review_reconciliation import _collect + + monkeypatch.setenv("OUROBOROS_REVIEW_MAX_CYCLES", "5") + harness.state["enforcement"] = "advisory" + _patch_health(monkeypatch, lambda slots: {}) + _effort_aware_builder(harness, monkeypatch) + objection = json.dumps([_finding("n1", "blocking", breaks="claim_1", summary="Friday is impossible")]) + sub = harness.install({"s1": objection, "s2": CLEAN, "s3": CLEAN}) + ctx = harness.make_ctx() + assert _control(_call(ctx, reviewer_effort="low")) == {"outcome": "REVIEW_REQUIRED", "closed": False} + fp = _state(harness)["waves"][-1]["request_fingerprint"] + closed = _collect(ctx, fp, items=[{"finding_id": "s1:n1", "decision": "reject", "rationale": "Friday is a hard date"}]) + assert _control(closed) == {"outcome": "GREEN", "closed": True} + sub.answers = {"s1": "", "s2": CLEAN, "s3": CLEAN} + later = _call(ctx, reviewer_effort="max") + assert _control(later) == {"outcome": "GREEN", "closed": True} + assert not any(f.get("carried_absent_answer") for f in _state(harness)["waves"][-1]["findings"]) + + +def test_an_unparseable_objector_reply_still_carries_its_finding(harness, monkeypatch): + """Standing findings are judged once ``ok`` is final: an objecting seat that answers prose + (no findings array) on a same-spec re-dispatch is a non-answer, so its earlier blocking + finding stays listed and the wave stays REVIEW_REQUIRED; a clean answer retires it.""" + monkeypatch.setenv("OUROBOROS_REVIEW_MAX_CYCLES", "5") + _patch_health(monkeypatch, lambda slots: {}) + _effort_aware_builder(harness, monkeypatch) + objection = json.dumps([_finding("n1", "blocking", breaks="claim_1", summary="Friday is impossible")]) + sub = harness.install({"s1": objection, "s2": CLEAN, "s3": CLEAN}) + ctx = harness.make_ctx() + assert _control(_call(ctx, reviewer_effort="low")) == {"outcome": "REVIEW_REQUIRED", "closed": False} + sub.answers = {"s1": "I have nothing to add this round.", "s2": CLEAN, "s3": CLEAN} + prose = _call(ctx, reviewer_effort="max") + assert _control(prose) == {"outcome": "REVIEW_REQUIRED", "closed": False} + wave = _state(harness)["waves"][-1] + s1 = next(a for a in wave["actors"] if a["slot_id"] == "s1") + assert s1["ok"] is False and s1["carried_findings"] == 1 + assert [f["finding_id"] for f in wave["findings"] if f.get("carried_absent_answer")] == ["s1:n1"] + assert wave["counts"]["parseable"] == 2 and "did not answer; its earlier finding is still listed" in prose + sub.answers = {"s1": CLEAN, "s2": CLEAN, "s3": CLEAN} + assert _control(_call(ctx, reviewer_effort="xhigh")) == {"outcome": "GREEN", "closed": True} + + +def test_a_seat_awaiting_the_barrier_carries_nothing_until_its_absence_is_terminal(harness, monkeypatch): + """At the dispatch barrier every fresh row is ``pending_dispatch``: a gap, never a + non-answer, so no standing finding is carried and nothing says «did not answer»; + once collection settles the objecting seat as a $0 ``not_dispatched`` refusal, its + earlier finding is carried and the actor line says «not sent».""" + from tests.test_plan_review_reconciliation import _collect, _install_barrier_substrate + + monkeypatch.setenv("OUROBOROS_REVIEW_MAX_CYCLES", "5") + _patch_health(monkeypatch, lambda slots: {}) + _effort_aware_builder(harness, monkeypatch) + objection = json.dumps([_finding("n1", "blocking", breaks="claim_1", summary="Friday is impossible")]) + harness.install({"s1": objection, "s2": CLEAN, "s3": CLEAN}) + ctx = harness.make_ctx() + assert _control(_call(ctx, reviewer_effort="low")) == {"outcome": "REVIEW_REQUIRED", "closed": False} + calls = [] + _install_barrier_substrate(monkeypatch, calls, refused={"s1"}) + barrier = _call(ctx, reviewer_effort="max") + assert _control(barrier) == {"outcome": "DEGRADED", "closed": False} + wave = _state(harness)["waves"][-1] + assert wave["custody_pending"] is True + assert not any(f.get("carried_absent_answer") for f in wave["findings"]) + assert all(not a.get("carried_findings") for a in wave["actors"]) + assert "earlier finding is still listed" not in barrier + settled = _collect(ctx, wave["request_fingerprint"]) + assert _control(settled) == {"outcome": "REVIEW_REQUIRED", "closed": False} + wave = _state(harness)["waves"][-1] + s1 = next(a for a in wave["actors"] if a["slot_id"] == "s1") + assert s1["operation_state"] == "not_dispatched" and s1["carried_findings"] == 1 + assert [f["finding_id"] for f in wave["findings"] if f.get("carried_absent_answer")] == ["s1:n1"] + assert "not sent; its earlier finding is still listed" in settled + + def test_a_changed_order_never_retires_a_silent_objectors_finding(harness, monkeypatch): """Standing findings by same spec hash and same seat: after s1 raised a blocking finding at `low`, a `max` re-dispatch on the SAME spec where s1 fails to answer @@ -899,10 +977,16 @@ def test_a_changed_order_never_retires_a_silent_objectors_finding(harness, monke sub.answers = {"s1": CLEAN, "s2": CLEAN, "s3": CLEAN} assert _control(_call(ctx, reviewer_effort="xhigh")) == {"outcome": "GREEN", "closed": True} assert not any(f.get("carried_absent_answer") for f in _state(harness)["waves"][-1]["findings"]) - # A changed spec is a fresh judgement: s1's silence there carries nothing. + # A changed spec is a fresh judgement: on a second task whose wave is OPEN on s1's + # objection, the changed spec with s1 silent carries nothing (the guard is spec-hash + # equality), while the same silence on the unchanged spec carries it (above). + other = harness.make_ctx(task_id="task-2") + sub.answers = {"s1": objection, "s2": CLEAN, "s3": CLEAN} + assert _control(_call(other, reviewer_effort="low")) == {"outcome": "REVIEW_REQUIRED", "closed": False} sub.answers = {"s1": "", "s2": CLEAN, "s3": CLEAN} - changed = _call(ctx, spec={**DECK_SPEC, "in_scope": ["a 6-slide deck"]}, reviewer_effort="low") + changed = _call(other, spec={**DECK_SPEC, "in_scope": ["a 6-slide deck"]}, reviewer_effort="low") assert _control(changed) == {"outcome": "GREEN", "closed": True} + assert not any(f.get("carried_absent_answer") for f in _state(harness, "task-2")["waves"][-1]["findings"]) assert "did not answer; its earlier finding is still listed" not in changed diff --git a/tests/test_reference_book_budgets.py b/tests/test_reference_book_budgets.py index 0277361ad..af1dc7c89 100644 --- a/tests/test_reference_book_budgets.py +++ b/tests/test_reference_book_budgets.py @@ -232,7 +232,8 @@ CHAPTER_BYTE_BUDGETS: dict[str, int] = { # observed-source read facts; the capture paragraph gains the snapshot-vs-inline clause. # 320500 -> 321400 (merge of the moved target into the plan-review branch, measured 321261): both # sides' replaced paragraphs land together; no text was appended by the merge itself. - "docs/architecture/06-agent-core.md": 321400, + # 321400 -> 321600 (measured 321394): the closure sentence names the CONFIGURED enforcement against the hurry-projected advisory. + "docs/architecture/06-agent-core.md": 321600, # 36991 -> 37300: the facade paragraph names the three loop constants runtime_limits.py # gained (events batch bound, budget-projection retry interval); no older text to displace. # 37300 -> 38400 (PR #1207): the Z.ai (`zai::`) direct provider gets its own route @@ -241,7 +242,8 @@ CHAPTER_BYTE_BUDGETS: dict[str, int] = { # 38400 -> 38700 (PR #1300): one settings row for the extra-CA trust bundle; the base sat 33 bytes under. # 38700 -> 39000: OUROBOROS_EFFORT_REVIEW and the reviewer-slot row description name the plan # order that outranks a pinned row effort (compound slugs keep theirs); the base sat 139 under. - "docs/architecture/07-configuration.md": 39000, + # 39000 -> 39100 (measured 38988): the review-enforcement row names what plan-review closure reads. + "docs/architecture/07-configuration.md": 39100, # 18947 -> 19287: CI failure collection now documents diagnostic desktop builds while release remains gated. # 19287 -> 20560 (#1215): three contracts the chapter had no older text for — the # ONE reusable browser lane and the two triggers that share it (the unfiltered diff --git a/web/modules/review_presentation.js b/web/modules/review_presentation.js index d977e5eab..cab41dfa9 100644 --- a/web/modules/review_presentation.js +++ b/web/modules/review_presentation.js @@ -533,18 +533,18 @@ function planFindingLines(wave) { function planActorAvailabilityLines(wave) { // The bug report's own bar: a result that was never received must say so - // explicitly instead of contributing silently-zero findings. A row names the - // model and quotes the engine's reported sentence; the failure code and the - // slot id stay in the task detail and Logs (an unresolved slot keeps its raw state). + // explicitly instead of contributing silently-zero findings. A row names the model and + // quotes the engine's sentence; failure code and slot id stay in the task detail and Logs. const lines = []; for (const actor of (Array.isArray(wave.actors) ? wave.actors : [])) { if (!actor || typeof actor !== 'object' || actor.ok !== false) continue; const model = text(actor.model) || 'reviewer'; const cause = text(actor.reported_cause).split(/\s+/).join(' '); + const carried = finiteCount(actor.carried_findings) ? '; its earlier finding is still listed' : ''; if (actorAwaiting(actor)) lines.push(`${model} · awaiting${sinceLocalTime(actor.awaiting_since)}`); else if (actorUnresolved(actor)) lines.push(`${model} · no answer${cause ? ` — "${cause}"` : ''}${sinceLocalTime(actor.awaiting_since)}`); - else if (text(actor.operation_state) === 'not_dispatched') lines.push(`${model} · not sent`); - else lines.push(`${model} · unavailable${cause ? ` — "${cause}"` : ''}${finiteCount(actor.carried_findings) ? ' · did not answer; its earlier finding is still listed' : ''}`); + else if (text(actor.operation_state) === 'not_dispatched') lines.push(`${model} · not sent${carried}`); + else lines.push(`${model} · unavailable${cause ? ` — "${cause}"` : ''}${carried ? ` · did not answer${carried}` : ''}`); } return lines; } @@ -561,6 +561,14 @@ function planWaveDetail(wave) { wave.reason ? `Reason: ${text(wave.reason)}` : '', ]; const counts = wave.counts && typeof wave.counts === 'object' ? wave.counts : {}; + // A panel ordered weaker than the owner's setting says so seat by seat (typed fact; the + // verdict token is never recoloured), on compact waves too since compaction keeps the fact. + const weaker = wave.ordered_weaker && typeof wave.ordered_weaker === 'object' + ? Object.entries(wave.ordered_weaker).filter(([, row]) => row && typeof row === 'object') : []; + if (weaker.length) { + lines.push(`Reviewers ordered weaker than your setting: ${weaker + .map(([sid, row]) => `${sid} ${text(row.effort) || '?'} (setting ${text(row.owner_effort) || '?'})`).join(', ')}`); + } if (wave.compact) { // A compacted wave keeps counts while its finding bodies moved to the // immutable wave artifact; name that remainder instead of rendering a @@ -580,14 +588,6 @@ function planWaveDetail(wave) { .filter((key) => finiteCount(counts[key]) != null) .map((key) => `${finiteCount(counts[key])} ${key}`); if (countParts.length) lines.push(`Findings: ${countParts.join(' · ')}`); - // A panel the mind ordered weaker than the owner's effort setting says so, seat by - // seat, from the wave's typed fact; the verdict token is never recoloured for it. - const weaker = wave.ordered_weaker && typeof wave.ordered_weaker === 'object' - ? Object.entries(wave.ordered_weaker).filter(([, row]) => row && typeof row === 'object') : []; - if (weaker.length) { - lines.push(`Reviewers ordered weaker than your setting: ${weaker - .map(([sid, row]) => `${sid} ${text(row.effort) || '?'} (setting ${text(row.owner_effort) || '?'})`).join(', ')}`); - } lines.push(...planFindingLines(wave)); lines.push(...planActorAvailabilityLines(wave)); const findingsShown = (Array.isArray(wave.findings) ? wave.findings : []).length; diff --git a/web/tests/review_findings_detail.test.js b/web/tests/review_findings_detail.test.js index 8be716280..6c95cc45e 100644 --- a/web/tests/review_findings_detail.test.js +++ b/web/tests/review_findings_detail.test.js @@ -351,3 +351,48 @@ test('the hydrate status node swaps its message text across the loading→error assert.match(current.children[0].innerHTML, /Loading review details/); assert.equal(current.children.length, 1); }); + +test('a compact Plan wave still names reviewers ordered weaker than the setting', () => { + const fingerprint = 'c'.repeat(64); + const group = planReviewGroupFromTaskDetail({ + task_id: 'root', + plan_review_state: { + schema_version: 2, + current_attempt: {}, + waves: [{ + compact: true, request_fingerprint: fingerprint, cycle_index: 1, aggregate: 'GREEN', closed: true, + counts: { findings: 0, blocking: 0, dispositions: 0 }, + wave_artifact: { root: 'artifact_store', path: 'w.json', sha256: 'abc123def4567890', bytes: 321 }, + ordered_weaker: { slot_1: { effort: 'low', owner_effort: 'xhigh' } }, + }], + waves_omitted: 0, + }, + }); + const detail = group.attempts[0].detailText; + assert.match(detail, /Reviewers ordered weaker than your setting: slot_1 low \(setting xhigh\)/); + assert.match(detail, /Finding bodies compacted/); +}); + +test('a carried finding is explained on a not-sent seat and on a failed seat alike', () => { + const fingerprint = 'e'.repeat(64); + const detail = (actor) => planReviewGroupFromTaskDetail({ + task_id: 'root', + plan_review_state: { + schema_version: 2, + current_attempt: { fingerprint, status: 'open' }, + waves: [{ + request_fingerprint: fingerprint, cycle_index: 2, aggregate: 'REVIEW_REQUIRED', closed: false, paid: true, + counts: { blocking: 1, note: 0, need_evidence: 0, parseable: 2, quorum: 2 }, findings: [], + actors: [{ slot_id: 'slot_1', model: 'm/a', ...actor }], + }], + waves_omitted: 0, + }, + }).attempts[0].detailText; + assert.match(detail({ ok: false, operation_state: 'not_dispatched', error: 'health_skip', carried_findings: 1 }), + /m\/a · not sent; its earlier finding is still listed/); + assert.match(detail({ ok: false, error: 'transport died', reported_cause: 'transport died', carried_findings: 1 }), + /m\/a · unavailable — "transport died" · did not answer; its earlier finding is still listed/); + assert.doesNotMatch(detail({ ok: false, operation_state: 'not_dispatched', error: 'health_skip' }), /earlier finding/); + assert.doesNotMatch(detail({ ok: false, error: 'transport died' }), /earlier finding/); +}); +