mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-19 18:14:54 +00:00
Max Review Cycles counts PAID cycles on the commit gate and skill review; identical bytes are never re-reviewed for pay
Owner decisions Q12/Q16-A/Q17-A/Q22-A/Q23-A (update-flow redesign sprint,
lane L-cycles, deltas 5-6). The one shared knob (OUROBOROS_REVIEW_MAX_CYCLES)
now means the same thing on every review gate: paid cycles per task.
Commit gate (commit_gate.py, git.py, review_state.py):
- Typed block-row classification at record time: verdict-blocks (triad/scope
reviewer FINDINGS) vs infra-blocks (fit fixed_overflow, scope sub_floor,
revalidation, transport/no-quorum). New durable CommitAttemptRecord fields
block_class, rebuttal_sha256, paid, review_contract_fingerprint,
root_task_id (merge- and roundtrip-safe; legacy rows classify by reason).
- A byte-identical resubmission of a verdict-blocked staged diff is refused
FREE from the FIRST block (identical_diff_refused), before the advisory
freshness gate and before any paid dispatch, quoting the recorded verdict;
the refusal is cross-task by fingerprint (anti-laundering). Infra-blocks,
infra/expired failures and preflight facts neither build nor reset the
streak; a post-review failure or a success ends it.
- Rebuttals are identified by CONTENT (sha256): a hash new to the streak buys
exactly one paid re-review; a repeated hash is refused free. A rebuttal is
"spent" only when it bought a dispatched, verdict-answered wave — one
refused undispatched (e.g. by the ceiling) stays fresh.
- The knob bounds PAID triad+scope cycles per ROOT task (the task tree shares
one ceiling; a manual session is its own task; a follow-up task is a fresh
root — origin_root_task_id is deliberately not honored, the cross-task
identical-fingerprint refusal stays the anti-laundering backstop). Paid is
recorded at dispatch on the attempt ledger (plan-review precedent; P7 - no
counter file) and the ceiling counts MONEY: every dispatched wave counts
whatever its terminal; only undispatched attempts stay outside the count.
Exhaustion is a free typed refusal plus
emit_review_cycles_exhausted(surface="commit_gate"); unlimited disables it.
- Free refusal/replay honors a review-contract fingerprint (triad roster and
routes, scope rows, enforcement, shipped prompt contract); a changed
contract lapses the streak and the refusal never quotes across the change.
- Under ADVISORY enforcement neither state hard-blocks: the commit proceeds
with a loud typed disclosure (commit_review_free_replay event + result
warning; the wording distinguishes a replayed verdict from a
ceiling-exhausted commit that has no verdict to reuse) and no further paid
dispatch. Managed resolvers keep MERGE_HEAD repaired on the free refusal
path.
- _run_reviewed_stage_cycle order: fingerprint (hoisted only because the free
gate needs it) -> free cycle gate -> advisory/tests gate -> binding
precondition (kept at its original position) -> paid dispatch; the
advisory+tests section moved whole to _advisory_and_tests_gate at the
function-size gate.
Skill review (skill_review.py, new skill_review_cycles.py,
skill_review_history.py, skill_review_runner.py):
- review_max_cycles() wired in for the first time: paid panel dispatches are
counted per ceiling key - the root task for task-driven review groups
(across every skill that task reviews; follow-ups start fresh), and, for
the manual lane, the CURRENT content snapshot (revised content restarts
the manual count; marketplace-install rows share the same per-snapshot
scoping). One chunked wave = ONE cycle; counts derive from the append-only
review history; exhaustion is a typed refusal plus
emit_review_cycles_exhausted(surface="skill_review").
- Q17-A free replay: an identical (group, content_hash, panel-contract
fingerprint) snapshot with a recorded SUBSTANTIVE verdict (clean/warnings/
blockers) replays at USD 0, quoting it - but only while the persisted
review state still covers that exact snapshot+verdict; a diverged state
falls through to a PAID rerun that re-persists (never a live-lock).
pending/interrupted/timeout/failed/cancelled are infra facts that never
replay; a rebuttal recorded on such a row stays fresh and buys the paid
rerun it is owed; a content-new rebuttal buys exactly one paid rerun.
- The panel-contract fingerprint pins the roster, the required items, the
shipped prompt/aggregation contract text and the skill's RESOLVED review
profile (computed once and threaded through aggregation).
- Terminal history rows carry the paid fact, the panel-contract fingerprint,
the rebuttal hash and replay provenance.
- Module splits at the size gates (update_candidate.py precedent): the
accepted-rebuttal ledger and the review-wave budget admission moved whole
to skill_review_cycles.py with same-name re-exports; review_skill's persist
tail extracted to _persist_reviewed_outcome and the cycles gate to
_skill_cycles_gate.
Disclosed residual: the ceiling checks are read-at-gate-time with no
reservation (TOCTOU) - concurrent dispatches sharing one ceiling key can
overshoot the cap by the concurrency width.
Pinned wording synced in this same commit (CHECKLISTS row 10):
review_cycles.py per-gate docstring (now four gates), commit_gate.py header
and module docstring, the advisory _identical_diff_cap_note, commit_reviewed
rebuttal schema texts, prompts/SYSTEM.md commit-gate wording,
docs/DEVELOPMENT.md cycles section plus the new anti-pattern instruction
(never pay for byte-identical review material; the limit counts PAID cycles;
exhaustion is a typed event), docs/ARCHITECTURE.md (review_cycles and
commit_gate module rows, skill review module map rows, evolution cleanup
paragraph, settings table), ouroboros/config.py settings comment, web
settings_ui.js copy, and review_evidence.py block-reason guidance - all
stating the per-ROOT-task scope. Pinned tests rewritten as contract tests
(test_commit_gate.py, test_review_cycles.py, plus the structural pins in
test_scope_review.py and test_advisory_delegated_route.py) and a new
tests/test_review_cycles_gates.py suite added, including behavior tests that
drive the real recorder to the ledger, the real MERGE_HEAD repair against a
temp git dir, and the real review_skill pipeline through replay, refusal and
paid dispatch.
Retired API: check_blocked_attempt_cap / blocked_attempt_fingerprint_cap
(pay-for-identical-re-review-up-to-a-cap semantics). Legacy
attempt_cap_reached ledger rows remain recognized as refusal records.
Fix round (5-seat review panel convergence; accepted findings F1-F8, F10,
fable P3-2/P3-3):
- F1 (CRITICAL) review_state.py: the 50-row attempt-history trim is now
authority-preserving - it evicts only non-authoritative noise (free
refusals, preflight facts, unpaid infra rows), oldest first, and never
paid rows (the money ledger the per-root-task ceiling derives from),
review-verdict anchors (the evidence an identical-diff refusal quotes) or
in-flight rows. The real bound (delta-review follow-up): the ACCOUNTING
FACTS of preserved rows are immortal, their heavy forensic payloads are
not - a preserved row that falls outside the newest-50 window is compacted
(raw_stripped=True): triad_raw_results/scope_raw_result dropped
(block_class materialized first, so a legacy scope verdict keeps its
refusal-anchor authority after the classifying evidence is gone),
block_details bounded to the 600 chars the refusal quote renders,
commit_message bounded to 300; paid/root/task ids, fingerprints, rebuttal
hash, timestamps and the quoted critical_findings all survive. The
serialized ledger therefore grows ~O(preserved rows x small record) - per
root task bounded by the money ceiling - instead of by full reviewer raw
output per reviewed commit forever. Regressions: 60+ free refusals after
the cap change neither the paid count nor the quoted verdict, and a
120-row heavy flood keeps full raw payloads only inside the newest-50
window (every over-window row serializes under 4KB).
- F1-adjacent recorder bug (exposed by the eviction test): a NEW attempt
recorded with no in-flight marker inherited the PREVIOUS terminal
attempt's fields - paid=True, block_class, rebuttal/contract/fingerprints
- so once any paid row existed, every free refusal was silently counted as
a paid cycle. The terminal-latest branch of _record_commit_attempt now
nulls the inherited row for a fresh attempt number, mirroring the
contract the reviewing branch already enforced.
- F2 (MAJOR) the paid stamp moved from gate passage to a WRITE-AHEAD record
at the first PHYSICAL reviewer dispatch: new module
ouroboros/review_dispatch.py (which also absorbs the slot-identity mint
moved whole from review_substrate.py at the module-size gate, same-name
re-exports kept) provides the idempotent thread-safe ReviewPaidStamp;
the commit gate installs it on ctx (_install_paid_dispatch_stamp) and the
shared transport entry run_review_request invokes it immediately before
the first reviewer call on EITHER side - triad and scope dispatch in
parallel, so any side dispatching makes the cycle paid. Assembly-only
exits (triad fit ladder, scope pack signals) stay $0 and outside the
ceiling; a crash after dispatch keeps the durable paid fact. The three
docstring enumerations ("assembly failures stay outside the count") are
now literally true. Design note: this seam is where the L-review lane's
two-phase admission slots in at synthesis.
- F3 (MAJOR) skill review shares ONE durable write-ahead dispatch marker
(state/skills/<name>/review_dispatch.json) between the lifecycle runner
and direct review_skill callers, written at physical panel dispatch with
paid=True + contract fingerprint + rebuttal hash + wave id. Terminal
history rows merge it idempotently (a lifecycle timeout with no result
still lands the paid facts) and clear it; an orphaned predecessor is
flushed into the history as an infra terminal; the derived count includes
an unmerged marker, so a crashed or swallowed wave never un-counts spent
money. The quorum-failure append now receives the paid facts (the direct
marketplace-install path no longer lands unpaid), and history append
failures surface as loud typed skill_review_history_append_failed events
instead of a silent pass. Four-outcome behavior tests (substantive
verdict / quorum-failed / transport-failed / crash-after-dispatch) each
pin exactly one paid unit on a real jsonl.
- F4 (wording + scoping, no semantics flip): spent-rebuttal memory on the
skill side is scoped to the CURRENT panel-contract fingerprint (a
contract lapse clears it along with replay); the contradictory wordings
(skill_review_cycles.py header, docs/DEVELOPMENT.md) now state the exact
rule - refusal-streak eligibility (substantive verdicts only) is distinct
from money accounting (every dispatched wave counts); a rebuttal is spent
by the substantive verdict it bought; a dispatched infra terminal
consumes money but not the rebuttal.
- F5 advisory-honesty sync: prompts/SYSTEM.md, web settings copy, the
advisory-review schema note (claude_advisory_review.py) each carry the
one enforcement caveat - under blocking the commit is refused for free;
under advisory it proceeds with a loud durable disclosure and no new
review spend.
- F6 the blocked-classification comment in git.py no longer claims a
dispatched infra block costs nothing - money is stamped at dispatch.
- F7 the skill_review tool schema documents the replay key (group,
content-hash, contract fingerprint), the paid-cycle ceiling, exhaustion
leaving the skill pending (never executable), and the rebuttal as content
whose sha256 buys exactly one paid wave when new to the streak.
- F8 auditability: review_evidence attempt projections expose block_class,
paid, rebuttal_sha256, review_contract_fingerprint and root_task_id; the
skill review-history UI detail includes the accounting facts inside the
existing free-form markdown detail string - the
SkillReviewHistoryDetailResponse contract is typed to exactly four
fields, so no api_types.js change and no version bump.
- F10 dead _POST_REVIEW_FAILURE_PHASES deleted (the streak walker's generic
terminal break already covers post-review failure phases).
- fable P3-2: the advisory identical-replay branch is marked as deliberate
defense-in-depth (verdict rows are only minted under blocking and the
contract fingerprint includes enforcement, so today it degrades to an
ordinary paid review).
- fable P3-3: end-to-end tests drive _run_reviewed_stage_cycle through both
advisory replay reasons, pinning the distinct progress notes and the
disclosure landing in the commit result formatting.
Residual disclosures:
- Bench settings_base templates set blocking enforcement and do not pin
OUROBOROS_REVIEW_MAX_CYCLES; the trace audit and the bench-cap option are
escalated to the owner before landing (per the sprint plan's I1
verification duty). Only the SWE-Pro evolution lane executes the commit
gate, and the E1v2 METHODOLOGY already holds advisory.
- Successful reviewed commits also consume paid cycles, so a root task's
third reviewed commit refuses under blocking at the default cap of 2
(settled Q16-A consequence).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
4ec638b170
commit
386e9417b1
25 changed files with 3808 additions and 588 deletions
|
|
@ -155,7 +155,8 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
|
|||
├── review_slot_cancel.py ← Hosted-review slot poller's cancel-honesty helpers (extracted from `review_execution.py` at the module-size gate; `review_execution` imports them and the poll loop stays there): early-termination decision for a parked question (`_interaction_outlives_slot`), the verified slot cancel reporting only what it PROVED (`_slot_cancel_outcome` — outcome + state + the verify read's own `terminal_detail` when carried), honest attribution wording (`_cancel_honesty_clause` — "host-cancelled" only on a `confirmed` receipt whose state is the cancel's own; a confirmed `failed`/`interrupted` is attributed to the run's OWN terminal, BR2-2), and completion-wins consumption of a discovered natural success (`_natural_success_terminal` — the carried detail is used as-is, a re-read gets one bounded retry, and a still-unreadable detail raises the typed `ReviewSessionSucceededResultUnavailable` naming the settled custody row and the capture surfaces instead of "may still be live", BR2-1).
|
||||
├── reviewer_slot_config.py ← Structured reviewer-slot SSOT: stable slot ids, route targets, per-slot effort, legacy projections, save/runtime validation, and disclosure-only last-effective execution records. Malformed configuration loudly refuses commit, scope, advisory, plan, and skill review; task acceptance deliberately retains the projected legacy/default API panel.
|
||||
├── review_state.py ← Durable advisory pre-review state (advisory_review.json)
|
||||
├── review_cycles.py ← Shared paid-review-cycle cap SSOT (`OUROBOROS_REVIEW_MAX_CYCLES`, string: positive int or `unlimited`; `review_max_cycles()` → Optional[int]): one number, three documented per-gate meanings — plan review (panel cycles per task), task acceptance (`improvement passes = cycles − 1`; the retired `OUROBOROS_ACCEPTANCE_MAX_IMPROVEMENT_PASSES` migrates into this key at settings load) and the commit gate's identical-diff attempt cap — plus `emit_review_cycles_exhausted`, the typed D27 escalation on the existing events rail
|
||||
├── review_cycles.py ← Shared paid-review-cycle cap SSOT (`OUROBOROS_REVIEW_MAX_CYCLES`, string: positive int or `unlimited`; `review_max_cycles()` → Optional[int]): one number, four documented per-gate meanings — plan review (panel cycles per task), task acceptance (`improvement passes = cycles − 1`; the retired `OUROBOROS_ACCEPTANCE_MAX_IMPROVEMENT_PASSES` migrates into this key at settings load), the commit gate (paid triad+scope cycles per root task; independently, a byte-identical staged diff is refused free from the first verdict-block) and skill review (paid panel dispatches per root task / manual group lane, free replay of identical snapshots) — plus `emit_review_cycles_exhausted`, the typed D27 escalation on the existing events rail
|
||||
├── review_dispatch.py ← Reviewer dispatch primitives (moved whole from `review_substrate.py` at the module-size gate): the row-identity mint (`slot_id_for_row` + surface prefixes; `review_substrate` re-exports the historical names) and the write-ahead PAID stamp seam — a gate that meters paid review cycles installs an idempotent `ReviewPaidStamp` on `ctx._review_paid_stamp` for the duration of its wave, and `run_review_request` invokes it immediately before the first physical reviewer transport call, so assembly-only refusals stay $0 while a crash after dispatch keeps the durable paid fact
|
||||
├── reviewer_window.py ← Reviewer context-window SSOT for every review surface (triad, scope, plan, deep self-review): ONE typed `ReviewerWindow` per ROUTE (window/status/stale/observed_at + computed `blocking_authority_allowed`, v6.87.44) with a metadata-only probe serialised by a per-route lock and rate-limited by `probe`'s own evidence TTL, never by a process-lifetime memo that would outlive the record (v6.87.45), fail-closed sub-floor when no evidence exists, and output/tokenizer reserves scaled to a sub-1M window so a small-window slot gets a fit-sized pack instead of a zero limit (v6.87.22; replaces the hardcoded 1M assumption each surface carried)
|
||||
├── triad_review.py ← Shared multi-model review primitives: JSON-array extraction is reused by repo + skill review; per-actor records, quorum/degraded accounting, and model-error events power the skill-review path. It also owns the review OUTPUT CONTRACT text rendered by the repo-triad, repo-advisory and scope prompts (skill review states its own contract in `skill_review.py`): findings-only (`REVIEW_JSON_ARRAY_CONTRACT`) and required-matrix (`REVIEW_JSON_MATRIX_CONTRACT`, no all-clear — advisory selects it whenever `expected_items` is supplied). A clean findings-only verdict is recognised only when the WHOLE response — modulo one optional code fence — is `[]`, optionally followed by the `NO_FINDINGS` sentinel and nothing else. Any surrounding prose, and the sentinel without the array, are parse failures: a refusal cannot be distinguished from a benign preamble by structure, so neither is accepted. (A valid non-empty array is of course the normal findings path; it is simply never a *clean* verdict, even with the sentinel appended.) Keeping the text beside `empty_array_is_verified_clean` — the parser that enforces it — is what stops the two from drifting apart
|
||||
├── onboarding_wizard.py ← Shared desktop/web onboarding bootstrap + validation
|
||||
|
|
@ -186,7 +187,8 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
|
|||
├── skill_review_status.py ← Skill-review verdict aggregation SSOT (FAILs → clean/warnings/blockers/pending; hard trust-boundary items block on FAIL, bug_hunting + selected conditional safety items follow severity; enforcement maps verdicts to executable_review)
|
||||
├── skill_review_passes.py ← (v6.41.0) Skill-review pass runner: one multi-model review pass, or a chunked per-pack pass (with per-chunk parseable quorum) when an over-budget skill is split — merged into one verdict (P5 token budget)
|
||||
├── skill_review.py ← Skill review pipeline: deterministic preflight + optional fail-open Claude Code advisory over the skill payload only (repo diff excluded, Skill Review Checklist coverage contract, scope-review effort, raw/session metadata plus parsed_items/contract_warning persisted as advisory_result) followed by the tri-model executable trust gate against the Skill Review Checklist section of docs/CHECKLISTS.md plus minimal host skill/widget context (CREATING_SKILLS.md, PluginAPI contract, extension UI validator); supports rebuttal/history/convergence evidence
|
||||
├── skill_review_history.py ← Append-only Skill Review history helpers: group-wide rounds, per-snapshot attempts, legacy read-time ordinals, and job-idempotent terminal rows
|
||||
├── skill_review_history.py ← Append-only Skill Review history helpers: group-wide rounds, per-snapshot attempts, legacy read-time ordinals, and job-idempotent terminal rows (terminal rows also carry the Max-Review-Cycles facts: paid dispatch + wave id, panel contract fingerprint, rebuttal content hash, replay provenance), plus the ONE write-ahead dispatch marker (`review_dispatch.json`, shared by lifecycle and direct callers): written immediately before a wave's first reviewer transport call, merged into that wave's terminal row (a timeout terminal with no result still gets the paid facts) and cleared on merge; an orphaned predecessor is flushed into the history as an infra terminal, and append failures surface as loud typed `skill_review_history_append_failed` events
|
||||
├── skill_review_cycles.py ← Max-Review-Cycles semantics for skill review (Q17/Q23): paid-cycle counting derived from the review history plus the unmerged write-ahead dispatch marker (root task for task-driven groups, manual lane otherwise; one chunked wave = one cycle), $0 replay of a recorded substantive verdict for an identical (group, content-hash, panel-contract) snapshot (spent-rebuttal memory is scoped to the same contract fingerprint), typed `review_cycles_exhausted` refusal, and the accepted-rebuttal ledger (moved whole from `skill_review.py` at the module-size gate)
|
||||
├── extension_loader.py ← Phase 4 loader for type: extension skills; imports no-dependency pure-Python extensions in-process with PluginAPIImpl, but catalogs isolated-dep/native-marker extensions through child-process proxies so plugin import cannot abort server.py; tracks registrations per-skill for atomic unload
|
||||
├── extension_process_runner.py ← Short-lived child-process runner for isolated-dep/native-marker extension catalog/tool/route/WS dispatch; uses scrubbed env, per-skill deps, process-group tracking, timeout/output caps, and returns graceful host errors on child crash
|
||||
├── extension_ui_validation.py ← One host-owned recursive declarative-schema-v1 validator shared by extension loader and skill preflight; exact tree paths, stable identity, depth/node budgets, passive-subscription enforcement
|
||||
|
|
@ -263,7 +265,7 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
|
|||
│ ├── ci.py ← CI trigger and monitoring (GitHub Actions API)
|
||||
│ ├── claude_advisory_review.py ← Advisory pre-review tool (read-only Claude Agent SDK)
|
||||
│ ├── recent_tasks.py ← Read-only context recovery tool exposing recent task_results summaries/traces for LLM-first continuation recovery
|
||||
│ ├── commit_gate.py ← Advisory freshness gate and commit-attempt recording (extracted from git.py); `_record_commit_attempt` runs LLM-based claim synthesis (via `review_synthesis.py`) on blocked attempts before durable obligations are created
|
||||
│ ├── commit_gate.py ← Advisory freshness gate, commit-attempt recording (extracted from git.py; `_record_commit_attempt` runs LLM-based claim synthesis via `review_synthesis.py` on blocked attempts before durable obligations are created) AND the commit-side Max-Review-Cycles machinery: typed block classification (`classify_review_block`/`attempt_block_class`), the free identical-diff refusal (`check_identical_verdict_refusal`), the per-root-task paid-cycle ceiling (`count_paid_review_cycles`/`check_review_cycles_ceiling`) and the review-contract fingerprint (`commit_review_contract_fingerprint`)
|
||||
│ ├── git_rollback.py ← vcs_rollback tool (wraps git_ops.rollback_to_version)
|
||||
│ ├── git_pr.py ← PR integration tools: fetch_pr_ref, create_integration_branch, cherry_pick_pr_commits, stage_adaptations, stage_pr_merge (non-core, require enable_tools)
|
||||
│ ├── github.py ← GitHub integration: issues (list/get/comment/close) + PR tools: list_github_prs, get_github_pr, comment_on_pr (non-core; github.py is in _FROZEN_TOOL_MODULES so PR inspection/comment tools work in packaged builds)
|
||||
|
|
@ -1011,7 +1013,7 @@ Background Consciousness is the high-horizon awareness loop. It can groom memory
|
|||
|
||||
An active campaign owns an explicit objective, campaign id, transaction, and task claim. `evolution_mode_enabled` is only its scheduling projection. Dispatch, review, commit, publication, and restart revalidate that exact authority; a restored row without a live uncommitted claim is cancelled. A reviewed commit binds to the claim by exact SHA before publication. If authority changes after commit, the commit is moved to a private inspection ref and any attempt-created tag is removed from the normal namespace; concurrent index/worktree edits are not reset and no restart occurs. Restart verification and boot reconciliation decide whether the cycle is absorbed, abandoned, or still pending, and exact terminal replay resumes only incomplete effects without double-counting a cycle.
|
||||
|
||||
Campaign cleanup is deterministic and custody-aware. A no-op or abandoned cycle may restore the transaction base while preserving dirty/ahead work in recorded stash or local refs, but cleanup is skipped when another task, a live test, or the operator kill-switch makes reset unsafe. Repeated review-verdict blocks on a byte-identical diff are capped without counting preflight failures; changing the diff or supplying a review rebuttal creates a new reviewable case. Checkpoint and outcome rows preserve git/memory identity, cost, rounds, and explicit omissions so the promotion loop learns from failed as well as absorbed cycles. An agent-requested restart first drains heartbeat-fresh running tasks up to its configured bound and then fails closed rather than cutting another task silently.
|
||||
Campaign cleanup is deterministic and custody-aware. A no-op or abandoned cycle may restore the transaction base while preserving dirty/ahead work in recorded stash or local refs, but cleanup is skipped when another task, a live test, or the operator kill-switch makes reset unsafe. A byte-identical diff whose last terminal is a review-verdict block is refused for free from the first block (preflight failures and infra-blocks neither build nor reset the streak); changing the diff, or supplying a rebuttal whose content hash is new to the streak, creates a new paid reviewable case, and the shared cycle cap bounds paid triad+scope cycles per root task. Checkpoint and outcome rows preserve git/memory identity, cost, rounds, and explicit omissions so the promotion loop learns from failed as well as absorbed cycles. An agent-requested restart first drains heartbeat-fresh running tasks up to its configured bound and then fails closed rather than cutting another task silently.
|
||||
|
||||
Post-task evolution is owner-gated and default-off. The worker may recommend one backlog item by writing a request, but only the supervisor may convert that request into one normal campaign cycle. An owner stop closes the campaign, clears queued requests, persists a sentinel, and cannot be autonomously reversed; only an owner-authorized start clears it. Evolution is hard-blocked in `light` runtime mode at every entry point and uses the normal task/review path in `advanced` or `pro`.
|
||||
|
||||
|
|
@ -2357,7 +2359,7 @@ Runtime floors:
|
|||
| OUROBOROS_LLM_TRANSPORT_READ_TIMEOUT_SEC | 2700 | (v6.54.3) Default httpx read/write timeout for no_proxy LLM clients (was a hardcoded 3600). Deliberately generous — long silent reasoning (scope review, deep self-review) can take 20-40 min before the first byte; this is the dead-socket bound, and explicit per-call timeouts always win. |
|
||||
| OUROBOROS_PLAN_TASK_DEADLINE_MIN_SEC | 300 | plan_task deadline floor: below this remaining window `plan_task` returns a typed `PLAN_TASK_SKIPPED_DEADLINE` + `plan_task_deadline_skip` telemetry instead of eating the budget tail. Without a deadline behavior is unchanged. |
|
||||
| OUROBOROS_ACCEPTANCE_REVIEW_EST_SEC | 200 | Initial acceptance-review estimate and floor. The first review reserves at least 200s; later passes use `max(floor, 1.5×EWMA)` with `alpha=0.5`, reconstructed from existing timing events. |
|
||||
| OUROBOROS_REVIEW_MAX_CYCLES | "2" | Shared paid-review-cycle cap (SSOT `ouroboros/review_cycles.py`; Settings → Behavior "Max Review Cycles" 1/2/3/5/∞). STRING: a positive integer or `unlimited` (aliases `inf`, `∞`); malformed values fail closed to the default and are logged once. Per-gate meaning: plan review — paid reviewer-panel cycles per task; task acceptance — paid panel runs per task, `improvement passes = cycles − 1` (binds under EVERY policy incl. Required+Blocking; an explicit `task_contract.budget_profile.max_improvement_passes` still wins; `unlimited` = no local count cap, deadline/budget/lifecycle rails still bind); commit gate — consecutive review-blocks of a BYTE-IDENTICAL staged diff before the identical-diff attempt cap refuses (was a hardcoded 3). |
|
||||
| OUROBOROS_REVIEW_MAX_CYCLES | "2" | Shared paid-review-cycle cap (SSOT `ouroboros/review_cycles.py`; Settings → Behavior "Max Review Cycles" 1/2/3/5/∞). STRING: a positive integer or `unlimited` (aliases `inf`, `∞`); malformed values fail closed to the default and are logged once. Per-gate meaning: plan review — paid reviewer-panel cycles per task; task acceptance — paid panel runs per task, `improvement passes = cycles − 1` (binds under EVERY policy incl. Required+Blocking; an explicit `task_contract.budget_profile.max_improvement_passes` still wins; `unlimited` = no local count cap, deadline/budget/lifecycle rails still bind); commit gate — PAID triad+scope cycles per root task (a follow-up task is a fresh root; paid recorded at dispatch on the attempt ledger and every dispatched wave counts whatever its terminal — only undispatched attempts stay outside the count; a byte-identical staged diff is independently refused FREE from the first verdict-block, and a content-new rebuttal buys exactly one paid re-review; under advisory enforcement exhaustion/identical-refusal disclose loudly and the commit proceeds without buying another review); skill review — paid panel dispatches per root task (task-driven groups) or per manual-lane content snapshot, one chunked wave = one cycle, identical snapshots replay free. |
|
||||
| OUROBOROS_ACCEPTANCE_MAX_IMPROVEMENT_PASSES | (retired) | Rename alias of the shared cap: a customized value is MIGRATED into `OUROBOROS_REVIEW_MAX_CYCLES` (cycles = passes + 1) when settings load and the key is then dropped, so it never binds at runtime. Residual: an env-only legacy value is not migrated. |
|
||||
| OUROBOROS_ACCEPTANCE_RESERVE_PCT | 5 | (v6.54.4) Default finalization-reserve percentage; reserve = max(OUROBOROS_FINALIZATION_GRACE_SEC, pct×total budget). |
|
||||
| OUROBOROS_OBSERVABILITY_RETENTION_DAYS | unset | Deprecated audit knob for private observability manifests/blobs; forensic replay blobs are kept compressed indefinitely |
|
||||
|
|
|
|||
|
|
@ -802,12 +802,38 @@ Review Cycles" 1 / 2 / 3 / 5 / ∞). Its per-gate meaning is documented in that
|
|||
module and is literally: plan review — paid reviewer-panel cycles per task;
|
||||
task acceptance — paid panel runs per task, `improvement passes = cycles − 1`
|
||||
(the retired `OUROBOROS_ACCEPTANCE_MAX_IMPROVEMENT_PASSES` is migrated into the shared key at
|
||||
settings load — cycles = passes + 1 — and never binds at runtime); commit gate — consecutive review-blocks of a
|
||||
BYTE-IDENTICAL staged diff before the identical-diff attempt cap refuses another
|
||||
triad+scope run (changing the diff starts a fresh streak; a rebuttal lifts the
|
||||
cap for that attempt only; the default moved from a hardcoded 3 to the shared 2). `unlimited` removes the
|
||||
local count everywhere; deadline, budget and lifecycle rails still bind. A
|
||||
malformed value fails closed to the bounded default and is logged once.
|
||||
settings load — cycles = passes + 1 — and never binds at runtime); commit gate
|
||||
— paid triad+scope cycles per ROOT task (the whole task tree shares one
|
||||
ceiling; a manual session is its own task; a follow-up task is a fresh root;
|
||||
the paid fact is recorded on the attempt row at dispatch and the count is
|
||||
derived from the attempt ledger); skill review — paid reviewer-panel
|
||||
dispatches per ceiling key (the root task for task-driven groups; the manual
|
||||
lane is scoped per content snapshot, so revised content restarts its count;
|
||||
one chunked wave = ONE cycle). `unlimited` removes the local count
|
||||
everywhere; deadline, budget and lifecycle rails still bind. A malformed
|
||||
value fails closed to the bounded default and is logged once.
|
||||
|
||||
Anti-pattern: paying for byte-identical review material. Never dispatch a paid
|
||||
reviewer wave for material a gate has already reviewed under the same review
|
||||
contract — the commit gate refuses a byte-identical staged diff for free from
|
||||
the FIRST verdict-block (`identical_diff_refused`, quoting the recorded
|
||||
verdict), and skill review replays a recorded substantive verdict for an
|
||||
identical snapshot at $0 (only while the persisted review state still covers
|
||||
it). A rebuttal is identified by CONTENT (sha256): a hash new to the streak
|
||||
buys exactly ONE paid re-review; a repeated hash is refused free. The exact
|
||||
rule keeps two axes distinct. Refusal-streak eligibility is about VERDICTS:
|
||||
only substantive reviewer verdicts build (or end) the identical-bytes refusal
|
||||
streak, and a rebuttal is "spent" only by the substantive verdict it bought —
|
||||
never when it was refused undispatched or when its wave died on infra. Money
|
||||
accounting is about DISPATCH: the limit counts PAID cycles, and every
|
||||
physically dispatched wave counts whatever its terminal — so a dispatched
|
||||
infra terminal (quorum failure, transport death, timeout) consumes money but
|
||||
not the rebuttal, while infra facts refused at assembly (fit overflow,
|
||||
sub-floor window) never dispatched and stay outside the count; the paid fact
|
||||
is recorded write-ahead at first dispatch. Byte-identical resubmissions are
|
||||
refused before any spend. Exhaustion is always the typed
|
||||
`review_cycles_exhausted` event with honest exits, never a silent grind or
|
||||
another paid dispatch.
|
||||
|
||||
`docs/CHECKLISTS.md` is the only reviewer-question, severity, and output SSOT.
|
||||
Architecture owns the dataflow; this section owns operator sequence. Finish all
|
||||
|
|
|
|||
|
|
@ -279,7 +279,7 @@ SETTINGS_DEFAULTS = {**UPDATE_SETTINGS_DEFAULTS,
|
|||
# reserves at least 200s; later passes use max(this floor, 1.5×timing EWMA).
|
||||
"OUROBOROS_ACCEPTANCE_REVIEW_EST_SEC": 200,
|
||||
# Shared paid-review-cycle cap (SSOT + per-gate meaning: ouroboros/review_cycles.py):
|
||||
# STRING "N"|"unlimited": plan review, acceptance (passes = cycles - 1), commit-gate cap.
|
||||
# STRING "N"|"unlimited": plan review, acceptance (passes = cycles - 1), commit gate and skill review (paid cycles per root task / manual snapshot); identical material is never re-reviewed for pay on any gate.
|
||||
"OUROBOROS_REVIEW_MAX_CYCLES": "2",
|
||||
"OUROBOROS_ACCEPTANCE_RESERVE_PCT": 5,
|
||||
# Prompt-cache TTL, one honest GLOBAL override (owner decision 2026-08-08, batch #2 Q2=A): applied to
|
||||
|
|
|
|||
|
|
@ -1051,8 +1051,28 @@ def _skill_review_history_detail_sync(
|
|||
"raw_actor_records": degraded_actors,
|
||||
"error": error_note,
|
||||
}
|
||||
markdown = render_skill_review_block(outcome, attempt_idx=attempt)
|
||||
# Max-Review-Cycles accounting facts (Q16/Q17 auditability) ride the
|
||||
# free-form markdown detail: the response contract
|
||||
# (SkillReviewHistoryDetailResponse) is typed to exactly four fields, so
|
||||
# adding response keys would need an api_types version bump — the rendered
|
||||
# detail string is the additive channel. Legacy rows without the facts
|
||||
# render nothing.
|
||||
accounting = []
|
||||
if record.get("paid"):
|
||||
accounting.append("paid panel dispatch (counts toward Max Review Cycles)")
|
||||
if record.get("replayed_from_ts"):
|
||||
accounting.append(f"free replay of the {record.get('replayed_from_ts')} verdict")
|
||||
if record.get("review_contract_fingerprint"):
|
||||
accounting.append(
|
||||
f"panel contract {str(record.get('review_contract_fingerprint'))[:12]}…"
|
||||
)
|
||||
if record.get("rebuttal_sha256"):
|
||||
accounting.append(f"rebuttal sha256 {str(record.get('rebuttal_sha256'))[:12]}…")
|
||||
if accounting:
|
||||
markdown += "\n\n_Review accounting: " + "; ".join(accounting) + "._"
|
||||
return {
|
||||
"markdown": render_skill_review_block(outcome, attempt_idx=attempt),
|
||||
"markdown": markdown,
|
||||
"status": status,
|
||||
"content_hash": outcome["content_hash"],
|
||||
"job_status": str(record.get("job_status") or ""),
|
||||
|
|
|
|||
|
|
@ -9,17 +9,37 @@ shipped default with one loud log line. It is a string on purpose:
|
|||
default would silently swallow ``"unlimited"``. ``review_max_cycles()`` returns
|
||||
``Optional[int]`` — ``None`` means unlimited.
|
||||
|
||||
Per-gate meaning of the ONE number:
|
||||
Per-gate meaning of the ONE number — on every gate it counts PAID cycles, and
|
||||
identical material is never re-reviewed for pay:
|
||||
|
||||
* plan review — paid reviewer-panel cycles per task (the engine consumes the
|
||||
getter; this module only exposes it);
|
||||
* task acceptance — paid panel runs per task, ``passes = cycles - 1``
|
||||
(``acceptance_max_improvement_passes_from_cycles``), so the default 2 equals
|
||||
the historical default of 1 improvement pass; unlimited → None;
|
||||
* commit gate — consecutive review-blocks on a BYTE-IDENTICAL staged diff before
|
||||
the identical-diff attempt cap refuses another triad+scope run. Semantics
|
||||
unchanged (a changed diff starts a fresh streak); only the number's source
|
||||
changed, and the default moved 3 → 2 (owner-approved, disclosed).
|
||||
* commit gate — paid triad+scope cycles per ROOT task (the whole task tree
|
||||
shares one ceiling; a manual session is its own task; a follow-up task is a
|
||||
fresh root). The paid fact is recorded on the attempt row AT DISPATCH and
|
||||
the count is derived from the attempt ledger; the ceiling counts MONEY —
|
||||
every dispatched wave counts whatever its terminal, and only UNDISPATCHED
|
||||
attempts (free refusals/replays, preflight and assembly failures) stay
|
||||
outside the count, which is also the only sense in which "infra retries
|
||||
freely" applies to the ceiling. Independently of the number, a
|
||||
byte-identical staged diff whose last terminal is a review-VERDICT block is
|
||||
refused for FREE from the FIRST block (``identical_diff_refused``, quoting
|
||||
the recorded verdict); a rebuttal is content-hashed and a hash new to the
|
||||
streak buys exactly ONE paid re-review (a rebuttal is "spent" only when it
|
||||
bought a dispatched, verdict-answered wave). Exhaustion under blocking is a
|
||||
free typed refusal; under advisory the commit proceeds with a loud typed
|
||||
disclosure and no further paid dispatch;
|
||||
* skill review — paid reviewer-panel dispatches per ceiling key (the root task
|
||||
for task-driven review groups — shared across every skill that task reviews,
|
||||
follow-ups start fresh — or, for the manual lane, the CURRENT content
|
||||
snapshot: revised content restarts the manual count; a chunked oversized
|
||||
wave is ONE cycle, not one per pack). A byte-identical skill snapshot with a
|
||||
recorded substantive verdict under the same panel contract replays free
|
||||
(``skill_review_cycles.py``) when the persisted review state still covers
|
||||
it; exhaustion is the same typed event with ``surface="skill_review"``.
|
||||
|
||||
Values are read from ``os.environ`` (``config.apply_settings_to_env`` projects
|
||||
saved settings there) falling back to ``SETTINGS_DEFAULTS`` — no second
|
||||
|
|
|
|||
90
ouroboros/review_dispatch.py
Normal file
90
ouroboros/review_dispatch.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""Reviewer dispatch primitives: the row-identity mint (moved whole from
|
||||
``review_substrate.py`` at the module-size gate, same split shape as
|
||||
``skill_review_cycles.py``) and the write-ahead PAID stamp seam (owner
|
||||
Q16/Q17; Max-Review-Cycles fix round).
|
||||
|
||||
The ``paid`` fact of Max-Review-Cycles accounting is recorded at PHYSICAL
|
||||
dispatch: a gate that must durably record "this wave spent reviewer money"
|
||||
installs a :class:`ReviewPaidStamp` on ``ctx._review_paid_stamp`` for the
|
||||
duration of its wave, and the shared reviewer transport entry
|
||||
(``review_substrate.run_review_request``) invokes it immediately before the
|
||||
first transport call. Assembly-only refusals (triad fit ladder, scope pack
|
||||
signals, skill prompt building) exit before the seam, so a $0 attempt stays
|
||||
outside every ceiling; a crash after dispatch keeps the durable paid fact
|
||||
(write-ahead). This seam is also where the L-review lane's two-phase
|
||||
admission slots in at synthesis.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Any, Callable
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Identity prefixes for the configured reviewer surfaces. A surface that fans
|
||||
# rows out registers its prefix here rather than spelling one inline, so
|
||||
# ``slot_id_for_row`` stays the only place a row id is built.
|
||||
SLOT_ID_PREFIX = "slot"
|
||||
SCOPE_SLOT_ID_PREFIX = "scope_slot"
|
||||
PLAN_SLOT_ID_PREFIX = "plan_slot"
|
||||
|
||||
|
||||
def slot_id_for_row(index: int, *, prefix: str = SLOT_ID_PREFIX) -> str:
|
||||
"""Identity of the ``index``-th (1-based) configured reviewer row.
|
||||
|
||||
The single mint for reviewer-slot identity, and the reason the substrate
|
||||
contract says slot identity is separate from model identity. Naming a row
|
||||
after its own model instead collides two rows that share a model (a supported
|
||||
configuration — ``get_scope_review_models`` preserves duplicates on purpose),
|
||||
collides two model spellings that sanitize alike (``openai::gpt-5`` and
|
||||
``openai/gpt/5``), and moves a row's identity the moment the owner edits its
|
||||
model, so the row's receipts stop lining up with its own history. The model,
|
||||
the route and the effort are PROPERTIES of a row, never its name.
|
||||
"""
|
||||
return f"{prefix}_{int(index)}"
|
||||
|
||||
|
||||
class ReviewPaidStamp:
|
||||
"""Idempotent, thread-safe once-only wrapper around one durable write.
|
||||
|
||||
Parallel dispatch means two sides can race to be "the first transport
|
||||
call" (the commit gate dispatches triad and scope concurrently): the first
|
||||
caller performs the durable write-ahead, later callers block on the lock
|
||||
until it lands and then no-op — so EVERY side is guaranteed the paid fact
|
||||
is durable before its own transport begins. A failing write is not
|
||||
retried and still marks the stamp fired: the terminal record is the
|
||||
primary ledger, and this writer is fail-open cost accounting, never a
|
||||
safety gate.
|
||||
"""
|
||||
|
||||
def __init__(self, write: Callable[[], None]) -> None:
|
||||
self._write = write
|
||||
self._lock = threading.Lock()
|
||||
self.fired = False
|
||||
|
||||
def __call__(self) -> None:
|
||||
with self._lock:
|
||||
if self.fired:
|
||||
return
|
||||
try:
|
||||
self._write()
|
||||
finally:
|
||||
self.fired = True
|
||||
|
||||
|
||||
def stamp_review_paid_on_dispatch(ctx: Any) -> None:
|
||||
"""Invoke the caller-installed write-ahead paid stamp; no-op without one.
|
||||
|
||||
Called by the shared reviewer transport entry immediately before the first
|
||||
physical reviewer call. Surfaces that do not meter paid cycles simply
|
||||
never install ``ctx._review_paid_stamp``. Fail-open by design.
|
||||
"""
|
||||
stamp = getattr(ctx, "_review_paid_stamp", None) if ctx is not None else None
|
||||
if not callable(stamp):
|
||||
return
|
||||
try:
|
||||
stamp()
|
||||
except Exception:
|
||||
log.debug("review paid dispatch stamp failed (fail-open)", exc_info=True)
|
||||
|
|
@ -1396,6 +1396,14 @@ def _review_status_attempt_payload(ca: Any) -> Dict[str, Any] | None:
|
|||
"pre_review_fingerprint": ca.pre_review_fingerprint[:12] or None,
|
||||
"post_review_fingerprint": ca.post_review_fingerprint[:12] or None,
|
||||
"degraded_reasons": list(ca.degraded_reasons or []),
|
||||
# Max-Review-Cycles accounting facts (Q16 auditability): the typed
|
||||
# block class, the dispatch-paid fact, and the identities the free
|
||||
# refusal/replay decisions key on.
|
||||
"block_class": str(getattr(ca, "block_class", "") or "") or None,
|
||||
"paid": bool(getattr(ca, "paid", False)),
|
||||
"rebuttal_sha256": str(getattr(ca, "rebuttal_sha256", "") or "")[:12] or None,
|
||||
"review_contract_fingerprint": str(getattr(ca, "review_contract_fingerprint", "") or "")[:12] or None,
|
||||
"root_task_id": str(getattr(ca, "root_task_id", "") or "") or None,
|
||||
**_review_status_actor_summary(ca),
|
||||
})
|
||||
return data
|
||||
|
|
@ -1464,6 +1472,8 @@ def _review_status_message(projection: Dict[str, Any]) -> str:
|
|||
"fingerprint_unavailable": "The staged diff could not be fingerprinted. Fix git diff and retry.",
|
||||
"overlap_guard": "Another reviewed attempt is still active. Wait or expire it before retrying.",
|
||||
"attempt_cap_reached": "The same staged diff was review-blocked repeatedly. Change the diff or rebut via review_rebuttal.",
|
||||
"identical_diff_refused": "This exact staged diff was already review-blocked. Change the diff or supply a NEW review_rebuttal (identical bytes are never re-reviewed for pay).",
|
||||
"review_cycles_exhausted": "This task tree spent its paid review cycles (OUROBOROS_REVIEW_MAX_CYCLES). Finalize honestly or ask the owner to raise the cap.",
|
||||
}
|
||||
label = "BLOCKED" if ca.status == "blocked" else "FAILED"
|
||||
current = (
|
||||
|
|
@ -1479,9 +1489,16 @@ def _review_status_message(projection: Dict[str, Any]) -> str:
|
|||
def _attempt_to_dict(item: Any) -> Dict[str, Any]:
|
||||
data = {
|
||||
key: str(getattr(item, key, "") or "")
|
||||
for key in ("ts", "tool_name", "status", "phase", "block_reason", "scope_model")
|
||||
for key in (
|
||||
"ts", "tool_name", "status", "phase", "block_reason", "scope_model",
|
||||
# Max-Review-Cycles accounting facts (Q16 auditability).
|
||||
"block_class", "rebuttal_sha256", "review_contract_fingerprint",
|
||||
"root_task_id",
|
||||
)
|
||||
}
|
||||
data.update({
|
||||
"paid": bool(getattr(item, "paid", False)),
|
||||
"raw_stripped": bool(getattr(item, "raw_stripped", False)),
|
||||
"attempt": int(getattr(item, "attempt", 0) or 0),
|
||||
"late_result_pending": bool(getattr(item, "late_result_pending", False)),
|
||||
"duration_sec": float(getattr(item, "duration_sec", 0.0) or 0.0),
|
||||
|
|
|
|||
|
|
@ -106,8 +106,8 @@ def _commit_readiness_debts_view(state: Any) -> List["CommitReadinessDebtItem"]:
|
|||
_OBLIGATION_STR_DEFAULTS = {"obligation_id": "", "item": "", "severity": "critical", "reason": "", "source_attempt_ts": "", "source_attempt_msg": "", "status": "still_open", "resolved_by": "", "repo_key": _LEGACY_CURRENT_REPO_KEY}
|
||||
_DEBT_STR_DEFAULTS = {"debt_id": "", "category": "", "summary": "", "severity": "warning", "status": "detected", "repo_key": _LEGACY_CURRENT_REPO_KEY, "fingerprint": "", "title": "Commit readiness debt", "source": "review_state", "first_seen_at": "", "last_seen_at": "", "updated_at": "", "verified_at": ""}
|
||||
_RUN_STR_DEFAULTS = {"snapshot_hash": "", "commit_message": "", "status": "stale", "snapshot_summary": "", "raw_result": "", "bypass_reason": "", "bypassed_by_task": "", "repo_key": _LEGACY_CURRENT_REPO_KEY, "tool_name": _DEFAULT_ADVISORY_TOOL_NAME, "phase": "advisory", "model_used": "", "session_id": ""}
|
||||
_ATTEMPT_STR_DEFAULTS = {"commit_message": "", "snapshot_hash": "", "block_reason": "", "block_details": "", "task_id": "", "repo_key": _LEGACY_CURRENT_REPO_KEY, "tool_name": _DEFAULT_TOOL_NAME, "pre_review_fingerprint": "", "post_review_fingerprint": "", "fingerprint_status": "", "scope_model": ""}
|
||||
_ATTEMPT_MERGE_INCOMING_FIRST = ("ts", "commit_message", "status", "snapshot_hash", "block_reason", "block_details", "duration_sec", "task_id", "repo_key", "tool_name", "phase", "pre_review_fingerprint", "post_review_fingerprint", "fingerprint_status", "scope_model")
|
||||
_ATTEMPT_STR_DEFAULTS = {"commit_message": "", "snapshot_hash": "", "block_reason": "", "block_details": "", "task_id": "", "repo_key": _LEGACY_CURRENT_REPO_KEY, "tool_name": _DEFAULT_TOOL_NAME, "pre_review_fingerprint": "", "post_review_fingerprint": "", "fingerprint_status": "", "scope_model": "", "block_class": "", "rebuttal_sha256": "", "review_contract_fingerprint": "", "root_task_id": ""}
|
||||
_ATTEMPT_MERGE_INCOMING_FIRST = ("ts", "commit_message", "status", "snapshot_hash", "block_reason", "block_details", "duration_sec", "task_id", "repo_key", "tool_name", "phase", "pre_review_fingerprint", "post_review_fingerprint", "fingerprint_status", "scope_model", "block_class", "rebuttal_sha256", "review_contract_fingerprint", "root_task_id")
|
||||
_ATTEMPT_MERGE_INCOMING_LISTS = ("critical_findings", "advisory_findings", "obligation_ids", "readiness_warnings")
|
||||
_RUN_STATUS_ICONS = {"fresh": "✅", "stale": "⚠️", "bypassed": "⏭️", "skipped": "⏭️", "parse_failure": "🔴"}
|
||||
|
||||
|
|
@ -256,6 +256,24 @@ class CommitAttemptRecord:
|
|||
scope_model: str = ""
|
||||
triad_raw_results: List[Dict[str, Any]] = field(default_factory=list)
|
||||
scope_raw_result: Dict[str, Any] = field(default_factory=dict)
|
||||
# Max-Review-Cycles semantics (owner Q12/Q16/Q22/Q23): the typed class of a
|
||||
# blocked row ("verdict" = reviewer findings, "infra" = fit/quorum/transport/
|
||||
# revalidation facts, "" = preflight/legacy), the content hash of the rebuttal
|
||||
# supplied with the attempt (absent on old rows = no rebuttal), whether a paid
|
||||
# triad/scope dispatch physically happened for this attempt (recorded at
|
||||
# dispatch time, plan-review precedent), the review-contract fingerprint the
|
||||
# attempt ran under (roster+routes+enforcement+prompt contract; a changed
|
||||
# fingerprint lapses free-replay/refusal authority), and the root task the
|
||||
# attempt belongs to (the whole task tree shares one paid-cycle ceiling).
|
||||
block_class: str = ""
|
||||
rebuttal_sha256: str = ""
|
||||
paid: bool = False
|
||||
review_contract_fingerprint: str = ""
|
||||
root_task_id: str = ""
|
||||
# True once the row's heavy forensic payloads (raw reviewer results, full
|
||||
# free text) were compacted because the preserved accounting row fell
|
||||
# outside the newest-50 ledger window (see _strip_attempt_heavy_payload).
|
||||
raw_stripped: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -482,10 +500,38 @@ class AdvisoryReviewState:
|
|||
self.attempts[idx] = merged
|
||||
return merged
|
||||
self.attempts.append(attempt)
|
||||
if len(self.attempts) > _MAX_ATTEMPT_HISTORY:
|
||||
self.attempts = self.attempts[-_MAX_ATTEMPT_HISTORY:]
|
||||
self._trim_attempt_history()
|
||||
return attempt
|
||||
|
||||
def _trim_attempt_history(self) -> None:
|
||||
"""Authority-preserving eviction (Q16 fix round, F1): trimming to the
|
||||
historical cap may only evict NON-authoritative rows, oldest first.
|
||||
Paid rows are the money ledger ``count_paid_review_cycles`` derives the
|
||||
per-root-task ceiling from, and verdict-block rows anchor the
|
||||
identical-diff refusal streak (the quoted verdict) — evicting either
|
||||
would let a capped root task loop free refusals until the ceiling and
|
||||
the refusal both forget themselves. Both preserved classes are bounded
|
||||
by the money ceiling itself, so only the noise portion (free refusals,
|
||||
preflight facts, unpaid infra rows) is capped at
|
||||
``_MAX_ATTEMPT_HISTORY``; the list may exceed the cap only by the
|
||||
authority-bearing rows. Preserved rows that fall OUTSIDE the newest-50
|
||||
window are COMPACTED (``_strip_attempt_heavy_payload``): accounting
|
||||
facts are immortal, heavy forensic payloads are not — keeping the
|
||||
serialized ledger ~O(preserved rows x small record) instead of growing
|
||||
by full reviewer raw output per reviewed commit forever."""
|
||||
overflow = len(self.attempts) - _MAX_ATTEMPT_HISTORY
|
||||
if overflow <= 0:
|
||||
return
|
||||
kept: List[CommitAttemptRecord] = []
|
||||
for item in self.attempts:
|
||||
if overflow > 0 and _attempt_history_evictable(item):
|
||||
overflow -= 1
|
||||
continue
|
||||
kept.append(item)
|
||||
self.attempts = kept
|
||||
for item in self.attempts[:-_MAX_ATTEMPT_HISTORY]:
|
||||
_strip_attempt_heavy_payload(item)
|
||||
|
||||
def _allocate_obligation_id(self) -> str:
|
||||
candidate, next_seq = _allocate_prefixed_id(
|
||||
self.open_obligations,
|
||||
|
|
@ -1070,6 +1116,8 @@ def _commit_attempt_from_dict(d: Dict[str, Any]) -> CommitAttemptRecord:
|
|||
triad_models=[str(x) for x in (d.get("triad_models") or [])],
|
||||
triad_raw_results=list(d.get("triad_raw_results") or []),
|
||||
scope_raw_result=dict(d.get("scope_raw_result") or {}),
|
||||
paid=bool(d.get("paid", False)),
|
||||
raw_stripped=bool(d.get("raw_stripped", False)),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1593,6 +1641,75 @@ def _normalize_findings(items: List[Any]) -> List[Dict[str, Any]]:
|
|||
return normalized
|
||||
|
||||
|
||||
def _attempt_history_evictable(item: "CommitAttemptRecord") -> bool:
|
||||
"""True only for rows the Max-Review-Cycles machinery derives NO authority
|
||||
from. Never evictable: paid rows (the per-root-task money count), rows of
|
||||
an attempt still in flight (their upcoming terminal/paid merge must find
|
||||
them), and review-VERDICT blocks (the anchors an identical-diff refusal
|
||||
quotes; legacy rows classify by recorded reason via the commit gate's
|
||||
``attempt_block_class``). Unclassifiable rows are kept — fail toward
|
||||
remembering."""
|
||||
if bool(getattr(item, "paid", False)):
|
||||
return False
|
||||
if str(getattr(item, "status", "") or "") == "reviewing" or bool(
|
||||
getattr(item, "late_result_pending", False)
|
||||
):
|
||||
return False
|
||||
try:
|
||||
from ouroboros.tools.commit_gate import BLOCK_CLASS_VERDICT, attempt_block_class
|
||||
|
||||
return attempt_block_class(item) != BLOCK_CLASS_VERDICT
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# What the refusal quote renders at most from an over-window row's
|
||||
# block_details (mirrors _quote_verdict_attempt's own limit=600), and a small
|
||||
# bound for the free-text commit message on compacted rows.
|
||||
_STRIPPED_DETAILS_LIMIT = 600
|
||||
_STRIPPED_MESSAGE_LIMIT = 300
|
||||
|
||||
|
||||
def _strip_attempt_heavy_payload(item: CommitAttemptRecord) -> None:
|
||||
"""Compact one preserved accounting row that fell outside the newest-50
|
||||
ledger window (F1 follow-up: paid-row immortality must not make the hot
|
||||
load-modify-save state file grow by full reviewer raw output forever).
|
||||
|
||||
Dropped: the heavy forensic payloads NO gate reads from over-window rows —
|
||||
``triad_raw_results`` and ``scope_raw_result`` (full reviewer raw_text) —
|
||||
plus the free-text fields bounded to what consumers render. Kept, exactly
|
||||
the facts the Max-Review-Cycles gates consume: ``paid``/``root_task_id``
|
||||
(the ceiling count), status/phase/``block_reason``/``block_class``/
|
||||
``pre_review_fingerprint``/``review_contract_fingerprint``/
|
||||
``rebuttal_sha256`` (the streak walker), and ``critical_findings`` plus a
|
||||
600-char ``block_details`` excerpt (everything ``_quote_verdict_attempt``
|
||||
renders in an identical-diff refusal). A legacy scope verdict classifies
|
||||
THROUGH ``scope_raw_result``, so ``block_class`` is materialized before
|
||||
that evidence is dropped — the row keeps its refusal-anchor authority.
|
||||
``raw_stripped=True`` marks the compaction for audits; full payloads live
|
||||
only in the newest-50 window."""
|
||||
if bool(getattr(item, "raw_stripped", False)):
|
||||
return
|
||||
if not item.block_class:
|
||||
try:
|
||||
from ouroboros.tools.commit_gate import attempt_block_class
|
||||
|
||||
klass = attempt_block_class(item)
|
||||
if klass:
|
||||
item.block_class = klass
|
||||
except Exception:
|
||||
pass
|
||||
item.triad_raw_results = []
|
||||
item.scope_raw_result = {}
|
||||
item.block_details = _truncate_review_artifact(
|
||||
str(item.block_details or ""), limit=_STRIPPED_DETAILS_LIMIT
|
||||
)
|
||||
item.commit_message = _truncate_review_artifact(
|
||||
str(item.commit_message or ""), limit=_STRIPPED_MESSAGE_LIMIT
|
||||
)
|
||||
item.raw_stripped = True
|
||||
|
||||
|
||||
def _merge_attempt(existing: CommitAttemptRecord, incoming: CommitAttemptRecord) -> CommitAttemptRecord:
|
||||
data = {
|
||||
name: getattr(incoming, name) or getattr(existing, name)
|
||||
|
|
@ -1610,6 +1727,14 @@ def _merge_attempt(existing: CommitAttemptRecord, incoming: CommitAttemptRecord)
|
|||
triad_models=list(incoming.triad_models or existing.triad_models),
|
||||
triad_raw_results=list(getattr(incoming, "triad_raw_results", None) or getattr(existing, "triad_raw_results", None) or []),
|
||||
scope_raw_result=dict(getattr(incoming, "scope_raw_result", None) or getattr(existing, "scope_raw_result", None) or {}),
|
||||
# Once an attempt physically dispatched a paid triad/scope wave the fact is
|
||||
# durable: a later terminal update on the same row must never launder it.
|
||||
paid=bool(getattr(incoming, "paid", False) or getattr(existing, "paid", False)),
|
||||
# The compaction mark is sticky too — an unlikely late merge onto an
|
||||
# over-window row must not present a stripped row as a full one.
|
||||
raw_stripped=bool(
|
||||
getattr(incoming, "raw_stripped", False) or getattr(existing, "raw_stripped", False)
|
||||
),
|
||||
)
|
||||
return CommitAttemptRecord(**data)
|
||||
|
||||
|
|
|
|||
|
|
@ -893,27 +893,16 @@ def build_improvement_capsule(
|
|||
return "\n".join(lines)
|
||||
|
||||
|
||||
# Identity prefixes for the configured reviewer surfaces. A surface that fans
|
||||
# rows out registers its prefix here rather than spelling one inline, so
|
||||
# ``slot_id_for_row`` stays the only place a row id is built.
|
||||
SLOT_ID_PREFIX = "slot"
|
||||
SCOPE_SLOT_ID_PREFIX = "scope_slot"
|
||||
PLAN_SLOT_ID_PREFIX = "plan_slot"
|
||||
|
||||
|
||||
def slot_id_for_row(index: int, *, prefix: str = SLOT_ID_PREFIX) -> str:
|
||||
"""Identity of the ``index``-th (1-based) configured reviewer row.
|
||||
|
||||
The single mint for reviewer-slot identity, and the reason this module's
|
||||
contract says slot identity is separate from model identity. Naming a row
|
||||
after its own model instead collides two rows that share a model (a supported
|
||||
configuration — ``get_scope_review_models`` preserves duplicates on purpose),
|
||||
collides two model spellings that sanitize alike (``openai::gpt-5`` and
|
||||
``openai/gpt/5``), and moves a row's identity the moment the owner edits its
|
||||
model, so the row's receipts stop lining up with its own history. The model,
|
||||
the route and the effort are PROPERTIES of a row, never its name.
|
||||
"""
|
||||
return f"{prefix}_{int(index)}"
|
||||
# The row-identity mint (slot_id_for_row + surface prefixes) moved whole to
|
||||
# ouroboros/review_dispatch.py at the module-size gate; the historical names
|
||||
# stay importable from here for every existing consumer.
|
||||
from ouroboros.review_dispatch import ( # noqa: E402,F401 — re-exports
|
||||
PLAN_SLOT_ID_PREFIX,
|
||||
SCOPE_SLOT_ID_PREFIX,
|
||||
SLOT_ID_PREFIX,
|
||||
slot_id_for_row,
|
||||
stamp_review_paid_on_dispatch,
|
||||
)
|
||||
|
||||
|
||||
def reviewer_slots(
|
||||
|
|
@ -1586,6 +1575,11 @@ def run_review_request(
|
|||
llm: LLMClient | None = None,
|
||||
usage_ctx: Any = None,
|
||||
) -> ReviewRunResult:
|
||||
# Write-ahead paid stamp (Q16 dispatch seam): a gate that meters paid
|
||||
# cycles installed a callback on ctx; it durably lands the paid fact
|
||||
# BEFORE the first reviewer transport call. Assembly-only refusals never
|
||||
# reach this line, so undispatched attempts stay outside every ceiling.
|
||||
stamp_review_paid_on_dispatch(usage_ctx)
|
||||
coordinator = ReviewCoordinator(llm=llm, drive_root=drive_root, usage_ctx=usage_ctx)
|
||||
result = coordinator.run(request, reviewer_slots(role_hint=request.surface) if slots is None else slots)
|
||||
if request.surface == "task_acceptance":
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@ from ouroboros.tools.review_helpers import (
|
|||
from ouroboros.triad_review import emit_review_model_error_events, extract_json_array, parse_model_review_results
|
||||
from ouroboros.utils import (
|
||||
append_jsonl,
|
||||
atomic_write_json,
|
||||
estimate_tokens,
|
||||
sanitize_tool_result_for_log,
|
||||
utc_now_iso,
|
||||
|
|
@ -183,6 +182,18 @@ class SkillReviewOutcome:
|
|||
requested_permissions: List[str] = field(default_factory=list)
|
||||
auto_granted_permissions: List[str] = field(default_factory=list)
|
||||
review_profile: str = ""
|
||||
# Max-Review-Cycles facts (Q17/Q23): ``paid`` = a reviewer panel was
|
||||
# physically dispatched for this outcome (recorded WRITE-AHEAD at dispatch
|
||||
# via the durable dispatch marker; one chunked wave = ONE cycle) with
|
||||
# ``wave_id`` naming that dispatch; the contract fingerprint the panel ran
|
||||
# under; the content hash of the rebuttal supplied; and, for a $0 free
|
||||
# replay, the ts of the recorded verdict it quotes. All land on the
|
||||
# terminal history row.
|
||||
paid: bool = False
|
||||
wave_id: str = ""
|
||||
review_contract_fingerprint: str = ""
|
||||
rebuttal_sha256: str = ""
|
||||
replayed_from_ts: str = ""
|
||||
|
||||
|
||||
def _apply_auto_grant_outcome(outcome: SkillReviewOutcome, skill: Any, auto_grant: Any) -> None:
|
||||
|
|
@ -284,122 +295,18 @@ def _review_history_path(drive_root: pathlib.Path, skill_name: str) -> pathlib.P
|
|||
return review_history_path(drive_root, skill_name)
|
||||
|
||||
|
||||
def _accepted_rebuttals_path(drive_root: pathlib.Path, skill_name: str) -> pathlib.Path:
|
||||
"""Path to persisted accepted rebuttals for one skill."""
|
||||
return drive_root / "state" / "skills" / skill_name / "accepted_rebuttals.json"
|
||||
|
||||
|
||||
def _load_accepted_rebuttals(drive_root: pathlib.Path, skill_name: str) -> List[Dict[str, Any]]:
|
||||
"""Return persisted accepted rebuttals (empty list when none / unreadable)."""
|
||||
path = _accepted_rebuttals_path(drive_root, skill_name)
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
if not isinstance(data, dict):
|
||||
return []
|
||||
items = data.get("items")
|
||||
if not isinstance(items, list):
|
||||
return []
|
||||
out: List[Dict[str, Any]] = []
|
||||
for entry in items:
|
||||
if isinstance(entry, dict):
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
def _persist_rebuttal_flips(
|
||||
drive_root: pathlib.Path,
|
||||
skill_name: str,
|
||||
*,
|
||||
history: List[Dict[str, Any]],
|
||||
findings: List[Dict[str, Any]],
|
||||
review_rebuttal: str,
|
||||
content_hash: str,
|
||||
responded_models: List[str],
|
||||
) -> None:
|
||||
"""Record rebuttals for items that flipped FAIL -> PASS on this attempt."""
|
||||
if not review_rebuttal or not history:
|
||||
return
|
||||
last_fail_items = _fail_items_from_history_entry(history[-1])
|
||||
current_fail_items = {
|
||||
str(f.get("item") or "")
|
||||
for f in findings
|
||||
if isinstance(f, dict)
|
||||
and str(f.get("verdict") or "").upper() == "FAIL"
|
||||
and str(f.get("item") or "")
|
||||
}
|
||||
for item in sorted(last_fail_items - current_fail_items):
|
||||
_record_accepted_rebuttal(
|
||||
drive_root,
|
||||
skill_name,
|
||||
item=item,
|
||||
rebuttal_text=review_rebuttal,
|
||||
content_hash=content_hash,
|
||||
passed_models=list(responded_models),
|
||||
)
|
||||
|
||||
|
||||
def _fail_items_from_history_entry(entry: Dict[str, Any]) -> set[str]:
|
||||
"""Return FAIL item names from both v5.18 and legacy history entries."""
|
||||
out = {
|
||||
str(f.get("item") or "")
|
||||
for f in (entry.get("fail_findings") or [])
|
||||
if isinstance(f, dict) and str(f.get("item") or "")
|
||||
}
|
||||
if out:
|
||||
return out
|
||||
for signature in entry.get("failure_signature") or []:
|
||||
parts = str(signature or "").split(":")
|
||||
if len(parts) >= 2 and parts[1].upper() == "FAIL" and parts[0]:
|
||||
out.add(parts[0])
|
||||
return out
|
||||
|
||||
|
||||
def _record_accepted_rebuttal(
|
||||
drive_root: pathlib.Path,
|
||||
skill_name: str,
|
||||
*,
|
||||
item: str,
|
||||
rebuttal_text: str,
|
||||
content_hash: str,
|
||||
passed_models: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""Persist (or refresh) an accepted rebuttal for ``item``."""
|
||||
path = _accepted_rebuttals_path(drive_root, skill_name)
|
||||
existing = _load_accepted_rebuttals(drive_root, skill_name)
|
||||
target: Optional[Dict[str, Any]] = None
|
||||
for entry in existing:
|
||||
if str(entry.get("item") or "") == item:
|
||||
target = entry
|
||||
break
|
||||
if target is None:
|
||||
target = {
|
||||
"item": item,
|
||||
"rebuttal_text": rebuttal_text,
|
||||
"accepted_at": utc_now_iso(),
|
||||
"content_hash_seen": [content_hash] if content_hash else [],
|
||||
"models_that_passed_after": list(passed_models or []),
|
||||
}
|
||||
existing.append(target)
|
||||
else:
|
||||
target["rebuttal_text"] = rebuttal_text
|
||||
target["accepted_at"] = utc_now_iso()
|
||||
seen = list(target.get("content_hash_seen") or [])
|
||||
if content_hash and content_hash not in seen:
|
||||
seen.append(content_hash)
|
||||
target["content_hash_seen"] = seen
|
||||
if passed_models:
|
||||
target["models_that_passed_after"] = list(passed_models)
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(path, {"items": existing}, trailing_newline=True)
|
||||
except OSError:
|
||||
log.debug("accepted rebuttal write failed", exc_info=True)
|
||||
# The accepted-rebuttal ledger and the Max-Review-Cycles machinery moved whole
|
||||
# to ouroboros/skill_review_cycles.py (module-size gate; same split shape as
|
||||
# phase 0's update_candidate.py). Historical names stay importable from here.
|
||||
from ouroboros.skill_review_cycles import ( # noqa: E402
|
||||
accepted_rebuttals_path as _accepted_rebuttals_path, # noqa: F401 — re-export
|
||||
load_accepted_rebuttals as _load_accepted_rebuttals,
|
||||
fail_items_from_history_entry as _fail_items_from_history_entry, # noqa: F401 — re-export
|
||||
install_skill_dispatch_stamp as _install_skill_dispatch_stamp,
|
||||
persist_rebuttal_flips as _persist_rebuttal_flips,
|
||||
record_accepted_rebuttal as _record_accepted_rebuttal, # noqa: F401 — re-export
|
||||
review_wave_budget_block as _review_wave_budget_block,
|
||||
)
|
||||
|
||||
|
||||
def _build_skill_review_history_section(
|
||||
|
|
@ -1036,53 +943,6 @@ def _run_skill_advisory_pre_review(ctx: Any, *, skill_name: str, file_pack: str)
|
|||
return {"status": "empty", "prompt_section": ""}
|
||||
|
||||
|
||||
def _review_wave_budget_block(
|
||||
ctx: Any,
|
||||
skill_name: str,
|
||||
file_packs: List[str],
|
||||
models: List[str],
|
||||
) -> Optional[str]:
|
||||
"""Return a human-readable refusal when the review wave cannot fit the
|
||||
remaining root budget, else None. Read-only; emits one typed event."""
|
||||
from ouroboros.tools.review_helpers import review_wave_budget_gate
|
||||
|
||||
# Estimate the WHOLE wave: a chunked oversized skill runs one full
|
||||
# reviewer pass PER pack (run_skill_review_passes), and every pass re-sends
|
||||
# the stable governance/checklist/host-contract files the prompt builder
|
||||
# inlines — so both the payload chars and the governance chars multiply by
|
||||
# the pack count. A single-pack estimate would under-admit exactly the
|
||||
# multi-chunk waves most likely to die mid-review.
|
||||
governance_chars = 0
|
||||
for rel in (
|
||||
"docs/ARCHITECTURE.md", "docs/DEVELOPMENT.md", "BIBLE.md",
|
||||
"docs/CHECKLISTS.md", "docs/CREATING_SKILLS.md",
|
||||
):
|
||||
try:
|
||||
governance_chars += int((_REPO_ROOT / rel).stat().st_size)
|
||||
except OSError:
|
||||
pass
|
||||
packs = max(1, len(file_packs))
|
||||
total_chars = sum(len(pack) + governance_chars for pack in file_packs)
|
||||
# One admission slot per PHYSICAL reviewer call (models x packs), each
|
||||
# sized at the average per-pack prompt: the input estimate sums to the
|
||||
# exact wave total while the per-call output reservation also multiplies
|
||||
# by the pack count (a models-only wave under-reserved chunked output).
|
||||
admission = review_wave_budget_gate(
|
||||
ctx, surface="skill_review", models=list(models) * packs,
|
||||
prompt_chars=total_chars // packs,
|
||||
extra={"skill_name": skill_name, "packs": packs},
|
||||
)
|
||||
if admission is None:
|
||||
return None
|
||||
return (
|
||||
"review wave declined before dispatch: estimated reviewer-wave cost "
|
||||
f"~${admission.get('estimated_wave_usd')} exceeds the remaining root budget "
|
||||
f"${admission.get('remaining_usd')} (limit ${admission.get('limit_usd')}). "
|
||||
"No reviewer was called; the skill stays pending. Raise the per-task "
|
||||
"budget or re-run the review in a fresh task."
|
||||
)
|
||||
|
||||
|
||||
def _build_review_prompt_for_attempt(
|
||||
ctx: Any,
|
||||
drive_root: pathlib.Path,
|
||||
|
|
@ -1247,6 +1107,68 @@ def is_official_hub_payload_verified(skill: Any) -> bool:
|
|||
# Public entry point
|
||||
|
||||
|
||||
def _skill_cycles_gate(
|
||||
ctx: Any,
|
||||
skill: Any,
|
||||
drive_root: pathlib.Path,
|
||||
models: List[str],
|
||||
review_rebuttal: str,
|
||||
content_hash: str,
|
||||
) -> tuple[Optional["SkillReviewOutcome"], str, str, str]:
|
||||
"""Max Review Cycles on the skill gate (Q17/Q23), run BEFORE any paid
|
||||
panel: a byte-identical snapshot with a recorded substantive verdict under
|
||||
the same panel contract replays for FREE, and the shared knob bounds PAID
|
||||
panel cycles per ceiling key (root task for task-driven groups; the manual
|
||||
lane per content_hash). Returns ``(early_outcome_or_None,
|
||||
contract_fingerprint, rebuttal_sha256, review_profile)`` — the resolved
|
||||
profile is part of the panel-contract identity and is computed ONCE here
|
||||
for the whole review."""
|
||||
from ouroboros.skill_review_cycles import (
|
||||
free_replay_outcome,
|
||||
skill_review_contract_fingerprint,
|
||||
skill_review_cycles_refusal,
|
||||
)
|
||||
from ouroboros.tools.commit_gate import compute_rebuttal_sha256
|
||||
|
||||
review_profile = _official_hub_review_profile(skill)
|
||||
contract_fp = skill_review_contract_fingerprint(
|
||||
models, required_items=_SKILL_REVIEW_ITEMS, review_profile=review_profile,
|
||||
)
|
||||
rebuttal_sha = compute_rebuttal_sha256(review_rebuttal)
|
||||
group_id = str(getattr(ctx, "_skill_review_group_id", "") or "") or f"manual:{skill.name}"
|
||||
replayed = free_replay_outcome(
|
||||
skill, drive_root=drive_root, group_id=group_id, content_hash=content_hash,
|
||||
contract_fingerprint=contract_fp, rebuttal_sha256=rebuttal_sha,
|
||||
)
|
||||
if replayed is not None:
|
||||
return replayed, contract_fp, rebuttal_sha, review_profile
|
||||
refusal = skill_review_cycles_refusal(
|
||||
ctx, skill.name, drive_root=drive_root, group_id=group_id, models=models,
|
||||
content_hash=content_hash, contract_fingerprint=contract_fp,
|
||||
)
|
||||
return refusal, contract_fp, rebuttal_sha, review_profile
|
||||
|
||||
|
||||
def _stamp_paid_facts(
|
||||
outcome: "SkillReviewOutcome",
|
||||
contract_fp: str,
|
||||
rebuttal_sha: str,
|
||||
stamp: Any = None,
|
||||
) -> "SkillReviewOutcome":
|
||||
"""Max-Review-Cycles facts for a post-panel outcome: the contract and
|
||||
rebuttal identities always ride it; ``paid`` (and the wave id) only when
|
||||
the wave PHYSICALLY dispatched — ``stamp.fired`` mirrors the durable
|
||||
write-ahead dispatch marker recorded before the first transport call
|
||||
(plan-review precedent), so a crash cannot launder the spend and an
|
||||
assembly-refused $0 wave never counts."""
|
||||
outcome.paid = bool(stamp is not None and getattr(stamp, "fired", False))
|
||||
outcome.review_contract_fingerprint = contract_fp
|
||||
outcome.rebuttal_sha256 = rebuttal_sha
|
||||
if outcome.paid:
|
||||
outcome.wave_id = str(getattr(stamp, "wave_id", "") or "")
|
||||
return outcome
|
||||
|
||||
|
||||
def _skill_quorum_failure_outcome(
|
||||
skill: Any,
|
||||
*,
|
||||
|
|
@ -1261,10 +1183,18 @@ def _skill_quorum_failure_outcome(
|
|||
drive_root: pathlib.Path,
|
||||
persist: bool,
|
||||
lifecycle_owns_history: bool = False,
|
||||
paid: bool = False,
|
||||
review_contract_fingerprint: str = "",
|
||||
rebuttal_sha256: str = "",
|
||||
wave_id: str = "",
|
||||
) -> SkillReviewOutcome:
|
||||
"""PENDING outcome for a skill review that missed the adaptive reviewer quorum,
|
||||
preserving the single-reviewer degraded marker on the outcome AND the durable
|
||||
history (extracted to keep ``review_skill`` under the function-size gate)."""
|
||||
history (extracted to keep ``review_skill`` under the function-size gate).
|
||||
The caller passes the Max-Review-Cycles paid facts (F3): the internal
|
||||
history append must carry them — a dispatched quorum failure spent
|
||||
reviewer money, and an unpaid row here would let the direct
|
||||
``review_skill(persist=True)`` path (marketplace install) launder it."""
|
||||
outcome = SkillReviewOutcome(
|
||||
skill_name=skill.name,
|
||||
status=STATUS_PENDING,
|
||||
|
|
@ -1289,6 +1219,10 @@ def _skill_quorum_failure_outcome(
|
|||
findings=findings,
|
||||
raw_actor_records=[record.to_dict() for record in parsed_review.actor_records],
|
||||
single_reviewer_no_diversity=single_reviewer_no_diversity,
|
||||
paid=paid,
|
||||
review_contract_fingerprint=review_contract_fingerprint,
|
||||
rebuttal_sha256=rebuttal_sha256,
|
||||
wave_id=wave_id,
|
||||
)
|
||||
return outcome
|
||||
|
||||
|
|
@ -1417,6 +1351,12 @@ def review_skill(
|
|||
skill_name=skill.name, status=STATUS_PENDING, content_hash=content_hash,
|
||||
error=f"invalid reviewer-slot configuration blocks skill review: {slot_err}")
|
||||
models = list(get_review_models())
|
||||
early_outcome, contract_fp, rebuttal_sha, review_profile = _skill_cycles_gate(
|
||||
ctx, skill, drive_root, models, review_rebuttal, content_hash,
|
||||
)
|
||||
if early_outcome is not None:
|
||||
return early_outcome
|
||||
|
||||
if len(file_packs) > 1:
|
||||
log.warning(
|
||||
"Skill %s exceeds the reviewer token budget; reviewing in %d chunked passes.",
|
||||
|
|
@ -1433,52 +1373,68 @@ def review_skill(
|
|||
error=budget_block)
|
||||
from ouroboros.skill_review_passes import run_skill_review_passes
|
||||
|
||||
prompt, advisory_evidence, result_json_text, infra_error = run_skill_review_passes(
|
||||
ctx,
|
||||
drive_root,
|
||||
skill,
|
||||
evidence={
|
||||
"manifest_dump": manifest_dump,
|
||||
"content_hash": content_hash,
|
||||
"history": history,
|
||||
"review_rebuttal": review_rebuttal,
|
||||
"required_items": _SKILL_REVIEW_ITEMS,
|
||||
},
|
||||
file_packs=file_packs,
|
||||
models=models,
|
||||
build_prompt=_build_review_prompt_for_attempt,
|
||||
run_review=_handle_multi_model_review,
|
||||
# F3 write-ahead seam: the durable dispatch marker lands immediately
|
||||
# before the panel's first transport call (assembly failures inside the
|
||||
# pass runner stay $0); every post-panel outcome below derives its paid
|
||||
# fact from whether the stamp actually fired.
|
||||
stamp, _prior_stamp = _install_skill_dispatch_stamp(
|
||||
ctx, drive_root, skill.name,
|
||||
group_id=str(getattr(ctx, "_skill_review_group_id", "") or "") or f"manual:{skill.name}",
|
||||
content_hash=content_hash, contract_fp=contract_fp, rebuttal_sha=rebuttal_sha,
|
||||
)
|
||||
|
||||
def _paid_facts(outcome: SkillReviewOutcome) -> SkillReviewOutcome:
|
||||
return _stamp_paid_facts(outcome, contract_fp, rebuttal_sha, stamp)
|
||||
|
||||
try:
|
||||
prompt, advisory_evidence, result_json_text, infra_error = run_skill_review_passes(
|
||||
ctx,
|
||||
drive_root,
|
||||
skill,
|
||||
evidence={
|
||||
"manifest_dump": manifest_dump,
|
||||
"content_hash": content_hash,
|
||||
"history": history,
|
||||
"review_rebuttal": review_rebuttal,
|
||||
"required_items": _SKILL_REVIEW_ITEMS,
|
||||
},
|
||||
file_packs=file_packs,
|
||||
models=models,
|
||||
build_prompt=_build_review_prompt_for_attempt,
|
||||
run_review=_handle_multi_model_review,
|
||||
)
|
||||
finally:
|
||||
ctx._review_paid_stamp = _prior_stamp
|
||||
if infra_error:
|
||||
log.warning("Skill review infrastructure failure for %s: %s", skill.name, infra_error)
|
||||
return SkillReviewOutcome(
|
||||
return _paid_facts(SkillReviewOutcome(
|
||||
skill_name=skill.name,
|
||||
status=STATUS_PENDING,
|
||||
reviewer_models=models,
|
||||
content_hash=content_hash,
|
||||
error=f"infrastructure failure: {sanitize_tool_result_for_log(infra_error)}",
|
||||
)
|
||||
))
|
||||
|
||||
try:
|
||||
result_json = json.loads(result_json_text)
|
||||
except json.JSONDecodeError:
|
||||
return SkillReviewOutcome(
|
||||
return _paid_facts(SkillReviewOutcome(
|
||||
skill_name=skill.name,
|
||||
status=STATUS_PENDING,
|
||||
reviewer_models=models,
|
||||
content_hash=content_hash,
|
||||
error="review returned non-JSON top-level response",
|
||||
raw_result=_truncate_raw_result(result_json_text),
|
||||
)
|
||||
))
|
||||
|
||||
if "error" in result_json:
|
||||
return SkillReviewOutcome(
|
||||
return _paid_facts(SkillReviewOutcome(
|
||||
skill_name=skill.name,
|
||||
status=STATUS_PENDING,
|
||||
reviewer_models=models,
|
||||
content_hash=content_hash,
|
||||
error=f"review service error: {result_json['error']}",
|
||||
)
|
||||
))
|
||||
|
||||
parsed_review = parse_model_review_results(result_json, required_items=_SKILL_REVIEW_ITEMS)
|
||||
emit_review_model_error_events(ctx, parsed_review, source="skill_review", skill_name=skill.name)
|
||||
|
|
@ -1491,7 +1447,7 @@ def review_skill(
|
|||
# P3) — in the outcome + review history below, not just this log line.
|
||||
log.warning("Skill review (trust gate) ran with a single reviewer (single_reviewer_no_diversity).")
|
||||
if len(responded_models) < required_quorum:
|
||||
return _skill_quorum_failure_outcome(
|
||||
return _paid_facts(_skill_quorum_failure_outcome(
|
||||
skill,
|
||||
findings=findings,
|
||||
models=models,
|
||||
|
|
@ -1504,16 +1460,23 @@ def review_skill(
|
|||
drive_root=drive_root,
|
||||
persist=persist,
|
||||
lifecycle_owns_history=bool(getattr(ctx, "_skill_review_lifecycle_guard", False)),
|
||||
)
|
||||
# F3(a): the internal history append must carry the paid facts —
|
||||
# a dispatched quorum failure spent reviewer money.
|
||||
paid=bool(getattr(stamp, "fired", False)),
|
||||
review_contract_fingerprint=contract_fp,
|
||||
rebuttal_sha256=rebuttal_sha,
|
||||
wave_id=str(getattr(stamp, "wave_id", "") or "") if getattr(stamp, "fired", False) else "",
|
||||
))
|
||||
|
||||
review_profile = _official_hub_review_profile(skill)
|
||||
# review_profile was resolved ONCE in _skill_cycles_gate (it is part of
|
||||
# the panel-contract fingerprint) and rides down to aggregation here.
|
||||
status = _aggregate_status(
|
||||
findings,
|
||||
skill_type=skill.manifest.type,
|
||||
is_module_widget=_is_module_widget_skill(skill),
|
||||
review_profile=review_profile,
|
||||
)
|
||||
outcome = SkillReviewOutcome(
|
||||
outcome = _paid_facts(SkillReviewOutcome(
|
||||
skill_name=skill.name,
|
||||
status=status,
|
||||
findings=findings,
|
||||
|
|
@ -1526,69 +1489,77 @@ def review_skill(
|
|||
advisory_result=advisory_evidence,
|
||||
convergence_hint=_convergence_hint(history, findings, current_status=status),
|
||||
review_profile=review_profile,
|
||||
)
|
||||
))
|
||||
|
||||
if persist:
|
||||
if getattr(ctx, "_skill_review_lifecycle_guard", False):
|
||||
from ouroboros.skill_review_runner import _can_persist_review_outcome
|
||||
return _persist_reviewed_outcome(
|
||||
ctx, skill, drive_root, outcome,
|
||||
history=history, review_rebuttal=review_rebuttal,
|
||||
contract_fp=contract_fp, rebuttal_sha=rebuttal_sha,
|
||||
)
|
||||
return outcome
|
||||
|
||||
if not _can_persist_review_outcome(
|
||||
drive_root,
|
||||
skill.name,
|
||||
content_hash,
|
||||
expected_job_id=str(getattr(ctx, "_skill_review_lifecycle_job_id", "") or ""),
|
||||
):
|
||||
outcome.status = STATUS_PENDING
|
||||
outcome.error = (
|
||||
"review outcome was not persisted because the lifecycle job "
|
||||
"is already terminal or no longer matches this content hash"
|
||||
)
|
||||
return outcome
|
||||
save_review_state(
|
||||
|
||||
def _persist_reviewed_outcome(
|
||||
ctx: Any,
|
||||
skill: Any,
|
||||
drive_root: pathlib.Path,
|
||||
outcome: SkillReviewOutcome,
|
||||
*,
|
||||
history: List[Dict[str, Any]],
|
||||
review_rebuttal: str,
|
||||
contract_fp: str,
|
||||
rebuttal_sha: str,
|
||||
) -> SkillReviewOutcome:
|
||||
"""Persist one reviewed verdict (state, history, rebuttal flips, auto-grant)
|
||||
— extracted whole from ``review_skill`` at the function-size gate."""
|
||||
content_hash = outcome.content_hash
|
||||
if getattr(ctx, "_skill_review_lifecycle_guard", False):
|
||||
from ouroboros.skill_review_runner import _can_persist_review_outcome
|
||||
|
||||
if not _can_persist_review_outcome(
|
||||
drive_root,
|
||||
skill.name,
|
||||
SkillReviewState(
|
||||
status=outcome.status,
|
||||
content_hash=content_hash,
|
||||
findings=findings,
|
||||
reviewer_models=responded_models,
|
||||
timestamp=utc_now_iso(),
|
||||
prompt_chars=outcome.prompt_chars,
|
||||
cost_usd=outcome.cost_usd,
|
||||
raw_result=outcome.raw_result,
|
||||
raw_actor_records=[record.to_dict() for record in parsed_review.actor_records],
|
||||
advisory_result=dict(advisory_evidence or {}),
|
||||
review_profile=review_profile,
|
||||
),
|
||||
)
|
||||
if not getattr(ctx, "_skill_review_lifecycle_guard", False):
|
||||
_append_skill_review_history(
|
||||
drive_root, skill.name,
|
||||
status=outcome.status, content_hash=content_hash, findings=findings,
|
||||
single_reviewer_no_diversity=single_reviewer_no_diversity,
|
||||
content_hash,
|
||||
expected_job_id=str(getattr(ctx, "_skill_review_lifecycle_job_id", "") or ""),
|
||||
):
|
||||
outcome.status = STATUS_PENDING
|
||||
outcome.error = (
|
||||
"review outcome was not persisted because the lifecycle job "
|
||||
"is already terminal or no longer matches this content hash"
|
||||
)
|
||||
_persist_rebuttal_flips(
|
||||
return outcome
|
||||
persisted_state = SkillReviewState(
|
||||
status=outcome.status,
|
||||
content_hash=content_hash,
|
||||
findings=outcome.findings,
|
||||
reviewer_models=outcome.reviewer_models,
|
||||
timestamp=utc_now_iso(),
|
||||
prompt_chars=outcome.prompt_chars,
|
||||
cost_usd=outcome.cost_usd,
|
||||
raw_result=outcome.raw_result,
|
||||
raw_actor_records=list(outcome.raw_actor_records or []),
|
||||
advisory_result=dict(outcome.advisory_result or {}),
|
||||
review_profile=outcome.review_profile,
|
||||
)
|
||||
save_review_state(drive_root, skill.name, persisted_state)
|
||||
if not getattr(ctx, "_skill_review_lifecycle_guard", False):
|
||||
_append_skill_review_history(
|
||||
drive_root, skill.name,
|
||||
history=history, findings=findings,
|
||||
review_rebuttal=review_rebuttal, content_hash=content_hash,
|
||||
responded_models=list(responded_models),
|
||||
status=outcome.status, content_hash=content_hash, findings=outcome.findings,
|
||||
single_reviewer_no_diversity=outcome.single_reviewer_no_diversity,
|
||||
paid=outcome.paid, review_contract_fingerprint=contract_fp,
|
||||
rebuttal_sha256=rebuttal_sha, wave_id=outcome.wave_id,
|
||||
)
|
||||
skill.review = SkillReviewState(
|
||||
status=outcome.status,
|
||||
content_hash=content_hash,
|
||||
findings=findings,
|
||||
reviewer_models=responded_models,
|
||||
timestamp=utc_now_iso(),
|
||||
prompt_chars=outcome.prompt_chars,
|
||||
cost_usd=outcome.cost_usd,
|
||||
raw_result=outcome.raw_result,
|
||||
raw_actor_records=[record.to_dict() for record in parsed_review.actor_records],
|
||||
advisory_result=dict(advisory_evidence or {}),
|
||||
review_profile=review_profile,
|
||||
)
|
||||
auto_grant = auto_grant_if_enabled(drive_root, skill)
|
||||
_apply_auto_grant_outcome(outcome, skill, auto_grant)
|
||||
|
||||
_persist_rebuttal_flips(
|
||||
drive_root, skill.name,
|
||||
history=history, findings=outcome.findings,
|
||||
review_rebuttal=review_rebuttal, content_hash=content_hash,
|
||||
responded_models=list(outcome.reviewer_models),
|
||||
)
|
||||
skill.review = persisted_state
|
||||
auto_grant = auto_grant_if_enabled(drive_root, skill)
|
||||
_apply_auto_grant_outcome(outcome, skill, auto_grant)
|
||||
return outcome
|
||||
|
||||
|
||||
|
|
|
|||
649
ouroboros/skill_review_cycles.py
Normal file
649
ouroboros/skill_review_cycles.py
Normal file
|
|
@ -0,0 +1,649 @@
|
|||
"""Max-Review-Cycles semantics for skill review, plus the rebuttal ledger.
|
||||
|
||||
Owner decisions Q12/Q16/Q17/Q23 aligned every review gate on ONE meaning of
|
||||
``OUROBOROS_REVIEW_MAX_CYCLES``: it bounds PAID review cycles per task, and
|
||||
identical material is never re-reviewed for pay. For skill review that means:
|
||||
|
||||
* **Paid cycle** = one physical reviewer-panel dispatch, counted as MONEY:
|
||||
every dispatched wave counts whatever its terminal (a quorum-failed panel
|
||||
still spent reviewer money); only undispatched attempts stay outside the
|
||||
count. A chunked oversized skill (multiple packs) is ONE cycle per wave,
|
||||
not per pack. The dispatch fact is recorded WRITE-AHEAD: a durable dispatch
|
||||
marker (``skill_review_history``) lands immediately before the first
|
||||
transport call, shared by the lifecycle runner and direct callers; terminal
|
||||
history rows merge it, and an unmerged marker still counts (a crashed or
|
||||
swallowed wave spent the money). Counts are DERIVED from the existing
|
||||
append-only review history plus that marker (P7 — no counter file).
|
||||
* **Ceiling key** (Q23): a task-driven review group (``task:<root>:<skill>``)
|
||||
shares the root task's ceiling across every skill that task reviews (a
|
||||
follow-up task is a fresh root); a manual group (``manual:<skill>``) has no
|
||||
session boundary, so its ceiling is scoped to the CURRENT content_hash —
|
||||
revised skill content always starts a fresh manual count.
|
||||
* **Free replay** (Q17-A): an identical ``(group_id, content_hash,
|
||||
roster/contract fingerprint)`` with a recorded SUBSTANTIVE terminal verdict
|
||||
(clean/warnings/blockers — never pending/interrupted/timeout/failed/
|
||||
cancelled, which are infra facts) and no NEW rebuttal replays the recorded
|
||||
verdict at $0, quoting it. A rebuttal is identified by CONTENT: a hash new
|
||||
to the snapshot's rows buys exactly one paid rerun.
|
||||
* **Exhaustion** = free typed refusal + ``emit_review_cycles_exhausted``
|
||||
(surface ``skill_review``) with honest LLM-first exits, never a silent grind.
|
||||
|
||||
This module also owns the accepted-rebuttal ledger (moved whole from
|
||||
``skill_review.py`` at the module-size gate; ``skill_review`` re-exports the
|
||||
historical names) — rebuttals are the currency the cycle semantics meter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import pathlib
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ouroboros.review_cycles import emit_review_cycles_exhausted, review_max_cycles
|
||||
from ouroboros.skill_review_history import load_history
|
||||
from ouroboros.skill_review_status import (
|
||||
STATUS_BLOCKERS,
|
||||
STATUS_CLEAN,
|
||||
STATUS_PENDING,
|
||||
STATUS_WARNINGS,
|
||||
normalize_skill_review_status,
|
||||
)
|
||||
from ouroboros.utils import atomic_write_json, iter_jsonl_objects, utc_now_iso
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Resolve repo root from this file for source and packaged builds.
|
||||
_REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
|
||||
# The ONLY statuses a free replay may quote: real reviewer verdicts. Everything
|
||||
# else on a terminal row (pending/interrupted/timeout/failed/cancelled) is an
|
||||
# infrastructure fact. The exact rule (F4): refusal/replay ELIGIBILITY
|
||||
# (substantive verdicts only — infra terminals never replay, never lapse a
|
||||
# streak, and never "spend" the rebuttal they carried) is distinct from MONEY
|
||||
# accounting (every dispatched wave counts toward the ceiling via its ``paid``
|
||||
# fact, whatever its terminal status). A dispatched infra terminal therefore
|
||||
# consumes money but leaves the rebuttal fresh for the paid rerun it is owed.
|
||||
SUBSTANTIVE_SKILL_REVIEW_STATUSES = frozenset(
|
||||
{STATUS_CLEAN, STATUS_WARNINGS, STATUS_BLOCKERS}
|
||||
)
|
||||
|
||||
|
||||
def _skill_prompt_contract_hash() -> str:
|
||||
"""Identity of the SHIPPED skill-review prompt/aggregation contract: the
|
||||
prompt builder's source text (the template literals ARE the contract) plus
|
||||
the aggregation vocabulary skill_review_status pins. Any shipped change to
|
||||
either lapses free-replay authority — conservative in the paid direction.
|
||||
Fail-open "": an unreadable contract never matches, so nothing replays."""
|
||||
try:
|
||||
import inspect
|
||||
|
||||
from ouroboros import skill_review as _skill_review
|
||||
from ouroboros import skill_review_status as _status
|
||||
|
||||
parts = [
|
||||
inspect.getsource(_skill_review._build_review_prompt),
|
||||
json.dumps(sorted(_status.HARD_CRITICAL_ITEMS)),
|
||||
json.dumps(sorted(_status.SEVERITY_DRIVEN_ITEMS)),
|
||||
str(int(getattr(_status, "WARNINGS_CONVERGENCE_ROUNDS", 0) or 0)),
|
||||
]
|
||||
return hashlib.sha256("\n".join(parts).encode("utf-8")).hexdigest()
|
||||
except Exception:
|
||||
log.debug("skill prompt-contract hash unavailable (fail-open)", exc_info=True)
|
||||
return ""
|
||||
|
||||
|
||||
def skill_review_contract_fingerprint(
|
||||
models: List[str], *, required_items: Any = (), review_profile: str = ""
|
||||
) -> str:
|
||||
"""Identity of the skill-review panel contract (Q17/Q22 pattern:
|
||||
plan_review's ``reviewer_config_fingerprint``): the configured roster, the
|
||||
required-item vocabulary, the shipped prompt/aggregation contract text
|
||||
(skill-4 parity with the commit gate) and the skill's RESOLVED review
|
||||
profile (official_hub aggregates blockers differently — a profile change
|
||||
must lapse replay). A changed fingerprint lapses free-replay authority —
|
||||
a fresh paid panel is due."""
|
||||
prompt_contract = _skill_prompt_contract_hash()
|
||||
if not prompt_contract:
|
||||
return "" # unknown contract never matches (fail-open toward paying)
|
||||
payload = json.dumps(
|
||||
{
|
||||
"models": [str(model) for model in (models or [])],
|
||||
"required_items": [str(item) for item in (required_items or ())],
|
||||
"prompt_contract": prompt_contract,
|
||||
"review_profile": str(review_profile or ""),
|
||||
},
|
||||
sort_keys=True,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _group_root_task_id(group_id: str) -> str:
|
||||
parts = str(group_id or "").split(":", 2)
|
||||
return parts[1] if len(parts) == 3 and parts[0] == "task" and parts[1] else ""
|
||||
|
||||
|
||||
def _paid_row(row: Any) -> bool:
|
||||
# The ceiling counts MONEY (machine-5): every row whose panel physically
|
||||
# dispatched counts, whatever its terminal (a quorum-failed wave still
|
||||
# spent reviewer money). Rows without the paid fact — free replays,
|
||||
# pre-dispatch refusals, pre-upgrade history, interrupted jobs whose
|
||||
# dispatch is unknown — never count (fail-open).
|
||||
return isinstance(row, dict) and bool(row.get("paid"))
|
||||
|
||||
|
||||
def _unmerged_marker_paid(
|
||||
drive_root: pathlib.Path,
|
||||
skill_name: str,
|
||||
rows: List[Dict[str, Any]],
|
||||
*,
|
||||
group_id: str = "",
|
||||
content_hash: str = "",
|
||||
root: str = "",
|
||||
) -> int:
|
||||
"""1 when this skill's write-ahead dispatch marker records a paid wave on
|
||||
the given ceiling key whose terminal row has NOT landed yet (crashed wave,
|
||||
or a direct-call infra outcome without a history row) — the money was
|
||||
spent before the first transport call and must count. 0 otherwise: a
|
||||
landed terminal row already carries the merged facts (the marker is
|
||||
cleared on merge; the wave-key check below also guards a failed clear)."""
|
||||
from ouroboros.skill_review_history import load_dispatch_marker
|
||||
|
||||
marker = load_dispatch_marker(pathlib.Path(drive_root), skill_name)
|
||||
wave = str(marker.get("wave_id") or "")
|
||||
if not wave or not marker.get("paid"):
|
||||
return 0
|
||||
if root:
|
||||
if str(marker.get("root_task_id") or "") != root:
|
||||
return 0
|
||||
elif (
|
||||
str(marker.get("group_id") or "") != str(group_id or "")
|
||||
or str(marker.get("content_hash") or "") != str(content_hash or "")
|
||||
):
|
||||
return 0
|
||||
for row in rows:
|
||||
if str(row.get("wave_id") or row.get("job_id") or "") == wave:
|
||||
return 0
|
||||
return 1
|
||||
|
||||
|
||||
def count_paid_skill_review_cycles(
|
||||
drive_root: pathlib.Path, skill_name: str, group_id: str, *, content_hash: str = ""
|
||||
) -> int:
|
||||
"""Paid panel cycles already spent on the ceiling key, derived from the
|
||||
append-only review history (P7) plus this skill's unmerged write-ahead
|
||||
dispatch marker (a dispatched wave whose terminal row never landed still
|
||||
spent the money). Task-driven groups count across EVERY skill the root
|
||||
task reviewed (terminal rows carry ``root_task_id``) — the root task is a
|
||||
naturally bounded lifetime. The manual lane has no session boundary, so
|
||||
its ceiling is scoped to the CURRENT content_hash lineage
|
||||
(machine-1/skill-1): paid rows for the SAME snapshot only — a revised
|
||||
skill (new content_hash) always starts a fresh count, mirroring the
|
||||
commit gate's changed-diff reset and Q23-A's "manual = its own task".
|
||||
Marketplace-install rows normalize into the same manual lane and are
|
||||
scoped identically."""
|
||||
root = _group_root_task_id(group_id)
|
||||
if not root:
|
||||
rows = load_history(drive_root, skill_name, limit=0)
|
||||
paid = sum(
|
||||
1
|
||||
for row in rows
|
||||
if str(row.get("group_id") or "") == str(group_id or "")
|
||||
and _paid_row(row)
|
||||
and str(row.get("content_hash") or "") == str(content_hash or "")
|
||||
)
|
||||
return paid + _unmerged_marker_paid(
|
||||
drive_root, skill_name, rows,
|
||||
group_id=group_id, content_hash=content_hash,
|
||||
)
|
||||
count = 0
|
||||
skills_root = pathlib.Path(drive_root) / "state" / "skills"
|
||||
try:
|
||||
skill_dirs = sorted(path for path in skills_root.iterdir() if path.is_dir())
|
||||
except OSError:
|
||||
return 0
|
||||
for skill_dir in skill_dirs:
|
||||
rows: List[Dict[str, Any]] = []
|
||||
try:
|
||||
rows = [
|
||||
row
|
||||
for row in iter_jsonl_objects(skill_dir / "review_history.jsonl")
|
||||
if isinstance(row, dict)
|
||||
]
|
||||
except OSError:
|
||||
rows = []
|
||||
count += sum(
|
||||
1
|
||||
for row in rows
|
||||
if str(row.get("root_task_id") or "") == root and _paid_row(row)
|
||||
)
|
||||
count += _unmerged_marker_paid(drive_root, skill_dir.name, rows, root=root)
|
||||
return count
|
||||
|
||||
|
||||
def find_free_replay_row(
|
||||
drive_root: pathlib.Path,
|
||||
skill_name: str,
|
||||
*,
|
||||
group_id: str,
|
||||
content_hash: str,
|
||||
contract_fingerprint: str,
|
||||
rebuttal_sha256: str = "",
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""The recorded verdict a free replay may quote, or ``None`` for a paid run.
|
||||
|
||||
Replays require the identical triple ``(group_id, content_hash,
|
||||
roster/contract fingerprint)`` and a SUBSTANTIVE terminal verdict; rows
|
||||
without a recorded fingerprint (pre-upgrade) never replay, and a rebuttal
|
||||
hash new to this snapshot's rows buys exactly one paid rerun (a repeated
|
||||
hash replays free, quoting the previous outcome)."""
|
||||
if not content_hash or not contract_fingerprint:
|
||||
return None
|
||||
rows = [
|
||||
row
|
||||
for row in load_history(drive_root, skill_name, limit=0, group_id=group_id)
|
||||
if str(row.get("content_hash") or "") == content_hash
|
||||
]
|
||||
if not rows:
|
||||
return None
|
||||
if rebuttal_sha256:
|
||||
# A rebuttal is "spent" only when a SUBSTANTIVE verdict answered it
|
||||
# (machine-6/skill-3): one recorded on an infra terminal (quorum
|
||||
# failure, timeout, interrupted) bought a wave that never delivered a
|
||||
# verdict, so it stays fresh and buys the paid rerun it is owed.
|
||||
# Spent-hash memory is scoped to the CURRENT contract fingerprint
|
||||
# (F4/Q22 parity with replay): a contract lapse clears the streak, so
|
||||
# a rebuttal answered under a retired roster/prompt contract is fresh
|
||||
# again for the contract that has never adjudicated it.
|
||||
seen_rebuttals = {
|
||||
str(row.get("rebuttal_sha256") or "")
|
||||
for row in rows
|
||||
if row.get("rebuttal_sha256")
|
||||
and str(row.get("review_contract_fingerprint") or "") == contract_fingerprint
|
||||
and normalize_skill_review_status(str(row.get("status") or ""))
|
||||
in SUBSTANTIVE_SKILL_REVIEW_STATUSES
|
||||
}
|
||||
if rebuttal_sha256 not in seen_rebuttals:
|
||||
return None # a NEW rebuttal = one paid rerun
|
||||
for row in reversed(rows):
|
||||
status = normalize_skill_review_status(str(row.get("status") or ""))
|
||||
if status not in SUBSTANTIVE_SKILL_REVIEW_STATUSES:
|
||||
continue # infra facts (pending/interrupted/timeout/…) neither replay nor lapse
|
||||
if str(row.get("review_contract_fingerprint") or "") != contract_fingerprint:
|
||||
return None # roster/contract changed (or legacy row): a paid review is due
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _quote_replay_row(row: Dict[str, Any]) -> str:
|
||||
fails = [f for f in (row.get("fail_findings") or []) if isinstance(f, dict)]
|
||||
if not fails:
|
||||
return ""
|
||||
lines = ["", "Recorded FAIL findings:"]
|
||||
for finding in fails[:5]:
|
||||
lines.append(
|
||||
f" - [{str(finding.get('severity') or '?').upper()}] "
|
||||
f"{finding.get('item') or '?'}: {finding.get('reason_excerpt') or ''}"
|
||||
)
|
||||
if len(fails) > 5:
|
||||
lines.append(f" … and {len(fails) - 5} more in the skill's review history.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def free_replay_outcome(
|
||||
skill: Any,
|
||||
*,
|
||||
drive_root: pathlib.Path,
|
||||
group_id: str,
|
||||
content_hash: str,
|
||||
contract_fingerprint: str,
|
||||
rebuttal_sha256: str = "",
|
||||
) -> Optional[Any]:
|
||||
"""A $0 ``SkillReviewOutcome`` replaying the recorded verdict (Q17-A), or
|
||||
``None`` when a paid panel is due.
|
||||
|
||||
A replay is allowed ONLY when the mutable persisted review state
|
||||
(review.json → ``skill.review``) already covers this exact
|
||||
(content_hash, status) — the $0 path must be effect-equivalent to the
|
||||
paid path, and the paid persist pipeline (state write, grants) already
|
||||
ran when that state landed. When the persisted state has diverged (e.g.
|
||||
content was edited away and reverted), the replay falls through to a
|
||||
PAID rerun that re-persists properly — never a live-lock on a stale
|
||||
verdict (skill-2)."""
|
||||
row = find_free_replay_row(
|
||||
drive_root,
|
||||
str(getattr(skill, "name", "") or ""),
|
||||
group_id=group_id,
|
||||
content_hash=content_hash,
|
||||
contract_fingerprint=contract_fingerprint,
|
||||
rebuttal_sha256=rebuttal_sha256,
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
from ouroboros.skill_review import SkillReviewOutcome
|
||||
|
||||
status = normalize_skill_review_status(str(row.get("status") or ""))
|
||||
review_state = getattr(skill, "review", None)
|
||||
if (
|
||||
review_state is None
|
||||
or str(getattr(review_state, "content_hash", "") or "") != content_hash
|
||||
or normalize_skill_review_status(str(getattr(review_state, "status", "") or ""))
|
||||
!= status
|
||||
):
|
||||
return None # persisted state diverged from the history row: pay and re-persist
|
||||
findings: List[Dict[str, Any]] = list(getattr(review_state, "findings", []) or [])
|
||||
reviewer_models: List[str] = list(getattr(review_state, "reviewer_models", []) or [])
|
||||
return SkillReviewOutcome(
|
||||
skill_name=str(getattr(skill, "name", "") or ""),
|
||||
status=status,
|
||||
findings=findings,
|
||||
reviewer_models=reviewer_models,
|
||||
content_hash=content_hash,
|
||||
review_contract_fingerprint=contract_fingerprint,
|
||||
rebuttal_sha256=rebuttal_sha256,
|
||||
replayed_from_ts=str(row.get("ts") or ""),
|
||||
single_reviewer_no_diversity=bool(row.get("single_reviewer_no_diversity")),
|
||||
convergence_hint=(
|
||||
"FREE REPLAY (Max Review Cycles): this exact skill snapshot "
|
||||
f"(content_hash {content_hash[:12]}) already has a recorded "
|
||||
f"'{status}' verdict from {row.get('ts') or 'an earlier run'} under the "
|
||||
"same reviewer roster — no paid panel was dispatched and no cycle was "
|
||||
"spent. Change the skill content, or supply a NEW review_rebuttal to buy "
|
||||
"exactly one paid rerun." + _quote_replay_row(row)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def skill_review_cycles_refusal(
|
||||
ctx: Any,
|
||||
skill_name: str,
|
||||
*,
|
||||
drive_root: pathlib.Path,
|
||||
group_id: str,
|
||||
models: List[str],
|
||||
content_hash: str,
|
||||
contract_fingerprint: str,
|
||||
) -> Optional[Any]:
|
||||
"""Free typed refusal when the ceiling key already spent
|
||||
``review_max_cycles()`` paid panel cycles; ``None`` allows the dispatch.
|
||||
Fail-open on ledger errors — a cost guard, not a safety gate. DISCLOSED
|
||||
RESIDUAL (skill-5): read-at-gate-time with no reservation — the paid fact
|
||||
lands write-ahead at first physical dispatch (the dispatch marker), which
|
||||
narrows but does not close the window: concurrent dispatches sharing one
|
||||
ceiling key can still each read ``paid < cap`` and overshoot by the
|
||||
concurrency width."""
|
||||
cap = review_max_cycles()
|
||||
if cap is None:
|
||||
return None
|
||||
try:
|
||||
paid = count_paid_skill_review_cycles(
|
||||
drive_root, skill_name, group_id, content_hash=content_hash,
|
||||
)
|
||||
except Exception:
|
||||
log.debug("paid skill-review cycle count failed (fail-open)", exc_info=True)
|
||||
return None
|
||||
if paid < cap:
|
||||
return None
|
||||
root = _group_root_task_id(group_id)
|
||||
lane = (
|
||||
f"root task {root}"
|
||||
if root
|
||||
else f"manual lane {group_id} for snapshot {str(content_hash or '')[:12]}"
|
||||
)
|
||||
fresh_exit = (
|
||||
"leave the review to a fresh task with its own ceiling"
|
||||
if root
|
||||
else "revise the skill content — a new snapshot starts a fresh manual count"
|
||||
)
|
||||
message = (
|
||||
f"⚠️ REVIEW_CYCLES_EXHAUSTED: {lane} already spent {paid} of {cap} paid "
|
||||
"skill-review panel cycle(s) (OUROBOROS_REVIEW_MAX_CYCLES). Refusing to buy "
|
||||
"another panel; the skill stays honestly pending. Decide the honest exit "
|
||||
"yourself: finalize and disclose the unreviewed skill, ask the owner to raise "
|
||||
f"Max Review Cycles (3/5/unlimited are one settings change away), or {fresh_exit}. "
|
||||
"A rebuttal cannot buy past the ceiling — rebuttal cycles count toward it."
|
||||
)
|
||||
try:
|
||||
from ouroboros.config import get_review_enforcement
|
||||
|
||||
enforcement = str(get_review_enforcement() or "")
|
||||
except Exception:
|
||||
enforcement = ""
|
||||
emit_review_cycles_exhausted(
|
||||
getattr(ctx, "event_queue", None),
|
||||
drive_root,
|
||||
surface="skill_review",
|
||||
task_id=str(getattr(ctx, "task_id", "") or ""),
|
||||
cycles_paid=int(paid),
|
||||
cap=int(cap),
|
||||
enforcement=enforcement,
|
||||
group_id=str(group_id or ""),
|
||||
skill=str(skill_name or ""),
|
||||
root_task_id=root,
|
||||
)
|
||||
from ouroboros.skill_review import SkillReviewOutcome
|
||||
|
||||
return SkillReviewOutcome(
|
||||
skill_name=str(skill_name or ""),
|
||||
status=STATUS_PENDING,
|
||||
reviewer_models=list(models or []),
|
||||
content_hash=str(content_hash or ""),
|
||||
review_contract_fingerprint=str(contract_fingerprint or ""),
|
||||
error=message,
|
||||
)
|
||||
|
||||
|
||||
def install_skill_dispatch_stamp(
|
||||
ctx: Any,
|
||||
drive_root: pathlib.Path,
|
||||
skill_name: str,
|
||||
*,
|
||||
group_id: str,
|
||||
content_hash: str,
|
||||
contract_fp: str,
|
||||
rebuttal_sha: str,
|
||||
) -> tuple[Any, Any]:
|
||||
"""Install the write-ahead paid stamp for ONE skill-review wave (F3, the
|
||||
same seam as the commit gate's F2 stamp): the shared reviewer transport
|
||||
entry invokes it via ``ctx._review_paid_stamp`` immediately before the
|
||||
first physical panel call, durably writing the ONE dispatch marker shared
|
||||
by lifecycle and direct callers (the lifecycle job id names the wave when
|
||||
present, so a timeout terminal can merge the marker back). Returns
|
||||
``(stamp, previous_attr_value)`` — the caller restores the previous value
|
||||
after the wave."""
|
||||
import uuid
|
||||
|
||||
from ouroboros.review_dispatch import ReviewPaidStamp
|
||||
from ouroboros.skill_review_history import write_dispatch_marker
|
||||
|
||||
wave_id = (
|
||||
str(getattr(ctx, "_skill_review_lifecycle_job_id", "") or "") or uuid.uuid4().hex
|
||||
)
|
||||
|
||||
def _write() -> None:
|
||||
write_dispatch_marker(
|
||||
drive_root,
|
||||
skill_name,
|
||||
wave_id=wave_id,
|
||||
group_id=group_id,
|
||||
content_hash=content_hash,
|
||||
root_task_id=_group_root_task_id(group_id),
|
||||
review_contract_fingerprint=contract_fp,
|
||||
rebuttal_sha256=rebuttal_sha,
|
||||
)
|
||||
|
||||
stamp = ReviewPaidStamp(_write)
|
||||
stamp.wave_id = wave_id
|
||||
previous = getattr(ctx, "_review_paid_stamp", None)
|
||||
ctx._review_paid_stamp = stamp
|
||||
return stamp, previous
|
||||
|
||||
|
||||
def review_wave_budget_block(
|
||||
ctx: Any,
|
||||
skill_name: str,
|
||||
file_packs: List[str],
|
||||
models: List[str],
|
||||
) -> Optional[str]:
|
||||
"""Return a human-readable refusal when the review wave cannot fit the
|
||||
remaining root budget, else None. Read-only; emits one typed event."""
|
||||
from ouroboros.tools.review_helpers import review_wave_budget_gate
|
||||
|
||||
# Estimate the WHOLE wave: a chunked oversized skill runs one full
|
||||
# reviewer pass PER pack (run_skill_review_passes), and every pass re-sends
|
||||
# the stable governance/checklist/host-contract files the prompt builder
|
||||
# inlines — so both the payload chars and the governance chars multiply by
|
||||
# the pack count. A single-pack estimate would under-admit exactly the
|
||||
# multi-chunk waves most likely to die mid-review.
|
||||
governance_chars = 0
|
||||
for rel in (
|
||||
"docs/ARCHITECTURE.md", "docs/DEVELOPMENT.md", "BIBLE.md",
|
||||
"docs/CHECKLISTS.md", "docs/CREATING_SKILLS.md",
|
||||
):
|
||||
try:
|
||||
governance_chars += int((_REPO_ROOT / rel).stat().st_size)
|
||||
except OSError:
|
||||
pass
|
||||
packs = max(1, len(file_packs))
|
||||
total_chars = sum(len(pack) + governance_chars for pack in file_packs)
|
||||
# One admission slot per PHYSICAL reviewer call (models x packs), each
|
||||
# sized at the average per-pack prompt: the input estimate sums to the
|
||||
# exact wave total while the per-call output reservation also multiplies
|
||||
# by the pack count (a models-only wave under-reserved chunked output).
|
||||
admission = review_wave_budget_gate(
|
||||
ctx, surface="skill_review", models=list(models) * packs,
|
||||
prompt_chars=total_chars // packs,
|
||||
extra={"skill_name": skill_name, "packs": packs},
|
||||
)
|
||||
if admission is None:
|
||||
return None
|
||||
return (
|
||||
"review wave declined before dispatch: estimated reviewer-wave cost "
|
||||
f"~${admission.get('estimated_wave_usd')} exceeds the remaining root budget "
|
||||
f"${admission.get('remaining_usd')} (limit ${admission.get('limit_usd')}). "
|
||||
"No reviewer was called; the skill stays pending. Raise the per-task "
|
||||
"budget or re-run the review in a fresh task."
|
||||
)
|
||||
|
||||
|
||||
# --- Accepted-rebuttal ledger (moved whole from skill_review.py) -------------
|
||||
|
||||
|
||||
def accepted_rebuttals_path(drive_root: pathlib.Path, skill_name: str) -> pathlib.Path:
|
||||
"""Path to persisted accepted rebuttals for one skill."""
|
||||
return drive_root / "state" / "skills" / skill_name / "accepted_rebuttals.json"
|
||||
|
||||
|
||||
def load_accepted_rebuttals(drive_root: pathlib.Path, skill_name: str) -> List[Dict[str, Any]]:
|
||||
"""Return persisted accepted rebuttals (empty list when none / unreadable)."""
|
||||
path = accepted_rebuttals_path(drive_root, skill_name)
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
if not isinstance(data, dict):
|
||||
return []
|
||||
items = data.get("items")
|
||||
if not isinstance(items, list):
|
||||
return []
|
||||
out: List[Dict[str, Any]] = []
|
||||
for entry in items:
|
||||
if isinstance(entry, dict):
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
def persist_rebuttal_flips(
|
||||
drive_root: pathlib.Path,
|
||||
skill_name: str,
|
||||
*,
|
||||
history: List[Dict[str, Any]],
|
||||
findings: List[Dict[str, Any]],
|
||||
review_rebuttal: str,
|
||||
content_hash: str,
|
||||
responded_models: List[str],
|
||||
) -> None:
|
||||
"""Record rebuttals for items that flipped FAIL -> PASS on this attempt."""
|
||||
if not review_rebuttal or not history:
|
||||
return
|
||||
last_fail_items = fail_items_from_history_entry(history[-1])
|
||||
current_fail_items = {
|
||||
str(f.get("item") or "")
|
||||
for f in findings
|
||||
if isinstance(f, dict)
|
||||
and str(f.get("verdict") or "").upper() == "FAIL"
|
||||
and str(f.get("item") or "")
|
||||
}
|
||||
for item in sorted(last_fail_items - current_fail_items):
|
||||
record_accepted_rebuttal(
|
||||
drive_root,
|
||||
skill_name,
|
||||
item=item,
|
||||
rebuttal_text=review_rebuttal,
|
||||
content_hash=content_hash,
|
||||
passed_models=list(responded_models),
|
||||
)
|
||||
|
||||
|
||||
def fail_items_from_history_entry(entry: Dict[str, Any]) -> set[str]:
|
||||
"""Return FAIL item names from both v5.18 and legacy history entries."""
|
||||
out = {
|
||||
str(f.get("item") or "")
|
||||
for f in (entry.get("fail_findings") or [])
|
||||
if isinstance(f, dict) and str(f.get("item") or "")
|
||||
}
|
||||
if out:
|
||||
return out
|
||||
for signature in entry.get("failure_signature") or []:
|
||||
parts = str(signature or "").split(":")
|
||||
if len(parts) >= 2 and parts[1].upper() == "FAIL" and parts[0]:
|
||||
out.add(parts[0])
|
||||
return out
|
||||
|
||||
|
||||
def record_accepted_rebuttal(
|
||||
drive_root: pathlib.Path,
|
||||
skill_name: str,
|
||||
*,
|
||||
item: str,
|
||||
rebuttal_text: str,
|
||||
content_hash: str,
|
||||
passed_models: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""Persist (or refresh) an accepted rebuttal for ``item``."""
|
||||
path = accepted_rebuttals_path(drive_root, skill_name)
|
||||
existing = load_accepted_rebuttals(drive_root, skill_name)
|
||||
target: Optional[Dict[str, Any]] = None
|
||||
for entry in existing:
|
||||
if str(entry.get("item") or "") == item:
|
||||
target = entry
|
||||
break
|
||||
if target is None:
|
||||
target = {
|
||||
"item": item,
|
||||
"rebuttal_text": rebuttal_text,
|
||||
"accepted_at": utc_now_iso(),
|
||||
"content_hash_seen": [content_hash] if content_hash else [],
|
||||
"models_that_passed_after": list(passed_models or []),
|
||||
}
|
||||
existing.append(target)
|
||||
else:
|
||||
target["rebuttal_text"] = rebuttal_text
|
||||
target["accepted_at"] = utc_now_iso()
|
||||
seen = list(target.get("content_hash_seen") or [])
|
||||
if content_hash and content_hash not in seen:
|
||||
seen.append(content_hash)
|
||||
target["content_hash_seen"] = seen
|
||||
if passed_models:
|
||||
target["models_that_passed_after"] = list(passed_models)
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(path, {"items": existing}, trailing_newline=True)
|
||||
except OSError:
|
||||
log.debug("accepted rebuttal write failed", exc_info=True)
|
||||
|
|
@ -26,6 +26,142 @@ def review_history_path(drive_root: pathlib.Path, skill_name: str) -> pathlib.Pa
|
|||
return drive_root / "state" / "skills" / skill_name / "review_history.jsonl"
|
||||
|
||||
|
||||
def dispatch_marker_path(drive_root: pathlib.Path, skill_name: str) -> pathlib.Path:
|
||||
return drive_root / "state" / "skills" / skill_name / "review_dispatch.json"
|
||||
|
||||
|
||||
def _emit_history_event(drive_root: pathlib.Path, event: Dict[str, Any]) -> None:
|
||||
"""Loud typed event on the existing events rail (never raises)."""
|
||||
try:
|
||||
append_jsonl(
|
||||
pathlib.Path(drive_root) / "logs" / "events.jsonl",
|
||||
{"ts": utc_now_iso(), **event},
|
||||
)
|
||||
except Exception:
|
||||
log.debug("skill review history event emission failed", exc_info=True)
|
||||
|
||||
|
||||
def load_dispatch_marker(drive_root: pathlib.Path, skill_name: str) -> Dict[str, Any]:
|
||||
"""The current write-ahead dispatch marker, ``{}`` when none/unreadable."""
|
||||
try:
|
||||
data = json.loads(dispatch_marker_path(drive_root, skill_name).read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def clear_dispatch_marker(drive_root: pathlib.Path, skill_name: str, *, wave_id: str) -> None:
|
||||
"""Remove the marker once its wave's terminal row landed (merge complete)."""
|
||||
if not wave_id:
|
||||
return
|
||||
marker = load_dispatch_marker(drive_root, skill_name)
|
||||
if str(marker.get("wave_id") or "") != str(wave_id):
|
||||
return
|
||||
try:
|
||||
dispatch_marker_path(drive_root, skill_name).unlink()
|
||||
except OSError:
|
||||
log.debug("skill review dispatch marker unlink failed", exc_info=True)
|
||||
|
||||
|
||||
def write_dispatch_marker(
|
||||
drive_root: pathlib.Path,
|
||||
skill_name: str,
|
||||
*,
|
||||
wave_id: str,
|
||||
group_id: str,
|
||||
content_hash: str,
|
||||
root_task_id: str = "",
|
||||
review_contract_fingerprint: str = "",
|
||||
rebuttal_sha256: str = "",
|
||||
) -> None:
|
||||
"""Durable WRITE-AHEAD dispatch marker (Q17; same principle as the commit
|
||||
gate's paid stamp): written immediately before the first physical reviewer
|
||||
transport call of ONE skill-review wave, shared by the lifecycle runner and
|
||||
direct ``review_skill`` callers. Terminal history rows merge it (facts ride
|
||||
the row, marker cleared); a wave that never lands a terminal row keeps the
|
||||
marker, so the derived paid-cycle count never forgets spent money. An
|
||||
unmerged predecessor from a crashed wave is first flushed into the history
|
||||
as an infra terminal carrying its paid facts. A failing write surfaces as a
|
||||
loud typed event — this is fail-open cost accounting, not a safety gate."""
|
||||
from ouroboros.utils import atomic_write_json
|
||||
|
||||
predecessor = load_dispatch_marker(drive_root, skill_name)
|
||||
if predecessor.get("wave_id") and str(predecessor["wave_id"]) != str(wave_id):
|
||||
_flush_orphan_dispatch_marker(drive_root, skill_name, predecessor)
|
||||
payload = {
|
||||
"ts": utc_now_iso(),
|
||||
"wave_id": str(wave_id),
|
||||
"group_id": str(group_id or ""),
|
||||
"content_hash": str(content_hash or ""),
|
||||
"root_task_id": str(root_task_id or ""),
|
||||
"paid": True,
|
||||
"review_contract_fingerprint": str(review_contract_fingerprint or ""),
|
||||
"rebuttal_sha256": str(rebuttal_sha256 or ""),
|
||||
}
|
||||
try:
|
||||
path = dispatch_marker_path(drive_root, skill_name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Same redaction invariant as the history rows: marker fields are all
|
||||
# hashes/ids today, so this is a no-op class guard, not a data change.
|
||||
atomic_write_json(path, _redact_history_payload(payload), trailing_newline=True)
|
||||
except Exception:
|
||||
log.warning("skill review dispatch marker write failed for %s", skill_name, exc_info=True)
|
||||
_emit_history_event(drive_root, {
|
||||
"type": "skill_review_history_append_failed",
|
||||
"skill": skill_name, "wave_id": str(wave_id),
|
||||
"reason": "dispatch marker write failed",
|
||||
})
|
||||
|
||||
|
||||
def _flush_orphan_dispatch_marker(
|
||||
drive_root: pathlib.Path, skill_name: str, marker: Dict[str, Any]
|
||||
) -> None:
|
||||
"""A previous wave dispatched but never finalized (crash, or a direct-call
|
||||
infra outcome that returns without a history row): append its paid facts
|
||||
as an infra terminal row — idempotently keyed by the wave id — so the
|
||||
ledger catches up instead of forgetting the spend."""
|
||||
append_history_once(drive_root, skill_name, {
|
||||
"ts": utc_now_iso(),
|
||||
"status": "interrupted",
|
||||
"terminal_reason": "dispatched_wave_never_finalized",
|
||||
"content_hash": str(marker.get("content_hash") or ""),
|
||||
"group_id": str(marker.get("group_id") or ""),
|
||||
"root_task_id": str(marker.get("root_task_id") or ""),
|
||||
"paid": True,
|
||||
"review_contract_fingerprint": str(marker.get("review_contract_fingerprint") or ""),
|
||||
"rebuttal_sha256": str(marker.get("rebuttal_sha256") or ""),
|
||||
"job_id": str(marker.get("wave_id") or ""),
|
||||
"wave_id": str(marker.get("wave_id") or ""),
|
||||
"failure_signature": [],
|
||||
"fail_findings": [],
|
||||
})
|
||||
|
||||
|
||||
def _merge_dispatch_marker_facts(
|
||||
drive_root: pathlib.Path, skill_name: str, payload: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Merge the write-ahead marker's paid facts into ITS wave's terminal row.
|
||||
|
||||
The producer can lose the facts legitimately — a lifecycle timeout
|
||||
finalizes with no result object — but the marker recorded the dispatch
|
||||
before the first transport call, so the terminal row still carries
|
||||
``paid``/contract/rebuttal. Rows of other waves pass through untouched."""
|
||||
marker = load_dispatch_marker(drive_root, skill_name)
|
||||
wave = str(marker.get("wave_id") or "")
|
||||
row_wave = str(payload.get("wave_id") or payload.get("job_id") or "")
|
||||
if not wave or wave != row_wave:
|
||||
return payload
|
||||
merged = dict(payload)
|
||||
if marker.get("paid") and not merged.get("paid"):
|
||||
merged["paid"] = True
|
||||
for key in ("review_contract_fingerprint", "rebuttal_sha256",
|
||||
"group_id", "content_hash", "root_task_id"):
|
||||
if marker.get(key) and not merged.get(key):
|
||||
merged[key] = marker[key]
|
||||
merged.setdefault("wave_id", wave)
|
||||
return merged
|
||||
|
||||
|
||||
def finding_signature(findings: List[Dict[str, Any]]) -> List[str]:
|
||||
return sorted({
|
||||
f"{finding.get('item')}:{finding.get('verdict')}:{finding.get('severity')}"
|
||||
|
|
@ -152,6 +288,11 @@ def append_history(
|
|||
findings: List[Dict[str, Any]],
|
||||
raw_actor_records: Optional[List[Dict[str, Any]]] = None,
|
||||
single_reviewer_no_diversity: bool = False,
|
||||
paid: bool = False,
|
||||
review_contract_fingerprint: str = "",
|
||||
rebuttal_sha256: str = "",
|
||||
replayed_from_ts: str = "",
|
||||
wave_id: str = "",
|
||||
) -> None:
|
||||
try:
|
||||
payload: Dict[str, Any] = {
|
||||
|
|
@ -165,12 +306,39 @@ def append_history(
|
|||
payload["single_reviewer_no_diversity"] = True
|
||||
if raw_actor_records:
|
||||
payload["raw_actor_records"] = list(raw_actor_records)
|
||||
append_jsonl(
|
||||
# Max-Review-Cycles facts (Q17/Q23): the paid-dispatch fact and the
|
||||
# panel contract identity ride the history row — counts and free-replay
|
||||
# decisions are DERIVED from this ledger (P7, no counter file).
|
||||
if paid:
|
||||
payload["paid"] = True
|
||||
if review_contract_fingerprint:
|
||||
payload["review_contract_fingerprint"] = str(review_contract_fingerprint)
|
||||
if rebuttal_sha256:
|
||||
payload["rebuttal_sha256"] = str(rebuttal_sha256)
|
||||
if replayed_from_ts:
|
||||
payload["replayed_from_ts"] = str(replayed_from_ts)
|
||||
if wave_id:
|
||||
payload["wave_id"] = str(wave_id)
|
||||
payload = _merge_dispatch_marker_facts(drive_root, skill_name, payload)
|
||||
if not append_jsonl(
|
||||
review_history_path(drive_root, skill_name),
|
||||
_redact_history_payload(payload),
|
||||
)
|
||||
):
|
||||
raise OSError("append_jsonl reported failure")
|
||||
if wave_id:
|
||||
clear_dispatch_marker(drive_root, skill_name, wave_id=wave_id)
|
||||
except Exception:
|
||||
log.debug("skill review history append failed", exc_info=True)
|
||||
# LOUD failure (F3): a lost terminal row silently un-counts spent
|
||||
# money and hides a verdict — log at warning and emit the typed event.
|
||||
log.warning("skill review history append failed for %s", skill_name, exc_info=True)
|
||||
_emit_history_event(drive_root, {
|
||||
"type": "skill_review_history_append_failed",
|
||||
"skill": skill_name,
|
||||
"status": str(status or ""),
|
||||
"content_hash": str(content_hash or ""),
|
||||
"wave_id": str(wave_id or ""),
|
||||
"reason": "direct history append failed",
|
||||
})
|
||||
|
||||
|
||||
def append_history_once(
|
||||
|
|
@ -191,7 +359,11 @@ def append_history_once(
|
|||
try:
|
||||
try:
|
||||
if any(str(row.get("job_id") or "") == job_id for row in iter_jsonl_objects(path)):
|
||||
# Already landed (idempotent retry): finish the merge by
|
||||
# clearing this wave's dispatch marker if it is still present.
|
||||
clear_dispatch_marker(drive_root, skill_name, wave_id=job_id)
|
||||
return True
|
||||
payload = _merge_dispatch_marker_facts(drive_root, skill_name, payload)
|
||||
safe_payload = _redact_history_payload(payload)
|
||||
data = (json.dumps(safe_payload, ensure_ascii=False) + "\n").encode("utf-8")
|
||||
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
|
||||
|
|
@ -202,6 +374,10 @@ def append_history_once(
|
|||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
clear_dispatch_marker(
|
||||
drive_root, skill_name,
|
||||
wave_id=str(payload.get("wave_id") or job_id),
|
||||
)
|
||||
return True
|
||||
except OSError:
|
||||
log.warning("skill review terminal history append failed for %s", skill_name, exc_info=True)
|
||||
|
|
|
|||
|
|
@ -150,7 +150,17 @@ def _review_provenance(ctx: Any, source: str, skill_name: str) -> Dict[str, Any]
|
|||
group_id = f"manual:{skill_name}"
|
||||
root_task_id = ""
|
||||
else:
|
||||
root_task_id = origin_root_task_id or origin_task_id
|
||||
# Ceiling/group root = the CURRENT task tree's root. The follow-up
|
||||
# chain marker origin_root_task_id is deliberately NOT honored here
|
||||
# (adversarial wave, machine-3): a scheduled follow-up is a fresh
|
||||
# root with a fresh review-cycle ceiling — the "fresh task" exit the
|
||||
# refusal advertises must be real. origin_* stay recorded below as
|
||||
# provenance facts.
|
||||
root_task_id = str(
|
||||
metadata.get("root_task_id")
|
||||
or getattr(ctx, "root_task_id", "")
|
||||
or task_id
|
||||
)
|
||||
group_id = f"task:{root_task_id}:{skill_name}"
|
||||
try:
|
||||
chat_id = int(getattr(ctx, "current_chat_id", 0) or 0)
|
||||
|
|
@ -208,6 +218,18 @@ def _terminal_history_payload(
|
|||
payload["raw_actor_records"] = raw_actor_records
|
||||
if bool(getattr(result, "single_reviewer_no_diversity", False)):
|
||||
payload["single_reviewer_no_diversity"] = True
|
||||
# Max-Review-Cycles facts (Q17/Q23) ride the terminal row: paid panel
|
||||
# dispatch (one chunked wave = ONE cycle), its wave id, the panel contract
|
||||
# identity, the rebuttal content hash, and — for a $0 replay — the quoted
|
||||
# verdict ts. A terminal with NO result (lifecycle timeout) still gets the
|
||||
# facts: append_history_once merges the write-ahead dispatch marker by
|
||||
# wave/job id (F3), so the money spent before the timeout stays counted.
|
||||
if bool(getattr(result, "paid", False)):
|
||||
payload["paid"] = True
|
||||
for fact_key in ("wave_id", "review_contract_fingerprint", "rebuttal_sha256", "replayed_from_ts"):
|
||||
value = str(getattr(result, fact_key, "") or "")
|
||||
if value:
|
||||
payload[fact_key] = value
|
||||
return payload
|
||||
|
||||
|
||||
|
|
@ -221,7 +243,7 @@ def _append_terminal_history(
|
|||
result: Any = None,
|
||||
ts: str,
|
||||
) -> bool:
|
||||
return skill_review_history.append_history_once(
|
||||
appended = skill_review_history.append_history_once(
|
||||
drive_root,
|
||||
skill_name,
|
||||
_terminal_history_payload(
|
||||
|
|
@ -232,6 +254,24 @@ def _append_terminal_history(
|
|||
ts=ts,
|
||||
),
|
||||
)
|
||||
if not appended:
|
||||
# LOUD failure (F3): a silently lost terminal row un-counts spent
|
||||
# review money and hides the verdict from the derived ledger. The
|
||||
# unmerged dispatch marker still preserves the paid fact.
|
||||
log.warning("skill review terminal history row did not land for %s", skill_name)
|
||||
append_jsonl(
|
||||
_events_path(drive_root),
|
||||
{
|
||||
"ts": ts,
|
||||
"type": "skill_review_history_append_failed",
|
||||
"skill": skill_name,
|
||||
"job_id": str(job_data.get("job_id") or ""),
|
||||
"status": status,
|
||||
"terminal_reason": terminal_reason,
|
||||
"reason": "terminal history append failed",
|
||||
},
|
||||
)
|
||||
return appended
|
||||
|
||||
|
||||
def _append_review_chat_summary(
|
||||
|
|
|
|||
|
|
@ -1224,20 +1224,39 @@ def _audit_bypass(ctx: ToolContext, snapshot_hash: str, commit_message: str,
|
|||
|
||||
|
||||
def _identical_diff_cap_note() -> str:
|
||||
"""Schema-build-time NOTE about the identical-diff attempt cap, derived from the
|
||||
shared OUROBOROS_REVIEW_MAX_CYCLES (never a hardcoded number)."""
|
||||
"""Schema-build-time NOTE about Max-Review-Cycles semantics on the commit
|
||||
gate, derived from the shared OUROBOROS_REVIEW_MAX_CYCLES (never a
|
||||
hardcoded number). Identical bytes are never re-reviewed for pay: from the
|
||||
FIRST review-verdict block, resubmitting the byte-identical staged diff
|
||||
without a NEW rebuttal never buys a new review (identical_diff_refused);
|
||||
the knob itself counts PAID triad+scope cycles per task. Whether either
|
||||
state blocks the commit follows enforcement (the honest caveat below)."""
|
||||
from ouroboros.review_cycles import review_max_cycles
|
||||
|
||||
cap = review_max_cycles()
|
||||
base = (
|
||||
"NOTE: identical bytes are never re-reviewed for pay — after ANY review-verdict "
|
||||
"block, a byte-identical resubmission to commit_reviewed buys no new review "
|
||||
"(identical_diff_refused, quoting the recorded verdict) until the diff changes "
|
||||
"or a NEW review_rebuttal is supplied (a rebuttal new to the streak buys exactly "
|
||||
"one paid re-review; a repeated one buys none)."
|
||||
)
|
||||
caveat = (
|
||||
" In both states, under blocking enforcement the commit is refused for free; "
|
||||
"under advisory the commit proceeds with a loud durable disclosure and no new "
|
||||
"review spend."
|
||||
)
|
||||
if cap is None:
|
||||
return (
|
||||
"NOTE: no identical-diff cap is configured (OUROBOROS_REVIEW_MAX_CYCLES=unlimited): "
|
||||
"commit_reviewed never refuses a resubmission on cap grounds."
|
||||
f"{base} OUROBOROS_REVIEW_MAX_CYCLES=unlimited: no per-root-task ceiling on "
|
||||
f"paid triad+scope cycles is configured.{caveat}"
|
||||
)
|
||||
return (
|
||||
f"NOTE: after {cap} genuine review-verdict block(s) of a byte-identical staged diff "
|
||||
"(the shared OUROBOROS_REVIEW_MAX_CYCLES cap), commit_reviewed refuses further "
|
||||
"attempts (attempt_cap_reached) until the diff changes or a review_rebuttal is provided."
|
||||
f"{base} The shared OUROBOROS_REVIEW_MAX_CYCLES cap bounds PAID triad+scope "
|
||||
f"cycles per ROOT task (shared across the whole task tree; a follow-up task "
|
||||
f"starts its own): after {cap} paid cycle(s) commit_reviewed buys no further "
|
||||
"review (typed review_cycles_exhausted event; every dispatched wave counts, "
|
||||
f"only undispatched attempts stay outside the count).{caveat}"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
"""Advisory freshness gate and durable commit-attempt recording."""
|
||||
"""Advisory freshness gate, durable commit-attempt recording, and the
|
||||
commit-side Max-Review-Cycles machinery (block classification, the free
|
||||
identical-diff refusal, the per-root-task paid-cycle ceiling, and the
|
||||
review-contract fingerprint)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import pathlib
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ouroboros.review_cycles import review_max_cycles
|
||||
from ouroboros.review_cycles import REASON_REVIEW_CYCLES_EXHAUSTED, review_max_cycles
|
||||
from ouroboros.review_state import infer_review_phase
|
||||
from ouroboros.tools.registry import ToolContext
|
||||
from ouroboros.utils import (
|
||||
|
|
@ -52,38 +57,298 @@ def _attempt_accepts_reviewing_update(existing: Any) -> bool:
|
|||
return bool(existing.status == "reviewing" or existing.late_result_pending)
|
||||
|
||||
|
||||
# Identical-diff blocked-review cap: after this many genuine review blocks of
|
||||
# the SAME staged diff (matching pre_review_fingerprint), further attempts are
|
||||
# refused BEFORE spending another triad+scope run. Changing the diff (fixing
|
||||
# findings) starts a fresh streak — legitimate fix-and-retry loops are never
|
||||
# capped, only verbatim resubmissions hoping for a different verdict. The number
|
||||
# is the shared review-cycle cap (OUROBOROS_REVIEW_MAX_CYCLES, default 2, was a
|
||||
# hardcoded 3), read at call time; ``unlimited`` disables the cap.
|
||||
_ATTEMPT_CAP_BLOCK_REASON = "attempt_cap_reached"
|
||||
# Max Review Cycles semantics on the commit gate (owner Q12/Q16/Q22/Q23):
|
||||
# identical bytes are never re-reviewed for pay. From the FIRST genuine
|
||||
# review-verdict block of a staged diff, resubmitting the same
|
||||
# pre_review_fingerprint without a NEW rebuttal is refused for FREE — before
|
||||
# the advisory-freshness gate and before any paid triad+scope dispatch —
|
||||
# quoting the recorded verdict. A rebuttal is identified by CONTENT
|
||||
# (sha256): a hash new to the current identical-fingerprint streak buys
|
||||
# exactly ONE paid re-review; a repeated hash is refused free, quoting the
|
||||
# previous outcome. Infra-blocks (fit overflow, sub-floor window,
|
||||
# revalidation, transport/no-quorum) are not verdicts: they never build the
|
||||
# refusal streak and retry freely. The shared OUROBOROS_REVIEW_MAX_CYCLES
|
||||
# knob (``review_max_cycles()``; ``None`` = unlimited) bounds PAID
|
||||
# triad+scope cycles per ROOT task (the whole task tree shares one ceiling;
|
||||
# a manual session is its own task; a follow-up task starts a fresh one).
|
||||
# The ceiling counts MONEY: every attempt that physically dispatched a wave
|
||||
# (``paid`` recorded at dispatch) counts regardless of how it terminated —
|
||||
# only UNDISPATCHED attempts (preflight refusals, assembly failures, free
|
||||
# replays) are outside the count. Exhaustion is a
|
||||
# free typed refusal plus ``emit_review_cycles_exhausted``. Both refusals
|
||||
# honor the recorded review-contract fingerprint (roster+routes+enforcement+
|
||||
# prompt contract): a changed contract lapses the streak. Under ADVISORY
|
||||
# enforcement neither refusal hard-blocks a commit — the prior verdict is
|
||||
# reused, loudly disclosed, and the commit proceeds without buying another
|
||||
# review.
|
||||
IDENTICAL_DIFF_BLOCK_REASON = "identical_diff_refused"
|
||||
_LEGACY_CAP_BLOCK_REASON = "attempt_cap_reached" # pre-Q16 refusal rows
|
||||
_REFUSAL_BLOCK_REASONS = frozenset({
|
||||
IDENTICAL_DIFF_BLOCK_REASON,
|
||||
REASON_REVIEW_CYCLES_EXHAUSTED,
|
||||
_LEGACY_CAP_BLOCK_REASON,
|
||||
})
|
||||
|
||||
BLOCK_CLASS_VERDICT = "verdict"
|
||||
BLOCK_CLASS_INFRA = "infra"
|
||||
|
||||
# Triad block reasons that ARE reviewer verdicts; everything else recorded at
|
||||
# phase=blocking_review is an infrastructure fact about the gate. Post-review
|
||||
# failure phases (post_commit_tests, commit_binding, tag_binding) need no set
|
||||
# of their own: the streak walker's generic terminal break already ends an
|
||||
# identical-diff streak on them, and their paid dispatch stays counted.
|
||||
_TRIAD_VERDICT_BLOCK_REASONS = frozenset({"critical_findings"})
|
||||
# Failed-status phases that are pre/around-review infrastructure facts.
|
||||
_INFRA_FAILURE_PHASES = frozenset({"infra", "expired"})
|
||||
|
||||
|
||||
def blocked_attempt_fingerprint_cap() -> Optional[int]:
|
||||
"""Live identical-diff cap from the shared setting; ``None`` = never cap."""
|
||||
return review_max_cycles()
|
||||
def _scope_actor_rows(scope_raw_result: Optional[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
raw = scope_raw_result if isinstance(scope_raw_result, dict) else {}
|
||||
rows = [row for row in (raw.get("raw_results") or []) if isinstance(row, dict)]
|
||||
return rows or ([raw] if raw else [])
|
||||
|
||||
|
||||
def check_blocked_attempt_cap(ctx: ToolContext, fingerprint: str, *, has_rebuttal: bool = False) -> str:
|
||||
"""Refusal message when the same staged diff was already review-blocked
|
||||
``blocked_attempt_fingerprint_cap()`` times in a row; "" allows the attempt.
|
||||
def _scope_verdict_blocked(scope_blocked: bool, scope_raw_result: Optional[Dict[str, Any]]) -> bool:
|
||||
"""A scope-side VERDICT block = an authoritative (``responded``) actor row
|
||||
carrying critical findings while the scope aggregate blocked. Sub-floor,
|
||||
fit-overflow, transport, parse and quorum blocks all arrive under
|
||||
non-``responded`` statuses — they are infra facts, not verdicts."""
|
||||
if not scope_blocked:
|
||||
return False
|
||||
for row in _scope_actor_rows(scope_raw_result):
|
||||
if str(row.get("status") or "") == "responded" and (row.get("critical_findings") or []):
|
||||
return True
|
||||
return False
|
||||
|
||||
Counts trailing blocked attempts for this (repo, tool) whose
|
||||
``pre_review_fingerprint`` matches the current staged diff — deliberately
|
||||
NOT task-scoped: the byte-identical diff is the identity, so opening a new
|
||||
task with the same unchanged diff cannot reset the streak. Cap-refusal
|
||||
records themselves are skipped (they must not reset the streak); any other
|
||||
non-matching terminal attempt (different diff, success, failure) breaks it.
|
||||
A call carrying a review_rebuttal is exempt — the rebuttal IS new review
|
||||
input even when the diff bytes are unchanged.
|
||||
Fail-open on ledger errors — the cap is a cost guard, not a safety gate.
|
||||
"""
|
||||
|
||||
def classify_review_block(
|
||||
*,
|
||||
triad_blocked: bool,
|
||||
triad_block_reason: str,
|
||||
scope_blocked: bool,
|
||||
scope_raw_result: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""Type one blocked review outcome at record time: ``verdict`` when ANY
|
||||
side delivered genuine reviewer findings, ``infra`` otherwise."""
|
||||
if triad_blocked and str(triad_block_reason or "") in _TRIAD_VERDICT_BLOCK_REASONS:
|
||||
return BLOCK_CLASS_VERDICT
|
||||
if _scope_verdict_blocked(scope_blocked, scope_raw_result):
|
||||
return BLOCK_CLASS_VERDICT
|
||||
return BLOCK_CLASS_INFRA
|
||||
|
||||
|
||||
def attempt_block_class(item: Any) -> str:
|
||||
"""The typed class of one ledger row: the recorded ``block_class`` when
|
||||
present, else a conservative legacy inference from the recorded reason.
|
||||
Non-review rows (preflight facts, refusal records) stay ``""``."""
|
||||
recorded = str(getattr(item, "block_class", "") or "")
|
||||
if recorded:
|
||||
return recorded
|
||||
if str(getattr(item, "status", "") or "") != "blocked":
|
||||
return ""
|
||||
if str(getattr(item, "block_reason", "") or "") in _REFUSAL_BLOCK_REASONS:
|
||||
return ""
|
||||
if str(getattr(item, "phase", "") or "") != "blocking_review":
|
||||
return "" # preflight/advisory-gate rows are neither verdict nor infra
|
||||
reason = str(getattr(item, "block_reason", "") or "")
|
||||
if reason in _TRIAD_VERDICT_BLOCK_REASONS:
|
||||
return BLOCK_CLASS_VERDICT
|
||||
if reason == "scope_blocked" and _scope_verdict_blocked(
|
||||
True, getattr(item, "scope_raw_result", None)
|
||||
):
|
||||
return BLOCK_CLASS_VERDICT
|
||||
return BLOCK_CLASS_INFRA
|
||||
|
||||
|
||||
def compute_rebuttal_sha256(review_rebuttal: Any) -> str:
|
||||
"""Content identity of a rebuttal; "" when no rebuttal was supplied."""
|
||||
text = str(review_rebuttal or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()
|
||||
|
||||
|
||||
def resolve_root_task_id(ctx: ToolContext) -> str:
|
||||
"""The root of the current task tree (Q23: one paid-cycle ceiling per
|
||||
tree); a task with no recorded root is its own root. "" = unknown.
|
||||
|
||||
DELIBERATE TRADEOFF (adversarial wave, machine-3): ``origin_root_task_id``
|
||||
— the follow-up chain marker — is NOT honored here, so a scheduled
|
||||
follow-up task is a FRESH root with its own ceiling. This makes the
|
||||
refusal's "leave the remaining work to a follow-up task with its own
|
||||
budget" exit real; the cost is that a follow-up can buy new paid cycles
|
||||
for the same goal. The cross-task identical-fingerprint refusal remains
|
||||
the anti-laundering backstop: byte-identical bytes stay refused whichever
|
||||
task resubmits them."""
|
||||
metadata = getattr(ctx, "task_metadata", None)
|
||||
metadata = metadata if isinstance(metadata, dict) else {}
|
||||
return str(
|
||||
metadata.get("root_task_id")
|
||||
or getattr(ctx, "root_task_id", "")
|
||||
or getattr(ctx, "task_id", "")
|
||||
or ""
|
||||
)
|
||||
|
||||
|
||||
def commit_review_contract_fingerprint() -> str:
|
||||
"""Identity of the commit gate's live review contract (Q22): triad roster+
|
||||
routes, scope rows, enforcement, and the shipped prompt-contract text. A
|
||||
changed fingerprint lapses free-refusal/replay authority (a new paid
|
||||
review is allowed and refusals never quote across the change). Fail-open
|
||||
"" — an unknown contract never matches, so nothing is refused on it."""
|
||||
try:
|
||||
from ouroboros.config import get_review_enforcement
|
||||
from ouroboros.review_substrate import scope_reviewer_slots
|
||||
from ouroboros.reviewer_slot_config import commit_triad_delivery
|
||||
from ouroboros.tools.review_helpers import CRITICAL_FINDING_CALIBRATION, REVIEW_PREAMBLE
|
||||
from ouroboros.triad_review import REVIEW_JSON_ARRAY_CONTRACT
|
||||
|
||||
row_plan = commit_triad_delivery()
|
||||
triad_rows = [
|
||||
[
|
||||
str(model),
|
||||
str(getattr(route, "value", route) or ""),
|
||||
str(effort or ""),
|
||||
str(target or ""),
|
||||
str(profile or ""),
|
||||
str(slot_id or ""),
|
||||
]
|
||||
for model, route, effort, target, profile, slot_id in zip(
|
||||
row_plan["models"], row_plan["routes"], row_plan["efforts"],
|
||||
row_plan["session_targets"], row_plan["session_profiles"],
|
||||
row_plan["slot_ids"],
|
||||
)
|
||||
]
|
||||
scope_rows = [
|
||||
[
|
||||
str(getattr(slot, "slot_id", "") or ""),
|
||||
str(getattr(slot, "model", "") or ""),
|
||||
str(getattr(getattr(slot, "route", None), "value", "") or ""),
|
||||
str(getattr(slot, "session_target", "") or ""),
|
||||
str(getattr(slot, "session_profile", "") or ""),
|
||||
str(getattr(slot, "effort", "") or ""),
|
||||
]
|
||||
for slot in scope_reviewer_slots()
|
||||
]
|
||||
prompt_contract = hashlib.sha256(
|
||||
"\n".join([REVIEW_PREAMBLE, CRITICAL_FINDING_CALIBRATION, REVIEW_JSON_ARRAY_CONTRACT]).encode("utf-8")
|
||||
).hexdigest()
|
||||
payload = json.dumps(
|
||||
{
|
||||
"triad": triad_rows,
|
||||
"scope": scope_rows,
|
||||
"enforcement": str(get_review_enforcement() or ""),
|
||||
"prompt_contract": prompt_contract,
|
||||
},
|
||||
sort_keys=True,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
except Exception:
|
||||
log.debug("commit review contract fingerprint unavailable (fail-open)", exc_info=True)
|
||||
return ""
|
||||
|
||||
|
||||
def _quote_verdict_attempt(item: Any) -> str:
|
||||
"""Render the recorded verdict an identical resubmission is refused with."""
|
||||
lines = [
|
||||
f"Recorded verdict: attempt #{int(getattr(item, 'attempt', 0) or 0)} "
|
||||
f"({getattr(item, 'ts', '') or 'unknown ts'}, block_reason="
|
||||
f"{getattr(item, 'block_reason', '') or 'unknown'}"
|
||||
+ (f", rebuttal_sha256={str(getattr(item, 'rebuttal_sha256', '') or '')[:12]}…" if getattr(item, "rebuttal_sha256", "") else "")
|
||||
+ ")"
|
||||
]
|
||||
findings = [f for f in (getattr(item, "critical_findings", None) or []) if isinstance(f, dict)]
|
||||
for finding in findings[:5]:
|
||||
label = str(finding.get("item") or finding.get("reason") or "?")
|
||||
reason = _truncate_review_reason(str(finding.get("reason", "") or ""), limit=200)
|
||||
lines.append(f" - [CRITICAL] {label}: {reason}")
|
||||
if len(findings) > 5:
|
||||
lines.append(f" … and {len(findings) - 5} more critical finding(s) in review_status.")
|
||||
details = str(getattr(item, "block_details", "") or "").strip()
|
||||
if details and not findings:
|
||||
lines.append(_truncate_review_reason(details, limit=600))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _walk_identical_verdict_streak(
|
||||
attempts: List[Any], fp: str, contract_fingerprint: str
|
||||
) -> tuple[Optional[Any], set]:
|
||||
"""Trailing verdict-block streak for ``fp``: ``(last_verdict_row,
|
||||
rebuttal_hashes_seen)``. ``(None, …)`` = no live streak. Skips in-flight
|
||||
rows, refusal records, preflight facts and infra-blocks (none of them is a
|
||||
verdict or evidence the diff changed); breaks on any other terminal. A
|
||||
verdict row recorded under a DIFFERENT (or unknown) review contract lapses
|
||||
the streak (Q22)."""
|
||||
last_verdict: Optional[Any] = None
|
||||
seen_rebuttals: set = set()
|
||||
for item in reversed(attempts):
|
||||
status = str(getattr(item, "status", "") or "")
|
||||
if status == "reviewing":
|
||||
continue # in-flight marker, not a verdict
|
||||
if str(getattr(item, "block_reason", "") or "") in _REFUSAL_BLOCK_REASONS:
|
||||
# Free refusals never reset the streak. Their recorded rebuttal
|
||||
# hashes are deliberately NOT harvested: a "spent" rebuttal is one
|
||||
# that BOUGHT a dispatch — one refused without dispatching stays
|
||||
# fresh (e.g. after the owner raises the ceiling).
|
||||
continue
|
||||
klass = attempt_block_class(item)
|
||||
if status == "blocked" and not klass:
|
||||
# Preflight facts (stale advisory, tests, protection) inherit the
|
||||
# prior fingerprint through the ledger merge; they are neither a
|
||||
# review verdict nor evidence the diff changed.
|
||||
continue
|
||||
if klass == BLOCK_CLASS_INFRA:
|
||||
continue # infra facts never build NOR break the streak
|
||||
if status == "failed" and str(getattr(item, "phase", "") or "") in _INFRA_FAILURE_PHASES:
|
||||
# Infra failures (lock/stage errors, expired reviewing rows) are
|
||||
# transients, not verdicts and not evidence the diff changed —
|
||||
# mirroring the ceiling's dispatch accounting, they neither build
|
||||
# nor reset the streak (adversarial wave, machine-2).
|
||||
continue
|
||||
if (
|
||||
status == "blocked"
|
||||
and klass == BLOCK_CLASS_VERDICT
|
||||
and str(getattr(item, "pre_review_fingerprint", "") or "") == fp
|
||||
):
|
||||
row_contract = str(getattr(item, "review_contract_fingerprint", "") or "")
|
||||
if not contract_fingerprint or row_contract != contract_fingerprint:
|
||||
if last_verdict is None:
|
||||
# The streak's HEAD was recorded under another (or unknown)
|
||||
# contract: replay authority lapses, a paid review is due.
|
||||
return None, seen_rebuttals
|
||||
# An OLDER row from a previous contract merely ends the streak;
|
||||
# the newer same-contract verdict keeps its refusal authority.
|
||||
break
|
||||
if last_verdict is None:
|
||||
last_verdict = item
|
||||
# A rebuttal is "spent" only when it BOUGHT this dispatched,
|
||||
# verdict-answered wave (machine-4/wording-2): harvest hashes from
|
||||
# paid verdict rows only — never from refusal rows or infra facts.
|
||||
rebuttal = str(getattr(item, "rebuttal_sha256", "") or "")
|
||||
if rebuttal and bool(getattr(item, "paid", False)):
|
||||
seen_rebuttals.add(rebuttal)
|
||||
continue
|
||||
break # success / pass / different diff / post-review terminal
|
||||
return last_verdict, seen_rebuttals
|
||||
|
||||
|
||||
def check_identical_verdict_refusal(
|
||||
ctx: ToolContext,
|
||||
fingerprint: str,
|
||||
*,
|
||||
rebuttal_sha256: str = "",
|
||||
contract_fingerprint: str = "",
|
||||
) -> str:
|
||||
"""Free typed refusal for a byte-identical resubmission whose streak's last
|
||||
terminal is a review VERDICT block and no NEW rebuttal is supplied; ""
|
||||
allows the attempt. Fires from the FIRST verdict-block — identical bytes
|
||||
are never re-reviewed for pay. Deliberately NOT task-scoped: the
|
||||
byte-identical diff is the identity, so a new task with the same unchanged
|
||||
diff cannot launder a fresh paid review (anti-laundering). Fail-open on
|
||||
ledger errors — this is a cost guard, not a safety gate."""
|
||||
fp = str(fingerprint or "").strip()
|
||||
cap = blocked_attempt_fingerprint_cap()
|
||||
if not fp or has_rebuttal or cap is None:
|
||||
if not fp:
|
||||
return ""
|
||||
try:
|
||||
from ouroboros.review_state import load_state, make_repo_key
|
||||
|
|
@ -93,42 +358,95 @@ def check_blocked_attempt_cap(ctx: ToolContext, fingerprint: str, *, has_rebutta
|
|||
repo_key=make_repo_key(pathlib.Path(ctx.repo_dir)),
|
||||
tool_name=_current_review_tool_name(ctx),
|
||||
)
|
||||
streak = 0
|
||||
for item in reversed(attempts):
|
||||
status = str(getattr(item, "status", "") or "")
|
||||
if status == "reviewing":
|
||||
continue # in-flight marker, not a verdict
|
||||
if str(getattr(item, "block_reason", "") or "") == _ATTEMPT_CAP_BLOCK_REASON:
|
||||
continue # earlier refusals must not reset the streak
|
||||
phase = str(getattr(item, "phase", "") or "")
|
||||
if status == "blocked" and phase != "blocking_review":
|
||||
# Preflight blocks (stale advisory, tests, protection) inherit
|
||||
# the prior fingerprint through the ledger merge; they are
|
||||
# neither a review verdict (must not inflate the streak — the
|
||||
# cap message claims N verdicts) nor evidence the diff changed
|
||||
# (must not reset it).
|
||||
continue
|
||||
if (
|
||||
status == "blocked"
|
||||
and str(getattr(item, "pre_review_fingerprint", "") or "") == fp
|
||||
):
|
||||
streak += 1
|
||||
continue
|
||||
break
|
||||
if streak < cap:
|
||||
last_verdict, seen_rebuttals = _walk_identical_verdict_streak(
|
||||
attempts, fp, str(contract_fingerprint or "")
|
||||
)
|
||||
if last_verdict is None:
|
||||
return ""
|
||||
if rebuttal_sha256 and rebuttal_sha256 not in seen_rebuttals:
|
||||
return "" # a NEW rebuttal buys exactly ONE paid re-review
|
||||
repeated_note = (
|
||||
"\nThe supplied review_rebuttal is byte-identical to one already spent on this "
|
||||
"streak — a repeated rebuttal does not buy another review."
|
||||
if rebuttal_sha256 else ""
|
||||
)
|
||||
return (
|
||||
f"⚠️ REVIEW_ATTEMPT_CAP: this exact staged diff was already blocked by review "
|
||||
f"{streak} times in a row. Refusing to spend another triad+scope run on an "
|
||||
"unchanged diff. Either FIX the blocking findings (any change to the staged "
|
||||
"diff starts a fresh review), genuinely rebut them via review_rebuttal with "
|
||||
"new evidence, or stop and escalate the disagreement to the owner."
|
||||
"⚠️ IDENTICAL_DIFF_REFUSED: this exact staged diff was already reviewed and "
|
||||
"BLOCKED — you appear to have forgotten to change anything. Identical bytes are "
|
||||
f"never re-reviewed for pay.{repeated_note}\n"
|
||||
f"{_quote_verdict_attempt(last_verdict)}\n"
|
||||
"Honest exits: change the code (any change to the staged diff starts a fresh "
|
||||
"paid review); supply a NEW review_rebuttal with genuinely new evidence (buys "
|
||||
"exactly one paid re-review); escalate the disagreement to the owner; or "
|
||||
"finalize honestly without this commit."
|
||||
)
|
||||
except Exception:
|
||||
log.debug("blocked-attempt cap check failed (fail-open)", exc_info=True)
|
||||
log.debug("identical-verdict refusal check failed (fail-open)", exc_info=True)
|
||||
return ""
|
||||
|
||||
|
||||
def count_paid_review_cycles(ctx: ToolContext, *, root_task_id: str) -> int:
|
||||
"""Paid triad/scope cycles already spent by this root task on this
|
||||
(repo, tool) gate, derived from the existing attempt ledger (P7 — no new
|
||||
counter file). The ceiling counts MONEY (machine-5): every attempt that
|
||||
physically dispatched a wave (``paid`` recorded at dispatch) counts,
|
||||
whatever its terminal — a dispatched-then-crashed or quorum-failed wave
|
||||
still spent reviewer money. Only UNDISPATCHED attempts (free refusals,
|
||||
replays, preflight/assembly failures — all ``paid=False``) stay outside
|
||||
the count; that is the whole "infra retries freely" carve-out."""
|
||||
root = str(root_task_id or "")
|
||||
if not root:
|
||||
return 0
|
||||
from ouroboros.review_state import load_state, make_repo_key
|
||||
|
||||
state = load_state(pathlib.Path(ctx.drive_root))
|
||||
attempts = state.filter_attempts(
|
||||
repo_key=make_repo_key(pathlib.Path(ctx.repo_dir)),
|
||||
tool_name=_current_review_tool_name(ctx),
|
||||
)
|
||||
return sum(
|
||||
1
|
||||
for item in attempts
|
||||
if bool(getattr(item, "paid", False))
|
||||
and str(getattr(item, "root_task_id", "") or "") == root
|
||||
)
|
||||
|
||||
|
||||
def check_review_cycles_ceiling(
|
||||
ctx: ToolContext, *, root_task_id: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""``None`` allows a paid dispatch; otherwise typed exhaustion facts
|
||||
(message/cycles_paid/cap) for the per-root-task paid-cycle ceiling
|
||||
(``review_max_cycles()``; ``None``/unknown root = unlimited). Fail-open on
|
||||
ledger errors — a cost guard, not a safety gate. DISCLOSED RESIDUAL
|
||||
(skill-5): the check is read-at-gate-time with no reservation, so
|
||||
concurrent dispatches sharing one root can each read ``paid < cap`` and
|
||||
overshoot by the concurrency width; the write-ahead paid stamp at first
|
||||
physical dispatch narrows but does not close that window."""
|
||||
cap = review_max_cycles()
|
||||
root = str(root_task_id or "")
|
||||
if cap is None or not root:
|
||||
return None
|
||||
try:
|
||||
paid = count_paid_review_cycles(ctx, root_task_id=root)
|
||||
except Exception:
|
||||
log.debug("paid review-cycle count failed (fail-open)", exc_info=True)
|
||||
return None
|
||||
if paid < cap:
|
||||
return None
|
||||
message = (
|
||||
f"⚠️ REVIEW_CYCLES_EXHAUSTED: this task tree (root {root}) already spent "
|
||||
f"{paid} of {cap} paid triad+scope review cycle(s) "
|
||||
"(OUROBOROS_REVIEW_MAX_CYCLES). Refusing to buy another review.\n"
|
||||
"Honest exits: finalize honestly with what is already reviewed and committed; "
|
||||
"escalate to the owner (the ceiling is the owner's Max Review Cycles setting — "
|
||||
"3/5/unlimited are one settings change away); or leave the remaining work to a "
|
||||
"follow-up task with its own budget. A rebuttal cannot buy past the ceiling — "
|
||||
"rebuttal cycles count toward it."
|
||||
)
|
||||
return {"message": message, "cycles_paid": paid, "cap": cap}
|
||||
|
||||
|
||||
def _record_commit_attempt(
|
||||
ctx: ToolContext,
|
||||
commit_message: Any = None,
|
||||
|
|
@ -174,6 +492,11 @@ def _record_commit_attempt(
|
|||
scope_model = _req("scope_model")
|
||||
triad_raw_results = _req("triad_raw_results", None)
|
||||
scope_raw_result = _req("scope_raw_result", None)
|
||||
block_class = _req("block_class")
|
||||
rebuttal_sha256 = _req("rebuttal_sha256")
|
||||
paid = _req("paid", False)
|
||||
review_contract_fingerprint = _req("review_contract_fingerprint")
|
||||
root_task_id = resolve_root_task_id(ctx)
|
||||
dr = pathlib.Path(ctx.drive_root)
|
||||
repo_key = make_repo_key(pathlib.Path(ctx.repo_dir))
|
||||
tool_name = _current_review_tool_name(ctx)
|
||||
|
|
@ -241,6 +564,13 @@ def _record_commit_attempt(
|
|||
attempt_no = int(existing.attempt or 0)
|
||||
else:
|
||||
attempt_no = state.next_attempt_number(repo_key, tool_name, task_id)
|
||||
# A NEW attempt inherits NOTHING from the previous terminal
|
||||
# row (mirrors the reviewing branch above). Leaking the
|
||||
# prior attempt's fields here let every fresh preflight
|
||||
# record inherit paid=True/block_class/fingerprints from
|
||||
# the last real review — inflating the paid-cycle count
|
||||
# on every free refusal (found by the F1 eviction test).
|
||||
existing = None
|
||||
ctx._current_review_attempt_number = attempt_no
|
||||
else:
|
||||
existing = state.latest_attempt_for(
|
||||
|
|
@ -301,6 +631,14 @@ def _record_commit_attempt(
|
|||
scope_model=scope_model or str(getattr(existing, "scope_model", "") or ""),
|
||||
triad_raw_results=list(triad_raw_results or []),
|
||||
scope_raw_result=dict(scope_raw_result or {}),
|
||||
block_class=block_class or str(getattr(existing, "block_class", "") or ""),
|
||||
rebuttal_sha256=rebuttal_sha256 or str(getattr(existing, "rebuttal_sha256", "") or ""),
|
||||
paid=bool(paid or getattr(existing, "paid", False)),
|
||||
review_contract_fingerprint=(
|
||||
review_contract_fingerprint
|
||||
or str(getattr(existing, "review_contract_fingerprint", "") or "")
|
||||
),
|
||||
root_task_id=root_task_id or str(getattr(existing, "root_task_id", "") or ""),
|
||||
)
|
||||
state.record_attempt(attempt, semantic_redirects=_obligation_redirects)
|
||||
|
||||
|
|
|
|||
|
|
@ -39,11 +39,21 @@ from ouroboros.tools.claude_advisory_review import (
|
|||
advisory_gate_unavailable,
|
||||
)
|
||||
from ouroboros.tools.commit_gate import (
|
||||
IDENTICAL_DIFF_BLOCK_REASON,
|
||||
_check_advisory_freshness,
|
||||
_check_overlapping_review_attempt,
|
||||
_invalidate_advisory,
|
||||
_record_commit_attempt,
|
||||
check_blocked_attempt_cap,
|
||||
check_identical_verdict_refusal,
|
||||
check_review_cycles_ceiling,
|
||||
classify_review_block,
|
||||
commit_review_contract_fingerprint,
|
||||
compute_rebuttal_sha256,
|
||||
resolve_root_task_id,
|
||||
)
|
||||
from ouroboros.review_cycles import (
|
||||
REASON_REVIEW_CYCLES_EXHAUSTED,
|
||||
emit_review_cycles_exhausted,
|
||||
)
|
||||
from ouroboros.tools.review_revalidation import handle_revalidation_failure
|
||||
from ouroboros.utils import utc_now_iso, write_text, safe_relpath, run_cmd
|
||||
|
|
@ -268,6 +278,7 @@ def _finalize_blocked_review(
|
|||
combined_findings: List[Dict[str, Any]],
|
||||
pre_fingerprint: Dict[str, Any],
|
||||
post_fingerprint: Dict[str, Any],
|
||||
block_class: str = "",
|
||||
) -> str:
|
||||
"""Persist a genuine blocked review result, then unstage the reviewed diff."""
|
||||
_record_commit_attempt(
|
||||
|
|
@ -279,6 +290,7 @@ def _finalize_blocked_review(
|
|||
duration_sec=time.time() - commit_start,
|
||||
critical_findings=combined_findings,
|
||||
phase="blocking_review",
|
||||
block_class=block_class,
|
||||
pre_review_fingerprint=pre_fingerprint.get("fingerprint", ""),
|
||||
post_review_fingerprint=post_fingerprint.get("fingerprint", ""),
|
||||
fingerprint_status="matched",
|
||||
|
|
@ -348,7 +360,23 @@ def _mark_failed_bypass_advisory_stale(
|
|||
log.debug("Failed to stale bypass advisory after preflight block", exc_info=True)
|
||||
|
||||
|
||||
def _refuse_capped_attempt(
|
||||
def _repair_managed_merge_head(ctx: ToolContext) -> None:
|
||||
"""Re-establish MERGE_HEAD for an authorized managed resolver after a
|
||||
refusal's index reset, so the resolution is not stranded (same repair the
|
||||
blocked-review path performs in ``_repo_commit_push``)."""
|
||||
try:
|
||||
from supervisor.update_merge import managed_assisted_tx_for, reestablish_merge_head
|
||||
|
||||
tx, _block = managed_assisted_tx_for(
|
||||
getattr(ctx, "task_id", ""), getattr(ctx, "task_metadata", None)
|
||||
)
|
||||
if tx:
|
||||
reestablish_merge_head(str(tx.get("target_sha") or ""))
|
||||
except Exception:
|
||||
log.debug("reestablish_merge_head after free refusal failed", exc_info=True)
|
||||
|
||||
|
||||
def _free_cycle_gate(
|
||||
ctx: ToolContext,
|
||||
commit_message: str,
|
||||
commit_start: float,
|
||||
|
|
@ -356,35 +384,124 @@ def _refuse_capped_attempt(
|
|||
pre_fingerprint: Dict[str, Any],
|
||||
review_rebuttal: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Identical-diff blocked-attempt cap preflight; None allows the attempt."""
|
||||
cap_msg = check_blocked_attempt_cap(
|
||||
ctx,
|
||||
pre_fingerprint.get("fingerprint", ""),
|
||||
has_rebuttal=bool(str(review_rebuttal or "").strip()),
|
||||
"""Max-Review-Cycles gate, run BEFORE advisory freshness and any paid
|
||||
dispatch. ``None`` allows a paid attempt. Two free typed outcomes:
|
||||
a byte-identical resubmission of a verdict-blocked diff without a NEW
|
||||
rebuttal, and an exhausted per-root-task paid-cycle ceiling. Under
|
||||
blocking enforcement each is a free refusal dict; under advisory each is
|
||||
an ``{"advisory_replay": …}`` marker — the commit proceeds with a loud
|
||||
disclosure and WITHOUT buying another review."""
|
||||
from ouroboros.config import get_review_enforcement
|
||||
|
||||
fp = pre_fingerprint.get("fingerprint", "")
|
||||
rebuttal_sha = compute_rebuttal_sha256(review_rebuttal)
|
||||
contract_fp = commit_review_contract_fingerprint()
|
||||
ctx._current_review_rebuttal_sha256 = rebuttal_sha
|
||||
ctx._current_review_contract_fingerprint = contract_fp
|
||||
root_task_id = resolve_root_task_id(ctx)
|
||||
identical_msg = check_identical_verdict_refusal(
|
||||
ctx, fp, rebuttal_sha256=rebuttal_sha, contract_fingerprint=contract_fp
|
||||
)
|
||||
if not cap_msg:
|
||||
ceiling = None if identical_msg else check_review_cycles_ceiling(
|
||||
ctx, root_task_id=root_task_id
|
||||
)
|
||||
if not identical_msg and ceiling is None:
|
||||
return None
|
||||
enforcement = str(get_review_enforcement() or "")
|
||||
reason = IDENTICAL_DIFF_BLOCK_REASON if identical_msg else REASON_REVIEW_CYCLES_EXHAUSTED
|
||||
message = identical_msg or str(ceiling["message"])
|
||||
if ceiling is not None:
|
||||
emit_review_cycles_exhausted(
|
||||
getattr(ctx, "event_queue", None), ctx.drive_root,
|
||||
surface="commit_gate", task_id=str(getattr(ctx, "task_id", "") or ""),
|
||||
cycles_paid=int(ceiling["cycles_paid"]), cap=int(ceiling["cap"]),
|
||||
enforcement=enforcement, root_task_id=root_task_id, fingerprint=str(fp),
|
||||
)
|
||||
if enforcement != "blocking":
|
||||
# ADVISORY: neither state hard-blocks a commit — disclose loudly (typed
|
||||
# event + result message) and reuse the recorded outcome for free.
|
||||
# The identical-replay half of this branch is structurally near-dead
|
||||
# TODAY (fable P3-2, deliberate): verdict rows are only minted under
|
||||
# blocking enforcement, and the review-contract fingerprint includes
|
||||
# enforcement, so blocking-era verdicts never match an advisory-era
|
||||
# contract — the streak lapses and the commit degrades to an ordinary
|
||||
# PAID review. Kept as defense-in-depth for any future path that
|
||||
# records advisory-era verdict rows.
|
||||
try:
|
||||
from ouroboros.utils import append_jsonl
|
||||
|
||||
append_jsonl(ctx.drive_logs() / "events.jsonl", {
|
||||
"ts": utc_now_iso(), "type": "commit_review_free_replay",
|
||||
"reason": reason, "enforcement": enforcement,
|
||||
"task_id": str(getattr(ctx, "task_id", "") or ""),
|
||||
"root_task_id": root_task_id,
|
||||
"pre_review_fingerprint": str(fp),
|
||||
})
|
||||
except Exception:
|
||||
log.debug("commit_review_free_replay event emission failed", exc_info=True)
|
||||
return {"advisory_replay": message, "replay_reason": reason}
|
||||
try:
|
||||
run_cmd(["git", "reset", "HEAD"], cwd=ctx.repo_dir)
|
||||
except Exception:
|
||||
pass
|
||||
if _authorized_managed_update_resolver(ctx):
|
||||
_repair_managed_merge_head(ctx)
|
||||
_record_commit_attempt(
|
||||
ctx,
|
||||
commit_message,
|
||||
"blocked",
|
||||
block_reason="attempt_cap_reached",
|
||||
block_details=cap_msg,
|
||||
block_reason=reason,
|
||||
block_details=message,
|
||||
duration_sec=time.time() - commit_start,
|
||||
phase="preflight",
|
||||
pre_review_fingerprint=pre_fingerprint.get("fingerprint", ""),
|
||||
pre_review_fingerprint=str(fp),
|
||||
rebuttal_sha256=rebuttal_sha,
|
||||
review_contract_fingerprint=contract_fp,
|
||||
)
|
||||
return {
|
||||
"status": "blocked",
|
||||
"message": cap_msg,
|
||||
"block_reason": "attempt_cap_reached",
|
||||
"message": message,
|
||||
"block_reason": reason,
|
||||
}
|
||||
|
||||
|
||||
def _install_paid_dispatch_stamp(
|
||||
ctx: ToolContext,
|
||||
commit_message: str,
|
||||
commit_start: float,
|
||||
pre_fingerprint: Dict[str, Any],
|
||||
) -> None:
|
||||
"""WRITE-AHEAD paid stamp (Q16 fix round, F2): durably merge ``paid=True``
|
||||
onto the current attempt row immediately BEFORE the first physical
|
||||
reviewer transport call, on EITHER side — triad and scope dispatch in
|
||||
parallel, so scope spend can coexist with a triad assembly overflow, and
|
||||
any side dispatching makes the cycle paid. Assembly-only exits (triad fit
|
||||
ladder, scope pack signals) never invoke the stamp, so a $0 attempt stays
|
||||
outside the ceiling; a crash after dispatch keeps the paid fact
|
||||
(write-ahead). The shared transport entry (``run_review_request``)
|
||||
invokes the stamp via ``ctx._review_paid_stamp`` — the seam where the
|
||||
L-review lane's two-phase admission slots in at synthesis."""
|
||||
from ouroboros.review_dispatch import ReviewPaidStamp
|
||||
|
||||
def _write() -> None:
|
||||
_record_commit_attempt(
|
||||
ctx,
|
||||
commit_message,
|
||||
"reviewing",
|
||||
duration_sec=time.time() - commit_start,
|
||||
phase="review",
|
||||
pre_review_fingerprint=pre_fingerprint.get("fingerprint", ""),
|
||||
fingerprint_status="pending",
|
||||
paid=True,
|
||||
rebuttal_sha256=str(getattr(ctx, "_current_review_rebuttal_sha256", "") or ""),
|
||||
review_contract_fingerprint=str(
|
||||
getattr(ctx, "_current_review_contract_fingerprint", "") or ""
|
||||
),
|
||||
)
|
||||
|
||||
ctx._review_paid_stamp = ReviewPaidStamp(_write)
|
||||
|
||||
|
||||
def _review_cycle_infra_failure(
|
||||
ctx: ToolContext,
|
||||
commit_message: str,
|
||||
|
|
@ -550,62 +667,19 @@ def _managed_candidate_needs_proof(ctx: ToolContext) -> bool:
|
|||
return True
|
||||
|
||||
|
||||
def _run_reviewed_stage_cycle(
|
||||
def _advisory_and_tests_gate(
|
||||
ctx: ToolContext,
|
||||
commit_message: str,
|
||||
commit_start: float,
|
||||
*,
|
||||
paths: Optional[List[str]] = None,
|
||||
skip_advisory_review: bool = False,
|
||||
skip_advisory_pre_review: bool = False,
|
||||
skip_tests: bool = False,
|
||||
goal: str = "",
|
||||
scope: str = "",
|
||||
review_rebuttal: str = "",
|
||||
came_from_detached_checkout: bool = False,
|
||||
require_release_tag: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
skip_advisory_pre_review = bool(skip_advisory_review or skip_advisory_pre_review)
|
||||
classification_paths, advisory_paths, stage_error = _stage_candidate_for_review(
|
||||
ctx,
|
||||
commit_message,
|
||||
commit_start,
|
||||
paths=paths,
|
||||
came_from_detached_checkout=came_from_detached_checkout,
|
||||
)
|
||||
if stage_error is not None:
|
||||
return stage_error
|
||||
protected_staged_paths = protected_paths_in(classification_paths)
|
||||
runtime_mode = _current_runtime_mode()
|
||||
if (
|
||||
protected_staged_paths
|
||||
and not mode_allows_protected_write(runtime_mode)
|
||||
and not _authorized_managed_update_resolver(ctx)
|
||||
):
|
||||
msg = _protected_paths_block_message(
|
||||
protected_staged_paths,
|
||||
runtime_mode=runtime_mode,
|
||||
action="commit",
|
||||
)
|
||||
try:
|
||||
run_cmd(["git", "reset", "HEAD"], cwd=ctx.repo_dir)
|
||||
except Exception:
|
||||
pass
|
||||
_record_commit_attempt(
|
||||
ctx,
|
||||
commit_message,
|
||||
"blocked",
|
||||
block_reason="core_protection_blocked",
|
||||
block_details=msg,
|
||||
duration_sec=time.time() - commit_start,
|
||||
critical_findings=[],
|
||||
phase="preflight",
|
||||
)
|
||||
return {
|
||||
"status": "blocked",
|
||||
"message": msg,
|
||||
"block_reason": "core_protection_blocked",
|
||||
}
|
||||
classification_paths: List[str],
|
||||
advisory_paths: Optional[List[str]],
|
||||
skip_advisory_pre_review: bool,
|
||||
skip_tests: bool,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Advisory-freshness gate plus the compensating tests preflight (moved
|
||||
whole out of ``_run_reviewed_stage_cycle`` at the function-size gate).
|
||||
``None`` = proceed to review."""
|
||||
advisory_err = _check_advisory_freshness(
|
||||
ctx,
|
||||
commit_message,
|
||||
|
|
@ -682,7 +756,7 @@ def _run_reviewed_stage_cycle(
|
|||
block_details=msg,
|
||||
duration_sec=time.time() - commit_start,
|
||||
# Preflight, not a review verdict: must neither inflate nor
|
||||
# reset the identical-diff blocked-attempt cap streak.
|
||||
# reset the identical-diff refusal streak.
|
||||
phase="preflight",
|
||||
)
|
||||
_mark_failed_bypass_advisory_stale(ctx, commit_message, advisory_paths)
|
||||
|
|
@ -711,7 +785,65 @@ def _run_reviewed_stage_cycle(
|
|||
)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _run_reviewed_stage_cycle(
|
||||
ctx: ToolContext,
|
||||
commit_message: str,
|
||||
commit_start: float,
|
||||
*,
|
||||
paths: Optional[List[str]] = None,
|
||||
skip_advisory_review: bool = False,
|
||||
skip_advisory_pre_review: bool = False,
|
||||
skip_tests: bool = False,
|
||||
goal: str = "",
|
||||
scope: str = "",
|
||||
review_rebuttal: str = "",
|
||||
came_from_detached_checkout: bool = False,
|
||||
require_release_tag: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
skip_advisory_pre_review = bool(skip_advisory_review or skip_advisory_pre_review)
|
||||
classification_paths, advisory_paths, stage_error = _stage_candidate_for_review(
|
||||
ctx,
|
||||
commit_message,
|
||||
commit_start,
|
||||
paths=paths,
|
||||
came_from_detached_checkout=came_from_detached_checkout,
|
||||
)
|
||||
if stage_error is not None:
|
||||
return stage_error
|
||||
protected_staged_paths = protected_paths_in(classification_paths)
|
||||
runtime_mode = _current_runtime_mode()
|
||||
if (
|
||||
protected_staged_paths
|
||||
and not mode_allows_protected_write(runtime_mode)
|
||||
and not _authorized_managed_update_resolver(ctx)
|
||||
):
|
||||
msg = _protected_paths_block_message(
|
||||
protected_staged_paths,
|
||||
runtime_mode=runtime_mode,
|
||||
action="commit",
|
||||
)
|
||||
try:
|
||||
run_cmd(["git", "reset", "HEAD"], cwd=ctx.repo_dir)
|
||||
except Exception:
|
||||
pass
|
||||
_record_commit_attempt(
|
||||
ctx,
|
||||
commit_message,
|
||||
"blocked",
|
||||
block_reason="core_protection_blocked",
|
||||
block_details=msg,
|
||||
duration_sec=time.time() - commit_start,
|
||||
critical_findings=[],
|
||||
phase="preflight",
|
||||
)
|
||||
return {
|
||||
"status": "blocked",
|
||||
"message": msg,
|
||||
"block_reason": "core_protection_blocked",
|
||||
}
|
||||
pre_fingerprint = _fingerprint_staged_diff(pathlib.Path(ctx.repo_dir))
|
||||
if not pre_fingerprint.get("ok"):
|
||||
return {
|
||||
|
|
@ -727,6 +859,31 @@ def _run_reviewed_stage_cycle(
|
|||
"pre_fingerprint": pre_fingerprint,
|
||||
"post_fingerprint": {},
|
||||
}
|
||||
# Max-Review-Cycles free gate: BEFORE the advisory-freshness gate (an
|
||||
# identical resubmission must not be told to buy a fresh SDK advisory) and
|
||||
# before any paid dispatch. A refusal is free; under advisory it becomes a
|
||||
# free replay marker instead of a block. The fingerprint is computed early
|
||||
# because this gate needs it; the binding-precondition check keeps its
|
||||
# original place AFTER the advisory/tests gate (wording-4, I1).
|
||||
gate_outcome = _free_cycle_gate(
|
||||
ctx, commit_message, commit_start,
|
||||
pre_fingerprint=pre_fingerprint, review_rebuttal=review_rebuttal,
|
||||
)
|
||||
advisory_replay: Optional[Dict[str, Any]] = None
|
||||
if gate_outcome is not None:
|
||||
if "advisory_replay" in gate_outcome:
|
||||
advisory_replay = gate_outcome
|
||||
else:
|
||||
return gate_outcome
|
||||
advisory_gate_outcome = _advisory_and_tests_gate(
|
||||
ctx, commit_message, commit_start,
|
||||
classification_paths=classification_paths,
|
||||
advisory_paths=advisory_paths,
|
||||
skip_advisory_pre_review=skip_advisory_pre_review,
|
||||
skip_tests=skip_tests,
|
||||
)
|
||||
if advisory_gate_outcome is not None:
|
||||
return advisory_gate_outcome
|
||||
binding_error = _review_binding_precondition_error(
|
||||
pre_fingerprint, require_release_tag=require_release_tag
|
||||
)
|
||||
|
|
@ -749,12 +906,6 @@ def _run_reviewed_stage_cycle(
|
|||
"pre_fingerprint": pre_fingerprint,
|
||||
"post_fingerprint": {},
|
||||
}
|
||||
cap_refusal = _refuse_capped_attempt(
|
||||
ctx, commit_message, commit_start,
|
||||
pre_fingerprint=pre_fingerprint, review_rebuttal=review_rebuttal,
|
||||
)
|
||||
if cap_refusal is not None:
|
||||
return cap_refusal
|
||||
_record_commit_attempt(
|
||||
ctx,
|
||||
commit_message,
|
||||
|
|
@ -763,15 +914,61 @@ def _run_reviewed_stage_cycle(
|
|||
phase="review",
|
||||
pre_review_fingerprint=pre_fingerprint.get("fingerprint", ""),
|
||||
fingerprint_status="pending",
|
||||
# The PAID fact is deliberately NOT recorded here: it lands write-ahead
|
||||
# at the first PHYSICAL reviewer dispatch (_install_paid_dispatch_stamp
|
||||
# below, plan-review precedent), so an attempt where BOTH packs refuse
|
||||
# at assembly — and a free advisory replay, which dispatches nothing —
|
||||
# stays unpaid. The per-root-task cycle count is derived from these
|
||||
# rows, never from a separate counter file (P7).
|
||||
rebuttal_sha256=str(getattr(ctx, "_current_review_rebuttal_sha256", "") or ""),
|
||||
review_contract_fingerprint=str(
|
||||
getattr(ctx, "_current_review_contract_fingerprint", "") or ""
|
||||
),
|
||||
)
|
||||
|
||||
review_err, scope_result, triad_block_reason, triad_advisory = _run_parallel_review(
|
||||
ctx,
|
||||
commit_message,
|
||||
goal=goal,
|
||||
scope=scope,
|
||||
review_rebuttal=review_rebuttal,
|
||||
)
|
||||
if advisory_replay is not None:
|
||||
# ADVISORY free outcome: disclose loudly and let the commit proceed
|
||||
# without buying another triad+scope run. Honest wording per cause
|
||||
# (wording-3): an identical-diff replay REUSES a recorded verdict; a
|
||||
# ceiling exhaustion on NEW bytes has no verdict to reuse — the diff
|
||||
# ships without a fresh review and the disclosure must say so.
|
||||
review_err, scope_result, triad_block_reason, triad_advisory = None, None, "", []
|
||||
replay_reason = str(advisory_replay.get("replay_reason") or "")
|
||||
if replay_reason == IDENTICAL_DIFF_BLOCK_REASON:
|
||||
progress_note = (
|
||||
"Max Review Cycles: identical staged diff — reusing the recorded "
|
||||
"review verdict, no paid triad+scope dispatch."
|
||||
)
|
||||
else:
|
||||
progress_note = (
|
||||
"Max Review Cycles: paid-cycle ceiling exhausted — no review outcome "
|
||||
"exists for this diff; the commit proceeds without a fresh triad+scope "
|
||||
"review under advisory enforcement."
|
||||
)
|
||||
disclosure = (
|
||||
"Review enforcement=Advisory: no new triad+scope review was bought for "
|
||||
f"this commit ({replay_reason}). "
|
||||
+ str(advisory_replay.get("advisory_replay") or "")
|
||||
)
|
||||
advisory_list = getattr(ctx, "_review_advisory", None)
|
||||
if isinstance(advisory_list, list):
|
||||
advisory_list.append(disclosure)
|
||||
try:
|
||||
ctx.emit_progress_fn(progress_note)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
_install_paid_dispatch_stamp(ctx, commit_message, commit_start, pre_fingerprint)
|
||||
try:
|
||||
review_err, scope_result, triad_block_reason, triad_advisory = _run_parallel_review(
|
||||
ctx,
|
||||
commit_message,
|
||||
goal=goal,
|
||||
scope=scope,
|
||||
review_rebuttal=review_rebuttal,
|
||||
)
|
||||
finally:
|
||||
ctx._review_paid_stamp = None
|
||||
blocked, combined_msg, block_reason, combined_findings, scope_advisory = _aggregate_review_verdict(
|
||||
review_err,
|
||||
scope_result,
|
||||
|
|
@ -818,6 +1015,18 @@ def _run_reviewed_stage_cycle(
|
|||
"post_fingerprint": post_fingerprint,
|
||||
}
|
||||
if blocked:
|
||||
# Typed block-row classification (Q16/Δ5): a reviewer VERDICT builds
|
||||
# the identical-diff refusal streak; an INFRA fact (fit/quorum/
|
||||
# transport/sub-floor) never does and retries freely. Money is a
|
||||
# separate axis: paid was stamped at PHYSICAL dispatch, so a
|
||||
# dispatched-then-infra-blocked wave still counts toward the ceiling,
|
||||
# while an assembly-refused (undispatched) infra block stays free.
|
||||
block_class = classify_review_block(
|
||||
triad_blocked=bool(review_err),
|
||||
triad_block_reason=str(triad_block_reason or ""),
|
||||
scope_blocked=bool(scope_result is not None and getattr(scope_result, "blocked", False)),
|
||||
scope_raw_result=getattr(ctx, "_last_scope_raw_result", {}) or {},
|
||||
)
|
||||
return {
|
||||
"status": "blocked",
|
||||
"message": _finalize_blocked_review(
|
||||
|
|
@ -829,6 +1038,7 @@ def _run_reviewed_stage_cycle(
|
|||
combined_findings=combined_findings,
|
||||
pre_fingerprint=pre_fingerprint,
|
||||
post_fingerprint=post_fingerprint,
|
||||
block_class=block_class,
|
||||
),
|
||||
"block_reason": block_reason,
|
||||
"pre_fingerprint": pre_fingerprint,
|
||||
|
|
@ -2874,7 +3084,7 @@ def get_tools() -> List[ToolEntry]:
|
|||
"paths": {"type": "array", "items": {"type": "string"}, "description": "Optional subset of task-attributed clean-at-baseline paths. Omitted computes the full attributed candidate set; an empty set never stages the whole tree."},
|
||||
"skip_tests": {"type": "boolean", "default": False, "description": "Skip pre-commit tests."},
|
||||
"review_rebuttal": {"type": "string", "default": "",
|
||||
"description": "If previous commit was blocked by reviewers and you disagree, include counter-argument."},
|
||||
"description": "If the previous commit was blocked by reviewers and you disagree, include a counter-argument. The rebuttal is identified by CONTENT: a rebuttal new to the current identical-diff streak buys exactly ONE paid re-review of the unchanged diff; resubmitting the same rebuttal (or none) is refused for free, quoting the recorded verdict."},
|
||||
"skip_advisory_review": {"type": "boolean", "default": False,
|
||||
"description": skip_advisory_description},
|
||||
"goal": {"type": "string", "default": "",
|
||||
|
|
@ -2890,7 +3100,8 @@ def get_tools() -> List[ToolEntry]:
|
|||
"commit_message": {"type": "string"},
|
||||
"paths": {"type": "array", "items": {"type": "string"}, "description": "Optional subset of task-attributed clean-at-baseline paths. Omitted computes candidates; empty never means git add -A."},
|
||||
"skip_tests": {"type": "boolean", "default": False, "description": "Skip pre-commit tests."},
|
||||
"review_rebuttal": {"type": "string", "default": ""},
|
||||
"review_rebuttal": {"type": "string", "default": "",
|
||||
"description": "Content-hashed counter-argument to a prior review block: a NEW rebuttal buys exactly one paid re-review of an unchanged diff; a repeated one is refused free."},
|
||||
"skip_advisory_review": {"type": "boolean", "default": False,
|
||||
"description": skip_advisory_description},
|
||||
"goal": {"type": "string", "default": ""},
|
||||
|
|
|
|||
|
|
@ -1057,7 +1057,17 @@ _REVIEW_SCHEMA = {
|
|||
"shared reviewer-slot configuration and scored against the "
|
||||
"Skill Review Checklist section in docs/CHECKLISTS.md. Persists the "
|
||||
"verdict to data/state/skills/<name>/review.json with a content "
|
||||
"hash so a later edit invalidates the review automatically."
|
||||
"hash so a later edit invalidates the review automatically. "
|
||||
"Max Review Cycles semantics: an identical snapshot never buys a new "
|
||||
"panel — a recorded substantive verdict REPLAYS free, keyed by (review "
|
||||
"group, content hash, panel-contract fingerprint), so only changed "
|
||||
"content, a changed panel contract, or a NEW rebuttal dispatches "
|
||||
"reviewers again. The shared OUROBOROS_REVIEW_MAX_CYCLES ceiling "
|
||||
"bounds PAID panel dispatches per ceiling key (root task for "
|
||||
"task-driven reviews; the current content snapshot for the manual "
|
||||
"lane); on exhaustion the review is refused free with a typed "
|
||||
"review_cycles_exhausted event and the skill stays honestly PENDING — "
|
||||
"never executable without a real verdict."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
|
|
@ -1071,7 +1081,12 @@ _REVIEW_SCHEMA = {
|
|||
"description": (
|
||||
"Optional rebuttal to prior review findings. Use only when "
|
||||
"you have code-grounded evidence that a previous finding was "
|
||||
"a false positive or already addressed."
|
||||
"a false positive or already addressed. The rebuttal is "
|
||||
"identified by CONTENT: its sha256 buys exactly ONE paid "
|
||||
"panel wave when new to the current snapshot's streak; "
|
||||
"repeating an already-adjudicated rebuttal replays the "
|
||||
"recorded verdict free, and no rebuttal buys past the "
|
||||
"paid-cycle ceiling."
|
||||
),
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -549,10 +549,19 @@ depth layers.
|
|||
Every commit is a release. Before commit, update all version carriers together:
|
||||
`VERSION`, `pyproject.toml` (PEP 440 canonical form), README badge/changelog, and
|
||||
`docs/ARCHITECTURE.md` header. Then use `commit_reviewed`; the commit path creates
|
||||
the annotated `v{VERSION}` tag automatically after the commit. After the configured number of genuine
|
||||
review-verdict blocks of a byte-identical staged diff, `commit_reviewed` refuses
|
||||
further attempts (`attempt_cap_reached`) — change the diff, provide a
|
||||
`review_rebuttal`, or escalate to the owner.
|
||||
the annotated `v{VERSION}` tag automatically after the commit. Identical bytes are
|
||||
never re-reviewed for pay: after ANY review-verdict block, resubmitting the
|
||||
byte-identical staged diff is refused for free (`identical_diff_refused`, quoting
|
||||
the recorded verdict) — change the diff, provide a NEW `review_rebuttal` (a
|
||||
rebuttal new to the streak buys exactly one paid re-review; a repeated one is
|
||||
refused free), or escalate to the owner. The Max Review Cycles setting bounds
|
||||
PAID triad+scope cycles per ROOT task — the whole task tree shares one ceiling
|
||||
(a follow-up task starts its own); on exhaustion no further review is bought
|
||||
and the typed `review_cycles_exhausted` event fires (every dispatched wave
|
||||
counts; undispatched refusals and replays do not) — finalize honestly or ask
|
||||
the owner to raise the cap. In both states, under blocking enforcement the
|
||||
commit is refused for free; under advisory the commit proceeds with a loud
|
||||
durable disclosure and no new review spend.
|
||||
|
||||
## Git Attribution
|
||||
|
||||
|
|
|
|||
|
|
@ -203,9 +203,14 @@ def test_commit_gate_bypass_detection_rides_the_route_slot_aware_predicate():
|
|||
|
||||
from ouroboros.tools import git as git_mod
|
||||
|
||||
source = inspect.getsource(git_mod._run_reviewed_stage_cycle)
|
||||
assert "advisory_gate_unavailable" in source
|
||||
assert 'os.environ.get("ANTHROPIC_API_KEY"' not in source
|
||||
# The bypass decision lives in the advisory+tests gate helper the stage
|
||||
# cycle calls (extracted whole at the function-size gate).
|
||||
stage_source = inspect.getsource(git_mod._run_reviewed_stage_cycle)
|
||||
assert "_advisory_and_tests_gate" in stage_source
|
||||
assert 'os.environ.get("ANTHROPIC_API_KEY"' not in stage_source
|
||||
gate_source = inspect.getsource(git_mod._advisory_and_tests_gate)
|
||||
assert "advisory_gate_unavailable" in gate_source
|
||||
assert 'os.environ.get("ANTHROPIC_API_KEY"' not in gate_source
|
||||
|
||||
|
||||
def _clear_session_route_envs(monkeypatch):
|
||||
|
|
|
|||
|
|
@ -50,11 +50,18 @@ def test_tool_registered(tool_name):
|
|||
assert tool_name in names
|
||||
|
||||
|
||||
def test_blocked_attempt_cap_refuses_identical_diff_resubmission(tmp_path, monkeypatch):
|
||||
"""B4: after blocked_attempt_fingerprint_cap() review-blocks of the SAME
|
||||
staged-diff fingerprint (the shared OUROBOROS_REVIEW_MAX_CYCLES, default 2),
|
||||
the next attempt is refused BEFORE triad+scope; a changed diff or a
|
||||
review_rebuttal lifts the cap; refusal records do not reset the streak."""
|
||||
CONTRACT_FP = "contract-fp-1"
|
||||
|
||||
|
||||
def _identical_ctx(tmp_path, task_id="t-cap"):
|
||||
return types.SimpleNamespace(repo_dir=tmp_path, drive_root=tmp_path, task_id=task_id)
|
||||
|
||||
|
||||
def _add_attempt(tmp_path, status, fingerprint, *, block_reason="critical_findings",
|
||||
attempt=1, phase="blocking_review", task_id="t-cap",
|
||||
block_class="", rebuttal_sha256="",
|
||||
review_contract_fingerprint=CONTRACT_FP,
|
||||
critical_findings=None, paid=True):
|
||||
import pathlib
|
||||
|
||||
from ouroboros.review_state import (
|
||||
|
|
@ -63,71 +70,155 @@ def test_blocked_attempt_cap_refuses_identical_diff_resubmission(tmp_path, monke
|
|||
update_state,
|
||||
_utc_now,
|
||||
)
|
||||
|
||||
repo_key = make_repo_key(pathlib.Path(tmp_path))
|
||||
|
||||
def _mutate(state):
|
||||
state.attempts.append(CommitAttemptRecord(
|
||||
ts=_utc_now(), commit_message="msg", status=status,
|
||||
block_reason=block_reason if status == "blocked" else "",
|
||||
repo_key=repo_key, tool_name="commit_reviewed", task_id=task_id,
|
||||
attempt=attempt, phase=phase,
|
||||
pre_review_fingerprint=fingerprint,
|
||||
block_class=block_class,
|
||||
rebuttal_sha256=rebuttal_sha256,
|
||||
review_contract_fingerprint=review_contract_fingerprint,
|
||||
critical_findings=list(critical_findings or []),
|
||||
paid=paid,
|
||||
))
|
||||
update_state(pathlib.Path(tmp_path), _mutate)
|
||||
|
||||
|
||||
def test_identical_diff_refused_free_from_first_verdict_block(tmp_path, monkeypatch):
|
||||
"""Q12/Q16 contract: identical bytes are never re-reviewed for pay. ONE
|
||||
review-verdict block of a staged-diff fingerprint refuses a byte-identical
|
||||
resubmission (quoting the recorded verdict), regardless of the cycles knob;
|
||||
a changed diff starts fresh; a cross-task identical resubmit stays refused
|
||||
(anti-laundering); a success ends the streak."""
|
||||
from ouroboros.tools.commit_gate import check_identical_verdict_refusal
|
||||
|
||||
monkeypatch.setenv("OUROBOROS_REVIEW_MAX_CYCLES", "unlimited") # refusal is knob-independent
|
||||
ctx = _identical_ctx(tmp_path)
|
||||
|
||||
# FIRST verdict-block already refuses — no paid streak of N is required.
|
||||
_add_attempt(tmp_path, "blocked", "fp-same", block_class="verdict",
|
||||
critical_findings=[{"item": "bug_x", "reason": "boom", "severity": "critical"}])
|
||||
msg = check_identical_verdict_refusal(ctx, "fp-same", contract_fingerprint=CONTRACT_FP)
|
||||
assert "IDENTICAL_DIFF_REFUSED" in msg
|
||||
assert "bug_x" in msg # quotes the recorded verdict
|
||||
# Cross-task: the byte-identical diff is the identity.
|
||||
other = _identical_ctx(tmp_path, task_id="t-other")
|
||||
assert "IDENTICAL_DIFF_REFUSED" in check_identical_verdict_refusal(
|
||||
other, "fp-same", contract_fingerprint=CONTRACT_FP)
|
||||
# A different staged diff is a fresh paid case.
|
||||
assert check_identical_verdict_refusal(ctx, "fp-other", contract_fingerprint=CONTRACT_FP) == ""
|
||||
# A refusal record must not reset the streak.
|
||||
_add_attempt(tmp_path, "blocked", "fp-same", block_reason="identical_diff_refused",
|
||||
phase="preflight", attempt=2)
|
||||
assert "IDENTICAL_DIFF_REFUSED" in check_identical_verdict_refusal(
|
||||
ctx, "fp-same", contract_fingerprint=CONTRACT_FP)
|
||||
# A successful commit ends the streak.
|
||||
_add_attempt(tmp_path, "succeeded", "fp-same", attempt=3, phase="commit")
|
||||
assert check_identical_verdict_refusal(ctx, "fp-same", contract_fingerprint=CONTRACT_FP) == ""
|
||||
|
||||
|
||||
def test_identical_refusal_rebuttal_by_content_and_contract_lapse(tmp_path, monkeypatch):
|
||||
"""Q16/Q22 contract: a rebuttal hash NEW to the streak buys exactly one
|
||||
paid re-review; the SAME hash is refused free; a changed (or unknown)
|
||||
review-contract fingerprint lapses the streak entirely."""
|
||||
from ouroboros.tools.commit_gate import (
|
||||
blocked_attempt_fingerprint_cap,
|
||||
check_blocked_attempt_cap,
|
||||
check_identical_verdict_refusal,
|
||||
compute_rebuttal_sha256,
|
||||
)
|
||||
|
||||
monkeypatch.delenv("OUROBOROS_REVIEW_MAX_CYCLES", raising=False)
|
||||
BLOCKED_ATTEMPT_FINGERPRINT_CAP = blocked_attempt_fingerprint_cap()
|
||||
assert BLOCKED_ATTEMPT_FINGERPRINT_CAP == 2 # shared default (was a hardcoded 3)
|
||||
ctx = _identical_ctx(tmp_path)
|
||||
_add_attempt(tmp_path, "blocked", "fp-r", block_class="verdict")
|
||||
|
||||
ctx = types.SimpleNamespace(repo_dir=tmp_path, drive_root=tmp_path, task_id="t-cap")
|
||||
repo_key = make_repo_key(pathlib.Path(tmp_path))
|
||||
new_sha = compute_rebuttal_sha256("the finding is a false positive because ...")
|
||||
assert new_sha and compute_rebuttal_sha256("") == ""
|
||||
# NEW rebuttal content: exempt (buys one paid re-review).
|
||||
assert check_identical_verdict_refusal(
|
||||
ctx, "fp-r", rebuttal_sha256=new_sha, contract_fingerprint=CONTRACT_FP) == ""
|
||||
# That rebuttal is spent on the streak (recorded on the next verdict-block):
|
||||
_add_attempt(tmp_path, "blocked", "fp-r", attempt=2, block_class="verdict",
|
||||
rebuttal_sha256=new_sha)
|
||||
repeated = check_identical_verdict_refusal(
|
||||
ctx, "fp-r", rebuttal_sha256=new_sha, contract_fingerprint=CONTRACT_FP)
|
||||
assert "IDENTICAL_DIFF_REFUSED" in repeated
|
||||
assert "repeated rebuttal" in repeated
|
||||
# A genuinely different rebuttal buys again.
|
||||
assert check_identical_verdict_refusal(
|
||||
ctx, "fp-r", rebuttal_sha256=compute_rebuttal_sha256("different evidence"),
|
||||
contract_fingerprint=CONTRACT_FP) == ""
|
||||
# A rebuttal is "spent" only when it BOUGHT a dispatch (machine-4/wording-2):
|
||||
# one recorded on an UNDISPATCHED refusal row (e.g. a ceiling refusal) stays
|
||||
# fresh — after the owner raises the cap it still buys its paid re-review.
|
||||
undispatched = compute_rebuttal_sha256("never dispatched")
|
||||
_add_attempt(tmp_path, "blocked", "fp-r", attempt=3,
|
||||
block_reason="review_cycles_exhausted", phase="preflight",
|
||||
rebuttal_sha256=undispatched, paid=False)
|
||||
assert check_identical_verdict_refusal(
|
||||
ctx, "fp-r", rebuttal_sha256=undispatched, contract_fingerprint=CONTRACT_FP) == ""
|
||||
# Q22: a changed contract fingerprint invalidates the streak — a paid
|
||||
# review is allowed and the refusal never quotes across the change.
|
||||
assert check_identical_verdict_refusal(
|
||||
ctx, "fp-r", contract_fingerprint="another-contract") == ""
|
||||
# An unknown current contract (fail-open "") never refuses.
|
||||
assert check_identical_verdict_refusal(ctx, "fp-r", contract_fingerprint="") == ""
|
||||
# The lapse applies to the streak HEAD only: an OLDER row from a previous
|
||||
# contract ends the streak but a NEWER verdict under the current contract
|
||||
# keeps its refusal authority.
|
||||
_add_attempt(tmp_path, "blocked", "fp-mixed", attempt=1, block_class="verdict",
|
||||
review_contract_fingerprint="old-contract")
|
||||
_add_attempt(tmp_path, "blocked", "fp-mixed", attempt=2, block_class="verdict",
|
||||
review_contract_fingerprint=CONTRACT_FP)
|
||||
assert "IDENTICAL_DIFF_REFUSED" in check_identical_verdict_refusal(
|
||||
ctx, "fp-mixed", contract_fingerprint=CONTRACT_FP)
|
||||
|
||||
def _add_attempt(status, fingerprint, block_reason="critical_findings", attempt=1,
|
||||
phase="blocking_review", task_id="t-cap"):
|
||||
def _mutate(state):
|
||||
state.attempts.append(CommitAttemptRecord(
|
||||
ts=_utc_now(), commit_message="msg", status=status,
|
||||
block_reason=block_reason if status == "blocked" else "",
|
||||
repo_key=repo_key, tool_name="commit_reviewed", task_id=task_id,
|
||||
attempt=attempt, phase=phase,
|
||||
pre_review_fingerprint=fingerprint,
|
||||
))
|
||||
update_state(pathlib.Path(tmp_path), _mutate)
|
||||
|
||||
# Below the cap: allowed. The second block comes from a DIFFERENT task —
|
||||
# the cap is diff-scoped, so a new task with the same unchanged diff
|
||||
# continues the streak instead of resetting it.
|
||||
_add_attempt("blocked", "fp-same", attempt=1)
|
||||
for i in range(BLOCKED_ATTEMPT_FINGERPRINT_CAP - 2):
|
||||
_add_attempt("blocked", "fp-same", attempt=i + 1, task_id="t-other")
|
||||
assert check_blocked_attempt_cap(ctx, "fp-same") == ""
|
||||
def test_identical_refusal_skips_infra_and_preflight_rows(tmp_path, monkeypatch):
|
||||
"""Δ5 contract: infra-blocks (fit/quorum/transport/revalidation) and
|
||||
preflight facts neither build the refusal streak nor reset it — the
|
||||
recorded verdict stays authoritative through infra noise, and infra-only
|
||||
history never refuses anything."""
|
||||
from ouroboros.tools.commit_gate import check_identical_verdict_refusal
|
||||
|
||||
# A preflight block (e.g. stale advisory) inheriting the same fingerprint
|
||||
# is NOT a review verdict: it must neither inflate nor reset the streak.
|
||||
_add_attempt("blocked", "fp-same", block_reason="no_advisory",
|
||||
attempt=BLOCKED_ATTEMPT_FINGERPRINT_CAP, phase="preflight")
|
||||
assert check_blocked_attempt_cap(ctx, "fp-same") == ""
|
||||
monkeypatch.delenv("OUROBOROS_REVIEW_MAX_CYCLES", raising=False)
|
||||
ctx = _identical_ctx(tmp_path)
|
||||
|
||||
# At the cap: refused.
|
||||
_add_attempt("blocked", "fp-same", attempt=BLOCKED_ATTEMPT_FINGERPRINT_CAP + 1)
|
||||
msg = check_blocked_attempt_cap(ctx, "fp-same")
|
||||
assert "REVIEW_ATTEMPT_CAP" in msg
|
||||
# Infra-only history: retry freely, never a refusal.
|
||||
_add_attempt(tmp_path, "blocked", "fp-i", block_reason="review_quorum",
|
||||
block_class="infra")
|
||||
_add_attempt(tmp_path, "blocked", "fp-i", block_reason="fixed_overflow",
|
||||
block_class="infra", attempt=2)
|
||||
assert check_identical_verdict_refusal(ctx, "fp-i", contract_fingerprint=CONTRACT_FP) == ""
|
||||
|
||||
# A rebuttal-bearing call is exempt (rebuttal IS new review input).
|
||||
assert check_blocked_attempt_cap(ctx, "fp-same", has_rebuttal=True) == ""
|
||||
|
||||
# A different staged diff starts fresh.
|
||||
assert check_blocked_attempt_cap(ctx, "fp-other") == ""
|
||||
|
||||
# Cap-refusal records themselves must not reset the streak.
|
||||
_add_attempt("blocked", "fp-same", block_reason="attempt_cap_reached",
|
||||
attempt=BLOCKED_ATTEMPT_FINGERPRINT_CAP + 2)
|
||||
assert "REVIEW_ATTEMPT_CAP" in check_blocked_attempt_cap(ctx, "fp-same")
|
||||
|
||||
# A successful commit breaks the streak.
|
||||
_add_attempt("committed", "fp-same", attempt=BLOCKED_ATTEMPT_FINGERPRINT_CAP + 3)
|
||||
assert check_blocked_attempt_cap(ctx, "fp-same") == ""
|
||||
# A verdict-block, then infra + preflight noise: still refused.
|
||||
_add_attempt(tmp_path, "blocked", "fp-i", attempt=3, block_class="verdict")
|
||||
_add_attempt(tmp_path, "blocked", "fp-i", block_reason="review_quorum",
|
||||
block_class="infra", attempt=4)
|
||||
_add_attempt(tmp_path, "blocked", "fp-i", block_reason="tests_preflight_blocked",
|
||||
phase="preflight", attempt=5)
|
||||
_add_attempt(tmp_path, "blocked", "", block_reason="tests_preflight_blocked",
|
||||
phase="preflight", task_id="t-new", attempt=1)
|
||||
# machine-2: a FAILED infra/expired row (lock timeout, path error, expired
|
||||
# reviewing attempt) is a transient too — it must not reset the streak.
|
||||
_add_attempt(tmp_path, "failed", "fp-i", phase="infra", attempt=6, paid=False)
|
||||
_add_attempt(tmp_path, "failed", "fp-i", phase="expired", attempt=7)
|
||||
assert "IDENTICAL_DIFF_REFUSED" in check_identical_verdict_refusal(
|
||||
ctx, "fp-i", contract_fingerprint=CONTRACT_FP)
|
||||
# A POST-REVIEW failure (the paid review completed, usually with a PASS)
|
||||
# supersedes the old verdict and ends the streak.
|
||||
_add_attempt(tmp_path, "failed", "fp-i", phase="post_commit_tests", attempt=8)
|
||||
assert check_identical_verdict_refusal(ctx, "fp-i", contract_fingerprint=CONTRACT_FP) == ""
|
||||
|
||||
|
||||
def test_tests_preflight_block_recorded_with_preflight_phase():
|
||||
"""The tests-preflight `_record_commit_attempt` call site must stamp
|
||||
phase="preflight": without it `infer_review_phase` defaults a blocked
|
||||
record to "blocking_review" and the identical-diff cap would count a flaky
|
||||
test failure as a review verdict (inflating the streak same-task) or break
|
||||
the streak from a new task (empty inherited fingerprint)."""
|
||||
record to "blocking_review" and legacy-row classification could read a
|
||||
flaky test failure as a review verdict for the identical-diff refusal."""
|
||||
git_mod = _get_git_module()
|
||||
source = inspect.getsource(git_mod)
|
||||
idx = source.find('block_reason="tests_preflight_blocked"')
|
||||
|
|
@ -137,59 +228,46 @@ def test_tests_preflight_block_recorded_with_preflight_phase():
|
|||
assert 'phase="preflight"' in window
|
||||
|
||||
|
||||
def test_blocked_attempt_cap_ignores_tests_preflight_blocks(tmp_path, monkeypatch):
|
||||
"""A tests-preflight block recorded the way ouroboros/tools/git.py records
|
||||
it (block_reason=tests_preflight_blocked, phase=preflight) neither inflates
|
||||
nor resets the identical-diff streak — in BOTH directions: same-task with
|
||||
an inherited fingerprint, and new-task with an empty fingerprint."""
|
||||
import pathlib
|
||||
def test_legacy_rows_classify_by_block_reason(tmp_path, monkeypatch):
|
||||
"""Pre-upgrade ledger rows carry no block_class: critical_findings rows
|
||||
keep building the refusal streak (verdict), while quorum/fit/transport
|
||||
rows classify infra and never refuse; preflight/refusal rows stay
|
||||
unclassified."""
|
||||
import types as _types
|
||||
|
||||
from ouroboros.review_state import (
|
||||
CommitAttemptRecord,
|
||||
make_repo_key,
|
||||
update_state,
|
||||
_utc_now,
|
||||
)
|
||||
from ouroboros.tools.commit_gate import (
|
||||
blocked_attempt_fingerprint_cap,
|
||||
check_blocked_attempt_cap,
|
||||
BLOCK_CLASS_INFRA,
|
||||
BLOCK_CLASS_VERDICT,
|
||||
attempt_block_class,
|
||||
check_identical_verdict_refusal,
|
||||
)
|
||||
|
||||
monkeypatch.delenv("OUROBOROS_REVIEW_MAX_CYCLES", raising=False)
|
||||
BLOCKED_ATTEMPT_FINGERPRINT_CAP = blocked_attempt_fingerprint_cap()
|
||||
assert BLOCKED_ATTEMPT_FINGERPRINT_CAP == 2 # shared default (was a hardcoded 3)
|
||||
|
||||
ctx = types.SimpleNamespace(repo_dir=tmp_path, drive_root=tmp_path, task_id="t-cap")
|
||||
repo_key = make_repo_key(pathlib.Path(tmp_path))
|
||||
def _legacy(status, block_reason, phase="blocking_review", scope_raw=None):
|
||||
return _types.SimpleNamespace(
|
||||
status=status, block_reason=block_reason, phase=phase,
|
||||
block_class="", scope_raw_result=scope_raw or {},
|
||||
)
|
||||
|
||||
def _add(status, fingerprint, block_reason="critical_findings",
|
||||
phase="blocking_review", task_id="t-cap"):
|
||||
def _mutate(state):
|
||||
state.attempts.append(CommitAttemptRecord(
|
||||
ts=_utc_now(), commit_message="msg", status=status,
|
||||
block_reason=block_reason if status == "blocked" else "",
|
||||
repo_key=repo_key, tool_name="commit_reviewed", task_id=task_id,
|
||||
attempt=1, phase=phase,
|
||||
pre_review_fingerprint=fingerprint,
|
||||
))
|
||||
update_state(pathlib.Path(tmp_path), _mutate)
|
||||
assert attempt_block_class(_legacy("blocked", "critical_findings")) == BLOCK_CLASS_VERDICT
|
||||
assert attempt_block_class(_legacy("blocked", "review_quorum")) == BLOCK_CLASS_INFRA
|
||||
assert attempt_block_class(_legacy("blocked", "fixed_overflow")) == BLOCK_CLASS_INFRA
|
||||
assert attempt_block_class(_legacy("blocked", "no_advisory", phase="advisory_gate")) == ""
|
||||
assert attempt_block_class(_legacy("blocked", "attempt_cap_reached", phase="preflight")) == ""
|
||||
# Legacy scope_blocked rows: verdict only when a RESPONDED actor row
|
||||
# carried critical findings; sub-floor/overflow scope blocks are infra.
|
||||
responded = {"raw_results": [{"status": "responded", "critical_findings": [{"item": "x"}]}]}
|
||||
sub_floor = {"raw_results": [{"status": "sub_floor", "critical_findings": []}]}
|
||||
assert attempt_block_class(_legacy("blocked", "scope_blocked", scope_raw=responded)) == BLOCK_CLASS_VERDICT
|
||||
assert attempt_block_class(_legacy("blocked", "scope_blocked", scope_raw=sub_floor)) == BLOCK_CLASS_INFRA
|
||||
|
||||
# CAP-1 genuine verdicts, then a tests-preflight block with the SAME
|
||||
# inherited fingerprint: must NOT count as the capping verdict.
|
||||
for _ in range(BLOCKED_ATTEMPT_FINGERPRINT_CAP - 1):
|
||||
_add("blocked", "fp-x")
|
||||
_add("blocked", "fp-x", block_reason="tests_preflight_blocked", phase="preflight")
|
||||
assert check_blocked_attempt_cap(ctx, "fp-x") == ""
|
||||
|
||||
# One more genuine verdict reaches the cap.
|
||||
_add("blocked", "fp-x")
|
||||
assert "REVIEW_ATTEMPT_CAP" in check_blocked_attempt_cap(ctx, "fp-x")
|
||||
|
||||
# A NEW-task tests-preflight block with an EMPTY fingerprint (no inherited
|
||||
# stage in that task yet) must not break the capped streak either.
|
||||
_add("blocked", "", block_reason="tests_preflight_blocked",
|
||||
phase="preflight", task_id="t-new")
|
||||
assert "REVIEW_ATTEMPT_CAP" in check_blocked_attempt_cap(ctx, "fp-x")
|
||||
# End-to-end on the ledger: a legacy critical_findings row (no block_class)
|
||||
# still refuses the identical resubmission.
|
||||
ctx = _identical_ctx(tmp_path)
|
||||
_add_attempt(tmp_path, "blocked", "fp-legacy", block_class="")
|
||||
assert "IDENTICAL_DIFF_REFUSED" in check_identical_verdict_refusal(
|
||||
ctx, "fp-legacy", contract_fingerprint=CONTRACT_FP)
|
||||
|
||||
|
||||
def test_non_committing_review_cycle_exists_and_reuses_shared_stage_cycle():
|
||||
|
|
@ -510,16 +588,18 @@ def test_review_status_registered():
|
|||
|
||||
|
||||
def test_advisory_gate_in_repo_commit_push():
|
||||
"""The shared reviewed stage must gate review on advisory freshness."""
|
||||
"""The shared reviewed stage must gate review on advisory freshness (the
|
||||
check lives in the extracted _advisory_and_tests_gate helper, called before
|
||||
any paid dispatch and after the free Max-Review-Cycles gate)."""
|
||||
git_mod = _get_git_module()
|
||||
source = inspect.getsource(git_mod._run_reviewed_stage_cycle)
|
||||
assert "_check_advisory_freshness" in source
|
||||
# Advisory gate must come before parallel review (which contains unified review)
|
||||
advisory_pos = source.find("_check_advisory_freshness")
|
||||
gate_pos = source.find("_advisory_and_tests_gate")
|
||||
review_pos = source.find("_run_parallel_review")
|
||||
assert advisory_pos != -1, "_check_advisory_freshness not found in _run_reviewed_stage_cycle"
|
||||
assert gate_pos != -1, "_advisory_and_tests_gate not found in _run_reviewed_stage_cycle"
|
||||
assert review_pos != -1, "_run_parallel_review not found in _run_reviewed_stage_cycle"
|
||||
assert advisory_pos < review_pos, "Advisory gate must precede parallel review"
|
||||
assert gate_pos < review_pos, "Advisory gate must precede parallel review"
|
||||
gate_source = inspect.getsource(git_mod._advisory_and_tests_gate)
|
||||
assert "_check_advisory_freshness" in gate_source
|
||||
# Verify _run_parallel_review contains _run_unified_review
|
||||
parallel_source = inspect.getsource(git_mod._run_parallel_review)
|
||||
assert "_run_unified_review" in parallel_source
|
||||
|
|
|
|||
|
|
@ -95,9 +95,14 @@ def test_getter_env_or_default_and_fail_closed_logged_once(monkeypatch, caplog):
|
|||
|
||||
|
||||
def test_settings_file_roundtrip_projects_unlimited_into_env(monkeypatch, tmp_path):
|
||||
# apply_settings_to_env writes os.environ directly; register the key with monkeypatch
|
||||
# first so the projected value is restored after the test (no cross-test leak).
|
||||
monkeypatch.delenv(KEY, raising=False)
|
||||
# apply_settings_to_env writes os.environ directly. SETENV-then-DELENV is
|
||||
# what registers the key's original (absent) state with monkeypatch:
|
||||
# pytest's delenv on an ABSENT key records NOTHING, so the previous
|
||||
# delenv-only registration silently LEAKED the projected "3" into sibling
|
||||
# tests on the same xdist worker (observed: improvement-pass caps reading
|
||||
# cycles=3 in test_review_verification_v6544).
|
||||
monkeypatch.setenv(KEY, "registered-for-restore")
|
||||
monkeypatch.delenv(KEY)
|
||||
settings_path = tmp_path / "settings.json"
|
||||
settings_path.write_text(json.dumps({KEY: "unlimited"}), encoding="utf-8")
|
||||
monkeypatch.setattr(cfg, "SETTINGS_PATH", settings_path)
|
||||
|
|
@ -183,10 +188,13 @@ def test_improvement_pass_gate_and_rails_follow_shared_cap(monkeypatch):
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Commit gate reads the live value
|
||||
# Commit gate reads the live value: the shared knob bounds PAID triad+scope
|
||||
# cycles per ROOT task (Q16/Q23); the identical-diff refusal is knob-independent
|
||||
# and covered in tests/test_commit_gate.py + tests/test_review_cycles_gates.py.
|
||||
|
||||
|
||||
def _seed_blocks(tmp_path, count, fingerprint="fp-same"):
|
||||
def _seed_paid_attempts(tmp_path, count, *, start=1, root="root-1", status="succeeded",
|
||||
block_reason="", block_class="", phase="commit", paid=True):
|
||||
from ouroboros.review_state import CommitAttemptRecord, make_repo_key, update_state, _utc_now
|
||||
|
||||
repo_key = make_repo_key(pathlib.Path(tmp_path))
|
||||
|
|
@ -194,55 +202,107 @@ def _seed_blocks(tmp_path, count, fingerprint="fp-same"):
|
|||
def _mutate(state):
|
||||
for i in range(count):
|
||||
state.attempts.append(CommitAttemptRecord(
|
||||
ts=_utc_now(), commit_message="msg", status="blocked",
|
||||
block_reason="critical_findings", repo_key=repo_key,
|
||||
tool_name="commit_reviewed", task_id="t-cap", attempt=i + 1,
|
||||
phase="blocking_review", pre_review_fingerprint=fingerprint,
|
||||
ts=_utc_now(), commit_message="msg", status=status,
|
||||
block_reason=block_reason, block_class=block_class,
|
||||
repo_key=repo_key, tool_name="commit_reviewed", task_id=root,
|
||||
attempt=start + i, phase=phase, paid=paid,
|
||||
pre_review_fingerprint=f"fp-{start + i}",
|
||||
root_task_id=root,
|
||||
))
|
||||
update_state(pathlib.Path(tmp_path), _mutate)
|
||||
|
||||
|
||||
def test_commit_gate_cap_reads_live_setting(monkeypatch, tmp_path):
|
||||
from ouroboros.tools.commit_gate import blocked_attempt_fingerprint_cap, check_blocked_attempt_cap
|
||||
def test_commit_gate_ceiling_reads_live_setting(monkeypatch, tmp_path):
|
||||
from ouroboros.tools.commit_gate import (
|
||||
check_review_cycles_ceiling,
|
||||
count_paid_review_cycles,
|
||||
)
|
||||
|
||||
ctx = types.SimpleNamespace(repo_dir=tmp_path, drive_root=tmp_path, task_id="t-cap")
|
||||
assert blocked_attempt_fingerprint_cap() == 2 # default moved 3 -> 2 (D20, disclosed)
|
||||
_seed_blocks(tmp_path, 1)
|
||||
assert check_blocked_attempt_cap(ctx, "fp-same") == ""
|
||||
_seed_blocks(tmp_path, 1) # streak 2 == cap: refused
|
||||
msg = check_blocked_attempt_cap(ctx, "fp-same")
|
||||
assert "REVIEW_ATTEMPT_CAP" in msg and "2 times in a row" in msg
|
||||
ctx = types.SimpleNamespace(repo_dir=tmp_path, drive_root=tmp_path, task_id="root-1")
|
||||
assert rc.review_max_cycles() == 2 # default moved 3 -> 2 (D20, disclosed)
|
||||
_seed_paid_attempts(tmp_path, 1)
|
||||
assert count_paid_review_cycles(ctx, root_task_id="root-1") == 1
|
||||
assert check_review_cycles_ceiling(ctx, root_task_id="root-1") is None
|
||||
_seed_paid_attempts(tmp_path, 1, start=2) # 2 paid == cap: refused
|
||||
exhausted = check_review_cycles_ceiling(ctx, root_task_id="root-1")
|
||||
assert exhausted is not None and "REVIEW_CYCLES_EXHAUSTED" in exhausted["message"]
|
||||
assert exhausted["cycles_paid"] == 2 and exhausted["cap"] == 2
|
||||
# Another root task has its own ceiling.
|
||||
assert check_review_cycles_ceiling(ctx, root_task_id="root-2") is None
|
||||
# An unknown root never gates (fail-open, disclosed).
|
||||
assert check_review_cycles_ceiling(ctx, root_task_id="") is None
|
||||
# Raise the shared cap: the SAME ledger is below it again.
|
||||
monkeypatch.setenv(KEY, "4")
|
||||
assert blocked_attempt_fingerprint_cap() == 4
|
||||
assert check_blocked_attempt_cap(ctx, "fp-same") == ""
|
||||
_seed_blocks(tmp_path, 2) # streak 4
|
||||
assert "REVIEW_ATTEMPT_CAP" in check_blocked_attempt_cap(ctx, "fp-same")
|
||||
# Unlimited: never cap, however long the identical-diff streak.
|
||||
assert check_review_cycles_ceiling(ctx, root_task_id="root-1") is None
|
||||
_seed_paid_attempts(tmp_path, 2, start=3)
|
||||
assert check_review_cycles_ceiling(ctx, root_task_id="root-1") is not None
|
||||
# Unlimited: never a ceiling, however many paid cycles.
|
||||
monkeypatch.setenv(KEY, "unlimited")
|
||||
assert blocked_attempt_fingerprint_cap() is None
|
||||
_seed_blocks(tmp_path, 10)
|
||||
assert check_blocked_attempt_cap(ctx, "fp-same") == ""
|
||||
_seed_paid_attempts(tmp_path, 10, start=5)
|
||||
assert check_review_cycles_ceiling(ctx, root_task_id="root-1") is None
|
||||
# Garbage fails closed to the bounded default (2), never to "no cap".
|
||||
monkeypatch.setenv(KEY, "lots")
|
||||
assert blocked_attempt_fingerprint_cap() == 2
|
||||
assert "REVIEW_ATTEMPT_CAP" in check_blocked_attempt_cap(ctx, "fp-same")
|
||||
assert check_review_cycles_ceiling(ctx, root_task_id="root-1") is not None
|
||||
|
||||
|
||||
def test_paid_cycle_count_counts_dispatched_money_only(tmp_path, monkeypatch):
|
||||
"""machine-5 contract: the ceiling counts MONEY. Every row that physically
|
||||
dispatched a wave (paid=True) counts whatever its terminal — verdict-block,
|
||||
pass, success, quorum-failed infra-block, crashed/expired failure alike.
|
||||
Only undispatched rows (paid=False: legacy history, free replays,
|
||||
preflight refusals) stay outside the count."""
|
||||
from ouroboros.tools.commit_gate import count_paid_review_cycles
|
||||
|
||||
monkeypatch.delenv(KEY, raising=False)
|
||||
ctx = types.SimpleNamespace(repo_dir=tmp_path, drive_root=tmp_path, task_id="root-1")
|
||||
_seed_paid_attempts(tmp_path, 1, status="blocked", block_reason="critical_findings",
|
||||
block_class="verdict", phase="blocking_review")
|
||||
_seed_paid_attempts(tmp_path, 1, start=2, status="reviewed", phase="review_only")
|
||||
_seed_paid_attempts(tmp_path, 1, start=3) # succeeded
|
||||
assert count_paid_review_cycles(ctx, root_task_id="root-1") == 3
|
||||
# Dispatched-then-degraded waves spent reviewer money: they count.
|
||||
_seed_paid_attempts(tmp_path, 1, start=4, status="blocked", block_reason="review_quorum",
|
||||
block_class="infra", phase="blocking_review")
|
||||
_seed_paid_attempts(tmp_path, 1, start=5, status="failed", block_reason="infra_failure",
|
||||
phase="expired")
|
||||
_seed_paid_attempts(tmp_path, 1, start=6, status="failed",
|
||||
block_reason="post_commit_tests_failed", phase="post_commit_tests")
|
||||
assert count_paid_review_cycles(ctx, root_task_id="root-1") == 6
|
||||
# Undispatched rows (legacy / free replay / refusals) never count.
|
||||
_seed_paid_attempts(tmp_path, 1, start=7, paid=False)
|
||||
_seed_paid_attempts(tmp_path, 1, start=8, status="blocked", paid=False,
|
||||
block_reason="identical_diff_refused", phase="preflight")
|
||||
assert count_paid_review_cycles(ctx, root_task_id="root-1") == 6
|
||||
|
||||
|
||||
def test_commit_gate_has_no_import_time_cap_constant():
|
||||
import ouroboros.tools.commit_gate as commit_gate
|
||||
|
||||
assert not hasattr(commit_gate, "BLOCKED_ATTEMPT_FINGERPRINT_CAP")
|
||||
# The pre-Q16 paid-identical-re-review API is retired: identical bytes are
|
||||
# refused free from the first verdict-block, not re-reviewed up to a cap.
|
||||
assert not hasattr(commit_gate, "check_blocked_attempt_cap")
|
||||
assert not hasattr(commit_gate, "blocked_attempt_fingerprint_cap")
|
||||
|
||||
|
||||
def test_advisory_review_schema_note_derives_from_shared_cap(monkeypatch):
|
||||
def test_advisory_review_schema_note_states_paid_cycle_semantics(monkeypatch):
|
||||
from ouroboros.tools.claude_advisory_review import _identical_diff_cap_note
|
||||
|
||||
assert "after 2 genuine review-verdict block(s)" in _identical_diff_cap_note()
|
||||
note = _identical_diff_cap_note()
|
||||
assert "identical bytes are never re-reviewed for pay" in note
|
||||
assert "identical_diff_refused" in note
|
||||
assert "after 2 paid cycle(s)" in note
|
||||
assert "per ROOT task" in note # wording-5: the tree shares one ceiling
|
||||
# F5 honesty caveat: "refused" is enforcement-dependent — advisory commits
|
||||
# proceed with a loud disclosure and no new spend.
|
||||
assert "under blocking enforcement the commit is refused for free" in note
|
||||
assert "under advisory the commit proceeds with a loud durable disclosure" in note
|
||||
monkeypatch.setenv(KEY, "5")
|
||||
assert "after 5 genuine" in _identical_diff_cap_note()
|
||||
assert "after 5 paid cycle(s)" in _identical_diff_cap_note()
|
||||
monkeypatch.setenv(KEY, "unlimited")
|
||||
assert "no identical-diff cap is configured" in _identical_diff_cap_note()
|
||||
note = _identical_diff_cap_note()
|
||||
assert "no per-root-task ceiling" in note
|
||||
assert "identical bytes are never re-reviewed for pay" in note # knob-independent
|
||||
source = (REPO / "ouroboros" / "tools" / "claude_advisory_review.py").read_text(encoding="utf-8")
|
||||
assert "after 3 genuine" not in source
|
||||
|
||||
|
|
|
|||
607
tests/test_review_cycles_dispatch.py
Normal file
607
tests/test_review_cycles_dispatch.py
Normal file
|
|
@ -0,0 +1,607 @@
|
|||
"""Max-Review-Cycles fix round: dispatch-time paid accounting and the
|
||||
authority-preserving attempt-ledger eviction.
|
||||
|
||||
Contract under test (accepted panel fixes F1-F3, F4a, fable P3-2/P3-3):
|
||||
* F1 — trimming the commit-attempt ledger never evicts paid rows or the
|
||||
verdict anchors of the identical-diff refusal streak: a capped root cannot
|
||||
loop free refusals until the ceiling and the quoted verdict forget
|
||||
themselves;
|
||||
* F2 — the commit gate's paid fact lands WRITE-AHEAD at the first PHYSICAL
|
||||
reviewer dispatch (either side): an attempt where both packs refuse at
|
||||
assembly spends $0 and consumes no ceiling cycle, while one dispatched side
|
||||
makes the cycle paid;
|
||||
* F3 — skill review shares ONE durable dispatch marker between lifecycle and
|
||||
direct callers: every dispatched wave (substantive verdict, quorum failure,
|
||||
transport failure, crash-after-dispatch) yields exactly one paid unit in
|
||||
the derived count, on a REAL history jsonl;
|
||||
* F4a — a rebuttal's "spent" memory is scoped to the CURRENT panel-contract
|
||||
fingerprint;
|
||||
* P3-3 — under advisory enforcement each free-replay reason drives the full
|
||||
stage cycle to a passing commit with its own honest progress note and a
|
||||
disclosure that lands in the commit result formatting.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import subprocess
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from tests._shared import ensure_claude_agent_sdk_mock
|
||||
|
||||
ensure_claude_agent_sdk_mock()
|
||||
|
||||
KEY = "OUROBOROS_REVIEW_MAX_CYCLES"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_env(monkeypatch):
|
||||
monkeypatch.delenv(KEY, raising=False)
|
||||
monkeypatch.delenv("OUROBOROS_REVIEW_ENFORCEMENT", raising=False)
|
||||
yield
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# F1 — authority-preserving attempt-ledger eviction
|
||||
|
||||
|
||||
def test_free_refusal_flood_cannot_evict_paid_rows_or_the_verdict_anchor(
|
||||
tmp_path, monkeypatch,
|
||||
):
|
||||
"""60+ free refusals after the cap is reached change NOTHING: the paid
|
||||
count stays, and the identical-diff refusal still quotes the original
|
||||
verdict — only the refusal noise is trimmed."""
|
||||
import ouroboros.tools.git as git_mod
|
||||
from ouroboros.review_state import (
|
||||
CommitAttemptRecord,
|
||||
load_state,
|
||||
make_repo_key,
|
||||
update_state,
|
||||
_utc_now,
|
||||
)
|
||||
from ouroboros.tools.commit_gate import count_paid_review_cycles
|
||||
|
||||
monkeypatch.setenv("OUROBOROS_REVIEW_ENFORCEMENT", "blocking")
|
||||
monkeypatch.setenv(KEY, "2")
|
||||
monkeypatch.setattr(git_mod, "commit_review_contract_fingerprint", lambda: "cf-1")
|
||||
monkeypatch.setattr(git_mod, "run_cmd", lambda *a, **k: "")
|
||||
monkeypatch.setattr(git_mod, "_authorized_managed_update_resolver", lambda ctx: False)
|
||||
(pathlib.Path(tmp_path) / "logs").mkdir(parents=True, exist_ok=True)
|
||||
ctx = types.SimpleNamespace(
|
||||
repo_dir=tmp_path, drive_root=pathlib.Path(tmp_path), task_id="root-1",
|
||||
task_metadata={}, event_queue=None,
|
||||
drive_logs=lambda: pathlib.Path(tmp_path) / "logs",
|
||||
_current_review_tool_name="commit_reviewed",
|
||||
)
|
||||
repo_key = make_repo_key(pathlib.Path(tmp_path))
|
||||
|
||||
def _seed(state):
|
||||
for attempt, (status, phase, paid) in enumerate(
|
||||
[("succeeded", "commit", True), ("blocked", "blocking_review", True)], start=1,
|
||||
):
|
||||
state.attempts.append(CommitAttemptRecord(
|
||||
ts=_utc_now(), commit_message="m", status=status, phase=phase,
|
||||
block_reason="critical_findings" if status == "blocked" else "",
|
||||
block_class="verdict" if status == "blocked" else "",
|
||||
critical_findings=(
|
||||
[{"item": "bug_original", "reason": "the anchor", "severity": "critical"}]
|
||||
if status == "blocked" else []
|
||||
),
|
||||
repo_key=repo_key, tool_name="commit_reviewed", task_id="root-1",
|
||||
attempt=attempt, paid=paid, root_task_id="root-1",
|
||||
pre_review_fingerprint="fp-1",
|
||||
review_contract_fingerprint="cf-1",
|
||||
))
|
||||
|
||||
update_state(pathlib.Path(tmp_path), _seed)
|
||||
assert count_paid_review_cycles(ctx, root_task_id="root-1") == 2
|
||||
|
||||
for _ in range(60):
|
||||
outcome = git_mod._free_cycle_gate(
|
||||
ctx, "msg", 0.0, pre_fingerprint={"fingerprint": "fp-1"}, review_rebuttal="",
|
||||
)
|
||||
assert outcome is not None and outcome["status"] == "blocked"
|
||||
assert outcome["block_reason"] == "identical_diff_refused"
|
||||
assert "bug_original" in outcome["message"] # the anchor is still quoted
|
||||
|
||||
assert count_paid_review_cycles(ctx, root_task_id="root-1") == 2
|
||||
state = load_state(pathlib.Path(tmp_path))
|
||||
rows = state.filter_attempts(repo_key=repo_key)
|
||||
# The authority rows survived the flood; the noise portion stayed capped.
|
||||
assert any(r.paid and r.status == "succeeded" for r in rows)
|
||||
assert any(r.block_class == "verdict" and r.pre_review_fingerprint == "fp-1" for r in rows)
|
||||
assert len([r for r in rows if not r.paid and r.block_class != "verdict"]) <= 50
|
||||
|
||||
|
||||
def test_ledger_growth_stays_bounded_while_accounting_authority_survives(tmp_path):
|
||||
"""F1 follow-up (strip-not-evict): flooding far past the 50-row window with
|
||||
HEAVY paid rows plus refusal noise must keep the serialized ledger bounded
|
||||
— over-window preserved rows lose their raw payloads (raw_stripped=True) —
|
||||
while every accounting fact and the refusal-quote behavior survive."""
|
||||
from ouroboros.review_state import (
|
||||
CommitAttemptRecord,
|
||||
load_state,
|
||||
make_repo_key,
|
||||
update_state,
|
||||
_utc_now,
|
||||
)
|
||||
from ouroboros.tools.commit_gate import (
|
||||
check_identical_verdict_refusal,
|
||||
count_paid_review_cycles,
|
||||
)
|
||||
|
||||
repo_key = make_repo_key(pathlib.Path(tmp_path))
|
||||
heavy_raw = "HEAVYRAWX" * 600 # ~5.4KB per payload field
|
||||
|
||||
def _flood(state):
|
||||
for i in range(1, 61):
|
||||
# A paid verdict-blocked wave with FULL forensic payloads …
|
||||
state.record_attempt(CommitAttemptRecord(
|
||||
ts=_utc_now(), commit_message="m" * 400, status="blocked",
|
||||
block_reason="critical_findings", block_class="verdict",
|
||||
block_details="details " + heavy_raw,
|
||||
repo_key=repo_key, tool_name="commit_reviewed", task_id="root-1",
|
||||
attempt=2 * i - 1, phase="blocking_review", paid=True,
|
||||
root_task_id="root-1", pre_review_fingerprint="fp-1",
|
||||
review_contract_fingerprint="cf-1",
|
||||
critical_findings=[{"item": "bug_anchor", "reason": "still broken",
|
||||
"severity": "critical"}],
|
||||
triad_raw_results=[{"model_id": "m1", "raw_text": heavy_raw}],
|
||||
scope_raw_result={"status": "responded", "raw_text": heavy_raw,
|
||||
"raw_results": [{"status": "responded",
|
||||
"critical_findings": [{"item": "x"}]}]},
|
||||
))
|
||||
# … followed by a free-refusal noise row.
|
||||
state.record_attempt(CommitAttemptRecord(
|
||||
ts=_utc_now(), commit_message="m", status="blocked",
|
||||
block_reason="identical_diff_refused", block_details="refused",
|
||||
repo_key=repo_key, tool_name="commit_reviewed", task_id="root-1",
|
||||
attempt=2 * i, phase="preflight", pre_review_fingerprint="fp-1",
|
||||
review_contract_fingerprint="cf-1",
|
||||
))
|
||||
|
||||
update_state(pathlib.Path(tmp_path), _flood)
|
||||
ctx = types.SimpleNamespace(
|
||||
repo_dir=tmp_path, drive_root=pathlib.Path(tmp_path), task_id="root-1",
|
||||
task_metadata={}, _current_review_tool_name="commit_reviewed",
|
||||
)
|
||||
# Every paid dispatch is still counted; the refusal still quotes the verdict.
|
||||
assert count_paid_review_cycles(ctx, root_task_id="root-1") == 60
|
||||
refusal = check_identical_verdict_refusal(ctx, "fp-1", contract_fingerprint="cf-1")
|
||||
assert "IDENTICAL_DIFF_REFUSED" in refusal and "bug_anchor" in refusal
|
||||
state = load_state(pathlib.Path(tmp_path))
|
||||
rows = state.filter_attempts(repo_key=repo_key)
|
||||
over_window, in_window = rows[:-50], rows[-50:]
|
||||
assert len(rows) > 50 and over_window # preservation forced past the cap
|
||||
for row in over_window:
|
||||
assert row.raw_stripped is True
|
||||
assert row.triad_raw_results == [] and row.scope_raw_result == {}
|
||||
assert len(row.block_details) <= 700 and len(row.commit_message) <= 400
|
||||
# The accounting facts are intact on every compacted row.
|
||||
assert row.paid is True and row.block_class == "verdict"
|
||||
assert row.root_task_id == "root-1"
|
||||
assert row.review_contract_fingerprint == "cf-1"
|
||||
assert row.critical_findings and row.critical_findings[0]["item"] == "bug_anchor"
|
||||
# The serialized ledger carries full raw payloads ONLY inside the window:
|
||||
# every heavy-sentinel occurrence is accounted for by in-window rows, and
|
||||
# each compacted over-window row serializes to a small bounded record —
|
||||
# the immortal portion grows ~O(preserved rows x small record), never by
|
||||
# full reviewer raw output per reviewed commit.
|
||||
import dataclasses
|
||||
|
||||
raw = (pathlib.Path(tmp_path) / "state" / "advisory_review.json").read_text(encoding="utf-8")
|
||||
heavy_in_window = sum(
|
||||
1 for row in in_window if row.triad_raw_results or row.scope_raw_result
|
||||
)
|
||||
assert heavy_in_window <= 50
|
||||
in_window_sentinels = sum(
|
||||
json.dumps(dataclasses.asdict(row)).count("HEAVYRAWX" * 600) for row in in_window
|
||||
)
|
||||
assert raw.count("HEAVYRAWX" * 600) == in_window_sentinels > 0
|
||||
for row in over_window:
|
||||
assert len(json.dumps(dataclasses.asdict(row))) < 4_000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# F2 / P3-3 — the write-ahead paid stamp on the real stage cycle
|
||||
|
||||
|
||||
def _stage_cycle_harness(tmp_path, monkeypatch, *, fingerprint):
|
||||
"""A REAL git repo with a stageable change plus the heavy collaborators
|
||||
(advisory gate, binding, fingerprint) pinned, so _run_reviewed_stage_cycle
|
||||
runs its true order: free gate -> advisory gate -> dispatch."""
|
||||
import ouroboros.tools.git as git_mod
|
||||
|
||||
repo = pathlib.Path(tmp_path) / "repo"
|
||||
repo.mkdir()
|
||||
for cmd in (
|
||||
["git", "init"],
|
||||
["git", "config", "user.email", "t@t"],
|
||||
["git", "config", "user.name", "t"],
|
||||
):
|
||||
subprocess.run(cmd, cwd=repo, check=True, capture_output=True)
|
||||
(repo / "f.txt").write_text("base\n", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "-A"], cwd=repo, check=True, capture_output=True)
|
||||
subprocess.run(["git", "commit", "-m", "base"], cwd=repo, check=True, capture_output=True)
|
||||
(repo / "f.txt").write_text("changed\n", encoding="utf-8")
|
||||
|
||||
drive = pathlib.Path(tmp_path) / "drive"
|
||||
(drive / "logs").mkdir(parents=True, exist_ok=True)
|
||||
progress: list = []
|
||||
ctx = types.SimpleNamespace(
|
||||
repo_dir=repo, drive_root=drive, task_id="root-1", task_metadata={},
|
||||
event_queue=None, branch_dev="dev",
|
||||
drive_logs=lambda: drive / "logs",
|
||||
emit_progress_fn=progress.append,
|
||||
_current_review_tool_name="commit_reviewed",
|
||||
_review_advisory=[],
|
||||
_last_triad_models=[], _last_scope_model="",
|
||||
_last_triad_raw_results=[], _last_scope_raw_result={},
|
||||
_review_degraded_reasons=[],
|
||||
)
|
||||
monkeypatch.setattr(git_mod, "commit_review_contract_fingerprint", lambda: "cf-1")
|
||||
monkeypatch.setattr(
|
||||
git_mod, "_fingerprint_staged_diff",
|
||||
lambda repo_dir: {"ok": True, "fingerprint": fingerprint},
|
||||
)
|
||||
monkeypatch.setattr(git_mod, "_advisory_and_tests_gate", lambda *a, **k: None)
|
||||
monkeypatch.setattr(git_mod, "_review_binding_precondition_error", lambda *a, **k: "")
|
||||
return git_mod, ctx, progress
|
||||
|
||||
|
||||
def _overflow_wave(dispatch):
|
||||
"""A wave whose BOTH sides land infra-blocked; ``dispatch`` controls
|
||||
whether the (real) transport seam was reached before the refusal."""
|
||||
from ouroboros.review_dispatch import stamp_review_paid_on_dispatch
|
||||
from ouroboros.tools.scope_review import ScopeReviewResult
|
||||
|
||||
def _wave(ctx, commit_message, **kwargs):
|
||||
if dispatch:
|
||||
stamp_review_paid_on_dispatch(ctx) # exactly what run_review_request does
|
||||
ctx._last_review_block_reason = "fixed_overflow"
|
||||
ctx._last_review_critical_findings = []
|
||||
scope = ScopeReviewResult(
|
||||
blocked=True,
|
||||
block_message="⚠️ SCOPE_REVIEW_BLOCKED: pack did not assemble.",
|
||||
status="fixed_overflow",
|
||||
)
|
||||
return "⚠️ REVIEW_BLOCKED: prompt cannot fit.", scope, "fixed_overflow", []
|
||||
|
||||
return _wave
|
||||
|
||||
|
||||
def test_all_assembly_refused_attempt_stays_unpaid(tmp_path, monkeypatch):
|
||||
"""F2(a): BOTH packs refusing at assembly ($0 spent) must not consume a
|
||||
ceiling cycle — with the default cap this used to exhaust a root for free."""
|
||||
from ouroboros.tools.commit_gate import count_paid_review_cycles
|
||||
|
||||
git_mod, ctx, _progress = _stage_cycle_harness(tmp_path, monkeypatch, fingerprint="fp-a")
|
||||
monkeypatch.setattr(git_mod, "_run_parallel_review", _overflow_wave(dispatch=False))
|
||||
outcome = git_mod._run_reviewed_stage_cycle(ctx, "msg", 0.0)
|
||||
assert outcome["status"] == "blocked" and outcome["block_reason"] == "fixed_overflow"
|
||||
assert count_paid_review_cycles(ctx, root_task_id="root-1") == 0
|
||||
from ouroboros.review_state import load_state, make_repo_key
|
||||
|
||||
rows = load_state(ctx.drive_root).filter_attempts(
|
||||
repo_key=make_repo_key(pathlib.Path(ctx.repo_dir)))
|
||||
assert rows and rows[-1].paid is False and rows[-1].block_class == "infra"
|
||||
|
||||
|
||||
def test_one_side_dispatched_attempt_counts_as_paid(tmp_path, monkeypatch):
|
||||
"""F2(b): parallel dispatch means one side can spend while the other
|
||||
overflows at assembly — any side dispatching makes the cycle paid."""
|
||||
from ouroboros.tools.commit_gate import count_paid_review_cycles
|
||||
|
||||
git_mod, ctx, _progress = _stage_cycle_harness(tmp_path, monkeypatch, fingerprint="fp-b")
|
||||
monkeypatch.setattr(git_mod, "_run_parallel_review", _overflow_wave(dispatch=True))
|
||||
outcome = git_mod._run_reviewed_stage_cycle(ctx, "msg", 0.0)
|
||||
assert outcome["status"] == "blocked"
|
||||
assert count_paid_review_cycles(ctx, root_task_id="root-1") == 1
|
||||
assert ctx._review_paid_stamp is None # the seam never leaks past the wave
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"seed,expected_reason,expected_note_part",
|
||||
[
|
||||
("verdict", "identical_diff_refused", "reusing the recorded"),
|
||||
("ceiling", "review_cycles_exhausted", "no review outcome"),
|
||||
],
|
||||
)
|
||||
def test_advisory_replay_reasons_drive_the_stage_cycle_to_a_disclosed_pass(
|
||||
tmp_path, monkeypatch, seed, expected_reason, expected_note_part,
|
||||
):
|
||||
"""fable P3-3 (end to end): under advisory each free-replay reason lets the
|
||||
REAL stage cycle pass without any dispatch, with its own honest progress
|
||||
note, and the disclosure lands in the commit result formatting."""
|
||||
from ouroboros.review_state import CommitAttemptRecord, make_repo_key, update_state, _utc_now
|
||||
|
||||
monkeypatch.setenv("OUROBOROS_REVIEW_ENFORCEMENT", "advisory")
|
||||
git_mod, ctx, progress = _stage_cycle_harness(tmp_path, monkeypatch, fingerprint="fp-r")
|
||||
monkeypatch.setattr(
|
||||
git_mod, "_run_parallel_review",
|
||||
lambda *a, **k: (_ for _ in ()).throw(AssertionError("free replay must not dispatch")),
|
||||
)
|
||||
repo_key = make_repo_key(pathlib.Path(ctx.repo_dir))
|
||||
if seed == "verdict":
|
||||
update_state(ctx.drive_root, lambda s: s.attempts.append(CommitAttemptRecord(
|
||||
ts=_utc_now(), commit_message="m", status="blocked",
|
||||
block_reason="critical_findings", block_class="verdict",
|
||||
repo_key=repo_key, tool_name="commit_reviewed", task_id="root-1",
|
||||
attempt=1, phase="blocking_review", pre_review_fingerprint="fp-r",
|
||||
review_contract_fingerprint="cf-1",
|
||||
critical_findings=[{"item": "bug_q", "reason": "r", "severity": "critical"}],
|
||||
)))
|
||||
else:
|
||||
monkeypatch.setenv(KEY, "1")
|
||||
update_state(ctx.drive_root, lambda s: s.attempts.append(CommitAttemptRecord(
|
||||
ts=_utc_now(), commit_message="m", status="succeeded", repo_key=repo_key,
|
||||
tool_name="commit_reviewed", task_id="root-1", attempt=1, phase="commit",
|
||||
paid=True, root_task_id="root-1", pre_review_fingerprint="fp-old",
|
||||
)))
|
||||
|
||||
outcome = git_mod._run_reviewed_stage_cycle(ctx, "msg", 0.0)
|
||||
assert outcome["status"] == "passed"
|
||||
notes = [n for n in progress if "Max Review Cycles" in n]
|
||||
assert notes and expected_note_part in notes[0]
|
||||
# The loud disclosure reached the advisory channel AND the commit result.
|
||||
assert any(expected_reason in w for w in ctx._review_advisory)
|
||||
result = git_mod._format_commit_result(ctx, "msg", "", "")
|
||||
assert "no new triad+scope review was bought" in result
|
||||
assert expected_reason in result
|
||||
events = [json.loads(line) for line in
|
||||
(ctx.drive_root / "logs" / "events.jsonl").read_text(encoding="utf-8").splitlines()]
|
||||
replays = [e for e in events if e["type"] == "commit_review_free_replay"]
|
||||
assert replays and replays[-1]["reason"] == expected_reason
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# F3 — the skill dispatch marker: four wave outcomes, one paid unit each
|
||||
|
||||
|
||||
def _fake_manifest():
|
||||
return types.SimpleNamespace(
|
||||
name="demo", description="d", version="1", type="tool", runtime="python",
|
||||
timeout_sec=30, permissions=[], conflicts=[], env_from_settings=[],
|
||||
requires=[], scripts=[], scheduled_tasks=[], entry="main.py",
|
||||
is_extension=lambda: False,
|
||||
)
|
||||
|
||||
|
||||
def _wire_skill_wave(monkeypatch, tmp_path, *, content_hash, passes):
|
||||
import ouroboros.skill_review_passes as passes_mod
|
||||
from ouroboros import config as cfg
|
||||
from ouroboros import skill_review
|
||||
|
||||
drive = pathlib.Path(tmp_path)
|
||||
skill = types.SimpleNamespace(
|
||||
name="demo", manifest=_fake_manifest(), skill_dir=drive / "skill",
|
||||
load_error="", review=None, source="",
|
||||
)
|
||||
binding = types.SimpleNamespace(state_drive_root=drive)
|
||||
monkeypatch.setattr(skill_review, "build_resolved_resource_binding", lambda *a, **k: binding)
|
||||
monkeypatch.setattr(skill_review, "load_bound_skill", lambda b: skill)
|
||||
monkeypatch.setattr(skill_review, "compute_content_hash", lambda *a, **k: content_hash)
|
||||
monkeypatch.setattr(skill_review, "_run_deterministic_preflight", lambda *a, **k: None)
|
||||
monkeypatch.setattr(skill_review, "reviewer_slot_config_error", lambda: "")
|
||||
monkeypatch.setattr(skill_review, "_build_skill_file_packs", lambda *a, **k: ["pack"])
|
||||
monkeypatch.setattr(skill_review, "_official_hub_review_profile", lambda s: "")
|
||||
monkeypatch.setattr(skill_review, "_review_wave_budget_block", lambda *a, **k: None)
|
||||
monkeypatch.setattr(skill_review, "emit_review_model_error_events", lambda *a, **k: None)
|
||||
monkeypatch.setattr(skill_review, "save_review_state", lambda *a, **k: None)
|
||||
monkeypatch.setattr(
|
||||
skill_review, "auto_grant_if_enabled",
|
||||
lambda *a, **k: types.SimpleNamespace(
|
||||
requested_keys=[], granted_keys=[],
|
||||
requested_permissions=[], granted_permissions=[]),
|
||||
)
|
||||
monkeypatch.setattr(cfg, "get_review_models", lambda: ["m1", "m2"])
|
||||
monkeypatch.setattr(passes_mod, "run_skill_review_passes", passes)
|
||||
return skill
|
||||
|
||||
|
||||
def _panel_result(models, *, verdict="PASS"):
|
||||
"""A minimal parseable two-model panel body for the REAL parser: each actor
|
||||
returns a JSON array answering every required checklist item."""
|
||||
from ouroboros.skill_review import _SKILL_REVIEW_ITEMS
|
||||
|
||||
items = [
|
||||
{"item": item, "verdict": verdict, "severity": "advisory", "reason": "checked"}
|
||||
for item in _SKILL_REVIEW_ITEMS
|
||||
]
|
||||
return json.dumps({
|
||||
"model_count": len(models),
|
||||
"results": [
|
||||
{"model": model, "text": json.dumps(items), "slot_id": f"slot_{idx + 1}"}
|
||||
for idx, model in enumerate(models)
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
def _dispatching(passes_body):
|
||||
from ouroboros.review_dispatch import stamp_review_paid_on_dispatch
|
||||
|
||||
def _wave(ctx, *a, **k):
|
||||
stamp_review_paid_on_dispatch(ctx)
|
||||
return passes_body(ctx)
|
||||
|
||||
return _wave
|
||||
|
||||
|
||||
def _paid_units(tmp_path, content_hash):
|
||||
from ouroboros.skill_review_cycles import count_paid_skill_review_cycles
|
||||
|
||||
return count_paid_skill_review_cycles(
|
||||
pathlib.Path(tmp_path), "demo", "manual:demo", content_hash=content_hash,
|
||||
)
|
||||
|
||||
|
||||
def _history_rows(tmp_path):
|
||||
from ouroboros.skill_review_history import review_history_path
|
||||
|
||||
path = review_history_path(pathlib.Path(tmp_path), "demo")
|
||||
if not path.is_file():
|
||||
return []
|
||||
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]
|
||||
|
||||
|
||||
def test_skill_wave_outcomes_yield_exactly_one_paid_unit_each(tmp_path, monkeypatch):
|
||||
"""F3 on a REAL jsonl (persist=True): substantive verdict, quorum failure,
|
||||
transport failure, and exception-after-dispatch each leave exactly ONE
|
||||
paid unit in the derived count — via the terminal row when one lands, via
|
||||
the unmerged write-ahead dispatch marker when none does."""
|
||||
from ouroboros import skill_review
|
||||
from ouroboros.skill_review_history import load_dispatch_marker
|
||||
|
||||
ctx = types.SimpleNamespace(task_id="", task_metadata={}, event_queue=None)
|
||||
|
||||
# (1) substantive verdict: terminal row carries the paid facts, marker merged.
|
||||
_wire_skill_wave(
|
||||
monkeypatch, tmp_path, content_hash="h-verdict",
|
||||
passes=_dispatching(lambda c: ("prompt", {}, _panel_result(["m1", "m2"]), "")),
|
||||
)
|
||||
outcome = skill_review.review_skill(ctx, "demo", persist=True)
|
||||
assert outcome.status in ("clean", "warnings", "blockers")
|
||||
assert outcome.paid is True and outcome.wave_id
|
||||
assert _paid_units(tmp_path, "h-verdict") == 1
|
||||
assert load_dispatch_marker(pathlib.Path(tmp_path), "demo") == {} # merged+cleared
|
||||
row = _history_rows(tmp_path)[-1]
|
||||
assert row["paid"] is True and row["wave_id"] == outcome.wave_id
|
||||
assert row["review_contract_fingerprint"] == outcome.review_contract_fingerprint
|
||||
|
||||
# (2) quorum failure: the internal history append carries the paid facts.
|
||||
_wire_skill_wave(
|
||||
monkeypatch, tmp_path, content_hash="h-quorum",
|
||||
passes=_dispatching(lambda c: ("prompt", {}, _panel_result(["m1"]), "")),
|
||||
)
|
||||
outcome = skill_review.review_skill(ctx, "demo", persist=True)
|
||||
assert outcome.status == "pending" and outcome.paid is True
|
||||
assert _paid_units(tmp_path, "h-quorum") == 1
|
||||
assert load_dispatch_marker(pathlib.Path(tmp_path), "demo") == {}
|
||||
row = _history_rows(tmp_path)[-1]
|
||||
assert row["paid"] is True and row["content_hash"] == "h-quorum"
|
||||
|
||||
# (3) transport failure: no history row lands, the unmerged marker counts.
|
||||
_wire_skill_wave(
|
||||
monkeypatch, tmp_path, content_hash="h-transport",
|
||||
passes=_dispatching(lambda c: ("prompt", {}, "", "provider exploded")),
|
||||
)
|
||||
outcome = skill_review.review_skill(ctx, "demo", persist=True)
|
||||
assert outcome.status == "pending" and outcome.paid is True
|
||||
assert _paid_units(tmp_path, "h-transport") == 1
|
||||
marker = load_dispatch_marker(pathlib.Path(tmp_path), "demo")
|
||||
assert marker.get("content_hash") == "h-transport" and marker.get("paid") is True
|
||||
|
||||
# (4) exception after dispatch: the write-ahead marker survives the crash.
|
||||
def _boom(ctx_arg):
|
||||
raise RuntimeError("wave crashed after dispatch")
|
||||
|
||||
_wire_skill_wave(
|
||||
monkeypatch, tmp_path, content_hash="h-crash", passes=_dispatching(_boom),
|
||||
)
|
||||
with pytest.raises(RuntimeError):
|
||||
skill_review.review_skill(ctx, "demo", persist=True)
|
||||
assert _paid_units(tmp_path, "h-crash") == 1
|
||||
# Writing marker (4) flushed the orphaned marker (3) into the history as an
|
||||
# infra terminal — the spend of wave (3) is still exactly one unit.
|
||||
assert _paid_units(tmp_path, "h-transport") == 1
|
||||
flushed = [r for r in _history_rows(tmp_path)
|
||||
if r.get("terminal_reason") == "dispatched_wave_never_finalized"]
|
||||
assert flushed and flushed[-1]["content_hash"] == "h-transport"
|
||||
# The seam is restored after every wave.
|
||||
assert getattr(ctx, "_review_paid_stamp", None) is None
|
||||
|
||||
|
||||
def test_lifecycle_timeout_terminal_merges_the_dispatch_marker(tmp_path, monkeypatch):
|
||||
"""F3(b): a lifecycle timeout finalizes with NO result object — the
|
||||
terminal history row still carries the paid facts, merged from the
|
||||
write-ahead dispatch marker by job id."""
|
||||
from ouroboros.skill_review_history import load_dispatch_marker, write_dispatch_marker
|
||||
from ouroboros.skill_review_runner import _mark_review_job_timeout, review_job_state_path
|
||||
from ouroboros.utils import atomic_write_json
|
||||
|
||||
drive = pathlib.Path(tmp_path)
|
||||
(drive / "logs").mkdir(parents=True, exist_ok=True)
|
||||
job_path = review_job_state_path(drive, "demo")
|
||||
job_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(job_path, {
|
||||
"job_id": "job-77", "status": "running", "skill": "demo",
|
||||
"content_hash": "h-t", "group_id": "task:root-5:demo", "root_task_id": "root-5",
|
||||
}, trailing_newline=True)
|
||||
write_dispatch_marker(
|
||||
drive, "demo", wave_id="job-77", group_id="task:root-5:demo",
|
||||
content_hash="h-t", root_task_id="root-5",
|
||||
review_contract_fingerprint="cf-9", rebuttal_sha256="reb-9",
|
||||
)
|
||||
_mark_review_job_timeout(drive, "demo", "h-t", reason="lifecycle_timeout")
|
||||
rows = _history_rows(tmp_path)
|
||||
assert rows and rows[-1]["status"] == "timeout"
|
||||
assert rows[-1]["paid"] is True # merged from the marker, result was None
|
||||
assert rows[-1]["review_contract_fingerprint"] == "cf-9"
|
||||
assert rows[-1]["rebuttal_sha256"] == "reb-9"
|
||||
assert load_dispatch_marker(drive, "demo") == {} # merge cleared it
|
||||
from ouroboros.skill_review_cycles import count_paid_skill_review_cycles
|
||||
|
||||
assert count_paid_skill_review_cycles(drive, "demo", "task:root-5:demo") == 1
|
||||
|
||||
|
||||
def test_terminal_history_append_failure_is_a_loud_typed_event(tmp_path, monkeypatch):
|
||||
"""F3(c): a swallowed terminal-history append surfaces as a typed event."""
|
||||
import ouroboros.skill_review_runner as runner
|
||||
from ouroboros import skill_review_history
|
||||
|
||||
drive = pathlib.Path(tmp_path)
|
||||
(drive / "logs").mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setattr(skill_review_history, "append_history_once", lambda *a, **k: False)
|
||||
ok = runner._append_terminal_history(
|
||||
drive, "demo", {"job_id": "j-1"}, status="failed",
|
||||
terminal_reason="boom", result=None, ts="t1",
|
||||
)
|
||||
assert ok is False
|
||||
events = [json.loads(line) for line in
|
||||
(drive / "logs" / "events.jsonl").read_text(encoding="utf-8").splitlines()]
|
||||
failed = [e for e in events if e["type"] == "skill_review_history_append_failed"]
|
||||
assert failed and failed[0]["skill"] == "demo" and failed[0]["job_id"] == "j-1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# F4a — spent-rebuttal memory is scoped to the CURRENT panel contract
|
||||
|
||||
|
||||
def test_spent_rebuttal_memory_lapses_with_the_panel_contract(tmp_path):
|
||||
from ouroboros.skill_review_history import review_history_path
|
||||
from ouroboros.skill_review_cycles import find_free_replay_row
|
||||
|
||||
path = review_history_path(pathlib.Path(tmp_path), "demo")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
rows = [
|
||||
# reb-1 was answered by a substantive verdict — under the RETIRED contract.
|
||||
{"ts": "t1", "status": "warnings", "content_hash": "h1", "paid": True,
|
||||
"group_id": "manual:demo", "review_contract_fingerprint": "cf-old",
|
||||
"rebuttal_sha256": "reb-1", "job_id": "j1"},
|
||||
# The current contract has its own substantive verdict for the snapshot.
|
||||
{"ts": "t2", "status": "warnings", "content_hash": "h1", "paid": True,
|
||||
"group_id": "manual:demo", "review_contract_fingerprint": "cf-new",
|
||||
"job_id": "j2"},
|
||||
]
|
||||
with open(path, "a", encoding="utf-8") as fh:
|
||||
for row in rows:
|
||||
fh.write(json.dumps(row) + "\n")
|
||||
# Under the current contract reb-1 has never been adjudicated: it buys the
|
||||
# paid rerun (no replay) instead of being refused as already spent.
|
||||
assert find_free_replay_row(
|
||||
pathlib.Path(tmp_path), "demo", group_id="manual:demo", content_hash="h1",
|
||||
contract_fingerprint="cf-new", rebuttal_sha256="reb-1",
|
||||
) is None
|
||||
# Once the CURRENT contract has answered it, the same hash replays free.
|
||||
with open(path, "a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(
|
||||
{"ts": "t3", "status": "warnings", "content_hash": "h1", "paid": True,
|
||||
"group_id": "manual:demo", "review_contract_fingerprint": "cf-new",
|
||||
"rebuttal_sha256": "reb-1", "job_id": "j3"}) + "\n")
|
||||
assert find_free_replay_row(
|
||||
pathlib.Path(tmp_path), "demo", group_id="manual:demo", content_hash="h1",
|
||||
contract_fingerprint="cf-new", rebuttal_sha256="reb-1",
|
||||
) is not None
|
||||
743
tests/test_review_cycles_gates.py
Normal file
743
tests/test_review_cycles_gates.py
Normal file
|
|
@ -0,0 +1,743 @@
|
|||
"""Max-Review-Cycles semantics on the commit gate and skill review (Δ5/Δ6;
|
||||
owner decisions Q12/Q16/Q17/Q22/Q23).
|
||||
|
||||
Contract under test:
|
||||
* blocked commit attempts are TYPED (verdict vs infra) at record time;
|
||||
* the paid fact is recorded at dispatch on the attempt ledger and survives the
|
||||
terminal merge; per-root-task counts are derived from it (P7);
|
||||
* the free-cycle gate runs BEFORE advisory freshness: blocking → free typed
|
||||
refusal (managed resolvers get MERGE_HEAD repaired), advisory → free replay
|
||||
marker (typed event, commit proceeds, no paid dispatch);
|
||||
* skill review: panel-contract fingerprint, per-key paid-cycle counting,
|
||||
$0 replay of an identical substantive verdict, typed exhaustion refusal,
|
||||
and the Max-Review-Cycles facts on terminal history rows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from tests._shared import ensure_claude_agent_sdk_mock
|
||||
|
||||
ensure_claude_agent_sdk_mock()
|
||||
|
||||
REPO = pathlib.Path(__file__).resolve().parents[1]
|
||||
KEY = "OUROBOROS_REVIEW_MAX_CYCLES"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_env(monkeypatch):
|
||||
monkeypatch.delenv(KEY, raising=False)
|
||||
monkeypatch.delenv("OUROBOROS_REVIEW_ENFORCEMENT", raising=False)
|
||||
yield
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Block-row classification at record time (Δ5.1)
|
||||
|
||||
|
||||
def test_classify_review_block_verdict_vs_infra():
|
||||
from ouroboros.tools.commit_gate import classify_review_block
|
||||
|
||||
# Triad critical findings = verdict.
|
||||
assert classify_review_block(
|
||||
triad_blocked=True, triad_block_reason="critical_findings",
|
||||
scope_blocked=False,
|
||||
) == "verdict"
|
||||
# Triad fit/quorum/transport = infra.
|
||||
for reason in ("fixed_overflow", "review_quorum", "infra_failure", "parse_failure"):
|
||||
assert classify_review_block(
|
||||
triad_blocked=True, triad_block_reason=reason, scope_blocked=False,
|
||||
) == "infra"
|
||||
# Scope: a RESPONDED actor row that blocked on critical findings = verdict …
|
||||
responded = {"raw_results": [
|
||||
{"status": "responded", "critical_findings": [{"item": "x"}]},
|
||||
]}
|
||||
assert classify_review_block(
|
||||
triad_blocked=False, triad_block_reason="", scope_blocked=True,
|
||||
scope_raw_result=responded,
|
||||
) == "verdict"
|
||||
# … while sub-floor / overflow / error scope blocks are infra.
|
||||
for status in ("sub_floor", "fixed_overflow", "error", "empty_response", "parse_failure"):
|
||||
infra_rows = {"raw_results": [{"status": status, "critical_findings": []}]}
|
||||
assert classify_review_block(
|
||||
triad_blocked=False, triad_block_reason="", scope_blocked=True,
|
||||
scope_raw_result=infra_rows,
|
||||
) == "infra"
|
||||
# A verdict on EITHER side wins even when the other side infra-blocked.
|
||||
assert classify_review_block(
|
||||
triad_blocked=True, triad_block_reason="review_quorum",
|
||||
scope_blocked=True, scope_raw_result=responded,
|
||||
) == "verdict"
|
||||
|
||||
|
||||
def test_finalize_blocked_review_records_block_class(tmp_path, monkeypatch):
|
||||
"""git.py's blocked finalizer stamps the typed class on the durable row."""
|
||||
import ouroboros.tools.git as git_mod
|
||||
from ouroboros.review_state import load_state, make_repo_key
|
||||
|
||||
monkeypatch.setattr(git_mod, "run_cmd", lambda *a, **k: "")
|
||||
ctx = types.SimpleNamespace(
|
||||
repo_dir=tmp_path, drive_root=tmp_path, task_id="t1", task_metadata={},
|
||||
_current_review_tool_name="commit_reviewed",
|
||||
_last_triad_models=[], _last_scope_model="",
|
||||
_last_triad_raw_results=[], _last_scope_raw_result={},
|
||||
_review_degraded_reasons=[],
|
||||
)
|
||||
git_mod._finalize_blocked_review(
|
||||
ctx, "msg", 0.0,
|
||||
combined_msg="blocked", block_reason="critical_findings",
|
||||
combined_findings=[{"item": "x", "reason": "r", "severity": "critical"}],
|
||||
pre_fingerprint={"fingerprint": "fp"}, post_fingerprint={"fingerprint": "fp"},
|
||||
block_class="verdict",
|
||||
)
|
||||
state = load_state(pathlib.Path(tmp_path))
|
||||
rows = state.filter_attempts(repo_key=make_repo_key(pathlib.Path(tmp_path)))
|
||||
assert rows and rows[-1].block_class == "verdict"
|
||||
assert rows[-1].root_task_id == "t1" # ctx.task_id is its own root
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paid-at-dispatch on the ledger (Δ5 paid + merge semantics, Q23 root scoping)
|
||||
|
||||
|
||||
def test_paid_fact_survives_terminal_merge_and_roundtrip(tmp_path):
|
||||
from ouroboros.review_state import (
|
||||
CommitAttemptRecord,
|
||||
load_state,
|
||||
make_repo_key,
|
||||
update_state,
|
||||
_utc_now,
|
||||
)
|
||||
|
||||
repo_key = make_repo_key(pathlib.Path(tmp_path))
|
||||
|
||||
def _record(state, **kw):
|
||||
base = dict(
|
||||
ts=_utc_now(), commit_message="m", repo_key=repo_key,
|
||||
tool_name="commit_reviewed", task_id="t1", attempt=1,
|
||||
)
|
||||
base.update(kw)
|
||||
state.record_attempt(CommitAttemptRecord(**base))
|
||||
|
||||
# Dispatch-time row: paid recorded on the in-flight marker.
|
||||
update_state(pathlib.Path(tmp_path), lambda s: _record(
|
||||
s, status="reviewing", phase="review", paid=True,
|
||||
rebuttal_sha256="r" * 64, review_contract_fingerprint="c" * 64,
|
||||
root_task_id="root-1", pre_review_fingerprint="fp-1",
|
||||
))
|
||||
# Terminal update on the SAME attempt does not pass paid — the merge must
|
||||
# keep it (a terminal row must never launder the spend).
|
||||
update_state(pathlib.Path(tmp_path), lambda s: _record(
|
||||
s, status="blocked", phase="blocking_review",
|
||||
block_reason="critical_findings", block_class="verdict",
|
||||
pre_review_fingerprint="fp-1",
|
||||
))
|
||||
state = load_state(pathlib.Path(tmp_path))
|
||||
row = state.filter_attempts(repo_key=repo_key)[-1]
|
||||
assert row.status == "blocked" and row.paid is True
|
||||
assert row.block_class == "verdict"
|
||||
assert row.rebuttal_sha256 == "r" * 64
|
||||
assert row.review_contract_fingerprint == "c" * 64
|
||||
assert row.root_task_id == "root-1"
|
||||
# And all of it survives the JSON roundtrip (save happened in update_state).
|
||||
reloaded = load_state(pathlib.Path(tmp_path)).filter_attempts(repo_key=repo_key)[-1]
|
||||
assert (reloaded.paid, reloaded.block_class, reloaded.root_task_id) == (True, "verdict", "root-1")
|
||||
|
||||
|
||||
def test_record_commit_attempt_reviewing_lands_paid_on_the_ledger(tmp_path):
|
||||
"""Behavior test for the dispatch seam (wording-1): driving the REAL
|
||||
recorder with the reviewing-row kwargs must land paid/rebuttal/contract/
|
||||
root facts on the durable ledger, and the ceiling counter must see them."""
|
||||
from ouroboros.review_state import load_state, make_repo_key
|
||||
from ouroboros.tools.commit_gate import (
|
||||
_record_commit_attempt,
|
||||
count_paid_review_cycles,
|
||||
)
|
||||
|
||||
ctx = types.SimpleNamespace(
|
||||
repo_dir=tmp_path, drive_root=tmp_path, task_id="t-1",
|
||||
task_metadata={"root_task_id": "root-9"},
|
||||
_current_review_tool_name="commit_reviewed",
|
||||
)
|
||||
_record_commit_attempt(
|
||||
ctx, "msg", "reviewing",
|
||||
duration_sec=0.0, phase="review",
|
||||
pre_review_fingerprint="fp-1", fingerprint_status="pending",
|
||||
paid=True, rebuttal_sha256="r" * 64, review_contract_fingerprint="c" * 64,
|
||||
)
|
||||
row = load_state(pathlib.Path(tmp_path)).filter_attempts(
|
||||
repo_key=make_repo_key(pathlib.Path(tmp_path)))[-1]
|
||||
assert row.status == "reviewing" and row.paid is True
|
||||
assert row.rebuttal_sha256 == "r" * 64
|
||||
assert row.review_contract_fingerprint == "c" * 64
|
||||
assert row.root_task_id == "root-9"
|
||||
assert count_paid_review_cycles(ctx, root_task_id="root-9") == 1
|
||||
|
||||
|
||||
def test_stage_cycle_free_gate_runs_before_advisory_gate():
|
||||
"""Order pin: the free Max-Review-Cycles gate precedes the advisory/tests
|
||||
gate, which precedes the paid dispatch."""
|
||||
import inspect
|
||||
|
||||
import ouroboros.tools.git as git_mod
|
||||
|
||||
source = inspect.getsource(git_mod._run_reviewed_stage_cycle)
|
||||
assert source.index("_free_cycle_gate(") < source.index("_advisory_and_tests_gate(")
|
||||
assert source.index("_advisory_and_tests_gate(") < source.index("_run_parallel_review(")
|
||||
|
||||
|
||||
def test_resolve_root_task_id_ignores_the_followup_chain():
|
||||
"""machine-3 decision: a follow-up task is a FRESH root for the ceiling —
|
||||
origin_root_task_id (the follow-up chain marker) is deliberately not
|
||||
honored; the identical-fingerprint refusal stays the anti-laundering
|
||||
backstop."""
|
||||
from ouroboros.tools.commit_gate import resolve_root_task_id
|
||||
|
||||
followup = types.SimpleNamespace(
|
||||
task_metadata={"origin_root_task_id": "old-root", "origin_task_id": "old-task"},
|
||||
task_id="f-1",
|
||||
)
|
||||
assert resolve_root_task_id(followup) == "f-1"
|
||||
subtask = types.SimpleNamespace(
|
||||
task_metadata={"root_task_id": "tree-root", "origin_root_task_id": "old-root"},
|
||||
task_id="child-1",
|
||||
)
|
||||
assert resolve_root_task_id(subtask) == "tree-root"
|
||||
|
||||
|
||||
def test_repair_managed_merge_head_writes_merge_head(tmp_path, monkeypatch):
|
||||
"""Behavior test (wording-1): the refusal path's repair helper re-writes
|
||||
the REAL .git/MERGE_HEAD via reestablish_merge_head with the tx target."""
|
||||
import subprocess
|
||||
|
||||
import ouroboros.tools.git as git_mod
|
||||
import supervisor.git_ops as git_ops
|
||||
import supervisor.update_merge as update_merge
|
||||
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True)
|
||||
monkeypatch.setattr(git_ops, "REPO_DIR", repo)
|
||||
target = "a" * 40
|
||||
monkeypatch.setattr(
|
||||
update_merge, "managed_assisted_tx_for",
|
||||
lambda task_id, metadata: ({"target_sha": target}, ""),
|
||||
)
|
||||
ctx = types.SimpleNamespace(task_id="t-1", task_metadata={})
|
||||
git_mod._repair_managed_merge_head(ctx)
|
||||
assert (repo / ".git" / "MERGE_HEAD").read_text(encoding="utf-8") == target + "\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The free-cycle gate (Δ5.2/Δ5.4): blocking refusals, advisory replay
|
||||
|
||||
|
||||
def _gate_ctx(tmp_path, task_id="root-1"):
|
||||
(pathlib.Path(tmp_path) / "logs").mkdir(parents=True, exist_ok=True)
|
||||
return types.SimpleNamespace(
|
||||
repo_dir=tmp_path, drive_root=pathlib.Path(tmp_path), task_id=task_id,
|
||||
task_metadata={}, event_queue=None,
|
||||
drive_logs=lambda: pathlib.Path(tmp_path) / "logs",
|
||||
_current_review_tool_name="commit_reviewed",
|
||||
)
|
||||
|
||||
|
||||
def _seed_verdict_block(tmp_path, fingerprint, contract_fp, **kw):
|
||||
from ouroboros.review_state import CommitAttemptRecord, make_repo_key, update_state, _utc_now
|
||||
|
||||
repo_key = make_repo_key(pathlib.Path(tmp_path))
|
||||
row = dict(
|
||||
ts=_utc_now(), commit_message="m", status="blocked",
|
||||
block_reason="critical_findings", block_class="verdict",
|
||||
repo_key=repo_key, tool_name="commit_reviewed", task_id="root-1",
|
||||
attempt=1, phase="blocking_review", pre_review_fingerprint=fingerprint,
|
||||
review_contract_fingerprint=contract_fp,
|
||||
critical_findings=[{"item": "bug_y", "reason": "broken", "severity": "critical"}],
|
||||
)
|
||||
row.update(kw)
|
||||
update_state(pathlib.Path(tmp_path), lambda s: s.attempts.append(CommitAttemptRecord(**row)))
|
||||
|
||||
|
||||
def test_free_cycle_gate_blocking_identical_refusal_repairs_managed_merge_head(
|
||||
tmp_path, monkeypatch,
|
||||
):
|
||||
import ouroboros.tools.git as git_mod
|
||||
|
||||
monkeypatch.setenv("OUROBOROS_REVIEW_ENFORCEMENT", "blocking")
|
||||
monkeypatch.setattr(git_mod, "commit_review_contract_fingerprint", lambda: "cf-1")
|
||||
monkeypatch.setattr(git_mod, "run_cmd", lambda *a, **k: "")
|
||||
repaired = []
|
||||
monkeypatch.setattr(git_mod, "_authorized_managed_update_resolver", lambda ctx: True)
|
||||
monkeypatch.setattr(git_mod, "_repair_managed_merge_head", lambda ctx: repaired.append(True))
|
||||
|
||||
ctx = _gate_ctx(tmp_path)
|
||||
_seed_verdict_block(tmp_path, "fp-1", "cf-1")
|
||||
outcome = git_mod._free_cycle_gate(
|
||||
ctx, "msg", 0.0, pre_fingerprint={"fingerprint": "fp-1"}, review_rebuttal="",
|
||||
)
|
||||
assert outcome is not None and outcome["status"] == "blocked"
|
||||
assert outcome["block_reason"] == "identical_diff_refused"
|
||||
assert "IDENTICAL_DIFF_REFUSED" in outcome["message"] and "bug_y" in outcome["message"]
|
||||
assert repaired == [True] # MERGE_HEAD kept repaired on the free refusal path
|
||||
# The refusal itself is durably recorded and never resets the streak.
|
||||
from ouroboros.review_state import load_state, make_repo_key
|
||||
|
||||
rows = load_state(pathlib.Path(tmp_path)).filter_attempts(
|
||||
repo_key=make_repo_key(pathlib.Path(tmp_path)))
|
||||
assert rows[-1].block_reason == "identical_diff_refused"
|
||||
again = git_mod._free_cycle_gate(
|
||||
ctx, "msg", 0.0, pre_fingerprint={"fingerprint": "fp-1"}, review_rebuttal="",
|
||||
)
|
||||
assert again is not None and again["block_reason"] == "identical_diff_refused"
|
||||
# A fresh diff passes the gate.
|
||||
assert git_mod._free_cycle_gate(
|
||||
ctx, "msg", 0.0, pre_fingerprint={"fingerprint": "fp-2"}, review_rebuttal="",
|
||||
) is None
|
||||
|
||||
|
||||
def test_free_cycle_gate_blocking_ceiling_emits_typed_event(tmp_path, monkeypatch):
|
||||
import ouroboros.tools.git as git_mod
|
||||
from ouroboros.review_state import CommitAttemptRecord, make_repo_key, update_state, _utc_now
|
||||
|
||||
monkeypatch.setenv("OUROBOROS_REVIEW_ENFORCEMENT", "blocking")
|
||||
monkeypatch.setenv(KEY, "1")
|
||||
monkeypatch.setattr(git_mod, "commit_review_contract_fingerprint", lambda: "cf-1")
|
||||
monkeypatch.setattr(git_mod, "run_cmd", lambda *a, **k: "")
|
||||
monkeypatch.setattr(git_mod, "_authorized_managed_update_resolver", lambda ctx: False)
|
||||
|
||||
ctx = _gate_ctx(tmp_path)
|
||||
repo_key = make_repo_key(pathlib.Path(tmp_path))
|
||||
update_state(pathlib.Path(tmp_path), lambda s: s.attempts.append(CommitAttemptRecord(
|
||||
ts=_utc_now(), commit_message="m", status="succeeded", repo_key=repo_key,
|
||||
tool_name="commit_reviewed", task_id="root-1", attempt=1, phase="commit",
|
||||
paid=True, root_task_id="root-1", pre_review_fingerprint="fp-old",
|
||||
)))
|
||||
outcome = git_mod._free_cycle_gate(
|
||||
ctx, "msg", 0.0, pre_fingerprint={"fingerprint": "fp-new"}, review_rebuttal="",
|
||||
)
|
||||
assert outcome is not None and outcome["block_reason"] == "review_cycles_exhausted"
|
||||
assert "REVIEW_CYCLES_EXHAUSTED" in outcome["message"]
|
||||
events = [json.loads(line) for line in
|
||||
(pathlib.Path(tmp_path) / "logs" / "events.jsonl").read_text(encoding="utf-8").splitlines()]
|
||||
exhausted = [e for e in events if e["type"] == "review_cycles_exhausted"]
|
||||
assert exhausted and exhausted[0]["surface"] == "commit_gate"
|
||||
assert exhausted[0]["cycles_paid"] == 1 and exhausted[0]["cap"] == 1
|
||||
assert exhausted[0]["enforcement"] == "blocking"
|
||||
|
||||
|
||||
def test_free_cycle_gate_advisory_returns_replay_marker_not_a_block(tmp_path, monkeypatch):
|
||||
"""A.4/A.2 advisory contract: exhaustion/identical-refusal do NOT hard-block
|
||||
— the gate returns a replay marker (typed free-replay event), never a
|
||||
blocked outcome, and dispatches nothing."""
|
||||
import ouroboros.tools.git as git_mod
|
||||
|
||||
monkeypatch.setenv("OUROBOROS_REVIEW_ENFORCEMENT", "advisory")
|
||||
monkeypatch.setattr(git_mod, "commit_review_contract_fingerprint", lambda: "cf-1")
|
||||
monkeypatch.setattr(
|
||||
git_mod, "run_cmd",
|
||||
lambda *a, **k: (_ for _ in ()).throw(AssertionError("no git calls on the advisory replay path")),
|
||||
)
|
||||
ctx = _gate_ctx(tmp_path)
|
||||
_seed_verdict_block(tmp_path, "fp-1", "cf-1")
|
||||
outcome = git_mod._free_cycle_gate(
|
||||
ctx, "msg", 0.0, pre_fingerprint={"fingerprint": "fp-1"}, review_rebuttal="",
|
||||
)
|
||||
assert outcome is not None and "status" not in outcome
|
||||
assert outcome["replay_reason"] == "identical_diff_refused"
|
||||
assert "IDENTICAL_DIFF_REFUSED" in outcome["advisory_replay"]
|
||||
events = [json.loads(line) for line in
|
||||
(pathlib.Path(tmp_path) / "logs" / "events.jsonl").read_text(encoding="utf-8").splitlines()]
|
||||
replays = [e for e in events if e["type"] == "commit_review_free_replay"]
|
||||
assert replays and replays[0]["reason"] == "identical_diff_refused"
|
||||
assert replays[0]["enforcement"] == "advisory"
|
||||
|
||||
|
||||
def test_free_cycle_gate_infra_streak_never_gates_advisory_or_blocking(tmp_path, monkeypatch):
|
||||
"""Infra-blocks retry freely: they neither refuse under blocking nor
|
||||
trigger the advisory replay."""
|
||||
import ouroboros.tools.git as git_mod
|
||||
|
||||
monkeypatch.setattr(git_mod, "commit_review_contract_fingerprint", lambda: "cf-1")
|
||||
monkeypatch.setattr(git_mod, "run_cmd", lambda *a, **k: "")
|
||||
ctx = _gate_ctx(tmp_path)
|
||||
_seed_verdict_block(
|
||||
tmp_path, "fp-1", "cf-1",
|
||||
block_reason="review_quorum", block_class="infra", critical_findings=[],
|
||||
)
|
||||
for enforcement in ("blocking", "advisory"):
|
||||
monkeypatch.setenv("OUROBOROS_REVIEW_ENFORCEMENT", enforcement)
|
||||
assert git_mod._free_cycle_gate(
|
||||
ctx, "msg", 0.0, pre_fingerprint={"fingerprint": "fp-1"}, review_rebuttal="",
|
||||
) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skill review (Δ6, Q17/Q23)
|
||||
|
||||
|
||||
def test_skill_review_contract_fingerprint_tracks_roster_items_and_profile():
|
||||
from ouroboros.skill_review_cycles import skill_review_contract_fingerprint
|
||||
|
||||
base = skill_review_contract_fingerprint(["m1", "m2"], required_items=("a", "b"))
|
||||
assert base and len(base) == 64
|
||||
assert base == skill_review_contract_fingerprint(["m1", "m2"], required_items=("a", "b"))
|
||||
assert base != skill_review_contract_fingerprint(["m1"], required_items=("a", "b"))
|
||||
assert base != skill_review_contract_fingerprint(["m1", "m2"], required_items=("a",))
|
||||
# skill-4: the resolved review profile is contract identity — official_hub
|
||||
# aggregates blockers differently, so a profile change lapses replay.
|
||||
assert base != skill_review_contract_fingerprint(
|
||||
["m1", "m2"], required_items=("a", "b"), review_profile="official_hub")
|
||||
|
||||
|
||||
def _write_history(drive_root, skill, rows):
|
||||
from ouroboros.skill_review_history import review_history_path
|
||||
|
||||
path = review_history_path(pathlib.Path(drive_root), skill)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "a", encoding="utf-8") as fh:
|
||||
for row in rows:
|
||||
fh.write(json.dumps(row) + "\n")
|
||||
|
||||
|
||||
def test_count_paid_skill_review_cycles_task_and_manual_keys(tmp_path):
|
||||
"""Q23 + machine-5 + machine-1: a task-driven group counts every PAID
|
||||
(dispatched) cycle across EVERY skill the root task reviewed — including
|
||||
dispatched-then-degraded waves; the manual lane is scoped per
|
||||
content_hash (a revised snapshot restarts its count); unpaid/legacy rows
|
||||
never count."""
|
||||
from ouroboros.skill_review_cycles import count_paid_skill_review_cycles
|
||||
|
||||
_write_history(tmp_path, "skill_a", [
|
||||
{"ts": "t1", "status": "blockers", "content_hash": "h1", "paid": True,
|
||||
"group_id": "task:root-9:skill_a", "root_task_id": "root-9", "job_id": "j1"},
|
||||
{"ts": "t2", "status": "interrupted", "content_hash": "h1", "paid": True,
|
||||
"group_id": "task:root-9:skill_a", "root_task_id": "root-9", "job_id": "j2"},
|
||||
{"ts": "t3", "status": "clean", "content_hash": "h2", # legacy: no paid fact
|
||||
"group_id": "task:root-9:skill_a", "root_task_id": "root-9", "job_id": "j3"},
|
||||
])
|
||||
_write_history(tmp_path, "skill_b", [
|
||||
{"ts": "t4", "status": "warnings", "content_hash": "h3", "paid": True,
|
||||
"group_id": "task:root-9:skill_b", "root_task_id": "root-9", "job_id": "j4"},
|
||||
{"ts": "t5", "status": "clean", "content_hash": "h4", "paid": True,
|
||||
"group_id": "manual:skill_b", "job_id": "j5"},
|
||||
{"ts": "t6", "status": "pending", "content_hash": "h4", "paid": True, # quorum-failed wave: money
|
||||
"group_id": "manual:skill_b", "job_id": "j6"},
|
||||
])
|
||||
# Task key = money across the tree: blockers(a) + interrupted-dispatched(a)
|
||||
# + warnings(b) = 3; legacy (no paid) and the manual lane excluded.
|
||||
assert count_paid_skill_review_cycles(pathlib.Path(tmp_path), "skill_a", "task:root-9:skill_a") == 3
|
||||
assert count_paid_skill_review_cycles(pathlib.Path(tmp_path), "skill_b", "task:root-9:skill_b") == 3
|
||||
# Manual key: per content snapshot — h4 spent 2 (clean + quorum-failed wave),
|
||||
# a revised snapshot h5 starts fresh at 0.
|
||||
assert count_paid_skill_review_cycles(
|
||||
pathlib.Path(tmp_path), "skill_b", "manual:skill_b", content_hash="h4") == 2
|
||||
assert count_paid_skill_review_cycles(
|
||||
pathlib.Path(tmp_path), "skill_b", "manual:skill_b", content_hash="h5") == 0
|
||||
|
||||
|
||||
def test_skill_free_replay_row_rules(tmp_path):
|
||||
"""Q17-A: replay needs the identical (group, content-hash, contract) triple
|
||||
with a SUBSTANTIVE verdict; infra terminals are skipped; a legacy row
|
||||
without a fingerprint never replays; a NEW rebuttal buys a paid rerun and
|
||||
a repeated one replays free."""
|
||||
from ouroboros.skill_review_cycles import find_free_replay_row
|
||||
|
||||
group = "manual:demo"
|
||||
_write_history(tmp_path, "demo", [
|
||||
{"ts": "t1", "status": "warnings", "content_hash": "h1", "paid": True,
|
||||
"group_id": group, "review_contract_fingerprint": "cf-1",
|
||||
"rebuttal_sha256": "reb-1", "job_id": "j1",
|
||||
"fail_findings": [{"item": "bug_z", "severity": "warning", "reason_excerpt": "meh"}]},
|
||||
{"ts": "t2", "status": "timeout", "content_hash": "h1", "paid": True,
|
||||
"group_id": group, "review_contract_fingerprint": "cf-1", "job_id": "j2"},
|
||||
])
|
||||
row = find_free_replay_row(
|
||||
pathlib.Path(tmp_path), "demo", group_id=group, content_hash="h1",
|
||||
contract_fingerprint="cf-1",
|
||||
)
|
||||
assert row is not None and row["status"] == "warnings" # timeout skipped, verdict quoted
|
||||
# Different content hash / contract / group: paid run due.
|
||||
assert find_free_replay_row(pathlib.Path(tmp_path), "demo", group_id=group,
|
||||
content_hash="h2", contract_fingerprint="cf-1") is None
|
||||
assert find_free_replay_row(pathlib.Path(tmp_path), "demo", group_id=group,
|
||||
content_hash="h1", contract_fingerprint="cf-2") is None
|
||||
assert find_free_replay_row(pathlib.Path(tmp_path), "demo", group_id="manual:other",
|
||||
content_hash="h1", contract_fingerprint="cf-1") is None
|
||||
# Rebuttal by content: new hash = paid rerun; the spent hash replays free.
|
||||
assert find_free_replay_row(pathlib.Path(tmp_path), "demo", group_id=group,
|
||||
content_hash="h1", contract_fingerprint="cf-1",
|
||||
rebuttal_sha256="reb-NEW") is None
|
||||
assert find_free_replay_row(pathlib.Path(tmp_path), "demo", group_id=group,
|
||||
content_hash="h1", contract_fingerprint="cf-1",
|
||||
rebuttal_sha256="reb-1") is not None
|
||||
# machine-6/skill-3: a rebuttal recorded only on an INFRA terminal (its
|
||||
# paid rerun quorum-failed/timed out) was never answered by a substantive
|
||||
# verdict — it stays fresh and buys the paid rerun it is owed.
|
||||
_write_history(tmp_path, "demo", [
|
||||
{"ts": "t3", "status": "timeout", "content_hash": "h1", "paid": True,
|
||||
"group_id": group, "review_contract_fingerprint": "cf-1",
|
||||
"rebuttal_sha256": "reb-infra", "job_id": "j3"},
|
||||
])
|
||||
assert find_free_replay_row(pathlib.Path(tmp_path), "demo", group_id=group,
|
||||
content_hash="h1", contract_fingerprint="cf-1",
|
||||
rebuttal_sha256="reb-infra") is None
|
||||
# A legacy substantive row WITHOUT a fingerprint never replays.
|
||||
_write_history(tmp_path, "legacy", [
|
||||
{"ts": "t1", "status": "clean", "content_hash": "h1", "paid": True,
|
||||
"group_id": "manual:legacy", "job_id": "j1"},
|
||||
])
|
||||
assert find_free_replay_row(pathlib.Path(tmp_path), "legacy", group_id="manual:legacy",
|
||||
content_hash="h1", contract_fingerprint="cf-1") is None
|
||||
|
||||
|
||||
def test_skill_free_replay_outcome_quotes_verdict_and_is_unpaid(tmp_path):
|
||||
from ouroboros.skill_review_cycles import free_replay_outcome
|
||||
|
||||
group = "manual:demo"
|
||||
_write_history(tmp_path, "demo", [
|
||||
{"ts": "2026-08-20T00:00:00Z", "status": "blockers", "content_hash": "h1",
|
||||
"paid": True, "group_id": group, "review_contract_fingerprint": "cf-1",
|
||||
"job_id": "j1",
|
||||
"fail_findings": [{"item": "no_repo_mutation", "severity": "critical",
|
||||
"reason_excerpt": "writes into repo/"}]},
|
||||
])
|
||||
review_state = types.SimpleNamespace(
|
||||
content_hash="h1", status="blockers",
|
||||
findings=[{"item": "no_repo_mutation", "verdict": "FAIL", "severity": "critical",
|
||||
"reason": "writes into repo/"}],
|
||||
reviewer_models=["m1", "m2"],
|
||||
)
|
||||
skill = types.SimpleNamespace(name="demo", review=review_state)
|
||||
outcome = free_replay_outcome(
|
||||
skill, drive_root=pathlib.Path(tmp_path), group_id=group, content_hash="h1",
|
||||
contract_fingerprint="cf-1",
|
||||
)
|
||||
assert outcome is not None
|
||||
assert outcome.status == "blockers" and outcome.paid is False
|
||||
assert outcome.replayed_from_ts == "2026-08-20T00:00:00Z"
|
||||
assert outcome.findings == review_state.findings # full findings reused from state
|
||||
assert "FREE REPLAY" in outcome.convergence_hint
|
||||
assert "no_repo_mutation" in outcome.convergence_hint
|
||||
# No recorded verdict → no replay.
|
||||
assert free_replay_outcome(
|
||||
skill, drive_root=pathlib.Path(tmp_path), group_id=group, content_hash="h2",
|
||||
contract_fingerprint="cf-1",
|
||||
) is None
|
||||
# skill-2: when the mutable persisted state (review.json) has DIVERGED from
|
||||
# the history row, the replay falls through to a PAID rerun that
|
||||
# re-persists — never a $0 replay whose effects the grant/enable gates
|
||||
# cannot see.
|
||||
diverged = types.SimpleNamespace(
|
||||
name="demo",
|
||||
review=types.SimpleNamespace(
|
||||
content_hash="OTHER", status="clean", findings=[], reviewer_models=[]),
|
||||
)
|
||||
assert free_replay_outcome(
|
||||
diverged, drive_root=pathlib.Path(tmp_path), group_id=group, content_hash="h1",
|
||||
contract_fingerprint="cf-1",
|
||||
) is None
|
||||
stateless = types.SimpleNamespace(name="demo", review=None)
|
||||
assert free_replay_outcome(
|
||||
stateless, drive_root=pathlib.Path(tmp_path), group_id=group, content_hash="h1",
|
||||
contract_fingerprint="cf-1",
|
||||
) is None
|
||||
|
||||
|
||||
def test_skill_review_cycles_refusal_emits_typed_event(tmp_path, monkeypatch):
|
||||
from ouroboros.skill_review_cycles import skill_review_cycles_refusal
|
||||
|
||||
monkeypatch.setenv(KEY, "1")
|
||||
group = "task:root-7:demo"
|
||||
_write_history(tmp_path, "demo", [
|
||||
{"ts": "t1", "status": "clean", "content_hash": "h1", "paid": True,
|
||||
"group_id": group, "root_task_id": "root-7", "job_id": "j1"},
|
||||
])
|
||||
ctx = types.SimpleNamespace(task_id="t-77", event_queue=None)
|
||||
outcome = skill_review_cycles_refusal(
|
||||
ctx, "demo", drive_root=pathlib.Path(tmp_path), group_id=group,
|
||||
models=["m1"], content_hash="h2", contract_fingerprint="cf-1",
|
||||
)
|
||||
assert outcome is not None and outcome.status == "pending"
|
||||
assert "REVIEW_CYCLES_EXHAUSTED" in outcome.error
|
||||
assert outcome.paid is False
|
||||
events = [json.loads(line) for line in
|
||||
(pathlib.Path(tmp_path) / "logs" / "events.jsonl").read_text(encoding="utf-8").splitlines()]
|
||||
exhausted = [e for e in events if e["type"] == "review_cycles_exhausted"]
|
||||
assert exhausted and exhausted[0]["surface"] == "skill_review"
|
||||
assert exhausted[0]["root_task_id"] == "root-7" and exhausted[0]["cap"] == 1
|
||||
# Under the cap (another key) nothing is refused.
|
||||
assert skill_review_cycles_refusal(
|
||||
ctx, "demo", drive_root=pathlib.Path(tmp_path), group_id="manual:demo",
|
||||
models=["m1"], content_hash="h2", contract_fingerprint="cf-1",
|
||||
) is None
|
||||
# Unlimited: never a ceiling.
|
||||
monkeypatch.setenv(KEY, "unlimited")
|
||||
assert skill_review_cycles_refusal(
|
||||
ctx, "demo", drive_root=pathlib.Path(tmp_path), group_id=group,
|
||||
models=["m1"], content_hash="h2", contract_fingerprint="cf-1",
|
||||
) is None
|
||||
|
||||
|
||||
def test_terminal_history_payload_carries_cycle_facts():
|
||||
from ouroboros.skill_review_runner import _terminal_history_payload
|
||||
|
||||
result = types.SimpleNamespace(
|
||||
findings=[], content_hash="h1", raw_actor_records=[],
|
||||
single_reviewer_no_diversity=False,
|
||||
paid=True, review_contract_fingerprint="cf-1",
|
||||
rebuttal_sha256="reb-1", replayed_from_ts="",
|
||||
)
|
||||
payload = _terminal_history_payload(
|
||||
{"job_id": "j1", "group_id": "manual:demo"},
|
||||
status="clean", terminal_reason="clean", result=result, ts="t1",
|
||||
)
|
||||
assert payload["paid"] is True
|
||||
assert payload["review_contract_fingerprint"] == "cf-1"
|
||||
assert payload["rebuttal_sha256"] == "reb-1"
|
||||
assert "replayed_from_ts" not in payload # empty facts are not written
|
||||
# A replayed outcome discloses its provenance and stays unpaid.
|
||||
replayed = types.SimpleNamespace(
|
||||
findings=[], content_hash="h1", raw_actor_records=[],
|
||||
single_reviewer_no_diversity=False,
|
||||
paid=False, review_contract_fingerprint="cf-1",
|
||||
rebuttal_sha256="", replayed_from_ts="t0",
|
||||
)
|
||||
payload = _terminal_history_payload(
|
||||
{"job_id": "j2", "group_id": "manual:demo"},
|
||||
status="clean", terminal_reason="clean", result=replayed, ts="t2",
|
||||
)
|
||||
assert "paid" not in payload and payload["replayed_from_ts"] == "t0"
|
||||
|
||||
|
||||
def _fake_manifest():
|
||||
return types.SimpleNamespace(
|
||||
name="demo", description="d", version="1", type="tool", runtime="python",
|
||||
timeout_sec=30, permissions=[], conflicts=[], env_from_settings=[],
|
||||
requires=[], scripts=[], scheduled_tasks=[], entry="main.py",
|
||||
)
|
||||
|
||||
|
||||
def _wire_review_skill(monkeypatch, tmp_path, *, content_hash, review_state, passes):
|
||||
"""Wire review_skill's heavy collaborators to a temp drive so the REAL
|
||||
pipeline (cycles gate → budget → panel seam) executes end to end."""
|
||||
import ouroboros.skill_review_passes as passes_mod
|
||||
from ouroboros import config as cfg
|
||||
from ouroboros import skill_review
|
||||
|
||||
drive = pathlib.Path(tmp_path)
|
||||
skill = types.SimpleNamespace(
|
||||
name="demo", manifest=_fake_manifest(), skill_dir=drive / "skill",
|
||||
load_error="", review=review_state, source="",
|
||||
)
|
||||
binding = types.SimpleNamespace(state_drive_root=drive)
|
||||
monkeypatch.setattr(skill_review, "build_resolved_resource_binding",
|
||||
lambda *a, **k: binding)
|
||||
monkeypatch.setattr(skill_review, "load_bound_skill", lambda b: skill)
|
||||
monkeypatch.setattr(skill_review, "compute_content_hash", lambda *a, **k: content_hash)
|
||||
monkeypatch.setattr(skill_review, "_run_deterministic_preflight",
|
||||
lambda *a, **k: None)
|
||||
monkeypatch.setattr(skill_review, "reviewer_slot_config_error", lambda: "")
|
||||
monkeypatch.setattr(skill_review, "_build_skill_file_packs", lambda *a, **k: ["pack"])
|
||||
monkeypatch.setattr(skill_review, "_official_hub_review_profile", lambda s: "")
|
||||
monkeypatch.setattr(skill_review, "_review_wave_budget_block",
|
||||
lambda *a, **k: None)
|
||||
monkeypatch.setattr(cfg, "get_review_models", lambda: ["m1", "m2"])
|
||||
monkeypatch.setattr(passes_mod, "run_skill_review_passes", passes)
|
||||
return skill
|
||||
|
||||
|
||||
def _live_skill_contract_fp():
|
||||
from ouroboros import skill_review
|
||||
from ouroboros.skill_review_cycles import skill_review_contract_fingerprint
|
||||
|
||||
return skill_review_contract_fingerprint(
|
||||
["m1", "m2"], required_items=skill_review._SKILL_REVIEW_ITEMS, review_profile="",
|
||||
)
|
||||
|
||||
|
||||
def test_review_skill_replays_refuses_and_pays_functionally(tmp_path, monkeypatch):
|
||||
"""Behavior test (wording-1): the REAL review_skill (a) free-replays an
|
||||
identical snapshot without touching the panel seam, (b) refuses at the
|
||||
exhausted ceiling without dispatching, (c) stamps the paid facts on a
|
||||
dispatched outcome."""
|
||||
from ouroboros import skill_review
|
||||
|
||||
def _must_not_dispatch(*a, **k):
|
||||
raise AssertionError("run_skill_review_passes must not be called")
|
||||
|
||||
contract_fp = _live_skill_contract_fp()
|
||||
state = types.SimpleNamespace(
|
||||
content_hash="h1", status="warnings",
|
||||
findings=[{"item": "bug_hunting", "verdict": "FAIL", "severity": "advisory",
|
||||
"reason": "meh"}],
|
||||
reviewer_models=["m1", "m2"],
|
||||
)
|
||||
# (a) $0 replay: seeded substantive row under the LIVE contract + matching state.
|
||||
_write_history(tmp_path, "demo", [
|
||||
{"ts": "t1", "status": "warnings", "content_hash": "h1", "paid": True,
|
||||
"group_id": "manual:demo", "review_contract_fingerprint": contract_fp,
|
||||
"job_id": "j1"},
|
||||
])
|
||||
_wire_review_skill(monkeypatch, tmp_path, content_hash="h1",
|
||||
review_state=state, passes=_must_not_dispatch)
|
||||
ctx = types.SimpleNamespace(task_id="", task_metadata={}, event_queue=None)
|
||||
outcome = skill_review.review_skill(ctx, "demo", persist=False)
|
||||
assert outcome.status == "warnings" and outcome.paid is False
|
||||
assert outcome.replayed_from_ts == "t1"
|
||||
# (b) ceiling: cap=1 with one paid row already spent on this snapshot,
|
||||
# and the persisted state diverged so no $0 replay can rescue it —
|
||||
# the typed refusal fires before any dispatch.
|
||||
monkeypatch.setenv(KEY, "1")
|
||||
_wire_review_skill(monkeypatch, tmp_path, content_hash="h1",
|
||||
review_state=types.SimpleNamespace(
|
||||
content_hash="OTHER", status="clean", findings=[],
|
||||
reviewer_models=[]),
|
||||
passes=_must_not_dispatch)
|
||||
refused = skill_review.review_skill(ctx, "demo", persist=False)
|
||||
assert refused.status == "pending"
|
||||
assert "REVIEW_CYCLES_EXHAUSTED" in refused.error and refused.paid is False
|
||||
# (c) paid dispatch: fresh snapshot, the wave DISPATCHES (the fake invokes
|
||||
# the write-ahead stamp exactly like the shared transport entry does) and
|
||||
# then fails — the outcome still carries the paid facts (money was spent).
|
||||
from ouroboros.review_dispatch import stamp_review_paid_on_dispatch
|
||||
|
||||
def _dispatch_then_fail(ctx_arg, *a, **k):
|
||||
stamp_review_paid_on_dispatch(ctx_arg)
|
||||
return ("prompt", {}, "", "provider exploded")
|
||||
|
||||
monkeypatch.setenv(KEY, "5")
|
||||
_wire_review_skill(monkeypatch, tmp_path, content_hash="h2",
|
||||
review_state=None, passes=_dispatch_then_fail)
|
||||
paid_outcome = skill_review.review_skill(ctx, "demo", persist=False)
|
||||
assert paid_outcome.status == "pending" and paid_outcome.paid is True
|
||||
assert paid_outcome.review_contract_fingerprint == contract_fp
|
||||
assert paid_outcome.wave_id # the write-ahead dispatch marker names the wave
|
||||
assert "infrastructure failure" in paid_outcome.error
|
||||
# (c2) an ASSEMBLY-refused wave (the seam never invoked) stays unpaid.
|
||||
_wire_review_skill(monkeypatch, tmp_path, content_hash="h3",
|
||||
review_state=None,
|
||||
passes=lambda *a, **k: ("prompt", {}, "", "packs never assembled"))
|
||||
unpaid_outcome = skill_review.review_skill(ctx, "demo", persist=False)
|
||||
assert unpaid_outcome.status == "pending" and unpaid_outcome.paid is False
|
||||
assert unpaid_outcome.wave_id == ""
|
||||
|
||||
|
||||
def test_review_skill_pipeline_order_pin():
|
||||
"""Order pin (kept thin beside the behavior test): the cycles gate runs
|
||||
before budget admission, before the panel seam; post-dispatch outcomes go
|
||||
through the paid-facts stamp."""
|
||||
import inspect
|
||||
|
||||
from ouroboros import skill_review
|
||||
|
||||
gate_source = inspect.getsource(skill_review._skill_cycles_gate)
|
||||
assert gate_source.index("free_replay_outcome(") < gate_source.index("skill_review_cycles_refusal(")
|
||||
source = inspect.getsource(skill_review.review_skill)
|
||||
assert source.index("_skill_cycles_gate(") < source.index("_review_wave_budget_block(")
|
||||
assert source.index("_review_wave_budget_block(") < source.index("run_skill_review_passes(")
|
||||
assert source.count("_paid_facts(") >= 5 # dispatch marks every post-panel outcome paid
|
||||
|
|
@ -1486,7 +1486,10 @@ class TestGitWiring:
|
|||
source = inspect.getsource(git._repo_commit_push)
|
||||
assert "_run_reviewed_stage_cycle" in source
|
||||
shared_source = inspect.getsource(git._run_reviewed_stage_cycle)
|
||||
assert "_check_advisory_freshness" in shared_source
|
||||
# The advisory-freshness check lives in the extracted gate helper the
|
||||
# stage cycle calls before any paid dispatch.
|
||||
assert "_advisory_and_tests_gate" in shared_source
|
||||
assert "_check_advisory_freshness" in inspect.getsource(git._advisory_and_tests_gate)
|
||||
assert "_run_parallel_review" in shared_source
|
||||
parallel_source = inspect.getsource(git._run_parallel_review)
|
||||
assert "run_scope_review" in parallel_source
|
||||
|
|
|
|||
|
|
@ -462,7 +462,7 @@ export function renderSettingsPage() {
|
|||
|
||||
<div class="form-section">
|
||||
<h3>Max Review Cycles</h3>
|
||||
<div class="settings-section-copy">One shared cap on paid review cycles per task for plan review, task acceptance (improvement passes = cycles − 1) and the commit gate (review blocks of the same unchanged diff before it refuses another run). <code>∞</code> removes the cap; the task's own deadline, budget and lifecycle rails still bind.</div>
|
||||
<div class="settings-section-copy">One shared cap on paid review cycles per task for plan review, task acceptance (improvement passes = cycles − 1), the commit gate (paid triad+scope cycles per root task, shared across the whole task tree; an unchanged diff never buys a new review after a verdict block regardless of this number — under blocking enforcement the commit is refused for free, under advisory it proceeds with a loud durable disclosure and no new review spend) and skill review (paid panel runs per root task or per manual snapshot; identical snapshots replay free). <code>∞</code> removes the cap; the task's own deadline, budget and lifecycle rails still bind.</div>
|
||||
<div class="settings-effort-card">
|
||||
<label>Max Review Cycles</label>
|
||||
<input id="s-review-max-cycles" type="hidden" value="2">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue