diff --git a/devtools/benchmarks/continual_learning/README.md b/devtools/benchmarks/continual_learning/README.md index b63fbdff9..75bba7f59 100644 --- a/devtools/benchmarks/continual_learning/README.md +++ b/devtools/benchmarks/continual_learning/README.md @@ -51,6 +51,14 @@ separately: The launcher fails loudly when any of these are absent; `--dry-run` works without them. +The launcher renders and exports Required result review. Its fidelity report +separates that request from the external adapter's executing configuration and +pre-review output producer, which this tree cannot attest. Checked-in patch +recipes are historical examples, not deployment evidence. Qualification of the +launcher with synthetic callable inputs establishes neither a live external +adapter nor score parity; the selected external `src/systems/ouroboros/` source +and its runtime configuration are needed to establish that chain. + ## v6.56.0 operator patches for the pinned adapter See `operator_patches/README.md` — three host/runtime incompatibilities of the diff --git a/devtools/benchmarks/continual_learning/run_clb.py b/devtools/benchmarks/continual_learning/run_clb.py index f8e014383..d953e0937 100644 --- a/devtools/benchmarks/continual_learning/run_clb.py +++ b/devtools/benchmarks/continual_learning/run_clb.py @@ -129,6 +129,7 @@ def render_run_settings(base_path: pathlib.Path, run_dir: pathlib.Path, *, solve # A benchmark template mode is an explicit operator declaration. settings["OUROBOROS_CONTEXT_MODE_AUTO_LOW"] = "false" settings = normalize_context_mode_compat(settings) + settings["OUROBOROS_TASK_REVIEW_MODE"] = "required" settings.pop("OUROBOROS_MODEL_HEAVY", None) settings.pop("USE_LOCAL_HEAVY", None) bare = _bare_model(solve_model) @@ -332,6 +333,10 @@ def _fidelity_report(settings: dict, args: argparse.Namespace, "declared": settings.get("OUROBOROS_REVIEW_ENFORCEMENT"), "status": _env_status, } + gap["OUROBOROS_TASK_REVIEW_MODE"] = { + "declared": "required", + "status": "requested in rendered settings and child environment; executing external adapter settings and pre-review output producer are not attested by this launcher", + } _subagents_status = ( "exported in env and materialized in isolated settings by the host adapter" if not args.docker and subagents_forwarded @@ -421,7 +426,7 @@ def _sanitized_child_env(run_dir: pathlib.Path, settings: dict, args: argparse.N # Operator patch 2026-07-23: forward review MODE (not just enforcement) and # subagent depth so a submittable "review required" / no-swarm run is achievable # from the launcher instead of silently living on defaults (auto / depth 2). - "OUROBOROS_TASK_REVIEW_MODE": str(settings.get("OUROBOROS_TASK_REVIEW_MODE") or "auto"), + "OUROBOROS_TASK_REVIEW_MODE": "required", "OUROBOROS_MAX_SUBAGENT_DEPTH": str(settings.get("OUROBOROS_MAX_SUBAGENT_DEPTH") or 2), "CLBENCH_SOLVE_DISABLED_TOOLS": ( _dt if isinstance((_dt := settings.get("CLBENCH_SOLVE_DISABLED_TOOLS") or []), str) diff --git a/devtools/benchmarks/continual_learning/settings_base.json b/devtools/benchmarks/continual_learning/settings_base.json index ae077c78d..7d5f53b48 100644 --- a/devtools/benchmarks/continual_learning/settings_base.json +++ b/devtools/benchmarks/continual_learning/settings_base.json @@ -6,6 +6,7 @@ "OUROBOROS_SUBAGENTS": "{\"enabled\":true,\"items\":[{\"subagent_id\":\"benchmark-model\",\"recommended_use\":\"Use for substantial implementation, difficult debugging, research synthesis, and end-to-end ownership. Prefer when overall quality and continuity matter more than speed.\",\"route\":{\"kind\":\"api_model\",\"target_id\":\"anthropic/claude-sonnet-4.6\"}},{\"subagent_id\":\"benchmark-review-1\",\"recommended_use\":\"Fixed-model benchmark reviewer: packet delivery on the measured model\",\"route\":{\"kind\":\"api_model\",\"target_id\":\"anthropic/claude-sonnet-4.6\"},\"review_eligible\":true,\"delivery\":\"packet\",\"minted_from\":\"factory_default\"},{\"subagent_id\":\"benchmark-review-2\",\"recommended_use\":\"Fixed-model benchmark reviewer: packet delivery on the measured model\",\"route\":{\"kind\":\"api_model\",\"target_id\":\"anthropic/claude-sonnet-4.6\"},\"review_eligible\":true,\"delivery\":\"packet\",\"minted_from\":\"factory_default\"},{\"subagent_id\":\"benchmark-review-3\",\"recommended_use\":\"Fixed-model benchmark reviewer: packet delivery on the measured model\",\"route\":{\"kind\":\"api_model\",\"target_id\":\"anthropic/claude-sonnet-4.6\"},\"review_eligible\":true,\"delivery\":\"packet\",\"minted_from\":\"factory_default\"}]}", "OUROBOROS_MODEL_LIGHT": "anthropic/claude-sonnet-4.6", "OUROBOROS_MODEL_FALLBACKS": "anthropic/claude-sonnet-4.6", + "OUROBOROS_TASK_REVIEW_MODE": "required", "OUROBOROS_REVIEW_ENFORCEMENT": "blocking", "OUROBOROS_REVIEW_MAX_CYCLES": "unlimited", "OUROBOROS_RUNTIME_MODE": "advanced", diff --git a/devtools/benchmarks/gaia/METHODOLOGY.md b/devtools/benchmarks/gaia/METHODOLOGY.md index bf59d9b5a..1662cdf61 100644 --- a/devtools/benchmarks/gaia/METHODOLOGY.md +++ b/devtools/benchmarks/gaia/METHODOLOGY.md @@ -119,6 +119,12 @@ does not rewrite the scorer or normalize Ouroboros's core `final_answer`. repo as forensic input; useful settings and wrapper ideas are copied/adapted here rather than imported as live code. +The Ouroboros adapter declares `final_answer_line`. Runtime retention selects its +exact answer payload before result-review eligibility, and the packet uses that +same source. Inspect consumes only the structured `final_answer` bytes; empty or +missing selection stays empty instead of falling back to narrative `result`. +Synthetic contract checks do not establish live scores or historical parity. + ## Answer-leakage audit protocol (publishable web-on runs) GAIA's validation answers are public on HuggingFace, so a web-capable agent can diff --git a/devtools/benchmarks/gaia/inspect_solver/ouroboros_solver.py b/devtools/benchmarks/gaia/inspect_solver/ouroboros_solver.py index 4e2b5ff36..01db16e57 100644 --- a/devtools/benchmarks/gaia/inspect_solver/ouroboros_solver.py +++ b/devtools/benchmarks/gaia/inspect_solver/ouroboros_solver.py @@ -151,9 +151,11 @@ def run_ouroboros(prompt: str, sample_id: str = "sample", attachments: list[path payload = json.loads(result_json.read_text(encoding="utf-8")) except Exception: payload = {} - answer = payload.get("final_answer") or payload.get("result") or "" + # The runtime selects and retains this protocol payload before review. + # Narration is never a fallback answer, and this consumer does not trim it. + answer = payload.get("final_answer") or "" return { - "final_answer": str(answer or "").strip(), + "final_answer": str(answer), "returncode": proc.returncode, "result_json": str(result_json), # The full runtime task result is already on disk here and was read for the answer diff --git a/devtools/benchmarks/osworld/README.md b/devtools/benchmarks/osworld/README.md index bce13329d..69cbe1d4f 100644 --- a/devtools/benchmarks/osworld/README.md +++ b/devtools/benchmarks/osworld/README.md @@ -6,6 +6,15 @@ still requires a runnable OSWorld checkout plus VM/desktop control infrastructure; public verified leaderboard claims require the official verification path (the OSWorld 2.0 maintainers run agent code on their side). +Result review uses Required in the template. Both launchers check the executing +server through `/api/settings`; a local setting cannot configure a remote server. +The whole-task computer-use bridge produces GUI-input receipts during the task; +those can qualify review without certifying goal success. `finish_task` stays +available to select a result after feedback. The step agent predicts an action +before the external runner executes it: its inner result review cannot judge +that future GUI effect. Synthetic adapter tests do not establish live deployment +or historical score parity. + Aligned upstream (pinned; see `ALIGNED_UPSTREAM` in `run_step_agent.py` and `METHODOLOGY.md` §0): OSWorld 2.0, @ diff --git a/devtools/benchmarks/osworld/cu_bridge_tool_policy.py b/devtools/benchmarks/osworld/cu_bridge_tool_policy.py index 01f79f317..7c6e8bcb4 100644 --- a/devtools/benchmarks/osworld/cu_bridge_tool_policy.py +++ b/devtools/benchmarks/osworld/cu_bridge_tool_policy.py @@ -32,6 +32,7 @@ from devtools.benchmarks.osworld.cu_bridge_runtime import SKILL_NAME _ALLOWED_CORE_TOOLS = frozenset({ "list_available_tools", "enable_tools", # discover + enable the computer-use skill "view_image", # the vision channel (SEE screenshots) + "finish_task", # select the result after review feedback "compact_context", "set_tool_timeout", # agent self-management (no host access) }) diff --git a/devtools/benchmarks/osworld/run_cu_bridge_agent.py b/devtools/benchmarks/osworld/run_cu_bridge_agent.py index 057e601d5..71ed39d92 100644 --- a/devtools/benchmarks/osworld/run_cu_bridge_agent.py +++ b/devtools/benchmarks/osworld/run_cu_bridge_agent.py @@ -133,6 +133,8 @@ def _cu_actor_preflight(settings_path: Path, server: str) -> dict[str, Any]: measured_model = str(loaded.get("OUROBOROS_MODEL") or "").strip() if not measured_model: raise ValueError("settings must declare measured OUROBOROS_MODEL") + if loaded.get("OUROBOROS_TASK_REVIEW_MODE") != "required": + failures.append("declared task review mode must be required") declared = runtime_actor_snapshot(loaded, expected_model=measured_model) failures.extend(f"declared settings {item}" for item in declared["mismatches"]) except Exception as exc: @@ -141,6 +143,8 @@ def _cu_actor_preflight(settings_path: Path, server: str) -> dict[str, Any]: try: target_settings = _api(server, "GET", "/api/settings", timeout=10) + if target_settings.get("OUROBOROS_TASK_REVIEW_MODE") != "required": + failures.append("executing server task review mode must be required") target = runtime_actor_snapshot(target_settings, expected_model=measured_model) failures.extend(f"target server {item}" for item in target["mismatches"]) except Exception as exc: diff --git a/devtools/benchmarks/osworld/run_step_agent.py b/devtools/benchmarks/osworld/run_step_agent.py index eb58224ed..9c8f08a1b 100755 --- a/devtools/benchmarks/osworld/run_step_agent.py +++ b/devtools/benchmarks/osworld/run_step_agent.py @@ -311,6 +311,7 @@ def _preflight(config: PreflightConfig) -> dict[str, Any]: # runs it — CLI env cannot configure the executing server, so the # preflight must verify it (adversarial review r2 #6). "OUROBOROS_REVIEW_ENFORCEMENT": "blocking", + "OUROBOROS_TASK_REVIEW_MODE": "required", } mismatches = [] for key, want in expected.items(): @@ -329,6 +330,7 @@ def _preflight(config: PreflightConfig) -> dict[str, Any]: for k in ( "OUROBOROS_RUNTIME_MODE", "OUROBOROS_SAFETY_MODE", + "OUROBOROS_TASK_REVIEW_MODE", "OUROBOROS_MAX_WORKERS", "OUROBOROS_MODEL", "OUROBOROS_SUBAGENTS", diff --git a/devtools/benchmarks/osworld/settings_base.json b/devtools/benchmarks/osworld/settings_base.json index 9b9585348..7df26df8b 100644 --- a/devtools/benchmarks/osworld/settings_base.json +++ b/devtools/benchmarks/osworld/settings_base.json @@ -12,7 +12,7 @@ "USE_LOCAL_FALLBACK": false, "USE_LOCAL_CONSCIOUSNESS": false, "OUROBOROS_WEBSEARCH_MODEL": "anthropic/claude-sonnet-4.6", - "OUROBOROS_TASK_REVIEW_MODE": "auto", + "OUROBOROS_TASK_REVIEW_MODE": "required", "OUROBOROS_REVIEW_ENFORCEMENT": "blocking", "OUROBOROS_REVIEW_MAX_CYCLES": "unlimited", "OUROBOROS_RUNTIME_MODE": "pro", diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 897376378..bb8b58cff 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -206,7 +206,10 @@ A segmented choice row gives every choice the same width. Up to four fill their row; a longer scale wraps into equal columns of four and leaves its last row partly empty rather than stretching a lone choice; a narrow card stacks two; one-glyph values such as review cycles stay one row. An empty inherit choice is as real as -any other, and the focused choice wears the one focus ring. +any other, and the focused choice wears the one focus ring. A choice the running +Access suspends — Blocking under Cyber Pro — stays selected; the note beside it +names its effect for the current process and for the selected next start, from +the draft before Save. Short fixed choices keep native selects, including the platform's own popup. A control never widens its column: a select shows its chosen label on one @@ -1438,6 +1441,8 @@ asks to return, and only the primary's own accepted reply counts as a return. A submitted action is shown as pending until the task reports its application. A saved Settings change and a still-pending task change are disclosed separately. +When a requested Settings save failed or is unconfirmed, resolving the wait shows one +toast with those facts; closing its card without resolution announces nothing. Retries preserve the original request identity and payload. Revisioned rows reject older observations and resolved episodes never reopen. Terminal authors retain exact live paid-review controls without reviving author status; other waits end. A mailbox delivery failure keeps Retry request available for diff --git a/docs/DOMAIN_MAP.md b/docs/DOMAIN_MAP.md index 13c5a890a..0f9d93b9a 100644 --- a/docs/DOMAIN_MAP.md +++ b/docs/DOMAIN_MAP.md @@ -13,7 +13,7 @@ The manifest is the SSOT of the module→domain assignment (1:1, complete over t | D03 | Context assembly, fit & compaction | 20 | 0 | | D04 | Tool execution: registry, access & typed results | 22 | 0 | | D05 | Tool surfaces: files, code, shell, media, external | 38 | 0 | -| D06 | Review stack | 82 | 0 | +| D06 | Review stack | 84 | 0 | | D07 | Delegation, subagents & Claudexor | 66 | 0 | | D08 | Supervisor: queue, workers, events & runtime control | 62 | 0 | | D09 | Cancellation, owner control & process custody | 18 | 0 | @@ -22,13 +22,13 @@ The manifest is the SSOT of the module→domain assignment (1:1, complete over t | D12 | Settings & configuration | 20 | 0 | | D13 | Safety, guards & runtime mode | 9 | 0 | | D14 | Skills & extensions | 58 | 0 | -| D15 | Memory, knowledge, consciousness & self-evolution | 28 | 0 | +| D15 | Memory, knowledge, consciousness & self-evolution | 29 | 0 | | D16 | Observability, usage accounting & cost | 21 | 0 | | D17 | Projects, workspaces & task results | 33 | 0 | | D18 | Launcher, packaging, platform & shared substrate | 25 | 0 | | D19 | Frozen contracts (ABI) | 11 | 0 | | D20 | Presence | 13 | 0 | -| **total** | | **731** | **0** | +| **total** | | **734** | **0** | ## Dependency direction matrix (strict, pinned) @@ -386,6 +386,7 @@ No function body (≥ 10 normalized lines) is shared verbatim across domains. Ne ### D06 — Review stack +- `ouroboros/acceptance_material.py` - `ouroboros/commit_admission.py` - `ouroboros/deep_self_review.py` - `ouroboros/dialogue_evidence.py` @@ -440,6 +441,7 @@ No function body (≥ 10 normalized lines) is shared verbatim across domains. Ne - `ouroboros/tools/parallel_review.py` - `ouroboros/tools/plan_ask.py` - `ouroboros/tools/plan_author_history.py` +- `ouroboros/tools/plan_delivery.py` - `ouroboros/tools/plan_dialogue.py` - `ouroboros/tools/plan_evidence.py` - `ouroboros/tools/plan_packet.py` @@ -854,6 +856,7 @@ No function body (≥ 10 normalized lines) is shared verbatim across domains. Ne - `ouroboros/evolution_fingerprint.py` - `ouroboros/improvement_backlog.py` - `ouroboros/knowledge.py` +- `ouroboros/late_review_marks.py` - `ouroboros/memory.py` - `ouroboros/memory_fallback.py` - `ouroboros/memory_inventory.py` diff --git a/docs/PERSISTENCE.md b/docs/PERSISTENCE.md index 3363323ef..ae4417825 100644 --- a/docs/PERSISTENCE.md +++ b/docs/PERSISTENCE.md @@ -78,7 +78,7 @@ path it scans from data-path constructors in `ouroboros/`, `supervisor/`, | `state/server_port.bindings.json` | `ouroboros/server_process.py` (`record_service_binding`, `clear_service_binding`, shared locked update) | no schema version; exact service keys and measured process identity validated on read | at most main, host_service and local_model; overwrite per observed binding, exact-identity removal on close | current endpoint facts are lost; browser falls back to existing port/custody facts and refuses unconfirmed known endpoints; pooled local readers report unknown serving capacity | | `state/server_process.json` | `launcher.py` | none — PID/path-bound process facts with a measured birth/command `fingerprint` (absent on old launchers: path/group/command proof); optional `server_host_source` distinguishes parent env from saved Settings, absent means unknown | retired after confirmed exit or refuted identity; kept while a stop is unconfirmed or identity unmeasurable, which blocks the next generation | predecessor cleanup skipped once (admission still holds); bind-source disclosure becomes unknown, never inferred from value equality | | `state/auth_secret.key` | `ouroboros/server_auth.py` | none — not needed | write-once | regenerated; one forced owner re-login (accepted) | -| `state/obligations/*.json` | `ouroboros/store_import.py` only: the old body's set files are the importer's input (one snapshot for rows and baseline; read errors abort typed, malformed files are refused in place) and the exporter's output; the sets themselves are `obligations` rows of `state/current.sqlite`, written by the transaction of the result, pool, custody or chat transition that changes them (`ouroboros/obligations.py`: `result_memberships` maps a merged result to its flag columns, owner-held rows are added and removed by name) | set name, id → facts | current outstanding work, unknown gaps and notice receipts — including `local_custody_witnesses` (`local_custody_repair.retain_witness`: a positive ended-local-owner fact whose retirement write failed; discharged only by a successful retirement at an addressed action, and not a `BOOT_SETS` member, so no rebuild derives or erases it); retire after discharge; no lock file, no rebuild mark: a transition that cannot write its set fails typed with the result it belongs to | a file beside a published store is inert; the next build (absent store) derives every `BOOT_SETS` member from the documents it admits and keeps receipts from the file | +| `state/obligations/*.json` | `ouroboros/store_import.py` only: the old body's set files are the importer's input (one snapshot for rows and baseline; read errors abort typed, malformed files are refused in place) and the exporter's output; the sets themselves are `obligations` rows of `state/current.sqlite`, written by the transaction of the result, pool, custody or chat transition that changes them (`ouroboros/obligations.py`: `result_memberships` maps a merged result to its flag columns, owner-held rows are added and removed by name) | set name, id → facts | current outstanding work, unknown gaps and notice receipts — including `local_custody_witnesses` (`local_custody_repair.retain_witness`: a positive ended-local-owner fact whose retirement write failed; discharged only by a successful retirement at an addressed action, and not a `BOOT_SETS` member, so no rebuild derives or erases it) and `late_review_marks` (`late_review_marks.py`: id → the immutable late-review record of a mark not yet placed or waiting for its author's task to end, or a producer duty written at its first durable fact — an open plan wave with a settled seat, a left change wave — that becomes marks after that end; a read notice discharges nothing; the periodic maintenance sweep replays only these rows, and a placed, unchanged or released mark discharges one, so no replay duplicates or resurrects it; not a `BOOT_SETS` member either); retire after discharge; no lock file, no rebuild mark: a transition that cannot write its set fails typed with the result it belongs to | a file beside a published store is inert; the next build (absent store) derives every `BOOT_SETS` member from the documents it admits and keeps receipts from the file | | `state/migrations.json` | `ouroboros/startup_migrations.py` (`stamp`), `supervisor/git_ops_reset.py` | the store generation (`current_store_generation`, stamped after publication, cleared before an export sets the store aside) and the cancel-latch generation | completion on success | an absent generation beside a present store is re-stamped at the next boot; a present generation beside an absent store reads typed LOST (never a silent rebuild) | | `state/owned_processes.json` | `ouroboros/owned_shutdown.py` (one document, atomic replace under the canonical process-ledger append lock; `workspace_executor._register_process`/`_forget_process`, `process_custody.record_process` and ledger compaction write it; the exit stop and the next start's retry stamp it) | own `schema_version: 1`; `inherited_import` marks the one-time import of older executor records | current set: an entry leaves on forget, ledger compaction or a confirmed stop; `stop_requested_at`/`unconfirmed_since` entries stay until a stop confirms them | the next start re-indexes executor records from disk once; ledgered task processes are unnamed until re-recorded (the reaper still reads the ledger), and pending stops are forgotten | | `state/owned_processes.pending/*.json` | `ouroboros/owned_shutdown.py` (`_publish`: one lock-free atomic file per registration that could not take the process-ledger append lock) | one ownership-set entry | normally empty; folded into `state/owned_processes.json` and deleted by the next locked update | every read of the set merges them, so the exit and the next start still name the process; a torn file names nothing and its registrant logged the failure | diff --git a/docs/architecture/06-agent-core.md b/docs/architecture/06-agent-core.md index 4734a8948..bff89cc1b 100644 --- a/docs/architecture/06-agent-core.md +++ b/docs/architecture/06-agent-core.md @@ -18,9 +18,9 @@ Provider death salvages text but ends `failed`/`infra_failed` with `provider_una #### Task acceptance -Task acceptance judges evidence and advises authorized work, apart from the P3 commit gate (`triad_review.TIER_CLASSIFICATION_RULES`). In `auto`/`required`, observable effects, typed deliverables/criteria and explicit root nominations qualify, research included; queue membership alone qualifies only in `required`, conversation, exploration or memory updates alone never in `auto`, and children get no automatic panel (`skipped_child_advisory`). No prose or tool-count classifier decides: nominations are recorded root deferrals (`loop_acceptance.recorded_acceptance_nomination`), review runs a producer's `actors`. `acceptance_history.py` freezes skipped roots/retries and a separate `historical_author_inputs` exhibit for later review, preserving capture gaps; historical premises never become owner directives, and a host-authored terminal mints no debt (`emitted_delivery_authorship`). Partial delivery does not close the goal; a sufficient result needs no embellishment. +Task acceptance advises work; commit review is separate. Auto needs root nomination (`deferred_to_host_acceptance`, `authoritative=false`); Required also admits observed effects or retained produced material. Requirements, inputs, checkpoints and queue status prove no production. `acceptance_material.py` retains selected `final_answer_line` bytes, verified outputs, baseline-attributed changes and OSWorld GUI-input receipts before eligibility; packet and subject reuse them. Empty/stale/failed sources grant nothing; GUI execution proves no goal success. Neither prose/tool counts nor automatic child panels qualify (`skipped_child_advisory`). -`task_acceptance_review` nominates work but is not the acceptance authority; Main authors criteria. Root `off` disables automatic review, while an explicit root call uses the full advisory pool. A child call chooses at most one configured `reviewer_slot_id`. `task_acceptance_review(late_review=...)` requires a new owner chat, quiz or mailbox source: `amend_cap` changes only the original-root cap; `review` buys an `acceptance_late` wave over frozen terminal work even with review off. Known root/group spend, Stop/Panic, prohibitions and calendar bounds still bind. Hurry/Wrapup stop unstarted automatic work, not collecting paid or unknown operations. +`task_acceptance_review` nominates work; Main authors criteria. Off forbids new root panels, manual/historical included, never paid/unknown collection, cap-only amendment, code or child critics. A child picks one configured `reviewer_slot_id`. Historical requests need new owner chat/quiz/mail: `amend_cap` changes only the original-root cap; `review` judges frozen terminal work. `acceptance_history.py` retains skipped roots/retries, gaps and separate `historical_author_inputs`; old premises direct nothing; host-authored terminals mint no debt (`emitted_delivery_authorship`). Spend, Stop/Panic, prohibitions and deadlines bind; Hurry/Wrapup stop unstarted automatic work, never paid collection. Partial delivery leaves the goal open; sufficient work needs no embellishment. `queue_transitions.py` proves subtree quiescence and fences child admission before payment; unanswered fences buy no round. Delivery seals again. Pooled fences bind attempt/PID: unanswered begins release, ended owners retire at handoff; consumed Resume rebinds direct/pooled park fences. The canonical `budget_drive_root` packet retains verbatim owner directives, criteria, deliverables, terminal children, verification receipts, service teardown (shown to the author; whole logs as `read_file` text, not tails), artifact references and an `omissions_manifest`. `repo_diff_capture.capture_repo_diff` produces preview and exact source from one read: failed, cut or undecodable capture is a gap, never a clean tree. Exact bytes stay private in observability CAS; public sections are redacted before cutting, withholding unsafe partial diagnostics. Shedding preserves `repo_diff_source_ref`; immutable-core overflow is explicit. `review_source_closure` retains named sources and host-attested refs under `source_handles/review_inputs//` before paid binding. Missing/corrupt sources refuse unpaid, oversized first sends use an exact-source work order, and changed session bytes degrade. Packet, session and native rows keep their own delivery contracts (§6 Review delivery). @@ -28,13 +28,13 @@ Task acceptance judges evidence and advises authorized work, apart from the P3 c `acceptance_paid_identity` binds the semantic subject and substantive obligation dispositions. Narration, forensic hashes and ingress counters alone buy no panel; an identical paid identity replays free with its actual verdict. `task_pacing.review_launch_allowed` requires cycle capacity, money admission and more than `OUROBOROS_ACCEPTANCE_REVIEW_EST_SEC` above the finalization reserve before evidence building; that floor predicts no duration and gates no author improvement. The first physical send claims `task_results.task_acceptance_review_accounting`; an unrecoverable claim remains unknown, never resend authority (§6 Paid stamp and owner custody). A launched review cut by its deadline is DEGRADED, not a free skip. -`review_operation.ReviewOperation` owns the immutable request, paid roster, waits and controls beyond the author turn's lifetime. Recovery verifies stored ownership and binding; `collect_recorded_acceptance_run` parses locally at $0 and never calls a model. Worker death is unknown. `acceptance_settlement.announce_acceptance_settlement` mails each reviewer's own verdict at quorum and final settlement; only typed owner/control input grants an owner revision. Its sole publisher, `settle_acceptance_operation`, owes one System `card_row="reviews"` through the durable outbox; failed publication retains that duty. Late settlement reports its time and reviewed identity, authorizing no continuation. Cold execution facts come from `final_attempt_facts` (§6 Session identity). +`review_operation.ReviewOperation` owns the immutable request, paid roster, waits and controls past the author turn. Recovery verifies stored ownership/binding; `collect_recorded_acceptance_run` parses locally at $0 with no model. Worker death is unknown. `announce_acceptance_settlement` mails each reviewer's verdict at quorum and final settlement; only typed owner/control input grants an owner revision. Its sole publisher, `settle_acceptance_operation`, owes one System `card_row="reviews"` via the durable outbox and a host mark in its room only the mind releases (`late_review_marks.py`, also ended plan or left change waves), reporting time and reviewed identity, authorizing no continuation; an unplaced notice or mark stays owed. Cold execution facts come from `final_attempt_facts` (§6 Session identity). Adaptive quorum is two of N for N≥3, both for N=2, and one disclosed `single_reviewer_no_diversity` for N=1. Failed or malformed rows are infrastructure evidence, never negative judgments; pre-send refusal is `not_dispatched`. `PASS`/`FAIL`/`DEGRADED` belong to reviewers; only `loop_acceptance._set_acceptance_decision` writes `accepted`, `revision_requested` or `finalized_unaccepted`. A clean criterion needs exact full-packet `evidence_refs`; claim support resolves through `acceptance_support_refs` to passing host receipts. Unresolved refs keep the audit but lose their clean contribution, preventing self-certification by repeating the expected outcome. Minority findings remain feedback without quorum. Stable obligations retain both author and critic positions through repair, rebuttal and declared disagreement. A valid rebuttal retires its finding; an invalid one reopens it. The bounded `acceptance_dialogue_history` is outside hashed material, so reading it buys nothing. Every outcome permits inspection, correction or rebuttal. An informed Advisory `author_action=finish` must bind to feedback exposed in Main's request and keeps author and critic hashes separate; Blocking requires fresh approval for disputed advancement. Cyber retains BIBLE P0/P3 authority without rewriting verdict, money or custody; `dialogue_status` never chooses the author's stop. -Pending final delivery waits in Blocking; Cyber Pro lets Main decide, and Advisory may finish as `pending_review`. A text-only rewrite can reuse a ready/pending PASS only while owner source, criteria and material evidence match, disclosed as `previous_revision_accepted`; it grants no commit, plan or skill authority. Forced finalization keeps the exact tool envelope for cache continuity but executes no tool and buys no further round. A dangling revision becomes `finalized_unaccepted` with `revision_unavailable_on_forced_rail`, preserving already terminal decisions. A deadline or unread late message never certifies a new subject. +Default `pending_review=wait` keeps the same operation in every mode through mail, rewrites and partial settlement without empty author polls; settled feedback, PASS included, precedes the next author decision; finish prose never waives it. Nonblocking modes allow explicit `pending_review=finish`, apart from criticism, with no Cyber improvement-counter veto. A rewrite keeps `previous_revision_accepted` only after that choice with matching premises/material, granting no candidate, commit, plan or skill PASS. Forced finalization keeps the tool envelope but runs no tool or extra round. A dangling revision becomes `finalized_unaccepted` with `revision_unavailable_on_forced_rail`, preserving terminal decisions; deadlines or unread mail certify no subject. #### Headless finalization and workspace patch capture @@ -366,7 +366,7 @@ Adoption is deliberate and restart-bound (`body_adoption.py`): `request_restart( ### Git and commit review -`pr_merge(number, expected_head_sha, method)` merges through the selected repository's `gh` identity at the exact head, never with force, admin or auto-merge; `merge_receipts.py` records intent before effect and keeps reviewed, current and merged as separate facts (a committed `base..head` ledger record named by `review_record_id`, else a `declared_only` declaration). An unknown or queued outcome is observation-only — exit status alone, transport loss or 5xx proves no rejection — a missing review is loud, never a veto, and shell or web merges leave no receipt. Only proven pre-effect failures release intent, including the exact full `gh pr merge` stderr matching its `canMerge` refusal (before the display cut); recorded unknowns are never reclassified. `list_github_prs` preserves its bound `gh pr list` selection and reads exact counts in one node-ID GraphQL query on the returned URLs’ host; unavailable counts withhold the list, never invent zero. +`pr_merge(number, expected_head_sha, method)` merges through the selected repository's `gh` identity at the exact head, never with force, admin or auto-merge; `merge_receipts.py` records intent before effect and keeps reviewed, current and merged apart (a committed `base..head` record named by `review_record_id`; a `declared_only` declaration never reads green). An unknown or queued outcome is observation-only — exit status alone, transport loss or 5xx proves no rejection — a missing review is loud, never a veto, and shell or web merges leave no receipt. Only proven pre-effect failures release intent, including the exact full `gh pr merge` stderr matching its `canMerge` refusal (before the display cut); recorded unknowns are never reclassified. `list_github_prs` preserves its bound `gh pr list` selection and reads exact counts in one node-ID GraphQL query on the returned URLs’ host; unavailable counts withhold the list, never invent zero. `mutation_attribution.py` captures the root-task baseline and projects the clean-at-baseline delta plus an adopted predecessor's exact retained changes; a changed pre-existing dirty path, a stale baseline or a failed scan blocks automatic staging for the panel to weigh. `commit_reviewed(paths=None)` stages only that attributed candidate, explicit paths must be a subset, and an empty candidate is `GIT_NO_ATTRIBUTED_CHANGES`; managed update transactions keep their separate whole-tree authority, and `predecessor_adoption` grants no review approval. @@ -374,7 +374,7 @@ Commit, tag, post-commit tests and push each pass `owner_pause.run_operation`: P A reviewed commit is bound to one staged fingerprint — the `git write-tree` SHA, ordered `HEAD` and `MERGE_HEAD` parents, indexed VERSION, expected `v{VERSION}` tag, any existing tag target and the binary staged-diff hash — re-read after the commit before success or push is recorded; an existing release tag is never silently accepted or retargeted. Every seat of the one wave inspects the same staged snapshot, any mutation stales the binding, and the author's early look (§6 Commit preflight) changes neither tests, wave nor binding. A managed-update resolution commit reviews the declared M0→S subject (`tools/review_subject.py`) while the gate binds S to the exact index write-tree. `release_sync.sync_release_metadata` projects version carriers during commit preparation; `VERSION_CARRIER_SPANS` / `substitute_carrier_spans` is the one span primitive the managed-update resolver and the packet's carrier cut share. BIBLE supplies review authority, CHECKLISTS the criteria and DEVELOPMENT the procedure; snapshot identity, the preflight fact, deterministic results, actor evidence and final Git identity describe one material. -A material ordinary Advisory outcome returns the complete result and the exact `review_reference` before commit, tag or push. Continuation through `commit_reviewed` / `vcs_commit_reviewed` with that reference and `author_disposition` binds the current attributed candidate, reruns the independent preflight, tests and Git checks and dispatches no critic; a settled handback is `reviewed/review_only`, pending custody stays `reviewing/late_wait` and collectible, and Blocking gets no author override. Evolution receipts record the wave's actual aggregate (`triad_scope_status`), never a PASS inferred from a successful commit. +Every Advisory or Cyber Pro outcome returns its complete result and exact `review_reference` before commit, tag or push. Continuation through `commit_reviewed` / `vcs_commit_reviewed` with that reference and `author_disposition` binds the current attributed candidate, reruns the independent preflight, tests and Git checks and dispatches no critic; a settled handback is `reviewed/review_only`, pending custody stays `reviewing/late_wait` and collectible, and Blocking gets no author override. Evolution receipts record the wave's actual aggregate (`triad_scope_status`), never a PASS inferred from a successful commit. #### Commit review evidence @@ -451,13 +451,13 @@ One pool, many subjects — who calls the wave and what each seat is asked: The review wave is parametric in its subject (`tools/review_subject.py`). A `ReviewSubjectSpec` names the root (`system_repo` or `active_workspace`), the kind (`index`, `worktree`, or `base..head` with base an ancestor of head), the surface and the checklist layer; `freeze_subject` pins it once as a `FrozenSubject` (the diff, its `diff_sha`, both trees). Governance is always the installed body's (`governance_root`: the serving checkout; a bound body candidate is a subject, never the source of its own rules), while the reading root is the subject's. -Exactly one subject is read on its live root: the gate's own (`is_gate_subject`: the system repository's `index` against HEAD with no explicit base), through the gate's own capture (`capture_staged_diff`), so its packet, brief, seats and decision are byte-identical to the gate's. Every other subject is materialized by `isolated_checkout`: a detached worktree at the parent with the frozen patch applied to its index, under the install's data root (`state/review_checkouts//repo`) at the path of the task's round (`checkout_token`), so a rerun of the round reads the checkout an earlier wave's open custody retained; it is removed on exit unless custody stays open. Every delivery — packet, brief, the `-U0` fit rung, the required-source manifest, the retrieving seats' checkout — reads the frozen subject, never a recapture of a live index. A frozen subject that is not the body's own index skips the body's release preflight: the core layer is a foreign root under the body's rules, not the body's release contract. +Only a synchronous wait reads a live root: the gate's own (`is_gate_subject`: the system repository's `index` against HEAD with no explicit base), through the gate's own capture (`capture_staged_diff`), so its packet, brief, seats and decision are byte-identical to the gate's. Every other subject, and a receptive wave (`review_pause`), is materialized by `isolated_checkout`: a detached worktree at the parent with the frozen patch applied to its index, under the install's data root (`state/review_checkouts//repo`) at the path of the task's round (`checkout_token`), so a rerun of the round reads the checkout an earlier wave's open custody retained; it is removed on exit unless custody stays open. Every delivery — packet, brief, the `-U0` fit rung, the required-source manifest, the retrieving seats' checkout — reads the frozen subject, never a recapture of a live index. A frozen subject that is not the body's own index skips the body's release preflight: the core layer is a foreign root under the body's rules, not the body's release contract. Three identities derive from the frozen subject, never from the live index of the running process. (a) The reuse key (`review_reuse_key`, `review_ledger.reuse_key_digest`) covers surface, subject, rules, layer, composition, enforcement, contract and the logical round; a settled, dispatched record with that key is returned at $0 (`review_ledger.find_reusable`; `reused=true`) unless this task's own operation of the round is still open, which is collected first; pending and `NOT_DISPATCHED` records are never reused. (b) The logical round (`review_round_sha` → `review_ledger.round_sha_of`) is what the author asked of these bytes — rebuttal, questions, goal, scope, base and head — so a new rebuttal or brief buys one more paid review; the identical-diff refusal (`commit_gate.check_identical_verdict_refusal`) lets a new rebuttal through once, and the paid-cycle ceiling (`check_review_cycles_ceiling`) stays common to the root task per `(repo, tool)`, so a second subject under an exhausted root is refused, not reset. (c) The retry key (`review_retry_key`; the gate's has no round suffix) identifies the physical operation custody keys attempts by (`review_custody._attempt_key`), set before dispatch: a retry rejoins or replays it and never pays twice, and a new round never receives the previous round's answers. The rejoin is durable (`tools/review_change_custody.py`): a rerun finds the write-ahead attempt row by retry key (`pending_round_attempt`) before the current pool, any reuse lookup, ceiling check or payment, restores its recorded panel and dispute input, and runs the gate's reconcile-only path (`arm_rejoin`: settled seats replay, the pending seat rejoins by its exact invocation, nothing new is sent, the same record is revised), in this process or after a restart. A parametric wave's record names the frozen subject and carries `fingerprints.reuse_key` / `fingerprints.retry_key`. #### Change review on any root -`review_change` (`tools/review_change.py`) is the commit gate's reviewer wave without the landing: one subject, at most one paid wave, one ledger record (`surface="change"`), nothing committed and no tests (`tests.policy=NOT_RUN`; the author attaches results). The mind calls it by judgment; nothing starts it. It reviews the active workspace (default), the system repository or a `workspace_root` admitted by the `schedule_subagent` folder policy, frozen once and read in an isolated checkout unless it is the body's own staged index (§6 Subject operation); a deterministic call error (incomplete `base..head`, unregistered folder, empty change) is a `TOOL_ARG_ERROR` before any payment. The checklist layer follows the root's `body_fact` (`layer_for`), and `treat_as_body` raises only an `unknown` root. Composition follows the same fact: on the body outside Cyber Pro the owner's saved rows stand and `reviewers` only adds seats (a named subset is recorded `reviewers_subset_ignored`); on the body in Cyber Pro and every other subject `reviewers` is the panel (`composition=composed`; omitted = `full_pool`), `reason` is recorded, and a narrowed panel without one records `reason_missing=true`, not failing. Counted seats of a composed panel are marked pool rows; an enabled row outside the pool is an added critic (`additional`, outside the quorum), and a composition naming no pool seat is a `TOOL_ARG_ERROR`. `commit_reviewed(reviewers=…, reason=…)` composes the commit panel through the same composer (`commit_gate.compose_commit_panel`), refused before staging; the composed seats reach the wave's pool readers as a context-local override (`reviewer_slot_config.composed_review_pool`): the gate's slot readers have no argument path — the one disclosed contextvar seam the plan-review rule excepts (DEVELOPMENT "Loop / State-Machine Changes") — and a concurrent wave keeps the configured pool. `reviewer_effort` orders this wave's strength; `author_questions` reach brief and record verbatim (reuse and rebuttal: §6 Subject operation). Paid waves count against the shared cap per root task and (reviewed repository, `review_change`), apart from the gate's count; exhaustion is typed `review_cycles_exhausted` with a `NOT_DISPATCHED` record and meets only a new paid wave, never an open operation's answer. Enforcement is recorded on every root; only the body under Blocking records `enforcement_blocks=true`, so another root's FAIL is information, never a lock. `commit_reviewed` / `vcs_commit_reviewed` stay the only landing in the system repository: their `root` accepts only `system_repo`, and any other root is a `TOOL_ARG_ERROR` pointing at `review_change` and ordinary git. +`review_change` (`tools/review_change.py`) is the commit gate's reviewer wave without the landing: one subject, at most one paid wave, one ledger record (`surface="change"`), nothing committed and no tests (`tests.policy=NOT_RUN`; the author attaches results). The mind calls it by judgment; nothing starts it. It reviews the active workspace (default), the system repository or a `workspace_root` admitted by the `schedule_subagent` folder policy, frozen once and read as §6 Subject operation says; a deterministic call error (incomplete `base..head`, unregistered folder, empty change) is a `TOOL_ARG_ERROR` before any payment. The checklist layer follows the root's `body_fact` (`layer_for`), and `treat_as_body` raises only an `unknown` root. Composition follows the same fact: on the body outside Cyber Pro the owner's saved rows stand and `reviewers` only adds seats (a named subset is recorded `reviewers_subset_ignored`); on the body in Cyber Pro and every other subject `reviewers` is the panel (`composition=composed`; omitted = `full_pool`), `reason` is recorded, and a narrowed panel without one records `reason_missing=true`, not failing. Counted seats of a composed panel are marked pool rows; an enabled row outside the pool is an added critic (`additional`, outside the quorum), and a composition naming no pool seat is a `TOOL_ARG_ERROR`. `commit_reviewed(reviewers=…, reason=…)` composes the commit panel through the same composer (`commit_gate.compose_commit_panel`), refused before staging; the composed seats reach the wave's pool readers as a context-local override (`reviewer_slot_config.composed_review_pool`): the gate's slot readers have no argument path — the one disclosed contextvar seam the plan-review rule excepts (DEVELOPMENT "Loop / State-Machine Changes") — and a concurrent wave keeps the configured pool. `reviewer_effort` orders this wave's strength; `author_questions` reach brief and record verbatim (reuse and rebuttal: §6 Subject operation). Paid waves count against the shared cap per root task and (reviewed repository, `review_change`), apart from the gate's count; exhaustion is typed `review_cycles_exhausted` with a `NOT_DISPATCHED` record and meets only a new paid wave, never an open operation's answer. Enforcement is recorded on every root; only the body under Blocking records `enforcement_blocks=true`, so another root's FAIL is information, never a lock. `commit_reviewed` / `vcs_commit_reviewed` stay the only landing in the system repository: their `root` accepts only `system_repo`, and any other root is a `TOOL_ARG_ERROR` pointing at `review_change` and ordinary git. #### Review ledger record diff --git a/docs/architecture/10-key-invariants.md b/docs/architecture/10-key-invariants.md index 842f0c60f..d783bc76a 100644 --- a/docs/architecture/10-key-invariants.md +++ b/docs/architecture/10-key-invariants.md @@ -47,4 +47,4 @@ A bounded view is an interface projection, never a new authority: the actor that | Task acceptance (three deliveries) | the full host packet (`review_evidence.build_task_acceptance_evidence`, with its `__provenance__` table), the applied host run retained through task source handles, and the paid-identity wallet ledger | the per-delivery work order (`acceptance_retrieving.acceptance_retrieving_work_order`): api pack, full packet with pointers, or packet without its degradable tail plus a scoped read root | exact `evidence_refs` from the packet's exhibit vocabulary; absolute pointers to the workspace and immutable snapshots; `review_projection.panels[].applied_source_ref` | refs resolve against the full packet only, never the rendered projection; native reads are `host_observed`; one strict wallet claim per panel whatever the deliveries | | Canonical versus execution roots | the canonical data root owns identity, authority, biography, results and promoted observability; execution drives own tools, workspace, transient trajectory and per-call manifests while a task runs | project, fork and task lenses and status projections | canonical-root resolver, task-result pointers, source handles | a fork is an execution lens, not a second mind; adoption precedes background retention, which verifies `task_source` bytes and never rewrites a carrier; GC's inventory recheck cannot prove quiescence of untracked late tool futures; missing bytes stay gaps | -Four rules the map relies on. Acceptance identity is fixed before history-dependent packet budgeting; changed sources still invalidate it; `tool_trajectory:[]` addresses original records, not moving-tail positions. Applied reviews and checkpoints are retained write-once (`source_handles/context_checkpoints`) before the compact `write_task_result` projection; `review_projection.publish_acceptance_checkpoint` orders panels under the result lock, and a retention failure is `applied_source_status="unavailable"`. `review_projection.late_notice_receipts` hands the late-notice duty to the existing outbox: custody, not delivery. `terminal_delivery.register_delivery` keeps the first retained receipt; `basis="send_handler_returned"` proves the send seam returned, not that a human read the text; Ouroboros judges late evidence on an admissible turn (§6 Task acceptance). +Four rules the map relies on. Acceptance identity is fixed before history-dependent packet budgeting; changed sources still invalidate it; `tool_trajectory:[]` addresses original records, not moving-tail positions. Applied reviews and checkpoints are retained write-once (`source_handles/context_checkpoints`) before the compact `write_task_result` projection; `review_projection.publish_acceptance_checkpoint` orders panels under the result lock, and a retention failure is `applied_source_status="unavailable"`. `review_projection.late_notice_receipts` hands the late-notice duty to the existing outbox: custody, not delivery. `terminal_delivery.register_delivery` keeps the first retained receipt; `basis="send_handler_returned"` proves the send seam returned, not that a human read it; Ouroboros judges late evidence on an admissible turn (§6 Task acceptance). diff --git a/docs/development/05-review-and-commit-protocol.md b/docs/development/05-review-and-commit-protocol.md index d47c506d4..48cfa71ce 100644 --- a/docs/development/05-review-and-commit-protocol.md +++ b/docs/development/05-review-and-commit-protocol.md @@ -5,7 +5,7 @@ This chapter owns the rules a reviewed commit obeys across its three stages — Finish the edits and focused tests, then call `commit_reviewed`; `preflight_reviewer` (or standalone `preflight_review(reviewer=…)`) buys one named catalog row's earlier look. `commit_reviewed` lands only in the system repository, and any other `root` is a typed `TOOL_ARG_ERROR`; a project copy or another registered root is reviewed with `review_change` — the same wave and one ledger record under that root's checklist layer, nothing committed — and committed there with ordinary git (ARCHITECTURE §6 "Change review on any root"). Enforced by `tests/test_commit_gate.py` and `tests/test_review_change_end_to_end.py`. 1. **Prepared preflight.** Authorization and unresolved-work checks come before file preparation, staging and the fingerprint; free-cycle and budget admission come before any paid look. The free deterministic checks and the independently applicable tests preflight run on every commit; a named row then looks once, inline with the full `review_rebuttal`, and only informs. `skip_advisory_review=True` records `preflight: skipped` and no name records `not_performed`: facts, not audited bypasses. A free replay buys no wave, and an explicit test skip is not green proof (ARCHITECTURE §6 "Commit preflight"). -2. **Review and author response.** A clean supported review keeps its one-call path. A material ordinary Advisory outcome returns before any Git effect; Main may finish unchanged, correct, request another permitted panel or stop. Free continuation passes the returned `review_reference` with `author_disposition` and rationale, binds the current attributed bytes and reruns the independent required checks without another critic. Blocking needs fresh reviewer approval (ARCHITECTURE §6 "Git and commit review"). +2. **Review and author response.** Blocking keeps its one-call PASS. Every Advisory or Cyber Pro outcome, PASS and pending too, returns before any Git effect; Main may finish unchanged, correct, request another permitted panel or stop. Free continuation passes the returned `review_reference` with `author_disposition` and rationale, binds the current attributed bytes and reruns the independent required checks without another critic. Blocking needs fresh reviewer approval (ARCHITECTURE §6 "Git and commit review"). 3. **Publication binding.** The created commit or tag is checked against that same binding before push; a mutation, rebase, conflict resolution or changed landing parent needs a fresh current-candidate binding under the applicable critic or Advisory author authority, with the same independent checks. Keep optional task evidence after the stable governance prefix and shrink it before any existing review material; a source pointer gives a packet-only row no retrieval; removing an ignored view never deletes the canonical source (ARCHITECTURE §6 "Commit review evidence"). diff --git a/docs/inventories/FACADE_INVENTORY.md b/docs/inventories/FACADE_INVENTORY.md index 0e4e1f278..650e5feff 100644 --- a/docs/inventories/FACADE_INVENTORY.md +++ b/docs/inventories/FACADE_INVENTORY.md @@ -2,7 +2,7 @@ AST-derived inventory of compatibility facades, regenerated by `python scripts/regenerate_inventories.py`. Do not edit. A facade row is any runtime module whose top-level `from import ...` statements carry the `noqa: F401` re-export marker — the codebase's declared "this binding exists for its binding, not for this module's own use" convention (reference FACADE_CONSUMERS method). Leaf domains come from `ouroboros/domains.toml`; a leaf outside the facade's domain is marked ✗ (that edge also appears in the manifest's pinned direction matrix). `tests/test_generated_inventories.py` pins byte-identity, so any re-export surface change must regenerate this file. -- facade modules: **66**; marked re-export bindings: **2341**; cross-domain facade→leaf pairs: **131** +- facade modules: **66**; marked re-export bindings: **2342**; cross-domain facade→leaf pairs: **131** | facade | domain | bindings | leaves | |---|---|---:|---| @@ -50,7 +50,7 @@ AST-derived inventory of compatibility facades, regenerated by `python scripts/r | `ouroboros/tools/core.py` | D05 | 78 | `ouroboros/code_search_rg.py` (5)
`ouroboros/contracts/skill_payload_policy.py` (13 ✗D19)
`ouroboros/project_facts.py` (1 ✗D15)
`ouroboros/tool_access.py` (9 ✗D04)
`ouroboros/tools/core_artifacts.py` (18)
`ouroboros/tools/core_file_tools.py` (23)
`ouroboros/tools/core_secret_paths.py` (1)
`ouroboros/tools/registry.py` (3 ✗D04)
`ouroboros/utils.py` (5 ✗D18) | | `ouroboros/tools/delegate.py` | D07 | 64 | `ouroboros/delegate_containment.py` (5)
`ouroboros/delegate_interactions.py` (9)
`ouroboros/delegate_output.py` (14)
`ouroboros/delegate_shared.py` (6)
`ouroboros/delegate_source_coverage.py` (3)
`ouroboros/subagent_runtime.py` (2)
`ouroboros/subagent_work_order.py` (2)
`ouroboros/tools/delegate_integration.py` (14)
`ouroboros/tools/delegate_terminal_evidence.py` (9) | | `ouroboros/tools/delegate_integration.py` | D07 | 10 | `ouroboros/delegate_target_drift.py` (3)
`ouroboros/tools/delegate_payload_patch.py` (7) | -| `ouroboros/tools/git.py` | D10 | 102 | `ouroboros/config.py` (1 ✗D12)
`ouroboros/contracts/skill_payload_policy.py` (2 ✗D19)
`ouroboros/contracts/task_constraint.py` (2 ✗D19)
`ouroboros/platform_layer.py` (2 ✗D18)
`ouroboros/runtime_mode_policy.py` (7 ✗D13)
`ouroboros/tool_access.py` (3 ✗D04)
`ouroboros/tools/commit_gate.py` (17)
`ouroboros/tools/core.py` (3 ✗D05)
`ouroboros/tools/git_evolution.py` (6)
`ouroboros/tools/git_managed_postcommit.py` (4)
`ouroboros/tools/git_plumbing.py` (12)
`ouroboros/tools/git_repo_edit.py` (4)
`ouroboros/tools/git_review_cycle.py` (14)
`ouroboros/tools/git_vcs_ops.py` (10)
`ouroboros/tools/parallel_review.py` (3 ✗D06)
`ouroboros/tools/registry.py` (4 ✗D04)
`ouroboros/tools/review_helpers.py` (3 ✗D06)
`ouroboros/tools/review_revalidation.py` (1)
`ouroboros/utils.py` (4 ✗D18) | +| `ouroboros/tools/git.py` | D10 | 103 | `ouroboros/config.py` (1 ✗D12)
`ouroboros/contracts/skill_payload_policy.py` (2 ✗D19)
`ouroboros/contracts/task_constraint.py` (2 ✗D19)
`ouroboros/platform_layer.py` (2 ✗D18)
`ouroboros/runtime_mode_policy.py` (7 ✗D13)
`ouroboros/tool_access.py` (3 ✗D04)
`ouroboros/tools/commit_gate.py` (18)
`ouroboros/tools/core.py` (3 ✗D05)
`ouroboros/tools/git_evolution.py` (6)
`ouroboros/tools/git_managed_postcommit.py` (4)
`ouroboros/tools/git_plumbing.py` (12)
`ouroboros/tools/git_repo_edit.py` (4)
`ouroboros/tools/git_review_cycle.py` (14)
`ouroboros/tools/git_vcs_ops.py` (10)
`ouroboros/tools/parallel_review.py` (3 ✗D06)
`ouroboros/tools/registry.py` (4 ✗D04)
`ouroboros/tools/review_helpers.py` (3 ✗D06)
`ouroboros/tools/review_revalidation.py` (1)
`ouroboros/utils.py` (4 ✗D18) | | `ouroboros/tools/registry.py` | D04 | 157 | `ouroboros/artifacts.py` (2 ✗D05)
`ouroboros/contracts/skill_payload_policy.py` (11 ✗D19)
`ouroboros/contracts/task_constraint.py` (3 ✗D19)
`ouroboros/git_shell_policy.py` (2 ✗D13)
`ouroboros/process_interpreters.py` (4 ✗D05)
`ouroboros/protected_artifacts.py` (1)
`ouroboros/runtime_mode_policy.py` (4 ✗D13)
`ouroboros/shell_parse.py` (10 ✗D13)
`ouroboros/tool_access.py` (19)
`ouroboros/tool_capabilities.py` (6)
`ouroboros/tools/deliverables_shell.py` (2 ✗D13)
`ouroboros/tools/registry_core.py` (14)
`ouroboros/tools/registry_guard_process.py` (13)
`ouroboros/tools/registry_guards.py` (17)
`ouroboros/tools/shell_guards.py` (14 ✗D13)
`ouroboros/tools/tool_catalog.py` (1)
`ouroboros/tools/tool_context.py` (2)
`ouroboros/tools/tool_resolution.py` (27)
`ouroboros/tools/tool_result.py` (3)
`ouroboros/tools/write_shape.py` (1 ✗D13)
`ouroboros/utils.py` (1 ✗D18) | | `ouroboros/tools/review.py` | D06 | 50 | `ouroboros/llm.py` (1 ✗D02)
`ouroboros/review_substrate.py` (3)
`ouroboros/reviewer_window.py` (2)
`ouroboros/tools/review_admission.py` (2)
`ouroboros/tools/review_helpers.py` (23)
`ouroboros/tools/review_multi_model.py` (8)
`ouroboros/tools/review_response.py` (2)
`ouroboros/tools/review_synthesis.py` (1)
`ouroboros/triad_review.py` (3)
`ouroboros/utils.py` (5 ✗D18) | | `ouroboros/tools/review_helpers.py` | D06 | 71 | `ouroboros/tools/release_sync.py` (1 ✗D10)
`ouroboros/tools/review_checklist.py` (7)
`ouroboros/tools/review_file_pack.py` (26)
`ouroboros/tools/review_prompt_text.py` (34)
`ouroboros/utils.py` (3 ✗D18) | diff --git a/ouroboros/acceptance_late.py b/ouroboros/acceptance_late.py index 122688c89..bd5d7af15 100644 --- a/ouroboros/acceptance_late.py +++ b/ouroboros/acceptance_late.py @@ -6,7 +6,7 @@ owners. This adapter never resumes an author or rebuilds historical evidence. Authority: a late review needs an owner chat, quiz or mailbox ``owner_source`` newer than the frozen answer; the target may sit in any Project. ``amend_cap`` only records an absolute original-root cap (no panel, no claim); ``review`` runs -the full triad over the frozen terminal root even with review mode off. +the full triad over the frozen terminal root unless result review is Off. Bindings: Stop, Panic, review prohibitions, Pauses/budget fences, calendar deadlines, original-root and live global caps with in-flight holds. Hurry and finalize-now block only unstarted automatic work, not a later explicit request; @@ -192,6 +192,9 @@ def run_historical_acceptance(ctx: Any, *, task_id: str, debt_id: str, for p in (row.get('review_operations') or {}).values()) if existing or any(claim.get('paid_identity') == paid for claim in claims.values()): return _collect_existing(ctx, row, retry_key, collect=not automatic) + from ouroboros.config import get_task_review_mode + if get_task_review_mode() == 'off': + return refused('review_mode_off') receipt = _receipt(root, debt) if not receipt: return refused('exact_delivery_unconfirmed') @@ -205,12 +208,6 @@ def run_historical_acceptance(ctx: Any, *, task_id: str, debt_id: str, schema_version=1, automatic=automatic, caller_task_id='' if automatic else str(ctx.task_id), caller_deadline_at='' if automatic else _owner_deadline_at(ctx), owner_source_ref=None if automatic else prepared['owner_source_ref']) - # Fast owner exclusions avoid creating automatic preparation duty for mode - # off. Full frozen/current controls and money are checked by the owner worker. - if automatic: - from ouroboros.config import get_task_review_mode - if get_task_review_mode() == 'off': - return refused('review_mode_off') try: return prepare_historical_operation(root=root, purpose=purpose, event_queue=getattr(ctx, 'event_queue', None), background=automatic, work=lambda operation: _run_historical_acceptance( @@ -400,6 +397,9 @@ def _run_historical_acceptance(ctx: Any, *, task_id: str, debt_id: str, time.sleep(_HOLD_POLL_SEC) # existing owner-hold observation cadence if blocked: return refused(blocked[0]) + from ouroboros.config import get_task_review_mode + if get_task_review_mode() == 'off': + return refused('review_mode_off') # Resume can finish preparation, never override Off. lineage = resolve_task_lineage(task_id, metadata=row.get('metadata'), **{ key: row.get(key) for key in ('root_task_id', 'parent_task_id', 'delegation_role', 'original_task_id', 'timeout_retry_from')}) if not lineage['is_root_task'] or lineage['root_task_id'] != accounting_id: diff --git a/ouroboros/acceptance_material.py b/ouroboros/acceptance_material.py new file mode 100644 index 000000000..5066a7215 --- /dev/null +++ b/ouroboros/acceptance_material.py @@ -0,0 +1,127 @@ +"""Retain produced material before root-result admission, then reuse it in review. + +Inputs, checkpoints and requested outputs do not establish production. These +sources use the existing artifact/source owners; this module neither dispatches +critics nor writes a terminal/quiescent mutation state. A failed capture stays a +gap and never resurrects the previous selection. +""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +def retain_acceptance_material(ctx: Any, trace: dict, content: str | None = None, *, include_outputs: bool = True) -> dict: + from ouroboros.artifacts import ( + collect_task_artifact_records, copy_artifact_file, persist_exact_text_source, + registered_task_artifact, stream_artifact_file, + task_artifact_dir_path, read_actor_source_bytes, + ) + from ouroboros.contracts.task_contract import answer_protocol_active + from ouroboros.outcomes import selected_final_answer + from ouroboros.artifacts import materialize_tool_result_source + from ouroboros.extension_surface_names import parse_extension_surface_name + from ouroboros.review_evidence import _accept_protected_set, _accept_repo_diff + + previous = trace.get("acceptance_material") or {} + material: dict = {"produced": False, "sources": [], "issues": []} + trace["acceptance_material"] = material + root, task_id = getattr(ctx, "drive_root", None), str(getattr(ctx, "task_id", "") or "") + if root is None or not task_id: + return material + + def retain(name: str, text: str) -> dict: + prior = next((row.get("source_ref") for row in previous.get("sources", []) if row.get("name") == name), None) + if prior and read_actor_source_bytes(root, task_id, prior) == text.encode("utf-8"): + material["sources"].append({"name": name, "source_ref": prior}) + return prior + exact, ref, gap = persist_exact_text_source(root, task_id, source_id=name, text=text) + if gap or exact != text: + raise OSError(gap.get("reason") or "material source readback mismatch") + material["sources"].append({"name": name, "source_ref": ref}) + return ref + + if answer_protocol_active(ctx): + try: + text = content if content is not None else str(getattr(getattr(ctx, "_delivery_candidate", None), "full_text", "")) + answer = selected_final_answer(text, trace) + if answer: + ref = retain("selected-final-answer", answer) + material["selected_answer"] = {"text": answer, "source_ref": ref} + material["produced"] = True + except Exception as exc: + material["issues"].append({"status": "source_unavailable", "reason": str(exc), "tool": "selected_answer"}) + if not include_outputs: + return material # Auto without nomination / Off still retain a selected answer. + try: + protected = _accept_protected_set(ctx) + base = task_artifact_dir_path(root, task_id) + for row in collect_task_artifact_records(root, task_id, measure=False, strict=True, require_registered=True): + name = str(row.get("relpath") or row["name"]) + if not registered_task_artifact(root, task_id, name) or not row.get("size"): + continue + if any(name == str(p).lstrip("/") or name.startswith(str(p).strip("/") + "/") + or Path(name).name == Path(str(p)).name for p in protected): + continue # hidden/restricted evidence is never opened for admission + source = Path(row["path"]) + identity = stream_artifact_file(source, expected=row) + rel = "source_handles/context_checkpoints/produced-" + identity["sha256"] + ".bin" + if not (base / rel).exists(): + copy_artifact_file(source, base / rel, expected=identity) + ref = {"kind": "task_source", "root": "artifact_store", "path": rel, **identity} + stream_artifact_file(base / rel, expected=identity) + material["sources"].append({"name": name, "source_ref": ref}) + material["produced"] = True + except Exception as exc: + material["issues"].append({"status": "source_unavailable", "reason": str(exc), "tool": "artifact_manifest"}) + + # The extension producer owns execution semantics (PluginAPI.register_tool). + # Read its exact receipt, never infer effects from a method/backend name. + for call in trace.get("tool_calls") or []: + if not isinstance(call, dict): + continue + surface = parse_extension_surface_name(str(call.get("tool") or "")) + if not surface or call.get("is_error"): + continue + try: + result, complete, _gap = materialize_tool_result_source(root, task_id, call) + if not complete: + material["issues"].append({"status": "source_unavailable", "reason": str(_gap), + "tool": str(call.get("tool") or "")}) + continue + try: + payload = json.loads(result) if isinstance(result, str) else {} + except ValueError: + continue # Plain extension text carries no declared execution fact. + if not isinstance(payload, dict) or payload.get("observed_effect") is not True: + continue + ref = retain("observed-effect-" + str(call.get("tool_call_id") or len(material["sources"])), result) + material.setdefault("observed_effects", []).append({"tool": call["tool"], "source_ref": ref}) + material["produced"] = True # producer execution fact, never goal success + except Exception as exc: + material["issues"].append({"status": "source_unavailable", "reason": str(exc), "tool": str(call.get("tool") or "")}) + + # Only a real task baseline establishes shell-produced changes. An arbitrary + # dirty working tree is evidence for a nominated review, not admission proof. + try: + from ouroboros.mutation_attribution import attributed_git_candidates, _git_path_fingerprints + + getter = getattr(ctx, "active_repo_dir", None) + repo = getter() if callable(getter) else getattr(ctx, "repo_dir", None) + if repo: + accounting = getattr(ctx, "budget_drive_root", None) or root + snapshot = attributed_git_candidates(accounting, task_id, repo) + if snapshot.get("candidates") and not snapshot.get("blockers"): + fingerprints = _git_path_fingerprints(Path(repo), snapshot["candidates"]) + diff, meta = _accept_repo_diff(ctx, include_recent_commit=False, drive_root=root, task_id=task_id) + if meta.get("issue") or fingerprints != _git_path_fingerprints(Path(repo), snapshot["candidates"]): + raise ValueError("workspace material changed or capture unavailable") + ref = retain("produced-workspace", json.dumps({**snapshot, "fingerprints": fingerprints}, sort_keys=True)) + diff_ref = retain("produced-workspace-diff", diff) + material["workspace"] = {"source_ref": ref, "repo_diff": diff, "repo_diff_source_ref": diff_ref, + "repo_diff_capture": meta.get("capture_disclosure") or {}} + material["produced"] = True + except Exception as exc: + material["issues"].append({"status": "source_unavailable", "reason": str(exc), "tool": "repo_diff"}) + return material diff --git a/ouroboros/acceptance_settlement.py b/ouroboros/acceptance_settlement.py index dd6676a1b..d621beb51 100644 --- a/ouroboros/acceptance_settlement.py +++ b/ouroboros/acceptance_settlement.py @@ -8,22 +8,22 @@ Main moves on — so they live together: * the wave wakes Main at its own quorum and again when the last slot settles, and the wake carries each reviewer's own verdict (``announce_acceptance_settlement``); -* final delivery of a text-only rewrite under that panel's feedback buys no - second panel (``_deliver_under_running_panel``), whether feedback returned - ready or pending. Main waits for a pending panel — the +* final delivery rejoins that paid panel (``_deliver_under_running_panel``), + whether feedback returned ready or pending. Main waits for a pending panel — the default, and the only option under blocking enforcement — or consciously - finishes; a panel that PASSED the earlier revision accepts the task and the - owner row says so (fork 1=B), while a changed subject (criteria, material - evidence, owner source) or any other settled verdict hands the - delivery to the ordinary acceptance path with the collected verdicts in its - dialogue history; + finishes where policy permits. A verdict on an earlier answer cannot approve + changed bytes, criteria, material evidence or owner premises. Ordinary + acceptance uses its existing subject-bound admission and reuse path, with the + collected verdicts retained in its dialogue history; * a panel that settles after its task ended is collected at $0, republished on the task's own review projection with the host's own settlement note and a neutral late-evidence fact (which version the reviewers read, what the host proved it emitted, when it settled and the exact source), and announced once in the task's room as one row of that card's Reviews group - (``attach_late_acceptance_settlement``). The fact is evidence for Ouroboros - to judge, never a host decision: no model turn starts here (fork 2=A). A + (``attach_late_acceptance_settlement``), and kept in view in that room's + memory until the mind releases it (``late_review_marks``). The fact is + evidence for Ouroboros to judge, never a host decision: no model turn starts + here (fork 2=A). A worker that no longer holds the trace falls back to the canonical published source, and a controller that died is collected by the existing maintenance pass through the same ``settle_acceptance_operation``; @@ -49,9 +49,9 @@ from ouroboros.tool_access_paths import canonical_data_root as _result_root log = logging.getLogger(__name__) -# The host acceptance reason for "the paid panel approved the answer Main has -# since rewritten": the task is accepted on the reviewers' word about the -# earlier revision and the owner row says so. Not a member of +# Historical reason for approval based on an earlier answer revision. Keep its +# display/reduction vocabulary for stored records; new delivery uses subject-bound +# admission instead. Not a member of # ``outcomes._ACCEPTANCE_BLOCKED_TERMINAL_REASONS``: nothing was refused and no # review cycle was spent. REASON_PREVIOUS_REVISION_ACCEPTED = "previous_revision_accepted" @@ -353,7 +353,7 @@ def announce_acceptance_settlement(usage_ctx: Any, request: Any, wave: Dict[str, mailbox, which is drained before every round and also ends an acceptance park. Once the task is terminal there is nobody to wake: the same verdicts are attached to the task result and announced once in the task's own room, - so the owner sees them and the next turn reads them in chat history. Never a + so the owner sees them and that room's next turn keeps them in view. Never a model turn, never a second paid dispatch. Runs on the settlement thread, outside custody locks. """ @@ -379,7 +379,8 @@ def announce_acceptance_settlement(usage_ctx: Any, request: Any, wave: Dict[str, from ouroboros.owner_mailbox import write_task_message trace = _settlement_trace(usage_ctx, str(getattr(request, "retry_key", "") or "")) source = next(({"task_id": task_id, "run_index": index, - "binding_hash": str(run.get("binding_hash") or "")} + "binding_hash": str(run.get("binding_hash") or ""), + "settled": bool(wave.get("slots")) and all((wave.get("slots") or {}).values())} for index, run in enumerate((trace or {}).get("review_runs") or []) if run.get("authority") == "host_root" and (run.get("request") or {}).get("retry_key") == request.retry_key), None) @@ -432,60 +433,64 @@ def awaited_panel_has_settled(tools_ctx: Any, llm_trace: Dict[str, Any]) -> bool def acceptance_choice_offered() -> bool: - """Whether the host can honour a wait/finish choice on this install. + """Effective nonblocking review permits an explicit early finish, Cyber included.""" + from ouroboros.config import get_review_enforcement + from ouroboros.tools.review_helpers import review_enforcement_blocks - Blocking enforcement always waits; Cyber Pro never does (Main's final - response is its decision there, unchanged). Only advisory enforcement on an - ordinary runtime mode leaves the choice to Main, so only there is it offered. - """ - from ouroboros.config import get_review_enforcement, get_runtime_mode - from ouroboros.runtime_mode_policy import runtime_mode_at_least - - return (get_review_enforcement() != "blocking" - and not runtime_mode_at_least(get_runtime_mode(), "cyber_pro")) + return not review_enforcement_blocks(get_review_enforcement()) def acceptance_wait_chosen(tools_ctx: Any) -> bool: """Waiting is the default; only an explicit, current ``finish`` releases an answer over a panel that is still running where the install lets Main choose.""" - from ouroboros.config import get_review_enforcement, get_runtime_mode - from ouroboros.runtime_mode_policy import runtime_mode_at_least + return (not acceptance_choice_offered() + or str(getattr(tools_ctx, "_acceptance_pending_review_choice", "") or "") != "finish") - if runtime_mode_at_least(get_runtime_mode(), "cyber_pro"): + +def continue_acceptance_wait(tools_ctx: Any, trace: dict, messages: list, task_id: str) -> bool: + """Collect paid work before preparation, preserving identity through owner mail. + Arrival is not exposure; offer settled feedback before a new author decision. + Stop remains independent. + """ + from ouroboros.loop_acceptance_review import acceptance_run_pending, _offer_acceptance_feedback, acceptance_feedback_text + from ouroboros.review_dispatch import reconcile_pending_acceptance_runs + + if not any(isinstance(run, dict) and run.get("authority") == "host_root" for run in trace.get("review_runs") or []): return False - if get_review_enforcement() == "blocking": + reconcile_pending_acceptance_runs(trace, drive_root=tools_ctx.drive_root, usage_ctx=tools_ctx) + run = panel_awaiting_this_turn(tools_ctx, trace) + if (not run or not acceptance_wait_chosen(tools_ctx) + or (getattr(tools_ctx, "_completion_selected", None) or {}).get("action") == "stop"): + return False + if acceptance_run_pending(run): return True - return str(getattr(tools_ctx, "_acceptance_pending_review_choice", "") or "") != "finish" + if not run.get("settled_feedback_delivered"): + from ouroboros.loop_acceptance import _end_task_acceptance_fence + _end_task_acceptance_fence(tools_ctx, outcome="revision") + context = SimpleNamespace(llm_trace=trace, messages=messages, task_id=task_id, + review_binding={"binding_hash": run.get("binding_hash")}) + _offer_acceptance_feedback(context, acceptance_feedback_text(SimpleNamespace(**run))) + return True + return False def _deliver_under_running_panel(ctx: Any, prior_run: Any) -> Optional[bool]: - """A DELIVERY is not a nomination: it neither buys a panel nor is refused one. - - The paid panel keeps its identity whether it returned ready or pending. - While it runs, Main waits (the default and the only option under blocking - enforcement) or consciously finishes. Once it has settled on the earlier - revision: a PASS accepts the task on the reviewers' word and the owner row - says so (fork 1=B); any other verdict is not a verdict on this answer, so the - ordinary path decides — a new panel while the review cap allows, otherwise - its typed capacity refusal — with the collected verdicts already in its - dialogue history. ``None`` means "not this case": a changed subject is one - (the ordinary path reviews it), and while the panel is still running a - text-only rewrite buys a NEW panel only when Main nominates it again - (``_acceptance_review_only``). - """ - from ouroboros import loop + """Rejoin paid work for an informed author selection. A rewrite retains the + earlier verdict's identity, never authority over new material/premises.""" from ouroboros.loop_acceptance_review import ( - _end_acceptance_terminal, _finish_cyber_acceptance, - _set_applied_host_acceptance_impact, acceptance_run_pending, + _finish_cyber_acceptance, acceptance_run_pending, ) from ouroboros.loop_delivery import delivery_subject_hash from ouroboros.loop_messages import owner_source_sha256 - from ouroboros.outcomes import ACCEPTANCE_ACCEPTED tools_ctx = ctx.tools._ctx - if prior_run is not None or getattr(tools_ctx, "_acceptance_review_only", False): + if getattr(tools_ctx, "_acceptance_review_only", False): return None run = panel_awaiting_this_turn(tools_ctx, ctx.llm_trace) + if run is not None and acceptance_run_pending(run): + return True if acceptance_wait_chosen(tools_ctx) else _finish_cyber_acceptance(ctx, SimpleNamespace(**run)) + if prior_run is not None: + return None if run is None: # Initial release may return a completed panel. Its delivered feedback # belongs to this turn without pretending the operation is still pending. @@ -505,48 +510,21 @@ def _deliver_under_running_panel(ctx: Any, prior_run: Any) -> Optional[bool]: if reviewed_source and reviewed_source != str(owner_source_sha256(tools_ctx) or ""): tools_ctx._task_acceptance_pending = "" return None - if acceptance_run_pending(run): - if acceptance_wait_chosen(tools_ctx): - ctx.emit_progress("Task acceptance review is still running; holding the answer for its verdict.") - return True - return _finish_cyber_acceptance(ctx, SimpleNamespace(**run)) tools_ctx._task_acceptance_pending = "" - if str(run.get("aggregate_signal") or "").upper() != "PASS": + if not run.get("settled_feedback_delivered") or not (getattr(tools_ctx, "_completion_selected", None) or {}).get("action") == "finish": return None result = SimpleNamespace(**run) - _set_applied_host_acceptance_impact(run, result, requires_revision=False) - ctx.llm_trace.setdefault("review_decision", {}).update({ - "panel_id": str(run.get("panel_id") or ""), - "binding_hash": str(run.get("binding_hash") or ""), - }) - _end_acceptance_terminal(ctx, "pass") - loop._set_acceptance_decision(ctx.llm_trace, { - "status": ACCEPTANCE_ACCEPTED, - "reason": REASON_PREVIOUS_REVISION_ACCEPTED, - "source": "task_acceptance_review", - "reviewer_signal": "PASS", - "reviewed_panel_id": str(run.get("panel_id") or ""), - "reviewed_candidate_hash": str(run.get("candidate_hash") or ""), - }) - ctx.emit_progress( - "Task acceptance review: PASS on the earlier revision of this answer; accepted on the " - "reviewers' word (the rewrite itself was not re-reviewed)." - ) - return False + from ouroboros.tools.review_helpers import review_enforcement_blocks + if not review_enforcement_blocks("blocking"): + return _finish_cyber_acceptance(ctx, result) + return None # Ordinary admission/reuse alone can approve the current subject. def forced_rail_panel_verdict(tools_ctx: Any, llm_trace: Dict[str, Any], rail_reason: str) -> Dict[str, Any]: - """What a forced rail may honestly record about the panel its turn owns. - - Every rail bypass reason says "the answer was never reviewed", so one may - be stamped only when no panel ran. The turn's own panel is collected at $0 - first — the same free collection delivery performs — and then speaks for - itself: a clean PASS on the SAME subject accepts the answer on the - reviewers' word; reviewers who had not answered leave it unaccepted with - ``review_pending``, because an answer that has not arrived is a gap and - never a verdict; any other settled outcome leaves it unaccepted with no - verdict established. Returns the decision fields the recorder merges — the - existing acceptance vocabulary only, no reason is minted here. + """Collect the turn's panel at $0 before recording a forced rail. Only no + panel permits an unreviewed bypass. Clean PASS needs the same subject; + pending work stays unaccepted with review_pending, other outcomes unaccepted + without a verdict. Return existing decision fields, never a new reason. """ from ouroboros.loop_acceptance_review import acceptance_run_pending from ouroboros.loop_delivery import delivery_subject_hash @@ -580,13 +558,8 @@ def forced_rail_panel_verdict(tools_ctx: Any, llm_trace: Dict[str, Any], rail_re def _unsettled_clause(run: Dict[str, Any]) -> str: - """The verdict clause of a panel that settled without a PASS or FAIL. - - It states what the host holds (no settled verdict), not what the panel did: - DEGRADED can be a reviewer's own deliberate answer. A reviewer whose PHYSICAL - outcome the host does not know — custody lost, or a dispatch still owed — - did not stay silent, so the row names it as unknown. - """ + """No settled verdict: DEGRADED may be deliberate. Lost custody or an owed + dispatch means unknown physical outcome, never reviewer silence.""" unknown = sum(1 for actor in (run.get("actors") or []) if isinstance(actor, dict) and (bool(actor.get("late_result_pending")) @@ -807,8 +780,9 @@ def attach_late_acceptance_settlement(usage_ctx: Any, request: Any, wave: Dict[s outbox (durably owed, keyed by ``delivery_id``; a second settlement of the same wave, concurrent or later, finds that notice already owed or delivered and queues no second live copy). The owner - sees it; the next turn reads it in chat history, and one bounded reflection - row carries the settled verdict to the learning log (plan_review_facts). + sees it; the next turn of that room keeps it in view until the mind releases + it, and one bounded reflection row carries the settled verdict to the + learning log (plan_review_facts). """ return settle_acceptance_operation( usage_ctx, retry_key=str(getattr(request, "retry_key", "") or ""), @@ -946,9 +920,30 @@ def enqueue_late_acceptance_settlement(usage_ctx: Any, task_id: str, retry_key: _LATE_UNPUBLISHED.discard((str(task_id), str(retry_key))) if outcome == ENQUEUE_QUEUED: # a NEW announcement, whichever path made it: one learning row, never on a replay learn_from_late_settlement(root, result, retry_key) + if _keep_late_review_in_view(root, task_id, delivery_id, chat_id, panel) == "failed": + return _unpublished(task_id, retry_key, "unpublished") # the mark is owed like the notice; its repeat is $0 return "announced" if outcome == ENQUEUE_QUEUED else "published" +def _keep_late_review_in_view(root: Any, task_id: str, delivery_id: str, chat_id: Any, panel: Dict[str, Any]) -> str: + """The published verdict stays in view in the task's own room until the mind releases it (#1738). + + Every settler of the wave reaches this with the same stored panel, so the mark is one per + notice: a repeat is ``unchanged`` and a released mark stays released (``late_review_marks``). + """ + from ouroboros.late_review_marks import publish_late_review + + late = panel["late_settlement"] + reviewed = late.get("reviewed_subject") if isinstance(late.get("reviewed_subject"), dict) else {} + return publish_late_review(root, { + "surface": "task_acceptance", "task_id": task_id, "publication_id": delivery_id, "chat_id": chat_id, + "lines": str(late.get("note") or "").splitlines(), "settled_at": str(late.get("settled_at") or ""), + "subject": {"panel_id": str(reviewed.get("panel_id") or ""), + "subject_sha256": str(reviewed.get("subject_sha256") or ""), + "reviewed_revision": str(late.get("reviewed_revision") or "unknown")}, + "source_ref": panel.get("applied_source_ref") or {}}).status + + def _unpublished(task_id: str, retry_key: str, status: str) -> str: with _LATE_LOCK: _LATE_UNPUBLISHED.add((str(task_id), str(retry_key))) @@ -996,3 +991,5 @@ def expose_acceptance_feedback(trace: Dict[str, Any], messages: list, task_id: s if (isinstance(run, dict) and run.get("authority") == "host_root" and str(run.get("binding_hash") or "") == source.get("binding_hash", "")): run["feedback_delivered"] = True + if source.get("settled"): + run["settled_feedback_delivered"] = True diff --git a/ouroboros/chronicle_store.py b/ouroboros/chronicle_store.py index cf891b77b..fed3509ed 100644 --- a/ouroboros/chronicle_store.py +++ b/ouroboros/chronicle_store.py @@ -101,7 +101,7 @@ class PublishResult(NamedTuple): """Outcome of one publication; ``reason`` is ``saved``, ``unchanged`` or a typed refusal. Refusals: ``revision_required``, ``revision_conflict``, ``already_sealed``, - ``already_folded``, ``quote_mismatch``, ``target_missing``, ``invalid``. A + ``already_folded``, ``quote_mismatch``, ``target_missing``, ``not_integrator``, ``invalid``. A refusal names ids (``conflict_ids``), the current revision or room head, and a short ``detail``; it never carries record text. """ @@ -147,6 +147,18 @@ def source_time_span(timestamps: Iterable[Any], *, incomplete: bool = False) -> "incomplete": bool(incomplete or not known or len(known) != len(parsed))} +# A host-signed mark that keeps late reviewer feedback in view in its room (``late_review_marks``). The +# feedback waits for the integrating mind: a delegated child or nanny is a helper, so only another +# focus releases it, with a reason (``_rule_mark_release``); a first show, a page seal or a read never does. +LATE_REVIEW_OPERATION = "late_review" +DELEGATED_FOCUS_ROLES = frozenset({"child", "nanny"}) + + +def is_late_review_mark(mark: Any) -> bool: + author = mark.get("author") if isinstance(mark, Mapping) else None + return isinstance(author, Mapping) and author.get("kind") == "host" and author.get("operation") == LATE_REVIEW_OPERATION + + def _author_problem(author: Any) -> str: if not isinstance(author, dict) or author.get("kind") not in AUTHOR_KINDS: return "author.kind is one of " + ", ".join(sorted(AUTHOR_KINDS)) @@ -775,10 +787,17 @@ class ChronicleStore: return None def _rule_mark_release(self, db, record): - if db.execute("SELECT 1 FROM active_marks WHERE id=?", (record.get("target_id", ""),)).fetchone() is None: + active = db.execute("SELECT body FROM active_marks WHERE id=?", (record.get("target_id", ""),)).fetchone() + if active is None: return _refuse("target_missing", "no active mark has this id", conflict_ids=(str(record.get("target_id")),)) if not _nonblank(record.get("reason")): return _refuse("invalid", "a mark release needs an explicit reason") + author = record["author"] + if is_late_review_mark(json.loads(active[0])) and ( + author["kind"] != "mind" or author["focus"]["role"] in DELEGATED_FOCUS_ROLES): + return _refuse("not_integrator", "late review feedback the host keeps in view is released by the " + "integrating mind with its reason; a delegated helper reports it instead", + conflict_ids=(str(record["target_id"]),)) return None def _head(self, db, room) -> int: diff --git a/ouroboros/contracts/plugin_api.py b/ouroboros/contracts/plugin_api.py index 438f77aa6..e510f8f04 100644 --- a/ouroboros/contracts/plugin_api.py +++ b/ouroboros/contracts/plugin_api.py @@ -344,6 +344,10 @@ class PluginAPI(Protocol): ``name`` is alphanumeric/underscore and <=24 chars. Handlers may be sync or async; async handlers run on a helper-thread event loop with timeout. + A JSON result may carry ``observed_effect: true`` only after the producer + observed an executed external effect. Proposals, reads and unconfirmed + requests omit it. The host retains the exact receipt for result review; + this fact proves neither goal success nor permission for another action. """ ... diff --git a/ouroboros/domains.toml b/ouroboros/domains.toml index bf608efe7..717bac590 100644 --- a/ouroboros/domains.toml +++ b/ouroboros/domains.toml @@ -103,6 +103,7 @@ D20 = "Presence" "ouroboros/acceptance_late.py" = "D01" "ouroboros/acceptance_retrieving.py" = "D01" "ouroboros/acceptance_settlement.py" = "D01" +"ouroboros/acceptance_material.py" = "D06" "ouroboros/agent.py" = "D01" "ouroboros/focus.py" = "D01" "ouroboros/agent_dispatch.py" = "D01" @@ -150,6 +151,7 @@ D20 = "Presence" "ouroboros/chat_chain.py" = "D15" "ouroboros/chronicle_store.py" = "D15" "ouroboros/chronicle_import.py" = "D15" +"ouroboros/late_review_marks.py" = "D15" # late reviewer feedback kept in view as a host mark in its owning room "ouroboros/consolidator.py" = "D15" "ouroboros/knowledge.py" = "D15" "ouroboros/markdown_source.py" = "D18" @@ -608,6 +610,7 @@ D20 = "Presence" "ouroboros/tools/memory_tools.py" = "D15" "ouroboros/tools/parallel_review.py" = "D06" "ouroboros/tools/plan_ask.py" = "D06" +"ouroboros/tools/plan_delivery.py" = "D06" "ouroboros/tools/plan_dialogue.py" = "D06" "ouroboros/tools/plan_evidence.py" = "D06" "ouroboros/tools/plan_packet.py" = "D06" diff --git a/ouroboros/extension_loader.py b/ouroboros/extension_loader.py index 82982edc3..b124ed658 100644 --- a/ouroboros/extension_loader.py +++ b/ouroboros/extension_loader.py @@ -609,7 +609,7 @@ def load_extension( gate = runtime_state.get("review_gate") or skill.review.gate_for(current_hash) if not gate.get("executable_review", False): return ( - f"skill {skill.name!r} must carry a fresh executable review " + f"skill {skill.name!r} has no current executable review authority " f"(status={skill.review.status!r}, " f"stale={skill.review.is_stale_for(current_hash)}, " f"reason={gate.get('blocking_reason')})" diff --git a/ouroboros/gateway/decision_contracts.py b/ouroboros/gateway/decision_contracts.py index 76a97edc2..b3cd73f5d 100644 --- a/ouroboros/gateway/decision_contracts.py +++ b/ouroboros/gateway/decision_contracts.py @@ -60,6 +60,11 @@ class DecisionResponse(TypedDict, total=False): applied_request_id. wait carries the current projection and revision; saved says whether the optional permanent role update landed, with null reserved for the existing settings-writer timeout's unknown outcome. + Applied, saved and a refusal or failure stay separate facts: an error after + the claim still reports the claim's current row and receipt. ``applied:false`` + is no observed application yet, not proof that a pending action cannot apply. The role saves + only while the task's stop state reads clear — a recorded Stop intent (Wrap + up included) or Panic saves nothing, nor does an unreadable (unknown) state. """ ok: bool diff --git a/ouroboros/gateway/host_service.py b/ouroboros/gateway/host_service.py index 940595cf1..57e944a26 100644 --- a/ouroboros/gateway/host_service.py +++ b/ouroboros/gateway/host_service.py @@ -307,7 +307,7 @@ class HostServiceContext: if loaded is None: raise HostServiceAuthError(f"skill {skill_name!r} is not installed") if not loaded.review.gate_for(loaded.content_hash)["executable_review"]: - raise HostServiceAuthError(f"skill {skill_name!r} does not have a fresh executable review") + raise HostServiceAuthError(f"skill {skill_name!r} has no current executable review authority") if not load_enabled(self.data_dir, skill_name): raise HostServiceAuthError(f"skill {skill_name!r} is disabled") if str(token_payload.get("content_hash") or "") != str(loaded.content_hash or ""): diff --git a/ouroboros/gateway/task_model_wait.py b/ouroboros/gateway/task_model_wait.py index dc1e481a9..387a431bc 100644 --- a/ouroboros/gateway/task_model_wait.py +++ b/ouroboros/gateway/task_model_wait.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio import copy import json +import pathlib from contextlib import nullcontext from typing import Any @@ -122,6 +123,7 @@ def _decide(root: Any, body: dict) -> JSONResponse: mailbox_attempted = False owner = None operation_wait = False + attempt = 0 def phase_owner(): from ouroboros.post_task_checkpoint import post_task_model_wait @@ -163,6 +165,40 @@ def _decide(root: Any, body: dict) -> JSONResponse: return None row = mutate(wait_id, discard) + + def receipt(current): + """This exact claim applied by its own controller: request, wait owner and attempt.""" + return bool(action and current.get("applied_request_id") == action["request_id"] + and current.get("task_attempt") == attempt + and str(current.get("model_wait_owner_id") or "") == str(getattr(owner, "owner_id", "") or "")) + + def claim_as_it_is(): + """After any discard, the claim's current row answers whether its controller applied it.""" + nonlocal row + discard_definitely_unapplied() + if claimed: + try: + row = mutate(wait_id, lambda previous: None) + except Exception: + pass # the last observed row stays the answer + return claimed and receipt(row) + + def stop_clear(): + """Whether the owner's separately confirmed Settings save may still land. A reviewer's wait follows + its operation's own stop state: Wrap up lets the reviewer finish and keeps this save (owner choice, + 2026-10-14); Stop now, Panic, a cancelled result or an unreadable state withhold it. Any other wait + keeps it only while no Stop intent at all (Wrap up included) is recorded.""" + from ouroboros.cancel_intents import cancel_pending, resolve_owner_stop_intent + from ouroboros.review_operation import _operation_stop_state + + try: + stopped = _operation_stop_state(pathlib.Path(root), task_id) + if operation_wait: + return not stopped + return not (stopped or cancel_pending(root, task_id, strict=True) + or resolve_owner_stop_intent(root, task_id)[1]) + except Exception: + return False try: task_id, wait_id, action = _action(body) from ouroboros.review_operation import names_review_operation @@ -211,9 +247,27 @@ def _decide(root: Any, body: dict) -> JSONResponse: from ouroboros.model_slots import apply_model_role_override, model_role_setting_keys if row.get("saved_request_id") != action["request_id"]: + def save_allowed(): + nonlocal row + try: + live_task() + except WaitDecisionRefused: + if not getattr(owner, "consumes_pending_action", False): + raise + # A controller consuming the claim itself may apply it while this save + # waits for the lock; its row then stops waiting and it reads closed, + # so its own receipt stands in for liveness. + current = mutate(wait_id, lambda previous: None) + if not receipt(current): + raise WaitDecisionRefused("task_not_live", current) from None + row = current + # One stop rule for a live and an applied claim (``stop_clear``). + return stop_clear() + def transform(settings): nonlocal transformation_completed - live_task() + if not save_allowed(): + return None # the decision stands; Settings are not written value = apply_model_role_override( settings, role=row["role"], model=action["model"], credential_profile_id=action["credential_profile_id"], use_local=action["use_local"]) @@ -223,8 +277,9 @@ def _decide(root: Any, body: dict) -> JSONResponse: with settings_document_mutation(): # a chosen model is authored, the shown default included _owner_update_settings(transform, boundary=boundary, authored_keys=model_role_setting_keys(row["role"])) - row = mutate(wait_id, - lambda previous: {**previous, "saved_request_id": action["request_id"]}) + if transformation_completed: + row = mutate(wait_id, + lambda previous: {**previous, "saved_request_id": action["request_id"]}) # Re-check after the optional settings write. A persistent owner # choice may have landed even when cancellation now fences the task. with owner.lock if owner is not None else nullcontext(): @@ -233,9 +288,15 @@ def _decide(root: Any, body: dict) -> JSONResponse: # mailbox is not its ingress): its controller already applied it, or # it is still pending before that exact live controller. row = mutate(wait_id, lambda previous: None) - applied = row.get("applied_request_id") == action["request_id"] + applied = receipt(row) if not applied and (row.get("pending_action") != action or owner.closed): - raise WaitDecisionRefused("task_not_live", row) + # A remote controller closes by consuming this very claim + # between the two reads: its durable receipt, read after the + # closed observation, answers; any other closure refuses. + row = mutate(wait_id, lambda previous: None) + applied = receipt(row) + if not applied: + raise WaitDecisionRefused("task_not_live", row) else: task = live_task() control = {**action, "wait_id": wait_id, "task_attempt": attempt} @@ -248,13 +309,15 @@ def _decide(root: Any, body: dict) -> JSONResponse: "saved": saved()}, status_code=200 if duplicate else 202) except WaitDecisionRefused as error: - discard_definitely_unapplied() + # Applied, saved and the refusal are separate facts; after a claim its row is read as it now is. + applied = claim_as_it_is() + current = row if claimed else error.row return json_error(error.reason, error.status, ok=False, reason_code=error.reason, - state=error.row.get("state", ""), wait=error.row, saved=saved()) + state=current.get("state", ""), wait=current, applied=applied, saved=saved()) except Exception as error: - discard_definitely_unapplied() + applied = claim_as_it_is() return json_error(str(error), 503, ok=False, reason_code="model_wait_decision_failed", wait=row, - saved=saved()) + applied=applied, saved=saved()) async def answer_model_wait_decision(root: Any, body: dict) -> tuple[int, dict]: diff --git a/ouroboros/late_review_marks.py b/ouroboros/late_review_marks.py new file mode 100644 index 000000000..2eb0821ba --- /dev/null +++ b/ouroboros/late_review_marks.py @@ -0,0 +1,295 @@ +"""Late reviewer feedback the host keeps in view in its owning room until the mind releases it. + +A paid review that settles after its author can no longer receive it is advice for Ouroboros, +never a decision, a veto or a wake (owner decision on late review delivery, 2026-10-11, #1738): +it waits in the next ordinary turn of the room the work came from, and consciousness reads it +when that room stays quiet. Its record writer (a late acceptance settlement, a plan wave's +historical supplement, a left change or commit review) persists the full source first, then +calls ``publish_late_review`` once per settled publication with immutable facts: no live +context, no clock. + +The publication becomes ONE chronicle ``mark`` signed by the host (``LATE_REVIEW_OPERATION``) +in the room the memory view reads for that work: the room ``dialogue_evidence.own_room_chat`` +proves (a project binding, the chat, its ancestors) when it is Main, a registered Project room +or the conversation of Presence-bound work. Another transport chat, the hidden partition or no +chat is never guessed into Main: no mark is placed (``not_activated``) and the full source stays +retained. The mark id is the publication's, so a repeat is ``unchanged``, a released mark stays +released (the journal replays the release after index loss) and the same id with other facts is +a ``conflict`` that writes nothing. The text is the writer's own words, each line and the whole +bounded with a disclosed omission (``utils.truncate_within_limit``); the target is the retained +source itself, read whole from any focus with ``memory_read(source_ref=…)`` unless the writer +names another reader (``source_read``). + +``memory_view`` shows it in its own section to that room's view and to consciousness; only the +integrating mind releases it, with a reason (``chronicle_store._rule_mark_release``). Showing it, +a tool read or a mailbox acknowledgement of a notice is not that release. + +A publication that could not be placed (``failed``) and every duty only the end of its author's +task can discharge stay owed as owner-held rows of the existing obligations table (``OWED_SET``, +immutable facts): a record a running author may still read itself (``owe_late_review``), and a +producer's own duty (``owe_after_end``: an ended plan author's still-open wave, a change or commit +wave its author left), which the producer turns into marks once the task's canonical terminal +status is recorded. The off-loop maintenance pass replays those rows only +(``replay_owed_late_reviews``), never history; a placed, unchanged or released mark discharges +the row, so a replay can neither duplicate nor resurrect it. +""" +from __future__ import annotations + +import hashlib +import json +import logging +import pathlib +import sqlite3 +from types import SimpleNamespace +from typing import Any, Dict, Mapping, NamedTuple, Optional + +from ouroboros.chronicle_store import LATE_REVIEW_OPERATION, ChronicleStore +from ouroboros.contracts.chat_id_policy import HIDDEN_CHAT_ID, WEB_UI_CHAT_ID, is_a2a_chat_id +from ouroboros.utils import truncate_within_limit + +log = logging.getLogger(__name__) + +# The review a mark names, by its writer's surface; another surface is named as recorded. +SURFACE_NAMES = {"task_acceptance": "result review", "plan_review": "plan review", "commit_gate": "commit review", + "multi_model_review": "change review", "change": "change review"} +LINE_LIMIT = 800 # one line of the writer's words +TEXT_LIMIT = 4000 # the whole mark: the view never degrades a mark, so the bound is set here +OWED_SET = "late_review_marks" # owner-held rows: id -> {record | plan_wave | left_review, after_end?} + + +class LateReviewMark(NamedTuple): + """``saved``, ``unchanged``, ``not_activated`` (the owning room is not proven), ``memory_not_activated``, + ``conflict``, ``invalid``, ``owed`` (kept for after its author ended) or ``failed`` (an I/O or lock + failure: the publication stays owed).""" + status: str + mark_id: str = "" + room_id: str = "" + detail: str = "" + + +def late_review_mark_id(surface: str, task_id: str, publication_id: str) -> str: + digest = hashlib.sha256("\0".join((surface, task_id, publication_id)).encode("utf-8")).hexdigest() + return f"late-review-{digest[:32]}" + + +def owning_room(drive_root: Any, task_id: str, chat_id: Any) -> Optional[str]: + """The room the view reads for this work when its ownership is proven, else ``None`` (never a guessed Main).""" + from ouroboros.dialogue_evidence import own_room_chat, task_room_record # D15->D06 is lazy-only + from ouroboros.dialogue_provenance import presence_record_binding + from ouroboros.memory_inventory import membership_facts + + try: + own = own_room_chat(SimpleNamespace(task_id=task_id, task_metadata={}, current_chat_id=chat_id), drive_root) + except (TypeError, ValueError): + return None + if own is None: + return None + if own == WEB_UI_CHAT_ID or own in membership_facts(drive_root).project_chat_ids: + return str(own) + presence = (own != HIDDEN_CHAT_ID and not is_a2a_chat_id(own) + and presence_record_binding(task_room_record(drive_root, task_id))) + return str(own) if presence else None + + +def _facts(record: Any) -> Dict[str, Any]: + """The record by value, checked: what the writer must name before anything is read or written.""" + if not isinstance(record, Mapping): + raise ValueError("a late review record is a mapping of immutable facts") + facts = json.loads(json.dumps(dict(record), ensure_ascii=False, sort_keys=True, default=str)) + for key in ("surface", "task_id", "publication_id"): + if not isinstance(facts.get(key), str) or not facts[key].strip(): + raise ValueError(f"{key} is a non-empty string") + lines = facts.get("lines") + if not isinstance(lines, list) or not all(isinstance(line, str) for line in lines) or not "".join(lines).strip(): + raise ValueError("lines are the record writer's own settled words, at least one non-empty line") + ref, read = facts.get("source_ref"), facts.get("source_read") + if not isinstance(ref, dict) or not ref.get("sha256") or not ref.get("path"): + raise ValueError("source_ref is the retained full source, with its path and sha256") + if read is None and ref.get("kind") != "task_source": + raise ValueError("a source other than a task source names its reader in source_read") + if read is not None and not (isinstance(read, dict) and read.get("tool")): + raise ValueError("source_read is a reader call: {tool, arguments}") + subject = facts.get("subject") or {} + if not isinstance(subject, dict) or any(isinstance(value, (dict, list)) for value in subject.values()): + raise ValueError("subject maps names to scalar identity values") + return {**facts, "subject": subject, "settled_at": str(facts.get("settled_at") or "")} + + +def _text(facts: Mapping[str, Any]) -> str: + name = SURFACE_NAMES.get(facts["surface"], facts["surface"]) + settled = f", settled {facts['settled_at']}" if facts["settled_at"] else "" + head = [f"Late {name} of task {facts['task_id']}{settled}."] + subject = "; ".join(f"{key}={value}" for key, value in sorted(facts["subject"].items()) if value not in (None, "")) + if subject: + head.append(f"Reviewed: {subject}") + body = [truncate_within_limit(line, LINE_LIMIT) for line in facts["lines"]] + return truncate_within_limit("\n".join(head + body), TEXT_LIMIT) + + +def _mark(mark_id: str, room: str, facts: Mapping[str, Any]) -> Dict[str, Any]: + ref = facts["source_ref"] + target = {**ref, "task_id": str(ref.get("task_id") or facts["task_id"])} # readable from any focus + return {"id": mark_id, "kind": "mark", "room_id": room, "target_ref": target, "text": _text(facts), + "author": {"kind": "host", "operation": LATE_REVIEW_OPERATION, "surface": facts["surface"], + "task_id": facts["task_id"]}, + "scope": "room", "quote": None, "visibility": "full", + "review": {"surface": facts["surface"], "task_id": facts["task_id"], + "publication_id": facts["publication_id"], "settled_at": facts["settled_at"], + "subject": facts["subject"], "source_read": facts.get("source_read")}} + + +def publish_late_review(drive_root: Any, record: Mapping[str, Any]) -> LateReviewMark: + """Keep one settled late review in view in its owning room (module docstring); never raises, never wakes. + + ``record``: ``surface``, ``task_id``, ``publication_id`` (one settled publication: the same id always + names the same facts), ``chat_id`` (the owning chat, by value), ``lines`` (the writer's settled words: + its verdict clause, then one line per seat of the whole roster, pending and unknown seats included), + ``subject`` (scalar identity of what the reviewers read), ``source_ref`` (the retained full source), + optional ``source_read`` and ``settled_at``. + """ + try: + facts = _facts(record) + except ValueError as exc: + return LateReviewMark("invalid", detail=str(exc)) + mark_id = late_review_mark_id(facts["surface"], facts["task_id"], facts["publication_id"]) + try: + outcome = _publish(pathlib.Path(drive_root), mark_id, facts) + except Exception as exc: # a store that cannot serve (StoreError, …): the publication stays owed + outcome = LateReviewMark("failed", mark_id, "", f"{type(exc).__name__}: {exc}") + if outcome.status not in {"saved", "unchanged"}: + log.warning("late review %s of task %s kept no mark in view: %s (%s)", facts["publication_id"], + facts["task_id"], outcome.status, outcome.detail) + _owed(drive_root, mark_id, {"record": facts} if outcome.status == "failed" else None) + return outcome + + +def owe_late_review(drive_root: Any, record: Mapping[str, Any]) -> LateReviewMark: + """Keep one late review owed until its author's task ended, then publish it (``replay_owed_late_reviews``). + + For feedback a running author may still read itself: reading it is not a release, so the mark + follows the task's end whatever the author read. An already ended task publishes now. + """ + try: + facts = _facts(record) + except ValueError as exc: + return LateReviewMark("invalid", detail=str(exc)) + mark_id = late_review_mark_id(facts["surface"], facts["task_id"], facts["publication_id"]) + if _task_ended(drive_root, facts["task_id"]): + return publish_late_review(drive_root, facts) + return LateReviewMark("owed" if _owed(drive_root, mark_id, {"record": facts, "after_end": True}) else "failed", + mark_id) + + +def owe_after_end(drive_root: Any, duty_id: str, duty: Mapping[str, Any]) -> bool: + """Persist one producer duty (``plan_wave`` or ``left_review``: immutable facts naming ``task_id``) that + only its author's ended task can discharge; the replay hands it back to its producer. False when the + store refused (the caller's own path still holds the duty).""" + return _owed(drive_root, duty_id, {**json.loads(json.dumps(dict(duty), default=str)), "after_end": True}) + + +def replay_owed_late_reviews(drive_root: Any, *, stop: Any = None) -> Dict[str, int]: + """The maintenance pass over owed rows only: each duty whose author ended is published or handed back to + its producer; a row that names nothing it can owe is retired.""" + from ouroboros import store # D15->D17 is lazy-only + + report = {"settled": 0, "waiting": 0, "retired": 0, "failed": 0} + try: + with store.open_store(drive_root, write=False) as txn: + rows = txn.set_rows(OWED_SET) + except Exception: + log.warning("owed late reviews are unreadable; they stay owed", exc_info=True) + return report + for duty_id, row in rows.items(): + if stop is not None and stop(): + break + duty = next((row[key] for key in ("record", "plan_wave", "left_review") if isinstance(row.get(key), dict)), None) + if duty is None: + report["retired"] += _owed(drive_root, duty_id, None) + elif row.get("after_end") and not _task_ended(drive_root, str(duty.get("task_id") or "")): + report["waiting"] += 1 + else: + report[_settle_owed(drive_root, duty_id, row)] += 1 + return report + + +def _settle_owed(drive_root: Any, duty_id: str, row: Mapping[str, Any]) -> str: + """One owed row whose author ended: ``settled``, ``waiting`` (its producer still owns it) or ``failed``.""" + try: + if isinstance(row.get("record"), dict): + status = publish_late_review(drive_root, row["record"]).status + elif isinstance(row.get("plan_wave"), dict): + from ouroboros.tools.plan_review_collect import settle_ended_plan_wave # D15->D06 is lazy-only + + status = settle_ended_plan_wave(drive_root, row["plan_wave"]) + else: + from ouroboros.review_pause import settle_left_review # D15->D06 is lazy-only + + status = settle_left_review(drive_root, row) + except Exception: + log.warning("owed late review %s could not be settled; it stays owed", duty_id, exc_info=True) + return "failed" + if status == "waiting": + return "waiting" + if status == "failed": + return "failed" + _owed(drive_root, duty_id, None) # a placed, unchanged, released or unplaceable outcome discharges it + return "settled" + + +def _owed(drive_root: Any, mark_id: str, row: Optional[Dict[str, Any]]) -> bool: + """Write (``row``) or discharge (``None``) one owed publication; False when the store refused.""" + from ouroboros import store # D15->D17 is lazy-only + + try: + if row is None: + with store.open_store(drive_root, write=False) as txn: + if txn.get_row(OWED_SET, mark_id) is None: + return True + with store.open_store(drive_root, write=True) as txn: + if row is None: + txn.delete_row(OWED_SET, mark_id) + else: + txn.put_row(OWED_SET, mark_id, row) + return True + except Exception: + log.warning("late review %s: its owed row could not be %s", mark_id, + "kept; nothing durable owes it now" if row is not None else "discharged", exc_info=True) + return False + + +def _task_ended(drive_root: Any, task_id: str) -> bool: + """The canonical row's terminal status (a latch); unreadable is not ended.""" + from ouroboros.task_results import _TRULY_TERMINAL_STATUSES, load_task_result # D15->D17 is lazy-only + + try: + return str((load_task_result(drive_root, task_id) or {}).get("status") or "") in _TRULY_TERMINAL_STATUSES + except Exception: + log.debug("task %s status unreadable; an owed late review keeps waiting", task_id, exc_info=True) + return False + + +def _publish(root: pathlib.Path, mark_id: str, facts: Mapping[str, Any]) -> LateReviewMark: + try: + room = owning_room(root, facts["task_id"], facts.get("chat_id")) + except (OSError, ValueError, sqlite3.Error) as exc: # an unreadable binding or Project registry + return LateReviewMark("failed", mark_id, "", f"owning room unreadable: {type(exc).__name__}: {exc}") + if room is None: + return LateReviewMark("not_activated", mark_id, "", ( + f"the room owning chat {facts.get('chat_id')!r} of task {facts['task_id']} is not proven; no mark is " + f"placed and the full source stays retained at {facts['source_ref']['path']}")) + try: + store = ChronicleStore(root) + receipt = store.ensure_activated() + if not isinstance(receipt, dict) or receipt.get("kind") != "activation": + kind = str((receipt or {}).get("kind") or "import_failed") + return LateReviewMark("failed" if kind == "import_pending" else "memory_not_activated", mark_id, room, + f"{kind}: {(receipt or {}).get('reason') or 'no activation receipt'}") + result = store.publish([_mark(mark_id, room, facts)]) + except (OSError, TimeoutError, sqlite3.Error) as exc: + return LateReviewMark("failed", mark_id, room, f"{type(exc).__name__}: {exc}") + except ValueError as exc: # the store's own refusal to open its journal + return LateReviewMark("memory_not_activated", mark_id, room, f"{type(exc).__name__}: {exc}") + if result.ok: + return LateReviewMark(result.reason, mark_id, room) + return LateReviewMark("conflict" if result.detail.startswith("identity collision") else "invalid", mark_id, room, + f"{result.reason}: {result.detail}") diff --git a/ouroboros/llm_local.py b/ouroboros/llm_local.py index cb128aff8..29be50bd0 100644 --- a/ouroboros/llm_local.py +++ b/ouroboros/llm_local.py @@ -100,6 +100,7 @@ _LOCAL_COMPACTION_MODES = { { "Shared understanding", "Words of my human that caused this work", + "Late review feedback", "Marks I keep in view", "This room", "Scratchpad", diff --git a/ouroboros/loop.py b/ouroboros/loop.py index 6cb9b26a8..49c5b467e 100644 --- a/ouroboros/loop.py +++ b/ouroboros/loop.py @@ -77,7 +77,7 @@ from ouroboros.loop_transport import ( ) from ouroboros.pricing import estimate_cost_optional # noqa: F401 -- the loop module keeps its historical import surface for the L-B leaves from ouroboros.budget_pause import load_budget_pause, resume_paused_loop -from ouroboros.owner_wait import load_owner_wait, resume_native_loop, wait_after_tools +from ouroboros.owner_wait import load_owner_wait, park_for_left_review, resume_native_loop, wait_after_tools from ouroboros.presence_continuation import ( conversation_lost as _presence_conversation_lost, lost_conversation_terminal as _presence_lost_terminal, @@ -113,7 +113,7 @@ def _finalize_loop_candidate(content, limit_ctx, tools, emit_progress, *, after_ _arm_delivery_control(tools, limit_ctx, limit_ctx.llm_trace, control="owner_revision_required") return None selected = bool(getattr(ctx, "_completion_selected", None)) or consume_completion_request(tools, limit_ctx, limit_ctx.llm_trace, content) - if after_tools and not selected: + if not selected and (after_tools or park_for_left_review(tools, limit_ctx, content, emit_progress)): return None if selected: from ouroboros.loop_messages import owner_source_sha256 @@ -412,13 +412,13 @@ def _reset_turn_state(ctx: Any) -> None: ctx._presence_completion, ctx._presence_completion_accepted = None, False ctx._presence_forced_declaration = ctx._presence_forced_pending = None ctx._completion_request = ctx._completion_selected = ctx._completion_observation = None - ctx._completion_conflict, ctx._completion_held_sha256 = False, "" + ctx._completion_conflict, ctx._completion_held_sha256, ctx._acceptance_pending_review_choice = False, "", "" ctx._delivery_candidate, ctx._delivery_candidate_revision, ctx._delivery_control_required = None, 0, False ctx._delivery_evidence_revision, ctx._delivery_evidence_fingerprint = 0, "" ctx.model_turn_state, ctx._authoring_handover, ctx._pending_model_wait_handover = ModelTurnState(), None, None ctx.route_wait_on_primary, ctx.active_role_override, ctx._route_facts_pending = False, None, "" ctx._primary_route_observation, ctx._accepted_route_binding, ctx._primary_return_requested = None, None, None - ctx._accepted_model_route = None + ctx._accepted_model_route = ctx._left_review_wait = None # a new turn waits on no left review def _initial_round_route(ctx: Any, llm: LLMClient, initial_effort: str) -> tuple: diff --git a/ouroboros/loop_acceptance.py b/ouroboros/loop_acceptance.py index 3965f1623..94a51d7ae 100644 --- a/ouroboros/loop_acceptance.py +++ b/ouroboros/loop_acceptance.py @@ -49,13 +49,9 @@ def _task_acceptance_eligible( ) -> tuple[bool, str]: """Return ``(host_should_review, trigger_reason)``. - Both modes review observable effects and typed deliverables/criteria. - ``auto`` also honors an agent's recorded NOMINATION (the typed root deferral, - never the review tool's name alone), so read-only research remains - reviewable without classifying its prose or tool counts. - Queue membership alone qualifies only in ``required``. Child reviews stay - advisory, and ``off`` never reviews. - Eligibility uses typed contracts and runtime facts, never message content. + Auto is the author's typed nomination. Required also reviews observed effects + and retained produced material. Requests, inputs and queue labels prove no + production; child critics and collection of paid panels have separate owners. """ if mode == "off": return False, "off" @@ -63,20 +59,13 @@ def _task_acceptance_eligible( return False, "skipped_child_advisory" if mode in {"auto", "required"}: prefix = "required" if mode == "required" else "auto" - if turn_has_reviewable_effects(llm_trace): - return True, f"{prefix}_effect" - if mode == "required" and not is_direct_chat: - return True, f"{prefix}_nondirect" - contract = task_contract if isinstance(task_contract, dict) else {} - if ( - str(contract.get("expected_output") or "").strip() - or bool(contract.get("acceptance_criteria")) - or bool(contract.get("success_criteria")) - or bool(contract.get("acceptance_claims")) - ): - return True, f"{prefix}_contract" if recorded_acceptance_nomination(llm_trace): return True, f"{prefix}_agent_request" + if mode == "required": + if turn_has_reviewable_effects(llm_trace, observed_only=True): + return True, "required_effect" + if (llm_trace.get("acceptance_material") or {}).get("produced") is True: + return True, "required_material" return False, "skipped_conversation" return False, "skipped_unknown_mode" @@ -379,10 +368,9 @@ def _supersede_task_acceptance_for_owner_followup( ) ctx._task_acceptance_reviewed = False ctx._task_acceptance_fence_generation_mismatch = False - # The panel in flight reviewed the answer to the OLD requirements: it stays - # custodied and its verdicts still arrive as advice, but the answer Main writes - # for the follow-up is not a delivery under it — the ordinary path decides. - ctx._task_acceptance_pending = "" + # A new message changes delivery premises, not physical review identity or + # the author's wait choice. The old verdict can inform the next decision + # without authorizing an answer to changed requirements. llm_trace.pop("root_phase_checkpoint", None) llm_trace["review_decision"] = {**dict(llm_trace.get("review_decision") or {}), "eligibility": "pending_owner_followup", @@ -605,6 +593,10 @@ def _latch_final_answer_marker( pass answer = extract_final_answer(text) if not answer: + from ouroboros.outcomes import FINAL_ANSWER_MARKER + if any(line.strip().startswith(FINAL_ANSWER_MARKER) for line in text.splitlines()): + llm_trace.pop("best_valid_final_answer", None) + llm_trace.pop("best_valid_final_answer_tools", None) return llm_trace["best_valid_final_answer"] = answer del current_tool_calls @@ -1054,6 +1046,22 @@ def _record_forced_acceptance_bypass( if tools_ctx is None: return from ouroboros.acceptance_history import seed_acceptance_history + from ouroboros.acceptance_material import retain_acceptance_material + + # Finalization collects paid facts even when a prior decision or Off forbids + # fresh review. Collection failure proves no new verdict and permits no resend. + try: + from ouroboros.review_dispatch import reconcile_pending_acceptance_runs + reconcile_pending_acceptance_runs(llm_trace, drive_root=tools_ctx.drive_root, usage_ctx=tools_ctx) + except Exception: + log.debug("Forced finalization could not collect paid feedback", exc_info=True) + # A forced replacement still owns its exact selection. Capture is fallible; + # an unavailable reader cannot resurrect the previous answer or abort salvage. + try: + retain_acceptance_material(tools_ctx, llm_trace) + except Exception as exc: + llm_trace["acceptance_material"] = {"produced": False, "sources": [], "issues": [ + {"status": "source_unavailable", "tool": "forced_material", "reason": str(exc)}]} # A recorded host decision (canonical status, NOT the status-less agent # stance merged on a deferral) wins; the bypass record exists only for the # no-host-verdict shape; `_set_acceptance_decision` stamps. @@ -1090,7 +1098,8 @@ def _record_forced_acceptance_bypass( log.debug("Forced acceptance-bypass eligibility probe failed", exc_info=True) llm_trace["review_decision"] = {"eligibility": "unknown", "trigger": trigger} return - if not eligible: + if not eligible and not any(isinstance(run, dict) and run.get("authority") == "host_root" + for run in llm_trace.get("review_runs") or []): # Explicitly "no panel warranted" — now distinguishable from "not evaluated". llm_trace["review_decision"] = {"eligibility": "not_eligible", "trigger": probe_trigger} return diff --git a/ouroboros/loop_acceptance_review.py b/ouroboros/loop_acceptance_review.py index 6fdbc0df0..32f3bdef6 100644 --- a/ouroboros/loop_acceptance_review.py +++ b/ouroboros/loop_acceptance_review.py @@ -27,13 +27,7 @@ log = logging.getLogger("ouroboros.loop") def _loop(): - """The parent loop module, read at call time. - - The loop's members stay monkeypatch-addressable at their historical - ``ouroboros.loop`` bindings (tests rebind them there), so this leaf - resolves every cross-reference through the module at each call instead - of freezing whatever object a from-import saw at import time. - """ + """Resolve historical loop bindings at call time; consumers may rebind them.""" from ouroboros import loop return loop @@ -73,17 +67,11 @@ class _TaskAcceptanceContext: passes_done: int evidence: Dict[str, Any] = field(default_factory=dict) review_binding: Dict[str, Any] = field(default_factory=dict) - # One pre-rendered rails line (money/time/rounds/passes headroom) built - # in loop.py from each real source, fed into the improvement capsule - # (v6.74.0 A1, owner Q6); the capsule builder never gains ctx. + # The capsule receives real rails, never the mutable caller context. rails_line: str = "" - # Int-like ceiling plus per-slot caps, resolved once (inside the local - # preparation stage, after the repeat guard) so rebuilds cannot drift. + # Resolve packet/seat caps once after the preparation repeat guard. packet_budget_chars: int = 0 - # Which typed stage of this pass is running. A failure in `preparation` is - # LOCAL and pre-dispatch (no reviewer exists yet); `reconcile`, `dispatch` - # and `application` failures concern runs that were already dispatched and - # must never be relabelled as "no new reviewer was dispatched". + # Only preparation is local/unpaid; later failures concern real panels. stage: str = "preparation" @@ -112,24 +100,17 @@ def _resolve_ctx_lineage(ctx: Any, task_id: str = "") -> Dict[str, Any]: def prepare_acceptance_observation(ctx: Any, trace: dict, incoming: Any, messages: list, tool_schemas: list) -> None: - """Present the current owner-source selector as an append-only transcript row. - - A new row is appended only when the rendered facts changed; earlier rows stay, - because rewriting or removing an already-sent message breaks provider prompt - caches that only reuse a previous request when it is a byte-prefix of the next - (issue #906). - """ + """Append changed owner-source facts; retain earlier rows for prefix caching.""" from ouroboros.loop_acceptance import capture_acceptance_observation, acceptance_observation_prompt observed = capture_acceptance_observation(ctx, trace, incoming) if not getattr(ctx, "_delivery_control_required", False) and (_loop().get_task_review_mode() not in {"auto", "required"} or not any(row.get("function", {}).get("name") == "task_acceptance_review" for row in tool_schemas)): return # an armed control always shows the selector it asks Main to name (subagents, review mode off) - # Cognitive-only direct turns (for example ``update_identity``) are ordinary - # conversation and are explicitly ineligible for task acceptance. Do not - # expose the internal source-selector protocol to Main in that case: after a - # successful cognitive tool call it can otherwise answer the selector itself, - # replacing the natural conversational response with acceptance bookkeeping. + # Ineligible conversation needs no selector bookkeeping unless delivery + # already requires a source acknowledgement. + # Observation only publishes owner facts. Capture produced sources once at + # finalization/admission, not on every (including Auto) model observation. lineage = _resolve_ctx_lineage(ctx) eligible, _reason = _loop()._task_acceptance_eligible( _loop().get_task_review_mode(), @@ -582,51 +563,65 @@ def _set_applied_host_acceptance_impact( def _finish_cyber_acceptance(ctx: _TaskAcceptanceContext, result: Any) -> bool: - """Main's final response is its decision; criticism retains its own facts.""" + """Return criticism for a decision; only an explicit author act finishes.""" + from ouroboros.acceptance_settlement import acceptance_wait_chosen from ouroboros.loop_delivery import delivery_subject_hash from ouroboros.review_records import build_author_disposition - from ouroboros.review_substrate import build_improvement_capsule, task_acceptance_is_clean + from ouroboros.review_substrate import task_acceptance_is_clean + tool_ctx = ctx.tools._ctx pending = acceptance_run_pending(result) - if getattr(ctx.tools._ctx, "_acceptance_review_only", False): - if not pending and (capsule := build_improvement_capsule(result, rails_line=ctx.rails_line)): - _loop()._append_or_merge_user_message(ctx.messages, capsule) - _loop()._end_task_acceptance_fence(ctx.tools._ctx, outcome="revision") - return False # nomination supplies feedback, never final delivery - if _loop()._task_acceptance_owner_generation_changed(ctx.tools._ctx): - _loop()._supersede_task_acceptance_for_owner_followup(ctx.tools._ctx, ctx.llm_trace) + selected = getattr(tool_ctx, "_completion_selected", None) or {} + observed = (selected.get("observation") or {}).get("feedback") or {} + explicit = selected.get("action") == "finish" + informed = (observed.get("binding_hash") == getattr(result, "binding_hash", ctx.review_binding.get("binding_hash")) + and observed.get("settled_feedback_delivered")) + if pending and acceptance_wait_chosen(tool_ctx): + return True + if getattr(tool_ctx, "_acceptance_review_only", False) or not explicit or ( + not pending and acceptance_wait_chosen(tool_ctx) and not informed): + if not pending: + _offer_acceptance_feedback(ctx, acceptance_feedback_text(result, rails_line=ctx.rails_line)) + _loop()._end_task_acceptance_fence(tool_ctx, outcome="revision") + return not bool(getattr(tool_ctx, "_acceptance_review_only", False)) + if _loop()._task_acceptance_owner_generation_changed(tool_ctx): + _loop()._supersede_task_acceptance_for_owner_followup(tool_ctx, ctx.llm_trace) return True from ouroboros.presence_continuation import keep_author_for_criticism if pending and keep_author_for_criticism(ctx): - return True # a Presence early release keeps its author for this criticism (#1536) - released = _loop()._end_task_acceptance_fence(ctx.tools._ctx, outcome="revision") - ctx.tools._ctx._task_acceptance_pending = "" # only the wait; original actors remain custodied - ctx.tools._ctx._task_acceptance_reviewed = False # final ingress, not review, owns delivery sealing - clean = not pending and task_acceptance_is_clean(result) - signal = "" if pending else str(getattr(result, "aggregate_signal", "") or "") - # The submitted final is Main's act (finish); Main stated no stance toward - # the criticism, so the record carries the act and no invented disposition. + return True + released = _loop()._end_task_acceptance_fence(tool_ctx, outcome="revision") + tool_ctx._task_acceptance_pending = "" # physical actors keep custody + tool_ctx._task_acceptance_reviewed = False # final ingress seals delivery + clean = (not pending and task_acceptance_is_clean(result) + and not getattr(result, "superseded_by_revision", False) + and getattr(result, "subject_hash", ctx.review_binding.get("subject_hash")) + == delivery_subject_hash(tool_ctx, ctx.llm_trace, ctx.content)) + stance = ctx.llm_trace.get("acceptance_decision") or {} author = build_author_disposition( - disposition="", action="finish", - rationale="Main submitted this complete response for delivery; independent review remains advisory.", - # Evidence assembly can fail before a review binding exists. Bind the - # author's decision to its real subject without inventing a reviewed pack. - subject_hash=ctx.review_binding.get("binding_hash") or delivery_subject_hash(ctx.tools._ctx, ctx.llm_trace, ctx.content), - reviewer_signal=signal, - enforcement="advisory", source="author_final_response", + disposition=str(stance.get("agent_disposition") or ""), action="finish", + rationale=str(selected.get("rationale") or stance.get("agent_rationale") or "Explicit completion selection."), + subject_hash=delivery_subject_hash(tool_ctx, ctx.llm_trace, ctx.content), + reviewer_signal="" if pending else str(getattr(result, "aggregate_signal", "") or ""), + enforcement="advisory", source=str(selected.get("source") or "finish_task"), ) + # The author's finish is intent; a still-running panel stays in flight in + # the projection (the admission pass rewrote eligibility before this act). ctx.llm_trace["review_decision"].update(author_finish=True, review_pending=pending, - admission_released=bool(released)) + admission_released=bool(released), + **({"eligibility": "review_in_flight", "operation_state": "in_flight"} + if pending else {})) _loop()._set_acceptance_decision(ctx.llm_trace, { "status": ACCEPTANCE_ACCEPTED if clean else ACCEPTANCE_FINALIZED_UNACCEPTED, "reason": "clean_pass" if clean else "author_finish", "source": "task_acceptance_review", "author_disposition": author, "review_pending": pending, }) - ctx.emit_progress("Task acceptance feedback remains advisory; Main chose delivery." + ctx.emit_progress("Author selected completion with advisory review." + (" Review is still running." if pending else "")) return False + def _finish_advisory_author(ctx: _TaskAcceptanceContext) -> bool: """Honor an informed Advisory finish or an explicit non-authorizing stop.""" stance = ctx.llm_trace.get("acceptance_decision") or {} @@ -649,6 +644,16 @@ def _finish_advisory_author(ctx: _TaskAcceptanceContext) -> bool: # fingerprint, and rejecting the stop for that bought a panel whose # advisory author_finish then read as Done over "not ready". if action != "stop": + from ouroboros.acceptance_settlement import acceptance_wait_chosen, panel_awaiting_this_turn + + awaited = panel_awaiting_this_turn(ctx.tools._ctx, ctx.llm_trace) + waiting = awaited or feedback + # A no-panel host outcome (refused launch, unavailable review) has no + # reviewer left to settle: its exposure is the settled feedback. Only a + # physical panel can still be pending or unread. + if acceptance_wait_chosen(ctx.tools._ctx) and waiting and waiting is not outcome and ( + acceptance_run_pending(waiting) or not waiting.get("settled_feedback_delivered")): + return False from ouroboros.loop_delivery import delivery_evidence_fingerprint from ouroboros.tool_capabilities import completion_observation_calls @@ -710,16 +715,24 @@ def _slot_cause_clause(result: Any) -> str: return f" Causes: {note}" if note else "" -def _offer_acceptance_feedback(ctx: _TaskAcceptanceContext, feedback: str) -> None: +def acceptance_feedback_text(result: Any, *, rails_line: str = "") -> str: + """A clean PASS has no improvement capsule, but is still a real verdict.""" + from ouroboros.review_substrate import build_improvement_capsule + return (build_improvement_capsule(result, rails_line=rails_line) + or f"[Task acceptance feedback] Review verdict: {getattr(result, 'aggregate_signal', '') or 'UNKNOWN'} for the nominated answer.") + + +def _offer_acceptance_feedback(ctx: _TaskAcceptanceContext, feedback: str, *, binding_hash: str | None = None) -> None: """Offer exact-panel feedback; the returned Main request proves delivery.""" runs = ctx.llm_trace.get("review_runs") or [] for index in range(len(runs) - 1, -1, -1): run = runs[index] if (isinstance(run, dict) and run.get("authority") == "host_root" - and run.get("binding_hash") == ctx.review_binding.get("binding_hash")): + and run.get("binding_hash") == (binding_hash or ctx.review_binding.get("binding_hash"))): run["feedback_offered"] = True ctx.messages.append({"role": "user", "content": feedback, "review_feedback": [{ "task_id": ctx.task_id, "run_index": index, "binding_hash": str(run.get("binding_hash") or ""), + "settled": not acceptance_run_pending(run), }]}) break @@ -741,6 +754,10 @@ def _apply_task_acceptance_result( _record_host_acceptance_run(ctx, result) if not review_enforcement_blocks("blocking"): return _finish_cyber_acceptance(ctx, result) + if task_acceptance_is_clean(result) and not getattr(result, "settled_feedback_delivered", False): + _offer_acceptance_feedback(ctx, acceptance_feedback_text(result, rails_line=ctx.rails_line)) + _loop()._end_task_acceptance_fence(ctx.tools._ctx, outcome="revision") + return True dissent = dissent_findings(result) blocking_lane = review_enforcement_blocks(_loop().get_review_enforcement()) # Reused panels already have obligations; collecting twice changes evidence @@ -1299,84 +1316,83 @@ def _run_task_acceptance_review_once( Loop-side rails facts arrive via the ``_acceptance_loop_rails`` ctx stash (set by ``_no_tool_final_answer``; keeps the signature at 8 params).""" mode = _loop().get_task_review_mode() - _loop()._latch_final_answer_marker(llm_trace, content) - subject_error = None - if getattr(tools._ctx, "_task_acceptance_reviewed", False): - from ouroboros.loop_delivery import delivery_subject_hash - from ouroboros.acceptance_preparation import preparation_delivery_choice - - reviewed_subject = getattr(tools._ctx, "_task_acceptance_reviewed_subject", "") - try: - # Both early reads are fallible host work over the same material: a - # failure here is accounted under the local incident guard below, - # never read as permission to deliver under an older verdict. - if preparation_delivery_choice(tools._ctx, llm_trace): - return False - if not reviewed_subject or reviewed_subject == delivery_subject_hash(tools._ctx, llm_trace, content): - return False - except Exception as exc: - subject_error = exc # Account below, under the same local incident guard. - tools._ctx._task_acceptance_reviewed = False - from ouroboros.review_evidence import acceptance_packet_budget_chars - - eligible, trigger = _observe_host_acceptance_request(tools, llm_trace, task_id, mode) - if not eligible: - return False - # Owner hurry (§19.7.2 item 8): AFTER structural eligibility is known, - # BEFORE acceptance-fence/quiescence/reviewer admission, an armed latch - # skips the next otherwise-eligible panel with the typed reason — no - # reviewer calls (an in-flight panel is never cancelled/relabeled). - from ouroboros.owner_hurry import acceptance_skip_applied, effective_budget_profile - - if acceptance_skip_applied( - tools._ctx, llm_trace, task_id=task_id, drive_root=drive_root, - set_decision=_loop()._set_acceptance_decision, emit_progress=emit_progress, - ): - return False - # A fence that answered no or not at all buys no model round: the panel runs on the - # disclosed rail `admission_fence_available=False` and final delivery seals again. - fence_ok, _fence_token = _loop()._begin_task_acceptance_fence(tools._ctx, task_id) - quiescent, subtree_statuses = _loop()._task_acceptance_subtree_snapshot(tools._ctx, drive_root, task_id) - if not quiescent and review_enforcement_blocks("blocking"): - _record_subtree_quiescence_wait(llm_trace, messages, emit_progress, - trigger=trigger, subtree_statuses=subtree_statuses) - return True - llm_trace["review_decision"].update(admission_fence_available=bool(fence_ok), subtree_quiescent=quiescent) - # One effective profile carries explicit author caps/Hurry to gates and display. - budget_profile = effective_budget_profile(tools._ctx, task_pacing.resolve_budget_profile(tools._ctx)) - budget_snapshot = task_pacing.build_budget_snapshot(tools._ctx, profile=budget_profile) - passes_done = int(getattr(tools._ctx, "_task_acceptance_improvement_passes", 0)) review_ctx = _TaskAcceptanceContext( tools=tools, content=content, task_id=task_id, task_type=task_type, - llm_trace=llm_trace, - drive_root=drive_root, - messages=messages, - emit_progress=emit_progress, - mode=mode, - subtree_statuses=subtree_statuses, - budget_profile=budget_profile, - passes_done=passes_done, - evidence={}, - review_binding={}, - rails_line=task_pacing.acceptance_rails_line( - budget_snapshot, - budget_profile, - passes_done, - getattr(tools._ctx, "_acceptance_loop_rails", None), - required_blocking=( - mode == "required" and review_enforcement_blocks(_loop().get_review_enforcement()) - ), workspace=task_pacing._workspace_delivery(tools._ctx), - ), + llm_trace=llm_trace, drive_root=drive_root, messages=messages, + emit_progress=emit_progress, mode=mode, subtree_statuses=[], + budget_profile=task_pacing.resolve_budget_profile(tools._ctx), + passes_done=int(getattr(tools._ctx, "_task_acceptance_improvement_passes", 0)), ) try: - from ouroboros import acceptance_preparation as prep - from ouroboros.review_dispatch import reconcile_pending_acceptance_runs + from ouroboros.acceptance_settlement import continue_acceptance_wait - # Reconciling ALREADY-DISPATCHED runs is its own stage: a failure here - # concerns real paid panels and never claims "no new reviewer was dispatched". + # Paid collection precedes new source preparation AND reviewed early exits. + # Failure remains a reconciliation fact beside the original operation. review_ctx.stage = STAGE_RECONCILE - reconcile_pending_acceptance_runs( - llm_trace, drive_root=drive_root or tools._ctx.drive_root, usage_ctx=tools._ctx) + if continue_acceptance_wait(tools._ctx, llm_trace, messages, task_id): + return True + review_ctx.stage = STAGE_PREPARATION + _loop()._latch_final_answer_marker(llm_trace, content) + from ouroboros.acceptance_material import retain_acceptance_material + + from ouroboros.loop_acceptance import recorded_acceptance_nomination + retain_acceptance_material(tools._ctx, llm_trace, content, include_outputs=( + mode == "required" or recorded_acceptance_nomination(llm_trace) + or bool(llm_trace.get("review_runs")))) + subject_error = None + if getattr(tools._ctx, "_task_acceptance_reviewed", False): + from ouroboros.loop_delivery import delivery_subject_hash + from ouroboros.acceptance_preparation import preparation_delivery_choice + + reviewed_subject = getattr(tools._ctx, "_task_acceptance_reviewed_subject", "") + try: + # Account failed early reads below; they authorize no older verdict. + if preparation_delivery_choice(tools._ctx, llm_trace): + return False + if not reviewed_subject or reviewed_subject == delivery_subject_hash(tools._ctx, llm_trace, content): + return False + except Exception as exc: + subject_error = exc # Account below, under the same local incident guard. + tools._ctx._task_acceptance_reviewed = False + from ouroboros.review_evidence import acceptance_packet_budget_chars + + eligible, trigger = _observe_host_acceptance_request(tools, llm_trace, task_id, mode) + if not eligible: + return False + # Owner hurry (§19.7.2 item 8): AFTER structural eligibility is known, + # BEFORE acceptance-fence/quiescence/reviewer admission, an armed latch + # skips the next otherwise-eligible panel with the typed reason — no + # reviewer calls (an in-flight panel is never cancelled/relabeled). + from ouroboros.owner_hurry import acceptance_skip_applied, effective_budget_profile + + if acceptance_skip_applied( + tools._ctx, llm_trace, task_id=task_id, drive_root=drive_root, + set_decision=_loop()._set_acceptance_decision, emit_progress=emit_progress, + ): + return False + # A fence that answered no or not at all buys no model round: the panel runs on the + # disclosed rail `admission_fence_available=False` and final delivery seals again. + fence_ok, _fence_token = _loop()._begin_task_acceptance_fence(tools._ctx, task_id) + quiescent, subtree_statuses = _loop()._task_acceptance_subtree_snapshot(tools._ctx, drive_root, task_id) + if not quiescent and review_enforcement_blocks("blocking"): + _record_subtree_quiescence_wait(llm_trace, messages, emit_progress, + trigger=trigger, subtree_statuses=subtree_statuses) + return True + llm_trace["review_decision"].update(admission_fence_available=bool(fence_ok), subtree_quiescent=quiescent) + # One effective profile carries explicit author caps/Hurry to gates and display. + budget_profile = effective_budget_profile(tools._ctx, task_pacing.resolve_budget_profile(tools._ctx)) + budget_snapshot = task_pacing.build_budget_snapshot(tools._ctx, profile=budget_profile) + passes_done = int(getattr(tools._ctx, "_task_acceptance_improvement_passes", 0)) + review_ctx.subtree_statuses = subtree_statuses + review_ctx.budget_profile = budget_profile + review_ctx.passes_done = passes_done + review_ctx.rails_line = task_pacing.acceptance_rails_line( + budget_snapshot, budget_profile, passes_done, + getattr(tools._ctx, "_acceptance_loop_rails", None), + required_blocking=mode == "required" and review_enforcement_blocks(_loop().get_review_enforcement()), + workspace=task_pacing._workspace_delivery(tools._ctx), + ) + from ouroboros import acceptance_preparation as prep from types import SimpleNamespace from ouroboros.review_substrate import build_review_binding @@ -1387,11 +1403,8 @@ def _run_task_acceptance_review_once( if _loop()._task_acceptance_owner_generation_changed(tools._ctx): _loop()._supersede_task_acceptance_for_owner_followup(tools._ctx, llm_trace) return True - # The material's identity comes FIRST and cannot raise, so an exposed, - # unresolved failure over unchanged material is refused before any - # fallible pre-binding computation (packet budget, subject hash, - # builder) can run again and count a host attempt that never reached - # the builder; a builder that raises still has a stable incident. + # Stable incident identity precedes fallible assembly: unchanged failed + # material cannot count a new preparation attempt before the builder. prep_record, handled = prep.open_local_preparation(review_ctx) if handled is not None: return handled @@ -1420,8 +1433,7 @@ def _run_task_acceptance_review_once( _loop()._supersede_task_acceptance_for_owner_followup(tools._ctx, llm_trace) return True binding_hash = str(review_ctx.review_binding.get("binding_hash") or "") - # A-material: what the tree's wallet actually buys. Stamped onto the - # binding before the free-replay lookup and the dispatch claim both read it. + # Paid identity precedes both free replay and dispatch claims. paid_identity = bind_acceptance_paid_identity(review_ctx.review_binding, llm_trace) seen_bindings, prior_run = _prior_acceptance_run( tools._ctx, llm_trace, binding_hash, paid_identity=paid_identity, @@ -1457,9 +1469,7 @@ def _run_task_acceptance_review_once( "panel_id", "paid_identity", "subject_hash", ) if key in prior_run}} elif binding_hash in seen_bindings: - # A process-local attempt without its authoritative trace is not - # safe to repeat or silently accept. The infra-degraded path below - # records the missing authority and closes finalization honestly. + # Missing paid authority permits neither resend nor acceptance. raise RuntimeError("acceptance binding was attempted but its host run is unavailable") else: launch_ok, launch_reason = task_pacing.review_launch_allowed(budget_snapshot) diff --git a/ouroboros/loop_delivery.py b/ouroboros/loop_delivery.py index cdc204e49..f9509407a 100644 --- a/ouroboros/loop_delivery.py +++ b/ouroboros/loop_delivery.py @@ -187,6 +187,7 @@ def delivery_subject_projection( text = full_answer if full_answer is not None else getattr(candidate, "full_text", "") return { "candidate_sha256": hashlib.sha256(str(text).encode("utf-8")).hexdigest(), + "produced_sources": (llm_trace.get("acceptance_material") or {}).get("sources", []), "effective_criteria": _effective_delivery_criteria(tool_ctx), "material_evidence_fingerprint": delivery_evidence_fingerprint(tool_ctx, llm_trace), } @@ -1561,16 +1562,14 @@ def finish_completed_stop(tools: ToolRegistry, ctx: Any, emit_progress: Any, def record_stopped_completion(tools: ToolRegistry, ctx: Any, trace: dict, request: dict, candidate: DeliveryCandidate) -> None: - """A local stop is truthful for every task; eligible roots retain author-stop review semantics.""" + """A local stop is truthful for every task; roots retain its author disposition.""" trace["task_completion"] = {"action": "stop", "rationale": request["rationale"], "answer_sha256": candidate.content_sha256, "source": request.get("source", "finish_task")} from ouroboros.loop_acceptance_review import _resolve_ctx_lineage from ouroboros.review_records import build_author_disposition - eligible, _ = _loop()._task_acceptance_eligible(_loop().get_task_review_mode(), trace, - bool(getattr(tools._ctx, "is_direct_chat", False)), - is_root_task=_resolve_ctx_lineage(tools._ctx, ctx.task_id)["is_root_task"], - task_contract=getattr(tools._ctx, "task_contract", {})) - if not eligible: + # Stop grants no approval and buys no panel. Preserve it even when Auto has + # no nomination, Required has no production, or new reviews are Off. + if not _resolve_ctx_lineage(tools._ctx, ctx.task_id)["is_root_task"]: return from ouroboros.review_dispatch import reconcile_pending_acceptance_runs from ouroboros.task_results import project_task_acceptance_review_capacity diff --git a/ouroboros/memory_view.py b/ouroboros/memory_view.py index b5ce8fcca..9ede6ab2c 100644 --- a/ouroboros/memory_view.py +++ b/ouroboros/memory_view.py @@ -1,9 +1,9 @@ """The resident memory view of the acting mind: one render for every role. The view replaces the old dialogue history and chat tail in a request. Block B carries my -story (``## My story``); block C the live part (``## Marks I keep in view``, -``## Live rooms`` and ``## This room (