From 128dcb5e4b55f334e3157145a31036b6339842e5 Mon Sep 17 00:00:00 2001 From: "S.J." Date: Sun, 20 Sep 2026 13:59:06 -0400 Subject: [PATCH 01/44] fix: preserve legacy subagent history on upgrade --- ouroboros/subagent_history.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ouroboros/subagent_history.py b/ouroboros/subagent_history.py index 207258e08..006a796ae 100644 --- a/ouroboros/subagent_history.py +++ b/ouroboros/subagent_history.py @@ -67,6 +67,10 @@ def record_last_delegation(*, route: str, requested_model: str, applied_model: s try: old = subagent_last_delegation(drive_root) rows = dict(old.get("latest_by_subagent") or {}) + legacy_actor = str(old.get("selected_subagent_id") or "") + if legacy_actor and legacy_actor not in rows: + rows[legacy_actor] = {key: value for key, value in old.items() + if key != "latest_by_subagent"} old_actor = rows.get(selected_subagent_id) or {} previous = old_actor if old_actor.get("run_id") == run_id else old if old.get("run_id") == run_id else {} if run_id and previous and (not occurred_at or ( From 86c1eb35482c0148ff02a99135f61a301c8dee19 Mon Sep 17 00:00:00 2001 From: "S.J." Date: Sun, 20 Sep 2026 13:59:11 -0400 Subject: [PATCH 02/44] test: cover first post-upgrade subagent write --- tests/test_subagent_execution_history.py | 36 ++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/test_subagent_execution_history.py b/tests/test_subagent_execution_history.py index e85b5df78..ba9fb6240 100644 --- a/tests/test_subagent_execution_history.py +++ b/tests/test_subagent_execution_history.py @@ -96,6 +96,42 @@ def test_recovery_settlement_and_refused_start_share_history(tmp_path, monkeypat assert subagent_last_delegation(tmp_path) == row +def test_first_post_upgrade_write_preserves_legacy_subagent_row(tmp_path): + path = tmp_path / "state" / "subagent_last_delegation.json" + path.parent.mkdir() + legacy = { + "ts": "2026-09-17T12:00:00Z", + "observed_at": "2026-09-17T12:00:00Z", + "occurred_at": "2026-09-17T12:00:00Z", + "route": "api_model", + "requested_model": "openai::legacy", + "applied_model": "openai::legacy", + "requested_profile": "", + "applied_profile": "", + "selected_subagent_id": "legacy-worker", + "run_id": "legacy-run", + "outcome": "succeeded", + } + path.write_text(json.dumps(legacy), encoding="utf-8") + + record_last_delegation( + route="api_model", + requested_model="openai::new", + applied_model="openai::new", + run_id="new-run", + selected_subagent_id="new-worker", + drive_root=tmp_path, + occurred_at="2026-09-20T12:00:00Z", + outcome="succeeded", + ) + + row = subagent_last_delegation(tmp_path) + assert row["selected_subagent_id"] == "new-worker" + assert row["latest_by_subagent"]["new-worker"]["run_id"] == "new-run" + assert row["latest_by_subagent"]["legacy-worker"]["run_id"] == "legacy-run" + assert "latest_by_subagent" not in row["latest_by_subagent"]["legacy-worker"] + + def test_old_corrupt_missing_and_unknown_time_are_not_health(tmp_path): assert subagent_last_delegation(tmp_path) == {} path = tmp_path / "state" / "subagent_last_delegation.json" From 9ecf90d14a1a6d9e9606f2ec77b56f6598852e97 Mon Sep 17 00:00:00 2001 From: Andrew Date: Wed, 23 Sep 2026 17:39:11 +0300 Subject: [PATCH 03/44] benchmarks/clb: adapter delta from the 6.113.5 evolution-arm pilots Publish the CL-Bench adapter state from the 2026-08-27 to 2026-09-06 evolution-arm pilots as an operator patch against PR #10 head a691cf3, with a README addendum that records what ran and on which engine. The delta diverges from the current adapter. It predates the regenerated adapter_official_submission.v681.patch (OUROBOROS_SUBAGENTS transport, no legacy HEAVY/CODE pins) and does not apply on top of it, and it has not been run on a 7.x engine. It is published for its methodology: the next question waits for the evolution round and any restart it requests, forced-arm cycles start only from the benchmark boundary, restart kills are drained and retried, the answer is located with the official adapters' parser, the sales sandbox outlives the 2 h library default, and reviewer slots are pinned to the solve model. Last end-to-end run: 2026-09-03 on Ouroboros 6.113.5, a local engine-fix build (1ffa14d5) over the public 8d13373b. Co-Authored-By: Claude Opus 5.5 --- .../operator_patches/README.md | 75 + .../adapter_evolution_campaign.v61135.patch | 3994 +++++++++++++++++ 2 files changed, 4069 insertions(+) create mode 100644 devtools/benchmarks/continual_learning/operator_patches/adapter_evolution_campaign.v61135.patch diff --git a/devtools/benchmarks/continual_learning/operator_patches/README.md b/devtools/benchmarks/continual_learning/operator_patches/README.md index 458cf66eb..f9c804c88 100644 --- a/devtools/benchmarks/continual_learning/operator_patches/README.md +++ b/devtools/benchmarks/continual_learning/operator_patches/README.md @@ -237,3 +237,78 @@ the external checkout root (a clean clone at `549998d` plus the patches above). Public implementation for the ablation run: branch `ouroboros-luna-ablation` of `razzant/continual-learning-bench`. PR #10's `ouroboros-submission` branch is left untouched so the sonnet-4.6 submission keeps matching the code that produced it. + +## Addendum (6.113.5 evolution campaign, 2026-09-06) + +12. `adapter_evolution_campaign.v61135.patch`: the adapter as it stood after the + evolution-arm pilots of 2026-08-27 to 2026-09-06 on Ouroboros 6.113.5, as one delta + against PR #10 head `a691cf3` (branch `ouroboros-submission` of + `razzant/continual-learning-bench`). Apply with `git apply` onto a clean checkout of + `a691cf3`. It already contains the one-line `_rb` import fix of + `adapter_luna_ablation.v6870.patch` and does not touch `src/systems/codex/system.py`, + so do not also apply the luna patch's `_live_bridge.py` hunk. + + This delta diverges from the current code and is published for its methodology, not + as a drop-in adapter. It branched before the regeneration of + `adapter_official_submission.v681.patch` that added the `OUROBOROS_SUBAGENTS` transport + from `run_clb.py` and removed the legacy HEAVY/CODE pins: it does not apply on top of + that patch (conflict in `_docker_launcher.py`), keeps the old pins, and writes its own + one-row roster instead of forwarding the launcher's. Its restart-refusal handling + targets an admission fence that exists only in the local engine build it ran on. It + was never run on a 7.x engine, and the 7.4.5 code shows two breaks: the injected + `remote_work` manifest lacks `plugin_api: "2.0"`, so the engine refuses its native-seed + trust and the live loop has no tools; and the task record no longer carries `cost_usd` + or `cost_usd_with_children`, so engine-task UsageEvents lose their cost. On 6.113.5 as + on 7.4.5 a fresh image build is expected to fail, because the engine's + `requirements.txt` has been a pointer to a lock file the Dockerfile does not copy since + v6.97.0; the 2026-09-03 run used a previously built image. + + The methodological changes, which are what this delta is for: + - The next question waits for the evolution round. The boundary wait used to end while + a cycle was still running (over the 12 completed cycles of the 2026-09-03 + treatment run, 464 s at the median and 920 s at most; the pilots capped the wait at + 300 s), and 14 of 22 questions in the 2026-09-03 treatment + run were submitted into a running cycle, where the restart that cycle requests can + kill them. The engine starts an evolution task only when its queue is idle, so the + overlap came entirely from the adapter. `on_instance_boundary` now waits until a + host-visible predicate says the engine is quiet: no promotion request, evolution + disarmed, no queued or running task, no active direct-chat activity (the post-restart + auto-resume task runs as one), no pending or claimed restart marker, and no + unresolved restart obligation. It needs two consecutive quiet samples, has one 1800 s + budget per boundary, and on expiry logs the blockers and submits anyway. + - On the forced arm, evolution cycles come only from the benchmark boundary: the adapter + sets `OUROBOROS_POST_TASK_EVOLUTION_CADENCE=off` there. Under the adapter's default + `every_n:1` (the engine's own default is `llm`) every finished task, including the engine's own post-restart auto-resume task, files + the next promotion, so cycles chained between questions (13 evolution tasks against + 11 boundaries in the 2026-09-03 treatment run). + - A restart no longer silently costs a question: the drain deadline is raised to + 1800 s (engine default 120 s), a question cancelled by a restart is retried once, and + submits wait out a pending restart. The earlier form of this gate held 8 submits in + the 2026-09-03 treatment run. + - The answer is located with the same code as the official Claude Code and Codex + adapters (`_json_candidates` and `_parse_action_text`, copied verbatim and pinned by a + test) over the task's assistant transcript, so a complete answer followed by a + delivery-control envelope is not lost. + - The sales sandbox outlives the 2 h library default (`container_timeout` raised to + 24 h at import), which every stateful Ouroboros sales rollout of the submitted run + exceeded; pgasawa/continual-learning-bench#22 proposes the bench-side fix. + - Reviewer slots are pinned to the solve model (`OUROBOROS_REVIEWER_SLOTS`: the triad as + `api_chat` rows, scope and advisory through a same-model scout row). Without the pin + the isolated settings inherit the host's structured slots, which override the flat + single-model review keys. + - Turn-1 responses attest the resolved configuration in `Response.metadata`. + + Last end-to-end run: 2026-09-03, CL-Bench `run-all` groups + `2026-09-03T05-22-33.505496Z` (control) and `2026-09-03T05-22-33.505495Z` (treatment), + `sales_prediction`, `openrouter/anthropic/claude-sonnet-4.6`, + on Ouroboros 6.113.5 built from a local engine-fix branch (head `1ffa14d5`, not + published; public ancestor `8d13373b`), runtime attestation + `clone_version=6.113.5 head=5fd3157e`. Active in that run: the earlier restart gate and + retry, the 1800 s drain, the reviewer-slot pin, the sandbox lifetime override and the + config attestation. Added afterwards and exercised by unit tests only: the + official-parser answer locator, the boundary readiness wait, the cadence `off` and the + current restart gate. The patch also rewrites the post-submission section of the + adapter's `METHODOLOGY.md` with the full account. + Verified: the patch reproduces its source tree byte for byte on `a691cf3`; + `tests/test_ouroboros_submission_ports.py` 140 passed and + `tests/test_ouroboros_live_parity.py` 76 passed in the bench venv. diff --git a/devtools/benchmarks/continual_learning/operator_patches/adapter_evolution_campaign.v61135.patch b/devtools/benchmarks/continual_learning/operator_patches/adapter_evolution_campaign.v61135.patch new file mode 100644 index 000000000..bdeabf37a --- /dev/null +++ b/devtools/benchmarks/continual_learning/operator_patches/adapter_evolution_campaign.v61135.patch @@ -0,0 +1,3994 @@ +diff --git a/src/systems/ouroboros/METHODOLOGY.md b/src/systems/ouroboros/METHODOLOGY.md +index a9a10a5..52d6366 100644 +--- a/src/systems/ouroboros/METHODOLOGY.md ++++ b/src/systems/ouroboros/METHODOLOGY.md +@@ -1,7 +1,10 @@ + # Ouroboros on CL-Bench — run methodology + + This documents the submitted run `final_results/runs/ouroboros-claude-sonnet-4.6` +-(2026-07-28/29, wall-clock 10h07m) and how to reproduce it. ++(2026-07-28/29, wall-clock 10h07m) and how to reproduce it. The submission is ++pgasawa/continual-learning-bench#10 (open, not on the public leaderboard as of ++2026-09-03). Sections up to "Reproduce" describe that run as scored; "Tests" describes ++the current tree; harness changes made after the run are listed in the final section. + + ## System topology + +@@ -35,8 +38,15 @@ System params (as recorded in `manifest.json.system_args`): + "final_answer_delivery":true,"ouroboros_repo":""} + ``` + +-Non-secret environment (every knob is mirrored into the isolated server settings and +-attested at container boot): ++Non-secret environment as exported on the host. The launcher mirrors most keys into the ++isolated server settings; five of the nineteen are not server settings. ++`OUROBOROS_NET_OUTAGE_HOLD_SEC` and `OUROBOROS_REVIEW_MAX_PASSES` are consumed by the ++bridge (the latter becomes the per-task `max_improvement_passes` budget when review is ++required and blocking, as it is here). `OUROBOROS_TRANSIENT_RETRY_MAX` did not reach the ++container, so the engine ran on its default (6). `CLBENCH_SCOPE_CREDENTIALS` is a ++host-side launcher switch, and `CLBENCH_SOLVE_DISABLED_TOOLS` travels as a per-task ++`disabled_tools` field. `CLBENCH_SHIM_BIND` is the host-side shim bind. Container boot ++attests the engine version and repository head, not knob values. + + ``` + OUROBOROS_TASK_REVIEW_MODE=required OUROBOROS_REVIEW_ENFORCEMENT=blocking +@@ -80,10 +90,12 @@ worker pool; delegation is disabled so this is not a swarm). + ## Reproduce + + ```bash +-# 0) engine clone at the pinned SHA + docker image ++# 0) engine clone at the pinned SHA + docker image. The build context must be the ++# ENGINE clone (the Dockerfile copies the engine's requirements.txt; the bench repo ++# root has none). The original image was built on arm64; rebuild on other hosts. + git clone https://github.com/razzant/ouroboros ouroboros-clone + git -C ouroboros-clone checkout 3f9d504b76af447e647139a141b8a76b6c62b114 +-docker build -t clbench-ouroboros:dev -f src/systems/ouroboros/docker/Dockerfile . ++docker build -t clbench-ouroboros:dev -f "$PWD/src/systems/ouroboros/docker/Dockerfile" ouroboros-clone + + # 1) env: the block above, plus OPENROUTER_API_KEY, plus + export OUROBOROS_BENCH_CLONE="$PWD/ouroboros-clone" +@@ -106,7 +118,165 @@ python scripts/analyze_final_results.py --out analysis.json + + ## Tests + +-`tests/test_ouroboros_live_parity.py` (47 tests: live-loop behavioral parity and +-failure containment) and `tests/test_ouroboros_submission_ports.py` (4 contract +-tests: engine-task cost harvesting into UsageEvents, env override tri-state, +-single-model vision pin). Both are in the default pytest collection. ++`tests/test_ouroboros_live_parity.py` (61 test functions, 76 collected cases: live-loop ++behavioural parity, failure containment, the restart gate and resubmit windows, the ++evolution-boundary hook, format repair) and `tests/test_ouroboros_submission_ports.py` ++(57 functions, 140 collected cases: engine-task cost harvesting into UsageEvents, env ++override tri-state, single-model pins for the vision slot and the reviewer/scope slots, ++turn-1 config attestation, the evolution-boundary readiness predicate, promotion cadence on ++the forced arm, the sandbox lifetime override, the official-parser copy drift guard, ++transcript order and latest-valid answer selection, format repair through the official ++parser). Both are in the default pytest collection. The live-parity file takes about ten ++minutes, mostly in the bounded cost harvest that `close()` runs against an unreachable ++test server. ++ ++## Harness changes after the submitted run (as of 2026-09-06) ++ ++These changes were made between 2026-08-27 and 2026-09-06 for evolution-arm pilots on ++Ouroboros 6.113.5 and are published as the operator patch ++`adapter_evolution_campaign.v61135.patch` (razzant/ouroboros, ++`devtools/benchmarks/continual_learning/operator_patches/`). None of them was active in the ++submitted run. They branch from PR #10 (`a691cf3`) and do not include the later upstream ++regeneration of `adapter_official_submission.v681.patch` (the `OUROBOROS_SUBAGENTS` ++transport from `run_clb.py` and the removal of the legacy HEAVY/CODE pins). ++ ++### What ran, and on which engine ++ ++The last end-to-end run of this adapter was on 2026-09-03: CL-Bench `run-all` groups ++`2026-09-03T05-22-33.505496Z` (control) and `2026-09-03T05-22-33.505495Z` (treatment), ++`sales_prediction`, ++`openrouter/anthropic/claude-sonnet-4.6`. The engine was Ouroboros 6.113.5 built from a ++local engine-fix branch, head `1ffa14d5`, 135 commits over the public ++razzant/ouroboros@`8d13373b` (VERSION 6.113.5) and not published. The runtime attestation ++recorded `clone_version=6.113.5` and `head=5fd3157e`, which is that head plus the ++harness-injected commit that seeds the `remote_work` skill. On the treatment arm the engine ++commits its own absorbed cycles into the read-write mount; that run ended at VERSION ++6.113.16. The runtime image `clbench-ouroboros:dev` was a pre-existing local build: the ++engine code is mounted from the clone and the image carries only dependencies. The upstream ++fix for stale delivery-control envelopes (`88948116`) is not in the evaluated engine's ++history. ++ ++Active in that run: the restart gate in its earlier form (marker file plus health probe, ++900 s bound; it held 8 submits in the treatment arm), the single retry of a question ++cancelled by a restart, the 1800 s restart drain, the single-model reviewer slots with the ++scope scout, the sandbox lifetime override (the treatment arm ran 2 h 25 min and lost no ++container), and the turn-1 config attestation. ++ ++Added after that run and exercised only by the unit tests above: the answer locator built ++on the official parser (2026-09-03, after the run finished); the evolution-boundary ++readiness wait with its shared budget, the current form of the restart gate, the promotion ++cadence `off` on the forced arm, the fail-closed forced request, and the handling of ++restart-shaped 503 refusals (2026-09-06). ++ ++This adapter has not been run on any 7.x engine. Reading the code of razzant/ouroboros ++7.4.5 (`33da7a4f`, 2026-09-23) shows two breaks: the engine refuses native-seed trust to a ++skill manifest without `plugin_api: "2.0"`, so `remote_work` is not enabled and the live ++loop has no tools; and the task record no longer carries `cost_usd` or ++`cost_usd_with_children`, so the engine-task UsageEvents lose their cost. Independently of ++the engine version, a fresh image build is expected to fail: the engine's `requirements.txt` ++has been a pointer to `requirements-runtime.lock` since v6.97.0, on 6.113.5 as on 7.4.5, and ++the Dockerfile does not copy the lock. The 2026-09-03 run used a previously built image. The ++restart-refusal handling targets an admission fence that exists only in the local engine ++build and stays dormant on public engines. ++ ++### Evolution arms and pilot shape ++ ++Post-submission pilots run two arms per model that differ in exactly one system parameter: ++`evolution:false` (control) and `evolution:true` (treatment). Both carry ++`evolution_trigger:"forced"`, which is inert on the control arm (`on_instance_boundary` ++returns before any evolution work when evolution is off). Since 2026-09-06 the treatment ++arm sets `OUROBOROS_POST_TASK_EVOLUTION_CADENCE=off` while keeping ++`OUROBOROS_POST_TASK_EVOLUTION=true`, so a cycle starts only from the request the adapter ++files at a question boundary. The runs up to 2026-09-03 used the adapter's default cadence ++`every_n:1` (the engine's own default is `llm`), under which every finished task, including the auto-resume task the engine ++starts after a restart, files the next promotion, so cycle, restart, auto-resume and the ++next cycle can chain between two questions. The pilots are not the submitted recipe: ++`sales_prediction` only, `--runs 1`, `--task-parallelism 1 --per-task-parallelism 1`, ++stateless baseline skipped (`OUROBOROS_BENCH_SKIP_BASELINE=1`). The engine repository is ++mounted read-write on the treatment arm and read-only on the control arm. ++ ++### Question boundary: the next question waits for the evolution round ++ ++In PR #10 the boundary wait runs until the task timeout (900 s by default), and its early ++exit (request file gone and nothing running) does not tell a consumed request whose cycle ++is not yet enqueued from a finished cycle. The 2026-09-02 and 2026-09-03 pilots capped the ++wait at 300 s. Over the 12 completed cycles of the 2026-09-03 treatment run a cycle took ++464 s at the median and 920 s at most, and 14 of its 22 questions were submitted while an evolution task was running. That is the ++only way a benchmark task can be caught by the restart the cycle requests, because the ++engine enqueues an evolution task only when its queue is idle ++(`enqueue_evolution_task_if_needed` in `supervisor/evolution_lifecycle.py`). ++ ++Now `on_instance_boundary` captures a baseline (server sha, campaign identity, both cycle ++counters) before publishing the forced request, publishes at most once per boundary, and ++then polls every 8 s until the engine is quiet by a host-visible predicate: no promotion ++request on disk and `evolution_mode_enabled` false; `/api/state` healthy with ++`evolution_enabled` false, zero pending and running tasks, empty `evolution_state` ids and ++no active direct-chat activity (the post-restart auto-resume task runs as one); no ++`pending_restart_verify` marker, claimed or not, and no unresolved restart obligation on ++the active transaction. Two consecutive quiet samples are required; any activity, ++unreadable state or sha change resets the count. If the `/api/state` payload does not carry ++the expected fields, the missing names are logged once and the boundary falls back to the ++previous counter-based early exits. The wait has one 1800 s monotonic budget per boundary; ++repeated cycles and restart retries inside it do not renew it, while a later wait that ++begins after the budget expired (a resubmit late in a long question, a format-repair ++submit) starts its own window. On the treatment arm the same predicate also runs before ++every question submit. On expiry the gate returns `reason="timeout"` with the blockers ++logged and the question is submitted anyway. ++ ++### Engine restarts ++ ++The engine's restart drain deadline is raised to 1800 s through ++`OUROBOROS_RESTART_DRAIN_MAX_SEC`; the engine default is 120 s, which the submitted run used. ++A question whose task record says it was cancelled by a restart is retried once. Before each ++submit `_await_pending_restart` waits out a pending restart: on the treatment arm through the ++readiness predicate above, elsewhere through the marker files and a health probe, inside the ++same budget. A restart-shaped HTTP 503 re-enters that wait without consuming a submit ++attempt. Restart-kill detection matches the word "restart" in the free-text task result, ++because the engine exposes no typed reason for it. Every boundary and pre-submit gate result ++(reason, cycle outcome, elapsed time, counters, blockers) is logged by the bridge. ++ ++### Answer extraction ++ ++The final action is located with the official adapters' candidate enumeration: ++`_json_candidates` and `_parse_action_text` are copied verbatim from ++`src/systems/claude/system.py` into `src/systems/ouroboros/action_parse.py` (the Codex ++adapter carries the same two functions), and a test pins the copies equal to the claude ++adapter. The live locator (`_final_answer_action`) walks `_json_candidates` from the most ++recent span and accepts the first that passes the shared `validate_with_coercion`. It ++re-implements the walk inline rather than calling `_parse_action_text`, and so differs from ++it in two ways: a candidate equal, ignoring `notes`, to the previously delivered payload is ++skipped while the current benchmark question has recorded no protocol step, and a walk that ++finds nothing returns None instead of raising the last validation error or retrying on ++`extract_json` of the whole text. `_parse_action_text` itself is used on the format-repair ++path and in the per-action loop. One property is inherited from the official parser: a ++fenced block is enumerated before the raw spans and duplicates keep their first position, ++so when the final answer is fenced and an earlier unfenced draft also validates, the draft ++wins the walk. ++ ++The source text is up to the last 40 assistant outputs of the engine task, oldest to newest ++(`_task_assistant_text`; an unreadable observability directory degrades the source to the ++engine fields alone), followed by the engine's `result` and `final_answer`. If the walk finds ++nothing, two fallbacks follow: a schema-coercion model call over the final text ++(`_coerce_final`; cost recorded), which has no Claude Code analogue, and one in-container ++format-repair task (`_format_repair_action`; `OUROBOROS_FORMAT_REPAIR=0` disables), which ++mirrors the repair turn the Claude and Codex adapters run on a parse failure. Every pilot ++result produced so far, including the 2026-09-03 pair, used the previous locator (typed ++`final_answer`, then the last JSON span in the final text, strict schema validation). ++ ++### Configuration deltas ++ ++The pilot environment differs from the submitted block in `OUROBOROS_EFFORT_TASK=low` ++(submitted: `high`; the launcher applies one effort to all task types), ++`OUROBOROS_RESTART_DRAIN_MAX_SEC=1800`, `OUROBOROS_BENCH_SKIP_BASELINE=1`, and ++`CLBENCH_SHIM_BIND` unset on the pilot host (Docker Desktop, where the adapter default ++`127.0.0.1` is rewritten to `host.docker.internal` for the container). The remaining 17 of ++the 19 published keys are identical; the pilot scripts additionally export ++`OUROBOROS_BENCH_RUNS_ROOT`. Since 2026-08-30 `_overrides` also writes a same-model ++configured-subagent roster (`clbench-scope-scout`) into `OUROBOROS_SUBAGENTS` and pins ++`OUROBOROS_REVIEWER_SLOTS`: the triad as three `api_chat` rows on the solve model, scope and ++advisory through the scout. Without the pin the isolated settings inherit the host's ++structured reviewer slots, which then override the flat single-model review keys. A ++subagent route is therefore in use even though the solving agent still delegates nothing ++(depth 0 gates the `schedule_subagent` tool, and every route resolves to the same model). ++Turn-1 responses carry a `config_attestation` of the resolved host keys in ++`Response.metadata`. +diff --git a/src/systems/ouroboros/_docker_launcher.py b/src/systems/ouroboros/_docker_launcher.py +index af9165e..0019d84 100644 +--- a/src/systems/ouroboros/_docker_launcher.py ++++ b/src/systems/ouroboros/_docker_launcher.py +@@ -182,11 +182,9 @@ def _write_forced_evolution_request(data_root: pathlib.Path, objective: str) -> + try: + if req.exists(): + return False +- st = {} +- try: +- st = json.loads((state_dir / "state.json").read_text(encoding="utf-8")) +- except Exception: +- pass ++ st = json.loads((state_dir / "state.json").read_text(encoding="utf-8")) ++ if not isinstance(st, dict): ++ raise ValueError("state.json is not an object") + if bool(st.get("evolution_mode_enabled")): + return False # a previous window's cycle is still in flight + payload = { +@@ -208,6 +206,85 @@ def _write_forced_evolution_request(data_root: pathlib.Path, objective: str) -> + return False + + ++_BOUNDARY_WAIT_CAP_SEC = 1800.0 # shared evolution/restart/auto-resume budget ++_BOUNDARY_POLL_SEC = 8.0 ++_BOUNDARY_BASELINE_CAP_SEC = 32.0 # leave time for request consumption after telemetry recovery ++_CYCLE_OUTCOMES = {"absorbed", "waiting_for_restart", "no_op", "abandoned"} ++ ++ ++def _boundary_api_shape_issues(state: dict) -> list[str]: ++ """Check the gateway field contract separately from observed activity.""" ++ issues = [] ++ fields = { ++ "supervisor_ready": bool, ++ "workers_alive": int, ++ "evolution_enabled": bool, ++ "pending_count": int, ++ "running_count": int, ++ "evolution_state": dict, ++ "active_direct_turns": list, ++ "active_chat_activities": list, ++ } ++ for key, expected in fields.items(): ++ if key not in state: ++ issues.append(f"missing API field: {key}") ++ elif type(state[key]) is not expected: ++ issues.append(f"invalid API field: {key}") ++ evolution = state.get("evolution_state") ++ if isinstance(evolution, dict): ++ for key in ("queued_task_id", "running_task_id"): ++ if key not in evolution: ++ issues.append(f"missing API field: evolution_state.{key}") ++ elif evolution[key] is not None and not isinstance(evolution[key], str): ++ issues.append(f"invalid API field: evolution_state.{key}") ++ return issues ++ ++ ++def _transaction_restart_unresolved(tx: dict) -> bool: ++ """Match the engine's committed-transaction restart obligation.""" ++ return bool(str(tx.get("commit_sha") or "").strip()) and ( ++ bool(tx.get("restart_required")) or not bool(tx.get("restart_verified")) ++ ) ++ ++ ++def _campaign_transactions(campaign: dict, blockers: list[str]) -> dict: ++ """Index durable transactions by campaign, transaction AND task identity.""" ++ transactions = {} ++ rows = [] ++ for field in ("history", "transaction_history"): ++ history = campaign.get(field, []) ++ if not isinstance(history, list): ++ blockers.append(f"invalid campaign {field}") ++ continue ++ for index, row in enumerate(history): ++ location = f"{field}[{index}]" ++ if field == "history" and isinstance(row, dict): ++ # The lifecycle explicitly preserves legacy history-only rows. ++ if "transaction" not in row: ++ continue ++ tx = row["transaction"] ++ if not isinstance(tx, dict) or row.get("task_id") != tx.get("task_id"): ++ blockers.append(f"invalid campaign {location} transaction") ++ continue ++ row = tx ++ rows.append((location, row)) ++ active = campaign.get("active_transaction", {}) ++ if not isinstance(active, dict): ++ raise ValueError("invalid active transaction") ++ if active: ++ rows.append(("active_transaction", active)) ++ for location, tx in rows: ++ if not isinstance(tx, dict): ++ blockers.append(f"invalid campaign {location}") ++ continue ++ key = tuple(tx.get(k) for k in ("campaign_id", "transaction_id", "task_id")) ++ if not all(isinstance(k, str) and k.strip() for k in key): ++ blockers.append(f"missing transaction identity in {location}") ++ continue ++ transactions[key] = tx ++ return transactions ++ ++ + class DockerOuroborosEngine: + def __init__(self, *, ouroboros_repo: str, model: str, mode: str, evolution: bool, + steer: str = "", task_timeout_sec: int = 900, primer: str = "", +@@ -230,6 +307,7 @@ class DockerOuroborosEngine: + self._server = None + self._cname = None + self._started = False ++ self._boundary_wait = None + self.runtime_attestation: dict = {} + # chain conversation-resume across run_turn calls (CC single_conversation). + # DEFAULT mode = continuation (true CC --resume analog: stored system verbatim, +@@ -239,12 +317,11 @@ class DockerOuroborosEngine: + self.resume_mode = ("splice" if _r == "splice" else "continuation") if self.resume else "" + self._last_task_id = None + from devtools.benchmarks.common.server_runner import ( # noqa: E402 +- build_isolated_settings, seed_owner_state, free_port, absorbed_cycles_done) ++ build_isolated_settings, seed_owner_state, free_port) + from supervisor.state import reset_per_task_budget, ISOLATED_BENCHMARK_SENTINEL # noqa: E402 + self._build_isolated_settings = build_isolated_settings + self._seed_owner_state = seed_owner_state + self._free_port = free_port +- self._absorbed_cycles_done = absorbed_cycles_done + self._reset_per_task_budget = reset_per_task_budget + self._SENTINEL = ISOLATED_BENCHMARK_SENTINEL + +@@ -277,6 +354,13 @@ class DockerOuroborosEngine: + OPENROUTER_API_KEY=os.environ.get("OPENROUTER_API_KEY", ""), + OUROBOROS_RUNTIME_MODE="advanced", OUROBOROS_PRE_PUSH_TESTS="0", + OUROBOROS_POST_TASK_EVOLUTION="true" if self.evolution else "false", ++ # Absorption-restart drain: the core DOES defer a requested restart while ++ # tasks run, but its default deadline is 120s (config.py SETTINGS_DEFAULTS) ++ # — far shorter than a bench question task (~6 min observed). At the ++ # deadline it proceeds fail-closed and chopped a reviewed answer 6s after ++ # its acceptance PASS (2026-08-30, q_index=3). 1800 = the server's own ++ # clamp ceiling; with it the drain outlives any in-flight question. ++ OUROBOROS_RESTART_DRAIN_MAX_SEC="1800", + OUROBOROS_MODEL=m, OUROBOROS_MODEL_HEAVY=m, OUROBOROS_MODEL_LIGHT=m, OUROBOROS_MODEL_CODE=m, + OUROBOROS_MODEL_CONSCIOUSNESS=m, OUROBOROS_MODEL_FALLBACK=m, OUROBOROS_MODEL_FALLBACKS=m, + OUROBOROS_MODEL_DEEP_SELF_REVIEW=m, OUROBOROS_REVIEW_MODELS=",".join([m, m, m]), +@@ -288,7 +372,10 @@ class DockerOuroborosEngine: + OUROBOROS_SERVER_HOST="0.0.0.0", OUROBOROS_SERVER_PORT="8765", OUROBOROS_HOST_SERVICE_PORT="8767", + ) + if self.evolution: +- ov["OUROBOROS_POST_TASK_EVOLUTION_CADENCE"] = self.cadence ++ # Keep request consumption enabled, but let only the boundary adapter ++ # publish forced promotions; worker completion must not chain campaigns. ++ ov["OUROBOROS_POST_TASK_EVOLUTION_CADENCE"] = ( ++ "off" if self.evolution_trigger == "forced" else self.cadence) + # Opt-in lost-update guard (engine ≥ our 655 clone): at task-done, an + # ahead HEAD descending from tx.base_head restores a clobbered commit + # registration instead of no_op'ing a good reviewed commit. Safe here: +@@ -328,6 +415,61 @@ class DockerOuroborosEngine: + for _rk, _dflt in (("OUROBOROS_TASK_REVIEW_MODE", "auto"), + ("OUROBOROS_REVIEW_ENFORCEMENT", "advisory")): + ov[_rk] = os.environ.get(_rk, "").strip() or _dflt ++ # SINGLE-MODEL PIN, part 2: the DESKTOP's structured reviewer slots leak in via ++ # the isolated-settings allowlist (server_runner.py lists OUROBOROS_REVIEWER_SLOTS) ++ # and reviewer_slot_config.py:583-608 projects them OVER the flat ++ # OUROBOROS_REVIEW_MODELS / OUROBOROS_SCOPE_REVIEW_MODEL(S) keys we set above — ++ # so a desktop triad (e.g. gpt-5.6-luna / gemini-3.6-flash / sonnet-5 at effort ++ # high) silently becomes the reviewer set of a run declared single-model. ++ # Observed live: CL-Bench evolution arm 2026-08-27, scope reviewer resolved to ++ # openai/gpt-5.6-luna despite every flat key pinning claude-sonnet-4.6. ++ # Reviewer routing, pinned EXPLICITLY (was: blanked). Two desktop keys leak into ++ # the isolated settings through the allowlist (server_runner.py lists both ++ # OUROBOROS_REVIEWER_SLOTS and OUROBOROS_SUBAGENTS) and reviewer_slot_config ++ # projects the structured slots OVER the flat model keys — observed live ++ # 2026-08-27: a run declared single-model reviewed with gpt-5.6-luna / ++ # gemini-3.6-flash / sonnet-5 at effort high. ++ # ++ # Blanking restored the pin but also forfeited the RETRIEVES delivery class: ++ # a scope row that references a configured-subagent roster id on an api_model ++ # route gets native_retrieval=True (reviewer_slot_config: "a session row and a ++ # native-retrieval actor row are one class here, and neither receives an ++ # assembled packet"), which is the only way a commit can pass the blocking ++ # scope gate on a repo this size — the assembled pack needs ~677K tokens of ++ # mandatory artifacts against a ~576K budget. The retrieving row's floor is ++ # 200K with sourced provenance instead of the api row's 1M. ++ # ++ # So: pin BOTH keys to the solve model, and route the scope slot through a ++ # one-row roster on that same model. Single-model holds; the scope reviewer ++ # retrieves instead of being handed a pack it cannot fit. ++ _scout_id = "clbench-scope-scout" ++ ov["OUROBOROS_SUBAGENTS"] = json.dumps({ ++ "enabled": True, ++ "items": [{ ++ "subagent_id": _scout_id, ++ "name": "CL-Bench scope scout", ++ "recommended_use": "Blocking scope review for the benchmark run; retrieves " ++ "the subject with its own tools instead of a packed atlas.", ++ "route": {"kind": "api_model", "target_id": m}, ++ "effort": _eff, ++ }], ++ }, ensure_ascii=False) ++ ov["OUROBOROS_REVIEWER_SLOTS"] = json.dumps({ ++ "triad": [ ++ {"slot_id": f"clbench_t{i}", ++ "route": {"kind": "api_chat", "target_id": m}, ++ "effort": _eff} ++ for i in (1, 2, 3) ++ ], ++ "scope": [{"slot_id": "clbench_scope", "subagent_id": _scout_id, "effort": _eff}], ++ # Advisory too: absent section defaults to api_chat, whose inline ++ # else-branch loads docs/ARCHITECTURE.md (~640KB) unconditionally and ++ # overflowed the route window mid-evolution (the agent diagnosed and ++ # patched exactly this in its own cycle, commit 97b8c6be). The ++ # subagent reference puts advisory on the same retrieving class. ++ "advisory": {"subagent_id": _scout_id, "effort": _eff}, ++ }, ensure_ascii=False) ++ + # Operator env overrides (campaign knobs, ported from the v6.81.0 bridge campaign). + # Parity defaults above stay authoritative unless the host explicitly exports one of + # these — then the export wins, so a campaign can pin e.g. runtime=pro / split +@@ -596,8 +738,446 @@ class DockerOuroborosEngine: + transcript — so the rollout is memory-driven, not whole-rollout ICL (bounds cost/disk/time too).""" + self._last_task_id = None + ++ def _boundary_file_blockers(self) -> list[str]: ++ """Request consumption and restart claiming are not completion signals.""" ++ state_dir = self._data_root / "state" ++ blockers = [] ++ try: ++ if (state_dir / "post_task_evolution_request.json").exists(): ++ blockers.append("promotion request pending") ++ markers = [ ++ p.name ++ for p in state_dir.iterdir() ++ if p.name == "pending_restart_verify.json" ++ or ( ++ p.name.startswith("pending_restart_verify.claimed.") ++ and p.name.endswith(".json") ++ ) ++ ] ++ blockers.extend(f"restart marker: {name}" for name in sorted(markers)) ++ state = json.loads((state_dir / "state.json").read_text(encoding="utf-8")) ++ if not isinstance(state, dict): ++ raise ValueError("state.json is not an object") ++ # The engine defaults an absent flag to off, including at publication. ++ if bool(state.get("evolution_mode_enabled")): ++ blockers.append("evolution_mode_enabled true") ++ except (OSError, ValueError) as exc: ++ blockers.append(f"state files unreadable: {exc}") ++ return blockers ++ ++ def _boundary_observation(self, ctx: dict, *, probe_deadline=None) -> dict: ++ """Read campaign and activity, retaining legacy compatibility per boundary.""" ++ blockers = self._boundary_file_blockers() ++ campaign, transactions, campaign_valid = None, {}, True ++ try: ++ raw = (self._data_root / "state" / "evolution_campaign.json").read_text( ++ encoding="utf-8" ++ ) ++ ctx["campaign_seen"] = True ++ campaign = json.loads(raw) ++ if ( ++ not isinstance(campaign, dict) ++ or not isinstance(campaign.get("id"), str) ++ or not campaign["id"].strip() ++ ): ++ raise ValueError("missing campaign identity") ++ for key in ("cycles_done", "absorbed_cycles_done"): ++ if type(campaign.get(key)) is not int or campaign[key] < 0: ++ raise ValueError(f"invalid {key}") ++ transaction_blockers = [] ++ transactions = _campaign_transactions(campaign, transaction_blockers) ++ blockers.extend(transaction_blockers) ++ campaign_valid = not transaction_blockers ++ active = campaign.get("active_transaction", {}) ++ # Retain identity evidence even while the baseline API is unavailable; ++ # a later missing campaign/transaction cannot become a zero baseline. ++ for key, tx in transactions.items(): ++ if tx is not active and tx.get("cycle_outcome") in ( ++ "absorbed", ++ "no_op", ++ "abandoned", ++ ): ++ # Startup abandonment retains commit_sha without verification. ++ # Its terminal archive discharges the previously observed work. ++ ctx["outstanding"].discard(key) ++ elif _transaction_restart_unresolved(tx) and ( ++ tx is active or key in ctx["outstanding"] ++ ): ++ ctx["outstanding"].add(key) ++ if tx is not active: ++ blockers.append("observed transaction restart unresolved") ++ elif tx.get("cycle_outcome") in _CYCLE_OUTCOMES: ++ ctx["outstanding"].discard(key) ++ elif tx is active: ++ ctx["outstanding"].add(key) ++ if ctx["outstanding"] - transactions.keys(): ++ campaign_valid = False ++ blockers.append("known transaction missing without terminal outcome") ++ # Match the engine's CURRENT-transaction restart predicate. An archived ++ # waiting_for_restart record must not block an unrelated later round. ++ if active and _transaction_restart_unresolved(active): ++ blockers.append("active transaction restart unresolved") ++ except FileNotFoundError: ++ if ctx.get("campaign_seen"): ++ campaign_valid = False ++ blockers.append("known campaign missing") ++ except (OSError, ValueError, TypeError) as exc: ++ campaign = None ++ campaign_valid = False ++ blockers.append(f"campaign unreadable: {exc}") ++ st = {} ++ remaining = ( ++ ctx["deadline"] if probe_deadline is None else probe_deadline ++ ) - time.monotonic() ++ if remaining > 0: ++ try: ++ st = _api( ++ self._server.base_url, ++ "GET", ++ "/api/state", ++ timeout=min(_BOUNDARY_POLL_SEC, remaining), ++ ) ++ if not isinstance(st, dict): ++ raise ValueError("state API is not an object") ++ # Recheck legacy telemetry until the full contract is observed. ++ # Once established, missing fields remain blockers in this window. ++ if not ctx["api_shape_checked"] or ctx["legacy_readiness"]: ++ ctx["api_shape_checked"] = True ++ issues = _boundary_api_shape_issues(st) ++ was_legacy = ctx["legacy_readiness"] ++ ctx["legacy_readiness"] = bool(issues) ++ if was_legacy != ctx["legacy_readiness"]: ++ ctx["quiet"] = 0 ++ if issues and not was_legacy: ++ print( ++ "[ouroboros-docker] unrecognized /api/state shape: " ++ f"{', '.join(issues)}; using legacy counter-based readiness " ++ "until full telemetry returns or this wait window ends", ++ file=sys.stderr, ++ flush=True, ++ ) ++ elif not issues and was_legacy: ++ print( ++ "[ouroboros-docker] /api/state shape recovered; " ++ "using full readiness checks", ++ file=sys.stderr, ++ flush=True, ++ ) ++ legacy = ctx["legacy_readiness"] ++ # Readiness must be the literal True while the shape is trusted. Under ++ # legacy a MISSING key is unknown (the shape check already named it and ++ # the counter fallback carries the boundary), and a truthy value of the ++ # wrong type ("true", 1) is positive evidence in an unexpected shape; ++ # a PRESENT falsy value ("", 0, False) is a "not ready" answer and blocks ++ # in both modes, so legacy never reads a negative as readiness. ++ ready = st.get("supervisor_ready") ++ if ready is not True and not ( ++ legacy ++ and ("supervisor_ready" not in st ++ or (not isinstance(ready, bool) and bool(ready))) ++ ): ++ blockers.append("supervisor not ready") ++ if st.get("supervisor_error"): ++ blockers.append(f"supervisor error: {st['supervisor_error']}") ++ alive = st.get("workers_alive") ++ if legacy and alive is None: ++ alive = st.get("workers_total") ++ if type(alive) is not int or alive <= 0: ++ blockers.append("no live workers or unknown") ++ # Decide by type first: only a bool answers the question. A truthy ++ # non-bool ("true", 1, [..]) is positive evidence in an unexpected ++ # shape and blocks even under legacy; a falsy non-bool ("", [], 0) ++ # is unknown, which blocks only while the shape is trusted. ++ enabled = st.get("evolution_enabled") ++ if enabled is True: ++ blockers.append("API evolution_enabled true") ++ elif enabled is not False and not isinstance(enabled, bool) and enabled: ++ blockers.append("API evolution_enabled true in unexpected shape") ++ elif enabled is not False and not legacy: ++ blockers.append("API evolution_enabled unknown") ++ for key in ("pending_count", "running_count"): ++ if legacy: ++ count = st.get(key, 0) ++ try: ++ if isinstance(count, str): ++ count = int(count) ++ idle = type(count) is int and count == 0 ++ except ValueError: ++ idle = False ++ else: ++ idle = type(st.get(key)) is int and st[key] == 0 ++ if not idle: ++ blockers.append(f"{key}={st.get(key)!r}") ++ evolution = st.get("evolution_state") ++ # The gateway emits these fields even while idle. Missing activity ++ # telemetry is unknown; name it separately from observed work. ++ if "evolution_state" not in st: ++ if not legacy: ++ blockers.append("missing API field: evolution_state") ++ elif not isinstance(evolution, dict): ++ if evolution: ++ blockers.append("evolution_state activity in unexpected shape") ++ elif not legacy or ( ++ evolution is not None and not isinstance(evolution, (str, list)) ++ ): ++ blockers.append("invalid API field: evolution_state") ++ else: ++ for key in ("queued_task_id", "running_task_id"): ++ if key not in evolution: ++ if not legacy: ++ blockers.append(f"missing API field: evolution_state.{key}") ++ elif evolution[key]: ++ activity = ( ++ "active" if isinstance(evolution[key], str) ++ else "activity in unexpected shape" ++ ) ++ blockers.append(f"evolution_state.{key} {activity}") ++ elif evolution[key] is not None and not isinstance(evolution[key], str): ++ if not legacy or not isinstance(evolution[key], (list, dict)): ++ blockers.append(f"invalid API field: evolution_state.{key}") ++ for key in ("active_direct_turns", "active_chat_activities"): ++ if key not in st: ++ if not legacy: ++ blockers.append(f"missing API field: {key}") ++ elif st[key]: ++ activity = ( ++ "active" if isinstance(st[key], list) ++ else "activity in unexpected shape" ++ ) ++ blockers.append(f"{key} {activity}") ++ elif not isinstance(st[key], list): ++ if not legacy or ( ++ st[key] is not None and not isinstance(st[key], (str, dict)) ++ ): ++ blockers.append(f"invalid API field: {key}") ++ except Exception as exc: # noqa: BLE001 — transient server re-exec ++ st = {} ++ blockers.append(f"state API unreadable: {exc}") ++ else: ++ blockers.append("state API probe deadline expired") ++ # The API call can span request consumption or marker publication. Recheck ++ # host guards before counting even the first quiet sample. ++ if not blockers: ++ blockers.extend(self._boundary_file_blockers()) ++ return { ++ "campaign": campaign, ++ "campaign_valid": campaign_valid, ++ "transactions": transactions, ++ "state": st, ++ "blockers": blockers, ++ } ++ ++ def _begin_boundary_wait(self) -> dict: ++ started = time.monotonic() ++ ctx = { ++ "started": started, ++ "deadline": started + _BOUNDARY_WAIT_CAP_SEC, ++ "quiet": 0, ++ "campaign_seen": False, ++ "api_shape_checked": False, ++ "legacy_readiness": False, ++ "outstanding": set(), ++ "cycle_outcome": "", ++ "cycle_done": False, ++ "absorbed": False, ++ "cycles": None, ++ "cycles_done": None, ++ "last": None, ++ } ++ self._boundary_wait = ctx ++ # Baseline must precede publication: a fast consumer can complete a new ++ # campaign (whose counters reset) before the next host observation. ++ baseline_deadline = min(ctx["deadline"], started + _BOUNDARY_BASELINE_CAP_SEC) ++ baseline = self._boundary_observation(ctx, probe_deadline=baseline_deadline) ++ captured = baseline if baseline["campaign_valid"] else None ++ captured_state = baseline["state"] ++ while time.monotonic() < baseline_deadline: ++ st = baseline["state"] ++ if baseline["campaign_valid"] and st: ++ break ++ remaining = baseline_deadline - time.monotonic() ++ if remaining <= 0: ++ break ++ time.sleep(min(_BOUNDARY_POLL_SEC, remaining)) ++ baseline = self._boundary_observation(ctx, probe_deadline=baseline_deadline) ++ if baseline["state"]: ++ captured_state = baseline["state"] ++ if baseline["campaign_valid"]: ++ captured = baseline ++ # Keep every readable pre-publication identity/counter even if its API read ++ # failed. An unknown campaign baseline must never become zero progress. ++ ctx["baseline"] = dict(captured or baseline, state=captured_state) ++ ctx["progress_baseline"] = ( ++ ctx["baseline"] if baseline["campaign_valid"] else baseline ++ ) ++ ctx["baseline_uncertain"] = not baseline["campaign_valid"] ++ campaign = ctx["baseline"]["campaign"] ++ if campaign is not None: ++ ctx["cycles"] = campaign["absorbed_cycles_done"] ++ ctx["cycles_done"] = campaign["cycles_done"] ++ if not captured or not baseline["state"]: ++ print( ++ f"[ouroboros-docker] baseline capture incomplete after " ++ f"{time.monotonic() - started:.1f}s; BLOCKERS: {', '.join(baseline['blockers'])}; " ++ "continuing with observed baseline only", ++ file=sys.stderr, ++ flush=True, ++ ) ++ self._track_boundary_observation(ctx, baseline) ++ ctx["quiet"] = 0 # the pre-publication sample never confirms the new window ++ return ctx ++ ++ def _track_boundary_observation(self, ctx: dict, observation: dict) -> None: ++ campaign, st = observation["campaign"], observation["state"] ++ blockers = observation["blockers"] ++ if campaign is not None: ++ ctx["campaign_seen"] = True ++ ctx["cycles"] = campaign["absorbed_cycles_done"] ++ ctx["cycles_done"] = campaign["cycles_done"] ++ if ( ++ not ctx["progress_baseline"]["campaign_valid"] ++ and observation["campaign_valid"] ++ ): ++ # A recovered snapshot is a baseline, never evidence of progress. ++ # Idle readiness does not require attribution to a new cycle. ++ ctx["progress_baseline"] = observation ++ progress_baseline = ctx["progress_baseline"] ++ baseline = progress_baseline["campaign"] ++ same_campaign = baseline is not None and campaign["id"] == baseline["id"] ++ prev_cycles = baseline["cycles_done"] if same_campaign else 0 ++ prev_absorbed = baseline["absorbed_cycles_done"] if same_campaign else 0 ++ can_compare = progress_baseline["campaign_valid"] ++ ctx["cycle_done"] |= can_compare and campaign["cycles_done"] > prev_cycles ++ if ( ++ can_compare ++ and campaign["absorbed_cycles_done"] > prev_absorbed ++ and st.get("sha") ++ and progress_baseline["state"].get("sha") ++ and st["sha"] != progress_baseline["state"]["sha"] ++ ): ++ ctx["absorbed"] = True ++ transactions = observation["transactions"] ++ for key, tx in transactions.items(): ++ if key[0] != campaign["id"]: ++ continue # archived campaigns cannot earn this boundary's outcome ++ outcome = tx.get("cycle_outcome") ++ if can_compare and outcome in _CYCLE_OUTCOMES: ++ previous = progress_baseline["transactions"].get(key, {}) ++ if not previous or ( ++ previous is (baseline or {}).get("active_transaction") ++ and outcome != previous.get("cycle_outcome") ++ ): ++ ctx["cycle_outcome"] = outcome ++ ctx["cycle_done"] = True ++ ctx["absorbed"] |= outcome == "absorbed" ++ if observation["campaign_valid"] or ctx["cycle_done"]: ++ ctx["baseline_uncertain"] = False ++ if ctx["baseline_uncertain"]: ++ blockers.append( ++ "pre-publication campaign baseline unavailable; outcome unconfirmed" ++ ) ++ last = ctx["last"] ++ if last: ++ sha, prev_sha = st.get("sha"), last["state"].get("sha") ++ if sha and prev_sha and sha != prev_sha: ++ blockers.append("SHA changed") ++ uptime, prev_uptime = st.get("uptime"), last["state"].get("uptime") ++ if ( ++ type(uptime) in (int, float) ++ and type(prev_uptime) in (int, float) ++ and 0 <= uptime < float("inf") ++ and 0 <= prev_uptime < float("inf") ++ and uptime < prev_uptime ++ ): ++ blockers.append("uptime reset") ++ # Even disarmed snapshots must be stable across the confirmation window. ++ # Counter/campaign churn cannot preserve an earlier quiet observation. ++ if campaign != last["campaign"]: ++ ctx["quiet"] = 0 ++ ctx["quiet"] = 0 if blockers else ctx["quiet"] + 1 ++ ctx["last"] = observation ++ ctx["blockers"] = blockers ++ ++ def _boundary_result( ++ self, ctx: dict, *, timed_out: bool = False, started=None ++ ) -> dict: ++ absorbed = ctx["absorbed"] and not timed_out ++ reason = ( ++ "timeout" ++ if timed_out ++ else "absorbed" ++ if absorbed ++ else "cycle_done_unabsorbed" ++ if ctx["cycle_done"] ++ else "no_promotion" ++ ) ++ result = { ++ "absorbed": absorbed, ++ "new_sha": str(ctx["last"]["state"].get("sha") or "")[:8], ++ "cycles": ctx["cycles"], ++ "reason": reason, ++ "cycle_outcome": ctx["cycle_outcome"], ++ "cycles_done": ctx["cycles_done"], ++ "elapsed": time.monotonic() ++ - (ctx["started"] if started is None else started), ++ "budget_elapsed": time.monotonic() - ctx["started"], ++ "blockers": list(ctx["blockers"]), ++ } ++ if timed_out: ++ result["blockers"] = list( ++ dict.fromkeys(result["blockers"] + self._boundary_file_blockers()) ++ ) ++ if not result["blockers"]: ++ result["blockers"] = ["quiet confirmation incomplete at deadline"] ++ print( ++ f"[ouroboros-docker] BOUNDARY TIMEOUT after {result['elapsed']:.1f}s; " ++ f"budget_elapsed={result['budget_elapsed']:.1f}s; " ++ f"cycle_outcome={result['cycle_outcome'] or 'unknown'} " ++ f"cycles_done={result['cycles_done']} absorbed_cycles={result['cycles']}; " ++ f"BLOCKERS: {', '.join(result['blockers'])}; proceeding at boundary limit", ++ file=sys.stderr, ++ flush=True, ++ ) ++ return result ++ ++ def _wait_for_boundary_readiness(self, *, renew_expired: bool = True) -> dict: ++ """Read-only gate reused by restart, reopen and immediate pre-POST checks. ++ ++ A later invocation can outlive the stored window and needs its own budget. ++ Polling and explicit continuations of an existing wait never renew it. ++ """ ++ started = time.monotonic() ++ ctx = getattr(self, "_boundary_wait", None) ++ if ctx is None: ++ ctx = self._begin_boundary_wait() ++ elif renew_expired and started >= ctx["deadline"]: ++ ctx["started"] = started ++ ctx["deadline"] = started + _BOUNDARY_WAIT_CAP_SEC ++ ctx["api_shape_checked"] = False ++ ctx["legacy_readiness"] = False ++ ctx["quiet"] = 0 # each invocation needs its own confirmation window ++ if time.monotonic() >= ctx["deadline"]: ++ # Refresh host guards at expiry, but do not grant another HTTP timeout. ++ # The timeout exception permits submission with API readiness unknown. ++ observation = self._boundary_observation(ctx) ++ self._track_boundary_observation(ctx, observation) ++ return self._boundary_result(ctx, timed_out=True, started=started) ++ while time.monotonic() < ctx["deadline"]: ++ observation = self._boundary_observation(ctx) ++ self._track_boundary_observation(ctx, observation) ++ if time.monotonic() >= ctx["deadline"]: ++ break ++ if ctx["quiet"] >= 2 or ( ++ ctx["legacy_readiness"] and ctx["cycle_done"] and not ctx["blockers"] ++ ): ++ return self._boundary_result(ctx, started=started) ++ remaining = ctx["deadline"] - time.monotonic() ++ if remaining > 0: ++ time.sleep(min(_BOUNDARY_POLL_SEC, remaining)) ++ return self._boundary_result(ctx, timed_out=True, started=started) ++ + def on_instance_boundary(self) -> dict: +- """Stateful+evolution: reset per-task budget + await an absorbed cycle (host-side data dir).""" ++ """Reset the task budget, publish once, then wait for evolution and restart idle.""" + if not self._started: + return {} + try: +@@ -606,32 +1186,19 @@ class DockerOuroborosEngine: + pass + if not self.evolution: + return {} ++ ctx = self._begin_boundary_wait() ++ if time.monotonic() >= ctx["deadline"]: ++ return self._boundary_result(ctx, timed_out=True) + if self.evolution_trigger == "forced": +- # Deterministic window: file the promotion signal ourselves instead of +- # relying on the native post-task daemon thread (races the worker +- # lifecycle at boundaries; declines on an empty backlog even forced). + wrote = _write_forced_evolution_request(self._data_root, self.steer) +- print(f"[ouroboros-docker] forced evolution window: request " +- f"{'written' if wrote else 'skipped (pending/in-flight)'}", file=sys.stderr) +- prev_sha = self._server.current_sha() +- prev_abs = self._absorbed_cycles_done(self._data_root) +- deadline = time.time() + self.task_timeout +- req = self._data_root / "state" / "post_task_evolution_request.json" +- while time.time() < deadline: +- cur_abs = self._absorbed_cycles_done(self._data_root) +- cur_sha = self._server.current_sha() +- if cur_abs > prev_abs and cur_sha and cur_sha != prev_sha: +- return {"absorbed": True, "new_sha": cur_sha[:8], "cycles": cur_abs} +- # honest no-promotion early-out: nothing queued + queue idle +- if not req.exists(): +- try: +- st = _api(self._server.base_url, "GET", "/api/state", timeout=8) +- if int(st.get("running_count") or 0) == 0: +- return {"absorbed": False, "reason": "no_promotion", "cycles": cur_abs} +- except Exception: +- pass +- time.sleep(8) +- return {"absorbed": False, "reason": "timeout", "cycles": self._absorbed_cycles_done(self._data_root)} ++ print( ++ f"[ouroboros-docker] forced evolution window: request " ++ f"{'written' if wrote else 'skipped (pending/in-flight)'}", ++ file=sys.stderr, ++ ) ++ result = self._wait_for_boundary_readiness(renew_expired=False) ++ result["elapsed"] = time.monotonic() - ctx["started"] ++ return result + + def close(self) -> None: + if self._cname: +diff --git a/src/systems/ouroboros/_live_bridge.py b/src/systems/ouroboros/_live_bridge.py +index 5f9d3b7..09f52ce 100644 +--- a/src/systems/ouroboros/_live_bridge.py ++++ b/src/systems/ouroboros/_live_bridge.py +@@ -48,6 +48,8 @@ from typing import Any, Optional + + from pydantic import BaseModel, ValidationError + ++from ._docker_launcher import _BOUNDARY_WAIT_CAP_SEC ++ + # How long the /step handler waits for the runner to deliver the next observation. + # The runner's turnaround (task.step on SQLite + bookkeeping) is milliseconds; this + # only trips if the run is wedged. Must stay below the skill's curl --max-time 120. +@@ -361,6 +363,24 @@ class LiveBridge: + return {"message": self._message_locked(), "done": self._done} + + ++def _restart_pending_refusal(exc: Exception) -> bool: ++ """True when the engine refused a submit because a restart is armed. ++ ++ The restart-admission fence (present in the local 6.113.5 engine branch, supervisor/task_admission.py; not in public releases through 6.114.0) answers ++ 503 with a retryable receipt and writes no durable row. Match on the HTTP status plus ++ the typed hint so an ordinary 503 (server re-exec) keeps the fast-retry path. ++ """ ++ import urllib.error ++ if not isinstance(exc, urllib.error.HTTPError) or exc.code != 503: ++ return False ++ try: ++ body = exc.read().decode("utf-8", "replace") ++ except Exception: # noqa: BLE001 — body already consumed or unreadable ++ return True # a 503 we cannot inspect: prefer waiting over burning retries ++ low = body.lower() ++ return ("restart" in low) or ('"retryable": true' in low.replace(" ", "")) or ("retryable" in low) ++ ++ + class LiveController: + """Per-System orchestration: engine + bridge + one live Ouroboros task per scope. + +@@ -419,7 +439,10 @@ class LiveController: + self._published = False + self._boundary_pending = False # question scope: previous question just ended + self._reopen_pending = False # dead-task recovery: reopen scope in ANY conversation mode ++ self._restart_retry_done = False # one retry per scope when an absorption restart kills the task + self._evolution_window_due = False # set ONLY at legit boundaries, never on abandon ++ self._restart_wait_deadline: Optional[float] = None ++ self._restart_wait_started: Optional[float] = None + self._turns = 0 + self._consec_fallbacks = 0 + self._fallback_instance: Optional[int] = None +@@ -568,10 +591,12 @@ class LiveController: + try: + # Mirrors the per-action path's boundary hook (#6): per-task budget + # reset (docker: best-effort no-op) + evolution absorb wait if enabled. +- self.engine.on_instance_boundary() ++ result = self.engine.on_instance_boundary() ++ self._log_boundary_result("evolution boundary", result) + except Exception as exc: # noqa: BLE001 + _log(f"on_instance_boundary failed (continuing): {exc}") + self._evolution_window_due = False ++ self._restart_retry_done = False + self.bridge.begin(query) + self._submit_task(query) + self._boundary_pending = False +@@ -638,6 +663,20 @@ class LiveController: + _log(f"task {self._task_id} terminal ({status}) but a typed FINAL " + f"ANSWER survived — salvaged instead of abandoning") + return final_action ++ if (status == "cancelled" and not self._restart_retry_done ++ and query is not None and self._cancelled_by_restart()): ++ # Absorption-restart casualty (2026-08-30): the answer existed and ++ # passed review, the restart tore down workers before the latch. ++ # One retry per scope: wait out the restart, reopen, resubmit. ++ self._restart_retry_done = True ++ _log(f"task {self._task_id} cancelled BY ENGINE RESTART — retrying the " ++ f"scope once instead of abandoning") ++ self._await_pending_restart() ++ self.bridge.begin(query) ++ self._submit_task(query) ++ start = time.time() ++ elapsed = 0.0 ++ continue + _log(f"task {self._task_id} terminal ({status}) without a protocol action — " + f"abandoning scope") + return None +@@ -661,6 +700,42 @@ class LiveController: + f"abandoning scope (respond() will fall back/escalate)") + return None + ++ def _task_assistant_text(self, tid: str) -> str: ++ """All of the task's assistant outputs as one text, oldest first — the analogue of ++ the official adapters' _extract_assistant_text, read from the engine's host-visible ++ observability (calls//llm_*_response.json -> blobs/.json.gz). Read-only, ++ bounded, fail-quiet: any I/O problem yields "" and the caller falls back to the task ++ record (final_answer / result).""" ++ import gzip as _gz ++ import json as _json ++ import pathlib as _pl ++ parts: list[str] = [] ++ try: ++ root = _pl.Path(str(self.engine.data_root)) ++ calls = sorted((root / "observability" / "calls" / str(tid)).glob("llm_*_response.json*"), ++ key=lambda p: p.stat().st_mtime)[-40:] ++ except Exception: # noqa: BLE001 ++ return "" ++ for call in calls: ++ try: ++ meta = _json.loads(call.read_text(encoding="utf-8", errors="replace")) ++ sha = str(((meta.get("full_payload_ref") or {}).get("sha256")) or "") ++ blob = next((root / "observability" / "blobs").glob(sha + "*"), None) if sha else None ++ if blob is None: ++ continue ++ opener = _gz.open if blob.suffix == ".gz" else open ++ with opener(blob, "rt", encoding="utf-8", errors="replace") as fh: ++ payload = _json.load(fh) ++ msg = payload.get("message") ++ if not isinstance(msg, dict): ++ msg = ((payload.get("choices") or [{}])[0] or {}).get("message") or {} ++ text = str(msg.get("content") or payload.get("content") or "") ++ if text.strip(): ++ parts.append(text) ++ except Exception: # noqa: BLE001 ++ continue ++ return "\n".join(parts) ++ + def _final_answer_action(self, query: Any): + """CC-style delivery: parse the completed task's FINAL ANSWER into the query's + action schema. Source order (re-emission fix): (1) the task record's typed +@@ -683,29 +758,39 @@ class LiveController: + typed = str(res.get("final_answer") or "").strip() + if not raw.strip() and not typed: + return None +- from .system import _extract_last_json # lazy: avoid module cycle +- # Candidate ladder (adversarial-review finding: a bad typed blob must never +- # SHADOW a good text one): each source is tried strictly in order; a candidate +- # that fails JSON-shape, echo-guard, or schema falls through to the next +- # source, and only after both fail does the coercion net see the raw text. +- candidates = [("typed final_answer", _extract_last_json(typed))] if typed else [] +- candidates.append(("final text", _extract_last_json(raw))) ++ from .action_parse import _json_candidates # official CC/Codex candidate scan, verbatim copy ++ from ..utils.structured_output import validate_with_coercion # shared validator (CC/Codex) ++ # Locate the answer the way the official adapters do: ONE assistant text for the ++ # whole task (every model output, oldest->newest, like Claude Code's ++ # _extract_assistant_text), every JSON span a candidate (_json_candidates), walked ++ # most-recent-first, first candidate that validates (validate_with_coercion) wins — ++ # exactly _parse_action_text's walk, with one bridge rule spliced into it: a ++ # candidate equal to the previously DELIVERED answer is an echo of the relayed ++ # verdict, not a new answer, so the walk continues to the next candidate instead ++ # of stopping (CC has no relayed-verdict echo, hence no such rule). The engine's ++ # result text and typed final_answer are appended last so the committed answer is ++ # the most recent candidate and the locator still works when observability is ++ # unavailable. A control envelope ({"delivery_control": "keep"}) fails the schema ++ # and the walk moves back to the real answer (2026-09-02: 8 zeros recovered). ++ assistant_text = self._task_assistant_text(tid) ++ combined = "\n".join(x for x in (assistant_text, raw, typed) if x and x.strip()) + action = source = seen_blob = None + echoed = failed = None +- for src, blob in candidates: +- if not isinstance(blob, dict): ++ for candidate in reversed(_json_candidates(combined)): ++ try: ++ parsed = validate_with_coercion(candidate, schema) ++ except Exception as exc: # noqa: BLE001 — not this candidate ++ failed = failed or f"latest JSON: {type(exc).__name__}" + continue ++ blob = parsed.model_dump() if hasattr(parsed, "model_dump") else dict(parsed) + blob.pop("notes", None) + if (self._last_delivered_blob is not None and blob == self._last_delivered_blob + and not self._item_had_steps): +- echoed = src +- continue +- try: +- action = schema.model_validate(blob) +- except Exception as exc: # noqa: BLE001 +- failed = f"{src}: {type(exc).__name__}" ++ echoed = echoed or "latest valid JSON" + continue +- source, seen_blob = src, blob ++ action, seen_blob = parsed, blob ++ source = ("typed final_answer" if typed and candidate.strip() in typed else ++ ("final text" if candidate.strip() in raw else "assistant text")) + break + if action is None: + why = (f"echo of previous action ({echoed})" if echoed +@@ -776,6 +861,7 @@ class LiveController: + asks to re-emit strictly the JSON payload. Mirrors v6.71's own in-engine + 'second send is an extraction/format repair' idea at the bridge boundary. + Env: OUROBOROS_FORMAT_REPAIR=0 disables; one attempt per question, never loops.""" ++ from . import run_clbench_bridge_agent as _rb # lazy: avoid import cycles (fix: NameError when this rung fires) + if os.environ.get("OUROBOROS_FORMAT_REPAIR", "1").strip() == "0": + return None + try: +@@ -805,6 +891,7 @@ class LiveController: + "answer_protocol": "final_answer_line", + } + try: ++ self._await_pending_restart() + created = _rb._api(self.engine.server.base_url, "POST", "/api/tasks", body, timeout=60) + rep_tid = str(created.get("task_id") or "") + if not rep_tid: +@@ -823,17 +910,20 @@ class LiveController: + except Exception: # noqa: BLE001 + _log(f"format-repair: task {tid} repair submission failed — giving up") + return None +- from .system import _extract_last_json # lazy: avoid module cycle +- for src_label, source_text in (("typed", str(res.get("final_answer") or "")), +- ("text", str(res.get("result") or ""))): +- blob = _extract_last_json(source_text) +- if not isinstance(blob, dict) or not blob: +- continue +- blob.pop("notes", None) ++ from .action_parse import _parse_action_text # official CC/Codex parser, verbatim copy ++ # Locate the repaired answer exactly as the official adapters locate a repaired ++ # answer: latest schema-valid JSON in the repair turn's assistant text (plus the ++ # engine's committed fields as a fallback view when observability is unavailable). ++ repair_text = "\n".join(x for x in (self._task_assistant_text(rep_tid), ++ str(res.get("final_answer") or ""), ++ str(res.get("result") or "")) if x and x.strip()) ++ for src_label, source_text in (("repair text", repair_text),): + try: +- action = schema.model_validate(blob) ++ action = _parse_action_text(source_text, schema) + except Exception: # noqa: BLE001 + continue ++ blob = action.model_dump() if hasattr(action, "model_dump") else {} ++ blob.pop("notes", None) + # empty-payload guard (parity with coercion): repair that carries no data fails + dumped = action.model_dump() + _CHAFF = {"thought", "reasoning", "rationale", "notes"} +@@ -939,6 +1029,135 @@ class LiveController: + if seq_done: + self._ensure_task_dead(bounded=120.0) + ++ def _pending_restart_path(self): ++ import pathlib as _pl ++ try: ++ return _pl.Path(str(self.engine.data_root)) / "state" / "pending_restart_verify.json" ++ except Exception: # noqa: BLE001 ++ return None ++ ++ def _log_boundary_result(self, stage: str, result: Any) -> None: ++ if not isinstance(result, dict): ++ _log(f"{stage}: result={result!r}") ++ return ++ prefix = "TIMEOUT " if result.get("reason") == "timeout" else "" ++ _log( ++ f"{prefix}{stage}: reason={result.get('reason')} " ++ f"cycle_outcome={result.get('cycle_outcome')} elapsed={result.get('elapsed')}s " ++ f"budget_elapsed={result.get('budget_elapsed')}s " ++ f"cycles={result.get('cycles')} cycles_done={result.get('cycles_done')} " ++ f"blockers={result.get('blockers', [])}" ++ ) ++ ++ def _await_pending_restart( ++ self, bound_sec: float = _BOUNDARY_WAIT_CAP_SEC, *, renew_expired: bool = True ++ ) -> Optional[dict]: ++ """Wait for restart verification and auto-resume within one bounded window. ++ ++ Docker owns the complete readiness predicate and monotonic deadline. Reusing ++ its read-only gate also catches late activity without publishing another request. ++ Other engines retain a marker/health gate, sharing one bound across retries; ++ a claimed marker remains pending until startup verification removes it. ++ A later invocation can renew an expired window; an ongoing refusal wait cannot. ++ """ ++ wait = getattr(self.engine, "_wait_for_boundary_readiness", None) ++ if getattr(self.engine, "evolution", False) and callable(wait): ++ result = wait(renew_expired=renew_expired) ++ self._log_boundary_result("pre-submit readiness", result) ++ return result ++ from . import run_clbench_bridge_agent as _rb # lazy: avoid import cycles ++ ++ marker = self._pending_restart_path() ++ if marker is None: ++ return ++ ++ def markers(): ++ return ([marker] if marker.exists() else []) + list( ++ marker.parent.glob("pending_restart_verify.claimed.*.json") ++ ) ++ ++ started = time.monotonic() ++ pending = markers() ++ deadline = getattr(self, "_restart_wait_deadline", None) ++ # A boundary alone does not imply a restart on engines without Docker's ++ # complete gate. Start health recovery only after observing a marker. ++ if not pending: ++ return ++ if deadline is None or (renew_expired and started >= deadline): ++ self._restart_wait_started = started ++ self._restart_wait_deadline = started + bound_sec ++ deadline = self._restart_wait_deadline ++ if pending: ++ _log( ++ f"restart pending ({', '.join(p.name for p in pending)}) — holding task " ++ f"submit (remaining {max(0.0, deadline - started):.0f}s)" ++ ) ++ ++ def result(reason, blockers): ++ observed = time.monotonic() ++ outcome = { ++ "reason": reason, ++ "elapsed": observed - started, ++ "budget_elapsed": observed - self._restart_wait_started, ++ "blockers": blockers, ++ } ++ self._log_boundary_result("restart readiness", outcome) ++ return outcome ++ ++ blockers = [p.name for p in pending] ++ while time.monotonic() < deadline: ++ pending = markers() ++ blockers = [p.name for p in pending] ++ if not pending: ++ remaining = deadline - time.monotonic() ++ if remaining <= 0: ++ blockers.append("restart health unconfirmed") ++ break ++ try: ++ st = _rb._api( ++ self.engine.server.base_url, ++ "GET", ++ "/api/state", ++ timeout=min(8.0, remaining), ++ ) ++ if ( ++ isinstance(st, dict) ++ and st.get("supervisor_ready") is True ++ and not st.get("supervisor_error") ++ and type(st.get("workers_alive")) is int ++ and st["workers_alive"] > 0 ++ ): ++ blockers = [p.name for p in markers()] ++ if not blockers and time.monotonic() < deadline: ++ _log( ++ f"engine restarted and healthy after " ++ f"{(time.monotonic() - started):.0f}s — " ++ "resuming task submit" ++ ) ++ return result("no_promotion", []) ++ else: ++ blockers.append("restart health unconfirmed") ++ except Exception: # noqa: BLE001 ++ blockers.append("restart API unavailable") ++ remaining = deadline - time.monotonic() ++ if remaining > 0: ++ time.sleep(min(8.0, remaining)) ++ return result("timeout", blockers or ["restart readiness deadline exhausted"]) ++ ++ def _cancelled_by_restart(self) -> bool: ++ """True when the current task's record says the SERVER RESTART interrupted it ++ (the engine writes that reason verbatim); network errors fail closed to False.""" ++ from . import run_clbench_bridge_agent as _rb # lazy: avoid import cycles ++ if not self._task_id: ++ return False ++ try: ++ rec = _rb._api(self.engine.server.base_url, "GET", ++ "/api/tasks/" + self._task_id, timeout=8) or {} ++ except Exception: # noqa: BLE001 ++ return False ++ blob = " ".join(str(rec.get(k) or "") for k in ("result", "reason_code", "status_reason")) ++ return "restart" in blob.lower() ++ + def _submit_task(self, query: Any) -> None: + from . import run_clbench_bridge_agent as _rb # lazy: avoid import cycles + +@@ -1043,12 +1262,67 @@ class LiveController: + time.sleep(30) + _held += 30 + created, last_exc = None, None +- for _attempt in range(6): ++ attempts = 0 ++ restart_waiting = False ++ while attempts < 6: ++ readiness = None + try: ++ # Connectivity holds can outlast the quiet observation. Recheck every ++ # POST. Once restart admission refuses, retries share that wait's cap. ++ readiness = self._await_pending_restart(renew_expired=not restart_waiting) + created = _rb._api(self.engine.server.base_url, "POST", "/api/tasks", body, timeout=60) + break + except Exception as exc: # noqa: BLE001 — transient 503/URLError during recovery + last_exc = exc ++ # Engines carrying the restart-admission fence answer a submit made while a ++ # restart is ARMED with 503 + retryable, and that window lasts from ++ # request_restart until the restart completes (7 min observed 2026-08-30) — ++ # far longer than this loop's 6x5s. Burning the retries there would raise and ++ # kill the run on the very fix that protects us, so a restart-shaped refusal ++ # re-enters the same marker wait the submit gate uses and does not consume an ++ # attempt. Any other error keeps the original fast-retry behaviour. ++ if _restart_pending_refusal(exc): ++ ctx = getattr(self.engine, "_boundary_wait", None) ++ deadline = ( ++ ctx["deadline"] ++ if getattr(self.engine, "evolution", False) ++ and isinstance(ctx, dict) ++ else self._restart_wait_deadline ++ ) ++ now = time.monotonic() ++ # Timeout permits one POST, but cannot override engine admission. ++ # A persistent refusal must terminate instead of renewing the wait. ++ if (restart_waiting and deadline is not None and deadline <= now) or ( ++ isinstance(readiness, dict) ++ and readiness.get("reason") == "timeout" ++ ): ++ _log( ++ "TIMEOUT submit refused: engine restart admission remains armed " ++ "after the readiness wait budget; stopping submission retries" ++ ) ++ raise RuntimeError( ++ "/api/tasks submit failed after readiness timeout: restart " ++ "admission remains armed" ++ ) from exc ++ if deadline is None or deadline <= now: ++ # The first refused POST can cross a previously quiet window's ++ # deadline. Admission recovery begins here and needs its own cap. ++ deadline = now + _BOUNDARY_WAIT_CAP_SEC ++ if getattr(self.engine, "evolution", False) and isinstance(ctx, dict): ++ ctx["started"], ctx["deadline"] = now, deadline ++ ctx["api_shape_checked"], ctx["legacy_readiness"] = False, False ++ else: ++ self._restart_wait_started = now ++ self._restart_wait_deadline = deadline ++ restart_waiting = True ++ remaining = deadline - now ++ _log( ++ "submit refused: engine restart armed (503 retryable) — " ++ "re-entering the restart wait instead of burning retries" ++ ) ++ time.sleep(min(5.0, remaining)) ++ continue ++ attempts += 1 + time.sleep(5) + if created is None: + raise RuntimeError(f"/api/tasks submit failed after retries: {last_exc}") +diff --git a/src/systems/ouroboros/action_parse.py b/src/systems/ouroboros/action_parse.py +new file mode 100644 +index 0000000..952647a +--- /dev/null ++++ b/src/systems/ouroboros/action_parse.py +@@ -0,0 +1,66 @@ ++"""Action extraction identical to the official CL-Bench system adapters. ++ ++`_json_candidates` and `_parse_action_text` are copied VERBATIM from ++src/systems/claude/system.py (the Codex adapter carries the same two functions) so that ++the way Ouroboros's answer is located in model output is byte-for-byte the way Claude ++Code's and Codex's answers are: every JSON-like span in the assistant text is a ++candidate, the most recent candidate that validates against the response schema wins, ++and validation uses the shared `validate_with_coercion`. Do not edit here — re-copy ++from the claude adapter if upstream changes; tests/test_ouroboros_submission_ports.py ++pins the copies equal. ++""" ++from __future__ import annotations ++ ++import json ++from typing import Any ++ ++from ..utils.structured_output import extract_json, validate_with_coercion ++ ++ ++def _json_candidates(text: str) -> list[str]: ++ """Return JSON-like candidates from an assistant message, most recent last.""" ++ stripped = text.strip() ++ if not stripped: ++ return [] ++ ++ candidates: list[str] = [stripped] ++ if "```" in stripped: ++ parts = stripped.split("```") ++ for part in parts[1::2]: ++ part = part.strip() ++ if part.startswith("json"): ++ part = part[4:].strip() ++ if part.startswith("{") or part.startswith("["): ++ candidates.append(part) ++ ++ decoder = json.JSONDecoder() ++ for idx, char in enumerate(stripped): ++ if char not in "{[": ++ continue ++ try: ++ _, end = decoder.raw_decode(stripped[idx:]) ++ except json.JSONDecodeError: ++ continue ++ candidates.append(stripped[idx : idx + end]) ++ ++ deduped: list[str] = [] ++ seen: set[str] = set() ++ for candidate in candidates: ++ if candidate in seen: ++ continue ++ seen.add(candidate) ++ deduped.append(candidate) ++ return deduped ++ ++ ++def _parse_action_text(text: str, schema: type[Any]) -> Any: ++ """Parse assistant text into the target schema, preferring the latest JSON.""" ++ last_error: Exception | None = None ++ for candidate in reversed(_json_candidates(text)): ++ try: ++ return validate_with_coercion(candidate, schema) ++ except Exception as exc: ++ last_error = exc ++ if last_error is not None: ++ raise last_error ++ return validate_with_coercion(extract_json(text), schema) +diff --git a/src/systems/ouroboros/system.py b/src/systems/ouroboros/system.py +index 5a394a8..6823d5b 100644 +--- a/src/systems/ouroboros/system.py ++++ b/src/systems/ouroboros/system.py +@@ -34,6 +34,25 @@ from ..utils import completion_with_structured_output + from . import _launcher, _docker_launcher, _live_bridge + + ++# --- bench sandbox lifetime ------------------------------------------------------- ++# The CL-Bench task sandboxes (sales_prediction, codebase_adaptation) are minisweagent ++# DockerEnvironments started as `docker run --rm ... sleep ` with the ++# library default of "2h" and no task-level override. Every stateful sales rollout of ++# the submitted Ouroboros run (232-437 min) crossed 2h and lost its container mid-run ++# ("No such container" on every later command; the 82-min stateless baseline was ++# unaffected; later instances answered blind). Non-Ouroboros ++# systems finish in <=38 min and never reach the cliff. The task classes are built after ++# this module is imported (registry imports the system first), so raising the library ++# default here covers both call sites without touching bench task code. Bench-side fix ++# (explicit container_timeout + recreate-on-loss): pgasawa/continual-learning-bench#22. ++try: # pragma: no cover - exercised by test_sandbox_lifetime_override ++ from minisweagent.environments.docker import DockerEnvironmentConfig as _DEC ++ _DEC.model_fields["container_timeout"].default = "24h" ++ _DEC.model_rebuild(force=True) ++except Exception: # noqa: BLE001 - minisweagent absent or API changed: keep the default ++ pass ++ ++ + @register_system("ouroboros") + class OuroborosSystem(ContinualLearningSystem): + """Bridge the Ouroboros agent into CL-Bench.""" +@@ -338,9 +357,24 @@ class OuroborosSystem(ContinualLearningSystem): + # convention into the Response metadata — domain-generic zero-credit forfeit. + _forfeit = self._live.forfeit_metadata or {} + self._live.forfeit_metadata = None ++ # Turn-1 config attestation: the published reference rows record their effort ++ # knob in artifacts (claude-code: reasoning_effort=low) while ours lived only in ++ # prose — un-reviewable from the shipped run. Stamp the resolved env once so the ++ # exact configuration is verifiable from final_results alone. ++ _attest = {} ++ if self._turns == 1: ++ import os as _os ++ _attest = {"config_attestation": { ++ k.rsplit("OUROBOROS_", 1)[-1].lower(): _os.environ.get(k, "") ++ for k in ("OUROBOROS_EFFORT_TASK", "OUROBOROS_EFFORT_REVIEW", ++ "OUROBOROS_EFFORT_SCOPE_REVIEW", "OUROBOROS_RUNTIME_MODE", ++ "OUROBOROS_SAFETY_MODE", "OUROBOROS_CONTEXT_MODE", ++ "OUROBOROS_TASK_REVIEW_MODE", "OUROBOROS_REVIEW_ENFORCEMENT", ++ "OUROBOROS_REVIEW_MAX_PASSES", "OUROBOROS_TOTAL_BUDGET", ++ "OUROBOROS_MAX_SUBAGENT_DEPTH")}} + return Response(action=action, metadata={"engine": "ouroboros-live", "mode": self.mode, + "conversation": self.conversation, +- "turn": self._turns, **_forfeit}) ++ "turn": self._turns, **_attest, **_forfeit}) + + # ------------------------------------------------------------------ engines + def _engine_llm(self, messages: list[dict], response_schema: type[BaseModel]) -> BaseModel: +@@ -387,14 +421,15 @@ class OuroborosSystem(ContinualLearningSystem): + def _parse_action_notes(self, raw: str, response_schema: type[BaseModel]): + """Extract the agent's action + its compact 'notes' (carried forward). 'notes' is popped before + validating the action against the (notes-free) CL-Bench response schema.""" ++ from .action_parse import _parse_action_text # official CC/Codex parser, verbatim copy + blob = _extract_last_json(raw) +- notes = "" +- if isinstance(blob, dict): +- notes = str(blob.pop("notes", "") or "").strip() +- try: +- return response_schema.model_validate(blob), notes +- except Exception: +- pass ++ notes = str(blob.pop("notes", "") or "").strip() if isinstance(blob, dict) else "" ++ try: ++ # Same locator as the official adapters: most recent JSON span that validates ++ # (with coercion); the notes side-channel is read separately above. ++ return _parse_action_text(raw, response_schema), notes ++ except Exception: ++ pass + return self._parse_action(raw, response_schema), notes + + def _coerce_final_answer(self, raw: str, response_schema: type[BaseModel]): +diff --git a/tests/test_ouroboros_live_parity.py b/tests/test_ouroboros_live_parity.py +index a134af8..fa1d8e7 100644 +--- a/tests/test_ouroboros_live_parity.py ++++ b/tests/test_ouroboros_live_parity.py +@@ -15,6 +15,8 @@ import threading + import time + from types import SimpleNamespace + ++import pytest ++ + from src.systems.utils.structured_output import schema_to_prompt_instruction + from src.tasks.database_exploration.task import DatabaseAction + from src.systems.ouroboros import run_clbench_bridge_agent as rb +@@ -22,6 +24,15 @@ from src.systems.ouroboros._live_bridge import LiveBridge, LiveController + from src.systems.ouroboros.system import _fallback_action + + ++@pytest.fixture(autouse=True) ++def _fake_engine_connectivity(monkeypatch): ++ """In-process engines do not depend on provider DNS in the test environment.""" ++ from src.systems.ouroboros import _live_bridge as live ++ ++ # Connectivity scenarios replace this probe with their own scripted outage. ++ monkeypatch.setattr(live, "_net_ok", lambda: True) ++ ++ + def _q(prompt="Question 1/1\nHow many rows?", queries_used=0, idx=0): + return SimpleNamespace( + prompt=prompt, response_schema=DatabaseAction, instance_id=f"q{idx}", +@@ -464,6 +475,7 @@ def test_forced_evolution_request_writer(tmp_path): + _FORCED_EVOLUTION_OBJECTIVE, _write_forced_evolution_request, + ) + (tmp_path / "state").mkdir() ++ (tmp_path / "state" / "state.json").write_text("{}") + # 1. fresh state → writes; steer empty → default generic objective + assert _write_forced_evolution_request(tmp_path, "") is True + req = json.loads((tmp_path / "state" / "post_task_evolution_request.json").read_text()) +@@ -483,36 +495,823 @@ def test_forced_evolution_request_writer(tmp_path): + assert req["objective"] == "custom objective" + + +-def test_abandon_reopen_does_not_open_evolution_window(): +- """Abandon-recovery scope reopen must NOT call on_instance_boundary (forced +- evolution window): during a provider outage every failed task would file a +- request -> cycle -> restart -> kill the retry -> another abandon (observed +- restart storm). Only a legit chunk/question boundary earns the window.""" +- calls = [] ++def _boundary_controller(tmp_path, monkeypatch): ++ from src.systems.ouroboros import _live_bridge as live ++ from src.systems.ouroboros._docker_launcher import _write_forced_evolution_request ++ ++ events = [] ++ result = { ++ "absorbed": True, ++ "new_sha": "new", ++ "cycles": 2, ++ "cycles_done": 3, ++ "reason": "absorbed", ++ "cycle_outcome": "absorbed", ++ "elapsed": 24.0, ++ "blockers": [], ++ } ++ (tmp_path / "state").mkdir() ++ (tmp_path / "state" / "state.json").write_text("{}") + +- class _SpyEngine: ++ class _Engine: + mm = "shared" ++ evolution = True ++ data_root = tmp_path ++ + def __init__(self): + self.server = self ++ self.base_url = "http://127.0.0.1:1" ++ self._boundary_wait = {"started": 0.0, "deadline": 1800.0} ++ ++ def _start(self): ++ pass ++ + def cancel_task(self, tid): +- return {} ++ events.append("cancel") ++ + def wait_task(self, tid, timeout=10): + return {"status": "cancelled"} ++ + def on_instance_boundary(self): +- calls.append(1) +- return {} ++ events.append("boundary") ++ assert _write_forced_evolution_request(tmp_path, "") ++ events.append("request") ++ (tmp_path / "state" / "post_task_evolution_request.json").unlink() ++ return dict(result) ++ ++ def _wait_for_boundary_readiness(self, *, renew_expired=True): ++ events.append("ready") ++ return dict(result) ++ ++ ctl = LiveController( ++ engine=_Engine(), ++ conversation="rollout", ++ memory_mode=None, ++ chunk_questions=1, ++ chunk_resume_mode="splice", ++ fallback_action_fn=_fallback_action, ++ action_timeout_sec=1, ++ task_timeout_sec=5, ++ ) ++ ctl.bridge.url = "http://127.0.0.1:1" ++ ctl._published = True ++ monkeypatch.setattr( ++ ctl, "_take_action_patient", lambda query: _fallback_action(DatabaseAction) ++ ) ++ monkeypatch.setattr(ctl, "_harvest_task_usage", lambda *a, **k: None) ++ monkeypatch.setattr(ctl, "finalize_usage", lambda *a, **k: None) ++ monkeypatch.setattr(live, "_net_ok", lambda: True) + +- ctl = LiveController(engine=_SpyEngine(), conversation="rollout", memory_mode=None, +- chunk_questions=8, chunk_resume_mode="splice", +- fallback_action_fn=_fallback_action, +- action_timeout_sec=1, task_timeout_sec=5) +- ctl._task_id = "dead-task" +- ctl._abandon_task() # abandon: boundary_pending=True, window NOT due ++ def api(base, method, path, body=None, timeout=60): ++ assert (method, path) == ("POST", "/api/tasks") ++ events.append("post") ++ return {"task_id": f"tid-{events.count('post')}"} ++ ++ monkeypatch.setattr(rb, "_api", api) ++ return ctl, events ++ ++ ++def test_abandon_reopen_does_not_open_evolution_window(tmp_path, monkeypatch): ++ """Recovery reopens the real submit path without earning a promotion request.""" ++ ctl, events = _boundary_controller(tmp_path, monkeypatch) ++ ctl.respond(_q()) ++ ctl._abandon_task() + assert ctl._boundary_pending is True +- assert ctl._evolution_window_due is False, "abandon must not earn an evolution window" +- # legit chunk boundary DOES arm it +- ctl._evolution_window_due = True ++ assert ctl._evolution_window_due is False ++ ctl.respond(_q(queries_used=1)) ++ assert events.count("post") == 2 ++ assert "boundary" not in events and "request" not in events ++ assert not (tmp_path / "state" / "post_task_evolution_request.json").exists() ++ assert ctl._reopen_pending is False and ctl._boundary_pending is False ++ assert ctl._resume_root == "tid-1" ++ ctl.close() ++ ++ ++def test_genuine_boundary_requests_once_before_submission( ++ tmp_path, monkeypatch, capsys ++): ++ ctl, events = _boundary_controller(tmp_path, monkeypatch) ++ ctl.respond(_q()) ++ # A completed item at a chunk seam earns one window; another turn does not. ++ ctl.observe(SimpleNamespace(content="verdict", instance_complete=True), _q(idx=1)) + assert ctl._evolution_window_due is True ++ ctl.respond(_q(idx=1)) ++ ctl.respond(_q(idx=1, queries_used=1)) ++ assert events.count("boundary") == events.count("request") == 1 ++ assert events.count("post") == 2 ++ assert events.index("request") < len(events) - 1 ++ assert events[-2:] == ["ready", "post"] ++ assert ctl._evolution_window_due is False ++ logged = capsys.readouterr().err ++ for value in ( ++ "reason=absorbed", ++ "cycle_outcome=absorbed", ++ "elapsed=24", ++ "cycles=2", ++ "cycles_done=3", ++ "blockers=", ++ ): ++ assert value in logged ++ ctl.close() ++ ++ ++def test_late_activity_after_connectivity_is_checked_before_post(tmp_path, monkeypatch): ++ from src.systems.ouroboros import _live_bridge as live ++ ++ ctl, events = _boundary_controller(tmp_path, monkeypatch) ++ marker = tmp_path / "state" / "pending_restart_verify.claimed.1.json" ++ active = [] ++ probes = iter([False, True]) ++ ++ def net_ok(): ++ available = next(probes) ++ events.append("connected" if available else "offline") ++ if available: ++ marker.write_text("{}") ++ active.append("auto-resume") ++ return available ++ ++ def ready(*, renew_expired=True): ++ events.append("ready") ++ if marker.exists() or active: ++ events.append("wait-late-work") ++ marker.unlink() ++ active.clear() ++ return { ++ "reason": "no_promotion", ++ "cycle_outcome": None, ++ "elapsed": 8, ++ "cycles": 0, ++ "cycles_done": 0, ++ "blockers": [], ++ } ++ ++ def api(base, method, path, body=None, timeout=60): ++ assert not marker.exists() and not active ++ assert events[-1] == "wait-late-work" ++ events.append("post") ++ return {"task_id": "tid-next"} ++ ++ monkeypatch.setattr(live, "_NET_OUTAGE_HOLD_SEC", 60) ++ monkeypatch.setattr(live, "_net_ok", net_ok) ++ monkeypatch.setattr(live.time, "sleep", lambda seconds: events.append("sleep")) ++ monkeypatch.setattr(ctl.engine, "_wait_for_boundary_readiness", ready) ++ monkeypatch.setattr(rb, "_api", api) ++ ctl._submit_task(_q()) ++ assert events[-4:] == ["connected", "ready", "wait-late-work", "post"] ++ assert events.count("ready") == 1 ++ assert "request" not in events and "boundary" not in events ++ ctl.close() ++ ++ ++@pytest.mark.parametrize("late_activity", [False, True]) ++def test_real_docker_gate_confirms_fresh_quiet_after_connectivity_hold( ++ tmp_path, monkeypatch, late_activity ++): ++ from src.systems.ouroboros import _docker_launcher as docker ++ from src.systems.ouroboros import _live_bridge as live ++ ++ ctl, _ = _boundary_controller(tmp_path, monkeypatch) ++ engine = docker.DockerOuroborosEngine.__new__(docker.DockerOuroborosEngine) ++ engine._started = True ++ engine._data_root = tmp_path ++ engine._server = SimpleNamespace(base_url="http://docker-fake") ++ engine._cname = None ++ engine.mm = "shared" ++ engine.evolution = True ++ ctl.engine = engine ++ (tmp_path / "state" / "state.json").write_text('{"evolution_mode_enabled":false}') ++ marker = tmp_path / "state" / "pending_restart_verify.claimed.5.json" ++ now, reads, posts = [0.0], [], [] ++ connections = iter([False, True]) ++ ++ def sleep(seconds): ++ assert seconds in (8, 30) ++ now[0] += seconds ++ if now[0] >= 46: ++ marker.unlink(missing_ok=True) ++ ++ def net_ok(): ++ connected = next(connections) ++ if connected and late_activity: ++ marker.write_text("{}") ++ return connected ++ ++ def api(base, method, path, body=None, timeout=60): ++ if method == "POST": ++ assert path == "/api/tasks" ++ assert not marker.exists() ++ assert reads[-2:] == [now[0] - 8, now[0]] ++ posts.append(now[0]) ++ return {"task_id": "tid-after-confirmation"} ++ assert (method, path) == ("GET", "/api/state") ++ assert timeout <= min(8, 1830 - now[0]) ++ reads.append(now[0]) ++ return { ++ "supervisor_ready": True, ++ "supervisor_error": None, ++ "workers_alive": 1, ++ "evolution_enabled": False, ++ "pending_count": 0, ++ "running_count": 0, ++ "evolution_state": {"queued_task_id": "", "running_task_id": ""}, ++ "active_direct_turns": ["auto-resume"] if marker.exists() else [], ++ "active_chat_activities": [], ++ "sha": "a" * 40, ++ "uptime": 1000 + now[0], ++ } ++ ++ monkeypatch.setattr(live.time, "monotonic", lambda: now[0]) ++ monkeypatch.setattr(live.time, "sleep", sleep) ++ monkeypatch.setattr(live, "_NET_OUTAGE_HOLD_SEC", 60) ++ monkeypatch.setattr(live, "_net_ok", net_ok) ++ monkeypatch.setattr(docker, "_api", api) ++ monkeypatch.setattr(rb, "_api", api) ++ monkeypatch.setattr( ++ docker, ++ "_write_forced_evolution_request", ++ lambda *a: pytest.fail("readiness must not publish"), ++ ) ++ ++ ctl._submit_task(_q()) ++ ++ assert posts == [54 if late_activity else 38] ++ assert min(reads) == 30 ++ assert engine._boundary_wait["deadline"] == 1830 ++ assert not (tmp_path / "state" / "post_task_evolution_request.json").exists() ++ ctl.close() ++ ++ ++def test_restart_refusal_rechecks_readiness_without_new_boundary(tmp_path, monkeypatch): ++ import io ++ import urllib.error ++ ++ from src.systems.ouroboros import _live_bridge as live ++ ++ ctl, events = _boundary_controller(tmp_path, monkeypatch) ++ monkeypatch.setattr(live.time, "monotonic", lambda: 0.0) ++ monkeypatch.setattr(live.time, "sleep", lambda seconds: events.append("backoff")) ++ context = ctl.engine._boundary_wait ++ deadlines = [] ++ marker = tmp_path / "state" / "pending_restart_verify.claimed.2.json" ++ attempts = [] ++ ++ def ready(*, renew_expired=True): ++ assert ctl.engine._boundary_wait is context ++ deadlines.append(context["deadline"]) ++ events.append("ready") ++ if marker.exists(): ++ events.append("wait-claimed") ++ marker.unlink() ++ return { ++ "reason": "cycle_done_unabsorbed", ++ "cycle_outcome": "waiting_for_restart", ++ "elapsed": 920, ++ "cycles": 0, ++ "cycles_done": 1, ++ "blockers": [], ++ } ++ ++ def api(base, method, path, body=None, timeout=60): ++ assert events[-1] in ("ready", "wait-claimed") and not marker.exists() ++ attempts.append(body) ++ events.append("post") ++ if len(attempts) == 1: ++ marker.write_text("{}") ++ raise urllib.error.HTTPError( ++ base + path, ++ 503, ++ "restart armed", ++ {}, ++ io.BytesIO(b'{"retryable":true,"reason":"restart"}'), ++ ) ++ return {"task_id": "tid-retry"} ++ ++ monkeypatch.setattr(ctl.engine, "_wait_for_boundary_readiness", ready) ++ monkeypatch.setattr(rb, "_api", api) ++ ctl._submit_task(_q()) ++ assert len(attempts) == 2 and attempts[0] == attempts[1] ++ assert "wait-claimed" in events ++ assert events[-2:] == ["wait-claimed", "post"] ++ assert all(deadline == 1800 for deadline in deadlines) ++ assert "boundary" not in events and "request" not in events ++ assert ctl._task_id == "tid-retry" ++ ctl.close() ++ ++ ++@pytest.mark.parametrize("never_clears", [False, True]) ++def test_restart_refusals_do_not_consume_attempts_or_hot_loop( ++ tmp_path, monkeypatch, never_clears ++): ++ import io ++ import urllib.error ++ ++ from src.systems.ouroboros import _live_bridge as live ++ ++ ctl, events = _boundary_controller(tmp_path, monkeypatch) ++ context = ctl.engine._boundary_wait ++ now, sleeps, posts = [1758.0], [], [] ++ ++ def sleep(seconds): ++ assert len(sleeps) < 9, "restart refusal wait exceeded its poll bound" ++ assert seconds == min(5, 1800 - now[0]) and seconds > 0 ++ sleeps.append(seconds) ++ now[0] += seconds ++ ++ def ready(*, renew_expired=True): ++ assert ctl.engine._boundary_wait is context ++ assert context["deadline"] == 1800 ++ events.append("ready") ++ return { ++ "reason": "timeout" if now[0] >= 1800 else "no_promotion", ++ "elapsed": 0.0, ++ "budget_elapsed": now[0], ++ "blockers": ["pending restart"] if now[0] >= 1800 else [], ++ } ++ ++ def api(base, method, path, body=None, timeout=60): ++ assert events[-1] == "ready" ++ events.append("post") ++ posts.append(now[0]) ++ if never_clears or len(posts) <= 8: ++ raise urllib.error.HTTPError( ++ base + path, ++ 503, ++ "restart armed", ++ {}, ++ io.BytesIO(b'{"retryable":true,"reason":"restart"}'), ++ ) ++ return {"task_id": "tid-after-restart"} ++ ++ monkeypatch.setattr(live.time, "monotonic", lambda: now[0]) ++ monkeypatch.setattr(live.time, "sleep", sleep) ++ monkeypatch.setattr(ctl.engine, "_wait_for_boundary_readiness", ready) ++ monkeypatch.setattr(rb, "_api", api) ++ ++ if never_clears: ++ with pytest.raises(RuntimeError, match="readiness timeout.*restart"): ++ ctl._submit_task(_q()) ++ assert now[0] == context["deadline"] == 1800 ++ else: ++ ctl._submit_task(_q()) ++ assert now[0] == 1798 ++ assert ctl._task_id == "tid-after-restart" ++ assert posts == [1758 + 5 * attempt for attempt in range(9)] + ( ++ [1800] if never_clears else [] ++ ) ++ assert sleeps == [5] * 8 + ([2] if never_clears else []) ++ assert context["deadline"] == 1800 ++ assert "boundary" not in events and "request" not in events ++ ctl.close() ++ ++ ++@pytest.mark.parametrize("docker_gate", [False, True]) ++@pytest.mark.parametrize("resubmit_entry", ["late_submit", "late_cancel", "crossing_submit"]) ++@pytest.mark.parametrize("never_clears", [False, True]) ++def test_late_question_resubmit_gets_one_fresh_restart_window( ++ tmp_path, monkeypatch, capsys, docker_gate, resubmit_entry, never_clears ++): ++ import io ++ import urllib.error ++ ++ from src.systems.ouroboros import _docker_launcher as docker ++ from src.systems.ouroboros import _live_bridge as live ++ ++ ctl, events = _boundary_controller(tmp_path, monkeypatch) ++ first_post_crosses_deadline = resubmit_entry == "crossing_submit" ++ ctl.conversation = "question" ++ ctl._task_id = "long-running-question" ++ ctl._restart_wait_started = 0.0 ++ ctl._restart_wait_deadline = 1800.0 ++ ctl.engine.evolution = docker_gate ++ if docker_gate: ++ engine = docker.DockerOuroborosEngine.__new__(docker.DockerOuroborosEngine) ++ engine._started = True ++ engine._data_root = tmp_path ++ engine._server = SimpleNamespace(base_url="http://docker-fake") ++ engine._cname = None ++ engine.mm = "shared" ++ engine.evolution = True ++ ctl.engine = engine ++ (tmp_path / "state" / "state.json").write_text( ++ '{"evolution_mode_enabled":false}' ++ ) ++ marker = tmp_path / "state" / "pending_restart_verify.claimed.7.json" ++ now, posts, probes, sleeps = [0.0], [], [], [] ++ refusals_armed = [never_clears] ++ healthy = { ++ "supervisor_ready": True, ++ "workers_alive": 1, ++ "evolution_enabled": False, ++ "pending_count": 0, ++ "running_count": 0, ++ "evolution_state": {"queued_task_id": "", "running_task_id": ""}, ++ "active_direct_turns": [], ++ "active_chat_activities": [], ++ "sha": "a" * 40, ++ "uptime": 1000, ++ } ++ ++ def sleep(seconds): ++ assert 0 < seconds <= 8 ++ assert len(sleeps) < 500, "restart wait renewed or stopped advancing" ++ sleeps.append(seconds) ++ now[0] += seconds ++ if posts and not refusals_armed[0] and now[0] >= posts[0] + 40: ++ marker.unlink(missing_ok=True) ++ ++ def api(base, method, path, body=None, timeout=60): ++ if method == "GET": ++ assert path == "/api/state" and timeout > 0 ++ probes.append(now[0]) ++ if docker_gate and first_post_crosses_deadline and posts: ++ # The restarted gateway has older telemetry in the renewed window. ++ return { ++ key: value for key, value in healthy.items() ++ if key != "active_direct_turns" ++ } ++ return healthy ++ assert (method, path) == ("POST", "/api/tasks") ++ if not posts and first_post_crosses_deadline: ++ # The gate was quiet, then the POST overlapped the old deadline. ++ now[0] = 1803.0 ++ posts.append(now[0]) ++ if len(posts) == 1 or refusals_armed[0]: ++ marker.write_text("{}") ++ raise urllib.error.HTTPError( ++ base + path, 503, "restart armed", {}, ++ io.BytesIO(b'{"retryable":true,"reason":"restart"}'), ++ ) ++ assert not marker.exists() ++ return {"task_id": "recovered-question"} ++ ++ monkeypatch.setattr(live.time, "monotonic", lambda: now[0]) ++ monkeypatch.setattr(live.time, "sleep", sleep) ++ monkeypatch.setattr(docker, "_api", api) ++ monkeypatch.setattr(rb, "_api", api) ++ monkeypatch.setattr( ++ docker, "_write_forced_evolution_request", ++ lambda *a: pytest.fail("resubmission must not publish an evolution request"), ++ ) ++ if docker_gate: ++ ctl.engine._begin_boundary_wait() ++ assert ctl.engine._boundary_wait["api_shape_checked"] is True ++ assert ctl.engine._boundary_wait["legacy_readiness"] is False ++ now[0] = 1790.0 if first_post_crosses_deadline else 2000.0 ++ ++ def resubmit(): ++ if resubmit_entry == "late_cancel": ++ monkeypatch.setattr( ++ ctl, "_task_status", ++ lambda: "cancelled" if ctl._task_id == "long-running-question" else "completed", ++ ) ++ monkeypatch.setattr(ctl, "_cancelled_by_restart", lambda: True) ++ answer = DatabaseAction(action="ANSWER", content="42") ++ monkeypatch.setattr( ++ ctl.bridge, "take_action", ++ lambda *a: None if ctl._task_id == "long-running-question" else answer, ++ ) ++ assert LiveController._take_action_patient(ctl, _q(queries_used=1)) == answer ++ assert ctl._restart_retry_done ++ else: ++ ctl._submit_task(_q(queries_used=1)) ++ ++ if never_clears: ++ with pytest.raises(RuntimeError, match="readiness timeout.*restart"): ++ resubmit() ++ assert "TIMEOUT submit refused" in capsys.readouterr().err ++ else: ++ resubmit() ++ assert ctl._task_id == "recovered-question" ++ ++ deadline = ( ++ ctl.engine._boundary_wait["deadline"] ++ if docker_gate else ctl._restart_wait_deadline ++ ) ++ expected_start = 1803.0 if first_post_crosses_deadline else 2000.0 ++ assert deadline == expected_start + 1800 ++ assert len(posts) == 2 and 0 < posts[-1] - posts[0] <= 1800 ++ assert now[0] <= deadline ++ assert (now[0] >= deadline) is never_clears ++ assert "boundary" not in events and "request" not in events ++ if docker_gate: ++ assert any(probe >= expected_start for probe in probes) ++ assert ctl.engine._boundary_wait["legacy_readiness"] is first_post_crosses_deadline ++ if never_clears: ++ # A later, independent submit is a new wait even after this one exhausted its cap. ++ refusals_armed[0] = False ++ ctl._submit_task(_q(queries_used=2)) ++ fresh_deadline = ( ++ ctl.engine._boundary_wait["deadline"] ++ if docker_gate else ctl._restart_wait_deadline ++ ) ++ assert fresh_deadline == deadline + 1800 ++ assert deadline < now[0] < fresh_deadline ++ assert ctl._task_id == "recovered-question" ++ ctl.close() ++ ++ ++def test_submit_ordinary_failures_still_stop_after_six(tmp_path, monkeypatch): ++ from src.systems.ouroboros import _live_bridge as live ++ ++ ctl, events = _boundary_controller(tmp_path, monkeypatch) ++ sleeps, posts = [], [] ++ ++ def api(base, method, path, body=None, timeout=60): ++ assert events[-1] == "ready" ++ posts.append(body) ++ raise OSError("ordinary connection failure") ++ ++ monkeypatch.setattr(live.time, "sleep", sleeps.append) ++ monkeypatch.setattr(rb, "_api", api) ++ ++ with pytest.raises(RuntimeError, match="submit failed after retries"): ++ ctl._submit_task(_q()) ++ ++ assert len(posts) == 6 ++ assert sleeps == [5] * 6 ++ assert "boundary" not in events and "request" not in events ++ ctl.close() ++ ++ ++@pytest.mark.parametrize("restart_pending", [False, True]) ++def test_control_submit_skips_evolution_gate_but_respects_restart_markers( ++ tmp_path, monkeypatch, restart_pending ++): ++ from src.systems.ouroboros import _live_bridge as live ++ ++ ctl, events = _boundary_controller(tmp_path, monkeypatch) ++ ctl.engine.evolution = False ++ (tmp_path / "state" / "state.json").unlink() ++ monkeypatch.setattr( ++ ctl.engine, ++ "_wait_for_boundary_readiness", ++ lambda: pytest.fail("control must not read evolution telemetry"), ++ ) ++ marker = tmp_path / "state" / "pending_restart_verify.claimed.8.json" ++ if restart_pending: ++ marker.write_text("{}") ++ now, sleeps, probes = [0.0], [], [] ++ ++ def sleep(seconds): ++ assert restart_pending and seconds == 8 ++ sleeps.append(seconds) ++ now[0] += seconds ++ marker.unlink() ++ ++ def api(base, method, path, body=None, timeout=60): ++ assert not marker.exists() ++ if method == "GET": ++ assert restart_pending and path == "/api/state" ++ probes.append(now[0]) ++ # No supervisor_error field means no reported error. ++ return {"supervisor_ready": True, "workers_alive": 1} ++ assert (method, path) == ("POST", "/api/tasks") ++ events.append("post") ++ return {"task_id": "tid-control"} ++ ++ monkeypatch.setattr(live.time, "monotonic", lambda: now[0]) ++ monkeypatch.setattr(live.time, "sleep", sleep) ++ monkeypatch.setattr(rb, "_api", api) ++ ++ ctl._submit_task(_q()) ++ ++ assert events == ["post"] ++ assert sleeps == ([8] if restart_pending else []) ++ assert probes == ([8] if restart_pending else []) ++ assert ctl._restart_wait_deadline == (1800 if restart_pending else None) ++ assert not (tmp_path / "state" / "state.json").exists() ++ ctl.close() ++ ++ ++@pytest.mark.parametrize("now", [500.0, 2000.0]) ++def test_restart_gate_without_markers_does_not_start_health_wait( ++ tmp_path, monkeypatch, now ++): ++ from src.systems.ouroboros import _live_bridge as live ++ ++ ctl, _ = _boundary_controller(tmp_path, monkeypatch) ++ ctl.engine = SimpleNamespace( ++ data_root=tmp_path, server=SimpleNamespace(base_url="http://127.0.0.1:1") ++ ) ++ ctl._restart_wait_started = 0.0 ++ ctl._restart_wait_deadline = 1800.0 ++ monkeypatch.setattr(live.time, "monotonic", lambda: now) ++ monkeypatch.setattr(live.time, "sleep", lambda seconds: pytest.fail("no restart")) ++ monkeypatch.setattr(rb, "_api", lambda *a, **k: pytest.fail("no restart to probe")) ++ ++ assert ctl._await_pending_restart() is None ++ assert ctl._restart_wait_deadline == 1800 ++ ctl.close() ++ ++ ++def test_expired_restart_gate_gives_late_markers_a_fresh_window( ++ tmp_path, monkeypatch, capsys ++): ++ from src.systems.ouroboros import _live_bridge as live ++ ++ ctl, _ = _boundary_controller(tmp_path, monkeypatch) ++ ctl.engine = SimpleNamespace( ++ data_root=tmp_path, server=SimpleNamespace(base_url="http://127.0.0.1:1") ++ ) ++ ctl._restart_wait_started = 0.0 ++ ctl._restart_wait_deadline = 1800.0 ++ marker = tmp_path / "state" / "pending_restart_verify.claimed.4.json" ++ marker.write_text("{}") ++ now = [2000.0] ++ ++ def sleep(seconds): ++ assert 0 < seconds <= 8 ++ now[0] += seconds ++ marker.unlink(missing_ok=True) ++ ++ monkeypatch.setattr(live.time, "monotonic", lambda: now[0]) ++ monkeypatch.setattr(live.time, "sleep", sleep) ++ monkeypatch.setattr( ++ rb, "_api", lambda *a, **k: {"supervisor_ready": True, "workers_alive": 1} ++ ) ++ ++ late = ctl._await_pending_restart() ++ cleared = ctl._await_pending_restart() ++ ++ assert late["reason"] == "no_promotion" and cleared is None ++ assert late["blockers"] == [] ++ assert 0 < late["elapsed"] == late["budget_elapsed"] < 1800 ++ assert ctl._restart_wait_deadline == 3800 ++ logged = capsys.readouterr().err ++ assert "resuming task submit" in logged and "TIMEOUT" not in logged ++ ctl.close() ++ ++ ++def test_restart_gate_clamps_last_api_probe_and_reports_call_elapsed( ++ tmp_path, monkeypatch ++): ++ from src.systems.ouroboros import _live_bridge as live ++ ++ ctl, _ = _boundary_controller(tmp_path, monkeypatch) ++ ctl.engine = SimpleNamespace( ++ data_root=tmp_path, server=SimpleNamespace(base_url="http://127.0.0.1:1") ++ ) ++ ctl._restart_wait_started = 0.0 ++ ctl._restart_wait_deadline = 1800.0 ++ now = [1797.0] ++ marker = tmp_path / "state" / "pending_restart_verify.json" ++ marker.write_text("{}") ++ reads = [] ++ real_exists = type(marker).exists ++ ++ def exists(path): ++ if path == marker: ++ reads.append(path) ++ if len(reads) == 2: ++ marker.unlink() ++ return real_exists(path) ++ ++ def api(base, method, path, body=None, timeout=60): ++ assert (method, path) == ("GET", "/api/state") ++ assert timeout == 3 ++ now[0] += timeout ++ raise OSError("engine unavailable") ++ ++ monkeypatch.setattr(live.time, "monotonic", lambda: now[0]) ++ monkeypatch.setattr(live.time, "sleep", lambda seconds: pytest.fail("renewed wait")) ++ monkeypatch.setattr(type(marker), "exists", exists) ++ monkeypatch.setattr(rb, "_api", api) ++ ++ result = ctl._await_pending_restart() ++ ++ assert result["reason"] == "timeout" ++ assert result["elapsed"] == 3 and result["budget_elapsed"] == 1800 ++ assert result["blockers"] == ["restart API unavailable"] ++ assert ctl._restart_wait_deadline == 1800 ++ ctl.close() ++ ++ ++def test_restart_markers_then_api_recovery_share_one_deadline( ++ tmp_path, monkeypatch, capsys ++): ++ from src.systems.ouroboros import _live_bridge as live ++ ++ engine = SimpleNamespace( ++ data_root=tmp_path, server=SimpleNamespace(base_url="http://127.0.0.1:1") ++ ) ++ ctl = LiveController( ++ engine=engine, ++ conversation="question", ++ memory_mode=None, ++ fallback_action_fn=_fallback_action, ++ action_timeout_sec=1, ++ task_timeout_sec=5, ++ ) ++ state = tmp_path / "state" ++ state.mkdir() ++ marker = state / "pending_restart_verify.json" ++ claimed = state / "pending_restart_verify.claimed.3.json" ++ marker.write_text("{}") ++ now, sleeps, probes = [0.0], [], [] ++ healthy = {"supervisor_ready": True, "supervisor_error": None, "workers_alive": 1} ++ health_states = [ ++ {}, ++ healthy | {"supervisor_ready": False}, ++ healthy | {"supervisor_error": "worker startup failed"}, ++ healthy | {"workers_alive": 0}, ++ healthy | {"workers_alive": True}, ++ healthy | {"workers_alive": "1"}, ++ {"supervisor_ready": True, "workers_alive": 1}, ++ healthy, ++ ] ++ ++ def sleep(seconds): ++ assert len(sleeps) < 12, "restart readiness exceeded its poll bound" ++ assert 0 < seconds <= min(8, 96 - now[0]) ++ sleeps.append(seconds) ++ now[0] += seconds ++ if now[0] == 8: ++ marker.rename(claimed) ++ elif now[0] == 16: ++ claimed.unlink() ++ ++ def api(base, method, path, body=None, timeout=60): ++ assert (method, path) == ("GET", "/api/state") ++ assert not marker.exists() and not claimed.exists() ++ assert timeout <= 96 - now[0] ++ probes.append((now[0], timeout)) ++ if len(probes) == 1: ++ raise OSError("server restarting") ++ return health_states[len(probes) - 2] ++ ++ monkeypatch.setattr(live.time, "monotonic", lambda: now[0]) ++ monkeypatch.setattr(live.time, "sleep", sleep) ++ monkeypatch.setattr(rb, "_api", api) ++ ctl._await_pending_restart(bound_sec=96) ++ assert probes == [(moment, 8) for moment in range(16, 73, 8)] ++ assert now[0] == 72 and ctl._restart_wait_deadline == 96 ++ # A late restart consumes the original remainder, including after health recovered. ++ marker.write_text("{}") ++ ctl._await_pending_restart(bound_sec=900) ++ assert now[0] == ctl._restart_wait_deadline == 96 ++ assert sleeps == [8] * 12 ++ logged = capsys.readouterr().err ++ assert "engine restarted and healthy after" in logged ++ assert "TIMEOUT" in logged and "pending_restart_verify.json" in logged ++ ++ ++def test_format_repair_checks_readiness_without_promotion(tmp_path, monkeypatch): ++ ctl, events = _boundary_controller(tmp_path, monkeypatch) ++ monkeypatch.setenv("OUROBOROS_FORMAT_REPAIR", "1") ++ monkeypatch.setattr( ++ ctl.engine, ++ "wait_task", ++ lambda *a, **k: { ++ "status": "completed", ++ "final_answer": '{"action":"ANSWER","content":"42"}', ++ }, ++ ) ++ action = ctl._format_repair_action(_q(), DatabaseAction, "previous-task", "no JSON") ++ assert action.content == "42" ++ assert events == ["ready", "post"] ++ ctl.close() ++ ++ ++def test_format_repair_after_long_question_waits_in_fresh_restart_window( ++ tmp_path, monkeypatch ++): ++ from src.systems.ouroboros import _live_bridge as live ++ ++ ctl, events = _boundary_controller(tmp_path, monkeypatch) ++ ctl.engine.evolution = False ++ ctl._restart_wait_started = 0.0 ++ ctl._restart_wait_deadline = 1800.0 ++ marker = tmp_path / "state" / "pending_restart_verify.claimed.9.json" ++ marker.write_text("{}") ++ now = [2000.0] ++ ++ def sleep(seconds): ++ assert 0 < seconds <= 8 and now[0] < 3800 ++ now[0] += seconds ++ marker.unlink(missing_ok=True) ++ ++ def api(base, method, path, body=None, timeout=60): ++ assert not marker.exists() ++ if method == "GET": ++ return {"supervisor_ready": True, "workers_alive": 1} ++ events.append("post") ++ return {"task_id": "format-repair"} ++ ++ monkeypatch.setenv("OUROBOROS_FORMAT_REPAIR", "1") ++ monkeypatch.setattr(live.time, "monotonic", lambda: now[0]) ++ monkeypatch.setattr(live.time, "sleep", sleep) ++ monkeypatch.setattr(rb, "_api", api) ++ monkeypatch.setattr( ++ ctl.engine, "wait_task", ++ lambda *a, **k: { ++ "status": "completed", "final_answer": '{"action":"ANSWER","content":"42"}' ++ }, ++ ) ++ action = ctl._format_repair_action(_q(), DatabaseAction, "previous-task", "no JSON") ++ ++ assert action.content == "42" and events == ["post"] ++ assert 0 < now[0] - 2000 < 1800 and ctl._restart_wait_deadline == 3800 + ctl.close() + + +@@ -559,6 +1358,8 @@ def test_dead_rollout_task_reopens_scope_with_resume_stitch(): + + ctl._submit_task = _fake_submit + ++ # Submission is in-process here; reopening does not need an HTTP listener. ++ ctl.bridge.url = "http://127.0.0.1:1" + t0 = time.time() + a1 = ctl.respond(_q()) # scope 1: task dies without an action + assert isinstance(a1, DatabaseAction) # degraded to fallback, no exception +@@ -645,6 +1446,8 @@ def _final_delivery_controller(final_result: str, final_answer: str = "", + ctl._task_id = f"tid-{len(submits)}" + + ctl._submit_task = _fake_submit ++ # Fake task delivery uses the bridge queues without opening a network listener. ++ ctl.bridge.url = "http://127.0.0.1:1" + return ctl, submits + + +diff --git a/tests/test_ouroboros_submission_ports.py b/tests/test_ouroboros_submission_ports.py +index 144b8f6..74f3b5e 100644 +--- a/tests/test_ouroboros_submission_ports.py ++++ b/tests/test_ouroboros_submission_ports.py +@@ -11,8 +11,12 @@ Covers the three review-mandated contracts: + from __future__ import annotations + + import time ++import copy ++import json + from types import SimpleNamespace + ++import pytest ++ + from src.systems.ouroboros import run_clbench_bridge_agent as rb + from src.systems.ouroboros._live_bridge import LiveController + from src.systems.ouroboros.system import _fallback_action +@@ -158,3 +162,1568 @@ def test_single_model_pin_overrides_foreign_live_vision_slot(monkeypatch): + live = {"OUROBOROS_MODEL_VISION": "openai::foreign-model", "TOTAL_BUDGET": 60.0} + merged = {**live, **ov} # build_isolated_settings applies overrides after live settings + assert merged["OUROBOROS_MODEL_VISION"] == "anthropic/claude-sonnet-4.6" ++ ++ ++def test_reviewer_slots_blanked_so_the_single_model_pin_holds(): ++ """The desktop's structured reviewer slots must never reach the isolated server. ++ ++ ouroboros/reviewer_slot_config.py projects OUROBOROS_REVIEWER_SLOTS OVER the flat ++ OUROBOROS_REVIEW_MODELS / OUROBOROS_SCOPE_REVIEW_MODEL(S) keys, so a leaked desktop ++ triad silently replaces the pinned single model. Observed live 2026-08-27: the scope ++ reviewer resolved to openai/gpt-5.6-luna in a run whose every flat key said ++ anthropic/claude-sonnet-4.6. ++ """ ++ from src.systems.ouroboros._docker_launcher import DockerOuroborosEngine ++ ++ srv = DockerOuroborosEngine.__new__(DockerOuroborosEngine) ++ srv.model = "anthropic/claude-sonnet-4.6" ++ srv.max_workers = 3 ++ srv.evolution = False ++ srv.cadence = "every_n:1" ++ srv.steer = "" ++ srv.resume = False ++ ov = srv._overrides() ++ ++ import json as _json ++ ++ slots = _json.loads(ov["OUROBOROS_REVIEWER_SLOTS"]) ++ roster = _json.loads(ov["OUROBOROS_SUBAGENTS"]) ++ ++ # Every api reviewer route is the solve model: the desktop's triad must not win. ++ for row in slots["triad"]: ++ assert row["route"]["target_id"] == srv.model, "triad row escaped the single-model pin" ++ for row in roster["items"]: ++ assert row["route"]["target_id"] == srv.model, "roster row escaped the single-model pin" ++ ++ # The scope row references the roster instead of carrying an inline route: that is ++ # what puts it in the RETRIEVES class (native_retrieval), so no packet is assembled ++ # and the 200K sourced floor applies instead of the 1M api floor. ++ scope = slots["scope"][0] ++ assert scope.get("subagent_id") == roster["items"][0]["subagent_id"], ( ++ "scope slot must reference the roster row; an inline route would take the " ++ "api pack path, which cannot assemble on a repo this size" ++ ) ++ assert "route" not in scope, "subagent reference and inline route are mutually exclusive" ++ for key in ("OUROBOROS_REVIEW_MODELS", "OUROBOROS_SCOPE_REVIEW_MODELS"): ++ if key in ov: ++ assert set(ov[key].split(",")) == {srv.model}, f"{key} must pin the solve model" ++ ++ ++def test_turn1_metadata_attests_the_resolved_config(monkeypatch): ++ """The exact effort/safety/runtime config must be verifiable from the artifacts. ++ ++ The published reference rows carry their knob in recorded params ++ (claude-code: reasoning_effort=low); ours previously lived only in prose. ++ Turn 1's Response.metadata now stamps the resolved env. ++ """ ++ monkeypatch.setenv("OUROBOROS_EFFORT_TASK", "low") ++ monkeypatch.setenv("OUROBOROS_SAFETY_MODE", "off") ++ import re ++ src = open("src/systems/ouroboros/system.py").read() ++ m = re.search(r"_attest = \{\"config_attestation\":", src) ++ assert m, "turn-1 attestation block missing" ++ for key in ("OUROBOROS_EFFORT_TASK", "OUROBOROS_SAFETY_MODE", ++ "OUROBOROS_CONTEXT_MODE", "OUROBOROS_TOTAL_BUDGET"): ++ assert key in src, f"{key} not attested" ++ ++ ++def test_restart_gate_waits_through_claimed_marker_and_reuses_deadline( ++ tmp_path, monkeypatch, capsys ++): ++ """Renaming a verification marker transfers ownership; it does not finish restart.""" ++ from src.systems.ouroboros import _live_bridge as lb ++ ++ state = tmp_path / "state" ++ state.mkdir() ++ marker = state / "pending_restart_verify.json" ++ claimed = state / "pending_restart_verify.claimed.42.json" ++ marker.write_text("{}") ++ clock = SimpleNamespace(now=0.0, sleeps=[]) ++ ++ def sleep(duration): ++ assert 0 < duration <= min(8.0, 19.0 - clock.now) ++ clock.sleeps.append(duration) ++ clock.now += duration ++ if marker.exists(): ++ marker.rename(claimed) ++ ++ ctl = lb.LiveController.__new__(lb.LiveController) ++ ctl.engine = SimpleNamespace( ++ data_root=tmp_path, server=SimpleNamespace(base_url="http://restart-fake") ++ ) ++ monkeypatch.setattr( ++ lb, "time", SimpleNamespace(monotonic=lambda: clock.now, sleep=sleep) ++ ) ++ probes = [] ++ ++ def api(*args, timeout, **kwargs): ++ probes.append(timeout) ++ pytest.fail("an expired restart budget cannot fund another API probe") ++ ++ monkeypatch.setattr(rb, "_api", api) ++ ++ ctl._await_pending_restart(bound_sec=19) ++ deadline = ctl._restart_wait_deadline ++ ctl._await_pending_restart(bound_sec=900, renew_expired=False) ++ ++ assert clock.now == 19 ++ assert clock.sleeps == [8, 8, 3] ++ assert ctl._restart_wait_deadline == deadline == 19 ++ assert probes == [] ++ log = capsys.readouterr().err ++ assert "TIMEOUT" in log ++ assert claimed.name in log ++ ++ ++def test_restart_gate_returns_fast_without_marker(tmp_path): ++ """No marker == no wait: the gate must be a no-op on the happy path.""" ++ import time ++ from src.systems.ouroboros import _live_bridge as lb ++ ++ class _Srv: # pragma: no cover - trivial stub ++ base_url = "http://127.0.0.1:1" ++ ++ class _Eng: ++ data_root = tmp_path ++ server = _Srv() ++ ++ ctl = lb.LiveController.__new__(lb.LiveController) ++ ctl.engine = _Eng() ++ t0 = time.time() ++ ctl._await_pending_restart(bound_sec=5) ++ assert time.time() - t0 < 1.0, "gate must not sleep when no restart is pending" ++ ++ ++def test_restart_503_refusal_re_enters_the_wait_not_the_retry_burn(): ++ """Engines >=6.113.5 refuse a submit made while a restart is armed with 503+retryable, ++ and that window outlasts the 6x5s submit retry loop — so the refusal must route back ++ into the marker wait, never consume the retry budget.""" ++ import urllib.error, io ++ from src.systems.ouroboros import _live_bridge as lb ++ ++ def _err(code, body): ++ return urllib.error.HTTPError("http://x/api/tasks", code, "n/a", {}, ++ io.BytesIO(body.encode())) ++ ++ assert lb._restart_pending_refusal(_err(503, '{"error":"restart_pending","retryable":true}')) ++ assert lb._restart_pending_refusal(_err(503, "server restarting")) ++ # an ordinary 503 with no restart hint keeps the fast-retry path only if it also ++ # carries no retryable marker; an unreadable body prefers waiting (fail-safe) ++ assert not lb._restart_pending_refusal(_err(500, "restart")) ++ assert not lb._restart_pending_refusal(ValueError("boom")) ++ ++def _campaign( ++ *, ++ campaign_id="campaign-a", ++ cycles=0, ++ absorbed=0, ++ outcome="", ++ transaction_id="tx-a", ++ task_id="task-a", ++ active=False, ++ **tx_fields, ++): ++ campaign = { ++ "id": campaign_id, ++ "status": "active", ++ "cycles_done": cycles, ++ "absorbed_cycles_done": absorbed, ++ "last_task_id": task_id if outcome else "", ++ } ++ if outcome or active: ++ tx = { ++ "campaign_id": campaign_id, ++ "transaction_id": transaction_id, ++ "task_id": task_id, ++ "cycle_outcome": outcome, ++ **tx_fields, ++ } ++ if active: ++ campaign["active_transaction"] = tx ++ else: ++ campaign["history"] = [{"task_id": task_id, "transaction": tx}] ++ campaign["transaction_history"] = [tx] ++ return campaign ++ ++ ++def _boundary_frame( ++ *, ++ campaign=None, ++ enabled=False, ++ request=False, ++ markers=(), ++ api_error=False, ++ **api_fields, ++): ++ api = { ++ "supervisor_ready": True, ++ "supervisor_error": "", ++ "workers_alive": 2, ++ "evolution_enabled": False, ++ "pending_count": 0, ++ "running_count": 0, ++ "evolution_state": {"queued_task_id": "", "running_task_id": ""}, ++ "active_direct_turns": [], ++ "active_chat_activities": [], ++ "sha": "a" * 40, ++ "uptime": 1000.0, ++ } ++ api.update(api_fields) ++ return { ++ "campaign": campaign, ++ "enabled": enabled, ++ "request": request, ++ "markers": markers, ++ "api_error": api_error, ++ "api": api, ++ } ++ ++ ++class _BoundaryScript: ++ """Advance host files and API telemetry together on each fake polling sleep.""" ++ ++ def __init__(self, root, initial, frames): ++ self.root = root / "state" ++ self.root.mkdir() ++ self.frames = frames ++ self.index = -1 ++ self.now = 0.0 ++ self.sleeps = [] ++ self.reads = [] ++ self.publications = 0 ++ self.api_cost = 0.0 ++ self.apply(initial) ++ ++ def apply(self, frame): ++ self.frame = copy.deepcopy(frame) ++ (self.root / "state.json").write_text( ++ json.dumps({"evolution_mode_enabled": frame["enabled"]}) ++ ) ++ campaign_path = self.root / "evolution_campaign.json" ++ campaign = frame["campaign"] ++ if campaign is None: ++ campaign_path.unlink(missing_ok=True) ++ else: ++ campaign_path.write_text( ++ campaign if isinstance(campaign, str) else json.dumps(campaign) ++ ) ++ request = self.root / "post_task_evolution_request.json" ++ if frame["request"]: ++ request.write_text("{}") ++ else: ++ request.unlink(missing_ok=True) ++ for marker in self.root.glob("pending_restart_verify*.json"): ++ marker.unlink() ++ for name in frame["markers"]: ++ (self.root / name).write_text("{}") ++ ++ def advance(self): ++ self.index = min(self.index + 1, len(self.frames) - 1) ++ self.apply(self.frames[self.index]) ++ ++ def monotonic(self): ++ return self.now ++ ++ def sleep(self, duration): ++ assert 0 < duration <= min(8.0, self.deadline() - self.now) ++ assert len(self.sleeps) < 1000, "readiness polling must remain bounded" ++ self.sleeps.append((self.now, duration)) ++ self.now += duration ++ self.advance() ++ ++ def api(self, base, method, path, body=None, timeout=30.0): ++ assert method == "GET" and path == "/api/state" ++ assert 0 < timeout <= min(8.0, self.deadline() - self.now) ++ assert len(self.reads) < 1000, "readiness probes must remain bounded" ++ self.reads.append((self.now, timeout)) ++ self.now += min(self.api_cost, timeout) ++ if self.frame["api_error"]: ++ raise OSError("scripted supervisor restart") ++ return copy.deepcopy(self.frame["api"]) ++ ++ def publish(self, root, objective): ++ self.publications += 1 ++ # Consumption can complete immediately; the baseline must already be captured. ++ self.advance() ++ return True ++ ++ ++def _scripted_boundary( ++ tmp_path, monkeypatch, frames, *, initial=None, trigger="forced" ++): ++ from src.systems.ouroboros import _docker_launcher as dl ++ ++ script = _BoundaryScript( ++ tmp_path, initial or _boundary_frame(campaign=_campaign()), frames ++ ) ++ engine = dl.DockerOuroborosEngine.__new__(dl.DockerOuroborosEngine) ++ engine._started = True ++ engine._data_root = tmp_path ++ engine._server = SimpleNamespace(base_url="http://boundary-fake") ++ engine._cname = None ++ engine.evolution = True ++ engine.evolution_trigger = trigger ++ engine.steer = "" ++ engine.task_timeout = 1 ++ engine._reset_per_task_budget = lambda *a, **k: None ++ script.deadline = lambda: engine._boundary_wait["deadline"] ++ monkeypatch.setattr( ++ dl, "time", SimpleNamespace(monotonic=script.monotonic, sleep=script.sleep) ++ ) ++ monkeypatch.setattr(dl, "_api", script.api) ++ monkeypatch.setattr(dl, "_write_forced_evolution_request", script.publish) ++ return engine, script ++ ++ ++@pytest.mark.parametrize( ++ "state,expected", ++ [({}, True), ({"evolution_mode_enabled": False}, True), ++ ({"evolution_mode_enabled": True}, False), ([], False), ("{malformed", False)], ++) ++def test_forced_request_and_readiness_agree_on_state_flag(tmp_path, state, expected): ++ from src.systems.ouroboros import _docker_launcher as dl ++ ++ state_dir = tmp_path / "state" ++ state_dir.mkdir() ++ (state_dir / "state.json").write_text( ++ state if isinstance(state, str) else json.dumps(state) ++ ) ++ engine = dl.DockerOuroborosEngine.__new__(dl.DockerOuroborosEngine) ++ engine._data_root = tmp_path ++ engine._cname = None ++ ++ assert (not engine._boundary_file_blockers()) is expected ++ assert dl._write_forced_evolution_request(tmp_path, "Improve the workflow") is expected ++ assert (state_dir / "post_task_evolution_request.json").exists() is expected ++ ++ ++@pytest.mark.parametrize( ++ "activity", ++ [ ++ {"running_count": 1}, ++ {"pending_count": 1}, ++ {"evolution_state": {"queued_task_id": "queued", "running_task_id": ""}}, ++ {"evolution_state": {"queued_task_id": "", "running_task_id": "running"}}, ++ {"active_direct_turns": [{"task_id": "resumed"}]}, ++ {"active_chat_activities": [{"chat_id": 3}]}, ++ {"evolution_enabled": True}, ++ ], ++) ++def test_boundary_counters_never_bypass_activity(tmp_path, monkeypatch, activity): ++ campaign = _campaign(cycles=1, absorbed=1, outcome="absorbed") ++ quiet = _boundary_frame(campaign=campaign, sha="b" * 40) ++ busy = _boundary_frame(campaign=campaign, sha="b" * 40, **activity) ++ engine, script = _scripted_boundary(tmp_path, monkeypatch, [busy, quiet, quiet]) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == "absorbed" ++ assert result["absorbed"] is True ++ assert result["cycles"] == 1 ++ assert result["cycle_outcome"] == "absorbed" ++ assert script.now >= 16 ++ assert script.publications == 1 ++ ++ ++def test_boundary_consumed_request_still_waits_for_disarm(tmp_path, monkeypatch): ++ campaign = _campaign(cycles=1, outcome="no_op") ++ engine, script = _scripted_boundary( ++ tmp_path, ++ monkeypatch, ++ [ ++ _boundary_frame(campaign=campaign, enabled=True), ++ _boundary_frame(campaign=campaign), ++ _boundary_frame(campaign=campaign), ++ ], ++ ) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == "cycle_done_unabsorbed" ++ assert script.now >= 16 ++ assert script.publications == 1 ++ ++ ++@pytest.mark.parametrize( ++ "outcome", ["absorbed", "no_op", "abandoned", "waiting_for_restart"] ++) ++def test_boundary_terminal_outcomes_still_require_two_quiet_samples( ++ tmp_path, monkeypatch, outcome ++): ++ absorbed = int(outcome == "absorbed") ++ campaign = _campaign( ++ cycles=1, ++ absorbed=absorbed, ++ outcome=outcome, ++ commit_sha="b" * 40 if absorbed else "", ++ restart_verified=True, ++ ) ++ quiet = _boundary_frame(campaign=campaign, sha="b" * 40 if absorbed else "a" * 40) ++ engine, script = _scripted_boundary(tmp_path, monkeypatch, [quiet, quiet, quiet]) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == ("absorbed" if absorbed else "cycle_done_unabsorbed") ++ assert result["cycle_outcome"] == outcome ++ assert result["absorbed"] is bool(absorbed) ++ assert result["new_sha"] == ("b" * 8 if absorbed else "a" * 8) ++ assert script.now >= 8 ++ ++ ++def test_boundary_restart_claim_api_outage_and_auto_resume_all_block( ++ tmp_path, monkeypatch ++): ++ pending = _campaign( ++ cycles=1, ++ outcome="waiting_for_restart", ++ active=True, ++ commit_sha="b" * 40, ++ restart_required=True, ++ restart_verified=False, ++ ) ++ completed = _campaign( ++ cycles=1, ++ absorbed=1, ++ outcome="absorbed", ++ commit_sha="b" * 40, ++ restart_required=False, ++ restart_verified=True, ++ ) ++ quiet = _boundary_frame(campaign=completed, sha="b" * 40, uptime=2) ++ engine, script = _scripted_boundary( ++ tmp_path, ++ monkeypatch, ++ [ ++ _boundary_frame(campaign=pending, markers=("pending_restart_verify.json",)), ++ _boundary_frame( ++ campaign=pending, markers=("pending_restart_verify.claimed.42.json",) ++ ), ++ _boundary_frame(campaign=pending, api_error=True), ++ _boundary_frame(campaign=pending), ++ _boundary_frame( ++ campaign=completed, ++ sha="b" * 40, ++ uptime=1, ++ active_direct_turns=[{"task_id": "auto-resume"}], ++ ), ++ quiet, ++ quiet, ++ ], ++ ) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == "absorbed" ++ assert result["cycle_outcome"] == "absorbed" ++ assert script.now >= 48 ++ assert result["blockers"] == [] ++ ++ ++def test_boundary_captures_campaign_and_counters_before_immediate_consumption( ++ tmp_path, monkeypatch ++): ++ initial = _boundary_frame(campaign=_campaign(cycles=7, absorbed=5, outcome="no_op")) ++ new = _campaign( ++ campaign_id="campaign-b", ++ cycles=1, ++ absorbed=1, ++ outcome="absorbed", ++ transaction_id="tx-b", ++ task_id="task-b", ++ commit_sha="b" * 40, ++ restart_verified=True, ++ ) ++ quiet = _boundary_frame(campaign=new, sha="b" * 40) ++ engine, script = _scripted_boundary( ++ tmp_path, monkeypatch, [quiet, quiet, quiet], initial=initial ++ ) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == "absorbed" ++ assert result["cycle_outcome"] == "absorbed" ++ assert result["cycles"] == 1 ++ assert result["cycles_done"] == 1 ++ assert script.publications == 1 ++ ++ ++def test_boundary_new_transaction_outcome_does_not_need_counter_growth( ++ tmp_path, monkeypatch ++): ++ initial = _boundary_frame( ++ campaign=_campaign(cycles=7, absorbed=5, outcome="absorbed") ++ ) ++ new = _campaign( ++ campaign_id="campaign-b", ++ cycles=1, ++ outcome="abandoned", ++ transaction_id="tx-b", ++ task_id="task-b", ++ ) ++ quiet = _boundary_frame(campaign=new) ++ engine, _ = _scripted_boundary( ++ tmp_path, monkeypatch, [quiet, quiet], initial=initial ++ ) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == "cycle_done_unabsorbed" ++ assert result["cycle_outcome"] == "abandoned" ++ assert result["absorbed"] is False ++ ++ ++def test_boundary_missing_campaign_can_only_finish_as_independently_idle( ++ tmp_path, monkeypatch ++): ++ initial = _boundary_frame() ++ quiet = _boundary_frame() ++ engine, script = _scripted_boundary( ++ tmp_path, monkeypatch, [quiet, quiet], initial=initial, trigger="native" ++ ) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == "no_promotion" ++ assert result["cycle_outcome"] in ("", None) ++ assert script.now == 8 ++ assert script.publications == 0 ++ ++ ++@pytest.mark.parametrize("bad_campaign", ["{malformed", {}, None]) ++def test_boundary_unreadable_or_disappearing_campaign_resets_confirmation( ++ tmp_path, monkeypatch, bad_campaign ++): ++ completed = _campaign(cycles=1, outcome="no_op") ++ quiet = _boundary_frame(campaign=completed) ++ engine, script = _scripted_boundary( ++ tmp_path, ++ monkeypatch, ++ [ ++ quiet, ++ _boundary_frame(campaign=bad_campaign), ++ quiet, ++ quiet, ++ ], ++ ) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == "cycle_done_unabsorbed" ++ assert script.now >= 24 ++ ++ ++def test_boundary_malformed_baseline_does_not_turn_historic_counters_into_progress( ++ tmp_path, monkeypatch ++): ++ from src.systems.ouroboros import _docker_launcher as dl ++ ++ initial = _boundary_frame(campaign="{malformed") ++ quiet = _boundary_frame( ++ campaign=_campaign(cycles=7, absorbed=5, outcome="absorbed") ++ ) ++ engine, script = _scripted_boundary( ++ tmp_path, monkeypatch, [quiet, quiet], initial=initial ++ ) ++ ++ def publish(*args): ++ assert isinstance(script.frame["campaign"], dict) ++ assert len(script.reads) >= 2 ++ return script.publish(*args) ++ ++ monkeypatch.setattr(dl, "_write_forced_evolution_request", publish) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == "no_promotion" ++ assert result["cycle_outcome"] in ("", None) ++ assert result["cycles"] == 5 ++ ++ ++def test_boundary_recovers_api_baseline_before_request_publication( ++ tmp_path, monkeypatch ++): ++ from src.systems.ouroboros import _docker_launcher as dl ++ ++ campaign = _campaign(cycles=7, absorbed=5, outcome="absorbed") ++ initial = _boundary_frame(campaign=campaign, api_error=True) ++ quiet = _boundary_frame(campaign=campaign) ++ engine, script = _scripted_boundary( ++ tmp_path, monkeypatch, [quiet, quiet], initial=initial ++ ) ++ ++ def publish(*args): ++ assert script.frame["api_error"] is False ++ assert len(script.reads) >= 2 ++ return script.publish(*args) ++ ++ monkeypatch.setattr(dl, "_write_forced_evolution_request", publish) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == "no_promotion" ++ assert result["absorbed"] is False ++ assert result["cycle_outcome"] in ("", None) ++ assert script.publications == 1 ++ ++ ++@pytest.mark.parametrize("missing", ["campaign", "transaction"]) ++@pytest.mark.parametrize("recovers", [True, False]) ++def test_boundary_baseline_retry_preserves_observed_campaign_and_transaction( ++ tmp_path, monkeypatch, missing, recovers ++): ++ from src.systems.ouroboros import _docker_launcher as dl ++ ++ initial = _boundary_frame(campaign=_campaign(active=True), api_error=True) ++ disappeared = _boundary_frame( ++ campaign=None if missing == "campaign" else _campaign() ++ ) ++ terminal = _boundary_frame(campaign=_campaign(cycles=1, outcome="no_op")) ++ frames = [disappeared, disappeared] ++ if recovers: ++ frames.extend([terminal, terminal]) ++ engine, script = _scripted_boundary(tmp_path, monkeypatch, frames, initial=initial) ++ ++ def publish(*args): ++ # An unavailable API must not erase host identities from the first baseline read. ++ if recovers: ++ assert script.frame["campaign"] == terminal["campaign"] ++ assert script.now >= 24 ++ else: ++ assert script.now == 32 ++ return script.publish(*args) ++ ++ monkeypatch.setattr(dl, "_write_forced_evolution_request", publish) ++ ++ result = engine.on_instance_boundary() ++ ++ if recovers: ++ assert result["reason"] == "no_promotion" ++ assert script.now >= 32 ++ assert script.publications == 1 ++ else: ++ assert result["reason"] == "timeout" ++ assert any(missing in blocker for blocker in result["blockers"]) ++ assert script.now == 1800 ++ assert script.publications == 1 ++ ++ ++@pytest.mark.parametrize( ++ "bad_baseline", ++ [ ++ _boundary_frame(campaign="{malformed"), ++ _boundary_frame(api_error=True), ++ ], ++) ++def test_boundary_unreadable_baseline_publishes_once_after_short_capture_budget( ++ tmp_path, monkeypatch, bad_baseline ++): ++ engine, script = _scripted_boundary( ++ tmp_path, monkeypatch, [bad_baseline], initial=bad_baseline ++ ) ++ from src.systems.ouroboros import _docker_launcher as dl ++ ++ def publish(*args): ++ assert script.now == 32 ++ return script.publish(*args) ++ ++ monkeypatch.setattr(dl, "_write_forced_evolution_request", publish) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == "timeout" ++ assert result["blockers"] ++ assert script.now == 1800 ++ assert script.publications == 1 ++ ++ ++@pytest.mark.parametrize("later_progress", [False, True]) ++def test_boundary_recovered_unknown_baseline_never_claims_historical_progress( ++ tmp_path, monkeypatch, later_progress ++): ++ unreadable = _boundary_frame(campaign="{malformed") ++ historical = _boundary_frame( ++ campaign=_campaign(cycles=7, absorbed=5, outcome="absorbed") ++ ) ++ frames = [unreadable] * 5 + [historical, historical] ++ if later_progress: ++ historical["request"] = True ++ finished = _boundary_frame( ++ campaign=_campaign( ++ cycles=8, ++ absorbed=5, ++ outcome="no_op", ++ transaction_id="tx-later", ++ task_id="task-later", ++ ) ++ ) ++ frames.extend([finished, finished]) ++ engine, script = _scripted_boundary( ++ tmp_path, monkeypatch, frames, initial=unreadable ++ ) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == ( ++ "cycle_done_unabsorbed" if later_progress else "no_promotion" ++ ) ++ assert result["cycle_outcome"] == ("no_op" if later_progress else "") ++ assert result["absorbed"] is False ++ assert script.publications == 1 ++ assert 32 < script.now < 1800 ++ assert engine._boundary_wait["baseline_uncertain"] is False ++ ++ ++def test_native_boundary_recovers_baseline_without_waiting_for_new_cycle( ++ tmp_path, monkeypatch ++): ++ unreadable = _boundary_frame(campaign="{malformed") ++ historical = _boundary_frame(campaign=_campaign(cycles=7, absorbed=5)) ++ engine, script = _scripted_boundary( ++ tmp_path, monkeypatch, [unreadable] * 5 + [historical, historical], ++ initial=unreadable, trigger="native" ++ ) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == "no_promotion" ++ assert result["absorbed"] is False ++ assert 32 < result["elapsed"] < 1800 ++ assert script.publications == 0 ++ assert not result["blockers"] ++ ++ ++def test_boundary_disappearing_active_transaction_waits_for_terminal_record( ++ tmp_path, monkeypatch ++): ++ initial = _boundary_frame(campaign=_campaign(active=True)) ++ missing = _boundary_frame(campaign=_campaign()) ++ finished = _boundary_frame(campaign=_campaign(cycles=1, outcome="no_op")) ++ engine, script = _scripted_boundary( ++ tmp_path, monkeypatch, [missing, missing, finished, finished], initial=initial ++ ) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == "cycle_done_unabsorbed" ++ assert result["cycle_outcome"] == "no_op" ++ assert script.now >= 24 ++ ++ ++@pytest.mark.parametrize("archived", [False, True]) ++def test_boundary_unverified_restart_transaction_cannot_disappear( ++ tmp_path, monkeypatch, archived ++): ++ waiting = _campaign( ++ cycles=1, ++ outcome="waiting_for_restart", ++ active=True, ++ commit_sha="b" * 40, ++ restart_required=True, ++ restart_verified=False, ++ ) ++ initial = _boundary_frame(campaign=waiting) ++ disappeared = _campaign(cycles=1) ++ if archived: ++ disappeared["transaction_history"] = [waiting["active_transaction"]] ++ quiet = _boundary_frame(campaign=disappeared) ++ engine, script = _scripted_boundary( ++ tmp_path, monkeypatch, [quiet, quiet], initial=initial, trigger="native" ++ ) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == "timeout" ++ assert any("transaction" in blocker for blocker in result["blockers"]) ++ assert script.now == 1800 and script.publications == 0 ++ ++ ++def test_boundary_archived_abandonment_resolves_observed_restart(tmp_path, monkeypatch): ++ waiting = _campaign( ++ cycles=1, ++ outcome="waiting_for_restart", ++ active=True, ++ commit_sha="b" * 40, ++ restart_required=True, ++ restart_verified=False, ++ ) ++ # Startup can abandon verification while preserving the committed SHA in history. ++ abandoned = _campaign( ++ cycles=1, ++ outcome="abandoned", ++ commit_sha="b" * 40, ++ restart_required=False, ++ restart_verified=False, ++ ) ++ quiet = _boundary_frame(campaign=abandoned) ++ engine, script = _scripted_boundary( ++ tmp_path, ++ monkeypatch, ++ [quiet, quiet], ++ initial=_boundary_frame(campaign=waiting), ++ trigger="native", ++ ) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == "cycle_done_unabsorbed" ++ assert result["cycle_outcome"] == "abandoned" ++ assert result["blockers"] == [] ++ assert script.now == 16 and script.publications == 0 ++ ++ ++@pytest.mark.parametrize("guard", ["request", "marker", "enabled"]) ++def test_boundary_rereads_host_guards_after_api_snapshot(tmp_path, monkeypatch, guard): ++ from src.systems.ouroboros import _docker_launcher as dl ++ ++ quiet = _boundary_frame(campaign=_campaign(cycles=1, outcome="no_op")) ++ engine, script = _scripted_boundary(tmp_path, monkeypatch, [quiet, quiet, quiet]) ++ ++ def api(*args, **kwargs): ++ result = script.api(*args, **kwargs) ++ if len(script.reads) == 2: ++ if guard == "request": ++ (script.root / "post_task_evolution_request.json").write_text("{}") ++ elif guard == "marker": ++ (script.root / "pending_restart_verify.claimed.42.json").write_text( ++ "{}" ++ ) ++ else: ++ (script.root / "state.json").write_text( ++ '{"evolution_mode_enabled": true}' ++ ) ++ return result ++ ++ monkeypatch.setattr(dl, "_api", api) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == "cycle_done_unabsorbed" ++ assert script.now >= 16 ++ ++ ++@pytest.mark.parametrize( ++ "field", ++ [ ++ "supervisor_ready", ++ "workers_alive", ++ "pending_count", ++ "running_count", ++ "evolution_enabled", ++ "evolution_state", ++ "active_direct_turns", ++ "active_chat_activities", ++ ], ++) ++def test_boundary_missing_activity_telemetry_is_not_quiet(tmp_path, monkeypatch, field): ++ campaign = _campaign(cycles=1, outcome="no_op") ++ missing = _boundary_frame(campaign=campaign) ++ missing["api"].pop(field) ++ quiet = _boundary_frame(campaign=campaign) ++ engine, script = _scripted_boundary(tmp_path, monkeypatch, [missing, quiet, quiet]) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == "cycle_done_unabsorbed" ++ assert script.now >= 16 ++ ++ ++def test_boundary_absent_supervisor_error_is_healthy(tmp_path, monkeypatch): ++ quiet = _boundary_frame(campaign=_campaign(cycles=1, outcome="no_op")) ++ quiet["api"].pop("supervisor_error") ++ engine, script = _scripted_boundary(tmp_path, monkeypatch, [quiet, quiet]) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == "cycle_done_unabsorbed" ++ assert result["blockers"] == [] ++ assert script.now == 8 ++ ++ ++@pytest.mark.parametrize( ++ "field", ["evolution_state", "active_direct_turns", "active_chat_activities"] ++) ++def test_boundary_missing_telemetry_names_the_field_at_timeout( ++ tmp_path, monkeypatch, capsys, field ++): ++ missing = _boundary_frame(campaign=_campaign(cycles=1, outcome="no_op")) ++ missing["api"].pop(field) ++ engine, script = _scripted_boundary(tmp_path, monkeypatch, [missing]) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == "timeout" ++ assert f"missing API field: {field}" in result["blockers"] ++ assert f"missing API field: {field}" in capsys.readouterr().err ++ assert script.now == 1800 ++ ++ ++@pytest.mark.parametrize( ++ "field", ++ [ ++ "supervisor_ready", "workers_alive", "pending_count", "running_count", ++ "evolution_enabled", "evolution_state", "evolution_state.queued_task_id", ++ "evolution_state.running_task_id", "active_direct_turns", "active_chat_activities", ++ ], ++) ++def test_boundary_unknown_initial_api_shape_uses_counter_fallback_once( ++ tmp_path, monkeypatch, capsys, field ++): ++ initial = _boundary_frame(campaign=_campaign()) ++ completed = _boundary_frame(campaign=_campaign(cycles=1, outcome="no_op")) ++ for frame in (initial, completed): ++ parent, _, key = field.rpartition(".") ++ (frame["api"][parent] if parent else frame["api"]).pop(key) ++ if field == "workers_alive": ++ frame["api"]["workers_total"] = 2 ++ engine, script = _scripted_boundary( ++ tmp_path, monkeypatch, [completed], initial=initial ++ ) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == "cycle_done_unabsorbed" ++ assert result["elapsed"] < 1800 ++ assert engine._boundary_wait["legacy_readiness"] is True ++ assert script.publications == 1 ++ log = capsys.readouterr().err ++ assert log.count("legacy counter-based readiness") == 1 ++ assert f"missing API field: {field}" in log ++ ++ ++@pytest.mark.parametrize( ++ "fields", ++ [ ++ {"evolution_state": []}, ++ {"evolution_state": {"queued_task_id": [], "running_task_id": ""}}, ++ {"active_direct_turns": {}}, ++ {"workers_alive": None, "workers_total": 2}, ++ {"evolution_enabled": ""}, ++ {"supervisor_ready": "true"}, ++ ], ++) ++def test_boundary_wrong_api_shape_fallback_allows_idle_no_promotion( ++ tmp_path, monkeypatch, capsys, fields ++): ++ idle = _boundary_frame(campaign=_campaign(), **fields) ++ engine, script = _scripted_boundary( ++ tmp_path, monkeypatch, [idle], initial=idle, trigger="native" ++ ) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == "no_promotion" ++ assert 0 < result["elapsed"] < 1800 ++ assert script.publications == 0 ++ assert "invalid API field:" in capsys.readouterr().err ++ ++ ++def test_boundary_api_shape_fallback_preserves_known_restart_and_activity_guards( ++ tmp_path, monkeypatch ++): ++ initial = _boundary_frame(campaign=_campaign()) ++ initial["api"].pop("active_direct_turns") ++ completed = _campaign(cycles=1, outcome="no_op") ++ frames = [ ++ _boundary_frame(campaign=completed, markers=("pending_restart_verify.json",)), ++ _boundary_frame(campaign=completed, running_count=1), ++ _boundary_frame(campaign=completed, request=True), ++ _boundary_frame(campaign=completed), ++ ] ++ engine, script = _scripted_boundary( ++ tmp_path, monkeypatch, frames, initial=initial ++ ) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == "cycle_done_unabsorbed" ++ assert script.index == len(frames) - 1 ++ assert 0 < result["elapsed"] < 1800 ++ ++ ++@pytest.mark.parametrize( ++ "field,value", ++ [ ++ ("evolution_enabled", 1), ++ ("evolution_enabled", "true"), ++ ("evolution_state.queued_task_id", 7), ++ ("evolution_state.queued_task_id", ["queued"]), ++ ("evolution_state.running_task_id", 7), ++ ("evolution_state.running_task_id", ["running"]), ++ ("active_direct_turns", {"turn": {}}), ++ ("active_chat_activities", {"chat": {}}), ++ ("evolution_state", '{"running_task_id": "running"}'), ++ ("evolution_state", ["running"]), ++ ("evolution_state.queued_task_id", 0), ++ ("evolution_state.running_task_id", False), ++ ("active_direct_turns", 0), ++ ("active_chat_activities", False), ++ ("evolution_state", 0), ++ ("evolution_state", False), ++ ("evolution_enabled", "true"), ++ ("evolution_enabled", 1), ++ ("supervisor_ready", ""), ++ ("supervisor_ready", 0), ++ ], ++) ++def test_boundary_legacy_invalid_activity_shapes_block( ++ tmp_path, monkeypatch, field, value ++): ++ initial = _boundary_frame(campaign=_campaign()) ++ active = _boundary_frame(campaign=_campaign(cycles=1, outcome="no_op")) ++ for frame in (initial, active): ++ parent, _, key = field.rpartition(".") ++ (frame["api"][parent] if parent else frame["api"])[key] = value ++ engine, script = _scripted_boundary( ++ tmp_path, monkeypatch, [active], initial=initial ++ ) ++ ++ result = engine.on_instance_boundary() ++ ++ assert engine._boundary_wait["legacy_readiness"] is True ++ assert result["reason"] == "timeout" ++ blocker = ( ++ f"{field} activity in unexpected shape" if value ++ else f"invalid API field: {field}" ++ ) ++ if field == "evolution_enabled": ++ blocker = "API evolution_enabled true in unexpected shape" ++ if field == "supervisor_ready": ++ blocker = "supervisor not ready" # a present falsy answer blocks in both modes ++ assert blocker in result["blockers"] ++ assert script.now == 1800 ++ ++ ++@pytest.mark.parametrize( ++ "fields,ready", ++ [ ++ ({"workers_alive": None, "workers_total": 2}, True), ++ ({"workers_alive": None, "workers_total": 0}, False), ++ ({"workers_alive": None, "workers_total": None}, False), ++ ({"workers_alive": None, "workers_total": "2"}, False), ++ ({"workers_alive": None}, False), ++ ({"workers_alive": "2"}, False), ++ ], ++) ++def test_boundary_legacy_worker_liveness_requires_usable_count( ++ tmp_path, monkeypatch, fields, ready ++): ++ idle = _boundary_frame(campaign=_campaign(), **fields) ++ engine, script = _scripted_boundary( ++ tmp_path, monkeypatch, [idle], initial=idle, trigger="native" ++ ) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == ("no_promotion" if ready else "timeout") ++ assert ("no live workers or unknown" in result["blockers"]) is not ready ++ assert script.now == (8 if ready else 1800) ++ ++ ++@pytest.mark.parametrize("cycle_done", [False, True]) ++def test_boundary_legacy_mode_recovers_with_two_shaped_quiet_samples( ++ tmp_path, monkeypatch, capsys, cycle_done ++): ++ initial = _boundary_frame(campaign=_campaign(), active_direct_turns={}) ++ campaign = _campaign(cycles=1, outcome="no_op") if cycle_done else _campaign() ++ quiet = _boundary_frame(campaign=campaign) ++ frames = [quiet, quiet] if cycle_done else [initial, quiet, quiet] ++ engine, script = _scripted_boundary( ++ tmp_path, monkeypatch, frames, initial=initial ++ ) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == ("cycle_done_unabsorbed" if cycle_done else "no_promotion") ++ assert script.now == (8 if cycle_done else 16) ++ assert engine._boundary_wait["legacy_readiness"] is False ++ engine._wait_for_boundary_readiness() ++ log = capsys.readouterr().err ++ assert log.count("legacy counter-based readiness") == 1 ++ assert log.count("using full readiness checks") == 1 ++ ++ ++@pytest.mark.parametrize("renew", [False, True]) ++@pytest.mark.parametrize("initial_legacy", [False, True]) ++def test_boundary_new_wait_resets_api_shape_mode( ++ tmp_path, monkeypatch, capsys, renew, initial_legacy ++): ++ from src.systems.ouroboros import _docker_launcher as dl ++ ++ legacy = _boundary_frame(campaign=_campaign(), active_direct_turns={}) ++ initial = legacy if initial_legacy else _boundary_frame(campaign=_campaign()) ++ engine, script = _scripted_boundary( ++ tmp_path, monkeypatch, [initial], initial=initial, trigger="native" ++ ) ++ engine.on_instance_boundary() ++ ctx = engine._boundary_wait ++ assert ctx["api_shape_checked"] is True ++ assert ctx["legacy_readiness"] is initial_legacy ++ script.apply(legacy) ++ script.frames = [legacy] ++ capsys.readouterr() ++ api = script.api ++ ++ def observe_reset(*args, **kwargs): ++ assert engine._boundary_wait["api_shape_checked"] is False ++ assert engine._boundary_wait["legacy_readiness"] is False ++ monkeypatch.setattr(dl, "_api", api) ++ return api(*args, **kwargs) ++ ++ monkeypatch.setattr(dl, "_api", observe_reset) ++ if renew: ++ script.now = ctx["deadline"] + 1 ++ engine._wait_for_boundary_readiness() ++ assert engine._boundary_wait is ctx ++ else: ++ engine.on_instance_boundary() ++ assert engine._boundary_wait is not ctx ++ ++ assert engine._boundary_wait["legacy_readiness"] is True ++ assert capsys.readouterr().err.count("legacy counter-based readiness") == 1 ++ ++ ++@pytest.mark.parametrize("count", [0.5, [], {}, None, "unknown"]) ++def test_boundary_api_shape_fallback_does_not_truncate_invalid_queue_counts( ++ tmp_path, monkeypatch, count ++): ++ initial = _boundary_frame(campaign=_campaign(), pending_count=count) ++ completed = _campaign(cycles=1, outcome="no_op") ++ invalid = _boundary_frame(campaign=completed, pending_count=count) ++ idle = _boundary_frame(campaign=completed) ++ engine, script = _scripted_boundary( ++ tmp_path, monkeypatch, [invalid, idle], initial=initial ++ ) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == "cycle_done_unabsorbed" ++ assert 0 < result["elapsed"] < 1800 ++ assert script.index == 1 ++ ++ ++def test_boundary_sha_change_and_uptime_reset_break_quiet_confirmation( ++ tmp_path, monkeypatch ++): ++ campaign = _campaign(cycles=1, outcome="no_op") ++ engine, script = _scripted_boundary( ++ tmp_path, ++ monkeypatch, ++ [ ++ _boundary_frame(campaign=campaign), ++ _boundary_frame(campaign=campaign, sha="b" * 40), ++ _boundary_frame(campaign=campaign, sha="b" * 40), ++ _boundary_frame(campaign=campaign, sha="b" * 40, uptime=1), ++ _boundary_frame(campaign=campaign, sha="b" * 40, uptime=2), ++ _boundary_frame(campaign=campaign, sha="b" * 40, uptime=3), ++ ], ++ ) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == "cycle_done_unabsorbed" ++ assert script.now >= 40 ++ ++ ++def test_boundary_repeated_promotion_uses_one_shared_deadline_and_logs_timeout( ++ tmp_path, monkeypatch, capsys ++): ++ from src.systems.ouroboros import _docker_launcher as dl ++ ++ frames = [ ++ _boundary_frame( ++ campaign=_campaign(cycles=n, absorbed=n, outcome="absorbed"), ++ enabled=True, ++ evolution_enabled=True, ++ running_count=1, ++ sha=f"{n:040x}", ++ ) ++ for n in range(1, 240) ++ ] ++ engine, script = _scripted_boundary(tmp_path, monkeypatch, frames) ++ script.api_cost = 0.25 ++ ++ result = engine.on_instance_boundary() ++ deadline = engine._boundary_wait["deadline"] ++ # Expiry still rereads host guards, but cannot grant another HTTP allowance. ++ marker = script.root / "pending_restart_verify.claimed.99.json" ++ marker.write_text("{}") ++ monkeypatch.setattr( ++ dl, "_api", lambda *a, **k: pytest.fail("API probe after boundary expiry") ++ ) ++ again = engine._wait_for_boundary_readiness(renew_expired=False) ++ ++ assert result["reason"] == again["reason"] == "timeout" ++ assert script.now == 1800 ++ assert result["elapsed"] == 1800 ++ assert deadline == engine._boundary_wait["deadline"] == 1800 ++ assert script.publications == 1 ++ assert script.sleeps[-1][1] < 8 ++ assert script.reads[-1][1] < 8 ++ assert result["blockers"] ++ assert f"restart marker: {marker.name}" in again["blockers"] ++ log = capsys.readouterr().err ++ assert "TIMEOUT" in log ++ assert "blockers" in log.lower() ++ ++ ++@pytest.mark.parametrize("recovers", [True, False]) ++def test_boundary_expired_gate_starts_a_fresh_bounded_wait( ++ tmp_path, monkeypatch, capsys, recovers ++): ++ idle = _boundary_frame(campaign=_campaign()) ++ busy = _boundary_frame( ++ campaign=_campaign(), markers=("pending_restart_verify.claimed.42.json",) ++ ) ++ engine, script = _scripted_boundary(tmp_path, monkeypatch, [idle]) ++ engine.on_instance_boundary() ++ old_deadline = engine._boundary_wait["deadline"] ++ script.now = old_deadline + 600 # a question can outlive its boundary window ++ script.apply(busy) ++ script.frames = [busy, idle] if recovers else [busy] ++ script.index = -1 ++ started, probes = script.now, len(script.reads) ++ ++ result = engine._wait_for_boundary_readiness() ++ ++ assert 0 < script.now - started <= 1800 ++ assert result["budget_elapsed"] == script.now - started ++ assert engine._boundary_wait["deadline"] == started + 1800 ++ assert len(script.reads) > probes ++ assert script.publications == 1 ++ assert result["reason"] == ("no_promotion" if recovers else "timeout") ++ if not recovers: ++ assert script.now == started + 1800 ++ assert "TIMEOUT" in capsys.readouterr().err ++ ++ ++def test_boundary_publication_cannot_renew_an_exhausted_wait(tmp_path, monkeypatch): ++ from src.systems.ouroboros import _docker_launcher as dl ++ ++ engine, script = _scripted_boundary(tmp_path, monkeypatch, [_boundary_frame()]) ++ ++ def publish(*args): ++ script.publications += 1 ++ script.now = engine._boundary_wait["deadline"] ++ return True ++ ++ monkeypatch.setattr(dl, "_write_forced_evolution_request", publish) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == "timeout" ++ assert result["budget_elapsed"] == 1800 ++ assert engine._boundary_wait["deadline"] == 1800 ++ assert script.publications == 1 ++ ++ ++def test_control_boundary_resets_budget_without_reading_evolution( ++ tmp_path, monkeypatch ++): ++ engine, script = _scripted_boundary( ++ tmp_path, monkeypatch, [_boundary_frame(api_error=True)] ++ ) ++ resets = [] ++ engine.evolution = False ++ engine._reset_per_task_budget = lambda *a, **k: resets.append(k) ++ monkeypatch.setattr( ++ engine, "_begin_boundary_wait", lambda: pytest.fail("control read a baseline") ++ ) ++ ++ assert engine.on_instance_boundary() == {} ++ assert resets == [{"confirm_isolated": True}] ++ assert script.now == 0 and script.publications == 0 ++ ++ ++def test_boundary_each_gate_requires_fresh_quiet_samples(tmp_path, monkeypatch): ++ quiet = _boundary_frame(campaign=_campaign(cycles=1, outcome="no_op")) ++ engine, script = _scripted_boundary(tmp_path, monkeypatch, [quiet, quiet]) ++ engine.on_instance_boundary() ++ deadline = engine._boundary_wait["deadline"] ++ script.now += 300 # a connectivity hold cannot preserve old confirmation ++ started, reads = script.now, len(script.reads) ++ ++ result = engine._wait_for_boundary_readiness() ++ ++ assert len(script.reads) == reads + 2 ++ assert script.now == started + 8 ++ assert result["elapsed"] == 8 ++ assert result["budget_elapsed"] == script.now ++ assert engine._boundary_wait["deadline"] == deadline ++ ++ ++@pytest.mark.parametrize("field", ["sha", "uptime"]) ++def test_boundary_optional_comparison_telemetry_does_not_block_publication( ++ tmp_path, monkeypatch, field ++): ++ initial = _boundary_frame(campaign=_campaign()) ++ quiet = _boundary_frame(campaign=_campaign(cycles=1, outcome="no_op")) ++ initial["api"].pop(field) ++ quiet["api"].pop(field) ++ engine, script = _scripted_boundary( ++ tmp_path, monkeypatch, [quiet, quiet], initial=initial ++ ) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == "cycle_done_unabsorbed" ++ assert script.now == 8 and script.publications == 1 ++ ++ ++@pytest.mark.parametrize( ++ "evolution,trigger,expected", ++ [ ++ (True, "forced", "off"), ++ (True, "native", "every_n:3"), ++ (False, "forced", None), ++ (False, "native", None), ++ ], ++) ++def test_boundary_cadence_only_disarms_autonomous_forced_treatment( ++ evolution, trigger, expected ++): ++ from src.systems.ouroboros._docker_launcher import DockerOuroborosEngine ++ ++ engine = DockerOuroborosEngine.__new__(DockerOuroborosEngine) ++ engine.model = "anthropic/claude-sonnet-4.6" ++ engine.evolution = evolution ++ engine.evolution_trigger = trigger ++ engine.cadence = "every_n:3" ++ engine.resume = False ++ engine.steer = "" ++ ++ overrides = engine._overrides() ++ ++ assert overrides["OUROBOROS_POST_TASK_EVOLUTION"] == str(evolution).lower() ++ assert overrides.get("OUROBOROS_POST_TASK_EVOLUTION_CADENCE") == expected ++ ++ ++def test_boundary_archived_restart_obligation_does_not_block_later_round( ++ tmp_path, monkeypatch ++): ++ historical = _campaign( ++ cycles=1, ++ outcome="waiting_for_restart", ++ commit_sha="b" * 40, ++ restart_required=True, ++ restart_verified=False, ++ ) ++ quiet = _boundary_frame(campaign=historical) ++ engine, script = _scripted_boundary( ++ tmp_path, monkeypatch, [quiet, quiet], initial=quiet, trigger="native" ++ ) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == "no_promotion" ++ assert result["cycle_outcome"] in ("", None) ++ assert script.now == 8 ++ assert script.publications == 0 ++ ++ ++def test_boundary_legacy_history_and_foreign_archives_preserve_current_progress( ++ tmp_path, monkeypatch ++): ++ campaign = _campaign(cycles=1, outcome="no_op") ++ campaign["history"].insert(0, {"task_id": "legacy", "status": "completed"}) ++ campaign["transaction_history"].append( ++ { ++ "campaign_id": "old-campaign", ++ "transaction_id": "old-tx", ++ "task_id": "old-task", ++ "cycle_outcome": "absorbed", ++ } ++ ) ++ quiet = _boundary_frame(campaign=campaign) ++ engine, script = _scripted_boundary(tmp_path, monkeypatch, [quiet, quiet]) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == "cycle_done_unabsorbed" ++ assert result["cycle_outcome"] == "no_op" ++ assert result["cycles_done"] == 1 ++ assert script.now == 8 ++ ++ ++def test_boundary_bad_history_row_keeps_counters_and_readable_transactions( ++ tmp_path, monkeypatch ++): ++ campaign = _campaign(cycles=1, outcome="abandoned") ++ campaign["transaction_history"].append({"task_id": "broken"}) ++ busy = _boundary_frame(campaign=campaign) ++ engine, script = _scripted_boundary(tmp_path, monkeypatch, [busy]) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == "timeout" ++ assert result["cycle_outcome"] == "abandoned" ++ assert result["cycles_done"] == 1 ++ assert any("transaction_history[1]" in blocker for blocker in result["blockers"]) ++ assert script.publications == 1 ++ ++ ++def test_boundary_repaired_baseline_transaction_is_not_new_progress( ++ tmp_path, monkeypatch ++): ++ campaign = _campaign(cycles=7, absorbed=5, outcome="absorbed") ++ repaired = _boundary_frame(campaign=copy.deepcopy(campaign)) ++ campaign["history"][0]["transaction"].pop("transaction_id") ++ malformed = _boundary_frame(campaign=campaign) ++ engine, script = _scripted_boundary( ++ tmp_path, monkeypatch, [malformed] * 5 + [repaired, repaired], initial=malformed ++ ) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == "no_promotion" ++ assert result["cycle_outcome"] == "" ++ assert result["absorbed"] is False ++ assert result["cycles_done"] == 7 and result["cycles"] == 5 ++ assert script.publications == 1 ++ assert 0 < result["elapsed"] < 1800 ++ ++ ++def test_boundary_verified_active_transaction_uses_engine_restart_predicate( ++ tmp_path, monkeypatch ++): ++ quiet = _boundary_frame( ++ campaign=_campaign( ++ cycles=1, ++ outcome="no_op", ++ active=True, ++ commit_sha="b" * 40, ++ restart_verified=True, ++ ) ++ ) ++ engine, script = _scripted_boundary(tmp_path, monkeypatch, [quiet, quiet]) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == "cycle_done_unabsorbed" ++ assert script.now == 8 ++ ++ ++def test_boundary_historical_active_outcome_is_not_new_progress(tmp_path, monkeypatch): ++ quiet = _boundary_frame( ++ campaign=_campaign( ++ cycles=1, ++ absorbed=1, ++ outcome="absorbed", ++ active=True, ++ commit_sha="b" * 40, ++ restart_verified=True, ++ ) ++ ) ++ engine, script = _scripted_boundary( ++ tmp_path, monkeypatch, [quiet, quiet], initial=quiet, trigger="native" ++ ) ++ ++ result = engine.on_instance_boundary() ++ ++ assert result["reason"] == "no_promotion" ++ assert result["cycle_outcome"] == "" ++ assert script.now == 8 ++ ++ ++def test_sandbox_lifetime_override(): ++ """Importing our system must raise minisweagent's 2h sandbox lifetime: the bench never ++ overrides it and every >2h Ouroboros sales run lost its container mid-run.""" ++ import importlib ++ importlib.import_module("src.systems.ouroboros.system") ++ from minisweagent.environments.docker import DockerEnvironmentConfig ++ assert DockerEnvironmentConfig(image="x").container_timeout == "24h" ++ ++ ++def _fake_observability(tmp_path, tid, outputs): ++ """outputs: list of (name, content, age_sec) — writes engine-shaped call/blob pairs.""" ++ import gzip, json, os, time ++ calls = tmp_path / "observability" / "calls" / tid; calls.mkdir(parents=True) ++ blobs = tmp_path / "observability" / "blobs"; blobs.mkdir(parents=True, exist_ok=True) ++ for k, (name, content, age) in enumerate(outputs): ++ sha = f"{k:064x}" ++ with gzip.open(blobs / f"{sha}.json.gz", "wt") as fh: ++ json.dump({"message": {"content": content}, "usage": {}}, fh) ++ p = calls / name; p.write_text(json.dumps({"full_payload_ref": {"sha256": sha}})) ++ ts = time.time() - age; os.utime(p, (ts, ts)) ++ ++ ++def test_official_parser_copy_matches_claude_adapter(): ++ """Our answer locator must stay byte-identical to the Claude Code adapter's.""" ++ import re ++ def func(src, name): ++ i = src.find(f"\ndef {name}(") + 1 ++ m = re.search(r"\n(?=(def |class |@|[A-Za-z_]))", src[i + 1:]) ++ end = i + 1 + m.start() if m else len(src) # last construct in file: run to EOF ++ return src[i:end].rstrip() ++ claude = open("src/systems/claude/system.py").read() ++ ours = open("src/systems/ouroboros/action_parse.py").read() ++ for name in ("_json_candidates", "_parse_action_text"): ++ assert func(claude, name) == func(ours, name), f"{name} drifted from the claude adapter" ++ ++ ++def test_task_assistant_text_is_oldest_first_and_parser_prefers_latest_valid(tmp_path): ++ """2026-09-02: a complete schema-valid answer written as plain text (no marker) ended as ++ final_answer="" / result={"delivery_control":"keep"}. Assembling the task's assistant text ++ like the official adapters and applying their parser recovers it: the trailing control ++ envelope fails the schema and the parser walks back to the latest valid JSON.""" ++ from pydantic import BaseModel ++ from src.systems.ouroboros import _live_bridge as lb ++ from src.systems.ouroboros.action_parse import _parse_action_text ++ ++ class Schema(BaseModel): ++ predictions: list[dict] ++ ++ good = '{"predictions": [{"locality": "SF", "furniture_name": "Chair", "year": 2032, "items_sold": 3}]}' ++ _fake_observability(tmp_path, "abc123", [ ++ ("llm_a_response.json", "thinking about the data", 30), ++ ("llm_b_response.json", "analysis...\n" + good, 20), ++ ("llm_c_response.json", '{"delivery_control":"keep"}', 1), ++ ]) ++ ++ class _Eng: data_root = tmp_path ++ ctl = lb.LiveController.__new__(lb.LiveController); ctl.engine = _Eng() ++ text = ctl._task_assistant_text("abc123") ++ assert text.index("thinking") < text.index('"predictions"') < text.index("delivery_control") ++ combined = text + "\n" + '{"delivery_control":"keep"}' # result text appended like the ladder does ++ out = _parse_action_text(combined, Schema) ++ assert out.predictions[0]["items_sold"] == 3 ++ ++ src = open("src/systems/ouroboros/_live_bridge.py").read() ++ body = src.split("def _final_answer_action", 1)[1] ++ # the official walk (most-recent candidate first, shared validator) runs before coercion/repair ++ assert "reversed(_json_candidates(combined))" in body ++ assert "validate_with_coercion(candidate, schema)" in body ++ assert body.index("reversed(_json_candidates(combined))") < body.index("self._coerce_final(raw, schema, tid, why)") ++ ++ ++def test_format_repair_locates_answer_with_the_official_parser(monkeypatch): ++ """The repair turn's answer is located exactly like the official adapters locate a ++ repaired answer (latest schema-valid JSON in the repair assistant text); also pins that ++ the repair path references the repair task id correctly (a NameError here killed a ++ probe in August).""" ++ from pydantic import BaseModel ++ from src.systems.ouroboros import _live_bridge as lb ++ from src.systems.ouroboros import run_clbench_bridge_agent as rb ++ ++ class Schema(BaseModel): ++ predictions: list[dict] ++ ++ class _Srv: ++ base_url = "http://127.0.0.1:1" ++ def wait_task(self, tid, timeout=30): ++ return {"status": "completed", "final_answer": "", "result": '{"delivery_control":"keep"}'} ++ class _Eng: ++ server = _Srv(); data_root = "/nonexistent"; mm = "tools" ++ class _Q: ++ prompt = "brief"; instance_index = 3 ++ response_schema = Schema ++ ++ ctl = lb.LiveController.__new__(lb.LiveController) ++ ctl.engine = _Eng(); ctl._task_id = "main1"; ctl.conversation = "question"; ctl.final_delivery = True ++ ctl.memory_mode = "tools"; ctl.discipline = ""; ctl.task_timeout_sec = 60 ++ for attr in ("_last_delivered_blob", "_item_had_steps", "_resume_root"): ++ setattr(ctl, attr, None) ++ monkeypatch.setattr(rb, "_api", lambda *a, **k: {"task_id": "rep1"}) ++ good = '{"predictions": [{"locality": "SF", "furniture_name": "Chair", "year": 2032, "items_sold": 7}]}' ++ monkeypatch.setattr(ctl, "_task_assistant_text", lambda tid: (good + '\n{"delivery_control":"keep"}') if tid == "rep1" else "") ++ out = ctl._format_repair_action(_Q(), Schema, "main1", "schema final text: ValidationError") ++ assert out is not None and out.predictions[0]["items_sold"] == 7 From b266f6fdd757fa580943d270121ef8fffc6970d5 Mon Sep 17 00:00:00 2001 From: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:37:19 +0300 Subject: [PATCH 04/44] fix: order migrated subagent receipts by timestamp instant Normalize mixed UTC spellings for legacy history and retain the architecture byte budget after integrating the original contributor PR. --- docs/architecture/06-agent-core.md | 2 +- ouroboros/subagent_history.py | 21 ++++++++++++++++++--- tests/test_subagent_execution_history.py | 16 ++++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/docs/architecture/06-agent-core.md b/docs/architecture/06-agent-core.md index ae04bdc02..c7c401dec 100644 --- a/docs/architecture/06-agent-core.md +++ b/docs/architecture/06-agent-core.md @@ -373,7 +373,7 @@ Catalog and tail reads check native controls and remaining operation/deadline al **Route health.** `subagents.route_health` is the one health reader for dispatcher, nanny and review slots, and it does not guess admission: the doctor `status` describes only the DEFAULT credential store while real accounts live in the engine's credential-profile pool, so admission is the engine's, whose start POST answers an empty pool with its own typed refusal (`credential_pool_exhausted` + earliest reset) at $0; the gateway types it as a timer-healing window, scheduled on its reset like a spent subscription window, only when `resetsAt` is dated; an undated (structural) pool stays a plain `ClaudexorUnavailable`. `enabled` is honored as `route_disabled` (the owner's switch, not an observation); the other structural refusals are `route_not_in_capability_catalog`, an access-profile mismatch, `engine_rejects_delegated_marker` and positively proven quota exhaustion. A fully-used ratio needs a valid future reset before it can refuse a route; an incomplete reading delegates admission to the engine and never certifies available quota; explicit active cooldowns bind. Review slots inherit "the engine decides", never a silent fallback onto metered API spend: on the `auto` lane a dead daemon keeps the native fallback with its visible marker, while "daemon alive, pool empty" is discovered at the engine and disclosed. The model sees a semi-stable facts-only catalog (`model_visible_subagent_catalog`); invalid, list-disabled or all-rows-off configuration projects nothing; dispatch is the live check. -Historical helper observations use `state/subagent_last_delegation.json`, owned by `subagent_history.py`. Task finalization joins configured API actors to their own attempts, preserving requested pins and observed routes/accounts; fallback success never certifies the failed route, nor does code/test failure condemn a provider. Sparse pre-response failures stay in usage/history/raw events; served-call trace references retain received response-backed calls, including incomplete responses. Foreground/recovered session terminals and typed start failures carry original effort/processing through `RunCustody`/`STARTED`/`START_FAILED` facts, without history-only archive reads; missing options stay unknown, distinct from captured defaults. Pre-invocation bootstrap refusals keep their source; reviewers keep separate receipts. Known occurrence and first observation differ: replay neither refreshes age nor replaces newer dated evidence; definite evidence may resolve an unknown start. `MAX_CONFIGURED_SUBAGENTS` bounds latest actor rows; an old single receipt stays readable and becomes its actor's row on the next write. Dynamic Runtime context and owner UI consume history without changing catalog, admission, quota, dispatch or ranking. +Historical helper observations use `state/subagent_last_delegation.json`, owned by `subagent_history.py`. Task finalization joins configured API actors to their own attempts, preserving requested pins and observed routes/accounts; fallback success never certifies the failed route, nor does code/test failure condemn a provider. Sparse pre-response failures stay in usage/history/raw events; served-call trace references retain received response-backed calls, including incomplete responses. Foreground/recovered session terminals and typed start failures carry original effort/processing through `RunCustody`/`STARTED`/`START_FAILED` facts, without history-only archive reads; missing options stay unknown, distinct from captured defaults. Pre-invocation bootstrap refusals keep their source; reviewers keep separate receipts. Known occurrence and first observation differ: replay neither refreshes age nor replaces newer dated evidence; definite evidence may resolve an unknown start. `MAX_CONFIGURED_SUBAGENTS` bounds actor rows; the next write seeds an old single receipt into its actor row. Runtime context and UI read history, not routing authority. **Read-only and mutating session rows share one nanny transport.** The only difference is the run shape, whose ONE owner is `subagents.delegated_run_shape`, asked one question — is this an acting child? — by `tools/delegate._derive_authority` (live `ToolContext`) and by `resolve_configured_actor_dispatch` (configured snapshot); a shape re-derived in two places drifts unsafely in exactly one branch. diff --git a/ouroboros/subagent_history.py b/ouroboros/subagent_history.py index 806152f50..a5499d785 100644 --- a/ouroboros/subagent_history.py +++ b/ouroboros/subagent_history.py @@ -5,6 +5,7 @@ from __future__ import annotations import json import logging import pathlib +from datetime import datetime, timezone from typing import Any, Mapping from ouroboros.utils import utc_now_iso, write_text_atomic @@ -13,6 +14,19 @@ log = logging.getLogger(__name__) LAST_DELEGATION_FILENAME = "subagent_last_delegation.json" +def _timestamp_key(value: Any) -> tuple[int, datetime]: + """Order recorded instants, not their differing ISO spellings (Z/offset/fraction).""" + text = str(value or "") + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return (1, parsed.astimezone(timezone.utc)) + except (ValueError, OverflowError): + # Unknown/legacy malformed dates cannot outrank a known instant. + return (0, datetime.min.replace(tzinfo=timezone.utc)) + + def _last_delegation_path(drive_root=None): from ouroboros.config import DATA_DIR @@ -117,11 +131,12 @@ def record_last_delegation(*, route: str, requested_model: str, applied_model: s ("task_id", task_id), ("invocation_id", invocation_id), ("attempt_id", attempt_id)): if value: row[key] = str(value) - if selected_subagent_id and (not old_actor or (occurred_at and row["ts"] >= str(old_actor.get("ts") or ""))): + if selected_subagent_id and (not old_actor or (occurred_at and + _timestamp_key(row["ts"]) >= _timestamp_key(old_actor.get("ts")))): rows[selected_subagent_id] = row - rows = dict(sorted(rows.items(), key=lambda item: str(item[1].get("ts") or ""), + rows = dict(sorted(rows.items(), key=lambda item: _timestamp_key(item[1].get("ts")), reverse=True)[:MAX_CONFIGURED_SUBAGENTS]) - latest = row if not old or (occurred_at and row["ts"] >= str(old.get("ts") or "")) else old + latest = row if not old or (occurred_at and _timestamp_key(row["ts"]) >= _timestamp_key(old.get("ts"))) else old write_text_atomic(path, json.dumps({**latest, "latest_by_subagent": rows}, ensure_ascii=False, indent=1)) finally: release_exclusive_file_lock(lock_path, lock) diff --git a/tests/test_subagent_execution_history.py b/tests/test_subagent_execution_history.py index 26718e027..e1eafd28e 100644 --- a/tests/test_subagent_execution_history.py +++ b/tests/test_subagent_execution_history.py @@ -169,6 +169,22 @@ def test_legacy_first_write_reaches_runtime_context(tmp_path, monkeypatch): assert seen == {"new-run", "legacy-run"} +def test_first_write_same_actor_orders_mixed_utc_timestamp_spellings(tmp_path): + """A fractional +00:00 instant follows a whole-second legacy Z instant.""" + path = tmp_path / "state" / "subagent_last_delegation.json" + path.parent.mkdir() + path.write_text(json.dumps({"ts": "2026-09-17T12:00:00Z", + "selected_subagent_id": "legacy-worker", "run_id": "legacy-run", + "route": "api_model"}), encoding="utf-8") + record_last_delegation(route="api_model", requested_model="openai::new", + applied_model="openai::new", run_id="new-run", + selected_subagent_id="legacy-worker", drive_root=tmp_path, + occurred_at="2026-09-17T12:00:00.999999+00:00", outcome="succeeded") + row = subagent_last_delegation(tmp_path) + assert row["run_id"] == "new-run" + assert row["latest_by_subagent"]["legacy-worker"]["run_id"] == "new-run" + + def test_old_corrupt_missing_and_unknown_time_are_not_health(tmp_path): assert subagent_last_delegation(tmp_path) == {} path = tmp_path / "state" / "subagent_last_delegation.json" From b0a49287bf6c85fa7209eda908bcc5f380fe4dc4 Mon Sep 17 00:00:00 2001 From: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:13:39 +0300 Subject: [PATCH 05/44] wip: restore configurable empty-Main welcome candidate from rescue 20260929_120807_88f10537 --- ouroboros/gateway/contracts.py | 1 + ouroboros/gateway/ui_preferences.py | 13 +++++ tests/test_ui_preferences_api.py | 24 ++++++++ web/modules/api_types.js | 1 + web/modules/chat.js | 41 ++++++++++++-- web/modules/settings.js | 11 ++-- web/modules/settings_ui.js | 22 +++++++ web/modules/welcome_preference.js | 85 ++++++++++++++++++++++++++++ web/style.css | 23 ++++++++ web/tests/welcome_preference.test.js | 75 ++++++++++++++++++++++++ 10 files changed, 285 insertions(+), 11 deletions(-) create mode 100644 web/modules/welcome_preference.js create mode 100644 web/tests/welcome_preference.test.js diff --git a/ouroboros/gateway/contracts.py b/ouroboros/gateway/contracts.py index 5b0f06b48..21e3d384d 100644 --- a/ouroboros/gateway/contracts.py +++ b/ouroboros/gateway/contracts.py @@ -916,6 +916,7 @@ class UiPreferencesResponse(TypedDict): sidebar_width: int # px; 0 = CSS default (resizable side sections, v6.33.0) project_panel_width: int # px; 0 = CSS default project_seen_revision: dict[str, int] # monotonic paint ACK per active Project + welcome: dict[str, str] # install-wide empty-Main UI copy: mode default|hidden|custom and plain text class GitLogResponse(TypedDict): diff --git a/ouroboros/gateway/ui_preferences.py b/ouroboros/gateway/ui_preferences.py index a5cbd9946..427073dec 100644 --- a/ouroboros/gateway/ui_preferences.py +++ b/ouroboros/gateway/ui_preferences.py @@ -31,6 +31,8 @@ DEFAULT_UI_PREFERENCES: dict[str, Any] = { # unknown-key 400, a stored legacy key is ignored on read and dropped on # the next write (``project_seen_revision`` is the replacement). "project_seen_revision": {}, + # Install-wide presentation only: never a model reply or a chat-history row. + "welcome": {"mode": "default", "text": ""}, } _KNOWN_KEYS = frozenset(DEFAULT_UI_PREFERENCES) _MAX_WIDGET_ORDER_ITEMS = 200 @@ -40,6 +42,7 @@ _SIDEBAR_WIDTH_MIN, _SIDEBAR_WIDTH_MAX = 180, 560 _PROJECT_PANEL_WIDTH_MIN, _PROJECT_PANEL_WIDTH_MAX = 320, 1100 _MAX_PROJECT_CURSORS = 1000 _MAX_PROJECT_ID_LENGTH = 64 +_MAX_WELCOME_CHARS = 500 @contextmanager @@ -140,6 +143,16 @@ def _normalize_preferences( except (TypeError, ValueError): raise ValueError("project_seen_revision values must be integers") prefs["project_seen_revision"] = cleaned + if "welcome" in raw: + value = raw["welcome"] + if not isinstance(value, dict) or set(value) != {"mode", "text"}: + raise ValueError("welcome must have exactly mode and text") + mode, text = value["mode"], value["text"] + if mode not in ("default", "hidden", "custom") or not isinstance(text, str): + raise ValueError("welcome mode or text is invalid") + if len(text) > _MAX_WELCOME_CHARS or (mode == "custom" and not text.strip()): + raise ValueError("welcome custom text must be nonblank and at most 500 characters") + prefs["welcome"] = {"mode": mode, "text": text} return prefs diff --git a/tests/test_ui_preferences_api.py b/tests/test_ui_preferences_api.py index c30eb838a..70245eb79 100644 --- a/tests/test_ui_preferences_api.py +++ b/tests/test_ui_preferences_api.py @@ -28,6 +28,7 @@ def test_ui_preferences_round_trip_and_normalization(tmp_path): "sidebar_width": 0, "project_panel_width": 0, "project_seen_revision": {}, + "welcome": {"mode": "default", "text": ""}, } create_project(tmp_path, "racer", name="Racer") @@ -123,6 +124,29 @@ def test_ui_preferences_round_trip_and_normalization(tmp_path): assert client.post("/api/ui/preferences", json={"unknown": True}).status_code == 400 +def test_empty_chat_welcome_preference_round_trip_and_refusals(tmp_path): + from starlette.testclient import TestClient + + app = Starlette(routes=collect_routes(data_dir=tmp_path)) + app.state.drive_root = tmp_path + with TestClient(app) as client: + assert client.get("/api/ui/preferences").json()["welcome"] == {"mode": "default", "text": ""} + custom = {"mode": "custom", "text": "Привет мир\nagain"} + response = client.post("/api/ui/preferences", json={"welcome": custom}) + assert response.status_code == 200 + assert response.json()["welcome"] == custom + assert client.get("/api/ui/preferences").json()["welcome"] == custom + for invalid in ({"mode": "custom", "text": " "}, {"mode": "custom", "text": "x" * 501}, + {"mode": "other", "text": "x"}, {"mode": "custom"}, None): + assert client.post("/api/ui/preferences", json={"welcome": invalid}).status_code == 400 + assert client.get("/api/ui/preferences").json()["welcome"] == custom + hidden = {"mode": "hidden", "text": custom["text"]} + assert client.post("/api/ui/preferences", json={"welcome": hidden}).json()["welcome"] == hidden + assert client.post("/api/ui/preferences", json={"widget_order": ["skill:x"]}).json()["welcome"] == hidden + default = {"mode": "default", "text": custom["text"]} + assert client.post("/api/ui/preferences", json={"welcome": default}).json()["welcome"] == default + + def test_ui_preferences_concurrent_paint_acks_are_monotonic(tmp_path): from ouroboros.gateway.ui_preferences import api_ui_preferences_post from ouroboros.projects_registry import create_project, increment_project_visible_revision diff --git a/web/modules/api_types.js b/web/modules/api_types.js index 8439b627f..2172d0f6c 100644 --- a/web/modules/api_types.js +++ b/web/modules/api_types.js @@ -1425,6 +1425,7 @@ * @property {number} sidebar_width // px; 0 = CSS default (v6.33.0) * @property {number} project_panel_width // px; 0 = CSS default * @property {Object.} project_seen_revision // monotonic paint ACK + * @property {{mode:'default'|'hidden'|'custom',text:string}} welcome // install-wide empty-Main UI copy, not chat history * @property {boolean=} ok */ diff --git a/web/modules/chat.js b/web/modules/chat.js index 9fbbaa2c5..06507520a 100644 --- a/web/modules/chat.js +++ b/web/modules/chat.js @@ -46,6 +46,7 @@ import { } from './task_control_menu.js'; import { openConfirmDialog } from './confirm_dialog.js'; import { bindEnterSubmit } from './ui_interactions.js'; +import { welcomeText } from './welcome_preference.js'; import { captureLiveCardPhaseState, desiredLiveCardPhase, @@ -329,8 +330,11 @@ export function createChatInstance({ const prefs = await apiClient.uiPreferences(); if (destroyed) return; nestedSubagentsExpanded = prefs?.nested_subagents_expanded === true; + welcomePreference = prefs?.welcome || null; + ensureWelcomeMessage(); } catch { nestedSubagentsExpanded = false; + // Keep a previously observed choice; an unavailable read is not a reset. } } @@ -428,7 +432,8 @@ export function createChatInstance({ let hydrationGatePromise = null; // The server retains whole-history coverage independently of the DOM window. let historyWindow = null; - let welcomeShown = false; + let welcomePreference = null; + let welcomeNode = null; // Cross-instance hide/show position; visible mutations use live geometry. let _savedScrollTop = Math.max(0, Number(initialScrollState?.scrollTop) || 0); let _savedStick = initialScrollState ? initialScrollState.stick !== false : true; @@ -2376,15 +2381,35 @@ export function createChatInstance({ const markPendingDropped = (clientMessageId) => markPendingDelivered(clientMessageId, true); function ensureWelcomeMessage() { - if (!isMain) return; - if (welcomeShown) return; + if (destroyed) return; const hasRealBubbles = Array.from(messagesDiv.querySelectorAll('.chat-bubble')).some( bubble => !bubble.classList.contains('typing-bubble') ); - if (hasRealBubbles) return; - welcomeShown = true; - addMessage('Ouroboros has awakened', 'assistant', false, null, false, { ephemeral: true }); + const copy = welcomeText(welcomePreference); + // A failed or partial history read cannot establish that this is an empty chat. + if (!isMain || !lastHistorySyncSucceeded || historyWindow?.complete !== true + || hasRealBubbles || !copy) { + if (welcomeNode) { welcomeNode.remove(); welcomeNode = null; } + return; + } + if (!welcomeNode) { + welcomeNode = document.createElement('div'); + welcomeNode.className = 'chat-empty-welcome'; + welcomeNode.dataset.welcomeState = 'ready'; + welcomeNode.innerHTML = 'Welcome

'; + messagesDiv.appendChild(welcomeNode); + } + welcomeNode.querySelector('p').textContent = copy; } + // Every kind of incoming content (including media and cards) has one DOM + // insertion seam; observing it removes the empty state without a type list. + const welcomeObserver = new MutationObserver(() => ensureWelcomeMessage()); + if (isMain) welcomeObserver.observe(messagesDiv, { childList: true }); + const onWelcomeChanged = (event) => { + welcomePreference = event.detail; + ensureWelcomeMessage(); + }; + if (isMain) window.addEventListener('ouro:welcome-changed', onWelcomeChanged); // Hydration triggers share one sticky request; reconnect/resync still refetch. function awaitInitialHydration({ includeUser = false } = {}) { @@ -2808,6 +2833,8 @@ export function createChatInstance({ const wasFirstLoad = !historyLoaded; historyLoaded = true; lastHistorySyncSucceeded = true; + messagesDiv.dataset.historyHydrated = 'true'; + ensureWelcomeMessage(); liveCardBound.settle({ rebuilt: wasFirstLoad || armedAtStart, size: liveCardRecords.size }); modelWaits.retainCards(liveCardRecords); // ANY successful sync leaves the instance hydrated @@ -3963,6 +3990,8 @@ export function createChatInstance({ destroy() { if (destroyed) return; destroyed = true; + welcomeObserver.disconnect(); + window.removeEventListener('ouro:welcome-changed', onWelcomeChanged); cancelHistoryPaint(); for (const dispose of wsDisposers) { try { dispose(); } catch {} diff --git a/web/modules/settings.js b/web/modules/settings.js index 4dc389819..35de1140e 100644 --- a/web/modules/settings.js +++ b/web/modules/settings.js @@ -28,6 +28,7 @@ import { createModelRolesEditor, modelRoleMap } from './model_roles.js'; import { PROCESSING_PREFERENCE_KEY, MODEL_PROCESSING_PREFERENCES_KEY } from './route_editor_primitives.js'; import { collectSafeFieldValues, normalizeTone, renderSafeField, setInlineStatus, revealNewRow } from './ui_helpers.js'; import { extensionActionStatus } from './extension_status_text.js'; +import { bindWelcomePreference } from './welcome_preference.js'; let markSettingsDirty = () => {}; const BASE_SECRET_KEYS = new Set(SECRET_KEYS.map(([key]) => key)); @@ -474,6 +475,7 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) { // Notification preferences are client-local for the same reason; the module // owns delegated handlers, so mounting only paints current state. getNotifier().mountSettings(page); + const disposeWelcomePreference = bindWelcomePreference(page); const disposeLocalModel = bindLocalModelControls({ state, onApplication: (local) => syncRestartState({ ...restartState, local_model: local }) }); // Best-effort About version from /api/health. @@ -1130,12 +1132,10 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) { }); } - // Client-local blocks (appearance, notifications) live on the Appearance - // tab but never enter the /api/settings payload, so their controls must not - // make the server draft dirty — otherwise toggling one would ask the owner - // to discard "unsaved settings" that do not exist. + // These Appearance controls save outside /api/settings (theme and notifications + // per client; greeting per installation). They cannot dirty that server draft. const onServerSettingEdited = (event) => { - if (event?.target?.closest?.('[data-notify-settings]')) return; + if (event?.target?.closest?.('[data-notify-settings], [data-welcome-settings]')) return; onSettingsEdited(); }; page.addEventListener('input', onServerSettingEdited); @@ -1198,6 +1198,7 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) { window.addEventListener('pagehide', (event) => { if (event.persisted) return; disposeSettingsTabs(); + disposeWelcomePreference(); window.removeEventListener('beforeunload', beforeUnload); disposeLocalModel(); disposeRestartReconnect?.(); diff --git a/web/modules/settings_ui.js b/web/modules/settings_ui.js index 8f96da61e..bbabbec9e 100644 --- a/web/modules/settings_ui.js +++ b/web/modules/settings_ui.js @@ -737,6 +737,28 @@ export function renderSettingsPage() {
+
+

Empty chat greeting

+
The sentence shown in an empty Main chat is a local display, not a message sent by Ouroboros. This installation shares it across its clients; it never enters chat history or calls a model.
+
+
+ + +
+
+ + +
+
+
+
Reading welcome preference…
+ +
+

Theme

diff --git a/web/modules/welcome_preference.js b/web/modules/welcome_preference.js new file mode 100644 index 000000000..c335a2033 --- /dev/null +++ b/web/modules/welcome_preference.js @@ -0,0 +1,85 @@ +import { apiClient } from './api_client.js'; + +export const DEFAULT_WELCOME_TEXT = 'Ouroboros has awakened'; + +export function welcomeText(value) { + if (value?.mode === 'hidden') return null; + if (value?.mode === 'custom') return typeof value.text === 'string' && value.text.trim() + ? value.text : null; + return value?.mode === 'default' ? DEFAULT_WELCOME_TEXT : null; +} + +// This control saves a presentation preference independently of /api/settings. +// A failed read never paints the default as though it were the owner's saved choice. +export function bindWelcomePreference(root, client = apiClient) { + const host = root.querySelector('[data-welcome-settings]'); + if (!host) return () => {}; + const mode = host.querySelector('[data-welcome-mode]'); + const text = host.querySelector('[data-welcome-text]'); + const save = host.querySelector('[data-welcome-save]'); + const status = host.querySelector('[data-welcome-status]'); + let alive = true; + let dirty = false; + let busy = false; + let loaded = false; + let editVersion = 0; + const show = (message, tone = 'muted') => { + status.textContent = message; + status.dataset.tone = tone; + }; + const sync = () => { + text.disabled = mode.value !== 'custom'; + save.disabled = busy || !loaded; + }; + const changed = () => { dirty = true; editVersion++; sync(); }; + mode.addEventListener('change', changed); + text.addEventListener('input', changed); + const load = async () => { + try { + const value = (await client.uiPreferences())?.welcome; + if (!alive || dirty) return; + if (!value || !['default', 'hidden', 'custom'].includes(value.mode) + || typeof value.text !== 'string') throw new Error('Welcome preference unavailable'); + loaded = true; + mode.value = value.mode; + text.value = value.text; + show('Saved for this installation.'); + sync(); + } catch (error) { + if (alive) show(`Could not read the welcome preference: ${error.message}`, 'warn'); + } + }; + const onSave = async () => { + if (busy || !loaded) return; + if (mode.value === 'custom' && !text.value.trim()) { + show('Enter a message, or choose Hidden.', 'warn'); + return; + } + busy = true; + sync(); + const submitted = { mode: mode.value, text: text.value }; + const submittedVersion = editVersion; + try { + const result = await client.saveUiPreferences({ welcome: submitted }); + if (!alive) return; + if (!result?.ok || !result?.welcome) throw new Error('Save was not confirmed'); + if (editVersion === submittedVersion) dirty = false; + show(dirty ? 'Saved; newer edits have not been saved.' : 'Welcome preference saved.'); + window.dispatchEvent(new CustomEvent('ouro:welcome-changed', { detail: result.welcome })); + } catch (error) { + if (alive) show(`Welcome preference was not saved: ${error.message}`, 'warn'); + } finally { + busy = false; + if (alive) sync(); + } + }; + save.addEventListener('click', onSave); + sync(); + void load(); + return () => { + alive = false; + mode.removeEventListener('change', changed); + text.removeEventListener('input', changed); + save.removeEventListener('click', onSave); + }; +} diff --git a/web/style.css b/web/style.css index 06d130d48..9be64c134 100644 --- a/web/style.css +++ b/web/style.css @@ -1475,6 +1475,29 @@ body.resizing-panels { user-select: none; } min-width: 0; } +/* Host-owned empty state, never an assistant or historical chat bubble. */ +.chat-empty-welcome { + align-self: flex-start; + max-width: min(80%, 520px); + min-width: 0; + padding: 14px 16px; + border: 1px solid var(--surface-border); + border-radius: var(--radius-lg); + background: var(--bg-hover); + overflow-wrap: anywhere; +} +.chat-empty-welcome-label { + color: var(--text-secondary); + font-size: var(--type-meta); + font-weight: 600; +} +.chat-empty-welcome p { + margin: 6px 0 0; + color: var(--text-primary); + font-size: var(--type-section); + white-space: pre-wrap; +} + .chat-bubble.user { background: linear-gradient(135deg, var(--user-bubble-from), var(--user-bubble-to)); border: 1px solid var(--user-border); diff --git a/web/tests/welcome_preference.test.js b/web/tests/welcome_preference.test.js new file mode 100644 index 000000000..ad9fb90be --- /dev/null +++ b/web/tests/welcome_preference.test.js @@ -0,0 +1,75 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { bindWelcomePreference, DEFAULT_WELCOME_TEXT, welcomeText } from '../modules/welcome_preference.js'; + +test('welcome mode resolves exactly without treating custom text as markup', () => { + assert.equal(welcomeText({ mode: 'default', text: 'ignored' }), DEFAULT_WELCOME_TEXT); + assert.equal(welcomeText({ mode: 'hidden', text: 'saved' }), null); + assert.equal(welcomeText({ mode: 'custom', text: 'Привет\nhello' }), 'Привет\nhello'); + assert.equal(welcomeText({ mode: 'custom', text: ' ' }), null); + assert.equal(welcomeText(null), null); +}); + +function field() { + const listeners = new Map(); + return { + value: '', disabled: false, dataset: {}, textContent: '', + addEventListener(type, callback) { listeners.set(type, callback); }, + removeEventListener(type) { listeners.delete(type); }, + fire(type) { listeners.get(type)?.(); }, + get listeners() { return listeners; }, + }; +} + +function fixture(client) { + const mode = field(), text = field(), save = field(), status = field(); + const nodes = { '[data-welcome-mode]': mode, '[data-welcome-text]': text, + '[data-welcome-save]': save, '[data-welcome-status]': status }; + const root = { querySelector: () => ({ querySelector: (key) => nodes[key] }) }; + const events = []; + const previous = globalThis.window, previousEvent = globalThis.CustomEvent; + globalThis.window = { dispatchEvent: (event) => events.push(event) }; + globalThis.CustomEvent = class { constructor(type, options) { this.type = type; this.detail = options.detail; } }; + const dispose = bindWelcomePreference(root, client); + return { mode, text, save, status, events, dispose: () => { + dispose(); globalThis.window = previous; globalThis.CustomEvent = previousEvent; + } }; +} + +const tick = () => new Promise((resolve) => setImmediate(resolve)); + +test('settings reads and saves separately, and does not save blank custom copy', async () => { + const calls = []; + const f = fixture({ + uiPreferences: async () => ({ welcome: { mode: 'default', text: '' } }), + saveUiPreferences: async (value) => { calls.push(value); return { ok: true, welcome: value.welcome }; }, + }); + try { + await tick(); + assert.equal(f.mode.value, 'default'); + assert.equal(f.text.disabled, true); + f.mode.value = 'custom'; f.mode.fire('change'); + f.text.value = ' '; f.text.fire('input'); f.save.fire('click'); + assert.equal(calls.length, 0); + f.text.value = 'Привет '; f.text.fire('input'); f.save.fire('click'); + await tick(); + assert.deepEqual(calls, [{ welcome: { mode: 'custom', text: 'Привет ' } }]); + assert.equal(f.events[0].type, 'ouro:welcome-changed'); + assert.equal(f.events[0].detail.text, calls[0].welcome.text); + f.mode.value = 'hidden'; f.mode.fire('change'); f.save.fire('click'); await tick(); + assert.equal(calls[1].welcome.mode, 'hidden'); + f.mode.value = 'default'; f.mode.fire('change'); f.save.fire('click'); await tick(); + assert.equal(calls[2].welcome.mode, 'default'); + } finally { f.dispose(); } +}); + +test('a failed preference read cannot authorize overwriting it and disposes handlers', async () => { + const f = fixture({ uiPreferences: async () => { throw new Error('offline'); }, + saveUiPreferences: () => { throw new Error('should not save'); } }); + try { + await tick(); + assert.equal(f.save.disabled, true); + assert.match(f.status.textContent, /offline/); + } finally { f.dispose(); } + assert.equal(f.save.listeners.size, 0); +}); From 860a3e7f6cb0acfee432aac13d8512bcd18f3b9e Mon Sep 17 00:00:00 2001 From: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:17:25 +0300 Subject: [PATCH 06/44] fix(#1374): reconcile terminal costs without re-reading unchanged results Move the terminal cost reconciliation duty behind the server_maintenance facade into terminal_cost_reconciliation.py. Collect task/root ledger ids without result reads, keep recovery (live/post-work/review/retry exclusions, expected_seq, persist, ACK, no-resend) independent, run one post-recovery breakdown, and memoize only a positive, stable EQUAL projection keyed by resolved root + owner with a five-field pre/post stamp, result-derived lineage scopes, detached relevant buckets and a rechecked authority binding. A hit skips only the owner body/ownership/projection; DIFFERS re-checks exact ownership before the existing refresh; foreign roots keep the eligibility-before-accounting path. No new store, scheduler, cadence or TTL; unknown stays unknown. Version-neutral contribution; release carriers untouched. Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com> --- docs/DOMAIN_MAP.md | 5 +- .../01-high-level-architecture.md | 1 + docs/architecture/06-agent-core.md | 2 +- docs/inventories/DATA_LAYOUT_INVENTORY.md | 2 +- ouroboros/domains.toml | 1 + ouroboros/server_maintenance.py | 125 +---- ouroboros/terminal_cost_reconciliation.py | 223 +++++++++ supervisor/events_task_done.py | 83 +++- tests/test_gateway_abi3_removals.py | 2 +- tests/test_reference_book_budgets.py | 4 +- tests/test_terminal_cost_reconciliation.py | 462 ++++++++++++++++++ tests/test_usage_abandoned_reconciliation.py | 11 +- 12 files changed, 770 insertions(+), 151 deletions(-) create mode 100644 ouroboros/terminal_cost_reconciliation.py create mode 100644 tests/test_terminal_cost_reconciliation.py diff --git a/docs/DOMAIN_MAP.md b/docs/DOMAIN_MAP.md index bccf9fc13..5694fc06f 100644 --- a/docs/DOMAIN_MAP.md +++ b/docs/DOMAIN_MAP.md @@ -18,7 +18,7 @@ The manifest is the SSOT of the module→domain assignment (1:1, complete over t | D08 | Supervisor: queue, workers, events & runtime control | 50 | 0 | | D09 | Cancellation, owner control & process custody | 14 | 0 | | D10 | Git, update & release machinery | 29 | 0 | -| D11 | Gateway, server & Web UI | 58 | 0 | +| D11 | Gateway, server & Web UI | 59 | 0 | | D12 | Settings & configuration | 15 | 0 | | D13 | Safety, guards & runtime mode | 9 | 0 | | D14 | Skills & extensions | 56 | 0 | @@ -28,7 +28,7 @@ The manifest is the SSOT of the module→domain assignment (1:1, complete over t | D18 | Launcher, packaging, platform & shared substrate | 15 | 0 | | D19 | Frozen contracts (ABI) | 10 | 0 | | D20 | Presence | 10 | 0 | -| **total** | | **597** | **0** | +| **total** | | **598** | **0** | ## Dependency direction matrix (strict, pinned) @@ -635,6 +635,7 @@ No function body (≥ 10 normalized lines) is shared verbatim across domains. Ne - `ouroboros/server_runtime.py` - `ouroboros/server_web.py` - `ouroboros/startup_historical_audit.py` +- `ouroboros/terminal_cost_reconciliation.py` - `ouroboros/upgrade_notices.py` - `server.py` diff --git a/docs/architecture/01-high-level-architecture.md b/docs/architecture/01-high-level-architecture.md index 81e6d7bd8..e858c40de 100644 --- a/docs/architecture/01-high-level-architecture.md +++ b/docs/architecture/01-high-level-architecture.md @@ -346,6 +346,7 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de ├── server_runtime.py ← Startup/onboarding wiring + WS liveness ├── server_web.py ← `NoCacheStaticFiles`, web-dir resolver and fixed-source `read_author_kit_assets(repo_dir)` for optional author-owned routes; no endpoint or cache ├── server_process.py, server_liveness.py, server_maintenance.py, server_restart.py, server_owner_routing.py, server_routing_context.py ← Server leaves the composition root calls: the facts one server process shares with every leaf, including applied startup settings; wedge detection for the supervisor generation; the upkeep a generation owes the drive; restart operations (shutdown, the checkout-first manual Restart, the planned restart's engine-pin daemon stop; §9); where one owner message goes; the bounded facts one owner turn may address + ├── terminal_cost_reconciliation.py ← Maintenance usage recovery, then cost projection behind a no-write EQUAL memo (§6) ├── task_continuation.py ← Durable review continuation state ├── task_results.py ← Durable task results `task_results/.json`; the locked `task_acceptance_review_accounting` claim (minted at first physical reviewer dispatch; a claim without a recoverable terminal host run is UNKNOWN, never permission to re-dispatch); the read-only root review-capacity projection is WALLET and cancellation only (`root_task_id`, `cap_cycles`, `claimed_cycles`, `remaining_cycles`, `binding_seen`, `dedupe`, `state`, `reason`), no time axis (§6 Task acceptance) ├── task_result_schema.py ← Task-result schema admission: the `_schema_version` stamp, the classifier, and the quarantine an unstamped, future, malformed or retired-key row lands in diff --git a/docs/architecture/06-agent-core.md b/docs/architecture/06-agent-core.md index fec54365f..d69eec518 100644 --- a/docs/architecture/06-agent-core.md +++ b/docs/architecture/06-agent-core.md @@ -685,7 +685,7 @@ The time/cost/intrinsic pacing checkpoints carry `resource_facts`: incremental p A proven abandoned send closes administratively as `settled` with `settle_reason="abandoned"`, `cost_usd=None` and `cost_final=false`: its reservation bound stays accounted, not confirmed spend, and the price remains non-final. A real late receipt may correct the same attempt once (`late_receipt`); a positive never-started receipt may instead release it through the existing `before_dispatch_failed:` seam. Ordinary terminal settlements and releases remain immutable. Full and incremental ledger validation preserve the same correction eligibility; compaction retains unresolved and abandoned chains for that exact-id join, while existing historical baseline groups remain aggregates (the full storage contract is [Usage-ledger compaction](../USAGE_COMPACTION.md)). -After cancellation custody, existing maintenance calls `server_maintenance._reconcile_abandoned_usage` for settled tasks with no live physical owner/open post-task synthesis; review attempts retain their review owner. `llm_claudexor.recover_model_attempt` reads retained terminal CAS or the exact operation via owned-only discovery, retains bytes before ACK and never starts/restarts/cancels bookkeeping work. Missing identity, live work or unreadable evidence defers. The money lock rechecks each row so real concurrent receipts win; no age cutoff, timer or ledger is added. Projection reconciliation includes compacted-baseline attribution, retaining retries after failed result writes/native late receipts. One bulk breakdown feeds task/root owners; foreign canonical budget roots stay separate. `events_task_done._refresh_terminal_task_cost` changes money fields only, without completion events or invented synthesis. +`server_maintenance._reconcile_abandoned_usage` delegates to `terminal_cost_reconciliation`. Recovery filters open/remote-abandoned rows before exact ownership/post-task checks; retained CAS precedes owned-only discovery/ACK, writer race checks stay; never resend. Every non-review task/root owner, compacted too, owes projection. One post-recovery breakdown feeds a canonical projector probe. Only stable EQUAL, final-known costs memoize file `(dev, ino, size, mtime_ns, ctime_ns)`, lineage scopes, detached buckets and rechecked budget-path binding. Hits skip projection, not recovery. DIFFERS rechecks exact ownership before the writer rereads/recomputes; foreign roots keep eligibility before accounting/import. Writes need later EQUAL confirmation; failures never memoize. No store/timer or mutation authority is added. `_refresh_terminal_task_cost` writes money only, not completion or synthesis. Summary/reflection/consolidation share a frozen ledger snapshot: settled subtree cost, reservations, unresolved bounds/counts, unknown exposure, integrity and non-final state. Final terminal checkpoint alone owns final cost; read failure is unavailable/null, never zero. No reconciliation LLM or parallel ledger exists. Cache-hit health uses only rounds carrying `cached_tokens`, and stays absent with insufficient samples: unmeasured is unknown, not zero, so providers lacking telemetry cannot dilute measured ratios. Unknown spend remains the existing `unknown_unmetered` field, not a third accounting status. diff --git a/docs/inventories/DATA_LAYOUT_INVENTORY.md b/docs/inventories/DATA_LAYOUT_INVENTORY.md index c3ed8d7d3..62258634d 100644 --- a/docs/inventories/DATA_LAYOUT_INVENTORY.md +++ b/docs/inventories/DATA_LAYOUT_INVENTORY.md @@ -2,7 +2,7 @@ Machine extraction of the `docs/ARCHITECTURE.md` "Data layout (`~/Ouroboros/`)" tree — the durable-file orientation carrier (this tree's counterpart of the reference PERSISTENCE_OWNERS derivation checklist) — regenerated by `python scripts/regenerate_inventories.py`. Do not edit. Every entry is probed against reality: repo entries must exist as tracked paths; data-plane entries must appear as a literal in the runtime sources that construct them. A durable file renamed or removed in code while its tree row survives = red (`tests/test_generated_inventories.py`). -Source: `docs/architecture/01-high-level-architecture.md`, physical LF lines 617-709; UTF-8 SHA-256 `02c0531d3142158974a70220ecc38f256871b6db318697d02c5098ab5c3af722`. +Source: `docs/architecture/01-high-level-architecture.md`, physical LF lines 618-710; UTF-8 SHA-256 `016eda211fc0bd52bf17e89a825748d7feef99d4ca7ae247fe8855a5587a10d8`. - entries: **82** (code-ref: 75, repo-dir: 6, repo-path: 1) diff --git a/ouroboros/domains.toml b/ouroboros/domains.toml index 821941d68..428e9d2b0 100644 --- a/ouroboros/domains.toml +++ b/ouroboros/domains.toml @@ -579,6 +579,7 @@ D20 = "Presence" "server.py" = "D11" "ouroboros/server_liveness.py" = "D11" "ouroboros/server_maintenance.py" = "D11" +"ouroboros/terminal_cost_reconciliation.py" = "D11" "ouroboros/server_owner_routing.py" = "D11" "ouroboros/server_process.py" = "D11" "ouroboros/server_restart.py" = "D11" diff --git a/ouroboros/server_maintenance.py b/ouroboros/server_maintenance.py index 480366599..2696e466a 100644 --- a/ouroboros/server_maintenance.py +++ b/ouroboros/server_maintenance.py @@ -6,16 +6,16 @@ thread — and the delegated-snapshot GC that fails closed on an unreadable log. from __future__ import annotations -import pathlib import json import logging import os +import pathlib import threading import time from contextlib import contextmanager from typing import Any, Dict -from ouroboros.server_process import DATA_DIR, log, _restart_requested, _supervisor_stop +from ouroboros.server_process import DATA_DIR, _restart_requested, _supervisor_stop, log from ouroboros.utils import utc_now_iso @@ -95,119 +95,10 @@ def _run_cancel_delivery_ref_sweep(drive_root: pathlib.Path) -> None: def _reconcile_abandoned_usage(drive_root: pathlib.Path) -> None: - """Close unowned terminal-task attempts; price and remote custody stay separate.""" - from ouroboros import usage_accounting as usage - from ouroboros.claudexor_daemon import read_owned_gateway - from ouroboros.gateways.claudexor import ClaudexorUnavailable - from ouroboros.llm_claudexor import recover_model_attempt - from ouroboros.post_task_checkpoint import post_task_synthesis_is_open - from ouroboros.task_results import load_task_result - from ouroboros.task_status import SETTLED_STATUSES - from ouroboros.transport_custody import ProviderNotDispatched, release_pre_dispatch_attempt - from ouroboros.usage_ledger import is_abandoned_settlement - from supervisor.events_task_done import _refresh_terminal_task_cost - from supervisor.queue import task_has_live_ownership + """Compatibility seam for the existing terminal-maintenance duty.""" + from ouroboros.terminal_cost_reconciliation import reconcile_abandoned_usage - root = pathlib.Path(drive_root) - rows = usage.read_usage_records(root, final_only=True) - tasks, refresh = {}, set() - gateway, gateway_unavailable = None, False - - def eligible_task(task_id): - if not task_id: - return False - if task_id not in tasks: - try: - task = load_task_result(root, task_id, strict=True) or {} - checkpoint = task.get("root_phase_checkpoint") or {} - tasks[task_id] = task if ( - task.get("status") in SETTLED_STATUSES - and not task_has_live_ownership(task_id) - and not post_task_synthesis_is_open(checkpoint.get("post_task_synthesis")) - ) else None - except Exception: - tasks[task_id] = None # Unreadable ownership permits neither duty. - return tasks[task_id] is not None - - def borrowed_gateway(): - nonlocal gateway, gateway_unavailable - if gateway_unavailable: - raise ClaudexorUnavailable("daemon_unreachable", "Usage recovery deferred until the next maintenance pass") - if gateway is None: - try: - gateway = read_owned_gateway() - except Exception: - gateway_unavailable = True - raise - return gateway - - try: - for row in rows: - kind = row.get("kind", "attempt") - if kind not in {"attempt", "usage_baseline_group"} or any(row.get(key) for key in usage.REVIEW_ATTRIBUTION_KEYS): - continue - task_id = str(row.get("task_id") or "") - # Settled/compacted attribution still owes projection after a failed write. - refresh.update(owner for owner in (task_id, str(row.get("root_task_id") or "")) if eligible_task(owner)) - if not eligible_task(task_id): - continue - remote = row.get("provider") == "claudexor" - abandoned = is_abandoned_settlement(row) - if (kind != "attempt" - or (row.get("state") not in {"reserved", "dispatched", "unresolved"} - and not (remote and abandoned))): - continue - reservation = usage.AttemptReservation( - str(row["attempt_id"]), root, str(row.get("model") or ""), - str(row.get("provider") or ""), row.get("reservation_upper_bound_usd"), - str(row.get("processing_preference") or ""), str(row.get("submitted_processing_mode") or ""), - row.get("processing_basis"), - ) - try: - recovered = None - if remote and row.get("state") != "reserved": - recovered = recover_model_attempt(root, row, gateway_factory=borrowed_gateway) - if recovered is None: - continue - disposition, reported, cost, final = recovered or ("abandoned", {}, None, False) - if disposition == "settled": - usage.settle_attempt(reservation, reported, cost_usd=cost, cost_final=final) - elif disposition == "released": - if not release_pre_dispatch_attempt(reservation, ProviderNotDispatched("recovered model operation never started")): - continue - elif disposition == "abandoned": - if abandoned: - continue - state = usage.terminalize_abandoned_attempt(reservation, reason="owner_task_terminal", expected_seq=row.get("seq")) - if state not in {"settled", "released"}: - continue - else: - continue - except ClaudexorUnavailable as exc: - gateway_unavailable = gateway_unavailable or exc.code == "daemon_unreachable" - log.debug("Model usage custody deferred for %s: %s", row["attempt_id"], exc.code) - except Exception: - log.warning("Usage reconciliation deferred for %s", row["attempt_id"], exc_info=True) - finally: - if gateway is not None: - try: - gateway.close() - except Exception: - log.debug("Usage recovery gateway close failed", exc_info=True) - if not refresh: - return - try: - usage.ensure_legacy_imported(root) - # One post-transition indexed view avoids per-owner scans; failure retries next pass. - breakdown = usage.usage_breakdown(root) - except Exception: - log.warning("Reconciled usage projection unavailable", exc_info=True) - return - for task_id in sorted(refresh): - try: - _refresh_terminal_task_cost(root, task_id, breakdown=breakdown) - except Exception: - log.warning("Reconciled task cost refresh failed for %s", task_id, exc_info=True) + reconcile_abandoned_usage(drive_root) # Memory only: consecutive failures per periodic step, so a failure that recurs every cadence @@ -1014,12 +905,14 @@ def _startup_worker_pids(drive_root: pathlib.Path) -> set[int] | None: def _recover_terminal_task_files(drive_root: pathlib.Path, protected: set[str]) -> dict: """Recover only known child directories, never infer a new model execution.""" + from ouroboros.cancel_intents import cancel_pending from ouroboros.headless import ( - HEADLESS_TASKS_DIR, TASK_DRIVES_DIR, prepare_terminal_task_files, + HEADLESS_TASKS_DIR, + TASK_DRIVES_DIR, + prepare_terminal_task_files, terminal_task_files_ready, ) from ouroboros.observability import _has_pending_ref_promotion - from ouroboros.cancel_intents import cancel_pending from ouroboros.task_results import load_task_result, validate_task_id, write_task_result from ouroboros.task_status import SETTLED_STATUSES, effective_task_result diff --git a/ouroboros/terminal_cost_reconciliation.py b/ouroboros/terminal_cost_reconciliation.py new file mode 100644 index 000000000..ae27e2d8e --- /dev/null +++ b/ouroboros/terminal_cost_reconciliation.py @@ -0,0 +1,223 @@ +"""Terminal maintenance's cost duty: recovery first, then projection selection. + +The equality memo is ONLY proof that the same observed projection needs no +write. It never authorizes recovery, declares ownership dead, or settles money. +No bodies, negative results, write outcomes, timers or durable state are cached. +""" +from __future__ import annotations + +import logging +import pathlib +import stat +from copy import deepcopy +from dataclasses import dataclass + +log = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class _EqualProjection: + stamp: tuple + scopes: tuple[bool, str] + authority_path: str + buckets: tuple + + +# The existing maintenance latch serializes production passes. Entries are +# disposable; restart or eviction only repeats an ordinary comparison. +_EQUAL_PROJECTIONS: dict[tuple[str, str], _EqualProjection] = {} + + +def _result_stamp(path: pathlib.Path) -> tuple: + info = path.stat() + if not stat.S_ISREG(info.st_mode): + raise ValueError("Cost projection result is not a regular file") + return info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns, info.st_ctime_ns + + +def _buckets(breakdown: dict, task_id: str, scopes: tuple[bool, str]) -> tuple: + """Full relevant buckets, including absence, never the global ledger sequence. + + Only inserts deepcopy this basis: a later append can mutate a caller's view + without retrospectively changing the equality we actually observed. + """ + own = breakdown["by_task"] + roots = breakdown["by_root"] + is_root, root_id = scopes + return (bool(breakdown.get("integrity_degraded")), + (task_id in own, own.get(task_id)), + (root_id in roots, roots.get(root_id)) if is_root else None) + + +def _refresh_costs(root: pathlib.Path, owners: set[str], recovery_tasks: dict) -> None: + from ouroboros import usage_accounting as usage + from ouroboros.task_results import load_task_result, task_result_path + from supervisor.events_task_done import _refresh_terminal_task_cost, _terminal_cost_probe + from supervisor.queue import task_has_live_ownership + + root_key = str(root) + for key in list(_EQUAL_PROJECTIONS): + if key[0] == root_key and key[1] not in owners: + del _EQUAL_PROJECTIONS[key] + if not owners: + return + try: + usage.ensure_legacy_imported(root) + # One post-recovery view; a failure never turns into per-owner fallback. + breakdown = usage.usage_breakdown(root) + except Exception: + for key in list(_EQUAL_PROJECTIONS): + if key[0] == root_key: + del _EQUAL_PROJECTIONS[key] + log.warning("Reconciled usage projection unavailable", exc_info=True) + return + for task_id in sorted(owners): + key = root_key, task_id + try: + # Reuse only a conservative recovery exclusion, NEVER its permission + # to mutate: a direct/review/retry owner can appear later in the pass. + if task_id in recovery_tasks and recovery_tasks[task_id] is None: + _EQUAL_PROJECTIONS.pop(key, None) + continue + path = task_result_path(root, task_id, create=False) + before = _result_stamp(path) + cached = _EQUAL_PROJECTIONS.get(key) + if (cached is not None and cached.stamp == before + and pathlib.Path(cached.authority_path).resolve() == root + and cached.buckets == _buckets(breakdown, task_id, cached.scopes)): + continue + _EQUAL_PROJECTIONS.pop(key, None) + current = load_task_result(root, task_id, strict=True) or {} + if _result_stamp(path) != before: + continue # a concurrent writer supplies next pass's inputs + outcome, fields, scopes = _terminal_cost_probe( + root, task_id, current, breakdown=breakdown, canonical_only=True) + if outcome == "equal": + authority_path = str(current.get("budget_drive_root") or root) + # Unknown/non-final prices are still compared each pass. Neither + # equality nor a False refresh response confirms their finality. + if (fields.get("cost_final") is True + and fields.get("accounted_upper_bound_usd") is not None + and not fields.get("unknown_unmetered") + and not fields.get("non_final_rows") + and not breakdown.get("integrity_degraded") + and (not scopes[0] or fields.get("accounted_upper_bound_usd_with_children") is not None) + and pathlib.Path(authority_path).resolve() == root + and _result_stamp(path) == before): + _EQUAL_PROJECTIONS[key] = _EqualProjection( + before, scopes, authority_path, deepcopy(_buckets(breakdown, task_id, scopes))) + continue + if outcome not in {"differs", "foreign"}: + continue + # Foreign authority goes through this guard BEFORE accounting/import. + # The writer rereads/recomputes; never carry early fields into a write. + if not task_has_live_ownership(task_id): + _refresh_terminal_task_cost(root, task_id, breakdown=breakdown) + # Even a successful write needs a subsequent stable EQUAL read. + except FileNotFoundError: + _EQUAL_PROJECTIONS.pop(key, None) # absence is not a lasting exclusion + except Exception: + _EQUAL_PROJECTIONS.pop(key, None) + log.warning("Reconciled task cost refresh failed for %s", task_id, exc_info=True) + + +def reconcile_abandoned_usage(drive_root: pathlib.Path) -> None: + """Close unowned terminal-task attempts; price and remote custody stay separate.""" + from ouroboros import usage_accounting as usage + from ouroboros.claudexor_daemon import read_owned_gateway + from ouroboros.gateways.claudexor import ClaudexorUnavailable + from ouroboros.llm_claudexor import recover_model_attempt + from ouroboros.post_task_checkpoint import post_task_synthesis_is_open + from ouroboros.task_results import load_task_result + from ouroboros.task_status import SETTLED_STATUSES + from ouroboros.transport_custody import ProviderNotDispatched, release_pre_dispatch_attempt + from ouroboros.usage_ledger import is_abandoned_settlement + from supervisor.queue import task_has_live_ownership + + root = pathlib.Path(drive_root).resolve() + rows = usage.read_usage_records(root, final_only=True) + tasks, refresh = {}, set() + gateway, gateway_unavailable = None, False + + def eligible_task(task_id): + if not task_id: + return False + if task_id not in tasks: + try: + task = load_task_result(root, task_id, strict=True) or {} + checkpoint = task.get("root_phase_checkpoint") or {} + tasks[task_id] = task if ( + task.get("status") in SETTLED_STATUSES + and not task_has_live_ownership(task_id) + and not post_task_synthesis_is_open(checkpoint.get("post_task_synthesis")) + ) else None + except Exception: + tasks[task_id] = None # Unreadable ownership permits neither duty. + return tasks[task_id] is not None + + def borrowed_gateway(): + nonlocal gateway, gateway_unavailable + if gateway_unavailable: + raise ClaudexorUnavailable("daemon_unreachable", "Usage recovery deferred until the next maintenance pass") + if gateway is None: + try: + gateway = read_owned_gateway() + except Exception: + gateway_unavailable = True + raise + return gateway + + try: + for row in rows: + kind = row.get("kind", "attempt") + if kind not in {"attempt", "usage_baseline_group"} or any(row.get(key) for key in usage.REVIEW_ATTRIBUTION_KEYS): + continue + task_id = str(row.get("task_id") or "") + # Settled/compacted attribution still owes projection after a failed write. + refresh.update(owner for owner in (task_id, str(row.get("root_task_id") or "")) if owner) + remote = row.get("provider") == "claudexor" + abandoned = is_abandoned_settlement(row) + if (kind != "attempt" + or (row.get("state") not in {"reserved", "dispatched", "unresolved"} + and not (remote and abandoned))): + continue + if not eligible_task(task_id): + continue + reservation = usage.AttemptReservation( + str(row["attempt_id"]), root, str(row.get("model") or ""), + str(row.get("provider") or ""), row.get("reservation_upper_bound_usd"), + str(row.get("processing_preference") or ""), str(row.get("submitted_processing_mode") or ""), + row.get("processing_basis"), + ) + try: + recovered = None + if remote and row.get("state") != "reserved": + recovered = recover_model_attempt(root, row, gateway_factory=borrowed_gateway) + if recovered is None: + continue + disposition, reported, cost, final = recovered or ("abandoned", {}, None, False) + if disposition == "settled": + usage.settle_attempt(reservation, reported, cost_usd=cost, cost_final=final) + elif disposition == "released": + if not release_pre_dispatch_attempt(reservation, ProviderNotDispatched("recovered model operation never started")): + continue + elif disposition == "abandoned": + if abandoned: + continue + state = usage.terminalize_abandoned_attempt(reservation, reason="owner_task_terminal", expected_seq=row.get("seq")) + if state not in {"settled", "released"}: + continue + else: + continue + except ClaudexorUnavailable as exc: + gateway_unavailable = gateway_unavailable or exc.code == "daemon_unreachable" + log.debug("Model usage custody deferred for %s: %s", row["attempt_id"], exc.code) + except Exception: + log.warning("Usage reconciliation deferred for %s", row["attempt_id"], exc_info=True) + finally: + if gateway is not None: + try: + gateway.close() + except Exception: + log.debug("Usage recovery gateway close failed", exc_info=True) + _refresh_costs(root, refresh, tasks) diff --git a/supervisor/events_task_done.py b/supervisor/events_task_done.py index f185caa89..ff6c541b4 100644 --- a/supervisor/events_task_done.py +++ b/supervisor/events_task_done.py @@ -11,6 +11,7 @@ import logging import pathlib from typing import Any, Dict +from ouroboros.contracts.chat_id_policy import HIDDEN_CHAT_ID from ouroboros.cost_projection import carry_cost_meta, with_cost_aliases from ouroboros.outcomes import ( EXECUTION_DEGRADED, @@ -30,7 +31,6 @@ from ouroboros.task_results import ( write_task_result, ) from ouroboros.utils import append_jsonl, truncate_for_log, utc_now_iso -from ouroboros.contracts.chat_id_policy import HIDDEN_CHAT_ID from supervisor.message_bus import notification_chat_route, row_chat_identity @@ -116,27 +116,14 @@ def _completed_lifecycle_display( return None -def _authoritative_terminal_cost( - task_id: str, task: Dict[str, Any], result: Dict[str, Any], evt: Dict[str, Any], drive_root: pathlib.Path, - *, breakdown: Dict[str, Any] | None = None, -) -> Dict[str, Any]: - """Project terminal cost; the optional breakdown belongs to drive_root only.""" - from ouroboros.cost_projection import ( - COST_SCOPE_ROOT_TREE, build_cost_presentation, honest_accounted_amount, - ) - from supervisor.state import reconstruct_task_cost - - authority_root = pathlib.Path(task.get("budget_drive_root") or drive_root) - if breakdown is not None and authority_root.resolve() != pathlib.Path(drive_root).resolve(): - breakdown = None # A split/copyback task keeps its canonical monetary authority. - projection = reconstruct_task_cost(task_id, fields=True, drive_root=authority_root, - **({"breakdown": breakdown} if breakdown is not None else {})) +def _terminal_cost_lineage(task_id: str, task: dict, result: dict, evt: dict) -> dict: + """The result-derived scopes used by both projection and its equality memo.""" from ouroboros.task_results import resolve_task_lineage metadata = task.get("metadata") if isinstance(task.get("metadata"), dict) else {} root_id = str(result.get("root_task_id") or task.get("root_task_id") or evt.get("root_task_id") or "") parent_id = str(result.get("parent_task_id") or task.get("parent_task_id") or evt.get("parent_task_id") or "") - lineage = resolve_task_lineage( + return resolve_task_lineage( task_id, metadata=metadata, root_task_id=root_id, @@ -157,6 +144,30 @@ def _authoritative_terminal_cost( or evt.get("timeout_retry_from") ), ) + + +def _authoritative_terminal_cost( + task_id: str, task: Dict[str, Any], result: Dict[str, Any], evt: Dict[str, Any], drive_root: pathlib.Path, + *, breakdown: Dict[str, Any] | None = None, canonical_only: bool = False, +) -> Dict[str, Any]: + """Project terminal cost; canonical_only forbids import/read fallback from a probe.""" + from ouroboros.cost_projection import ( + COST_SCOPE_ROOT_TREE, + build_cost_presentation, + honest_accounted_amount, + ) + from supervisor.state import reconstruct_task_cost + + authority_root = pathlib.Path(task.get("budget_drive_root") or drive_root) + if (breakdown is not None or canonical_only) and authority_root.resolve() != pathlib.Path(drive_root).resolve(): + if canonical_only: + raise ValueError("Cost probe lost canonical monetary authority") + breakdown = None # A split/copyback task keeps its canonical monetary authority. + if canonical_only and breakdown is None: + raise ValueError("Cost probe requires a prefetched canonical breakdown") + projection = reconstruct_task_cost(task_id, fields=True, drive_root=authority_root, + **({"breakdown": breakdown} if breakdown is not None else {})) + lineage = _terminal_cost_lineage(task_id, task, result, evt) is_root = bool(lineage["is_root_task"]) if is_root and projection.get("cost_accounting_status") == "available": try: @@ -234,6 +245,33 @@ def _authoritative_terminal_cost( return with_cost_aliases(projection) +def _terminal_cost_probe( + drive_root: pathlib.Path, task_id: str, current: dict, *, + breakdown: Dict[str, Any] | None = None, canonical_only: bool = False, +) -> tuple[str, dict, tuple[bool, str] | None]: + """Classify the ordinary refresh's inputs, not permission to write. + + Maintenance uses canonical_only with its indexed snapshot: foreign authority + is reported BEFORE accounting, which can import legacy rows on fallback. + """ + from ouroboros.task_status import SETTLED_STATUSES + + if current.get("status") not in SETTLED_STATUSES: + return "ineligible", {}, None + checkpoint = current.get("root_phase_checkpoint") or {} + if post_task_synthesis_is_open(checkpoint.get("post_task_synthesis")): + return "ineligible", {}, None + if canonical_only and pathlib.Path(current.get("budget_drive_root") or drive_root).resolve() != drive_root.resolve(): + return "foreign", {}, None + fields = _authoritative_terminal_cost( + task_id, current, current, {}, drive_root, breakdown=breakdown, canonical_only=canonical_only) + lineage = _terminal_cost_lineage(task_id, current, current, {}) + scopes = bool(lineage["is_root_task"]), str(lineage["root_task_id"]) + if fields.get("cost_accounting_status") != "available": + return "unavailable", fields, scopes + return ("equal" if all(current.get(key) == value for key, value in fields.items()) else "differs"), fields, scopes + + def _refresh_terminal_task_cost( drive_root: pathlib.Path, task_id: str, *, breakdown: Dict[str, Any] | None = None, ) -> bool: @@ -241,13 +279,8 @@ def _refresh_terminal_task_cost( from ouroboros.task_status import SETTLED_STATUSES current = load_task_result(drive_root, task_id, strict=True) or {} - if current.get("status") not in SETTLED_STATUSES: - return False - checkpoint = current.get("root_phase_checkpoint") or {} - if post_task_synthesis_is_open(checkpoint.get("post_task_synthesis")): - return False - fields = _authoritative_terminal_cost(task_id, current, current, {}, drive_root, breakdown=breakdown) - if fields.get("cost_accounting_status") != "available" or all(current.get(key) == value for key, value in fields.items()): + outcome, fields, _ = _terminal_cost_probe(pathlib.Path(drive_root), task_id, current, breakdown=breakdown) + if outcome != "differs": return False def project(latest, patch): @@ -263,8 +296,8 @@ def _refresh_terminal_task_cost( event = {"type": "task_cost_finalized", "ts": utc_now_iso(), "task_id": task_id, "root_task_id": str(stored.get("root_task_id") or task_id), **carry_cost_meta(stored)} if append_jsonl(drive_root / "logs" / "events.jsonl", event): - from supervisor.message_bus import try_get_bridge from supervisor.log_addressing import address_handler_push + from supervisor.message_bus import try_get_bridge bridge = try_get_bridge() if bridge is not None: diff --git a/tests/test_gateway_abi3_removals.py b/tests/test_gateway_abi3_removals.py index 021db31cd..404bcbd4d 100644 --- a/tests/test_gateway_abi3_removals.py +++ b/tests/test_gateway_abi3_removals.py @@ -187,7 +187,7 @@ class TestAliasProducerFanOutSweep: ("ouroboros/usage_accounting.py", "cost_usd", "record_subscription_session"): ("ledger subscription session row", 1), ("ouroboros/usage_accounting.py", "cost_usd", "terminalize_abandoned_attempt"): ("ledger settlement transition", 1), ("ouroboros/usage_accounting.py", "cost_usd", "_settlement_fields"): ("ledger settlement fields shared by received and abandoned attempts", 1), - ("ouroboros/server_maintenance.py", "cost_usd", "_reconcile_abandoned_usage"): ("existing ledger settlement call for the original recovered model operation", 1), + ("ouroboros/terminal_cost_reconciliation.py", "cost_usd", "reconcile_abandoned_usage"): ("existing ledger settlement call for the original recovered model operation", 1), ("ouroboros/usage_accounting.py", "cost_usd", "_terminalize_failed_attempt"): ("ledger settlement transitions + settle call for a rejected stream whose usage frame was read", 3), ("ouroboros/usage_accounting.py", "cost_usd", "_account_response"): ("ledger settlement call shared by sync and async received responses", 1), ("ouroboros/usage_legacy_import.py", "cost_usd", "_ensure_legacy_imported_locked"): ("legacy usage.json ledger import rows", 2), diff --git a/tests/test_reference_book_budgets.py b/tests/test_reference_book_budgets.py index 6012a8f4e..8b854ed10 100644 --- a/tests/test_reference_book_budgets.py +++ b/tests/test_reference_book_budgets.py @@ -47,7 +47,9 @@ CHAPTER_BYTE_BUDGETS: dict[str, int] = { # 166450 -> 167000 (merge of the moved target into the plan-review branch, measured 166885): both # sides' replaced paragraphs land together; no text was appended by the merge itself. # 167000 -> 167300 (merge of the moved target into the plan-review branch, measured 167032): both sides' paragraphs land together. - "docs/architecture/01-high-level-architecture.md": 167300, + # 167300 -> 167450 (#1374, measured 167398): the module-map row for the new + # terminal_cost_reconciliation.py leaf; the base sat 38 bytes under. + "docs/architecture/01-high-level-architecture.md": 167450, # 15517 -> 16200 (#1195): the session-custodied startup historical audit is a # new node of the startup flow (readiness no longer waits for the historical # seal diagnostic); the chapter had no older description of that pass to replace. diff --git a/tests/test_terminal_cost_reconciliation.py b/tests/test_terminal_cost_reconciliation.py new file mode 100644 index 000000000..3bb3eacac --- /dev/null +++ b/tests/test_terminal_cost_reconciliation.py @@ -0,0 +1,462 @@ +"""Selection is a no-write proof, never an ownership or accounting authority.""" +from collections import Counter +from types import SimpleNamespace + +import pytest + +from ouroboros import server_maintenance as maintenance +from ouroboros import task_results +from ouroboros import terminal_cost_reconciliation as reconciliation +from ouroboros import usage_accounting as usage +from supervisor import events_task_done as done +from supervisor import queue + + +@pytest.fixture +def env(tmp_path, monkeypatch): + live = set() + monkeypatch.setattr(reconciliation, "_EQUAL_PROJECTIONS", {}) + monkeypatch.setattr(queue, "DRIVE_ROOT", tmp_path) + monkeypatch.setattr(queue, "task_has_live_ownership", lambda task_id: task_id in live) + return SimpleNamespace(root=tmp_path, live=live) + + +def task(env, tid="root", **fields): + return task_results.write_task_result(env.root, tid, "cancelled", result="kept answer", **fields) + + +def attempt(env, tid="root", *, logical=None, cost=0.4, final=True, provider="openai"): + reservation = usage.reserve_attempt(usage.AttemptRequest( + model="test", provider=provider, drive_root=env.root, task_id=tid, + root_task_id=logical or tid, reservation_usd=1.0, global_limit_usd=100.0)) + usage.mark_dispatched(reservation) + if final: + usage.settle_attempt(reservation, {"prompt_tokens": 7}, cost_usd=cost, cost_final=True) + return reservation + + +def key(env, tid="root"): + return str(env.root.resolve()), tid + + +def observe(monkeypatch): + counts = Counter() + # Count BOTH import sites: the probe and the original refresh's reread. + read, project, owned = task_results.load_task_result, done._authoritative_terminal_cost, queue.task_has_live_ownership + + def load(root, tid, **kw): + counts["reads"] += 1 + return read(root, tid, **kw) + + def projection(*args, **kw): + counts["projections"] += 1 + return project(*args, **kw) + + def ownership(tid): + counts["ownership"] += 1 + return owned(tid) + + monkeypatch.setattr(task_results, "load_task_result", load) + monkeypatch.setattr(done, "load_task_result", load) + monkeypatch.setattr(done, "_authoritative_terminal_cost", projection) + monkeypatch.setattr(queue, "task_has_live_ownership", ownership) + return counts + + +def warm(env): + maintenance._reconcile_abandoned_usage(env.root) # write; not evidence of equality + maintenance._reconcile_abandoned_usage(env.root) # independent confirmation + assert key(env) in reconciliation._EQUAL_PROJECTIONS + + +def test_quiet_history_reads_once_then_skips_bodies_projection_and_ownership(env, monkeypatch): + for tid in ("a", "b", "c"): + task(env, tid) + attempt(env, tid) + done._refresh_terminal_task_cost(env.root, tid) + before = {p: p.read_bytes() for folder in ("task_results", "state", "logs") + for p in (env.root / folder).glob("*.json*")} + counts = observe(monkeypatch) + maintenance._reconcile_abandoned_usage(env.root) + assert counts == {"reads": 3, "projections": 3} + counts.clear() + for _ in range(2): + maintenance._reconcile_abandoned_usage(env.root) + assert counts == {} + assert {p: p.read_bytes() for p in before} == before + + +def test_a_write_is_not_memoized_until_a_later_equal_read(env, monkeypatch): + task(env) + attempt(env) + counts = observe(monkeypatch) + maintenance._reconcile_abandoned_usage(env.root) + assert key(env) not in reconciliation._EQUAL_PROJECTIONS + assert counts == {"reads": 2, "projections": 2, "ownership": 1} + counts.clear() + maintenance._reconcile_abandoned_usage(env.root) + assert key(env) in reconciliation._EQUAL_PROJECTIONS + assert counts == {"reads": 1, "projections": 1} + counts.clear() + maintenance._reconcile_abandoned_usage(env.root) + assert not counts + + +@pytest.mark.parametrize("failure", ["false", "raise"]) +def test_failed_or_ambiguous_refresh_is_not_an_equal_proof(env, monkeypatch, failure): + task(env) + attempt(env) + refresh = done._refresh_terminal_task_cost + calls = [] + + def failed(*args, **kwargs): + calls.append(args[1]) + if failure == "raise": + raise OSError("unwritable result") + return False + + monkeypatch.setattr(done, "_refresh_terminal_task_cost", failed) + for _ in range(2): + maintenance._reconcile_abandoned_usage(env.root) + assert not reconciliation._EQUAL_PROJECTIONS + assert calls == ["root", "root"] + monkeypatch.setattr(done, "_refresh_terminal_task_cost", refresh) + warm(env) + assert task_results.load_task_result(env.root, "root")["accounted_upper_bound_usd"] == 0.4 + + +def test_unrelated_bucket_does_not_invalidate_but_logical_root_bucket_does(env, monkeypatch): + task(env, root_task_id="old", parent_task_id="", delegation_role="root", + original_task_id="old", timeout_retry_from="old") + attempt(env, logical="old") + warm(env) + # The physical root is 'root'; its monetary subtree is 'old', not 'root'. + assert reconciliation._EQUAL_PROJECTIONS[key(env)].scopes == (True, "old") + counts = observe(monkeypatch) + attempt(env, "unrelated") # missing result isn't memoized + maintenance._reconcile_abandoned_usage(env.root) + assert not counts + attempt(env, "child", logical="old", cost=0.6) + maintenance._reconcile_abandoned_usage(env.root) + assert counts["projections"] == 2 and counts["ownership"] == 1 + row = task_results.load_task_result(env.root, "root") + assert row["accounted_upper_bound_usd"] == 0.4 + assert row["accounted_upper_bound_usd_with_children"] == 1.0 + + +def test_result_lineage_change_invalidates_without_ledger_change(env): + task(env) + attempt(env) + attempt(env, "old-child", logical="old", cost=0.6) + warm(env) + ledger = (env.root / usage.LEDGER_REL).read_bytes() + task(env, root_task_id="old", parent_task_id="", delegation_role="root", + original_task_id="old", timeout_retry_from="old") + maintenance._reconcile_abandoned_usage(env.root) + row = task_results.load_task_result(env.root, "root") + assert row["accounted_upper_bound_usd_with_children"] == 0.6 + assert (env.root / usage.LEDGER_REL).read_bytes() == ledger + + +def test_detached_basis_detects_in_place_changes_and_absent_bucket_integrity(env, monkeypatch): + task(env) + attempt(env, "child", logical="root") # root's own bucket is absent + warm(env) + breakdown = usage.usage_breakdown(env.root) + monkeypatch.setattr(usage, "usage_breakdown", lambda *_a, **_kw: breakdown) + counts = observe(monkeypatch) + maintenance._reconcile_abandoned_usage(env.root) + assert not counts + # Mutating a nested basis must not mutate the saved comparison witness. + breakdown["by_root"]["root"]["prompt_cache_ttls"]["fixture"] = 1 + maintenance._reconcile_abandoned_usage(env.root) + assert counts["projections"] == 1 # no written field changed, a new EQUAL proof + assert reconciliation._EQUAL_PROJECTIONS[key(env)].buckets[2][1] is not breakdown["by_root"]["root"] + breakdown["integrity_degraded"] = True + maintenance._reconcile_abandoned_usage(env.root) + assert key(env) not in reconciliation._EQUAL_PROJECTIONS + row = task_results.load_task_result(env.root, "root") + assert row["ledger_integrity_degraded"] and not row["cost_final"] + + +def test_unknown_same_amount_is_not_final_and_never_memoized(env): + task(env) + reservation = attempt(env, final=False) + for _ in range(3): + maintenance._reconcile_abandoned_usage(env.root) + assert key(env) not in reconciliation._EQUAL_PROJECTIONS + before = task_results.load_task_result(env.root, "root") + assert before["accounted_upper_bound_usd"] == 1.0 and not before["cost_final"] + usage.settle_attempt(reservation, cost_usd=1.0, cost_final=True) + warm(env) + after = task_results.load_task_result(env.root, "root") + assert after["accounted_upper_bound_usd"] == 1.0 and after["cost_final"] + assert after["cost_presentation"] != before["cost_presentation"] + + +@pytest.mark.parametrize("change", ["deleted", "malformed", "schema", "postwork", "replacement"]) +def test_result_stamp_invalidates_and_no_negative_cache_survives_repair(env, monkeypatch, change): + task(env) + attempt(env) + warm(env) + path = task_results.task_result_path(env.root, "root", create=False) + good = path.read_bytes() + if change == "deleted": + path.unlink() + elif change == "malformed": + path.write_bytes(b'{broken') + elif change == "schema": + path.write_text('{"_schema_version":99999,"task_id":"root","status":"cancelled"}', encoding="utf-8") + elif change == "postwork": + task(env, root_phase_checkpoint={"post_task_synthesis": "running"}) + else: + replacement = path.with_suffix(".tmp") + replacement.write_bytes(good) + replacement.replace(path) + counts = observe(monkeypatch) + maintenance._reconcile_abandoned_usage(env.root) + if change == "replacement": + assert counts == {"reads": 1, "projections": 1} + else: + assert key(env) not in reconciliation._EQUAL_PROJECTIONS + path.write_bytes(good) + maintenance._reconcile_abandoned_usage(env.root) + assert key(env) in reconciliation._EQUAL_PROJECTIONS + + +def test_concurrent_result_replacement_cannot_mint_equal_memo(env, monkeypatch): + task(env) + attempt(env) + done._refresh_terminal_task_cost(env.root, "root") + original = task_results.load_task_result + + def concurrent(root, tid, **kw): + row = original(root, tid, **kw) + task(env, root_phase_checkpoint={"post_task_synthesis": "running"}) + return row + + monkeypatch.setattr(task_results, "load_task_result", concurrent) + maintenance._reconcile_abandoned_usage(env.root) + assert not reconciliation._EQUAL_PROJECTIONS + + +def test_unavailable_breakdown_invalidates_old_proof_without_per_owner_fallback(env, monkeypatch): + task(env) + attempt(env) + warm(env) + breakdown = usage.usage_breakdown + calls = [] + + def unavailable(*_a, **kw): + calls.append(kw) + raise OSError("ledger unavailable") + + monkeypatch.setattr(usage, "usage_breakdown", unavailable) + maintenance._reconcile_abandoned_usage(env.root) + assert calls == [{}] and not reconciliation._EQUAL_PROJECTIONS + monkeypatch.setattr(usage, "usage_breakdown", breakdown) + counts = observe(monkeypatch) + maintenance._reconcile_abandoned_usage(env.root) + assert counts == {"reads": 1, "projections": 1} + + +def test_foreign_live_owner_never_imports_or_reads_foreign_money(env, monkeypatch): + foreign = env.root / "foreign" + task(env, budget_drive_root=str(foreign)) + attempt(env) + env.live.add("root") + imported = usage.ensure_legacy_imported + calls = [] + + def checked(root): + calls.append(root) + assert root != foreign, "foreign accounting must follow eligibility" + return imported(root) + + monkeypatch.setattr(usage, "ensure_legacy_imported", checked) + maintenance._reconcile_abandoned_usage(env.root) + assert calls == [env.root] + assert not foreign.exists() and not reconciliation._EQUAL_PROJECTIONS + assert "accounted_upper_bound_usd" not in task_results.load_task_result(env.root, "root") + + +def test_authority_symlink_rebinding_without_result_change_invalidates_hit(env): + alias = env.root.parent / f"{env.root.name}-alias" + try: + alias.symlink_to(env.root, target_is_directory=True) + except OSError as exc: + pytest.skip(f"symlink unavailable: {exc}") + task(env, budget_drive_root=str(alias)) + attempt(env) + warm(env) + before = task_results.task_result_path(env.root, "root").read_bytes() + other = SimpleNamespace(root=env.root / "other") + attempt(other, cost=0.9) + alias.unlink() + alias.symlink_to(other.root, target_is_directory=True) + maintenance._reconcile_abandoned_usage(env.root) + row = task_results.load_task_result(env.root, "root") + assert row["accounted_upper_bound_usd"] == 0.9 + assert key(env) not in reconciliation._EQUAL_PROJECTIONS + assert before != task_results.task_result_path(env.root, "root").read_bytes() + + +def test_fresh_ownership_after_recovery_prevents_projection_write(env, monkeypatch): + task(env) + attempt(env, final=False) + original = usage.terminalize_abandoned_attempt + + def recovered(*args, **kw): + result = original(*args, **kw) + env.live.add("root") + return result + + monkeypatch.setattr(usage, "terminalize_abandoned_attempt", recovered) + monkeypatch.setattr(done, "_refresh_terminal_task_cost", lambda *_a, **_kw: pytest.fail("owner revived")) + maintenance._reconcile_abandoned_usage(env.root) + assert "accounted_upper_bound_usd" not in task_results.load_task_result(env.root, "root") + + +def test_retained_remote_recovery_is_independent_of_prior_equal_proof(env, monkeypatch): + from ouroboros import llm_claudexor + + task(env) + attempt(env) + warm(env) + reservation = attempt(env, provider="claudexor", final=False) + calls = [] + receipt = [None] + + def recover(root, row, **kw): + calls.append(row["attempt_id"]) + return receipt[0] + + monkeypatch.setattr(llm_claudexor, "recover_model_attempt", recover) + maintenance._reconcile_abandoned_usage(env.root) + assert calls == [reservation.attempt_id] + assert key(env) not in reconciliation._EQUAL_PROJECTIONS + # Remote administrative settlement remains recoverable on an unchanged + # ledger: a retained receipt can arrive without any local accounting append. + usage.terminalize_abandoned_attempt(reservation, reason="owner_task_terminal") + maintenance._reconcile_abandoned_usage(env.root) + receipt[0] = ("settled", {"prompt_tokens": 2}, 0.2, True) + maintenance._reconcile_abandoned_usage(env.root) + assert calls == [reservation.attempt_id] * 3 + assert task_results.load_task_result(env.root, "root")["accounted_upper_bound_usd"] == 0.6 + + +def test_memo_is_root_scoped_and_pruned_by_current_attribution(env): + task(env) + attempt(env) + warm(env) + other = SimpleNamespace(root=env.root / "other") + task(other) + attempt(other, cost=0.8) + warm(other) + assert len(reconciliation._EQUAL_PROJECTIONS) == 2 + reconciliation._refresh_costs(env.root, set(), {}) + assert key(env) not in reconciliation._EQUAL_PROJECTIONS + assert key(other) in reconciliation._EQUAL_PROJECTIONS + + +@pytest.mark.parametrize("owner", ["running", "busy", "direct", "postwork", "retry", "malformed_retry", "review", "control_review"]) +def test_real_ownership_predicate_still_fences_projection_and_recovery(env, monkeypatch, owner): + from ouroboros import review_operation + from supervisor import queue_transitions, workers + + monkeypatch.setattr(queue, "task_has_live_ownership", queue_transitions.task_has_live_ownership) + monkeypatch.setattr(queue, "RUNNING", {}) + monkeypatch.setattr(queue, "PENDING", []) + monkeypatch.setattr(queue, "QUEUE_MAX_RETRIES", 3) + monkeypatch.setattr(workers, "WORKERS", {}) + monkeypatch.setattr(workers, "direct_chat_turn", lambda tid: object() if owner == "direct" else None) + monkeypatch.setattr(queue_transitions, "post_task_synthesis_in_flight", lambda *_: owner == "postwork") + task(env) + reservation = attempt(env, final=False) + if owner == "running": + queue.RUNNING["root"] = {"task": {"id": "root"}} + if owner == "busy": + workers.WORKERS[1] = SimpleNamespace(busy_task_id="root") + if owner in {"retry", "malformed_retry"}: + lineage = dict(root_task_id="root", parent_task_id="", delegation_role="root", + original_task_id="root", timeout_retry_from="root", supersedes_task_id="root") + task(env, "retry", **lineage) + queue.RUNNING["retry"] = {"task": {"id": "retry", **lineage}} + # The malformed case is a live retry OUTSIDE the reciprocal chain. + if owner == "retry": + task(env, superseded_by="retry", retry_task_id="retry") + if owner in {"review", "control_review"}: + primary = {"state": review_operation.OPERATION_DISPATCHED, "controller": {"fixture": "alive"}, "source_ref": {"id": "source"}} + monkeypatch.setattr(review_operation, "controller_state", lambda _: "alive") + if owner == "review": + task(env, **{review_operation.OPERATIONS_FIELD: {"operation": primary}}) + else: + task(env, "subject", **{review_operation.OPERATIONS_FIELD: {"operation": primary}}) + link = {"control_only": True, "subject_task_id": "subject", "controller": primary["controller"], "source_ref": primary["source_ref"]} + task(env, **{review_operation.OPERATIONS_FIELD: {"operation": link}}) + assert queue.task_has_live_ownership("root") + ledger = (env.root / usage.LEDGER_REL).read_bytes() + original = task_results.task_result_path(env.root, "root").read_bytes() + maintenance._reconcile_abandoned_usage(env.root) + assert (env.root / usage.LEDGER_REL).read_bytes() == ledger + assert task_results.task_result_path(env.root, "root").read_bytes() == original + usage.settle_attempt(reservation, cost_usd=0.4, cost_final=True) + maintenance._reconcile_abandoned_usage(env.root) + assert task_results.task_result_path(env.root, "root").read_bytes() == original + + +def test_a_live_review_link_changed_elsewhere_is_checked_before_new_money(env, monkeypatch): + from ouroboros import review_operation + from supervisor import queue_transitions, workers + + task(env) + attempt(env) + primary = {"state": review_operation.OPERATION_CLOSED, "controller": {"fixture": "alive"}, "source_ref": {"id": "s"}} + task(env, "subject", **{review_operation.OPERATIONS_FIELD: {"operation": primary}}) + link = {"control_only": True, "subject_task_id": "subject", "controller": primary["controller"], "source_ref": primary["source_ref"]} + task(env, **{review_operation.OPERATIONS_FIELD: {"operation": link}}) + warm(env) + before = task_results.task_result_path(env.root, "root").read_bytes() + monkeypatch.setattr(queue, "task_has_live_ownership", queue_transitions.task_has_live_ownership) + monkeypatch.setattr(queue, "RUNNING", {}) + monkeypatch.setattr(queue, "PENDING", []) + monkeypatch.setattr(workers, "WORKERS", {}) + monkeypatch.setattr(workers, "direct_chat_turn", lambda _: None) + monkeypatch.setattr(queue_transitions, "post_task_synthesis_in_flight", lambda *_: False) + monkeypatch.setattr(review_operation, "controller_state", lambda _: "alive") + task(env, "subject", **{review_operation.OPERATIONS_FIELD: {"operation": {**primary, "state": review_operation.OPERATION_DISPATCHED}}}) + # Equal no-write memo does not claim this owner is dead; a changed ledger + # must invoke the exact control-link predicate despite unchanged owner file. + maintenance._reconcile_abandoned_usage(env.root) + attempt(env, cost=0.1) + maintenance._reconcile_abandoned_usage(env.root) + assert task_results.task_result_path(env.root, "root").read_bytes() == before + assert key(env) not in reconciliation._EQUAL_PROJECTIONS + + +def test_canonical_probe_does_not_fallback_if_authority_moves_during_comparison(env, monkeypatch): + alias = env.root.parent / f"{env.root.name}-alias" + try: + alias.symlink_to(env.root, target_is_directory=True) + except OSError as exc: + pytest.skip(f"symlink unavailable: {exc}") + task(env, budget_drive_root=str(alias)) + attempt(env) + imported = usage.ensure_legacy_imported + project = done._authoritative_terminal_cost + foreign = env.root / "foreign" + + def imports(root): + assert root.resolve() == env.root, "probe must not enter foreign accounting" + return imported(root) + + def moved(*args, **kw): + alias.unlink() + alias.symlink_to(foreign, target_is_directory=True) + return project(*args, **kw) + + monkeypatch.setattr(usage, "ensure_legacy_imported", imports) + monkeypatch.setattr(done, "_authoritative_terminal_cost", moved) + maintenance._reconcile_abandoned_usage(env.root) + assert not foreign.exists() and not reconciliation._EQUAL_PROJECTIONS + assert "accounted_upper_bound_usd" not in task_results.load_task_result(env.root, "root") diff --git a/tests/test_usage_abandoned_reconciliation.py b/tests/test_usage_abandoned_reconciliation.py index f01b8e652..eee96acc8 100644 --- a/tests/test_usage_abandoned_reconciliation.py +++ b/tests/test_usage_abandoned_reconciliation.py @@ -1,15 +1,16 @@ """Existing terminal maintenance closes abandoned work without inventing prices.""" -from contextlib import contextmanager import hashlib import json import threading import time +from contextlib import contextmanager from types import SimpleNamespace import pytest -from ouroboros import server_maintenance as maintenance, usage_accounting as usage +from ouroboros import server_maintenance as maintenance +from ouroboros import usage_accounting as usage from ouroboros.observability import persist_call from ouroboros.task_results import load_task_result, write_task_result from ouroboros.usage_ledger import is_abandoned_settlement @@ -309,7 +310,7 @@ def test_a_daemon_failure_never_starves_a_later_retained_local_receipt(env, monk @pytest.mark.parametrize("failed_task", ["child", "root"]) @pytest.mark.parametrize("compact", [False, True]) def test_failed_projection_retries_from_settled_or_compacted_truth(env, monkeypatch, failed_task, compact): - from ouroboros import usage_compaction, _usage_rows_memo + from ouroboros import _usage_rows_memo, usage_compaction from supervisor import events_task_done _terminal(env) @@ -453,7 +454,9 @@ def test_projection_uses_one_indexed_breakdown_for_distinct_owners(env, monkeypa monkeypatch.setattr(usage, "usage_breakdown", aggregate) maintenance._reconcile_abandoned_usage(env.root) assert aggregations == [{}], "one bulk view, no per-owner full-ledger filters" - assert sorted(reads) == sorted(owners), "ownership reads are cached across attempts" + # Recovery eligibility is cached across attempts; projection independently + # re-reads those two open owners after recovery, never reusing permission. + assert sorted(reads) == sorted([*owners, "child-1", "child-2"]) for task_id in owners: stored = load_task_result(env.root, task_id) expected = events_task_done._authoritative_terminal_cost(task_id, stored, stored, {}, env.root) From 6c82c5e651f1e111228ff5657080a061aac77de2 Mon Sep 17 00:00:00 2001 From: Anton Date: Tue, 29 Sep 2026 23:19:44 +0300 Subject: [PATCH 07/44] wip: preserve recovered welcome consumer candidate before hidden configuration refinement Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com> --- docs/DESIGN.md | 7 + docs/PERSISTENCE.md | 2 +- .../01-high-level-architecture.md | 5 +- .../03-web-ui-pages-and-buttons.md | 45 ++- docs/development/11-design-system.md | 2 +- docs/inventories/DATA_LAYOUT_INVENTORY.md | 2 +- docs/inventories/UI_CONTROL_TEXT_INVENTORY.md | 4 +- ouroboros/gateway/contracts.py | 5 +- tests/test_navigation_shell_static.py | 11 +- tests/test_ui_smoke_acceptance_source.py | 2 +- tests/test_ui_smoke_project_continuity.py | 4 +- tests/test_ui_welcome_browser.py | 291 ++++++++++++++++++ tests/ui_chat_viewport_smoke.py | 5 +- web/modules/chat.js | 50 +-- web/modules/chat_render_batch.js | 5 +- web/modules/settings_ui.js | 2 +- web/modules/welcome_preference.js | 45 +++ web/tests/provider_test.test.js | 8 +- web/tests/render_batch.test.js | 16 + web/tests/welcome_preference.test.js | 62 +++- 20 files changed, 484 insertions(+), 89 deletions(-) create mode 100644 tests/test_ui_welcome_browser.py diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 52e3ab500..0eba9dbbf 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -463,6 +463,13 @@ renderer regardless of `markdown`; ordinary System text is escaped unless Voice does not confer task finality. The existing untyped terminal-host-notice contract remains a documented exception, not a pattern for new notices. +Main's empty-chat greeting speaks in neither voice: it is host copy in a quiet +`.chat-empty-welcome` placeholder (a `Welcome` label over one sentence), never a +bubble, a history row or a model reply. The sentence is the install-wide `welcome` +preference — the built-in "Ouroboros has awakened", hidden, or the owner's plain +text, set as text and never as markup. It appears only in a Main whose history read +confirmed it empty, and it leaves with the first message or task card. + ## 5. Card and section composition - A panel is one `.ui-card`-family surface: `--ui-card-border`, diff --git a/docs/PERSISTENCE.md b/docs/PERSISTENCE.md index 1182803ef..ceaec07a1 100644 --- a/docs/PERSISTENCE.md +++ b/docs/PERSISTENCE.md @@ -63,7 +63,7 @@ scanned data-relative path to be covered by a row here (count-anchored both ways | `state/evolution_campaign.json` | `supervisor/evolution_lifecycle.py` (CAS under state.lock) | own `schema_version: 1` (campaign) / 2 (active_transaction); the active transaction carries the pre-commit `commit_intent` (reviewed tree + parents) written BEFORE `git commit`, which boot recovery turns into the `commit_receipt` a crash never wrote | bounded histories (50) | in-flight self-modification transaction unabsorbable; anti-repeat fingerprints lost | | `state/evolution_metrics_cache.json` | `ouroboros/utils.py` | own `schema: 1` (strictly validated) | one point per git tag, no prune — accepted (derived cache) | pure cache; recomputed from git | | `state/projects.json`, `state/project_task_bindings.json` | `ouroboros/projects_registry.py` (sidecar locks) | `_schema_version: 2` / `1` (ABI-2) | never age-pruned (owner curates); deletes are durable tombstones | tombstones live here: losing it can resurrect deleted project rooms (marker unlink mitigates) | -| `state/ui_preferences.json` | `ouroboros/gateway/ui_preferences.py` | none — not needed (defaults contract + strict unknown-key rejection; retired keys dropped on write) | bounded (widget_order 200, cursors 1000) | cosmetic; defaults restored | +| `state/ui_preferences.json` | `ouroboros/gateway/ui_preferences.py` | none — not needed (defaults contract + strict unknown-key and value rejection: a refused POST (400/503) writes nothing; retired keys dropped on write; the Settings greeting control saves only after its own read succeeded) | bounded (widget_order 200, cursors 1000, `welcome` text 500 chars) | cosmetic; defaults restored (`welcome` = built-in sentence) | | `state/server_port` | `ouroboros/server_entrypoint.py` | none — not needed (1-line transport fact) | overwrite; pre-start unlink | readers fall back to default port | | `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 known endpoints with unconfirmed identity | | `state/server_process.json` | `launcher.py` | none — PID/path-bound process facts; optional `server_host_source` distinguishes parent env from saved Settings, absent on old launchers means unknown | one-shot, self-deleting | stray-server reap skipped once; bind-source disclosure becomes unknown, never inferred from value equality | diff --git a/docs/architecture/01-high-level-architecture.md b/docs/architecture/01-high-level-architecture.md index 4b55a49e1..a5b7194c2 100644 --- a/docs/architecture/01-high-level-architecture.md +++ b/docs/architecture/01-high-level-architecture.md @@ -18,6 +18,7 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de │ ├── modules/chat_decision.js, question_presentation.js, chat_render_batch.js, task_phase_chip.js, lifecycle_card.js ← Chat helpers: the typed decision (quiz) cards, one form for a Project question in its room and in its Main mirror, with one pure lifecycle projection shared by both (its Python twin: `project_dialogue.QUESTION_STATUS`); keyed timeline items, DOM patches, history-control presentation and reading anchors; pure desired-phase chip projection where terminal truth wins; skill lifecycle card state with best-effort polling │ ├── modules/chat_history.js, chat_history_replay.js ← Per-chat history pages with bounded retention and exact return handles; source-keyed replay merging without live-task authority (§3 Timeline ownership and ordering) │ ├── modules/delegated_activity.js ← Run/seq-based activity projection, Unicode fragment cuts, explicit preview gaps and retained-source links (§3 Child cards and executor presentation) + │ ├── modules/welcome_preference.js ← Install-wide `welcome` copy: Settings → Appearance control, Main's host-owned empty state (§3 Timeline ownership and ordering) │ ├── modules/project_answer.js ← Main's Project lifecycle rows: the fold of a mirrored final answer and the Project reference under every row (§3 Main rows) │ ├── modules/project_handoff.js ← Main-only transfer anchors: every converted card stays visible, at most one receipt row per `handoff_id` folds under the first visible card, shadows restored on eviction, phase from the existing census/task detail; `receiptNotice` words the typed receipt (§3 Project handoff receipts) │ ├── modules/project_reference.js ← the one control that points at a Project, and the only raiser of `ouro:open-project` (DESIGN "References and actions") @@ -408,7 +409,7 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de │ ├── update_progress.py ← Process-local stages owned by the synchronous update executor; status projection and WS invalidation, never recovery authority │ ├── schedules.py ← Cron schedule HTTP surface │ ├── files.py ← File Browser + chat upload - │ ├── ui_preferences.py ← `state/ui_preferences.json`: widget order, per-card start-mode overrides (`widget_start_mode`, values from `extension_ui_validation.WIDGET_START_MODES`), nested subagent expansion + │ ├── ui_preferences.py ← `state/ui_preferences.json`: widget order, per-card start-mode overrides (`widget_start_mode`, values from `extension_ui_validation.WIDGET_START_MODES`), nested subagent expansion, empty-Main `welcome` copy │ ├── models.py ← Model catalog + provider probes + local-model lifecycle │ ├── extensions.py ← extensions/skills HTTP surface (GET /api/extensions, GET /api/extensions//manifest, GET /api/extensions//module/ — reviewed module sources served from the live loader registration, ALL /api/extensions//, POST /api/skills//toggle, POST /api/skills//delete, POST /api/skills//review, POST /api/skills//grants) │ ├── extension_receipts.py ← Process-qualified extension index/toggle/reconcile receipt projection @@ -661,7 +662,7 @@ Bundled resources use the CLI / Headless Boundary lookup order rather than assum │ │ ├── update_letter.json ← the last update letter (key = base/target/channel/ref, state, text, `last_good`); kept after apply and projected against the live HEAD (update_letter.py) │ │ ├── projects.json ← Project registry: immutable id/chat identity, working folder, lifecycle/routing fence, revision; tombstones are durable and never age-pruned │ │ ├── project_task_bindings.json ← schema v1 root↔Project bindings with REQUIRED typed origin; one-way enrichment; tombstoning never removes a binding -│ │ ├── ui_preferences.json ← owner-local layout preferences + monotonic project_seen_revision ACKs +│ │ ├── ui_preferences.json ← owner-local layout preferences, empty-Main `welcome` copy, monotonic project_seen_revision ACKs │ │ ├── cancel_intents.json ← compact locked projection of ACTIVE cancel intents; the forensic trail is typed cancel_intent rows in logs/supervisor.jsonl, never read back (cancel_intents.py) │ │ ├── terminal_deliveries.json ← delivery-id dedupe, emitted-byte receipts, pending outbox (terminal_delivery.py) │ │ ├── extension_companions.json ← runtime snapshot of live companion processes diff --git a/docs/architecture/03-web-ui-pages-and-buttons.md b/docs/architecture/03-web-ui-pages-and-buttons.md index 129646f9c..89f6f36f6 100644 --- a/docs/architecture/03-web-ui-pages-and-buttons.md +++ b/docs/architecture/03-web-ui-pages-and-buttons.md @@ -8,7 +8,7 @@ The desktop shell exposes a small `MainApi` JS bridge (`window.pywebview.api`, ` ### Navigation and shared UI contracts -Appearance is client-local, not a runtime setting. `web/theme.js` runs before +The theme is client-local, not a runtime setting. `web/theme.js` runs before CSS in both first-party documents. `ouroboros.theme` stores `light`, `dark` or `system` (missing/invalid means System); `data-theme-choice` preserves the choice and `data-theme` holds the palette resolved through `prefers-color-scheme`. @@ -41,8 +41,8 @@ banner, one tone and click-to-source. `attach()` takes ONE subscription per client on the shared socket in `app.js` (`chat`, `quiz`, `log`); `chat.js` contains no notification code, because a chat instance dies with its room and a closed Project must still be able to pull the owner back. Only live frames reach -it — history rendering and reconnect backfill never do, which is what makes -replay safety structural rather than a stored ledger. The room gate mirrors +it — history rendering and reconnect backfill never do, so +replay safety is structural, not a stored ledger. The room gate mirrors `chat_activity.mainThreadAccepts`: a known room (Main plus `state.projectChatIds`) or any positive chat not stamped as another Project's, so an external owner transport notifies while the hidden partition and A2A ids @@ -70,12 +70,11 @@ author, and `fixed` names a stable visual world; the field is advisory. Desktop `launcher.py` and `launcher_onboarding.py`, including cookies, not just appearance. Existing packaged launchers must be rebuilt and installed to change that flag. Profile identity, origin and platform storage -still govern retention: source tests do not certify a cold-launch result. When -migrating from a private session, explicitly select the owner's Light in the new -persistent client before testing full quit/relaunch; no private-to-persistent -migration is promised. Browser reload and server restart are separate checks. +still govern retention: source tests do not certify a cold-launch result. After a +private session, select the owner's Light again in the new persistent client +before a full quit/relaunch check; no private-to-persistent migration is promised. Browser reload and server restart are separate checks. -Primary navigation exposes Chat (Main), a collapsible Projects group, Files, Skills, Widgets, Dashboard and Settings; About is a Settings sub-tab. `syncNavigationState()` is the single presentation state machine for active page, active Project, Projects expansion, mobile drawer and panel backdrop, so independent toggles cannot leave multiple rows active or a hidden surface looking selected. Sidebar and Project panel widths are owner-local UI preferences, not runtime settings. The application has exactly one client-side route: a `#` fragment for the injected page ids, honoured once on load and validated against the existing `#page-` section (an unknown fragment is ignored rather than painting a blank surface), and never written back on navigation — the desktop shell and the Linux browser fallback have no address bar, and the Telegram mini app always loads at `/` — so a browser gains a shareable `/#widgets` link while no other surface changes. Projects are panels, not routes, and the sidebar is the document's one `