From ad03473337ca4b994f820093a4bf6114d581c618 Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:56:52 +0900 Subject: [PATCH 01/27] Re-exec the CI user manager after package upgrades and restore ubuntu-latest GitHub ubuntu-26.04 images upgrade systemd 259.5 to 259.9 while installing the CAM build dependencies, leaving the running user manager on the old binary. Confined candidate-validation sandboxes then missed their readiness window and library shards hit the 45-minute limit, so the Linux jobs were pinned to ubuntu-24.04. Re-exec the active user manager after apt and log its version so the jobs can return to ubuntu-latest. --- .github/workflows/ci.yml | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce298aad..9163b4dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,9 +48,7 @@ jobs: linux-library: name: Linux library tests (${{ matrix.shard }}) needs: nix-package - # Ubuntu 26.04 shards exceeded the 45-minute job timeout mid-test; keep - # 24.04 (last within budget) until 26.04 test runtime fits the timeout. - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest timeout-minutes: 45 strategy: fail-fast: false @@ -236,6 +234,18 @@ jobs: run: | sudo apt-get update sudo apt-get install -y pkg-config libdbus-1-dev + # Package installation can replace systemd and its sandbox executor. + # Re-exec the existing user manager before starting confined tests. + if systemctl is-active --quiet "user@$(id -u).service"; then + export XDG_RUNTIME_DIR="/run/user/$(id -u)" + export DBUS_SESSION_BUS_ADDRESS="unix:path=${XDG_RUNTIME_DIR}/bus" + systemctl --version + echo 'systemd user manager before re-exec:' + systemctl --user show --property=Version + systemctl --user daemon-reexec + echo 'systemd user manager after re-exec:' + systemctl --user show --property=Version + fi - name: Verify delegated Linux process startup run: bash .github/scripts/run-linux-all-tests-in-delegated-user-scope.sh /usr/bin/true @@ -299,9 +309,7 @@ jobs: linux-integration: name: Linux integration tests needs: nix-package - # Ubuntu 26.04 hits the candidate-validation readiness timeout; keep the - # last verified image until its sandbox startup behavior is qualified. - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest timeout-minutes: 45 steps: - name: Check out repository @@ -483,6 +491,18 @@ jobs: run: | sudo apt-get update sudo apt-get install -y pkg-config libdbus-1-dev + # Package installation can replace systemd and its sandbox executor. + # Re-exec the existing user manager before starting confined tests. + if systemctl is-active --quiet "user@$(id -u).service"; then + export XDG_RUNTIME_DIR="/run/user/$(id -u)" + export DBUS_SESSION_BUS_ADDRESS="unix:path=${XDG_RUNTIME_DIR}/bus" + systemctl --version + echo 'systemd user manager before re-exec:' + systemctl --user show --property=Version + systemctl --user daemon-reexec + echo 'systemd user manager after re-exec:' + systemctl --user show --property=Version + fi - name: Verify delegated Linux process startup run: bash .github/scripts/run-linux-all-tests-in-delegated-user-scope.sh /usr/bin/true From 24aaf3babf8a6535f8a2400ee7f0b6e4d5c60110 Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:52:06 +0900 Subject: [PATCH 02/27] Split the slow Linux test shards so every job fits on ubuntu-26.04 On ubuntu-26.04 the supervise-core library shard exceeded the 45-minute job limit and the rest shard needed 44.5 minutes. Refine supervise-core and rest by module into smaller library shards and split the integration targets into merge and other groups, keeping the job limits and requiring every shard. The partition helpers verify at runtime that the shards cover exactly the original test set. --- .../scripts/linux_ci_cargo_test_partition.py | 23 ++++++++++- .github/scripts/linux_ci_library_shards.py | 26 +++++++++++- .../run-linux-ci-cargo-test-partition.sh | 12 ++++-- .../scripts/test_linux_ci_library_shards.py | 40 +++++++++++++++++-- .github/workflows/ci.yml | 10 +++-- .../test_linux_ci_cargo_test_partition.py | 36 +++++++++++++++++ scripts/tests/test_linux_ci_library_shards.py | 22 ++++++++-- 7 files changed, 154 insertions(+), 15 deletions(-) diff --git a/.github/scripts/linux_ci_cargo_test_partition.py b/.github/scripts/linux_ci_cargo_test_partition.py index 3d5342b7..93bcbd0c 100644 --- a/.github/scripts/linux_ci_cargo_test_partition.py +++ b/.github/scripts/linux_ci_cargo_test_partition.py @@ -15,6 +15,8 @@ TESTABLE_KINDS = frozenset({"lib", "bin", "test", "bench", "example"}) SKIPPED_ONLY_KINDS = frozenset({"custom-build"}) TARGET_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$") +NON_LIB_SHARDS = ("merge", "other") +MERGE_TEST_TARGETS = frozenset({"merge_cli", "pr_issue_cli"}) @dataclass(frozen=True) @@ -117,7 +119,11 @@ def load_root_package(manifest_dir: Path) -> Mapping[str, object]: raise SystemExit(f"no metadata package for {manifest_path}") -def partition_argv(targets: Sequence[TestableTarget], partition: str) -> list[str]: +def partition_argv( + targets: Sequence[TestableTarget], partition: str, shard: str | None = None +) -> list[str]: + if shard is not None and (partition != "non-lib" or shard not in NON_LIB_SHARDS): + raise ValueError(f"invalid {partition} shard: {shard}") if partition == "lib": lib_targets = [target for target in targets if target.kind == "lib"] if len(lib_targets) != 1: @@ -128,6 +134,9 @@ def partition_argv(targets: Sequence[TestableTarget], partition: str) -> list[st for target in targets: if target.kind == "lib": continue + owner = "merge" if target.kind == "test" and target.name in MERGE_TEST_TARGETS else "other" + if shard is not None and owner != shard: + continue argv.extend(target.argv_tokens()) return argv raise ValueError(f"unknown partition: {partition}") @@ -183,6 +192,12 @@ def verify_partition(targets: Sequence[TestableTarget]) -> None: "partition argv does not cover metadata targets " f"(missing={missing!r}, extra={extra!r})" ) + sharded_keys = [ + key for shard in NON_LIB_SHARDS + for key in argv_to_keys(partition_argv(targets, "non-lib", shard)) + ] + if len(sharded_keys) != len(set(sharded_keys)) or set(sharded_keys) != non_lib_set: + raise ValueError("non-lib shards do not form a disjoint complete partition") def emit_argv(argv: Sequence[str]) -> None: @@ -203,6 +218,10 @@ def build_parser() -> argparse.ArgumentParser: choices=("lib", "non-lib"), help="required for argv", ) + parser.add_argument( + "--shard", choices=NON_LIB_SHARDS, + help="optional non-lib target shard; omission retains the complete non-lib partition", + ) parser.add_argument( "--manifest-dir", type=Path, @@ -225,7 +244,7 @@ def main(argv: Sequence[str] | None = None) -> int: if args.partition is None: parser.error("argv requires a partition") - partition_argv_tokens = partition_argv(targets, args.partition) + partition_argv_tokens = partition_argv(targets, args.partition, args.shard) if args.partition == "non-lib" and not partition_argv_tokens: sys.stderr.write("non-lib partition is empty\n") return 1 diff --git a/.github/scripts/linux_ci_library_shards.py b/.github/scripts/linux_ci_library_shards.py index 387a5373..bd37b8be 100644 --- a/.github/scripts/linux_ci_library_shards.py +++ b/.github/scripts/linux_ci_library_shards.py @@ -14,7 +14,10 @@ from pathlib import Path from typing import Sequence -SHARDS = ("autopilot", "supervise-core", "supervise-fixtures", "rest") +SHARDS = ( + "autopilot", "supervise-core", "supervise-bound", "supervise-decomposition", "supervise-parent", + "supervise-nested", "supervise-fixtures", "external-agent", "worktree", "rest", +) REST_SAFETY_PREFLIGHTS = ( "process_runner::tests::gemini_online_profile_projects_only_the_trusted_resolver_file", "runtime_adapter::cursor_managed_parent::tests::offline_check_failure_diagnostic_keeps_untrusted_output_private", @@ -95,6 +98,10 @@ def partition_inventory(names: Sequence[str]) -> dict[str, list[str]]: if not names or len(names) != len(set(names)): raise ValueError("library inventory must be nonempty and unique") partitions: dict[str, list[str]] = {shard: [] for shard in SHARDS} + standalone = { + *SAFETY_PREFLIGHTS["supervise-core"], + *SUPERVISE_DIAGNOSTICS, *SUPERVISE_CAUSAL_DIAGNOSTICS, + } for name in sorted(names): # Match the root module, not an arbitrary substring in a test name. if name.startswith("autopilot::"): @@ -103,6 +110,23 @@ def partition_inventory(names: Sequence[str]) -> dict[str, list[str]]: owner = "supervise-fixtures" elif name.startswith("supervise::"): owner = "supervise-core" + # Keep the existing standalone preflights/diagnostics with their + # original owners; split the remaining slow assignment bodies. + if name not in standalone: + if name.startswith("supervise::assignment_execution::bound_parent_turn"): + owner = "supervise-bound" + elif name.startswith("supervise::assignment_execution::decomposition_tests::"): + owner = "supervise-decomposition" + elif name.startswith("supervise::assignment_execution::managed_parent_controller_tests::"): + owner = "supervise-parent" + elif name.startswith("supervise::assignment_execution::") and not name.startswith( + "supervise::assignment_execution::attached_parent_capture_tests::" + ): + owner = "supervise-nested" + elif name.startswith("external_agent::"): + owner = "external-agent" + elif name.startswith("worktree::"): + owner = "worktree" else: owner = "rest" partitions[owner].append(name) diff --git a/.github/scripts/run-linux-ci-cargo-test-partition.sh b/.github/scripts/run-linux-ci-cargo-test-partition.sh index c01a2c30..173167de 100644 --- a/.github/scripts/run-linux-ci-cargo-test-partition.sh +++ b/.github/scripts/run-linux-ci-cargo-test-partition.sh @@ -3,13 +3,15 @@ set -euo pipefail if [[ "$#" -lt 1 || "$#" -gt 2 ]]; then - printf '%s\n' "usage: $0 lib | non-lib" >&2 + printf '%s\n' "usage: $0 lib | non-lib [merge|other]" >&2 exit 1 fi partition="$1" case "${partition}:${2:-}" in - lib:autopilot | lib:supervise-core | lib:supervise-fixtures | lib:rest | non-lib:) ;; + lib:autopilot | lib:supervise-core | lib:supervise-bound | lib:supervise-decomposition | lib:supervise-parent | \ + lib:supervise-nested | lib:supervise-fixtures | lib:external-agent | lib:worktree | lib:rest | \ + non-lib: | non-lib:merge | non-lib:other) ;; *) printf '%s\n' "invalid partition: ${partition}" >&2 exit 1 @@ -25,8 +27,12 @@ python3 "${partition_py}" verify --manifest-dir "${repo_root}" partition_argv_output="" argv_status=0 +shard_args=() +if [[ "${partition}" == non-lib && -n "${2:-}" ]]; then + shard_args=(--shard "$2") +fi partition_argv_output="$( - python3 "${partition_py}" argv "${partition}" --manifest-dir "${repo_root}" + python3 "${partition_py}" argv "${partition}" "${shard_args[@]}" --manifest-dir "${repo_root}" )" || argv_status=$? if [[ "${argv_status}" -ne 0 ]]; then exit "${argv_status}" diff --git a/.github/scripts/test_linux_ci_library_shards.py b/.github/scripts/test_linux_ci_library_shards.py index bdaf6327..7191aa2c 100644 --- a/.github/scripts/test_linux_ci_library_shards.py +++ b/.github/scripts/test_linux_ci_library_shards.py @@ -22,10 +22,11 @@ class LinuxCiLibraryShardTests(unittest.TestCase): def setUpClass(cls) -> None: cls.shards = load_library_shards() - def test_four_shard_names(self) -> None: + def test_shard_names(self) -> None: self.assertEqual( self.shards.SHARDS, - ("autopilot", "supervise-core", "supervise-fixtures", "rest"), + ("autopilot", "supervise-core", "supervise-bound", "supervise-decomposition", "supervise-parent", + "supervise-nested", "supervise-fixtures", "external-agent", "worktree", "rest"), ) def test_mixed_inventory_disjoint_complete_union(self) -> None: @@ -38,7 +39,7 @@ def test_mixed_inventory_disjoint_complete_union(self) -> None: parts = self.shards.partition_inventory(inventory) self.assertEqual(parts["autopilot"], ["autopilot::alpha"]) self.assertEqual( - parts["supervise-core"], ["supervise::assignment_execution::beta"] + parts["supervise-nested"], ["supervise::assignment_execution::beta"] ) self.assertEqual(parts["supervise-fixtures"], ["supervise::tests::gamma"]) self.assertEqual(parts["rest"], ["runtime_adapter::delta"]) @@ -46,6 +47,39 @@ def test_mixed_inventory_disjoint_complete_union(self) -> None: self.assertEqual(sorted(flattened), sorted(inventory)) self.assertEqual(len(flattened), len(set(flattened))) + def test_new_shards_refine_the_old_partition_without_loss_or_overlap(self) -> None: + inventory = [ + *self.shards.SUPERVISE_DIAGNOSTICS, + *self.shards.SUPERVISE_CAUSAL_DIAGNOSTICS, + *self.shards.SAFETY_PREFLIGHTS["supervise-core"], + *self.shards.REST_SAFETY_PREFLIGHTS, + "autopilot::new", "supervise::tests::new", "supervise::future::new", + "supervise::assignment_execution::decomposition_tests::new", + "supervise::assignment_execution::managed_parent_controller_tests::new", + "supervise::assignment_execution::nested_worker::new", + "supervise::assignment_execution::attached_parent_capture_tests::new", + "supervise::assignment_execution::bound_parent_turn_tests::new", + "external_agent::new", "worktree::new", "future_module::new", + "future::external_agent::lookalike", "worktree_extra::lookalike", + ] + inventory = sorted(set(inventory)) + parts = self.shards.partition_inventory(inventory) + self.assertEqual(parts, self.shards.partition_inventory(list(reversed(inventory)))) + old_core = sorted(n for n in inventory if n.startswith("supervise::") + and not n.startswith("supervise::tests::")) + old_rest = sorted(n for n in inventory if not n.startswith(("autopilot::", "supervise::"))) + self.assertEqual(sorted(n for key in ("supervise-core", "supervise-bound", "supervise-decomposition", + "supervise-parent", "supervise-nested") + for n in parts[key]), old_core) + self.assertEqual(sorted(n for key in ("external-agent", "worktree", "rest") + for n in parts[key]), old_rest) + flattened = [n for group in parts.values() for n in group] + self.assertEqual(sorted(flattened), inventory) + self.assertEqual(len(flattened), len(set(flattened))) + for shard, names in self.shards.SAFETY_PREFLIGHTS.items(): + self.assertTrue(set(names) <= set(parts[shard])) + self.shards.validate_mandatory_supervise_ownership(inventory, parts) + def test_lookalike_substrings_go_to_rest(self) -> None: inventory = [ "host::autopilot::lookalike", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9163b4dc..9afb7028 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,7 +53,7 @@ jobs: strategy: fail-fast: false matrix: - shard: [autopilot, supervise-core, supervise-fixtures, rest] + shard: [autopilot, supervise-core, supervise-bound, supervise-decomposition, supervise-parent, supervise-nested, supervise-fixtures, external-agent, worktree, rest] steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -307,10 +307,14 @@ jobs: run: bash .github/scripts/run-linux-ci-cargo-test-partition.sh lib "${{ matrix.shard }}" linux-integration: - name: Linux integration tests + name: Linux integration tests (${{ matrix.shard }}) needs: nix-package runs-on: ubuntu-latest timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + shard: [merge, other] steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -518,7 +522,7 @@ jobs: run: bash .github/scripts/install-github-hosted-ci-codex.sh - name: Run integration and binary tests - run: bash .github/scripts/run-linux-ci-cargo-test-partition.sh non-lib + run: bash .github/scripts/run-linux-ci-cargo-test-partition.sh non-lib "${{ matrix.shard }}" linux-gate: name: Linux fmt, check, clippy, and test diff --git a/scripts/tests/test_linux_ci_cargo_test_partition.py b/scripts/tests/test_linux_ci_cargo_test_partition.py index 1e157934..92dcc918 100644 --- a/scripts/tests/test_linux_ci_cargo_test_partition.py +++ b/scripts/tests/test_linux_ci_cargo_test_partition.py @@ -57,6 +57,42 @@ def test_custom_build_target_is_excluded(self) -> None: targets = partition.collect_testable_targets(package) self.assertEqual([target.kind for target in targets], ["lib"]) + def test_non_lib_shards_cover_original_targets_once_including_future_targets(self) -> None: + package = sample_package() + package["targets"].extend([ + {"kind": ["test"], "name": "pr_issue_cli"}, + {"kind": ["test"], "name": "future_integration"}, + {"kind": ["example"], "name": "merge_cli"}, + ]) + targets = partition.collect_testable_targets(package) + partition.verify_partition(targets) + old = partition.argv_to_keys(partition.partition_argv(targets, "non-lib")) + groups = { + shard: partition.argv_to_keys(partition.partition_argv(targets, "non-lib", shard)) + for shard in partition.NON_LIB_SHARDS + } + self.assertEqual(groups["merge"], [("test", "merge_cli"), ("test", "pr_issue_cli")]) + self.assertIn(("example", "merge_cli"), groups["other"]) + self.assertIn(("test", "future_integration"), groups["other"]) + flattened = [key for group in groups.values() for key in group] + self.assertCountEqual(flattened, old) + self.assertEqual(len(flattened), len(set(flattened))) + + def test_invalid_non_lib_shard_refused(self) -> None: + targets = partition.collect_testable_targets(sample_package()) + for group, shard in (("non-lib", "unknown"), ("lib", "merge")): + with self.subTest(group=group, shard=shard), self.assertRaises(ValueError): + partition.partition_argv(targets, group, shard) + + def test_integration_matrix_requires_every_target_shard(self) -> None: + source = WORKFLOW.read_text(encoding="utf-8") + job = source.split(" linux-integration:", 1)[1].split(" linux-gate:", 1)[0] + self.assertIn("shard: [merge, other]", job) + self.assertIn("fail-fast: false", job) + self.assertIn("timeout-minutes: 45", job) + self.assertNotIn("continue-on-error", job) + self.assertIn('non-lib "${{ matrix.shard }}"', job) + def test_unsupported_target_kind_fails_closed(self) -> None: package = { "targets": [ diff --git a/scripts/tests/test_linux_ci_library_shards.py b/scripts/tests/test_linux_ci_library_shards.py index 76dc4563..0b956a39 100644 --- a/scripts/tests/test_linux_ci_library_shards.py +++ b/scripts/tests/test_linux_ci_library_shards.py @@ -684,7 +684,11 @@ def test_wrapper_keeps_metadata_verification_and_delegation(self): " marker.touch()\n" "else:\n" " assert marker.exists()\n" - " print('--lib' if sys.argv[2] == 'lib' else '--bin\\nmaco')\n", + " if '--shard' in sys.argv:\n" + " shard = sys.argv[sys.argv.index('--shard') + 1]\n" + " print('--test\\nmerge_cli' if shard == 'merge' else '--bench\\ncoordination')\n" + " else:\n" + " print('--lib' if sys.argv[2] == 'lib' else '--bin\\nmaco')\n", encoding="utf-8", ) (scripts / "run-linux-all-tests-in-delegated-user-scope.sh").write_text( @@ -703,6 +707,12 @@ def test_wrapper_keeps_metadata_verification_and_delegation(self): ) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.stdout.splitlines(), ["cargo", "test", "--locked", "--bin", "maco"]) + for shard, flags in (("merge", ["--test", "merge_cli"]), ("other", ["--bench", "coordination"])): + result = subprocess.run( + ["bash", str(wrapper), "non-lib", shard], capture_output=True, text=True + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.splitlines(), ["cargo", "test", "--locked", *flags]) for args in [["lib"], ["lib", "unknown"], ["non-lib", "rest"]]: result = subprocess.run(["bash", str(wrapper), *args], capture_output=True) self.assertNotEqual(result.returncode, 0) @@ -748,15 +758,19 @@ def test_exact_shards_cover_real_harness_and_preserve_ignored_tests(self): mod assignment_execution { mod decomposition_tests { #[test] fn auditor_runtime_override_preserves_active_heterogeneous_lens_contract() {} + #[test] fn new_decomposition_body() {} } mod bound_parent_turn_tests { // Empty body verifies wrapper selection only, not evidence semantics. #[test] fn bound_nested_evidence_rejects_forged_widened_failed_and_cancelled_results() {} + #[test] fn new_bound_parent_body() {} } mod managed_parent_controller_tests { // Empty body verifies wrapper selection only, not worker cleanup semantics. #[test] fn managed_cycle_isolated_workers_overlap_and_import_exact_union() {} + #[test] fn new_managed_parent_body() {} } + mod nested_driver_tests { #[test] fn new_nested_body() {} } mod attached_parent_capture_tests { // Empty body verifies wrapper selection only, not real ownership semantics. #[test] fn attached_capture_bookkeeping_and_shutdown_errors_retain_owners() {} @@ -767,6 +781,8 @@ def test_exact_shards_cover_real_harness_and_preserve_ignored_tests(self): mod autopilot { #[test] fn nested() {} } #[test] fn supervise_lookalike() {} } +mod external_agent { #[test] fn new_external_body() {} } +mod worktree { #[test] fn new_worktree_body() {} } mod process_runner { mod tests { #[test] fn gemini_online_profile_projects_only_the_trusted_resolver_file() {} @@ -787,7 +803,7 @@ def test_exact_shards_cover_real_harness_and_preserve_ignored_tests(self): subprocess.run(["rustc", "--test", str(fixture), "-o", str(binary)], check=True) runner = [str(binary)] inventory = shards.enumerate_tests(runner) - self.assertEqual(len(inventory), 20) + self.assertEqual(len(inventory), 26) partitions = shards.partition_inventory(inventory) observed = [] for shard, names in partitions.items(): @@ -813,7 +829,7 @@ def test_exact_shards_cover_real_harness_and_preserve_ignored_tests(self): ) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertIn(f'"selected": "{shard}"', result.stdout) - self.assertIn('"total": 20', result.stdout) + self.assertIn('"total": 26', result.stdout) if __name__ == "__main__": From 72be2a779367087c3f39ad7d8785834cf868398d Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:53:03 +0900 Subject: [PATCH 03/27] Add strict dated operator pricing contract validation Add a pure versioned validator for dated operator pricing metadata and explicit flat token-rate maps. - Bind rates, source attribution and exact applicability to deterministic digests; reject unsupported or mismatched input. - Apply the same validation to explicit zero rates. No catalog prices are added or invented. - This is the standalone validation slice (A) of #754; supervised admission, resume and reporting integration follow. Validation: formatting, 18 exact-name tests and all-target compilation passed; Clippy and the full suites run in this PR's CI. Refs #754, #677 and #678. --- src/llm/mod.rs | 1 + src/llm/pricing_contract.rs | 374 +++++++++++++++++++++++++++ src/llm/pricing_contract/tests.rs | 414 ++++++++++++++++++++++++++++++ src/llm/provider.rs | 1 + 4 files changed, 790 insertions(+) create mode 100644 src/llm/pricing_contract.rs create mode 100644 src/llm/pricing_contract/tests.rs diff --git a/src/llm/mod.rs b/src/llm/mod.rs index 047fd1bd..14fa8d06 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -5,6 +5,7 @@ //! worktrees and path claims. Nothing in this boundary edits repository files. pub mod fake; +pub mod pricing_contract; pub mod prompt; pub mod provider; pub mod transcript; diff --git a/src/llm/pricing_contract.rs b/src/llm/pricing_contract.rs new file mode 100644 index 00000000..b588b773 --- /dev/null +++ b/src/llm/pricing_contract.rs @@ -0,0 +1,374 @@ +//! Pure validation of operator-supplied flat token prices and their applicability. +//! +//! No prices, clock, account discovery, or source retrieval are supplied here. +//! A validated contract describes an estimate, not an invoice or quota conversion. +//! Runtime admission and frozen-run integration remain the caller's responsibility. + +use super::provider::ModelPricing; +use crate::{artifacts::state_auth::sha256_hex, runtime_adapter::RuntimeId}; +use serde::{de, Deserialize, Deserializer, Serialize}; +use std::{collections::BTreeMap, fmt}; +use thiserror::Error; + +pub const OPERATOR_PRICING_CONTRACT_SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct OperatorPricingContract { + pub schema_version: u32, + pub contract_id: String, + pub revision: String, + pub effective_date: String, + /// Inclusive UTC calendar date, in YYYY-MM-DD format. + pub valid_through: String, + pub source_reference: String, + pub source_attribution: String, + pub currency: String, + pub unit: String, + /// Only `flat_input_output` is supported. Cache, tier and other billing + /// dimensions require evidence that the two-rate table cannot represent. + pub billing: String, + pub scopes: Vec, + /// SHA-256 of the normalized, exact operator `model_pricing` map. + pub price_map_sha256: String, +} + +/// An exact applicability tuple. Identifiers are labels, never credentials. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct PricingScope { + pub runtime: RuntimeId, + pub model: String, + pub account_id: String, + pub plan_id: String, +} + +pub struct PricingContext<'a> { + pub runtime: RuntimeId, + /// Callers must supply an authenticated, unambiguous usage identity. + pub model: &'a str, + pub account_id: &'a str, + pub plan_id: &'a str, + /// Trusted UTC calendar date supplied by the caller at each use. + pub as_of: &'a str, +} + +/// Immutable rates and provenance; this type cannot be deserialized unchecked. +#[derive(Debug, Clone)] +pub struct ValidatedPricingContract { + contract: OperatorPricingContract, + prices: BTreeMap, + content_sha256: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum PricingUnavailable { + #[error("pricing_contract metadata is missing")] + MissingContract, + #[error("unsupported pricing_contract schema version {0}")] + UnsupportedVersion(u32), + #[error("pricing_contract {0} must be nonempty, trimmed text without control characters")] + InvalidText(&'static str), + #[error("pricing_contract {0} must be a valid YYYY-MM-DD calendar date")] + InvalidDate(&'static str), + #[error("pricing_contract valid_through precedes effective_date")] + InvalidValidityWindow, + #[error("unsupported pricing_contract {0}; only USD, per_million_tokens, flat_input_output pricing is supported")] + UnsupportedBasis(&'static str), + #[error("model_pricing must contain explicit rates")] + EmptyPriceMap, + #[error("invalid model_pricing key '{0}'")] + InvalidModel(String), + #[error("model_pricing has duplicate model '{0}' after trimming")] + DuplicateModel(String), + #[error("model_pricing for '{0}' must contain finite nonnegative rates")] + InvalidRates(String), + #[error("pricing_contract must declare at least one exact applicability scope")] + EmptyScopes, + #[error("pricing_contract scope identifiers cannot contain wildcards")] + WildcardScope, + #[error("pricing_contract contains a duplicate applicability scope")] + DuplicateScope, + #[error("pricing_contract scope has no model_pricing entry for '{0}'")] + UnpricedScope(String), + #[error("model_pricing entry '{0}' has no pricing_contract scope")] + MissingScope(String), + #[error("pricing_contract price_map_sha256 must be 64 lowercase hexadecimal characters")] + InvalidDigest, + #[error("pricing_contract price_map_sha256 does not match normalized model_pricing")] + DigestMismatch, + #[error("pricing_contract is not yet effective")] + NotYetEffective, + #[error("pricing_contract has expired")] + Expired, + #[error("no pricing_contract scope matches runtime/model/account/plan exactly")] + ScopeMismatch, + #[error("pricing_contract serialization failed: {0}")] + Serialization(String), +} + +/// Read a rate map without silently accepting duplicate keys or extra billing +/// fields. The returned map uses the same trimmed keys as authored plans. +pub fn parse_price_map(bytes: &[u8]) -> Result, serde_json::Error> { + struct PriceMap(BTreeMap); + impl<'de> Deserialize<'de> for PriceMap { + fn deserialize>(deserializer: D) -> Result { + struct Visitor; + impl<'de> de::Visitor<'de> for Visitor { + type Value = PriceMap; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("an explicit model_pricing map with unique model keys") + } + + fn visit_map>( + self, + mut map: A, + ) -> Result { + let mut prices = BTreeMap::new(); + while let Some((model, pricing)) = map.next_entry::()? { + let model = model.trim().to_string(); + if prices.insert(model.clone(), pricing).is_some() { + return Err(de::Error::custom(PricingUnavailable::DuplicateModel( + model, + ))); + } + } + normalize_price_map(&prices) + .map(PriceMap) + .map_err(de::Error::custom) + } + } + deserializer.deserialize_map(Visitor) + } + } + serde_json::from_slice::(bytes).map(|map| map.0) +} + +pub fn normalize_price_map( + prices: &BTreeMap, +) -> Result, PricingUnavailable> { + if prices.is_empty() { + return Err(PricingUnavailable::EmptyPriceMap); + } + let mut normalized = BTreeMap::new(); + for (model, pricing) in prices { + let model = model.trim(); + if model.is_empty() || model.chars().any(char::is_control) || model.contains(['*', '?']) { + return Err(PricingUnavailable::InvalidModel(model.to_string())); + } + if !pricing.is_valid() { + return Err(PricingUnavailable::InvalidRates(model.to_string())); + } + let mut pricing = *pricing; + // Both IEEE zero representations denote the same explicit rate. + if pricing.input_usd_per_million_tokens == 0.0 { + pricing.input_usd_per_million_tokens = 0.0; + } + if pricing.output_usd_per_million_tokens == 0.0 { + pricing.output_usd_per_million_tokens = 0.0; + } + if normalized.insert(model.to_string(), pricing).is_some() { + return Err(PricingUnavailable::DuplicateModel(model.to_string())); + } + } + Ok(normalized) +} + +/// Canonical JSON object with sorted, trimmed model keys, struct field order, +/// finite f64 rates, and positive zero; hashed as UTF-8 without a trailing LF. +pub fn price_map_sha256( + prices: &BTreeMap, +) -> Result { + content_sha256(&normalize_price_map(prices)?) +} + +impl OperatorPricingContract { + pub fn validate( + &self, + prices: &BTreeMap, + ) -> Result { + if self.schema_version != OPERATOR_PRICING_CONTRACT_SCHEMA_VERSION { + return Err(PricingUnavailable::UnsupportedVersion(self.schema_version)); + } + for (field, text) in [ + ("contract_id", self.contract_id.as_str()), + ("revision", &self.revision), + ("source_reference", &self.source_reference), + ("source_attribution", &self.source_attribution), + ] { + validate_text(field, text)?; + } + validate_date("effective_date", &self.effective_date)?; + validate_date("valid_through", &self.valid_through)?; + if self.valid_through < self.effective_date { + return Err(PricingUnavailable::InvalidValidityWindow); + } + for (field, actual, supported) in [ + ("currency", self.currency.as_str(), "USD"), + ("unit", &self.unit, "per_million_tokens"), + ("billing", &self.billing, "flat_input_output"), + ] { + if actual != supported { + return Err(PricingUnavailable::UnsupportedBasis(field)); + } + } + let prices = normalize_price_map(prices)?; + if self.scopes.is_empty() { + return Err(PricingUnavailable::EmptyScopes); + } + for (index, scope) in self.scopes.iter().enumerate() { + for (field, text) in [ + ("scope.model", scope.model.as_str()), + ("scope.account_id", &scope.account_id), + ("scope.plan_id", &scope.plan_id), + ] { + validate_text(field, text)?; + if text.contains(['*', '?']) { + return Err(PricingUnavailable::WildcardScope); + } + } + if self.scopes[..index].contains(scope) { + return Err(PricingUnavailable::DuplicateScope); + } + if !prices.contains_key(&scope.model) { + return Err(PricingUnavailable::UnpricedScope(scope.model.clone())); + } + } + for model in prices.keys() { + if !self.scopes.iter().any(|scope| scope.model == *model) { + return Err(PricingUnavailable::MissingScope(model.clone())); + } + } + if self.price_map_sha256.len() != 64 + || !self + .price_map_sha256 + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(PricingUnavailable::InvalidDigest); + } + if self.price_map_sha256 != content_sha256(&prices)? { + return Err(PricingUnavailable::DigestMismatch); + } + let mut contract = self.clone(); + contract.scopes.sort_by(|left, right| { + ( + left.runtime.as_str(), + &left.model, + &left.account_id, + &left.plan_id, + ) + .cmp(&( + right.runtime.as_str(), + &right.model, + &right.account_id, + &right.plan_id, + )) + }); + Ok(ValidatedPricingContract { + content_sha256: content_sha256(&contract)?, + contract, + prices, + }) + } +} + +impl ValidatedPricingContract { + pub fn contract(&self) -> &OperatorPricingContract { + &self.contract + } + + pub fn prices(&self) -> &BTreeMap { + &self.prices + } + + /// Binds all metadata and, through price_map_sha256, every normalized rate. + pub fn content_sha256(&self) -> &str { + &self.content_sha256 + } + + /// Recheck the date and exact scope on each use; never fall back to catalog + /// placeholders, requested identity, another account, or another plan. + pub fn price_for( + &self, + context: &PricingContext<'_>, + ) -> Result { + validate_date("as_of", context.as_of)?; + if context.as_of < self.contract.effective_date.as_str() { + return Err(PricingUnavailable::NotYetEffective); + } + if context.as_of > self.contract.valid_through.as_str() { + return Err(PricingUnavailable::Expired); + } + if !self.contract.scopes.iter().any(|scope| { + scope.runtime == context.runtime + && scope.model == context.model + && scope.account_id == context.account_id + && scope.plan_id == context.plan_id + }) { + return Err(PricingUnavailable::ScopeMismatch); + } + self.prices + .get(context.model) + .copied() + .ok_or_else(|| PricingUnavailable::UnpricedScope(context.model.to_string())) + } +} + +pub fn resolve_operator_pricing( + contract: Option<&OperatorPricingContract>, + prices: &BTreeMap, + context: &PricingContext<'_>, +) -> Result { + contract + .ok_or(PricingUnavailable::MissingContract)? + .validate(prices)? + .price_for(context) +} + +fn content_sha256(value: &impl Serialize) -> Result { + serde_json::to_vec(value) + .map(|bytes| sha256_hex(&bytes)) + .map_err(|error| PricingUnavailable::Serialization(error.to_string())) +} + +fn validate_text(field: &'static str, text: &str) -> Result<(), PricingUnavailable> { + if text.is_empty() || text.trim() != text || text.chars().any(char::is_control) { + return Err(PricingUnavailable::InvalidText(field)); + } + Ok(()) +} + +fn validate_date(field: &'static str, date: &str) -> Result<(), PricingUnavailable> { + let invalid = || PricingUnavailable::InvalidDate(field); + let bytes = date.as_bytes(); + if bytes.len() != 10 + || bytes[4] != b'-' + || bytes[7] != b'-' + || bytes + .iter() + .enumerate() + .any(|(index, byte)| index != 4 && index != 7 && !byte.is_ascii_digit()) + { + return Err(invalid()); + } + let year: u32 = date[..4].parse().map_err(|_| invalid())?; + let month: u32 = date[5..7].parse().map_err(|_| invalid())?; + let day: u32 = date[8..].parse().map_err(|_| invalid())?; + let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + let days = match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if leap => 29, + 2 => 28, + _ => return Err(invalid()), + }; + if year == 0 || day == 0 || day > days { + return Err(invalid()); + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/src/llm/pricing_contract/tests.rs b/src/llm/pricing_contract/tests.rs new file mode 100644 index 00000000..54bca575 --- /dev/null +++ b/src/llm/pricing_contract/tests.rs @@ -0,0 +1,414 @@ +use super::*; +use serde_json::json; + +// Synthetic rates and identities exercise the contract, not a real price table. +fn fixture() -> (OperatorPricingContract, BTreeMap) { + let prices = BTreeMap::from([( + "fixture-model".to_string(), + ModelPricing { + input_usd_per_million_tokens: 1.0, + output_usd_per_million_tokens: 2.0, + }, + )]); + let contract = OperatorPricingContract { + schema_version: 1, + contract_id: "fixture-contract".to_string(), + revision: "fixture-r1".to_string(), + effective_date: "2026-10-01".to_string(), + valid_through: "2026-10-31".to_string(), + source_reference: "fixture:operator-rate-card".to_string(), + source_attribution: "Synthetic test fixture".to_string(), + currency: "USD".to_string(), + unit: "per_million_tokens".to_string(), + billing: "flat_input_output".to_string(), + scopes: vec![PricingScope { + runtime: RuntimeId::Codex, + model: "fixture-model".to_string(), + account_id: "fixture-account".to_string(), + plan_id: "fixture-plan".to_string(), + }], + price_map_sha256: price_map_sha256(&prices).unwrap(), + }; + (contract, prices) +} + +fn context() -> PricingContext<'static> { + PricingContext { + runtime: RuntimeId::Codex, + model: "fixture-model", + account_id: "fixture-account", + plan_id: "fixture-plan", + as_of: "2026-10-29", + } +} + +fn rejects_contract( + change: impl FnOnce(&mut OperatorPricingContract), + expected: PricingUnavailable, +) { + let (mut contract, prices) = fixture(); + change(&mut contract); + assert_eq!(contract.validate(&prices).unwrap_err(), expected); +} + +#[test] +fn dated_contract_requires_source_scope_and_valid_rates() { + let (mut contract, mut prices) = fixture(); + assert_eq!( + resolve_operator_pricing(Some(&contract), &prices, &context()).unwrap(), + prices["fixture-model"] + ); + prices.values_mut().for_each(|price| { + price.input_usd_per_million_tokens = 0.0; + price.output_usd_per_million_tokens = 0.0; + }); + contract.price_map_sha256 = price_map_sha256(&prices).unwrap(); + let zero = resolve_operator_pricing(Some(&contract), &prices, &context()).unwrap(); + assert_eq!(zero.input_usd_per_million_tokens, 0.0); + assert_eq!(zero.output_usd_per_million_tokens, 0.0); + + rejects_contract( + |contract| contract.schema_version = 2, + PricingUnavailable::UnsupportedVersion(2), + ); + for field in [ + "contract_id", + "revision", + "source_reference", + "source_attribution", + ] { + let (contract, prices) = fixture(); + let mut value = serde_json::to_value(contract).unwrap(); + value[field] = json!(""); + let contract: OperatorPricingContract = serde_json::from_value(value).unwrap(); + assert_eq!( + contract.validate(&prices).unwrap_err(), + PricingUnavailable::InvalidText(field) + ); + } + rejects_contract( + |contract| contract.source_reference = " source ".to_string(), + PricingUnavailable::InvalidText("source_reference"), + ); + rejects_contract( + |contract| contract.source_attribution = "source\nextra".to_string(), + PricingUnavailable::InvalidText("source_attribution"), + ); + for (field, value) in [ + ("currency", "EUR"), + ("unit", "per_token"), + ("billing", "cached_input"), + ("billing", "tiered"), + ("billing", "separate_reasoning"), + ("billing", "subscription_quota"), + ] { + let (contract, prices) = fixture(); + let mut wire = serde_json::to_value(contract).unwrap(); + wire[field] = json!(value); + let contract: OperatorPricingContract = serde_json::from_value(wire).unwrap(); + assert_eq!( + contract.validate(&prices).unwrap_err(), + PricingUnavailable::UnsupportedBasis(field) + ); + } + for invalid in [-1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + for input in [true, false] { + let (contract, mut prices) = fixture(); + let rate = prices.get_mut("fixture-model").unwrap(); + if input { + rate.input_usd_per_million_tokens = invalid; + } else { + rate.output_usd_per_million_tokens = invalid; + } + assert_eq!( + contract.validate(&prices).unwrap_err(), + PricingUnavailable::InvalidRates("fixture-model".to_string()) + ); + } + } +} + +#[test] +fn strict_contract_and_price_map_reject_unknown_or_duplicate_fields() { + let (contract, _) = fixture(); + let original = serde_json::to_value(&contract).unwrap(); + let mut extra = original.clone(); + extra["extra"] = json!(true); + assert!(serde_json::from_value::(extra).is_err()); + let mut extra = original.clone(); + extra["scopes"][0]["extra"] = json!(true); + assert!(serde_json::from_value::(extra).is_err()); + let mut invalid_runtime = original.clone(); + invalid_runtime["scopes"][0]["runtime"] = json!("*"); + assert!(serde_json::from_value::(invalid_runtime).is_err()); + for field in ["source_reference", "scopes", "price_map_sha256"] { + let mut missing = original.clone(); + missing.as_object_mut().unwrap().remove(field); + assert!(serde_json::from_value::(missing).is_err()); + } + let encoded = serde_json::to_string(&contract).unwrap(); + let duplicate = format!(r#"{{"schema_version":1,{}"#, &encoded[1..]); + assert!(serde_json::from_str::(&duplicate).is_err()); + + let row = r#"{"input_usd_per_million_tokens":1.0,"output_usd_per_million_tokens":2.0}"#; + let valid = format!(r#"{{" fixture-model ":{row}}}"#); + assert!(parse_price_map(valid.as_bytes()) + .unwrap() + .contains_key("fixture-model")); + for extra in [ + "cached_input_usd_per_million_tokens", + "tier", + "reasoning_rate", + ] { + let mut value: serde_json::Value = serde_json::from_str(&valid).unwrap(); + value[" fixture-model "][extra] = json!(0); + assert!(parse_price_map(&serde_json::to_vec(&value).unwrap()).is_err()); + } + for keys in [ + r#""fixture-model","fixture-model""#, + r#""fixture-model"," fixture-model ""#, + ] { + let (first, second) = keys.split_once(',').unwrap(); + let duplicate = format!("{{{first}:{row},{second}:{row}}}"); + assert!(parse_price_map(duplicate.as_bytes()).is_err()); + } + let duplicate_rate = br#"{"fixture-model":{"input_usd_per_million_tokens":1,"input_usd_per_million_tokens":0,"output_usd_per_million_tokens":2}}"#; + assert!(parse_price_map(duplicate_rate).is_err()); + for invalid in [b"{}".as_slice(), b"null", b"[]", b"{} trailing"] { + assert!(parse_price_map(invalid).is_err()); + } +} + +#[test] +fn normalized_digest_binds_exact_rates_without_key_or_zero_ambiguity() { + let (contract, prices) = fixture(); + let padded = BTreeMap::from([(" fixture-model ".to_string(), prices["fixture-model"])]); + assert_eq!( + price_map_sha256(&padded).unwrap(), + contract.price_map_sha256 + ); + assert_eq!(contract.validate(&padded).unwrap().prices(), &prices); + let mut duplicate = padded; + duplicate.insert("fixture-model".to_string(), prices["fixture-model"]); + assert_eq!( + normalize_price_map(&duplicate).unwrap_err(), + PricingUnavailable::DuplicateModel("fixture-model".to_string()) + ); + for model in ["", " ", "fixture\nmodel", "fixture-*", "fixture-?"] { + let invalid = BTreeMap::from([(model.to_string(), prices["fixture-model"])]); + assert!(matches!( + normalize_price_map(&invalid), + Err(PricingUnavailable::InvalidModel(_)) + )); + } + let mut negative_zero = prices.clone(); + negative_zero + .get_mut("fixture-model") + .unwrap() + .input_usd_per_million_tokens = -0.0; + let mut positive_zero = negative_zero.clone(); + positive_zero + .get_mut("fixture-model") + .unwrap() + .input_usd_per_million_tokens = 0.0; + assert_eq!( + price_map_sha256(&negative_zero).unwrap(), + price_map_sha256(&positive_zero).unwrap() + ); + let mut changed = prices.clone(); + changed + .get_mut("fixture-model") + .unwrap() + .output_usd_per_million_tokens = 3.0; + assert_eq!( + contract.validate(&changed).unwrap_err(), + PricingUnavailable::DigestMismatch + ); + rejects_contract( + |contract| contract.price_map_sha256 = "not-a-digest".to_string(), + PricingUnavailable::InvalidDigest, + ); + rejects_contract( + |contract| contract.price_map_sha256 = "A".repeat(64), + PricingUnavailable::InvalidDigest, + ); +} + +#[test] +fn dates_use_real_calendar_and_inclusive_utc_validity_bounds() { + let (contract, prices) = fixture(); + let validated = contract.validate(&prices).unwrap(); + for as_of in ["2026-10-01", "2026-10-31"] { + let mut context = context(); + context.as_of = as_of; + assert!(validated.price_for(&context).is_ok()); + } + for (as_of, expected) in [ + ("2026-09-30", PricingUnavailable::NotYetEffective), + ("2026-11-01", PricingUnavailable::Expired), + ("2026-02-30", PricingUnavailable::InvalidDate("as_of")), + ] { + let mut context = context(); + context.as_of = as_of; + assert_eq!(validated.price_for(&context).unwrap_err(), expected); + } + for date in ["2024-02-29", "2000-02-29", "2400-02-29", "0001-01-01"] { + let mut contract = contract.clone(); + contract.effective_date = date.to_string(); + contract.valid_through = date.to_string(); + assert!(contract.validate(&prices).is_ok()); + } + for date in [ + "2026-02-29", + "1900-02-29", + "2100-02-29", + "2026-04-31", + "0000-01-01", + "2026-00-01", + "2026-13-01", + "2026-01-00", + "2026-1-01", + "2026-01-01T00:00:00Z", + "2026-01-01", + ] { + rejects_contract( + |contract| contract.effective_date = date.to_string(), + PricingUnavailable::InvalidDate("effective_date"), + ); + } + rejects_contract( + |contract| contract.valid_through = "2026-02-30".to_string(), + PricingUnavailable::InvalidDate("valid_through"), + ); + rejects_contract( + |contract| contract.valid_through = "2026-09-30".to_string(), + PricingUnavailable::InvalidValidityWindow, + ); +} + +#[test] +fn scopes_require_exact_covered_runtime_model_account_and_plan() { + let (contract, prices) = fixture(); + let validated = contract.validate(&prices).unwrap(); + let mut wrong_runtime = context(); + wrong_runtime.runtime = RuntimeId::Grok; + let mut wrong_model = context(); + wrong_model.model = "other-fixture-model"; + let mut wrong_account = context(); + wrong_account.account_id = "other-account"; + let mut wrong_plan = context(); + wrong_plan.plan_id = "other-plan"; + let mut padded_model = context(); + padded_model.model = " fixture-model "; + for wrong in [ + wrong_runtime, + wrong_model, + wrong_account, + wrong_plan, + padded_model, + ] { + assert_eq!( + validated.price_for(&wrong).unwrap_err(), + PricingUnavailable::ScopeMismatch + ); + } + rejects_contract( + |contract| contract.scopes.clear(), + PricingUnavailable::EmptyScopes, + ); + rejects_contract( + |contract| contract.scopes.push(contract.scopes[0].clone()), + PricingUnavailable::DuplicateScope, + ); + rejects_contract( + |contract| contract.scopes[0].account_id.clear(), + PricingUnavailable::InvalidText("scope.account_id"), + ); + rejects_contract( + |contract| contract.scopes[0].plan_id = "*".to_string(), + PricingUnavailable::WildcardScope, + ); + rejects_contract( + |contract| contract.scopes[0].model = "missing-model".to_string(), + PricingUnavailable::UnpricedScope("missing-model".to_string()), + ); + let mut extra = prices.clone(); + extra.insert("extra-model".to_string(), prices["fixture-model"]); + assert_eq!( + contract.validate(&extra).unwrap_err(), + PricingUnavailable::MissingScope("extra-model".to_string()) + ); +} + +#[test] +fn immutable_view_binds_provenance_and_rechecks_expiry() { + let (mut contract, mut prices) = fixture(); + let frozen = contract.validate(&prices).unwrap(); + let original_rate = frozen.price_for(&context()).unwrap(); + for field in [ + "source_reference", + "source_attribution", + "revision", + "contract_id", + ] { + let mut changed = serde_json::to_value(&contract).unwrap(); + changed[field] = json!("changed-fixture"); + let changed: OperatorPricingContract = serde_json::from_value(changed).unwrap(); + assert_ne!( + changed.validate(&prices).unwrap().content_sha256(), + frozen.content_sha256() + ); + } + contract.scopes.push(PricingScope { + account_id: "second-fixture-account".to_string(), + ..contract.scopes[0].clone() + }); + let ordered = contract.validate(&prices).unwrap(); + contract.scopes.reverse(); + assert_eq!( + contract.validate(&prices).unwrap().content_sha256(), + ordered.content_sha256() + ); + prices + .get_mut("fixture-model") + .unwrap() + .input_usd_per_million_tokens = 4.0; + assert_eq!( + contract.validate(&prices).unwrap_err(), + PricingUnavailable::DigestMismatch + ); + contract.price_map_sha256 = price_map_sha256(&prices).unwrap(); + assert_ne!( + contract.validate(&prices).unwrap().content_sha256(), + ordered.content_sha256() + ); + assert_eq!(frozen.price_for(&context()).unwrap(), original_rate); + assert_eq!(frozen.contract().revision, "fixture-r1"); + let mut expired = context(); + expired.as_of = "2026-11-01"; + assert_eq!( + frozen.price_for(&expired).unwrap_err(), + PricingUnavailable::Expired + ); +} + +#[test] +fn missing_contract_or_unknown_identity_never_uses_catalog_prices() { + let (contract, prices) = fixture(); + assert_eq!( + resolve_operator_pricing(None, &prices, &context()).unwrap_err(), + PricingUnavailable::MissingContract + ); + assert_eq!( + contract.validate(&BTreeMap::new()).unwrap_err(), + PricingUnavailable::EmptyPriceMap + ); + for model in ["fake", "gpt-6.1-sol", "unknown-model"] { + let mut context = context(); + context.model = model; + assert_eq!( + resolve_operator_pricing(Some(&contract), &prices, &context).unwrap_err(), + PricingUnavailable::ScopeMismatch + ); + } +} diff --git a/src/llm/provider.rs b/src/llm/provider.rs index e12a023b..010334ab 100644 --- a/src/llm/provider.rs +++ b/src/llm/provider.rs @@ -255,6 +255,7 @@ impl Usage { } #[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct ModelPricing { pub input_usd_per_million_tokens: f64, pub output_usd_per_million_tokens: f64, From 76acc05a2019ecda3f6e87353f1a49dffd7946d0 Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:03:02 +0900 Subject: [PATCH 04/27] Preserve cleanliness failure causes without misleading pending-change advice Agent and orchestrator assignment creation added commit/stash/remove advice to every repository cleanliness acquisition failure, including inventory timeouts that occur before any pending change is observed. - Use neutral caller context while keeping the complete underlying failure and fail-closed behavior. - Add regression coverage for timeout details, dirty-state failures and binding failures. Validation: formatting and six exact focused tests passed; Clippy and the full suites run in this PR's CI. --- src/agent.rs | 32 +++++++++++++++++++++++++++----- src/orchestrator.rs | 10 +++++----- src/orchestrator/tests.rs | 27 +++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 10 deletions(-) diff --git a/src/agent.rs b/src/agent.rs index eadb31fe..d89e3ccc 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -36,6 +36,8 @@ const OUTPUT_CHAR_LIMIT: usize = 32 * 1024; const OUTPUT_CAPTURE_LIMIT_BYTES: usize = OUTPUT_CHAR_LIMIT * 4; const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(30); const MAX_PROMPT_EXCERPT_BYTES: usize = 256 * 1024; +const ASSIGNMENT_CLEANLINESS_CONTEXT: &str = + "agent assignment creation requires a capability-bound repository cleanliness input"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum AgentExecutionRuntime { @@ -710,11 +712,9 @@ fn select_worktree( let record = manager.create_for_test(create_options)?; #[cfg(not(test))] let record = { - let cleanliness = manager.acquire_repository_cleanliness().context( - "agent assignment creation requires a capability-bound repository \ - cleanliness input; commit, stash, or remove pending changes in the \ - primary repository, then rerun the agent", - )?; + let cleanliness = manager + .acquire_repository_cleanliness() + .context(ASSIGNMENT_CLEANLINESS_CONTEXT)?; manager.create_with_repository_cleanliness(create_options, &cleanliness)? }; let lease = manager @@ -1352,6 +1352,28 @@ mod tests { use std::{io::Read, sync::mpsc, thread}; use tempfile::TempDir; + #[test] + fn assignment_cleanliness_context_preserves_failure_without_pending_changes_advice() { + for cause in [ + "repository inventory exceeded its time limit before directory stream open; \ + failure_class=inventory_timeout relative_directory=\"vendor/sdk\" \ + elapsed_ms=50 entries_inspected=0", + "primary repository is dirty; managed worktree creation requires clean repository state", + "failed to bind primary repository cleanliness", + ] { + let result: Result<()> = Err(anyhow::anyhow!(cause)); + let error = result + .context(ASSIGNMENT_CLEANLINESS_CONTEXT) + .expect_err("cleanliness failure must remain an error"); + let message = format!("{error:#}"); + assert!(message.starts_with("agent assignment creation requires a capability-bound")); + assert!(message.ends_with(cause), "cause must be preserved: {message}"); + for advice in ["commit", "stash", "remove pending changes"] { + assert!(!message.contains(advice), "unsupported advice: {message}"); + } + } + } + #[test] fn provider_rate_limit_crosses_agent_boundary_and_latches_workspace_pool() -> Result<()> { let temp = TempDir::new().context("tempdir")?; diff --git a/src/orchestrator.rs b/src/orchestrator.rs index 10470db4..7e5541d7 100644 --- a/src/orchestrator.rs +++ b/src/orchestrator.rs @@ -46,6 +46,8 @@ use std::{ const OUTPUT_CHAR_LIMIT: usize = 32 * 1024; const OUTPUT_CAPTURE_LIMIT_BYTES: usize = OUTPUT_CHAR_LIMIT * 4; +const ASSIGNMENT_CLEANLINESS_CONTEXT: &str = + "orchestration assignment creation requires a capability-bound repository cleanliness input"; const CHECKPOINT_STATE_VERSION: u32 = 3; const GIT_COMMAND_CAPTURE_LIMIT_BYTES: usize = 64 * 1024 * 1024; const GIT_COMMAND_TIMEOUT: Duration = Duration::from_secs(30); @@ -2938,11 +2940,9 @@ fn select_worktrees( manager.create_for_test(create_options)?; #[cfg(not(test))] { - let cleanliness = manager.acquire_repository_cleanliness().context( - "orchestration assignment creation requires a capability-bound \ - repository cleanliness input; commit, stash, or remove pending \ - changes in the primary repository, then rerun the orchestration", - )?; + let cleanliness = manager + .acquire_repository_cleanliness() + .context(ASSIGNMENT_CLEANLINESS_CONTEXT)?; manager.create_with_repository_cleanliness(create_options, &cleanliness)?; } let lease = manager diff --git a/src/orchestrator/tests.rs b/src/orchestrator/tests.rs index a153d07c..16f3ba9a 100644 --- a/src/orchestrator/tests.rs +++ b/src/orchestrator/tests.rs @@ -10,6 +10,33 @@ use std::os::unix::fs::PermissionsExt; use std::sync::mpsc; use tempfile::TempDir; +#[test] +fn assignment_cleanliness_context_preserves_failure_without_pending_changes_advice() { + for cause in [ + "repository inventory exceeded its time limit before directory stream open; \ + failure_class=inventory_timeout relative_directory=\"vendor/sdk\" \ + elapsed_ms=50 entries_inspected=0", + "primary repository is dirty; managed worktree creation requires clean repository state", + "failed to bind primary repository cleanliness", + ] { + let result: Result<()> = Err(anyhow::anyhow!(cause)); + let error = result + .context(ASSIGNMENT_CLEANLINESS_CONTEXT) + .expect_err("cleanliness failure must remain an error"); + let message = format!("{error:#}"); + assert!( + message.starts_with("orchestration assignment creation requires a capability-bound") + ); + assert!( + message.ends_with(cause), + "cause must be preserved: {message}" + ); + for advice in ["commit", "stash", "remove pending changes"] { + assert!(!message.contains(advice), "unsupported advice: {message}"); + } + } +} + #[cfg(unix)] #[test] fn collect_status_paths_fails_closed_on_non_utf8_path() -> Result<()> { From 13d0f45584231e602bf612efbe0a6f671bbe33d4 Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:03:13 +0900 Subject: [PATCH 05/27] Preserve startup grace and rollback diagnostics during attachment - Restore the existing three-second attachment setup grace while keeping the total operation deadline. - Include verified rollback elapsed time and completion in cancellation evidence without exposing launcher output. - Add deterministic stage and missing-marker regressions that keep diagnostics after cleanup. Validation: formatting and eight focused attachment tests passed; full platform coverage runs in this PR's CI. --- src/process_runner.rs | 3 - src/process_runner/part2.rs | 5 +- src/process_runner/part3.rs | 8 +-- src/process_runner/tests.rs | 122 +++++++++++++++++++++++++++++++++++- 4 files changed, 127 insertions(+), 11 deletions(-) diff --git a/src/process_runner.rs b/src/process_runner.rs index 28373f81..224c1546 100644 --- a/src/process_runner.rs +++ b/src/process_runner.rs @@ -98,9 +98,6 @@ const TERMINATE_GRACE: Duration = Duration::from_millis(100); const EXIT_AND_DRAIN_GRACE: Duration = Duration::from_millis(500); #[cfg(target_os = "linux")] const SYSTEMD_OPERATION_GRACE: Duration = Duration::from_secs(3); -// Attachment alone may use this ceiling, always inside the original operation deadline. -#[cfg(target_os = "linux")] -const SYSTEMD_ATTACH_CEILING: Duration = Duration::from_secs(10); #[cfg(target_os = "linux")] const SYSTEMD_SLOT_WAIT: Duration = Duration::from_secs(30); // Keep both supervise admission and strict containment deterministic in the unit-test binary. diff --git a/src/process_runner/part2.rs b/src/process_runner/part2.rs index 78183b26..3ee2db0b 100644 --- a/src/process_runner/part2.rs +++ b/src/process_runner/part2.rs @@ -920,7 +920,8 @@ impl PreparedProcessTree { } let rollback_elapsed = rollback_started.elapsed(); tracing::debug!(elapsed_ms = rollback_elapsed.as_millis(), "systemd attachment rollback verified empty"); - let source = with_phase_diagnostics(source, format!("attachment rollback: elapsed_ms={} completed=true", rollback_elapsed.as_millis())); + let rollback_diagnostics = format!("attachment rollback: elapsed_ms={} completed=true", rollback_elapsed.as_millis()); + let source = with_phase_diagnostics(source, rollback_diagnostics.clone()); return if cancellation.is_cancelled() { Err(ProcessRunError::Cancelled { label: label.to_string(), @@ -934,7 +935,7 @@ impl PreparedProcessTree { ), side_effects: self.side_effects, process_error: Some(format!( - "process was cancelled by its run supervisor; {cancellation_diagnostics}" + "process was cancelled by its run supervisor; {cancellation_diagnostics}; {rollback_diagnostics}" )), stdin_error: None, })), diff --git a/src/process_runner/part3.rs b/src/process_runner/part3.rs index e34cf0fc..bb489d5b 100644 --- a/src/process_runner/part3.rs +++ b/src/process_runner/part3.rs @@ -2375,7 +2375,7 @@ fn systemd_attach_deadline(now: Instant, operation_deadline: Option) -> let allowance = match operation_deadline { Some(deadline) => { let remaining = deadline.checked_duration_since(now).filter(|r| !r.is_zero()).ok_or_else(|| io::Error::new(io::ErrorKind::TimedOut, "systemd attachment operation deadline is exhausted"))?; - remaining.min(SYSTEMD_OPERATION_GRACE.max(SYSTEMD_ATTACH_CEILING.min(remaining / 2))) + remaining.min(SYSTEMD_OPERATION_GRACE) } None => SYSTEMD_OPERATION_GRACE, }; @@ -4153,9 +4153,9 @@ mod start_gate_deadline_phase_tests { use std::time::{Duration, Instant}; #[test] - fn adaptive_attach_budget_scales_and_clips_without_renewal() { + fn attach_budget_preserves_three_second_grace_and_clips_without_renewal() { let now = Instant::now(); - for (remaining, expected) in [(40, 10), (12, 6), (6, 3), (1, 1)] { + for (remaining, expected) in [(40, 3), (12, 3), (6, 3), (3, 3), (1, 1)] { let operation = now + Duration::from_secs(remaining); let deadline = systemd_attach_deadline(now, Some(operation)).unwrap(); assert_eq!(deadline, now + Duration::from_secs(expected)); @@ -4170,7 +4170,7 @@ mod start_gate_deadline_phase_tests { } assert_eq!(systemd_attach_deadline(now, None).unwrap(), now + SYSTEMD_OPERATION_GRACE); if let Some(large) = now.checked_add(Duration::from_secs(u32::MAX as u64)) { - assert_eq!(systemd_attach_deadline(large, Some(large + Duration::from_secs(12))).unwrap(), large + Duration::from_secs(6)); + assert_eq!(systemd_attach_deadline(large, Some(large + Duration::from_secs(12))).unwrap(), large + Duration::from_secs(3)); } } diff --git a/src/process_runner/tests.rs b/src/process_runner/tests.rs index b5280b10..a78f146f 100644 --- a/src/process_runner/tests.rs +++ b/src/process_runner/tests.rs @@ -3206,6 +3206,119 @@ fn systemd_unit_fixture(runtime: &Path, helper_search_path: Option) -> } } +#[cfg(target_os = "linux")] +#[test] +fn systemd_withheld_attach_stages_retain_observed_evidence_after_rollback() { + for stage in 0..=8 { + let _guard = AttachTestGuard; + let temp = tempfile::tempdir().unwrap(); + let runtime = temp.path().join("runtime"); + fs::create_dir(&runtime).unwrap(); + let mut unit = systemd_unit_fixture(&runtime, None); + unit.cgroup_path = temp.path().join("absent-cgroup"); + // This fixture has no service. A stop of unknown ownership is a no-op; + // rollback must still verify the absent cgroup and remove the real files. + unit.systemctl = PathBuf::from("/bin/true"); + unit.observed_owned = stage >= 1; + unit.fifos_prepared = stage >= 2; + if stage >= 2 { + fs::write(&unit.fifo_waiting_path, b"").unwrap(); + } + if stage >= 3 { + unit.owner_channel = Some(tempfile::tempfile().unwrap()); + } + if stage >= 4 { + fs::write(&unit.waiting_path, b"").unwrap(); + } + unit.environment_published = stage >= 5; + unit.environment_released = stage >= 6; + if stage >= 7 { + fs::write(&unit.ready_path, b"").unwrap(); + } + unit.sandbox_verified = stage >= 8; + if stage == 8 { + // Later established flags cannot stand in for currently missing markers. + fs::remove_file(&unit.fifo_waiting_path).unwrap(); + fs::remove_file(&unit.waiting_path).unwrap(); + fs::remove_file(&unit.ready_path).unwrap(); + } + let private_file = runtime.join("private-input"); + fs::write(&private_file, b"SECRET").unwrap(); + unit.runtime_file_paths.push(private_file); + + let now = Instant::now(); + let operation_deadline = now + Duration::from_secs(40); + ATTACH_TEST_NOW.with(|clock| clock.set(Some(now))); + ATTACH_TEST_HOOK.with(|hook| { + *hook.borrow_mut() = Some(Box::new(move |phase| { + if phase == "before_attach_budget_check" { + // Hold the fixture at precisely this observed transition until + // the original setup grace expires, without a wall-clock wait. + ATTACH_TEST_NOW.with(|clock| clock.set(Some(now + Duration::from_secs(3)))); + } + })); + }); + let prepared = PreparedProcessTree { + backend: PreparedContainmentBackend::Systemd(Box::new(unit)), + side_effects: SideEffectConfinementEvidence::Unverified( + SideEffectConfinementProfileKind::StrictOfflineWorkspace, + ), + }; + let mut child = Command::new("/bin/sleep") + .arg("30") + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut timings = PhaseTimings::new( + now, + Some(operation_deadline), + Duration::from_secs(40), + Duration::ZERO, + "attachment", + ); + let error = prepared + .attach( + &mut child, + "withheld stage fixture", + "redacted command", + Some(operation_deadline), + &ProcessCancellation::new(), + &mut timings, + ) + .err() + .expect("a withheld stage must time out"); + child.kill().unwrap(); + child.wait().unwrap(); + assert!( + matches!(error, ProcessRunError::ProcessOwnership { ref source, .. } + if source.kind() == io::ErrorKind::TimedOut) + ); + assert!(!runtime.exists(), "stage {stage}: rollback left files"); + let message = error.to_string(); + assert!( + message.contains("did not reach its start gate"), + "{message}" + ); + let expected = format!( + "pre-cleanup gate: owned={} fifo_waiting={} fifos_prepared={} owner_channel={} guardian_waiting={} environment_published={} environment_released={} ready={} sandbox_verified={}", + stage >= 1, (2..8).contains(&stage), stage >= 2, stage >= 3, (4..8).contains(&stage), + stage >= 5, stage >= 6, stage == 7, stage >= 8, + ); + assert!(message.contains(&expected), "stage {stage}: {message}"); + assert!(message.contains("attach_allowance_ms=3000"), "{message}"); + assert!( + message.contains("operation_remaining_ms=37000"), + "{message}" + ); + assert!( + message.contains("attachment rollback: elapsed_ms="), + "{message}" + ); + assert!(message.contains("completed=true"), "{message}"); + assert!(!message.contains("SECRET"), "{message}"); + } +} + #[cfg(target_os = "linux")] #[test] fn systemd_start_gate_timeout_preserves_bounded_redacted_diagnostics_before_cleanup() { @@ -5857,6 +5970,11 @@ fn systemd_attach_cancellation_redacts_launcher_failure_and_rolls_back() { message.contains("phase=") && message.contains("pre-cleanup gate:"), "{message}" ); + assert!( + message.contains("attachment rollback: elapsed_ms="), + "{message}" + ); + assert!(message.contains("completed=true"), "{message}"); assert!( !message.contains("SECRET") && !message.contains("/private"), "{message}" @@ -6051,7 +6169,7 @@ fn systemd_attach_delayed_ready_retains_full_ownership_and_sandbox_proof() { } else if phase == "after_sandbox_verification" { observed.set(true); let base = selected_at.expect("capture the deadline-selection clock"); - ATTACH_TEST_NOW.with(|clock| clock.set(Some(base + Duration::from_secs(4)))); + ATTACH_TEST_NOW.with(|clock| clock.set(Some(base + Duration::from_secs(2)))); } })) }); @@ -6066,7 +6184,7 @@ fn systemd_attach_delayed_ready_retains_full_ownership_and_sandbox_proof() { ) .with_timeout(Some(Duration::from_secs(12))), ) - .expect("strict production path must pass with four-second injected readiness"); + .expect("strict production path must pass with two-second injected readiness"); assert!(advanced.get(), "must reach real sandbox verification"); assert!(output.status.unwrap().success() && !output.timed_out); assert!(output.safety_evidence_verified(), "{output:?}"); From 9341d54e6c1d7368293b954bd3b2d344f6f0ab2c Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:03:25 +0900 Subject: [PATCH 06/27] Add authored independent check declarations and acquisition receipts First slice of #713. - Add strict parent-authored Python check declarations bound to nested Worker scopes and the parent union. - Persist declarations in normalized plans and acquire opaque file, snapshot, command and semantic-risk receipts; a semantic-risk report with errors is refused. - Refuse drift during revalidation; restored metadata cannot restore acquired receipts. Scheduler pre-claim integration follows in a separate change. Validation: formatting, the focused declaration/acquisition tests and two schema-contract tests passed; Clippy and the full suites run in this PR's CI. --- src/supervise.rs | 5 + src/supervise/authored_checks.rs | 829 +++++++++++++++++++++++++++++++ src/supervise/plan_api.rs | 24 + src/supervise/plan_validation.rs | 10 + 4 files changed, 868 insertions(+) create mode 100644 src/supervise/authored_checks.rs diff --git a/src/supervise.rs b/src/supervise.rs index 95e5af76..c1761236 100644 --- a/src/supervise.rs +++ b/src/supervise.rs @@ -253,6 +253,7 @@ pub(crate) fn live_quota_concurrency_bound( } mod plan_api; pub use plan_api::*; +pub mod authored_checks; pub(crate) use plan_api::{ load_supervisor_plan_file_in_repo, task_routing_assessment_from_plan_document, validate_generated_follow_up_plan_document_in_repo, @@ -1988,6 +1989,7 @@ struct AssignmentMetadata { reasoning_efforts: BTreeMap, source_inputs: BTreeMap>, + authored_independent_checks: BTreeMap>, /// Assignment-level mechanical duty for a direct Worker (empty nested list). /// Nested `worker_assignments` keep [`WorkerAssignmentMetadata::mechanical_duty`]. direct_mechanical_duties: BTreeMap, @@ -2037,6 +2039,8 @@ impl AssignmentMetadata { fn retain_assignment(&mut self, assignment_id: &str) { self.workers.retain(|(owner, _), _| owner == assignment_id); self.source_inputs.retain(|owner, _| owner == assignment_id); + self.authored_independent_checks + .retain(|owner, _| owner == assignment_id); self.reasoning_efforts .retain(|owner, _| owner == assignment_id); self.direct_mechanical_duties @@ -2051,6 +2055,7 @@ impl From> for AssignmentMe parent_validation: None, reasoning_efforts: BTreeMap::new(), source_inputs: BTreeMap::new(), + authored_independent_checks: BTreeMap::new(), direct_mechanical_duties: BTreeMap::new(), } } diff --git a/src/supervise/authored_checks.rs b/src/supervise/authored_checks.rs new file mode 100644 index 00000000..ded8c51b --- /dev/null +++ b/src/supervise/authored_checks.rs @@ -0,0 +1,829 @@ +//! Frozen declarations and independently acquired file evidence for authored checks. +//! +//! This API does not grant preclaim admission or execute a command. Restoring a +//! normalized plan restores declarations only; receipts always require acquisition +//! against the current repository and semantic-risk inputs. + +use super::{AgentRole, AssignmentPhase, OrchestratorAssignment, SupervisorPlan}; +use crate::repo_semantic::SemanticRiskReport; +#[cfg(not(unix))] +use anyhow::bail; +use anyhow::{ensure, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::{collections::BTreeSet, path::Path}; + +const MAX_CHECKS: usize = 8; +const MAX_PATH_BYTES: usize = 1024; + +/// One exact Python check for one nested Worker, authored on its parent. +/// +/// The initial command grammar is exactly `[absolute_python3, "-I", "-B", +/// check_path]`. No PATH lookup, shell, inline code, module selection, additional +/// arguments or environment overrides are accepted. The script need not have an +/// executable bit; the bound interpreter must be executable. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct AuthoredIndependentCheck { + pub worker_id: String, + pub assigned_paths: Vec, + pub check_path: String, + pub argv: Vec, +} + +fn exact_relative_path(path: &str) -> Result<()> { + ensure!( + !path.is_empty() + && path.len() <= MAX_PATH_BYTES + && !path.contains(['\\', ':']) + && !path.chars().any(char::is_control) + && path.split('/').all(|part| { + !part.is_empty() && part != "." && part != ".." && !part.starts_with('.') + }), + "authored check requires an exact, non-hidden repository-relative path" + ); + Ok(()) +} + +fn portable_path(path: &Path) -> Result { + let parts = path + .components() + .map(|component| { + let std::path::Component::Normal(part) = component else { + anyhow::bail!("authored check scope must be repository-relative"); + }; + part.to_str().context("authored check scope is not UTF-8") + }) + .collect::>>()?; + let portable = parts.join("/"); + exact_relative_path(&portable)?; + Ok(portable) +} + +pub(super) fn validate_declarations<'a>( + plan: &'a SupervisorPlan, + parent_id: &str, + checks: &[AuthoredIndependentCheck], +) -> Result<&'a OrchestratorAssignment> { + let mut parents = plan.assignments.iter().filter(|item| item.id == parent_id); + let parent = parents.next().context("authored check parent is missing")?; + ensure!( + parents.next().is_none(), + "authored check parent is ambiguous" + ); + ensure!( + parent.role == AgentRole::ChildOrchestrator + && parent.phase == AssignmentPhase::Execution + && !parent.category_is_operator_override() + && !parent.worker_assignments.is_empty(), + "authored checks require an execution parent with nested Workers" + ); + ensure!( + !checks.is_empty() && checks.len() <= MAX_CHECKS, + "authored_independent_checks requires 1..={MAX_CHECKS} checks" + ); + let mut worker_paths = BTreeSet::new(); + let mut worker_ids = BTreeSet::new(); + for worker in &parent.worker_assignments { + ensure!( + worker.role == AgentRole::Worker + && !worker.category_is_operator_override() + && !worker.assigned_paths.is_empty() + && worker_ids.insert(&worker.id), + "authored checks require distinct terminal Workers with explicit scopes" + ); + for path in &worker.assigned_paths { + portable_path(path)?; + ensure!( + worker_paths.iter().all(|existing: &&Path| { + !path.starts_with(existing) && !existing.starts_with(path) + }), + "authored check Worker scopes must be disjoint" + ); + worker_paths.insert(path.as_path()); + } + } + let parent_paths = parent + .assigned_paths + .iter() + .map(|p| p.as_path()) + .collect::>(); + ensure!( + parent_paths == worker_paths && parent_paths.len() == parent.assigned_paths.len(), + "authored check parent scope must equal the exact Worker union" + ); + let mut subjects = BTreeSet::new(); + let mut check_paths = BTreeSet::new(); + for check in checks { + let worker = parent + .worker_assignments + .iter() + .find(|worker| worker.id == check.worker_id) + .context("authored check names an unrelated Worker")?; + let declared = check + .assigned_paths + .iter() + .cloned() + .collect::>(); + for path in &check.assigned_paths { + exact_relative_path(path)?; + } + let assigned = worker + .assigned_paths + .iter() + .map(|path| portable_path(path)) + .collect::>>()?; + ensure!( + declared == assigned + && declared.len() == check.assigned_paths.len() + && subjects.insert(&check.worker_id), + "authored check must bind its Worker's exact assigned paths once" + ); + exact_relative_path(&check.check_path)?; + ensure!( + check.check_path.ends_with(".py") && check_paths.insert(&check.check_path), + "authored checks require distinct Python script paths" + ); + let script = Path::new(&check.check_path); + // Include every flattened assignment, so another writable assignment + // cannot replace a check declared outside this parent's own union. + ensure!( + plan.assignments + .iter() + .filter(|assignment| { + matches!( + assignment.role, + AgentRole::Worker | AgentRole::ChildOrchestrator + ) + }) + .all(|assignment| { + assignment + .assigned_paths + .iter() + .chain( + assignment + .worker_assignments + .iter() + .flat_map(|w| &w.assigned_paths), + ) + .all(|write| !script.starts_with(write) && !write.starts_with(script)) + }), + "authored check overlaps a plan write scope" + ); + ensure!( + check.argv.len() == 4 + && check.argv[1] == "-I" + && check.argv[2] == "-B" + && check.argv[3] == check.check_path, + "authored check argv must be absolute Python, -I, -B and the exact check path" + ); + let program = &check.argv[0]; + let name = program.rsplit('/').next().unwrap_or_default(); + ensure!( + program.starts_with('/') + && program.len() <= MAX_PATH_BYTES + && !program.contains(['\\', ':']) + && !program.chars().any(char::is_control) + && program + .split('/') + .skip(1) + .all(|p| !p.is_empty() && p != "." && p != "..") + && (name == "python3" + || name.strip_prefix("python3.").is_some_and(|v| { + !v.is_empty() && v.bytes().all(|c| c.is_ascii_digit()) + })), + "authored check interpreter must be an exact absolute Python 3 executable" + ); + } + ensure!( + subjects.len() == worker_ids.len(), + "authored checks must cover every nested Worker" + ); + Ok(parent) +} + +/// In-memory evidence acquired from current files, never serialized or restored. +/// A receipt is not a successful check result or permission to launch a process. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AcquiredAuthoredCheckEvidence { + repository: std::path::PathBuf, + parent_id: String, + plan_sha256: String, + risk_sha256: String, + head: String, + tree: String, + files: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct BoundFile { + path: std::path::PathBuf, + sha256: String, + identity: (u64, u64), + mode: u32, + modified: (i64, i64), + changed: (i64, i64), +} + +impl AcquiredAuthoredCheckEvidence { + /// Refuse any drift in the canonical plan, snapshot, risk or file identities. + /// Callers must supply freshly acquired risk inputs from the same snapshot. + pub fn revalidate( + &self, + repository: &Path, + plan_document: &str, + risk: &SemanticRiskReport, + ) -> Result<()> { + let current = acquire(repository, plan_document, &self.parent_id, risk)?; + ensure!(current == *self, "authored check acquired evidence changed"); + Ok(()) + } +} + +/// Acquire a fresh receipt from a normalized or operator-authored plan document. +/// Parsing a document alone never calls this function or restores a receipt. +/// Acquisition is currently supported only on Unix, where no-follow descriptors, +/// hard-link counts and executable identity are checked. Nothing is executed. +pub fn acquire( + repository: &Path, + plan_document: &str, + parent_id: &str, + risk: &SemanticRiskReport, +) -> Result { + #[cfg(unix)] + { + acquire_unix(repository, plan_document, parent_id, risk) + } + #[cfg(not(unix))] + { + let _ = (repository, plan_document, parent_id, risk); + bail!("authored check file identity acquisition requires Unix") + } +} + +#[cfg(unix)] +fn acquire_unix( + repository: &Path, + plan_document: &str, + parent_id: &str, + risk: &SemanticRiskReport, +) -> Result { + use crate::artifacts::state_auth::sha256_hex; + let loaded = super::plan_api::parse_supervisor_plan_with_consultant_in_repo( + plan_document, + Some(repository), + )?; + let checks = loaded + .assignment_metadata + .authored_independent_checks + .get(parent_id) + .context("parent has no authored independent checks")?; + let parent = validate_declarations(&loaded.plan, parent_id, checks)?; + ensure!( + risk.errors.is_empty(), + "authored checks require semantic risk without scan errors" + ); + let changed = risk.changed_paths.iter().collect::>(); + ensure!( + changed == parent.assigned_paths.iter().collect::>() + && changed.len() == risk.changed_paths.len(), + "authored checks require semantic risk bound to the parent union" + ); + let root = std::fs::canonicalize(repository)?; + let repo = git2::Repository::open(&root)?; + ensure!( + repo.workdir() + .is_some_and(|workdir| std::fs::canonicalize(workdir).is_ok_and(|dir| dir == root)), + "authored check repository must be the exact worktree root" + ); + let snapshot = || -> Result<(String, String)> { + let index = repo.index()?; + ensure!( + index.len() <= 100_000, + "authored check snapshot index exceeds its entry limit" + ); + ensure!(index.iter().all(|entry| entry.flags & 0xf000 == 0 && entry.flags_extended == 0 && entry.mode != 0o040000), + "authored check snapshot cannot use hidden, extended, conflicted or sparse index entries"); + let mut options = git2::StatusOptions::new(); + options + .include_untracked(true) + .recurse_untracked_dirs(true) + .include_ignored(false); + ensure!( + repo.statuses(Some(&mut options))?.is_empty(), + "authored checks require a clean Git snapshot" + ); + let commit = repo.head()?.peel_to_commit()?; + Ok((commit.id().to_string(), commit.tree_id().to_string())) + }; + let (head, tree) = snapshot()?; + let tree_object = repo.find_tree(tree.parse()?)?; + let mut files = Vec::new(); + for check in checks { + let relative = Path::new(&check.check_path); + let entry = tree_object + .get_path(relative) + .context("authored check is not Git-visible")?; + ensure!( + matches!(entry.filemode(), 0o100644 | 0o100755), + "authored check is not a tracked regular file" + ); + let (blob_bytes, kind) = repo.odb()?.read_header(entry.id())?; + ensure!( + kind == git2::ObjectType::Blob && blob_bytes <= 1024 * 1024, + "authored check snapshot exceeds its byte limit" + ); + let path = crate::process_runner::resolve_existing_path_without_symlinks(&root, relative)?; + let (file, bytes) = capture_file(&path, 1024 * 1024, false)?; + ensure!( + repo.find_blob(entry.id())?.content() == bytes, + "authored check differs from its snapshot" + ); + files.push(file); + // System interpreter symlinks are resolved once and the resolved path, + // descriptor identity and bytes are all compared on revalidation. + let interpreter = std::fs::canonicalize(&check.argv[0])?; + ensure!( + !interpreter.starts_with(&root), + "authored check interpreter must be outside the repository" + ); + let (file, _) = capture_file(&interpreter, 32 * 1024 * 1024, true)?; + files.push(file); + } + ensure!( + snapshot()? == (head.clone(), tree.clone()), + "authored check snapshot changed during acquisition" + ); + let canonical = super::plan_api::supervisor_plan_value( + &loaded.plan, + &loaded.consultant, + &loaded.assignment_metadata, + &loaded.plan_metadata, + )?; + Ok(AcquiredAuthoredCheckEvidence { + repository: root, + parent_id: parent_id.to_string(), + plan_sha256: sha256_hex(&serde_json::to_vec(&canonical)?), + risk_sha256: sha256_hex(&serde_json::to_vec(risk)?), + head, + tree, + files, + }) +} + +#[cfg(unix)] +fn capture_file(path: &Path, cap: u64, executable: bool) -> Result<(BoundFile, Vec)> { + use std::os::unix::fs::MetadataExt; + let mut metadata = None; + let bytes = crate::safe_state::BoundedRegularReader::read_tree_no_follow_validated( + path, + cap, + |current| { + ensure!( + !executable || current.mode() & 0o111 != 0, + "authored check interpreter is not executable" + ); + ensure!(current.len() > 0, "authored check input is empty"); + metadata = Some(current.clone()); + Ok(()) + }, + )?; + let after = metadata.context("authored check descriptor metadata is missing")?; + Ok(( + BoundFile { + path: path.to_path_buf(), + sha256: crate::artifacts::state_auth::sha256_hex(&bytes), + identity: (after.dev(), after.ino()), + mode: after.mode(), + modified: (after.mtime(), after.mtime_nsec()), + changed: (after.ctime(), after.ctime_nsec()), + }, + bytes, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + #[cfg(unix)] + use serde_json::Value; + #[cfg(unix)] + use std::{ + fs, + os::unix::fs::{symlink, PermissionsExt}, + path::PathBuf, + }; + + #[test] + fn declarations_preserve_portable_paths_without_acquiring_authority() -> Result<()> { + let plan = json!({"task":"Repair source", "assignments":[{ + "id":"parent", "phase":"execution", "assigned_paths":["src/identifier.py"], + "worker_assignments":[{"id":"identifier", "assigned_paths":["src/identifier.py"]}], + "authored_independent_checks":[{ + "worker_id":"identifier", "assigned_paths":["src/identifier.py"], + "check_path":"checks/check_identifier.py", + "argv":["/usr/bin/python3", "-I", "-B", "checks/check_identifier.py"] + }] + }]}); + let loaded = + super::super::plan_api::parse_supervisor_plan_with_consultant(&plan.to_string())?; + assert_eq!( + loaded.assignment_metadata.authored_independent_checks["parent"][0].assigned_paths, + ["src/identifier.py"] + ); + let mut metadata = loaded.assignment_metadata.clone(); + metadata.retain_assignment("another-parent"); + assert!(metadata.authored_independent_checks.is_empty()); + Ok(()) + } + + #[cfg(unix)] + fn commit(repo: &git2::Repository) -> Result<()> { + let mut index = repo.index()?; + index.add_all(["*"], git2::IndexAddOption::DEFAULT, None)?; + index.write()?; + let tree_id = index.write_tree()?; + let tree = repo.find_tree(tree_id)?; + let signature = git2::Signature::now("Check tests", "checks@example.invalid")?; + let parent = repo.head().ok().and_then(|head| head.peel_to_commit().ok()); + repo.commit( + Some("HEAD"), + &signature, + &signature, + "Snapshot", + &tree, + &parent.iter().collect::>(), + )?; + Ok(()) + } + + #[cfg(unix)] + fn fixture() -> Result<(tempfile::TempDir, Value, SemanticRiskReport)> { + let temp = tempfile::tempdir()?; + let root = temp.path(); + fs::create_dir(root.join("src"))?; + fs::create_dir(root.join("checks"))?; + for subject in ["identifier", "duration"] { + fs::write(root.join(format!("src/{subject}.py")), "value = 1\n")?; + fs::write(root.join(format!("checks/check_{subject}.py")), format!( + "import importlib.util\nspec = importlib.util.spec_from_file_location('subject', 'src/{subject}.py')\nsubject = importlib.util.module_from_spec(spec)\nspec.loader.exec_module(subject)\nassert subject.value == 1\n" + ))?; + } + let repo = git2::Repository::init(root)?; + commit(&repo)?; + let interpreter = ["/usr/bin/python3", "/bin/python3"] + .iter() + .map(PathBuf::from) + .find(|path| path.is_file()) + .context("Python 3 fixture interpreter is missing")?; + let mut checks = Vec::new(); + let mut workers = Vec::new(); + for subject in ["identifier", "duration"] { + let assigned = format!("src/{subject}.py"); + let check = format!("checks/check_{subject}.py"); + workers.push(json!({"id":subject, "assigned_paths":[assigned], "task":"Repair the declared source"})); + checks.push( + json!({"worker_id":subject, "assigned_paths":[assigned], "check_path":check, + "argv":[interpreter, "-I", "-B", check]}), + ); + } + let plan = json!({"task":"Repair identifier and duration", "assignments":[{ + "id":"parent", "phase":"execution", "assigned_paths":["src/identifier.py", "src/duration.py"], + "worker_assignments":workers, "authored_independent_checks":checks + }]}); + let risk = SemanticRiskReport { + changed_paths: vec!["src/identifier.py".into(), "src/duration.py".into()], + touched_files: Vec::new(), + touched_symbols: Vec::new(), + dependency_impacts: Vec::new(), + impacted_files: Vec::new(), + errors: Vec::new(), + }; + Ok((temp, plan, risk)) + } + + #[test] + #[cfg(unix)] + fn declarations_bind_exact_independent_files() -> Result<()> { + let (temp, plan, risk) = fixture()?; + assert!(risk.dependency_impacts.is_empty()); + let evidence = acquire(temp.path(), &plan.to_string(), "parent", &risk)?; + assert_eq!(evidence.files.len(), 4); + evidence.revalidate(temp.path(), &plan.to_string(), &risk)?; + // Neither acquisition nor revalidation executes the Python checks. + assert!(!temp.path().join("src/__pycache__").exists()); + Ok(()) + } + + #[test] + #[cfg(unix)] + fn declarations_reject_unrelated_commands_and_write_scopes() -> Result<()> { + let (temp, plan, risk) = fixture()?; + for case in 0..14 { + let mut changed = plan.clone(); + let mut risk = risk.clone(); + let parent = &mut changed["assignments"][0]; + match case { + 0 => parent["authored_independent_checks"][0]["argv"][0] = json!("python3"), + 1 => parent["authored_independent_checks"][0]["argv"][0] = json!("/bin/sh"), + 2 => parent["authored_independent_checks"][0]["argv"][1] = json!("-c"), + 3 => { + parent["authored_independent_checks"][0]["argv"][3] = + json!("checks/check_duration.py") + } + 4 => parent["authored_independent_checks"][0]["worker_id"] = json!("other"), + 5 => { + parent["authored_independent_checks"][0]["assigned_paths"] = + json!(["src/duration.py"]) + } + 6 => parent["authored_independent_checks"][0]["check_path"] = json!("../check.py"), + 7 => { + parent["authored_independent_checks"][0]["check_path"] = + json!("checks\\check.py") + } + 8 => parent["authored_independent_checks"][0]["receipt"] = json!({"valid":true}), + 9 => { + parent["assigned_paths"] = + json!(["src/identifier.py", "src/duration.py", "checks"]) + } + 10 => parent["worker_assignments"][0]["authored_independent_checks"] = json!([]), + 11 => parent["authored_independent_checks"] + .as_array_mut() + .unwrap() + .truncate(1), + 12 => { + parent["worker_assignments"][0]["assigned_paths"] = + json!(["checks/check_identifier.py"]); + parent["assigned_paths"] = + json!(["checks/check_identifier.py", "src/duration.py"]); + parent["authored_independent_checks"][0]["assigned_paths"] = + json!(["checks/check_identifier.py"]); + risk.changed_paths = vec![ + "checks/check_identifier.py".into(), + "src/duration.py".into(), + ]; + } + 13 => risk.errors.push(crate::repo_semantic::SemanticScanError { + file: "src/identifier.py".into(), + kind: crate::repo_semantic::SemanticScanErrorKind::Parse, + message: "injected parse error".into(), + span: None, + }), + _ => unreachable!(), + } + let error = acquire(temp.path(), &changed.to_string(), "parent", &risk) + .expect_err("invalid authored check declaration must refuse"); + let expected = match case { + 12 => Some("authored check overlaps a plan write scope"), + 13 => Some("authored checks require semantic risk without scan errors"), + _ => None, + }; + if let Some(expected) = expected { + assert!( + format!("{error:#}").contains(expected), + "case {case}: {error:#}" + ); + } + } + Ok(()) + } + + #[test] + #[cfg(unix)] + fn declarations_reject_sibling_write_scopes() -> Result<()> { + let (temp, plan, risk) = fixture()?; + for role in ["worker", "child_orchestrator"] { + for scope in ["checks/check_identifier.py", "checks"] { + let mut changed = plan.clone(); + changed["max_child_assignments"] = json!(2); + let mut sibling = json!({ + "id":"sibling", "phase":"execution", "role":role, + "assigned_paths":[scope] + }); + if role == "worker" { + sibling["role_category"] = json!("non_delegating_terminal_worker"); + } + changed["assignments"].as_array_mut().unwrap().push(sibling); + let error = acquire(temp.path(), &changed.to_string(), "parent", &risk) + .expect_err("sibling cannot write an authored check"); + assert!( + format!("{error:#}").contains("authored check overlaps a plan write scope"), + "role={role}, scope={scope}: {error:#}" + ); + } + } + Ok(()) + } + + #[test] + #[cfg(unix)] + fn acquired_evidence_refuses_drift() -> Result<()> { + for case in 0..9 { + let (temp, mut plan, mut risk) = fixture()?; + let evidence = acquire(temp.path(), &plan.to_string(), "parent", &risk)?; + let check = temp.path().join("checks/check_identifier.py"); + match case { + 0 => fs::write(&check, "assert False\n")?, + 1 => plan["task"] = json!("Different task"), + 2 => { + plan["assignments"][0]["worker_assignments"][0]["task"] = + json!("Different Worker task") + } + 3 => { + plan["assignments"][0]["authored_independent_checks"][0]["argv"][2] = + json!("-I") + } + 4 => risk + .impacted_files + .push("checks/check_identifier.py".into()), + 5 => { + fs::write(temp.path().join("new.txt"), "changed snapshot")?; + commit(&git2::Repository::open(temp.path())?)?; + } + 6 => { + let bytes = fs::read(&check)?; + let replacement = temp.path().join("replacement.py"); + fs::write(&replacement, bytes)?; + fs::rename(replacement, &check)?; + } + 7 => { + plan["assignments"][0]["assigned_paths"][0] = json!("src/other.py"); + } + 8 => { + // A fully valid new declaration still cannot reuse the old receipt. + let mut changed = plan["assignments"][0]["authored_independent_checks"] + .as_array() + .unwrap() + .clone(); + changed.swap(0, 1); + plan["assignments"][0]["authored_independent_checks"] = json!(changed); + } + _ => unreachable!(), + } + assert!( + evidence + .revalidate(temp.path(), &plan.to_string(), &risk) + .is_err(), + "case {case}" + ); + } + Ok(()) + } + + #[test] + #[cfg(unix)] + fn checks_refuse_non_snapshot_files_and_identity_substitutions() -> Result<()> { + for case in 0..9 { + let (temp, mut plan, risk) = fixture()?; + let aliases = tempfile::tempdir()?; + let check = temp.path().join("checks/check_identifier.py"); + match case { + 0 => fs::remove_file(&check)?, + 1 => { + fs::remove_file(&check)?; + fs::create_dir(&check)?; + } + 2 => { + fs::remove_file(&check)?; + symlink("check_duration.py", &check)?; + commit(&git2::Repository::open(temp.path())?)?; + } + 3 => fs::hard_link(&check, aliases.path().join("alias.py"))?, + 4 => { + fs::write(&check, "")?; + commit(&git2::Repository::open(temp.path())?)?; + } + 5 => { + let interpreter_dir = tempfile::tempdir()?; + let interpreter = interpreter_dir.path().join("python3"); + fs::write(&interpreter, "unexecutable")?; + fs::set_permissions(&interpreter, fs::Permissions::from_mode(0o600))?; + plan["assignments"][0]["authored_independent_checks"][0]["argv"][0] = + json!(interpreter); + assert!(acquire(temp.path(), &plan.to_string(), "parent", &risk).is_err()); + continue; + } + 6 => { + fs::write(temp.path().join("checks/untracked.py"), "assert True\n")?; + plan["assignments"][0]["authored_independent_checks"][0]["check_path"] = + json!("checks/untracked.py"); + plan["assignments"][0]["authored_independent_checks"][0]["argv"][3] = + json!("checks/untracked.py"); + } + 7 => { + let checks = temp.path().join("checks"); + fs::rename(&checks, temp.path().join("original-checks"))?; + symlink("original-checks", checks)?; + } + 8 => { + fs::write(&check, vec![b'x'; 1024 * 1024 + 1])?; + commit(&git2::Repository::open(temp.path())?)?; + } + _ => unreachable!(), + } + assert!( + acquire(temp.path(), &plan.to_string(), "parent", &risk).is_err(), + "case {case}" + ); + } + Ok(()) + } + + #[test] + #[cfg(unix)] + fn interpreter_identity_is_bound_without_execution() -> Result<()> { + let (temp, mut plan, risk) = fixture()?; + let runtime = tempfile::tempdir()?; + let interpreter = runtime.path().join("python3"); + let source = fs::canonicalize( + plan["assignments"][0]["authored_independent_checks"][0]["argv"][0] + .as_str() + .unwrap(), + )?; + fs::copy(&source, &interpreter)?; + fs::set_permissions(&interpreter, fs::Permissions::from_mode(0o755))?; + for check in plan["assignments"][0]["authored_independent_checks"] + .as_array_mut() + .unwrap() + { + check["argv"][0] = json!(interpreter); + } + let evidence = acquire(temp.path(), &plan.to_string(), "parent", &risk)?; + evidence.revalidate(temp.path(), &plan.to_string(), &risk)?; + let replacement = runtime.path().join("replacement"); + fs::copy(&source, &replacement)?; + fs::rename(replacement, &interpreter)?; + assert!(evidence + .revalidate(temp.path(), &plan.to_string(), &risk) + .is_err()); + fs::set_permissions(&interpreter, fs::Permissions::from_mode(0o600))?; + assert!(acquire(temp.path(), &plan.to_string(), "parent", &risk).is_err()); + Ok(()) + } + + #[test] + #[cfg(unix)] + fn index_flags_cannot_hide_snapshot_drift() -> Result<()> { + let (temp, plan, risk) = fixture()?; + let evidence = acquire(temp.path(), &plan.to_string(), "parent", &risk)?; + let repo = git2::Repository::open(temp.path())?; + let mut index = repo.index()?; + let subject = Path::new("src/identifier.py"); + let mut entry = index + .get_path(subject, 0) + .context("indexed subject is missing")?; + entry.flags |= 0x8000; + index.add(&entry)?; + index.write()?; + assert_ne!( + repo.index()?.get_path(subject, 0).unwrap().flags & 0x8000, + 0 + ); + fs::write(temp.path().join(subject), "value = 2\n")?; + assert!(acquire(temp.path(), &plan.to_string(), "parent", &risk).is_err()); + assert!(evidence + .revalidate(temp.path(), &plan.to_string(), &risk) + .is_err()); + Ok(()) + } + + #[test] + #[cfg(unix)] + fn plan_roundtrip_restores_declarations_only() -> Result<()> { + let (temp, plan, risk) = fixture()?; + let loaded = super::super::plan_api::parse_supervisor_plan_with_consultant_in_repo( + &plan.to_string(), + Some(temp.path()), + )?; + let canonical = super::super::plan_api::supervisor_plan_value( + &loaded.plan, + &loaded.consultant, + &loaded.assignment_metadata, + &loaded.plan_metadata, + )?; + assert_eq!( + canonical["assignments"][0]["authored_independent_checks"], + plan["assignments"][0]["authored_independent_checks"] + ); + let restored = super::super::plan_api::parse_supervisor_plan_with_consultant_in_repo( + &canonical.to_string(), + Some(temp.path()), + )?; + assert_eq!(restored.assignment_metadata, loaded.assignment_metadata); + let evidence = acquire(temp.path(), &canonical.to_string(), "parent", &risk)?; + fs::write( + temp.path().join("checks/check_identifier.py"), + "assert False\n", + )?; + // Restoring declarations remains possible, but cannot restore authority. + super::super::plan_api::parse_supervisor_plan_with_consultant_in_repo( + &canonical.to_string(), + Some(temp.path()), + )?; + assert!(evidence + .revalidate(temp.path(), &canonical.to_string(), &risk) + .is_err()); + assert!(acquire(temp.path(), &canonical.to_string(), "parent", &risk).is_err()); + Ok(()) + } +} diff --git a/src/supervise/plan_api.rs b/src/supervise/plan_api.rs index 3caf0673..b51020a8 100644 --- a/src/supervise/plan_api.rs +++ b/src/supervise/plan_api.rs @@ -3691,6 +3691,7 @@ pub(super) fn parse_supervisor_plan_with_consultant_in_repo( bind_assignment_role_categories(&mut plan); validate_task_routing_assessment_binding(&plan, &plan_metadata)?; let assignment_metadata = assignment_metadata_from_plan_value(&value, &plan)?; + validate_authored_check_metadata(&plan, &assignment_metadata)?; validate_consultant_plan(&consultant)?; Ok(LoadedSupervisorPlan { plan, @@ -4371,6 +4372,13 @@ fn collect_assignment_metadata( .trim(); let assignment = assignments_by_id.get(raw_id).copied(); if let Some(assignment) = assignment { + if let Some(raw_checks) = raw_assignment.get("authored_independent_checks") { + let checks = serde_json::from_value(raw_checks.clone()) + .context("invalid authored_independent_checks")?; + metadata_by_worker + .authored_independent_checks + .insert(assignment.id.clone(), checks); + } if let Some(raw_inputs) = raw_assignment.get("source_inputs") { use crate::external_agent::researcher_inputs::{ validate_declarations, ResearcherSourceInput, @@ -4414,6 +4422,9 @@ fn collect_assignment_metadata( .map(Vec::as_slice) .unwrap_or_default(); for raw_worker in raw_workers { + if raw_worker.get("authored_independent_checks").is_some() { + bail!("authored_independent_checks must be declared on the parent assignment"); + } if raw_worker.get("source_inputs").is_some() { bail!("source_inputs cannot be delegated to nested Workers"); } @@ -4495,6 +4506,7 @@ pub(super) fn supervisor_plan_value( plan_metadata: &SupervisorPlanMetadata, ) -> Result { validate_task_routing_assessment_binding(plan, plan_metadata)?; + validate_authored_check_metadata(plan, assignment_metadata)?; let mut value = serde_json::to_value(plan).context("failed to serialize normalized supervisor plan")?; let assignments = value @@ -4502,6 +4514,18 @@ pub(super) fn supervisor_plan_value( .and_then(Value::as_array_mut) .context("normalized supervisor plan assignments did not serialize to an array")?; for (assignment_value, assignment) in assignments.iter_mut().zip(&plan.assignments) { + if let Some(checks) = assignment_metadata + .authored_independent_checks + .get(&assignment.id) + { + assignment_value + .as_object_mut() + .context("normalized assignment is not an object")? + .insert( + "authored_independent_checks".into(), + serde_json::to_value(checks)?, + ); + } if let Some(inputs) = assignment_metadata.source_inputs.get(&assignment.id) { assignment_value .as_object_mut() diff --git a/src/supervise/plan_validation.rs b/src/supervise/plan_validation.rs index a0f8ef95..19c2f45b 100644 --- a/src/supervise/plan_validation.rs +++ b/src/supervise/plan_validation.rs @@ -6,6 +6,16 @@ use crate::{ }; use std::path::Path; +pub(super) fn validate_authored_check_metadata( + plan: &SupervisorPlan, + metadata: &AssignmentMetadata, +) -> Result<()> { + for (parent_id, checks) in &metadata.authored_independent_checks { + authored_checks::validate_declarations(plan, parent_id, checks)?; + } + Ok(()) +} + pub(super) fn validate_assignment_phase_contract(plan: &SupervisorPlan) -> Result<()> { for assignment in &plan.assignments { if assignment.phase != AssignmentPhase::Planning { From c44b8d9267bcbcdc5efa323dbc5f5521f9cfba20 Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:24:02 +0900 Subject: [PATCH 07/27] Recover pending cancellation delivery after transient failures - When cancellation delivery fails after its submission is persisted, an identical retry resumes delivery under the journal lock. - Recovery revalidates authentication, deadline and target authority, keeps process identity checks and records no second submission. - An absent registry entry keeps the cancellation in the inbox; stale process identities stay LostChild and completed actions are not replayed. - Add restart, pre-registration, repeated failure, uncertain-stop, stale PID and refusal regressions. Validation: formatting, nine exact unit tests and five exact integration tests passed on Linux; Clippy, all-target compilation and the full platform suites run in this PR's CI. --- src/steering/plane.rs | 169 +++++++++++------ src/steering/tests.rs | 410 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 523 insertions(+), 56 deletions(-) diff --git a/src/steering/plane.rs b/src/steering/plane.rs index 5343cc87..148c3add 100644 --- a/src/steering/plane.rs +++ b/src/steering/plane.rs @@ -43,6 +43,16 @@ struct LiveSlot { pub struct SteeringPlane { repo: PathBuf, live: Arc>>, + #[cfg(test)] + cancel_failure: Arc>>, +} + +#[cfg(test)] +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum CancelFailurePoint { + BeforeInspection, + AfterDelivery, + BeforeAck, } impl SteeringPlane { @@ -58,6 +68,8 @@ impl SteeringPlane { Ok(Self { repo, live: Arc::new(Mutex::new(BTreeMap::new())), + #[cfg(test)] + cancel_failure: Arc::new(Mutex::new(None)), }) } @@ -530,6 +542,12 @@ impl SteeringPlane { if existing.request != request { return Ok(refused_ack(&request, SteeringRefusal::DuplicateAction)); } + if existing.refusal.is_none() + && existing.outcome == SteeringOutcome::Pending + && matches!(request.action, SteeringAction::CancelAssignment { .. }) + { + return self.deliver_and_record(journal, &request, now_unix_ms); + } return Ok(match existing.refusal { Some(refusal) => refused_ack(&request, refusal), None => accepted_ack(&request, existing.outcome), @@ -549,67 +567,82 @@ impl SteeringPlane { ), )?; - let delivery = self.deliver_locked(&request)?; - match delivery { - DeliveryResult::LostChild => { - append_event( - journal, + self.deliver_and_record(journal, &request, now_unix_ms) + }) + } + + fn deliver_and_record( + &self, + journal: &mut crate::state_journal::AuthenticatedStateJournal< + crate::steering::evidence::SteeringJournalSpec, + >, + request: &SteeringRequest, + now_unix_ms: u64, + ) -> Result { + let delivery = self.deliver_locked(request)?; + #[cfg(test)] + self.fail_cancel_at(request, CancelFailurePoint::AfterDelivery)?; + match delivery { + DeliveryResult::LostChild => { + append_event( + journal, + "lost_child", + &payload_for_request( "lost_child", - &payload_for_request( - "lost_child", - &request, - SteeringOutcome::LostChild, - None, - None, - now_unix_ms, - ), - )?; - Ok(accepted_ack(&request, SteeringOutcome::LostChild)) - } - DeliveryResult::CancelledAndAcked => { - append_event( - journal, + request, + SteeringOutcome::LostChild, + None, + None, + now_unix_ms, + ), + )?; + Ok(accepted_ack(request, SteeringOutcome::LostChild)) + } + DeliveryResult::CancelledAndAcked => { + append_event( + journal, + "deliver", + &payload_for_request( "deliver", - &payload_for_request( - "deliver", - &request, - SteeringOutcome::Delivered, - None, - None, - now_unix_ms, - ), - )?; - append_event( - journal, + request, + SteeringOutcome::Delivered, + None, + None, + now_unix_ms, + ), + )?; + #[cfg(test)] + self.fail_cancel_at(request, CancelFailurePoint::BeforeAck)?; + append_event( + journal, + "ack", + &payload_for_request( "ack", - &payload_for_request( - "ack", - &request, - SteeringOutcome::Acknowledged, - None, - None, - now_unix_ms, - ), - )?; - Ok(accepted_ack(&request, SteeringOutcome::Acknowledged)) - } - DeliveryResult::Mailbox => { - append_event( - journal, + request, + SteeringOutcome::Acknowledged, + None, + None, + now_unix_ms, + ), + )?; + Ok(accepted_ack(request, SteeringOutcome::Acknowledged)) + } + DeliveryResult::Mailbox => { + append_event( + journal, + "deliver", + &payload_for_request( "deliver", - &payload_for_request( - "deliver", - &request, - SteeringOutcome::Delivered, - None, - None, - now_unix_ms, - ), - )?; - Ok(accepted_ack(&request, SteeringOutcome::Delivered)) - } + request, + SteeringOutcome::Delivered, + None, + None, + now_unix_ms, + ), + )?; + Ok(accepted_ack(request, SteeringOutcome::Delivered)) } - }) + } } fn record_refusal( @@ -654,6 +687,8 @@ impl SteeringPlane { fn deliver_locked(&self, request: &SteeringRequest) -> Result { if matches!(request.action, SteeringAction::CancelAssignment { .. }) { + #[cfg(test)] + self.fail_cancel_at(request, CancelFailurePoint::BeforeInspection)?; if let Some(cancellation) = self.live_cancellation(&request.run_id, &request.assignment_id)? { @@ -675,6 +710,28 @@ impl SteeringPlane { Ok(DeliveryResult::Mailbox) } + #[cfg(test)] + pub(super) fn inject_cancel_failure( + &self, + request: &SteeringRequest, + point: CancelFailurePoint, + ) { + *self.cancel_failure.lock().expect("cancel failure lock") = + Some((request.run_id.clone(), request.action_id.clone(), point)); + } + + #[cfg(test)] + fn fail_cancel_at(&self, request: &SteeringRequest, point: CancelFailurePoint) -> Result<()> { + let mut failure = self.cancel_failure.lock().expect("cancel failure lock"); + if failure.as_ref() == Some(&(request.run_id.clone(), request.action_id.clone(), point)) + && matches!(request.action, SteeringAction::CancelAssignment { .. }) + { + *failure = None; + bail!("injected cancellation delivery failure"); + } + Ok(()) + } + fn live_cancellation( &self, run_id: &str, diff --git a/src/steering/tests.rs b/src/steering/tests.rs index 8ee9b323..e2553241 100644 --- a/src/steering/tests.rs +++ b/src/steering/tests.rs @@ -312,6 +312,416 @@ fn fake_cancel_is_provider_neutral() { assert!(decision.ack().steered); } +fn operator_cancel(now: u64) -> SteeringRequest { + let mut request = operator_inject("run-1", "task-1", "act-cancel", now); + request.action = SteeringAction::CancelAssignment { + reason: "operator abort".to_string(), + }; + request +} + +#[cfg(unix)] +struct CancellationChild(std::process::Child); + +#[cfg(unix)] +impl CancellationChild { + fn registered(path: &std::path::Path, run_id: &str) -> Self { + let program = [ + "/run/current-system/sw/bin/sleep", + "/usr/bin/sleep", + "/bin/sleep", + ] + .into_iter() + .find(|path| std::path::Path::new(path).is_file()) + .expect("sleep executable"); + let child = Self( + std::process::Command::new(program) + .arg("60") + .spawn() + .expect("spawn"), + ); + let registry = crate::agent_lifecycle::AgentRegistry::open(path).expect("registry"); + registry + .register( + &crate::agent_lifecycle::AgentLaunchMetadata::new(path, "worker", run_id, "task-1") + .expect("metadata"), + child.0.id(), + vec!["sleep".to_string(), "60".to_string()], + ) + .expect("register child"); + child + } +} + +#[cfg(unix)] +impl Drop for CancellationChild { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +#[cfg(unix)] +#[test] +fn pending_cancel_delivery_resumes_after_restart() { + use super::plane::CancelFailurePoint; + + let (_temp, path) = repo(); + let plane = SteeringPlane::open(&path).expect("open plane"); + plane + .register_assignment(worker_binding("run-1", "task-1")) + .expect("bind"); + let mut child = CancellationChild::registered(&path, "run-1"); + let mut other_run = CancellationChild::registered(&path, "run-other"); + let now = plane.current_unix_ms().expect("clock"); + let request = operator_cancel(now); + plane.inject_cancel_failure(&request, CancelFailurePoint::BeforeInspection); + assert!(plane.submit(request.clone(), now).is_err()); + assert!(child.0.try_wait().expect("poll child").is_none()); + drop(plane); + + let reopened = SteeringPlane::open(&path).expect("reopen plane"); + assert_eq!( + reopened.inbox("run-1", "task-1").expect("inbox")[0].outcome, + SteeringOutcome::Pending + ); + let mut changed = request.clone(); + changed.action = SteeringAction::CancelAssignment { + reason: "different".to_string(), + }; + assert_eq!( + reopened.submit(changed, now).expect("duplicate").refused(), + Some(SteeringRefusal::DuplicateAction) + ); + reopened.inject_cancel_failure(&request, CancelFailurePoint::BeforeInspection); + assert!(reopened.submit(request.clone(), now).is_err()); + assert!(child.0.try_wait().expect("poll child").is_none()); + drop(reopened); + + let recovered = SteeringPlane::open(&path).expect("reopen after second failure"); + assert_eq!( + recovered + .submit(request.clone(), now) + .expect("recover") + .ack() + .outcome, + SteeringOutcome::Acknowledged + ); + assert!(!child.0.wait().expect("stopped child").success()); + assert!(other_run.0.try_wait().expect("other run").is_none()); + let mut replacement = CancellationChild::registered(&path, "run-1"); + assert_eq!( + recovered + .submit(request, now) + .expect("terminal retry") + .ack() + .outcome, + SteeringOutcome::Acknowledged + ); + assert!(replacement.0.try_wait().expect("replacement").is_none()); + let events = recovered.evidence("run-1").expect("evidence"); + assert_eq!( + events + .iter() + .map(|event| event.event.as_str()) + .collect::>(), + ["bind", "submit", "deliver", "ack"] + ); +} + +#[cfg(unix)] +#[test] +fn pending_cancel_uncertain_stop_is_idempotent() { + assert_uncertain_cancel_retry( + false, + SteeringOutcome::Delivered, + &["bind", "submit", "deliver"], + ); +} + +#[cfg(unix)] +#[test] +fn pending_cancel_reused_pid_is_lost_without_signalling() { + assert_uncertain_cancel_retry( + true, + SteeringOutcome::LostChild, + &["bind", "submit", "lost_child"], + ); +} + +#[cfg(unix)] +fn assert_uncertain_cancel_retry( + reused_pid: bool, + expected: SteeringOutcome, + expected_events: &[&str], +) { + use super::plane::CancelFailurePoint; + + let (_temp, path) = repo(); + let plane = SteeringPlane::open(&path).expect("open plane"); + plane + .register_assignment(worker_binding("run-1", "task-1")) + .expect("bind"); + let mut child = CancellationChild::registered(&path, "run-1"); + let now = plane.current_unix_ms().expect("clock"); + let request = operator_cancel(now); + plane.inject_cancel_failure(&request, CancelFailurePoint::AfterDelivery); + assert!(plane.submit(request.clone(), now).is_err()); + assert!(!child.0.wait().expect("child already stopped").success()); + drop(plane); + + let mut sentinel = CancellationChild::registered(&path, "run-other"); + if reused_pid { + // Model a reused PID by retaining the live PID with a different + // recorded start time; the retry must never signal that process. + let registry = crate::agent_lifecycle::AgentRegistry::open(&path).expect("registry"); + let registry_path = registry.registry_path(); + let mut state: serde_json::Value = + serde_json::from_slice(&std::fs::read(®istry_path).expect("read registry")) + .expect("registry JSON"); + let record = &mut state["processes"][0]; + record["run_id"] = json!("run-1"); + let (incarnation, ticks) = record["process_start_time"] + .as_str() + .expect("start time") + .rsplit_once(':') + .expect("identity ticks"); + record["process_start_time"] = json!(format!( + "{incarnation}:{}", + ticks.parse::().expect("ticks") ^ 1 + )); + std::fs::write( + ®istry_path, + serde_json::to_vec(&state).expect("encode registry"), + ) + .expect("write stale identity"); + } + let reopened = SteeringPlane::open(&path).expect("reopen plane"); + assert_eq!( + reopened.inbox("run-1", "task-1").expect("inbox")[0].outcome, + SteeringOutcome::Pending + ); + let decision = reopened + .submit(request.clone(), now) + .expect("recover uncertain stop"); + assert_eq!(decision.ack().outcome, expected); + assert!(!decision.ack().steered); + assert!(sentinel.0.try_wait().expect("sentinel").is_none()); + let mut replacement = CancellationChild::registered(&path, "run-1"); + assert_eq!( + reopened + .submit(request, now) + .expect("duplicate retry") + .ack() + .outcome, + expected + ); + assert!(replacement.0.try_wait().expect("replacement").is_none()); + let events = reopened.evidence("run-1").expect("evidence"); + assert_eq!( + events + .iter() + .map(|event| event.event.as_str()) + .collect::>(), + expected_events + ); +} + +#[test] +fn pending_cancel_without_registered_child_recovers_to_mailbox() { + use super::plane::CancelFailurePoint; + + let (_temp, path) = repo(); + let plane = SteeringPlane::open(&path).expect("open plane"); + plane + .register_assignment(worker_binding("run-1", "task-1")) + .expect("bind"); + let now = plane.current_unix_ms().expect("clock"); + let request = operator_cancel(now); + plane.inject_cancel_failure(&request, CancelFailurePoint::BeforeInspection); + assert!(plane.submit(request.clone(), now).is_err()); + drop(plane); + + let reopened = SteeringPlane::open(&path).expect("reopen plane"); + assert_eq!( + reopened.inbox("run-1", "task-1").expect("pending inbox")[0].outcome, + SteeringOutcome::Pending + ); + let decision = reopened.submit(request, now).expect("recover to mailbox"); + assert_eq!(decision.ack().outcome, SteeringOutcome::Delivered); + assert!(!decision.ack().steered); + let inbox = reopened.inbox("run-1", "task-1").expect("delivered inbox"); + assert_eq!(inbox.len(), 1); + assert_eq!(inbox[0].outcome, SteeringOutcome::Delivered); + assert!(matches!( + inbox[0].action, + SteeringAction::CancelAssignment { .. } + )); + assert_eq!( + reopened + .evidence("run-1") + .expect("evidence") + .iter() + .map(|event| event.event.as_str()) + .collect::>(), + ["bind", "submit", "deliver"] + ); +} + +#[test] +fn pending_cancel_recovery_revalidates_authentication_deadline_and_authority() { + use super::plane::CancelFailurePoint; + + let (_temp, path) = repo(); + let plane = SteeringPlane::open(&path).expect("open plane"); + plane + .register_assignment(worker_binding("run-1", "task-1")) + .expect("bind"); + let now = plane.current_unix_ms().expect("clock"); + let request = operator_cancel(now); + plane.inject_cancel_failure(&request, CancelFailurePoint::BeforeInspection); + assert!(plane.submit(request.clone(), now).is_err()); + drop(plane); + + let reopened = SteeringPlane::open(&path).expect("reopen plane"); + let cancellation = ProcessCancellation::new(); + reopened + .register_assignment(worker_binding("run-1", "task-1")) + .expect("bind"); + reopened + .register_live_cancellation("run-1", "task-1", cancellation.clone()) + .expect("live cancellation"); + let mut signed = reopened.sign(&request).expect("sign"); + signed.mac = "ab".repeat(32); + assert_eq!( + reopened + .submit_signed(signed, now) + .expect("bad MAC") + .refused(), + Some(SteeringRefusal::Unauthenticated) + ); + assert_eq!( + reopened + .submit(request.clone(), request.deadline_unix_ms + 1) + .expect("expired") + .refused(), + Some(SteeringRefusal::DeadlineExpired) + ); + assert!(!cancellation.is_cancelled()); + assert_eq!( + reopened.inbox("run-1", "task-1").expect("still pending")[0].outcome, + SteeringOutcome::Pending + ); + + let mut binding = worker_binding("run-1", "task-1"); + binding.kind = AssignmentKind::ReviewGate; + reopened + .register_assignment(binding) + .expect("review binding"); + reopened + .register_live_cancellation("run-1", "task-1", cancellation.clone()) + .expect("live cancellation"); + drop(reopened); + let reopened = SteeringPlane::open(&path).expect("reopen changed authority"); + assert_eq!( + reopened + .submit(request, now) + .expect("authority refusal") + .refused(), + Some(SteeringRefusal::MergeBypass) + ); + assert!(!cancellation.is_cancelled()); + let events = reopened.evidence("run-1").expect("evidence"); + assert_eq!( + events + .iter() + .filter(|event| event.event == "submit") + .count(), + 1 + ); + assert!(events.iter().all(|event| event.event != "deliver")); +} + +#[test] +fn pending_mailbox_and_delivered_cancellation_are_not_redelivered() { + use super::{ + evidence::{append_event, payload_for_request, with_run_journal}, + plane::CancelFailurePoint, + }; + + let (_temp, path) = repo(); + let plane = SteeringPlane::open(&path).expect("open plane"); + plane + .register_assignment(worker_binding("run-1", "task-1")) + .expect("bind"); + let now = plane.current_unix_ms().expect("clock"); + let mailbox = operator_inject("run-1", "task-1", "act-mailbox", now); + with_run_journal(&path, "run-1", |journal| { + append_event( + journal, + "submit", + &payload_for_request( + "submit", + &mailbox, + SteeringOutcome::Pending, + None, + None, + now, + ), + ) + }) + .expect("persist interrupted mailbox submit"); + assert_eq!( + plane + .submit(mailbox, now) + .expect("mailbox retry") + .ack() + .outcome, + SteeringOutcome::Pending + ); + + let cancellation = ProcessCancellation::new(); + plane + .register_live_cancellation("run-1", "task-1", cancellation.clone()) + .expect("live cancellation"); + let request = operator_cancel(now); + plane.inject_cancel_failure(&request, CancelFailurePoint::BeforeAck); + assert!(plane.submit(request.clone(), now).is_err()); + assert!(cancellation.is_cancelled()); + drop(plane); + let reopened = SteeringPlane::open(&path).expect("reopen plane"); + reopened + .register_assignment(worker_binding("run-1", "task-1")) + .expect("bind"); + let replacement = ProcessCancellation::new(); + reopened + .register_live_cancellation("run-1", "task-1", replacement.clone()) + .expect("replacement"); + let before = reopened.evidence("run-1").expect("evidence"); + assert_eq!( + reopened + .submit(request.clone(), now) + .expect("delivered retry") + .ack() + .outcome, + SteeringOutcome::Delivered + ); + assert!(!replacement.is_cancelled()); + assert_eq!(reopened.evidence("run-1").expect("evidence"), before); + reopened + .acknowledge("run-1", "task-1", &request.action_id, now) + .expect("acknowledge"); + assert_eq!( + reopened + .submit(request, now) + .expect("acknowledged retry") + .ack() + .outcome, + SteeringOutcome::Acknowledged + ); + assert!(!replacement.is_cancelled()); +} + #[test] fn cursor_adapter_launch_is_cancelled_through_the_neutral_handle() { let (_temp, path) = repo(); From 075156843a86a4e05d698698812dccbf114fe474 Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:24:13 +0900 Subject: [PATCH 08/27] Detect owned target exit during private-channel startup - When a contained runtime exits before its first private-channel frame, stop waiting once its exact target exits. - Pin the authenticated target at the start gate with a pidfd and keep conservative deadline behavior when exit evidence is unavailable. - Keep bounded diagnostics, ordinary terminal results and verified process-tree cleanup; incomplete handshakes remain failures. - Add early-exit, silent-target, unknown-identity and held-incarnation regressions. Validation: formatting passed locally on Linux; the new tests, all-target compilation, Clippy and the full platform suites run in this PR's CI. --- src/external_agent/gemini_bridge.rs | 29 ++++- src/process_runner.rs | 11 +- src/process_runner/part3.rs | 71 ++++++++++ src/process_runner/tests.rs | 194 ++++++++++++++++++++++++++++ 4 files changed, 298 insertions(+), 7 deletions(-) diff --git a/src/external_agent/gemini_bridge.rs b/src/external_agent/gemini_bridge.rs index 569ba097..e2b829bd 100644 --- a/src/external_agent/gemini_bridge.rs +++ b/src/external_agent/gemini_bridge.rs @@ -1168,7 +1168,7 @@ pub(super) fn run( report } -#[cfg(all(target_os = "linux", test))] +#[cfg(target_os = "linux")] fn bootstrap_startup_category(stderr: &CapturedBytes) -> Option<&'static str> { if stderr.is_truncated() { return None; @@ -1184,7 +1184,7 @@ fn bootstrap_startup_category(stderr: &CapturedBytes) -> Option<&'static str> { } } -#[cfg(all(target_os = "linux", test))] +#[cfg(target_os = "linux")] fn bootstrap_startup_error(error: Option, stderr: &CapturedBytes) -> Option { let Some(category) = bootstrap_startup_category(stderr) else { return error; @@ -1787,7 +1787,9 @@ fn run_linux_online( report.side_effects = Some(output.process.side_effects); protocol.evidence.no_release_quiescent = !protocol.evidence.provider_released && output.process.safety_evidence_verified(); - if let Err(error) = output.interaction { + if let Some(error) = + bootstrap_startup_error(output.interaction.err(), &output.process.stderr) + { record_external_error(report, error); } if output.process.timed_out { @@ -2940,14 +2942,31 @@ mod tests { let held = run .gemini_bridge_evidence() .with_context(|| format!("no held bridge evidence: {:?}", run.error))?; - assert!(held.ready, "{:?}", run.error); assert!(held.no_release_quiescent, "{:?}", run.error); assert!(!held.provider_released); assert_eq!(held.tokens, None); assert!(run.scratch_quiescence_verified()); assert!(!run.succeeded()); assert!(!run.publishable); - assert_eq!(run.exit_code, Some(0)); + if held.ready { + assert_eq!(run.exit_code, Some(0)); + } else { + // An installed bootstrap may reject its profile before hello. That is a + // prompt, quiescent startup failure, never a runtime qualification. + assert!(!run.timed_out); + assert!(started.elapsed() < command.timeout); + assert!(run.exit_code.is_some_and(|code| code != 0)); + let error = run.error.as_deref().context("missing startup failure")?; + assert!( + error + .contains("contained private-channel target exited before protocol completion"), + "{error}" + ); + assert!( + error.contains("Gemini bootstrap startup: pre_handshake_"), + "{error}" + ); + } let restored: ExternalAgentRun = serde_json::from_slice(&serde_json::to_vec(&run)?)?; assert!(restored.gemini_bridge_evidence().is_none()); Ok(()) diff --git a/src/process_runner.rs b/src/process_runner.rs index 28373f81..49e76af6 100644 --- a/src/process_runner.rs +++ b/src/process_runner.rs @@ -3740,7 +3740,11 @@ fn run_process_cancellable_with_interaction( .target_pid(&mut child, operation_deadline, cancellation) .and_then(|pid| { crate::agent_lifecycle::process_start_time(pid) - .map(|start| (pid, start, unit.cgroup_path.clone())) + .map(|start| { + let watch = + PrivateTargetExitWatch::capture(pid, &start, &unit.cgroup_path); + ((pid, start, unit.cgroup_path.clone()), watch) + }) .map_err(|error| std::io::Error::other(error.to_string())) }), _ => Err(std::io::Error::other( @@ -3813,7 +3817,10 @@ fn run_process_cancellable_with_interaction( // Capture the guardian-published target identity while the target is still held // at the start gate. Waiting after release can consume the operation deadline // before the private-channel handler starts. - session.peer_binding = private_peer_binding; + if let Some((binding, watch)) = private_peer_binding { + session.peer_binding = Some(binding); + session.private_target = watch; + } } if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| handler(&mut session))) .is_err() diff --git a/src/process_runner/part3.rs b/src/process_runner/part3.rs index e34cf0fc..2870f629 100644 --- a/src/process_runner/part3.rs +++ b/src/process_runner/part3.rs @@ -3083,6 +3083,8 @@ impl PreparedChildIo { Ok(ContainedProcessSession { #[cfg(target_os = "linux")] peer_binding: None, + #[cfg(target_os = "linux")] + private_target: None, label: label.to_string(), cancellation, operation_deadline, @@ -3129,6 +3131,8 @@ impl ContainedProcessStartGate<'_> { pub(crate) struct ContainedProcessSession<'a> { #[cfg(target_os = "linux")] peer_binding: Option<(u32, String, PathBuf)>, + #[cfg(target_os = "linux")] + private_target: Option, label: String, cancellation: &'a ProcessCancellation, operation_deadline: Option, @@ -3141,6 +3145,62 @@ pub(crate) struct ContainedProcessSession<'a> { output_drainers: OutputDrainers, } +#[cfg(target_os = "linux")] +#[derive(Debug, PartialEq, Eq)] +enum PrivateTargetState { + Alive, + Exited, + Unknown, +} + +#[cfg(target_os = "linux")] +struct PrivateTargetExitWatch(std::os::fd::OwnedFd); + +#[cfg(target_os = "linux")] +impl PrivateTargetExitWatch { + // Called while the guardian still holds its authenticated target at the start gate. + // Failure to pin that incarnation leaves exit evidence unknown, never confirmed. + fn capture(pid: u32, start: &str, cgroup: &Path) -> Option { + use std::os::fd::FromRawFd; + + let pid = libc::pid_t::try_from(pid).ok().filter(|pid| *pid > 0)?; + // SAFETY: pidfd_open consumes a positive PID and zero flags, without pointers. + let descriptor = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0_u32) }; + if descriptor < 0 { + return None; + } + // SAFETY: a successful pidfd_open returns a fresh owned descriptor. + let descriptor = unsafe { std::os::fd::OwnedFd::from_raw_fd(descriptor as i32) }; + let same_start = crate::agent_lifecycle::process_start_time(pid as u32) + .is_ok_and(|observed| observed == start); + let member = fs::read_to_string(cgroup.join("cgroup.procs")) + .is_ok_and(|members| members.lines().any(|line| line.trim() == pid.to_string())); + (same_start && member).then_some(Self(descriptor)) + } + + fn observe(&self) -> PrivateTargetState { + use std::os::fd::AsRawFd; + + let mut descriptor = libc::pollfd { + fd: self.0.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + // SAFETY: poll borrows one initialized descriptor; timeout zero never waits. + let result = unsafe { libc::poll(&mut descriptor, 1, 0) }; + if result < 0 || descriptor.revents & (libc::POLLERR | libc::POLLNVAL) != 0 { + PrivateTargetState::Unknown + } else if descriptor.revents & libc::POLLIN != 0 { + // This handle stays bound to the captured incarnation even after PID reuse. + PrivateTargetState::Exited + } else if result == 0 { + PrivateTargetState::Alive + } else { + PrivateTargetState::Unknown + } + } +} + #[cfg(target_os = "linux")] fn private_peer_start_refusal( expected: &str, @@ -3221,9 +3281,20 @@ impl ContainedProcessSession<'_> { pub(crate) fn check_private_channel_live(&mut self) -> Result<(), String> { self.ensure_interactive_live()?; self.output_drainers.drain_ready(); + #[cfg(target_os = "linux")] + if self.private_target_state() == PrivateTargetState::Exited { + return self.fail_io("contained private-channel target exited before protocol completion"); + } Ok(()) } + #[cfg(target_os = "linux")] + fn private_target_state(&self) -> PrivateTargetState { + self.private_target + .as_ref() + .map_or(PrivateTargetState::Unknown, PrivateTargetExitWatch::observe) + } + pub(crate) fn receive_line( &mut self, wait: Duration, diff --git a/src/process_runner/tests.rs b/src/process_runner/tests.rs index b5280b10..afbc97fe 100644 --- a/src/process_runner/tests.rs +++ b/src/process_runner/tests.rs @@ -685,6 +685,199 @@ fn verified_contained_interactive_session_proves_tree_and_side_effects() { assert!(result.process.safety_evidence_verified()); } +#[cfg(target_os = "linux")] +#[test] +fn private_channel_reports_owned_target_exit_before_deadline() { + let temp = tempfile::tempdir().expect("tempdir"); + let spec = private_channel_fixture( + temp.path(), + "printf '%s\\n' 'bootstrap: pre_handshake_profile' 'credential-secret' >&2; exit 23", + ); + let unit_capture = TestSystemdUnitNameCapture::start(); + let result = run_process_interactive(spec, &ProcessCancellation::new(), |session| { + assert!( + session.private_target.is_some(), + "target must be pinned before release" + ); + let started = Instant::now(); + loop { + if let Err(error) = session.check_private_channel_live() { + assert!(started.elapsed() < Duration::from_secs(5)); + return Err::<(), _>(error); + } + thread::sleep(Duration::from_millis(2)); + } + }); + let unit_names = unit_capture.finish(); + let result = match result { + Ok(result) => result, + Err(error) if is_verified_backend_unavailable(&error) => { + report_verified_backend_unavailable_skip(&error, &unit_names); + return; + } + Err(error) => panic!("private-channel runner failed: {error:?}"), + }; + let error = result.interaction.unwrap_err(); + assert_eq!( + error, + "contained private-channel target exited before protocol completion" + ); + assert_eq!(result.process.stdin_error.as_deref(), Some(error.as_str())); + assert_eq!( + result.process.status.and_then(|status| status.code()), + Some(23) + ); + assert!(!result.process.timed_out); + assert!(result.process.safety_evidence_verified()); + assert!(String::from_utf8_lossy(result.process.stderr.as_bytes()) + .contains("bootstrap: pre_handshake_profile")); + assert!(!error.contains("credential-secret")); + assert_systemd_units_have_no_residue(&unit_names); +} + +#[cfg(target_os = "linux")] +#[test] +fn private_channel_live_silent_target_keeps_deadline() { + let temp = tempfile::tempdir().expect("tempdir"); + let spec = private_channel_fixture(temp.path(), "read -r _"); + let unit_capture = TestSystemdUnitNameCapture::start(); + let result = run_process_interactive( + spec, + &ProcessCancellation::new(), + |session| -> Result<(), String> { + assert_eq!(session.private_target_state(), PrivateTargetState::Alive); + loop { + session.check_private_channel_live()?; + thread::sleep(Duration::from_millis(2)); + } + }, + ); + let unit_names = unit_capture.finish(); + let result = match result { + Ok(result) => result, + Err(error) if is_verified_backend_unavailable(&error) => { + report_verified_backend_unavailable_skip(&error, &unit_names); + return; + } + Err(error) => panic!("private-channel runner failed: {error:?}"), + }; + assert_eq!( + result.interaction.unwrap_err(), + "contained interactive session reached its operation deadline" + ); + assert!(result.process.timed_out); + assert!(result.process.safety_evidence_verified()); + assert_systemd_units_have_no_residue(&unit_names); +} + +#[cfg(target_os = "linux")] +fn private_channel_fixture(root: &Path, script: &str) -> ProcessSpec { + let workspace = root.join("workspace"); + let control = root.join("control"); + let profile = control.join("profile"); + fs::create_dir_all(&workspace).unwrap(); + fs::create_dir_all(&profile).unwrap(); + ProcessSpec::shell( + "private-channel target fixture", + Shell::UnixSh, + script, + &workspace, + 1024, + ) + .with_stdin_limit(64) + .with_timeout(Some(CONTENTION_RESILIENT_PROCESS_TEST_TIMEOUT)) + .with_side_effect_confinement(SideEffectConfinementProfile::GeminiOfflineBridge( + GeminiOfflineBridgeProfile::new(&workspace, &control, &profile), + )) +} + +#[cfg(target_os = "linux")] +#[test] +fn private_channel_exit_watch_is_bound_to_the_held_incarnation() { + let temp = tempfile::tempdir().unwrap(); + let mut child = Command::new("/bin/sh") + .args(["-c", "read -r _; exit 19"]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let pid = child.id(); + let start = crate::agent_lifecycle::process_start_time(pid).unwrap(); + // Controlled membership fixture; containment is exercised separately above. + fs::write(temp.path().join("cgroup.procs"), format!("{pid}\n")).unwrap(); + let watch = PrivateTargetExitWatch::capture(pid, &start, temp.path()); + let before = watch.as_ref().map(PrivateTargetExitWatch::observe); + drop(child.stdin.take()); + let status = child.wait().unwrap(); + assert_eq!(status.code(), Some(19)); + assert_eq!(before, Some(PrivateTargetState::Alive)); + // Reaping and rebinding the membership path cannot retarget the held descriptor. + fs::write( + temp.path().join("cgroup.procs"), + format!("{}\n", std::process::id()), + ) + .unwrap(); + assert_eq!(watch.unwrap().observe(), PrivateTargetState::Exited); + assert!(PrivateTargetExitWatch::capture(std::process::id(), &start, temp.path()).is_none()); +} + +#[cfg(target_os = "linux")] +#[test] +fn private_channel_unknown_target_never_claims_exit() { + let pid = std::process::id(); + let start = crate::agent_lifecycle::process_start_time(pid).unwrap(); + let temp = tempfile::tempdir().unwrap(); + let cancellation = ProcessCancellation::new(); + let empty_reader = |stream| PipeReader { + stream, + receiver: None, + thread: None, + tee_helper: None, + capture: BoundedBuffer::new(64), + complete: true, + error: None, + }; + let mut session = ContainedProcessSession { + peer_binding: Some((pid, start.clone(), temp.path().to_path_buf())), + private_target: None, + label: "private-channel unknown target fixture".into(), + cancellation: &cancellation, + operation_deadline: None, + stdin: None, + stdin_bytes_written: 0, + max_stdin_bytes: 64, + pending_stdout: Vec::new(), + stdout_eof: true, + io_error: None, + output_drainers: OutputDrainers { + stdout: empty_reader("stdout"), + stderr: empty_reader("stderr"), + label: "private-channel unknown target fixture".into(), + }, + }; + // Neither EOF nor unavailable cgroup evidence establishes the captured target's exit. + assert!(PrivateTargetExitWatch::capture(pid, &start, temp.path()).is_none()); + assert_eq!(session.private_target_state(), PrivateTargetState::Unknown); + assert!(session.check_private_channel_live().is_ok()); + fs::write(temp.path().join("cgroup.procs"), format!("{pid}\n")).unwrap(); + // A rebound PID must not acquire a watch for a different start-time identity. + assert!(PrivateTargetExitWatch::capture(pid, "private-identity-secret", temp.path()).is_none()); + assert!(PrivateTargetExitWatch::capture(0, &start, temp.path()).is_none()); + assert!(session.check_private_channel_live().is_ok()); + assert!(session.io_error.is_none()); + session.operation_deadline = Some(Instant::now()); + assert_eq!( + session.check_private_channel_live().unwrap_err(), + "contained interactive session reached its operation deadline" + ); + cancellation.cancel(); + assert_eq!( + session.check_private_channel_live().unwrap_err(), + "contained interactive session was cancelled" + ); +} + #[cfg(target_os = "linux")] #[test] fn private_peer_identity_refusal_is_fixed_and_preserves_guard_order() { @@ -708,6 +901,7 @@ fn private_peer_identity_refusal_is_fixed_and_preserves_guard_order() { // This exercises the peer guard directly, not launched-child containment. let session = |peer_binding| ContainedProcessSession { peer_binding, + private_target: None, label: "private peer guard fixture".into(), cancellation: &cancellation, operation_deadline: None, From 9ae159f6191a2671926b5b0f438a6f15c61bb404 Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:24:25 +0900 Subject: [PATCH 09/27] Persist supervisor-owned target containment receipts - Persist private target process-tree and side-effect confinement observations beside each command summary. - Bind receipts to the supervisor lifecycle, exact attempt, runtime and summary digest, separately from fixed-version probes and provider reports. - Keep missing legacy observations unknown and restored evidence diagnostic-only. - Cover target/probe distinctions, tampering, cross-attempt substitution and failed returned attempts without changing execution authority. Validation: formatting and the new receipt tests passed on Linux; all-target compilation, Clippy and the full platform suites run in this PR's CI. --- src/supervise.rs | 1 + src/supervise/assignment_execution.rs | 16 +- .../managed_parent_controller_tests.rs | 40 +- src/supervise/reporting.rs | 16 +- src/supervise/target_containment.rs | 612 ++++++++++++++++++ src/supervise/tests/run_artifacts.rs | 10 + 6 files changed, 675 insertions(+), 20 deletions(-) create mode 100644 src/supervise/target_containment.rs diff --git a/src/supervise.rs b/src/supervise.rs index 95e5af76..90991362 100644 --- a/src/supervise.rs +++ b/src/supervise.rs @@ -433,6 +433,7 @@ use acceptance::*; use researcher::*; mod reporting; +mod target_containment; #[cfg(all(test, target_os = "linux"))] pub(crate) use reporting::append_precreated_worker_execution_journal; #[cfg(target_os = "linux")] diff --git a/src/supervise/assignment_execution.rs b/src/supervise/assignment_execution.rs index a017ab25..04820ddf 100644 --- a/src/supervise/assignment_execution.rs +++ b/src/supervise/assignment_execution.rs @@ -4041,10 +4041,10 @@ fn dispatch_and_capture_child_attempt_with_release_state<'a>( state.store(1, Ordering::Release); } } - // Retain returned continuation bytes before checkpoints, settlement diagnostics, + // Retain returned target observations before checkpoints, settlement diagnostics, // or envelope validation can return early. This does not authorize or clean scratch. let returned_evidence = match &external_run_result { - Ok(run) if command.codex_managed_readonly_continuation_enabled() => { + Ok(run) => { let imported = preserve_returned_parent_evidence( context, &attempt_artifacts, @@ -4052,7 +4052,9 @@ fn dispatch_and_capture_child_attempt_with_release_state<'a>( launch_runtime, run, ); - if !run.scratch_quiescence_verified() { + if command.codex_managed_readonly_continuation_enabled() + && !run.scratch_quiescence_verified() + { if release_state.is_some() { // Isolated continuation owns a separate failure disposition. // Keep every real returned resource alive; its controller must @@ -4213,6 +4215,7 @@ fn preserve_returned_parent_evidence( run: &ExternalAgentRun, ) -> Result<()> { with_supervisor_artifacts(context.artifacts, |writer, _| { + super::target_containment::persist(writer, artifacts, command, runtime, run)?; let bytes = run.stdout_bytes(); if bytes.len() > MAX_SUPERVISOR_REPORT_BYTES { bail!("descriptor-held parent stdout exceeds its configured byte limit"); @@ -4222,13 +4225,6 @@ fn preserve_returned_parent_evidence( bytes, ArtifactFileDisposition::PrivateEvidence, )?; - write_artifact_json( - writer, - &artifacts.command_record_relative, - &command_record_from_external_for_runtime(run, command, runtime), - MAX_SUPERVISOR_REPORT_BYTES, - ArtifactFileDisposition::PrivateEvidence, - )?; Ok(()) }) .context("managed parent returned evidence import failed") diff --git a/src/supervise/assignment_execution/managed_parent_controller_tests.rs b/src/supervise/assignment_execution/managed_parent_controller_tests.rs index fe15b1b9..f64f7f39 100644 --- a/src/supervise/assignment_execution/managed_parent_controller_tests.rs +++ b/src/supervise/assignment_execution/managed_parent_controller_tests.rs @@ -419,6 +419,7 @@ fn exercise_retained_failure( use crate::external_agent::{ CapturedOutput, CodexParentEvidence, CodexParentResolvedField, CodexParentTurnUsage, }; + use std::os::unix::fs::PermissionsExt; let checkpoint_failure = case == "managed-cycle-retained-checkpoint-error"; let continuation_failure = case == "managed-cycle-retained-continuation" || checkpoint_failure; let import_failure = case == "managed-cycle-retained-import-error"; @@ -427,7 +428,9 @@ fn exercise_retained_failure( .run_dir .join(&prepared.attempt_artifacts.raw_stdout_relative); if import_failure { - fs::create_dir_all(blocked_raw.parent().unwrap())?; + let logs = blocked_raw.parent().unwrap(); + fs::create_dir_all(logs)?; + fs::set_permissions(logs, fs::Permissions::from_mode(0o700))?; fs::create_dir(&blocked_raw)?; } let retained = Mutex::new(Vec::<(PathBuf, Vec)>::new()); @@ -617,6 +620,41 @@ fn exercise_retained_failure( Ok(()) })?; assert!(!initial_scratch.exists()); + for (relative, _) in retained.lock().unwrap().iter() { + let summary = relative.with_extension("summary.json"); + let receipt = relative.with_extension("summary.target-containment.json"); + with_supervisor_artifacts(context.artifacts, |writer, _| { + let summary_bytes = writer.read_authenticated(&summary, MAX_SUPERVISOR_REPORT_BYTES)?; + let bytes = writer.read_authenticated(&receipt, 16 * 1024)?; + let receipt: serde_json::Value = serde_json::from_slice(&bytes)?; + assert_eq!(receipt["version"], 1); + assert_eq!( + receipt["binding"]["run_id"], + context.options.run_id.as_str() + ); + assert_eq!(receipt["binding"]["task_id"], "parent"); + assert_eq!(receipt["binding"]["runtime"], "codex"); + assert_eq!( + receipt["binding"]["command_summary"], + serde_json::to_value(&summary)? + ); + assert_eq!( + receipt["command_summary_sha256"], + crate::artifacts::state_auth::sha256_hex(&summary_bytes) + ); + assert_eq!(receipt["target_process_tree"]["status"], "observed"); + assert_eq!( + receipt["target_process_tree"]["evidence"]["status"], + "verified_empty" + ); + assert_eq!(receipt["target_side_effects"]["status"], "observed"); + assert_eq!( + receipt["target_side_effects"]["evidence"]["status"], + "verified" + ); + Ok(()) + })?; + } if !import_failure { for (relative, raw) in retained.lock().unwrap().iter() { let imported = fs::read(context.run_dir.join(relative))?; diff --git a/src/supervise/reporting.rs b/src/supervise/reporting.rs index fced77fc..36dbf0df 100644 --- a/src/supervise/reporting.rs +++ b/src/supervise/reporting.rs @@ -798,6 +798,13 @@ pub(super) fn import_external_attempt_evidence( runtime, } = context; let import_result = (|| -> Result<()> { + super::target_containment::persist( + writer, + artifacts, + external_command, + runtime, + external_run, + )?; if raw_report_validated { if let Some(contents) = external_run.output_last_message() { if contents.len() > MAX_SUPERVISOR_REPORT_BYTES { @@ -825,15 +832,6 @@ pub(super) fn import_external_attempt_evidence( stdout_bytes, ArtifactFileDisposition::PrivateEvidence, )?; - let command_record = - command_record_from_external_for_runtime(external_run, external_command, runtime); - write_artifact_json( - writer, - &artifacts.command_record_relative, - &command_record, - MAX_SUPERVISOR_REPORT_BYTES, - ArtifactFileDisposition::PrivateEvidence, - )?; Ok(()) })(); diff --git a/src/supervise/target_containment.rs b/src/supervise/target_containment.rs new file mode 100644 index 00000000..e8124fa2 --- /dev/null +++ b/src/supervise/target_containment.rs @@ -0,0 +1,612 @@ +//! Private diagnostic receipts. Restored observations never carry execution authority. + +use super::*; +use crate::artifacts::state_auth::sha256_hex; + +const VERSION: u32 = 1; +const MAX_RECEIPT_BYTES: usize = 16 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct TargetBinding { + run_id: String, + task_id: String, + parent: Option, + attempt: usize, + role: String, + runtime: SupervisorRuntime, + command_summary: PathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(tag = "status", content = "evidence", rename_all = "snake_case")] +enum Observation { + Unknown, + Observed(T), +} + +impl From> for Observation { + fn from(value: Option) -> Self { + value.map_or(Self::Unknown, Self::Observed) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct TargetContainmentReceipt { + version: u32, + binding: TargetBinding, + command_summary_sha256: String, + target_launch_attempted: bool, + target_process_tree: Observation, + target_side_effects: Observation, +} + +fn receipt_path(summary: &Path) -> PathBuf { + summary.with_extension("target-containment.json") +} + +fn binding( + run_dir: &Path, + artifacts: &ChildAttemptArtifacts, + command: &ExternalAgentCommand, + runtime: SupervisorRuntime, +) -> Result { + let identity = command + .agent_lifecycle + .as_ref() + .context("target receipt requires supervisor lifecycle identity")?; + let attempt = command + .assignment_process_launch_attempt + .filter(|attempt| *attempt > 0) + .context("target receipt requires a supervisor launch attempt")?; + if run_dir.file_name().and_then(|name| name.to_str()) != Some(&identity.run_id) + || normalize_agent_id(&identity.task_id)? != identity.task_id + || identity.role.trim().is_empty() + || command.prompt != artifacts.prompt_path + || command.json_log != artifacts.log_path + || command.output_last_message != artifacts.report_path + || (runtime != SupervisorRuntime::Fake + && command + .invocation + .adapter_id() + .and_then(|id| id.to_runtime_id()) + != Some(runtime)) + { + bail!("target receipt lifecycle, command, or runtime binding mismatch"); + } + let summary = &artifacts.command_record_relative; + let plain = PathBuf::from("logs").join(format!("{}.summary.json", identity.task_id)); + let numbered = PathBuf::from("logs").join(format!( + "{}.attempt-{attempt}.summary.json", + identity.task_id + )); + let nested = identity.parent.as_ref().map(|parent| { + PathBuf::from("nested") + .join(parent) + .join(format!("attempt-{attempt}")) + .join(&identity.task_id) + .join("command.json") + }); + if summary != &plain && summary != &numbered && nested.as_ref() != Some(summary) { + bail!("target receipt command summary does not match the lifecycle attempt"); + } + Ok(TargetBinding { + run_id: identity.run_id.clone(), + task_id: identity.task_id.clone(), + parent: identity.parent.clone(), + attempt, + role: identity.role.clone(), + runtime, + command_summary: summary.clone(), + }) +} + +pub(super) fn persist( + writer: &mut ArtifactRunWriter, + artifacts: &ChildAttemptArtifacts, + command: &ExternalAgentCommand, + runtime: SupervisorRuntime, + run: &ExternalAgentRun, +) -> Result<()> { + let binding = binding(writer.run_dir(), artifacts, command, runtime)?; + let record = command_record_from_external_for_runtime(run, command, runtime); + let mut summary = serde_json::to_vec_pretty(&record)?; + summary.push(b'\n'); + if summary.len() > MAX_SUPERVISOR_REPORT_BYTES { + bail!("target command summary exceeds its configured byte limit"); + } + let receipt = TargetContainmentReceipt { + version: VERSION, + binding, + command_summary_sha256: sha256_hex(&summary), + target_launch_attempted: run.stdout.target_launch_attempted, + target_process_tree: run.process_tree.into(), + target_side_effects: run.side_effects.into(), + }; + let relative = receipt_path(&artifacts.command_record_relative); + // Returned-parent capture and ordinary import can observe the same held run. + // Refuse contradictory repeats rather than replacing the earlier observation. + match fs::symlink_metadata(writer.run_dir().join(&relative)) { + Ok(_) => { + let bytes = writer.read_authenticated(&relative, MAX_RECEIPT_BYTES)?; + let retained_summary = writer.read_authenticated( + &artifacts.command_record_relative, + MAX_SUPERVISOR_REPORT_BYTES, + )?; + let existing = validate(&bytes, &receipt.binding, &retained_summary)?; + if existing != receipt { + bail!("target receipt contradicts a previously retained observation"); + } + return Ok(()); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + writer.write_bytes( + &artifacts.command_record_relative, + &summary, + ArtifactFileDisposition::PrivateEvidence, + )?; + write_artifact_json( + writer, + &relative, + &receipt, + MAX_RECEIPT_BYTES, + ArtifactFileDisposition::PrivateEvidence, + )?; + let summary = writer.read_authenticated( + &artifacts.command_record_relative, + MAX_SUPERVISOR_REPORT_BYTES, + )?; + let bytes = writer.read_authenticated(&relative, MAX_RECEIPT_BYTES)?; + validate(&bytes, &receipt.binding, &summary)?; + Ok(()) +} + +fn validate( + bytes: &[u8], + expected: &TargetBinding, + summary: &[u8], +) -> Result { + let receipt: TargetContainmentReceipt = serde_json::from_slice(bytes)?; + if receipt.version != VERSION + || &receipt.binding != expected + || receipt.command_summary_sha256 != sha256_hex(summary) + { + bail!("target receipt version, attempt identity, or command summary hash mismatch"); + } + Ok(receipt) +} + +// Diagnostic readback only. In particular, neither this type nor the receipt can +// be converted to a launch grant, cleanup proof, lease release, or accepted report. +#[derive(Debug, PartialEq, Eq)] +enum RestoredTargetContainment { + UnknownLegacy, + Retained(TargetContainmentReceipt), +} + +#[allow(dead_code)] // Kept private until an operator diagnostic consumer needs readback. +fn read(reader: &ArtifactRunReader, expected: &TargetBinding) -> Result { + if reader.finalization().run_id != expected.run_id { + bail!("target receipt expected run does not match the authenticated manifest"); + } + let summary_record = reader + .finalization() + .files + .iter() + .find(|file| file.path == expected.command_summary) + .context("target command summary is absent from the authenticated manifest")?; + if summary_record.disposition != ArtifactFileDisposition::PrivateEvidence + || summary_record.bytes > MAX_SUPERVISOR_REPORT_BYTES as u64 + { + bail!("target command summary must be bounded private evidence"); + } + let summary = reader.read(&expected.command_summary)?; + let relative = receipt_path(&expected.command_summary); + let record = reader + .finalization() + .files + .iter() + .find(|file| file.path == relative); + let Some(record) = record else { + return Ok(RestoredTargetContainment::UnknownLegacy); + }; + if record.disposition != ArtifactFileDisposition::PrivateEvidence + || record.bytes > MAX_RECEIPT_BYTES as u64 + { + bail!("target receipt must be bounded private evidence"); + } + let receipt = validate(&reader.read(relative)?, expected, &summary)?; + Ok(RestoredTargetContainment::Retained(receipt)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + external_agent::{ + EnvironmentExecutable, EnvironmentFixedVersionProbeEvidence, + EnvironmentFixedVersionProbeStream, + }, + process_runner::{ContainmentBackend, SideEffectConfinementProfileKind}, + }; + + struct Fixture { + _temp: tempfile::TempDir, + repo: PathBuf, + run_id: RunId, + writer: ArtifactRunWriter, + artifacts: ChildAttemptArtifacts, + command: ExternalAgentCommand, + } + + fn fixture() -> Fixture { + let (temp, repo) = super::super::tests::injected_repository(); + let run_id = RunId::new("target-receipt-test").unwrap(); + let writer = + ArtifactRunWriter::reserve(&repo, RunArtifactFamily::Supervise, run_id.clone(), "test") + .unwrap(); + let dirs = RunDirs::for_writer(&writer); + let artifacts = child_attempt_artifacts( + &dirs, + &dirs.run_dir.join("incoming"), + &dirs.run_dir.join("capture"), + "research", + 2, + true, + ); + let mut command = ExternalAgentCommand::codex( + "unused-codex", + &repo, + &artifacts.prompt_path, + &artifacts.log_path, + &artifacts.report_path, + Duration::from_secs(1), + ) + .with_agent_lifecycle(&repo, "researcher", run_id.as_str(), "research"); + command.assignment_process_launch_attempt = Some(2); + Fixture { + _temp: temp, + repo, + run_id, + writer, + artifacts, + command, + } + } + + fn finalize(mut writer: ArtifactRunWriter, repo: &Path, run_id: &RunId) -> ArtifactRunReader { + let report = super::super::tests::artifact_test_final_report(run_id); + write_final_report(&mut writer, &report).unwrap(); + writer + .finalize( + RunArtifactFamily::Supervise.final_report_relative_path(), + false, + ) + .unwrap(); + ArtifactRunReader::open(repo, RunArtifactFamily::Supervise, run_id).unwrap() + } + + #[test] + fn target_receipt_binds_attempt_and_distinguishes_probe() { + let mut fixture = fixture(); + let mut run = deterministic_fake_run(&fixture.command, Vec::new()); + run.process_tree = Some(ProcessTreeEvidence::Unverified( + ContainmentBackend::SystemdUserService, + )); + run.side_effects = Some(SideEffectConfinementEvidence::Unverified( + SideEffectConfinementProfileKind::ExternalCodex, + )); + let stream = EnvironmentFixedVersionProbeStream { + text: String::new(), + truncated: false, + }; + run.attach_fixed_version_probe_evidence_for_test(EnvironmentFixedVersionProbeEvidence { + executable: EnvironmentExecutable::Git, + exit_code: Some(0), + timed_out: false, + stdout: stream.clone(), + stderr: stream, + process_tree: ProcessTreeEvidence::VerifiedEmpty( + ContainmentBackend::SystemdUserService, + ), + side_effects: SideEffectConfinementEvidence::Verified( + SideEffectConfinementProfileKind::TrustedFixedNetwork, + ), + }); + let expected = binding( + fixture.writer.run_dir(), + &fixture.artifacts, + &fixture.command, + SupervisorRuntime::Codex, + ) + .unwrap(); + persist( + &mut fixture.writer, + &fixture.artifacts, + &fixture.command, + SupervisorRuntime::Codex, + &run, + ) + .unwrap(); + persist( + &mut fixture.writer, + &fixture.artifacts, + &fixture.command, + SupervisorRuntime::Codex, + &run, + ) + .unwrap(); + let reader = finalize(fixture.writer, &fixture.repo, &fixture.run_id); + let RestoredTargetContainment::Retained(receipt) = read(&reader, &expected).unwrap() else { + panic!("missing target receipt") + }; + assert_eq!(receipt.binding.attempt, 2); + assert_eq!(receipt.binding.role, "researcher"); + assert_eq!( + receipt.target_process_tree, + Observation::Observed(run.process_tree.unwrap()) + ); + assert_eq!( + receipt.target_side_effects, + Observation::Observed(run.side_effects.unwrap()) + ); + let summary: CommandRunRecord = + serde_json::from_slice(&reader.read(&expected.command_summary).unwrap()).unwrap(); + assert!(summary + .fixed_version_probe_evidence + .unwrap() + .process_tree + .is_verified_empty()); + assert!(!external_containment_verified( + &run, + SupervisorRuntime::Codex + )); + assert!(reader + .finalization() + .files + .iter() + .all(|record| record.disposition == ArtifactFileDisposition::PrivateEvidence)); + } + + #[test] + fn missing_legacy_target_is_unknown() { + let mut legacy = fixture(); + let run = deterministic_fake_run(&legacy.command, Vec::new()); + let expected = binding( + legacy.writer.run_dir(), + &legacy.artifacts, + &legacy.command, + SupervisorRuntime::Codex, + ) + .unwrap(); + let record = command_record_from_external_for_runtime( + &run, + &legacy.command, + SupervisorRuntime::Codex, + ); + legacy + .writer + .write_json( + &expected.command_summary, + &record, + ArtifactFileDisposition::PrivateEvidence, + ) + .unwrap(); + let reader = finalize(legacy.writer, &legacy.repo, &legacy.run_id); + assert_eq!( + read(&reader, &expected).unwrap(), + RestoredTargetContainment::UnknownLegacy + ); + + let mut current = fixture(); + persist( + &mut current.writer, + ¤t.artifacts, + ¤t.command, + SupervisorRuntime::Codex, + &run, + ) + .unwrap(); + let reader = finalize(current.writer, ¤t.repo, ¤t.run_id); + let RestoredTargetContainment::Retained(receipt) = read(&reader, &expected).unwrap() else { + panic!("missing current receipt") + }; + assert_eq!(receipt.target_process_tree, Observation::Unknown); + assert_eq!(receipt.target_side_effects, Observation::Unknown); + } + + #[test] + fn tampered_or_cross_attempt_receipt_is_rejected() { + let mut fixture = fixture(); + let run_dir = fixture.writer.run_dir().to_path_buf(); + let run = deterministic_fake_run(&fixture.command, Vec::new()); + let expected = binding( + fixture.writer.run_dir(), + &fixture.artifacts, + &fixture.command, + SupervisorRuntime::Codex, + ) + .unwrap(); + persist( + &mut fixture.writer, + &fixture.artifacts, + &fixture.command, + SupervisorRuntime::Codex, + &run, + ) + .unwrap(); + let reader = finalize(fixture.writer, &fixture.repo, &fixture.run_id); + for field in ["run", "task", "attempt", "role", "runtime", "parent"] { + let mut other = expected.clone(); + match field { + "run" => other.run_id = "other-run".into(), + "task" => other.task_id = "other-task".into(), + "attempt" => other.attempt += 1, + "role" => other.role = "worker".into(), + "runtime" => other.runtime = SupervisorRuntime::Grok, + "parent" => other.parent = Some("other-parent".into()), + _ => unreachable!(), + } + assert!(read(&reader, &other).is_err(), "accepted wrong {field}"); + } + let relative = receipt_path(&expected.command_summary); + let bytes = reader.read(&relative).unwrap(); + let summary = reader.read(&expected.command_summary).unwrap(); + assert!(validate(&bytes, &expected, b"different command summary").is_err()); + let mut altered: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + altered["version"] = json!(2); + assert!(validate(&serde_json::to_vec(&altered).unwrap(), &expected, &summary).is_err()); + altered["version"] = json!(1); + altered["child_claim"] = json!("verified"); + assert!(validate(&serde_json::to_vec(&altered).unwrap(), &expected, &summary).is_err()); + fs::write(run_dir.join(relative), b"{}").unwrap(); + assert!(read(&reader, &expected).is_err()); + assert!(ArtifactRunReader::open( + &fixture.repo, + RunArtifactFamily::Supervise, + &fixture.run_id + ) + .is_err()); + } + + #[test] + fn failed_timeout_without_valid_report_retains_target_before_scratch_disposal() { + let mut fixture = fixture(); + let (incoming, capture) = create_invocation_scratches(&mut fixture.writer).unwrap(); + let mut run = deterministic_fake_run(&fixture.command, b"invalid report".to_vec()); + run.exit_code = Some(1); + run.timed_out = true; + run.process_tree = Some(ProcessTreeEvidence::VerifiedEmpty( + ContainmentBackend::SystemdUserService, + )); + run.side_effects = Some(SideEffectConfinementEvidence::Verified( + SideEffectConfinementProfileKind::ExternalCodex, + )); + let expected = binding( + fixture.writer.run_dir(), + &fixture.artifacts, + &fixture.command, + SupervisorRuntime::Fake, + ) + .unwrap(); + import_external_attempt_evidence( + &mut fixture.writer, + ExternalAttemptEvidenceContext { + incoming_scratch: &incoming, + capture_scratch: &capture, + artifacts: &fixture.artifacts, + external_run: &run, + external_command: &fixture.command, + raw_report_validated: false, + runtime: SupervisorRuntime::Fake, + }, + ) + .unwrap(); + assert!(!incoming.path().exists()); + assert!(!capture.path().exists()); + let reader = finalize(fixture.writer, &fixture.repo, &fixture.run_id); + let RestoredTargetContainment::Retained(receipt) = read(&reader, &expected).unwrap() else { + panic!("missing failed target receipt") + }; + assert_eq!( + receipt.target_process_tree, + Observation::Observed(run.process_tree.unwrap()) + ); + assert_eq!( + receipt.target_side_effects, + Observation::Observed(run.side_effects.unwrap()) + ); + let summary: CommandRunRecord = + serde_json::from_slice(&reader.read(&expected.command_summary).unwrap()).unwrap(); + assert!(summary.timed_out); + assert_eq!(summary.exit_code, Some(1)); + assert!(reader.read(&fixture.artifacts.raw_report_relative).is_err()); + assert!(!external_containment_verified( + &run, + SupervisorRuntime::Codex + )); + } + + #[test] + fn missing_or_contradictory_lifecycle_and_repeated_observations_are_refused() { + let mut fixture = fixture(); + let mut run = deterministic_fake_run(&fixture.command, Vec::new()); + let mut command = fixture.command.clone(); + command.agent_lifecycle = None; + assert!(persist( + &mut fixture.writer, + &fixture.artifacts, + &command, + SupervisorRuntime::Codex, + &run + ) + .is_err()); + command = fixture.command.clone(); + command.assignment_process_launch_attempt = None; + assert!(persist( + &mut fixture.writer, + &fixture.artifacts, + &command, + SupervisorRuntime::Codex, + &run + ) + .is_err()); + command = fixture.command.clone(); + command.assignment_process_launch_attempt = Some(3); + assert!(persist( + &mut fixture.writer, + &fixture.artifacts, + &command, + SupervisorRuntime::Codex, + &run + ) + .is_err()); + assert!(persist( + &mut fixture.writer, + &fixture.artifacts, + &fixture.command, + SupervisorRuntime::Grok, + &run + ) + .is_err()); + persist( + &mut fixture.writer, + &fixture.artifacts, + &fixture.command, + SupervisorRuntime::Codex, + &run, + ) + .unwrap(); + run.process_tree = Some(ProcessTreeEvidence::VerifiedEmpty( + ContainmentBackend::SystemdUserService, + )); + assert!(persist( + &mut fixture.writer, + &fixture.artifacts, + &fixture.command, + SupervisorRuntime::Codex, + &run + ) + .is_err()); + run.process_tree = None; + fs::write( + fixture + .writer + .run_dir() + .join(&fixture.artifacts.command_record_relative), + b"tampered summary", + ) + .unwrap(); + assert!(persist( + &mut fixture.writer, + &fixture.artifacts, + &fixture.command, + SupervisorRuntime::Codex, + &run + ) + .is_err()); + } +} diff --git a/src/supervise/tests/run_artifacts.rs b/src/supervise/tests/run_artifacts.rs index 3b8c3317..bdfcce12 100644 --- a/src/supervise/tests/run_artifacts.rs +++ b/src/supervise/tests/run_artifacts.rs @@ -2001,6 +2001,9 @@ fn supervise_writer_discards_reusable_invocation_scratches_and_finalizes_private &artifacts.report_path, Duration::from_secs(1), ); + let mut command = + command.with_agent_lifecycle(&repo_path, "child_orchestrator", run_id.as_str(), "child-a"); + command.assignment_process_launch_attempt = Some(1); let external_run = deterministic_fake_run(&command, child_bytes.clone()); import_external_attempt_evidence( &mut writer, @@ -2077,6 +2080,13 @@ fn attempted_unverified_target_preserves_both_scratches_and_has_no_marker() { serde_json::to_vec(&injected_child_report(&assignment)).expect("serialize report"); fs::write(&artifacts.report_path, &child_bytes).expect("write incoming report"); fs::write(&artifacts.log_path, b"unverified capture\n").expect("write capture"); + let mut command = command.with_agent_lifecycle( + &repo_path, + "child_orchestrator", + "artifact-unverified-target", + "child-unverified", + ); + command.assignment_process_launch_attempt = Some(1); let mut run = deterministic_fake_run(&command, child_bytes); run.program_trust = ExternalProgramTrust::TrustedSystemCodex; run.process_tree = Some(ProcessTreeEvidence::Unverified( From 4dcb6a589b7666f90f9462054a2d119f490e3634 Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:24:37 +0900 Subject: [PATCH 10/27] Validate correlated authority response envelopes in both clients Use a shared response validator that requires matching nonempty request IDs, the supported protocol version and exactly one result or error before either authority client consumes a response. Strict desktop decoding is preserved and validation diagnostics never include remote values. - Add core and framed-client regressions for malformed envelopes and local selection preservation. Validation: formatting and six exact core/root regressions passed on Linux; desktop client tests, Clippy and the platform suites run in this PR's CI. --- .../core/src/account_authority/mod.rs | 4 +- .../core/src/account_authority/protocol.rs | 93 +++++++++++++++++++ .../src-tauri/src/authority_client.rs | 81 ++++++++++++++-- src/account_authority/socket_client.rs | 72 ++++++++++++-- 4 files changed, 232 insertions(+), 18 deletions(-) diff --git a/account-manager/core/src/account_authority/mod.rs b/account-manager/core/src/account_authority/mod.rs index 8ac41f7a..15d455de 100644 --- a/account-manager/core/src/account_authority/mod.rs +++ b/account-manager/core/src/account_authority/mod.rs @@ -34,8 +34,8 @@ pub use operation::{ pub use protocol::{ authority_id_for, decode_request, dispatch, map_core_error, AuthorityContext, AuthorityResponse, DecodedRequest, ErrorCode, LoginPort, ProtocolBounds, ProtocolFailure, - ADVERTISED_OPERATIONS, MAX_REQUEST_BYTES, MAX_RESPONSE_BYTES, PROTOCOL_VERSION, - REQUEST_IO_DEADLINE, + ResponseValidationError, ADVERTISED_OPERATIONS, MAX_REQUEST_BYTES, MAX_RESPONSE_BYTES, + PROTOCOL_VERSION, REQUEST_IO_DEADLINE, }; pub use registry::StoredAccountRegistry; pub use server::{ diff --git a/account-manager/core/src/account_authority/protocol.rs b/account-manager/core/src/account_authority/protocol.rs index b50b027b..4f1df20a 100644 --- a/account-manager/core/src/account_authority/protocol.rs +++ b/account-manager/core/src/account_authority/protocol.rs @@ -87,6 +87,23 @@ pub struct AuthorityResponse { } impl AuthorityResponse { + /// Validate correlation and the exclusive result/error shape before consuming a response. + pub fn validate_for_request(&self, request_id: &str) -> Result<(), ResponseValidationError> { + if request_id.is_empty() || self.request_id.is_empty() { + return Err(ResponseValidationError::EmptyRequestId); + } + if self.request_id != request_id { + return Err(ResponseValidationError::RequestIdMismatch); + } + if self.protocol_version != PROTOCOL_VERSION { + return Err(ResponseValidationError::UnsupportedVersion); + } + if self.result.is_some() == self.error.is_some() { + return Err(ResponseValidationError::InvalidResultShape); + } + Ok(()) + } + pub fn result(request_id: impl Into, result: Value) -> Self { Self { protocol_version: PROTOCOL_VERSION, @@ -117,6 +134,19 @@ impl AuthorityResponse { } } +/// Closed response failures whose diagnostics never include remote values. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum ResponseValidationError { + #[error("authority response requires nonempty request IDs")] + EmptyRequestId, + #[error("authority response request ID mismatch")] + RequestIdMismatch, + #[error("authority response protocol version is unsupported")] + UnsupportedVersion, + #[error("authority response requires exactly one result or error")] + InvalidResultShape, +} + /// Decode or dispatch failure that still has a closed wire shape. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProtocolFailure { @@ -1307,3 +1337,66 @@ fn hex_lower(bytes: &[u8]) -> String { } out } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn response_validation_requires_correlated_exclusive_envelope() { + let valid = AuthorityResponse::result("rpc", json!({})); + assert_eq!(valid.validate_for_request("rpc"), Ok(())); + assert_eq!( + AuthorityResponse::error("rpc", ErrorCode::Busy, "busy").validate_for_request("rpc"), + Ok(()) + ); + assert_eq!( + valid.validate_for_request(""), + Err(ResponseValidationError::EmptyRequestId) + ); + for (response, expected) in [ + ( + AuthorityResponse::result("", json!({})), + ResponseValidationError::EmptyRequestId, + ), + ( + AuthorityResponse::result("secret-sentinel", json!({})), + ResponseValidationError::RequestIdMismatch, + ), + ( + AuthorityResponse { + protocol_version: 2, + ..valid.clone() + }, + ResponseValidationError::UnsupportedVersion, + ), + ( + AuthorityResponse { + result: None, + ..valid.clone() + }, + ResponseValidationError::InvalidResultShape, + ), + ( + AuthorityResponse { + result: Some(json!({})), + ..AuthorityResponse::error("rpc", ErrorCode::Busy, "secret-sentinel") + }, + ResponseValidationError::InvalidResultShape, + ), + ] { + let error = response.validate_for_request("rpc").unwrap_err(); + assert_eq!(error, expected); + assert!(!error.to_string().contains("secret-sentinel")); + } + assert!(serde_json::from_value::(json!({ + "protocolVersion": PROTOCOL_VERSION, "result": {} + })) + .is_err()); + assert_eq!( + AuthorityResponse::result("", json!({})).validate_for_request(""), + Err(ResponseValidationError::EmptyRequestId) + ); + } +} diff --git a/account-manager/src-tauri/src/authority_client.rs b/account-manager/src-tauri/src/authority_client.rs index ce47beaa..f7c5b1e3 100644 --- a/account-manager/src-tauri/src/authority_client.rs +++ b/account-manager/src-tauri/src/authority_client.rs @@ -610,14 +610,9 @@ fn decode_response(bytes: &[u8], request_id: &str) -> Result .transpose() .map_err(|_| authority_decode_error("invalid authority error envelope"))?, }; - if response.protocol_version != PROTOCOL_VERSION - || response.request_id != request_id - || response.result.is_some() == response.error.is_some() - { - return Err(authority_decode_error( - "authority response envelope mismatch", - )); - } + response + .validate_for_request(request_id) + .map_err(|error| authority_decode_error(&error.to_string()))?; Ok(response) } @@ -1456,9 +1451,20 @@ mod tests { fn response_refuses_uncorrelated_ambiguous_unknown_and_secret_decode_errors() { let valid = json!({"protocolVersion":1,"requestId":"rpc","result":{}}); assert!(decode_response(&serde_json::to_vec(&valid).unwrap(), "rpc").is_ok()); + assert!(decode_response( + br#"{"protocolVersion":1,"requestId":"rpc","error":{"code":"busy","message":"busy"}}"#, + "rpc" + ) + .is_ok()); + assert!(decode_response(&serde_json::to_vec(&valid).unwrap(), "").is_err()); + assert!( + decode_response(br#"{"protocolVersion":1,"requestId":"","result":{}}"#, "").is_err() + ); for invalid in [ json!({"protocolVersion":2,"requestId":"rpc","result":{}}), json!({"protocolVersion":1,"requestId":"other","result":{}}), + json!({"protocolVersion":1,"result":{}}), + json!({"protocolVersion":1,"requestId":"","result":{}}), json!({"protocolVersion":1,"requestId":"rpc"}), json!({"protocolVersion":1,"requestId":"rpc","result":{},"error":{"code":"busy","message":"busy"}}), json!({"protocolVersion":1,"requestId":"rpc","result":{},"command":"arbitrary"}), @@ -1547,6 +1553,65 @@ mod tests { std::env::remove_var(CAM_ACCOUNT_AUTHORITY_SOCKET_ENV); } + #[test] + fn exchange_refuses_invalid_envelopes_without_changing_local_selection() { + let (_registry_dir, registry) = isolated_registry(); + registry + .begin_add( + "codex-cli", + "work", + "Work", + AuthKind::OAuth, + StoredAccountMaterial::VendorHome, + ) + .unwrap(); + registry.complete_add("codex-cli", "work").unwrap(); + registry + .select_complete_revision("codex-cli", "work", None) + .unwrap(); + let original = registry.selected_binding("codex-cli").unwrap(); + for case in 0..6 { + let (_guard, root) = private_tempdir(); + let socket = root.join("authority.sock"); + let listener = std::os::unix::net::UnixListener::bind(&socket).unwrap(); + let client = AuthorityClient::connect(&socket).unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let deadline = std::time::Instant::now() + REQUEST_IO_DEADLINE; + let request = read_framed(&mut stream, MAX_REQUEST_BYTES, deadline).unwrap(); + let request: Value = serde_json::from_slice(&request).unwrap(); + assert_eq!(request["operation"], "selection.get"); + let mut response = json!({"protocolVersion": PROTOCOL_VERSION, + "requestId": request["requestId"], "result": {"selected": false}}); + match case { + 0 => response["requestId"] = json!("secret-sentinel"), + 1 => { + response.as_object_mut().unwrap().remove("requestId"); + } + 2 => response["requestId"] = json!(""), + 3 => { + response["error"] = + json!({"code": "busy", "message": "secret-sentinel"}); + } + 4 => { + response.as_object_mut().unwrap().remove("result"); + } + _ => response["protocolVersion"] = json!(2), + } + write_framed( + &mut stream, + &serde_json::to_vec(&response).unwrap(), + deadline, + ) + .unwrap(); + }); + let error = client.selected_binding("codex-cli").unwrap_err(); + assert!(!error.to_string().contains("secret-sentinel")); + assert_eq!(registry.selected_binding("codex-cli").unwrap(), original); + server.join().unwrap(); + } + } + #[test] fn client_selection_get_round_trips() { let (_registry_dir, registry) = isolated_registry(); diff --git a/src/account_authority/socket_client.rs b/src/account_authority/socket_client.rs index 8b920790..abbbbb82 100644 --- a/src/account_authority/socket_client.rs +++ b/src/account_authority/socket_client.rs @@ -149,14 +149,14 @@ impl AuthoritySocketClient { let deadline = Instant::now() + REQUEST_IO_DEADLINE; write_framed(&mut stream, &body, deadline)?; let response_bytes = read_framed(&mut stream, MAX_RESPONSE_BYTES, deadline)?; - let response: AuthorityResponse = - serde_json::from_slice(&response_bytes).context("decode CAM authority response")?; - if response.protocol_version != PROTOCOL_VERSION { - return Err(anyhow!( - "CAM authority response protocolVersion {} is not supported", - response.protocol_version - )); - } + let response: AuthorityResponse = serde_json::from_slice(&response_bytes) + .map_err(|_| anyhow!("invalid CAM authority response envelope"))?; + response.validate_for_request( + request + .get("requestId") + .and_then(Value::as_str) + .unwrap_or(""), + )?; Ok(response) } } @@ -397,6 +397,62 @@ mod tests { (dir, listener) } + #[test] + fn exchange_refuses_uncorrelated_or_ambiguous_response() { + use std::os::unix::net::UnixListener; + + let (_registry_dir, registry) = isolated_registry(); + seed_selected_account(®istry); + let original = registry.selected_binding("codex-cli").unwrap(); + for case in 0..7 { + let (_guard, root) = private_tempdir(); + let socket = root.join("account.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let client = connect_authority_socket(&socket).unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let deadline = Instant::now() + REQUEST_IO_DEADLINE; + let request = read_framed(&mut stream, MAX_REQUEST_BYTES, deadline).unwrap(); + let request: Value = serde_json::from_slice(&request).unwrap(); + assert_eq!(request["operation"], "selection.get"); + let mut response = json!({ + "protocolVersion": PROTOCOL_VERSION, + "requestId": request["requestId"], + "result": {"selected": false}, + }); + match case { + 0 => response["requestId"] = json!("secret-sentinel"), + 1 => { + response.as_object_mut().unwrap().remove("requestId"); + } + 2 => response["requestId"] = json!(""), + 3 => response["error"] = json!({"code": "busy", "message": "secret-sentinel"}), + 4 => { + response.as_object_mut().unwrap().remove("result"); + } + 5 => response["protocolVersion"] = json!(2), + _ => { + response.as_object_mut().unwrap().remove("result"); + response["error"] = json!({"code": "busy", "message": "busy"}); + } + } + write_framed( + &mut stream, + &serde_json::to_vec(&response).unwrap(), + deadline, + ) + .unwrap(); + }); + let error = client.selection_get("codex-cli").unwrap_err(); + assert!(!format!("{error:#}").contains("secret-sentinel")); + if case == 6 { + assert!(error.to_string().contains("busy")); + } + assert_eq!(registry.selected_binding("codex-cli").unwrap(), original); + server.join().unwrap(); + } + } + #[test] fn framing_helpers_round_trip_payload() { use std::os::unix::net::UnixListener; From 247ac15bffbc76b7f2447840c636d0bef7114686 Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:35:38 +0900 Subject: [PATCH 11/27] Use is_multiple_of in the pricing contract date check Use is_multiple_of for the leap-year check (Clippy manual_is_multiple_of). --- src/llm/pricing_contract.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/llm/pricing_contract.rs b/src/llm/pricing_contract.rs index b588b773..8c1feb51 100644 --- a/src/llm/pricing_contract.rs +++ b/src/llm/pricing_contract.rs @@ -356,7 +356,7 @@ fn validate_date(field: &'static str, date: &str) -> Result<(), PricingUnavailab let year: u32 = date[..4].parse().map_err(|_| invalid())?; let month: u32 = date[5..7].parse().map_err(|_| invalid())?; let day: u32 = date[8..].parse().map_err(|_| invalid())?; - let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + let leap = year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400)); let days = match month { 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, 4 | 6 | 9 | 11 => 30, From 4dbd6085c37aafcc7d2324282a6765b492afc753 Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:36:35 +0900 Subject: [PATCH 12/27] Bind frozen operator priors to held-out provider evaluation - Add an opt-in `--prior-data` input for held-out real-provider evaluation, resolved against the explicit source repository. - Validate source-relative bytes and requested-model capability evidence before any artifact or budget reservation. - Freeze and rebind the evidence per isolated profile, keeping raw bytes and policy digests through refusals. - Keep deterministic-fake and absent-input artifacts compatible. Selection priors remain separate from budget pricing. Validation: formatting, 15 exact regression tests and all-target compilation passed on Linux; Clippy and the full suites run in this PR's CI. No production qualification is claimed. --- src/cli.rs | 19 +- src/evaluation.rs | 2 + src/evaluation/executed_experiment.rs | 86 +++++-- src/evaluation/experiment.rs | 1 + src/evaluation/prior_input.rs | 44 ++++ src/evaluation/tests.rs | 338 ++++++++++++++++++++++++++ src/supervise.rs | 1 + src/supervise/prior_input.rs | 155 ++++++++++-- 8 files changed, 614 insertions(+), 32 deletions(-) create mode 100644 src/evaluation/prior_input.rs diff --git a/src/cli.rs b/src/cli.rs index 9ec9ef09..3650e5ee 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -4785,6 +4785,9 @@ struct RunExperimentArgs { value_parser = parse_additional_runtime_bin )] additional_runtime_bin: Vec, + /// Dated selection priors and capability evidence inside --source-repo. Real-provider only. + #[arg(long, value_name = "REPO_RELATIVE_FILE")] + prior_data: Option, /// Exact reviewed machine-global config. Required with real-provider; never inferred from --source-repo. #[arg(long, value_name = "PATH")] machine_global_config: Option, @@ -4906,11 +4909,12 @@ fn resolve_held_out_real_provider_cli( || args.runtime.is_some() || args.runtime_bin.is_some() || !args.additional_runtime_bin.is_empty() + || args.prior_data.is_some() || args.machine_global_config.is_some() || args.machine_global_runtime_root_id.is_some() { bail!( - "provider plan, runtime, and retention options require --execution real-provider" + "provider plan, runtime, prior-data, and retention options require --execution real-provider" ); } return Ok(None); @@ -4946,6 +4950,13 @@ fn resolve_held_out_real_provider_cli( crate::evaluation::HeldOutRealProviderExperimentRequest { execution: args.execution, allow_real_provider: args.allow_real_provider, + prior_data: args + .prior_data + .as_ref() + .map(|path| { + crate::evaluation::HeldOutPriorDataInput::freeze(&source.source_repo, path) + }) + .transpose()?, source, provider_plan, runtime, @@ -5613,6 +5624,12 @@ mod cli_integration_tests { .to_string() .contains("require --execution real-provider")); args.additional_runtime_bin.clear(); + args.prior_data = Some(PathBuf::from("priors.json")); + assert!(resolve_held_out_real_provider_cli(&args, None) + .expect_err("prior-data requires explicit execution mode") + .to_string() + .contains("require --execution real-provider")); + args.prior_data = None; args.execution = crate::evaluation::EvaluationExecution::RealProvider; assert!(resolve_held_out_real_provider_cli(&args, None) .expect_err("real execution requires the complete tuple") diff --git a/src/evaluation.rs b/src/evaluation.rs index 2db90778..998eee49 100644 --- a/src/evaluation.rs +++ b/src/evaluation.rs @@ -3725,6 +3725,7 @@ mod executed_experiment; mod executed_measurements; mod executed_summary; mod experiment; +mod prior_input; pub use crate::merge::held_out::{CommandObservation, CommandObservationStatus}; pub use crate::supervise::held_out::{ HeldOutCandidateEvidence, HeldOutCommandEvidence, HeldOutRunBinding, @@ -3748,6 +3749,7 @@ pub use executed_summary::{ ExecutedParetoPoint, ExecutedProfileSummary, EXECUTED_SUMMARY_SCHEMA_VERSION, MISSING_PARENT_REVIEW_PRODUCER_NOTICE, QUALIFIED_ACCEPTED_TASK_FRACTION_LABEL, }; +pub use prior_input::HeldOutPriorDataInput; pub mod rescore; pub use experiment::{ parse_canonical_full_commit_oid, parse_experiment_manifest, diff --git a/src/evaluation/executed_experiment.rs b/src/evaluation/executed_experiment.rs index 4ad91c0a..14630547 100644 --- a/src/evaluation/executed_experiment.rs +++ b/src/evaluation/executed_experiment.rs @@ -321,12 +321,37 @@ pub fn run_held_out_real_provider_experiment( manifest: &ExperimentManifest, request: HeldOutRealProviderExperimentRequest, artifact_repo: &Path, +) -> Result { + run_held_out_real_provider_experiment_with_dispatch( + manifest, + request, + artifact_repo, + supervise::run_held_out_production_experiment, + ) +} + +pub(super) fn run_held_out_real_provider_experiment_with_dispatch( + manifest: &ExperimentManifest, + request: HeldOutRealProviderExperimentRequest, + artifact_repo: &Path, + mut dispatch: impl FnMut( + supervise::SupervisorRunOptions, + ParentValidationAuthority, + supervise::FrozenHeldOutRuntimeAllowlist, + ) -> Result, ) -> Result { manifest.validate_for_observation()?; refuse_incomplete_real_provider_request(&request)?; if manifest.held_out_validation.is_empty() { bail!("--execute-held-out requires at least one declared validation"); } + if let Some(prior) = &request.prior_data { + prior.verify_source(&request.source.source_repo)?; + } + let _source_prior = super::HeldOutPriorDataInput::bind_for_repo( + request.prior_data.as_ref(), + &request.source.source_repo, + )?; refuse_caller_plan_inside_evaluated_source( &request.provider_plan, &request.source.source_repo, @@ -336,6 +361,27 @@ pub fn run_held_out_real_provider_experiment( Some(&request.source.source_repo), ) .map_err(|error| anyhow!("held-out production caller plan is invalid: {error}"))?; + if request.prior_data.is_some() { + for profile in &manifest.profiles { + for (role, selection) in &profile.role_models { + if let Some(model) = selection.model.as_deref() { + supervise::validate_known_judgment_role_model(*role, Some(model)) + .with_context(|| { + format!( + "profile '{}' prior-data capability is invalid for {}: {model}", + profile.id, + role.as_str() + ) + })?; + } + } + supervise::materialize_held_out_profile_effective_caller_plan_in_repo( + &frozen, + &profile.role_models, + Some(&request.source.source_repo), + )?; + } + } let resolved_commit = experiment::resolve_held_out_explicit_source_baseline(&request.source) .map_err(|error| anyhow!("held-out explicit source baseline is invalid: {error}"))?; let runtime_allowlist = freeze_runtime_bindings_before_reservation(&request)?; @@ -345,6 +391,9 @@ pub fn run_held_out_real_provider_experiment( let run_id = artifacts::generate_run_id(&repo, family)?; let mut writer = ArtifactRunWriter::reserve(&repo, family, run_id.clone(), "evaluation-held-out")?; + if let Some(prior) = &request.prior_data { + prior.archive(&mut writer, Path::new("held-out"))?; + } writer.write_json( "held-out/manifest.json", manifest, @@ -396,6 +445,10 @@ pub fn run_held_out_real_provider_experiment( "{}-p{profile_index}-r{repetition}", run_id.as_str() ))?; + let _profile_prior = super::HeldOutPriorDataInput::bind_for_repo( + request.prior_data.as_ref(), + &isolated.repo, + )?; let git = crate::git_repository::open(&isolated.repo)?; let baseline = git.head()?.peel_to_commit()?; let baseline_pair = (baseline.id().to_string(), baseline.tree_id().to_string()); @@ -422,18 +475,25 @@ pub fn run_held_out_real_provider_experiment( &effective_plan_bytes, ArtifactFileDisposition::PrivateEvidence, )?; + let mut profile_binding = json!({ + "profile_id": profile.id, + "profile_sha256": sha256_hex(&serde_json::to_vec(profile)?), + "requested_role_models": profile.role_models, + "caller_plan_sha256": frozen.caller_plan_sha256, + "effective_plan_sha256": effective_plan_sha256, + "assignment_id": frozen.assignment_id, + "assigned_paths": frozen.assigned_paths, + "observation": "requested", + }); + if let Some(prior) = &request.prior_data { + prior.archive(&mut retained, &prefix.join("requested-input"))?; + profile_binding["operator_prior_data"] = json!(prior.provenance()); + profile_binding["supervisor_run_id"] = json!(isolated.run_id.as_str()); + profile_binding["repetition"] = json!(repetition); + } retained.write_json( prefix.join("requested-profile-binding.json"), - &json!({ - "profile_id": profile.id, - "profile_sha256": sha256_hex(&serde_json::to_vec(profile)?), - "requested_role_models": profile.role_models, - "caller_plan_sha256": frozen.caller_plan_sha256, - "effective_plan_sha256": effective_plan_sha256, - "assignment_id": frozen.assignment_id, - "assigned_paths": frozen.assigned_paths, - "observation": "requested", - }), + &profile_binding, ArtifactFileDisposition::PrivateEvidence, )?; } @@ -469,11 +529,7 @@ pub fn run_held_out_real_provider_experiment( request.machine_global_retention.clone(), ); options.budget_max_duration_seconds = Some(manifest.limits.wall_time_seconds); - let outcome = supervise::run_held_out_production_experiment( - options, - authority.clone(), - runtime_allowlist.clone(), - ); + let outcome = dispatch(options, authority.clone(), runtime_allowlist.clone()); let (report, launch) = match outcome { Ok(outcome) => (outcome.report, outcome.launch), Err(error) => { diff --git a/src/evaluation/experiment.rs b/src/evaluation/experiment.rs index af247e46..46a846fe 100644 --- a/src/evaluation/experiment.rs +++ b/src/evaluation/experiment.rs @@ -256,6 +256,7 @@ pub struct HeldOutRealProviderExperimentRequest { pub runtime: SupervisorRuntime, pub runtime_executable: PathBuf, pub additional_runtime_executables: Vec, + pub prior_data: Option, pub machine_global_retention: MachineGlobalRetentionBinding, } diff --git a/src/evaluation/prior_input.rs b/src/evaluation/prior_input.rs new file mode 100644 index 00000000..1a1370cb --- /dev/null +++ b/src/evaluation/prior_input.rs @@ -0,0 +1,44 @@ +//! Frozen selection evidence for explicitly opted-in held-out execution. + +use crate::{ + artifacts::ArtifactRunWriter, + selection::OperatorPriorDataProvenance, + supervise::{FrozenOperatorPriorData, OperatorPriorDataGuard}, +}; +use anyhow::Result; +use std::path::Path; + +/// Validated operator input resolved against the evaluated source repository. +/// This supplies selection evidence, never token prices or provider authority. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HeldOutPriorDataInput { + snapshot: FrozenOperatorPriorData, +} + +impl HeldOutPriorDataInput { + /// Read once through the same bounded Unix repository confinement as supervise. + pub fn freeze(source_repo: &Path, relative_path: &Path) -> Result { + Ok(Self { + snapshot: FrozenOperatorPriorData::read(source_repo, relative_path)?, + }) + } + + pub fn provenance(&self) -> &OperatorPriorDataProvenance { + self.snapshot.provenance() + } + + pub(super) fn verify_source(&self, source_repo: &Path) -> Result<()> { + self.snapshot.verify_source(source_repo) + } + + pub(super) fn bind_for_repo( + input: Option<&Self>, + repo: &Path, + ) -> Result { + FrozenOperatorPriorData::bind_for_repo(input.map(|input| &input.snapshot), repo) + } + + pub(super) fn archive(&self, writer: &mut ArtifactRunWriter, prefix: &Path) -> Result<()> { + self.snapshot.archive(writer, prefix) + } +} diff --git a/src/evaluation/tests.rs b/src/evaluation/tests.rs index 13a3f1c4..fc9699be 100644 --- a/src/evaluation/tests.rs +++ b/src/evaluation/tests.rs @@ -2323,6 +2323,7 @@ fn real_provider_request( runtime: crate::supervise::SupervisorRuntime::Grok, runtime_executable: std::path::PathBuf::from("/usr/bin/true"), additional_runtime_executables: Vec::new(), + prior_data: None, machine_global_retention: dummy_retention(), } } @@ -2343,6 +2344,343 @@ fn no_reserved_runs(artifact_repo: &std::path::Path) { ); } +#[cfg(unix)] +fn held_out_prior_fixture() -> Vec { + let base = crate::selection::built_in_prior_dataset().expect("bundled priors"); + let template = base + .models + .iter() + .find(|row| row.model == "gpt-5.6-sol") + .expect("bundled model"); + let models: Vec<_> = [("codex", "held-out-codex"), ("grok", "held-out-grok")] + .into_iter() + .map(|(runtime, model)| { + let mut row = template.clone(); + row.runtime = runtime.into(); + row.model = model.into(); + row.observed_on = base.published_on.clone(); + row.source_id = "held-out-technical-fixture".into(); + row.prior_scope = "technical fixture only; no production qualification".into(); + row + }) + .collect(); + let capabilities = ["held-out-codex", "held-out-grok"].map(|model| { + json!({ + "model": model, + "capability": "critical_judgment", + "eligible": true, + "evidence": "technical fixture only", + "as_of": base.published_on, + }) + }); + serde_json::to_vec_pretty(&json!({ + "schema_version": 1, + "dataset_id": "held-out-technical-fixture", + "revision": format!("{}+held-out", base.revision), + "published_on": base.published_on, + "models": models, + "capability_policy": { + "id": "held-out-technical-fixture", + "version": 1, + "source": "technical fixture; no production qualification", + "models": capabilities + } + })) + .expect("prior fixture") +} + +#[cfg(unix)] +#[test] +fn held_out_prior_input_rejects_escape_mutation_and_missing_capability_before_reservation() { + use std::path::Path; + let mut manifest = observation_manifest_one_profile(); + manifest.profiles[0] = profile("mixed", "held-out-codex", "held-out-grok"); + let (_workspace, source_repo, base_commit) = init_committed_source_repo("baseline"); + let source = HeldOutExplicitSourceBaseline { + source_repo: source_repo.clone(), + base_commit, + }; + let (_artifact, artifact_repo) = artifact_owner(); + let plan_workspace = tempfile::TempDir::new().expect("plans"); + let provider_plan = write_caller_plan(plan_workspace.path(), "docs-child", false, false); + let raw = held_out_prior_fixture(); + fs::write(source_repo.join("prior.json"), &raw).unwrap(); + for path in [Path::new("../prior.json"), &source_repo.join("prior.json")] { + assert!(HeldOutPriorDataInput::freeze(&source_repo, path).is_err()); + no_reserved_runs(&artifact_repo); + } + std::os::unix::fs::symlink( + source_repo.join("prior.json"), + source_repo.join("link.json"), + ) + .unwrap(); + assert!(HeldOutPriorDataInput::freeze(&source_repo, Path::new("link.json")).is_err()); + let input = HeldOutPriorDataInput::freeze(&source_repo, Path::new("prior.json")).unwrap(); + fs::write(source_repo.join("prior.json"), b"{}").unwrap(); + let mut request = real_provider_request(source.clone(), provider_plan.clone()); + request.prior_data = Some(input.clone()); + let error = run_held_out_real_provider_experiment(&manifest, request, &artifact_repo) + .expect_err("changed prior before reservation"); + assert!( + error.to_string().contains("changed from the frozen"), + "{error:#}" + ); + no_reserved_runs(&artifact_repo); + + fs::write(source_repo.join("prior.json"), &raw).unwrap(); + let (_foreign_workspace, foreign_repo, foreign_commit) = init_committed_source_repo("other"); + let mut request = real_provider_request( + HeldOutExplicitSourceBaseline { + source_repo: foreign_repo, + base_commit: foreign_commit, + }, + provider_plan.clone(), + ); + request.prior_data = Some(input); + let error = run_held_out_real_provider_experiment(&manifest, request, &artifact_repo) + .expect_err("foreign source"); + assert!( + error.to_string().contains("different source repository"), + "{error:#}" + ); + no_reserved_runs(&artifact_repo); + + for invalid in [b"{}".to_vec(), vec![b' '; 256 * 1024 + 1]] { + fs::write(source_repo.join("prior.json"), invalid).unwrap(); + assert!(HeldOutPriorDataInput::freeze(&source_repo, Path::new("prior.json")).is_err()); + no_reserved_runs(&artifact_repo); + } + let mut value: serde_json::Value = serde_json::from_slice(&raw).unwrap(); + value["capability_policy"]["models"] = json!([]); + fs::write( + source_repo.join("prior.json"), + serde_json::to_vec(&value).unwrap(), + ) + .unwrap(); + let mut request = real_provider_request(source, provider_plan); + request.prior_data = + Some(HeldOutPriorDataInput::freeze(&source_repo, Path::new("prior.json")).unwrap()); + let error = run_held_out_real_provider_experiment(&manifest, request, &artifact_repo) + .expect_err("missing capability before reservation"); + assert!( + format!("{error:#}").contains("no trusted capability policy"), + "{error:#}" + ); + no_reserved_runs(&artifact_repo); +} + +#[cfg(target_os = "linux")] +#[test] +fn held_out_prior_snapshot_is_identical_across_mixed_runtime_profiles() { + use crate::artifacts::{ArtifactRunReader, RunArtifactFamily}; + use crate::supervise::{trusted_model_capability, SupervisorPlan, SupervisorRuntime}; + use std::path::Path; + let mut manifest = experiment_manifest(); + manifest.profiles = vec![ + profile("mixed-a", "held-out-codex", "held-out-grok"), + profile("mixed-b", "held-out-codex", "held-out-grok"), + ]; + let (_workspace, source_repo, base_commit) = init_committed_source_repo("baseline"); + let raw = held_out_prior_fixture(); + fs::write(source_repo.join("prior.json"), &raw).unwrap(); + let input = HeldOutPriorDataInput::freeze(&source_repo, Path::new("prior.json")).unwrap(); + let provenance = input.provenance().clone(); + let (_artifact, artifact_repo) = artifact_owner(); + // The artifact repository deliberately contains an invalid same-named file. + fs::write(artifact_repo.join("prior.json"), b"{}").unwrap(); + let plan_workspace = tempfile::TempDir::new().unwrap(); + let provider_plan = write_caller_plan(plan_workspace.path(), "docs-child", false, false); + let mut plan: serde_json::Value = + serde_json::from_slice(&fs::read(&provider_plan).unwrap()).unwrap(); + plan["assignments"][0]["role"] = json!("worker"); + plan["assignments"][0]["role_category"] = json!("non_delegating_terminal_worker"); + plan["assignments"][0]["runtime"] = json!("grok"); + plan["assignments"][0]["worker_assignments"] = json!([]); + fs::write(&provider_plan, serde_json::to_vec(&plan).unwrap()).unwrap(); + let mut request = real_provider_request( + HeldOutExplicitSourceBaseline { + source_repo: source_repo.clone(), + base_commit, + }, + provider_plan, + ); + request.runtime = SupervisorRuntime::Codex; + request + .additional_runtime_executables + .push(HeldOutAdditionalRuntimeBinding { + runtime: SupervisorRuntime::Grok, + executable: "/usr/bin/true".into(), + }); + let _runtime_bindings = crate::supervise::bind_explicit_runtime_executables( + &source_repo, + &crate::orchestrator::RunId::new("held-out-runtime-binding-preflight").unwrap(), + SupervisorRuntime::Codex, + Path::new("/usr/bin/true"), + &[(SupervisorRuntime::Grok, "/usr/bin/true".into())], + ) + .unwrap(); + request.prior_data = Some(input); + let mut dispatches = 0; + let results = super::executed_experiment::run_held_out_real_provider_experiment_with_dispatch( + &manifest, + request, + &artifact_repo, + |options, _authority, _allowlist| { + dispatches += 1; + assert_eq!(options.runtime, SupervisorRuntime::Codex); + assert!(trusted_model_capability("held-out-grok").is_some()); + let plan: SupervisorPlan = + serde_json::from_slice(&fs::read(&options.plan_file).unwrap()).unwrap(); + assert_eq!(plan.assignments[0].runtime, Some(SupervisorRuntime::Grok)); + assert_eq!( + plan.role_models[&AgentRole::Worker].model.as_deref(), + Some("held-out-grok") + ); + assert!(!options.repo.join("prior.json").exists()); + // A later profile must use frozen bytes even after the supplied file changes. + fs::write(source_repo.join("prior.json"), b"{}").unwrap(); + anyhow::bail!("bounded fixture refusal before any provider launch") + }, + ) + .expect("retained refusal observations"); + assert_eq!(dispatches, 2); + assert!(trusted_model_capability("held-out-grok").is_none()); + assert!(!results.real_provider_executed); + assert!(!results.production_eligible); + assert!(!results.eligible_for_production_economics); + assert!(results.runs.iter().all(|run| run.failure.is_some())); + let reader = ArtifactRunReader::open( + &artifact_repo, + RunArtifactFamily::Supervise, + &crate::orchestrator::RunId::new(&results.artifact_run_id).unwrap(), + ) + .unwrap(); + assert_eq!( + reader + .read("held-out/operator_prior_data/input.json") + .unwrap(), + raw + ); + let effective = reader + .read("held-out/operator_prior_data/effective.json") + .unwrap(); + let effective_value: serde_json::Value = serde_json::from_slice(&effective).unwrap(); + for (runtime, model) in [("codex", "held-out-codex"), ("grok", "held-out-grok")] { + let mut selection = crate::selection::selection_test_base_input(); + selection.priors = serde_json::from_value(effective_value["priors"].clone()).unwrap(); + selection + .catalogs + .retain(|catalog| catalog.runtime == runtime); + selection.catalogs[0].models.truncate(1); + selection.catalogs[0].models[0].model = model.into(); + selection.pools.retain(|pool| pool.runtime == runtime); + let choice = crate::selection::select(&selection) + .unwrap() + .choice + .expect("dated fixture choice"); + assert_eq!(choice.candidate.runtime, runtime); + assert_eq!(choice.candidate.model, model); + } + for index in 0..2 { + let prefix = format!("held-out/p{index}-r0"); + assert_eq!( + reader + .read(format!( + "{prefix}/requested-input/operator_prior_data/input.json" + )) + .unwrap(), + raw + ); + assert_eq!( + reader + .read(format!( + "{prefix}/requested-input/operator_prior_data/effective.json" + )) + .unwrap(), + effective + ); + let binding: serde_json::Value = serde_json::from_slice( + &reader + .read(format!("{prefix}/requested-profile-binding.json")) + .unwrap(), + ) + .unwrap(); + assert_eq!(binding["operator_prior_data"], json!(provenance)); + assert_eq!( + binding["supervisor_run_id"], + results.runs[index].held_out.run.supervisor_run_id + ); + } +} + +#[test] +fn held_out_prior_absence_preserves_fake_and_legacy_manifests() { + let manifest = experiment_manifest(); + let bytes = serde_json::to_vec(&manifest).unwrap(); + let parsed = parse_experiment_manifest(&bytes).unwrap(); + assert_eq!(serde_json::to_vec(&parsed).unwrap(), bytes); + assert!(!serde_json::from_slice::(&bytes) + .unwrap() + .as_object() + .unwrap() + .contains_key("prior_data")); + let results = run_fake_supervise_experiment(&parsed, ExperimentRunRequest::default()).unwrap(); + assert!(!results.evidence.real_provider_executed); + assert!(!results.evidence.production_eligible); +} + +#[cfg(unix)] +#[test] +fn held_out_prior_absence_clears_inherited_scope_and_omits_prior_artifacts() { + use crate::artifacts::{ArtifactRunReader, RunArtifactFamily}; + use crate::supervise::trusted_model_capability; + use std::path::Path; + let manifest = observation_manifest_one_profile(); + let (_workspace, source_repo, base_commit) = init_committed_source_repo("baseline"); + fs::write(source_repo.join("prior.json"), held_out_prior_fixture()).unwrap(); + let input = HeldOutPriorDataInput::freeze(&source_repo, Path::new("prior.json")).unwrap(); + let _outer = HeldOutPriorDataInput::bind_for_repo(Some(&input), &source_repo).unwrap(); + assert!(trusted_model_capability("held-out-grok").is_some()); + let (_artifact, artifact_repo) = artifact_owner(); + let plan_workspace = tempfile::TempDir::new().unwrap(); + let mut request = real_provider_request( + HeldOutExplicitSourceBaseline { + source_repo, + base_commit, + }, + write_caller_plan(plan_workspace.path(), "docs-child", false, false), + ); + request.runtime = crate::supervise::SupervisorRuntime::Codex; + let results = super::executed_experiment::run_held_out_real_provider_experiment_with_dispatch( + &manifest, + request, + &artifact_repo, + |_options, _authority, _allowlist| { + assert!(trusted_model_capability("held-out-grok").is_none()); + anyhow::bail!("bounded fixture refusal") + }, + ) + .unwrap(); + assert!(trusted_model_capability("held-out-grok").is_some()); + let reader = ArtifactRunReader::open( + &artifact_repo, + RunArtifactFamily::Supervise, + &crate::orchestrator::RunId::new(&results.artifact_run_id).unwrap(), + ) + .unwrap(); + assert!(reader.finalization().files.iter().all(|record| !record + .path + .to_string_lossy() + .contains("operator_prior_data"))); + let binding: serde_json::Value = serde_json::from_slice( + &reader + .read("held-out/p0-r0/requested-profile-binding.json") + .unwrap(), + ) + .unwrap(); + assert!(binding.get("operator_prior_data").is_none()); +} + #[test] fn held_out_real_provider_refuses_incomplete_tuple_before_artifact_reservation() { let manifest = experiment_manifest(); diff --git a/src/supervise.rs b/src/supervise.rs index 95e5af76..d1d9182e 100644 --- a/src/supervise.rs +++ b/src/supervise.rs @@ -309,6 +309,7 @@ pub use accepted_task_cost::AcceptedTaskCostRollup; mod environment_observation; mod outcome_history; mod prior_input; +pub(crate) use prior_input::{FrozenOperatorPriorData, OperatorPriorDataGuard}; mod runtime_executables; use outcome_history::*; pub use prior_input::{bind_frozen_operator_prior_data_for_run, bind_operator_prior_data}; diff --git a/src/supervise/prior_input.rs b/src/supervise/prior_input.rs index f0c40d2c..6fb4f91d 100644 --- a/src/supervise/prior_input.rs +++ b/src/supervise/prior_input.rs @@ -68,6 +68,75 @@ pub(crate) struct BoundPriorData { provenance: selection::OperatorPriorDataProvenance, } +/// Immutable validated input, independent of the thread that later uses it. +#[derive(Debug, Clone)] +pub(crate) struct FrozenOperatorPriorData(Arc); + +impl PartialEq for FrozenOperatorPriorData { + fn eq(&self, other: &Self) -> bool { + self.0.repo == other.0.repo + && self.0.raw == other.0.raw + && self.0.provenance == other.0.provenance + } +} + +impl Eq for FrozenOperatorPriorData {} + +impl FrozenOperatorPriorData { + pub(crate) fn read(repo: &Path, relative_path: &Path) -> Result { + require_supported_prior_input_platform()?; + let repo = crate::artifacts::discover_repo_root(repo)?; + let relative_path = normalize_repo_relative_path(relative_path) + .context("operator prior-data path must be repository-relative")?; + let raw = BoundedRegularReader::read_relative( + &repo, + &relative_path, + MAX_OPERATOR_PRIOR_INPUT_BYTES, + ) + .context("failed to read bounded repository-local operator prior data")?; + Ok(Self(bind_from_bytes(repo, relative_path, raw)?)) + } + + pub(crate) fn verify_source(&self, repo: &Path) -> Result<()> { + if crate::artifacts::discover_repo_root(repo)? != self.0.repo { + bail!("operator prior snapshot belongs to a different source repository"); + } + let raw = BoundedRegularReader::read_relative( + &self.0.repo, + Path::new(&self.0.provenance.relative_path), + MAX_OPERATOR_PRIOR_INPUT_BYTES, + )?; + if raw != self.0.raw { + bail!("--prior-data changed from the frozen source snapshot"); + } + Ok(()) + } + + /// Rebind frozen evidence to an isolated repository without rereading input. + /// An absent input explicitly clears inherited thread-local prior authority. + pub(crate) fn bind_for_repo( + snapshot: Option<&Self>, + repo: &Path, + ) -> Result { + let binding = snapshot + .map(|snapshot| -> Result<_> { + let mut binding = (*snapshot.0).clone(); + binding.repo = crate::artifacts::discover_repo_root(repo)?; + Ok(Arc::new(binding)) + }) + .transpose()?; + Ok(install(binding)) + } + + pub(crate) fn provenance(&self) -> &selection::OperatorPriorDataProvenance { + &self.0.provenance + } + + pub(crate) fn archive(&self, writer: &mut ArtifactRunWriter, prefix: &Path) -> Result<()> { + archive_binding(&self.0, writer, prefix) + } +} + thread_local! { static BOUND_PRIOR_DATA: RefCell>> = const { RefCell::new(None) }; } @@ -135,18 +204,26 @@ pub(crate) fn archive_current(writer: &mut ArtifactRunWriter, repo: &Path) -> Re let Some(binding) = captured_binding() else { return Ok(()); }; + archive_binding(&binding, writer, Path::new("")) +} + +fn archive_binding( + binding: &BoundPriorData, + writer: &mut ArtifactRunWriter, + prefix: &Path, +) -> Result<()> { writer.write_bytes( - RAW_ARTIFACT, + prefix.join(RAW_ARTIFACT), &binding.raw, ArtifactFileDisposition::PrivateEvidence, )?; writer.write_json( - EFFECTIVE_ARTIFACT, + prefix.join(EFFECTIVE_ARTIFACT), &binding.effective, ArtifactFileDisposition::PrivateEvidence, )?; writer.write_json( - BINDING_ARTIFACT, + prefix.join(BINDING_ARTIFACT), &binding.provenance, ArtifactFileDisposition::PrivateEvidence, )?; @@ -157,19 +234,8 @@ pub fn bind_operator_prior_data( repo: impl AsRef, relative_path: impl AsRef, ) -> Result { - require_supported_prior_input_platform()?; - let repo = crate::artifacts::discover_repo_root(repo.as_ref())?; - let relative_path = normalize_repo_relative_path(relative_path.as_ref()) - .context("operator prior-data path must be repository-relative")?; - let raw = - BoundedRegularReader::read_relative(&repo, &relative_path, MAX_OPERATOR_PRIOR_INPUT_BYTES) - .with_context(|| { - format!( - "failed to read bounded repository-local operator prior data {}", - relative_path.display() - ) - })?; - Ok(install(Some(bind_from_bytes(repo, relative_path, raw)?))) + let snapshot = FrozenOperatorPriorData::read(repo.as_ref(), relative_path.as_ref())?; + Ok(install(Some(snapshot.0))) } fn bind_from_bytes( @@ -717,6 +783,63 @@ mod tests { Ok(()) } + #[cfg(unix)] + #[test] + fn frozen_snapshot_rebinds_isolated_repo_and_captured_threads_without_scope_leakage( + ) -> Result<()> { + let (_source_owner, source) = super::super::tests::injected_repository(); + let (_profile_owner, profile) = super::super::tests::injected_repository(); + let raw = serde_json::to_vec(&new_model_snapshot("isolated-prior-model")?)?; + std::fs::write(source.join("prior.json"), &raw)?; + let snapshot = FrozenOperatorPriorData::read(&source, Path::new("prior.json"))?; + snapshot.verify_source(&source)?; + assert!(snapshot.verify_source(&profile).is_err()); + let _source = FrozenOperatorPriorData::bind_for_repo(Some(&snapshot), &source)?; + assert!(require_binding_repo(&profile).is_err()); + let source_provenance = current_provenance(); + std::fs::write(source.join("prior.json"), b"{}")?; + assert!(snapshot.verify_source(&source).is_err()); + let expected_digest = { + let mut input = selection::selection_test_base_input(); + input.priors = current_priors().context("source priors")?; + selection::select(&input)?.input_digests.priors.value + }; + { + let _profile = FrozenOperatorPriorData::bind_for_repo(Some(&snapshot), &profile)?; + require_binding_repo(&profile)?; + assert!(require_binding_repo(&source).is_err()); + let captured = captured_binding(); + let thread_repo = profile.clone(); + let expected_provenance = snapshot.provenance().clone(); + let observed = std::thread::spawn(move || -> Result { + assert!(current_priors().is_none()); + let _thread = install_captured(captured); + require_binding_repo(&thread_repo)?; + assert_eq!(current_provenance(), Some(expected_provenance)); + validate_known_judgment_role_model( + AgentRole::Auditor, + Some("isolated-prior-model"), + )?; + let mut input = selection::selection_test_base_input(); + input.priors = + super::super::selection_bridge::selector_priors_with_terminal_worker_economics( + )?; + Ok(selection::select(&input)?.input_digests.priors.value) + }) + .join() + .expect("captured profile thread")?; + assert_eq!(observed, expected_digest); + { + let _absent = FrozenOperatorPriorData::bind_for_repo(None, &profile)?; + assert!(current_priors().is_none()); + } + assert_eq!(current_provenance(), source_provenance); + } + require_binding_repo(&source)?; + assert_eq!(current_provenance(), source_provenance); + Ok(()) + } + #[test] fn unbound_selection_artifacts_keep_legacy_shape() -> Result<()> { let input = selection::selection_test_base_input(); From f8276dcd179a0b54185fc7784bec45c22c3aa062 Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:37:16 +0900 Subject: [PATCH 13/27] Continue batch process stops after individual target failures - Batch stop attempts every registered identity in the selected run and reports termination and registry-update failures separately. - Confirmed-stale rows are cleaned during stop; single-target ambiguity still refuses multiple live or uncertain rows. - Unresolved entries stay registered for retry, and the CLI prints a complete report before returning failure. - PID/start-time and pidfd safeguards are unchanged. - Regression coverage includes first and middle failures, bookkeeping errors, restarted tasks, uncertain identities and safe retry. Validation: the targeted regressions, formatting, Clippy and the platform suites run in this PR's CI. --- src/agent_lifecycle.rs | 427 ++++++++++++++++++++++++++++++++++++++--- src/cli.rs | 2 +- src/cli/part2.rs | 17 +- tests/cli_smoke.rs | 100 ++++++++++ 4 files changed, 513 insertions(+), 33 deletions(-) diff --git a/src/agent_lifecycle.rs b/src/agent_lifecycle.rs index fb94fa79..73fa2f17 100644 --- a/src/agent_lifecycle.rs +++ b/src/agent_lifecycle.rs @@ -672,8 +672,48 @@ pub struct AgentStopEntry { } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentStopFailureReason { + Termination, + RegistryUpdate, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AgentStopFailure { + pub process: AgentProcessRecord, + pub reason: AgentStopFailureReason, + /// Present when exit was confirmed but registry removal failed. + pub outcome: Option, + pub error: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] pub struct AgentStopReport { pub stopped: Vec, + pub failures: Vec, +} + +impl AgentStopReport { + pub fn ensure_complete(&self) -> Result<()> { + if !self.failures.is_empty() { + let details = self + .failures + .iter() + .map(|failure| { + format!( + "PID {} ({:?}): {}", + failure.process.pid, failure.reason, failure.error + ) + }) + .collect::>() + .join("; "); + bail!( + "agent stop left {} unresolved target(s): {details}", + self.failures.len() + ); + } + Ok(()) + } } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] @@ -909,8 +949,8 @@ impl AgentRegistry { pub fn stop_selector(&self, selector: &str, wait: Duration) -> Result { validate_text_field("agent selector", selector, MAX_IDENTIFIER_BYTES)?; - let live = self.list(&AgentListFilter::default())?; - let matches = live + let matches = self + .snapshot_records()? .into_iter() .filter(|process| { process.pid.to_string() == selector @@ -919,17 +959,18 @@ impl AgentRegistry { }) .collect::>(); if matches.is_empty() { - bail!("no live MACO agent matches selector '{selector}'"); + bail!("no registered MACO agent matches selector '{selector}'"); } - if matches.len() > 1 { - let details = matches + let competing = competing_stop_records(&matches); + if competing.len() > 1 { + let details = competing .iter() - .map(AgentProcessRecord::summary) + .map(|process| process.summary()) .collect::>() .join(", "); bail!( "agent selector '{selector}' is ambiguous; {} matches: {details}", - matches.len() + competing.len() ); } self.stop_records(matches, wait) @@ -937,9 +978,11 @@ impl AgentRegistry { pub fn stop_run(&self, run_id: &str, wait: Duration) -> Result { validate_text_field("run id", run_id, MAX_IDENTIFIER_BYTES)?; - let matches = self.list(&AgentListFilter { - run_id: Some(run_id.to_string()), - })?; + let matches = self + .snapshot_records()? + .into_iter() + .filter(|process| process.run_id == run_id) + .collect(); self.stop_records(matches, wait) } @@ -951,36 +994,47 @@ impl AgentRegistry { ) -> Result { validate_text_field("run id", run_id, MAX_IDENTIFIER_BYTES)?; validate_text_field("task id", task_id, MAX_IDENTIFIER_BYTES)?; - let live = self.list(&AgentListFilter { - run_id: Some(run_id.to_string()), - })?; - let matches = live + let matches = self + .snapshot_records()? .into_iter() - .filter(|process| process.task_id == task_id) + .filter(|process| process.run_id == run_id && process.task_id == task_id) .collect::>(); if matches.is_empty() { - return Ok(AgentStopReport { - stopped: Vec::new(), - }); + return Ok(AgentStopReport::default()); } - if matches.len() > 1 { - let details = matches + let competing = competing_stop_records(&matches); + if competing.len() > 1 { + let details = competing .iter() - .map(AgentProcessRecord::summary) + .map(|process| process.summary()) .collect::>() .join(", "); bail!( "agent assignment ({run_id}, {task_id}) is ambiguous; {} matches: {details}", - matches.len() + competing.len() ); } - self.stop_records(matches, wait) + let report = self.stop_records(matches, wait)?; + report.ensure_complete()?; + Ok(report) } fn stop_records( &self, records: Vec, wait: Duration, + ) -> Result { + self.stop_records_with_operations(records, wait, terminate_process, |process| { + self.remove_identity(process.pid, &process.process_start_time) + }) + } + + fn stop_records_with_operations( + &self, + records: Vec, + wait: Duration, + mut terminate: impl FnMut(&AgentProcessRecord, Duration) -> Result, + mut remove: impl FnMut(&AgentProcessRecord) -> Result<()>, ) -> Result { if wait > MAX_STOP_WAIT { bail!( @@ -988,14 +1042,31 @@ impl AgentRegistry { MAX_STOP_WAIT.as_secs() ); } - let mut stopped = Vec::with_capacity(records.len()); + let mut report = AgentStopReport::default(); for process in records { - let outcome = terminate_process(&process, wait) - .with_context(|| format!("failed to stop {}", process.summary()))?; - self.remove_identity(process.pid, &process.process_start_time)?; - stopped.push(AgentStopEntry { process, outcome }); + let outcome = match terminate(&process, wait) { + Ok(outcome) => outcome, + Err(error) => { + report.failures.push(AgentStopFailure { + process, + reason: AgentStopFailureReason::Termination, + outcome: None, + error: format!("{error:#}"), + }); + continue; + } + }; + match remove(&process) { + Ok(()) => report.stopped.push(AgentStopEntry { process, outcome }), + Err(error) => report.failures.push(AgentStopFailure { + process, + reason: AgentStopFailureReason::RegistryUpdate, + outcome: Some(outcome), + error: format!("{error:#}"), + }), + } } - Ok(AgentStopReport { stopped }) + Ok(report) } fn remove_identity(&self, pid: u32, start_time: &str) -> Result<()> { @@ -1049,6 +1120,18 @@ impl AgentRegistry { } } +fn competing_stop_records(records: &[AgentProcessRecord]) -> Vec<&AgentProcessRecord> { + records + .iter() + .filter(|process| { + !matches!( + process_state(process.pid, &process.process_start_time), + Ok(ProcessIdentityState::Gone | ProcessIdentityState::Reused) + ) + }) + .collect() +} + fn read_state(root: &SafeRoot, repo: &Path) -> Result { if !root.direct_child_exists(REGISTRY_FILE)? { return Ok(RegistryState::default()); @@ -1857,6 +1940,9 @@ mod tests { assert_eq!(inspections[0].identity, AgentIdentityLiveness::Uncertain); assert!(registry.list(&AgentListFilter::default()).is_err()); assert!(terminate_process(&record, Duration::from_millis(10)).is_err()); + assert!(registry + .stop_assignment(&record.run_id, &record.task_id, Duration::from_millis(10)) + .is_err()); assert_eq!(std::fs::read(®istry_path)?, before); assert_eq!(registry.snapshot_all()?, vec![record]); Ok(()) @@ -1989,6 +2075,289 @@ mod tests { Ok(()) } + #[cfg(target_os = "linux")] + #[test] + fn single_target_stop_ignores_stale_siblings_and_removes_both_records() -> Result<()> { + for assignment in [false, true] { + for reused in [false, true] { + let (_temp, registry) = registry()?; + let mut stale_child = SleepChild::spawn()?; + let mut live_child = SleepChild::spawn()?; + let mut stale = registry.register( + &metadata(®istry, "restarted-run", "restarted-task")?, + stale_child.pid(), + vec!["sleep".into()], + )?; + let live = registry.register( + &metadata(®istry, "restarted-run", "restarted-task")?, + live_child.pid(), + vec!["sleep".into()], + )?; + if reused { + let (incarnation, ticks) = stale + .process_start_time + .rsplit_once(':') + .context("fixture incarnation")?; + stale.process_start_time = + format!("{incarnation}:{}", ticks.parse::()? ^ 1); + registry.replace_records(vec![stale.clone(), live.clone()])?; + } else { + stale_child.0.kill()?; + stale_child.0.wait()?; + } + + let report = if assignment { + registry.stop_assignment( + "restarted-run", + "restarted-task", + Duration::from_secs(1), + )? + } else { + registry.stop_selector("restarted-task", Duration::from_secs(1))? + }; + report.ensure_complete()?; + assert_eq!(report.stopped.len(), 2); + assert!(report.stopped.iter().any(|entry| { + entry.process == stale && entry.outcome == AgentStopOutcome::AlreadyExited + })); + assert!(report.stopped.iter().any(|entry| { + entry.process == live && entry.outcome == AgentStopOutcome::Terminated + })); + assert!(!live_child.0.wait()?.success()); + if reused { + assert!(stale_child.0.try_wait()?.is_none()); + } + assert!(registry.snapshot_all()?.is_empty()); + } + } + Ok(()) + } + + #[cfg(target_os = "linux")] + #[test] + fn single_target_stop_keeps_uncertain_siblings_competing() -> Result<()> { + for assignment in [false, true] { + let (_temp, registry) = registry()?; + let mut uncertain_child = SleepChild::spawn()?; + let mut live_child = SleepChild::spawn()?; + let mut uncertain = registry.register( + &metadata(®istry, "uncertain-run", "uncertain-task")?, + uncertain_child.pid(), + vec!["sleep".into()], + )?; + let live = registry.register( + &metadata(®istry, "uncertain-run", "uncertain-task")?, + live_child.pid(), + vec!["sleep".into()], + )?; + uncertain.process_start_time = "123:456".into(); + let records = vec![uncertain, live]; + registry.replace_records(records.clone())?; + + let result = if assignment { + registry.stop_assignment("uncertain-run", "uncertain-task", Duration::from_secs(1)) + } else { + registry.stop_selector("uncertain-task", Duration::from_secs(1)) + }; + let error = result.expect_err("uncertain and live rows must compete"); + let message = error.to_string(); + assert!(message.contains("ambiguous; 2 matches")); + assert!(message.contains(&uncertain_child.pid().to_string())); + assert!(message.contains(&live_child.pid().to_string())); + assert!(uncertain_child.0.try_wait()?.is_none()); + assert!(live_child.0.try_wait()?.is_none()); + assert_eq!(registry.snapshot_all()?, records); + } + Ok(()) + } + + #[test] + fn batch_stop_continues_after_target_and_bookkeeping_failures() -> Result<()> { + for failed_index in [0, 1] { + let (_temp, registry) = registry()?; + let children = (0..4) + .map(|_| SleepChild::spawn()) + .collect::>>()?; + let records = children + .iter() + .enumerate() + .map(|(index, child)| { + registry.register( + &metadata(®istry, "run-batch", &format!("task-{index}"))?, + child.pid(), + vec!["sleep".into()], + ) + }) + .collect::>>()?; + let mut attempted = Vec::new(); + let mut removals = Vec::new(); + let report = registry.stop_records_with_operations( + records.clone(), + Duration::from_millis(10), + |process, _| { + attempted.push(process.pid); + if process == &records[failed_index] { + bail!("termination fixture refusal"); + } + Ok(AgentStopOutcome::AlreadyExited) + }, + |process| { + removals.push(process.pid); + if process == &records[2] { + bail!("registry fixture refusal"); + } + registry.remove_identity(process.pid, &process.process_start_time) + }, + )?; + assert_eq!(attempted, records.iter().map(|p| p.pid).collect::>()); + assert_eq!(removals.len(), 3); + assert!(!removals.contains(&records[failed_index].pid)); + assert_eq!(report.stopped.len(), 2); + assert_eq!(report.stopped.last().unwrap().process, records[3]); + assert_eq!(report.failures.len(), 2); + assert_eq!(report.failures[0].process, records[failed_index]); + assert_eq!( + report.failures[0].reason, + AgentStopFailureReason::Termination + ); + assert_eq!(report.failures[0].outcome, None); + assert_eq!(report.failures[1].process, records[2]); + assert_eq!( + report.failures[1].reason, + AgentStopFailureReason::RegistryUpdate + ); + assert_eq!( + report.failures[1].outcome, + Some(AgentStopOutcome::AlreadyExited) + ); + let error = report.ensure_complete().unwrap_err().to_string(); + assert!(error.contains(&format!("PID {} (Termination)", records[failed_index].pid))); + assert!(error.contains("termination fixture refusal")); + assert!(error.contains(&format!("PID {} (RegistryUpdate)", records[2].pid))); + assert!(error.contains("registry fixture refusal")); + let remaining = registry.snapshot_all()?; + assert_eq!(remaining.len(), 2); + assert!(remaining.contains(&records[failed_index])); + assert!(remaining.contains(&records[2])); + } + Ok(()) + } + + #[test] + fn batch_stop_retry_preserves_unresolved_identity() -> Result<()> { + let (_temp, registry) = registry()?; + let mut children = (0..3) + .map(|_| SleepChild::spawn()) + .collect::>>()?; + let records = children + .iter() + .enumerate() + .map(|(index, child)| { + registry.register( + &metadata(®istry, "run-retry", &format!("task-{index}"))?, + child.pid(), + vec!["sleep".into()], + ) + }) + .collect::>>()?; + let report = registry.stop_records_with_operations( + records.clone(), + Duration::from_secs(1), + |process, wait| { + if process == &records[0] { + bail!("termination fixture refusal"); + } + terminate_process(process, wait) + }, + |process| { + if process == &records[1] { + bail!("registry fixture refusal"); + } + registry.remove_identity(process.pid, &process.process_start_time) + }, + )?; + assert_eq!(report.stopped.len(), 1); + assert_eq!(report.failures.len(), 2); + assert_eq!(registry.snapshot_all()?, records[..2]); + assert!(children[0].0.try_wait()?.is_none()); + children[1].0.wait()?; + children[2].0.wait()?; + + let retry = registry.stop_run("run-retry", Duration::from_secs(1))?; + retry.ensure_complete()?; + assert_eq!(retry.stopped.len(), 2); + assert!(retry.stopped.iter().any(|entry| { + entry.process == records[0] && entry.outcome == AgentStopOutcome::Terminated + })); + assert!(retry.stopped.iter().any(|entry| { + entry.process == records[1] && entry.outcome == AgentStopOutcome::AlreadyExited + })); + children[0].0.wait()?; + assert!(registry.snapshot_all()?.is_empty()); + Ok(()) + } + + #[cfg(target_os = "linux")] + #[test] + fn batch_stop_reports_absent_reused_and_uncertain_identities() -> Result<()> { + let (_temp, registry) = registry()?; + let mut children = (0..5) + .map(|_| SleepChild::spawn()) + .collect::>>()?; + let mut records = children + .iter() + .enumerate() + .map(|(index, child)| { + registry.register( + &metadata(®istry, "run-mixed", &format!("task-{index}"))?, + child.pid(), + vec!["sleep".into()], + ) + }) + .collect::>>()?; + records[0].process_start_time = "123:456".into(); + children[1].0.kill()?; + children[1].0.wait()?; + let (incarnation, ticks) = records[2] + .process_start_time + .rsplit_once(':') + .context("fixture incarnation")?; + records[2].process_start_time = format!("{incarnation}:{}", ticks.parse::()? ^ 1); + records[4].run_id = "other-run".into(); + records[4].process_start_time = "123:456".into(); + registry.replace_records(records.clone())?; + + let report = registry.stop_run("run-mixed", Duration::from_secs(1))?; + assert_eq!(report.failures.len(), 1); + assert_eq!(report.failures[0].process, records[0]); + assert_eq!( + report.failures[0].reason, + AgentStopFailureReason::Termination + ); + assert_eq!(report.stopped.len(), 3); + for index in [1, 2] { + assert!(report.stopped.iter().any(|entry| { + entry.process == records[index] && entry.outcome == AgentStopOutcome::AlreadyExited + })); + } + assert!(report.stopped.iter().any(|entry| { + entry.process == records[3] && entry.outcome == AgentStopOutcome::Terminated + })); + children[3].0.wait()?; + for index in [0, 2, 4] { + assert!(children[index].0.try_wait()?.is_none()); + } + let remaining = registry.snapshot_all()?; + assert_eq!(remaining.len(), 2); + assert!(remaining.contains(&records[0])); + assert!(remaining.contains(&records[4])); + let retry = registry.stop_run("run-mixed", Duration::from_secs(1))?; + assert!(retry.stopped.is_empty()); + assert_eq!(retry.failures, report.failures); + assert_eq!(registry.snapshot_all()?, remaining); + Ok(()) + } + #[test] fn terminate_process_does_not_signal_a_reused_pid() -> Result<()> { let current_pid = std::process::id(); diff --git a/src/cli.rs b/src/cli.rs index 9ec9ef09..4e71fe08 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -3386,7 +3386,7 @@ struct ListAgentsArgs { struct StopAgentsArgs { /// Run id, task/assignment id, or decimal PID. The selector must match exactly one process. selector: Option, - /// Stop every live process in the run selected by --run-id. + /// Stop every registered process in the run selected by --run-id. #[arg(long)] all: bool, /// Run id used with --all. diff --git a/src/cli/part2.rs b/src/cli/part2.rs index 84c8743f..1bd978b2 100644 --- a/src/cli/part2.rs +++ b/src/cli/part2.rs @@ -2858,8 +2858,8 @@ fn print_agent_processes(processes: &[AgentProcessRecord], json: bool) -> Result fn print_agent_stop_report(report: &AgentStopReport, json: bool) -> Result<()> { if json { println!("{}", serde_json::to_string_pretty(report)?); - } else if report.stopped.is_empty() { - println!("No live MACO agents matched the selected run."); + } else if report.stopped.is_empty() && report.failures.is_empty() { + println!("No registered MACO agents matched the selected run."); } else { for stopped in &report.stopped { println!( @@ -2870,8 +2870,19 @@ fn print_agent_stop_report(report: &AgentStopReport, json: bool) -> Result<()> { stopped.process.task_id ); } + for failure in &report.failures { + println!( + "Failed({:?}, {:?})\t{}\t{}\t{}\t{}", + failure.reason, + failure.outcome, + failure.process.pid, + failure.process.run_id, + failure.process.task_id, + failure.error + ); + } } - Ok(()) + report.ensure_complete() } fn print_worktree_lifecycle_report(report: &WorktreeLifecycleReport, json: bool) -> Result<()> { diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index 09c89250..263abd32 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -2315,6 +2315,106 @@ fn assert_orchestration_failed_closed(summary: &Value) -> Result<()> { Ok(()) } +#[cfg(target_os = "linux")] +#[test] +fn cli_agents_stop_emits_partial_report_before_failure() -> Result<()> { + use multi_agent_coding_orchestrator::agent_lifecycle::{AgentLaunchMetadata, AgentRegistry}; + + struct StopFixture(std::process::Child); + impl Drop for StopFixture { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + + let sleep = [ + "/run/current-system/sw/bin/sleep", + "/usr/bin/sleep", + "/bin/sleep", + ] + .into_iter() + .map(Path::new) + .find(|path| path.is_file()) + .context("sleep executable")?; + for failed_index in [0, 1] { + let temp = TempDir::new()?; + Repository::init(temp.path())?; + let registry = AgentRegistry::open(temp.path())?; + let mut children = Vec::new(); + let mut records = Vec::new(); + for index in 0..3 { + let child = StopFixture(Command::new(sleep).arg("60").spawn()?); + records.push(registry.register( + &AgentLaunchMetadata::new( + registry.repo(), + "worker", + "run-partial", + format!("task-{index}"), + )?, + child.0.id(), + vec!["sleep".into()], + )?); + children.push(child); + } + // A legacy identity cannot authorize signalling even a live fixture PID. + records[failed_index].process_start_time = "123:456".into(); + fs::write( + registry.registry_path(), + serde_json::to_vec(&serde_json::json!({ "version": 1, "processes": records }))?, + )?; + let output = Command::new(BIN) + .args([ + "agents", + "stop", + "--all", + "--run-id", + "run-partial", + "--repo", + ]) + .arg(temp.path()) + .args(["--wait-seconds", "1", "--json"]) + .output()?; + assert!(!output.status.success()); + let report: Value = serde_json::from_slice(&output.stdout) + .context("partial stop must emit a complete JSON document")?; + assert_eq!(report["stopped"].as_array().unwrap().len(), 2); + assert_eq!(report["failures"].as_array().unwrap().len(), 1); + assert_eq!(report["failures"][0]["reason"], "termination"); + assert!(report["failures"][0]["outcome"].is_null()); + assert_eq!( + report["failures"][0]["process"], + serde_json::to_value(&records[failed_index])? + ); + assert!(String::from_utf8_lossy(&output.stderr).contains("unresolved target")); + for (index, child) in children.iter_mut().enumerate() { + if index == failed_index { + assert!(child.0.try_wait()?.is_none()); + } else { + assert!(!child.0.wait()?.success()); + assert!(report["stopped"].as_array().unwrap().iter().any(|entry| { + entry["process"]["pid"] == child.0.id() && entry["outcome"] == "terminated" + })); + } + } + let retained: Value = serde_json::from_slice(&fs::read(registry.registry_path())?)?; + assert_eq!(retained["processes"].as_array().unwrap().len(), 1); + assert_eq!(retained["processes"][0], report["failures"][0]["process"]); + + let retry = Command::new(BIN) + .args(["agents", "stop", &format!("task-{failed_index}"), "--repo"]) + .arg(temp.path()) + .args(["--wait-seconds", "1", "--json"]) + .output()?; + assert!(!retry.status.success()); + let retry_report: Value = serde_json::from_slice(&retry.stdout)?; + assert!(retry_report["stopped"].as_array().unwrap().is_empty()); + assert_eq!(retry_report["failures"], report["failures"]); + assert!(children[failed_index].0.try_wait()?.is_none()); + } + Ok(()) +} + fn create_committed_repo(root: &Path) -> Result { let repo_path = root.join("repo"); let output = Command::new(BIN) From 0a73822135c264d197a8bcd9e2486a3a486db8f5 Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:38:21 +0900 Subject: [PATCH 14/27] Add bounded qualification fixtures for token control, source inputs and lower-effort workers Add finite qualification plans and committed input fixtures for nested token control, supplied-source review and a lower-effort writable lane. - Document the exact validation and live commands, expected contract checks, independent acceptance and retained evidence for each. - Keep account, containment, served-identity and exhaustion gates explicit without expanding runtime authority. - The seed fixtures intentionally fail until repaired; conflicting replays must be refused without changing ledger state. Validation: formatting, the Python seed-baseline, replay-mutant and binding checks, and offline CLI parses of all three plans passed. Real account-backed qualification runs remain separate. Refs #714. Refs #689. Refs #688. --- docs/qualification/README.md | 70 ++++ docs/qualification/grok-lower-effort.md | 90 +++++ docs/qualification/nested-codex-budget.md | 95 +++++ docs/qualification/researcher-source-input.md | 71 ++++ .../nested-codex-budget-qualification.json | 348 ++++++++++++++++++ .../qualification-data/source-packet.md | 42 +++ .../qualification-data/test_admission.py | 24 ++ .../qualification-data/test_grok_ledger.py | 48 +++ .../qualification-data/test_settlement.py | 39 ++ .../qualification-grok-lower-effort.json | 310 ++++++++++++++++ ...qualification-researcher-source-input.json | 316 ++++++++++++++++ 11 files changed, 1453 insertions(+) create mode 100644 docs/qualification/README.md create mode 100644 docs/qualification/grok-lower-effort.md create mode 100644 docs/qualification/nested-codex-budget.md create mode 100644 docs/qualification/researcher-source-input.md create mode 100644 examples/supervise/nested-codex-budget-qualification.json create mode 100644 examples/supervise/qualification-data/source-packet.md create mode 100644 examples/supervise/qualification-data/test_admission.py create mode 100644 examples/supervise/qualification-data/test_grok_ledger.py create mode 100644 examples/supervise/qualification-data/test_settlement.py create mode 100644 examples/supervise/qualification-grok-lower-effort.json create mode 100644 examples/supervise/qualification-researcher-source-input.json diff --git a/docs/qualification/README.md b/docs/qualification/README.md new file mode 100644 index 00000000..d55d3dc2 --- /dev/null +++ b/docs/qualification/README.md @@ -0,0 +1,70 @@ +# Live qualification preparation + +These fixtures prepare separate acceptance gates for [nested token control](nested-codex-budget.md), +[read-only source inspection](researcher-source-input.md), and a +[lower-effort writable lane](grok-lower-effort.md). None records a passed live run. +Plan validation is offline and does not assert account access, selector +eligibility, containment, provider identity, usage, or successful acceptance. + +Use an admitted Linux host, or an already configured WSL guest meeting the +[operator prerequisites](../OPERATOR_QUICKSTART.md). Use the accepted executable +and a clean disposable target repository containing the committed fixtures, +source packet and original test fixtures. Run qualifications serially. Preserve +existing shared state, account selection and other runs; never reset them to +make a fixture pass. Do not run these commands in an occupied working checkout. +An existing selected account is required; these instructions do not set up auth. + +In Bash, set exact existing paths and a fresh private evidence directory: + +```bash +export repo=/path/to/clean/qualification-checkout +export maco=/path/to/accepted/maco +export config=/path/to/existing/machine-global.json +export OUT=/path/to/private/new-qualification-evidence +umask 077 +mkdir -m 700 "$OUT" || exit 1 +cd "$repo" || exit 1 +test -z "$(git status --porcelain)" || exit 1 +git rev-parse HEAD > "$OUT/source-commit.txt" +git status --porcelain > "$OUT/source-status.txt" +"$maco" --version > "$OUT/maco-version.txt" +sha256sum "$maco" > "$OUT/maco-sha256.txt" +codex --version > "$OUT/codex-version.txt" +sha256sum "$(readlink -f "$(command -v codex)")" > "$OUT/codex-sha256.txt" +"$maco" machine-global status --config "$config" --json > "$OUT/machine-global-status.json" || exit 1 +``` + +The existing config must admit runtime root id `runtime`; otherwise use its +reviewed id in every command. Do not create a substitute config or bypass +containment. Record accepted build source/dirty identity as well as the target +snapshot commit; a target commit alone does not identify the executable. +Record selected account id, principal/incarnation, selection revision, observed +catalog revision/digest, model menu and available capacity from existing +non-secret authority receipts. Never copy auth files or process environments. +Unknown capacity or identity remains unknown and can block live admission. + +Each page gives the exact offline and live command. Offline commands need no +provider, login or delegated desktop. The JSON stdout is the normalized checked +plan; preserve it beside the fixture SHA256. The fixtures freeze the shipped +policy and carry deterministic `policy_heuristic` assessments. These are +declaration estimates, not account support or measured quality. Changing task, +scope, policy or hash-bound notes requires regenerating the corresponding +assessment/contract through accepted tooling and validating replay again. +Changing an effort preference alone does not create selector eligibility. + +Keep the full private run directory `.maco/o2/runs/` and any returned +cascade/follow-up run ids, not just stdout. Capture collection independently of +the launch status, including expected budget rejection. The final report is +`.maco/o2/runs//reports/supervisor-final.json` when finalization reaches +that stage. A missing report is a failure needing retained raw evidence. +Retain authenticated journals, command/result observations, schema bytes and +digest, native usage, path claims/candidate manifest, budget ledger, containment +and owned-process exit receipts. Do not treat exit 0 as sufficient evidence. + +Independent acceptance must cite real source observations and inspect the +candidate (or justify rejection), preserve all default review lenses and +strong control roles, and verify no surviving owned child or outstanding +reservation. Never publish credentials, private journals or raw transcripts. +Record a redacted result with date, source/executable hashes, run id, exact +command, exit statuses, evidence locations, acceptance decision, remaining +unknowns and reviewer identity. Publication and integration are separate gates. diff --git a/docs/qualification/grok-lower-effort.md b/docs/qualification/grok-lower-effort.md new file mode 100644 index 00000000..54a0172f --- /dev/null +++ b/docs/qualification/grok-lower-effort.md @@ -0,0 +1,90 @@ +# Lower-effort writable qualification (#688) + +Status: prepared; `grok-4.6` / `low` eligibility and live acceptance are unknown. +Follow the [shared preparation and evidence contract](README.md) and the +[managed Grok prerequisites](../OPERATOR_QUICKSTART.md#grok-runtime). +The [fixture](../../examples/supervise/qualification-grok-lower-effort.json) +assigns one substantive ledger repair to a terminal Worker and keeps +supervision, all independent review, acceptance and publication on qualified +control roles. It requests canonical `low`, standard headless streaming JSON, +and fail-closed availability; it never rewrites the request to `xhigh`. + +Before a live run, record that the selected CAM account is `work2`, its exact +principal/incarnation and selection revision, the installed executable/version, +and its observed menu/catalog digest supporting **both** `grok-4.6` and `low`. +Record capacity without inventing a balance. A catalog banner is not inference +authentication proof. The current frozen task/authority priors must independently +admit the exact lane. The bundled terminal prior historically selects `xhigh`; +parser success or generic protocol support does not establish a `low` prior. +Until all gates are established, record **unknown/refused before release** and +do not run a paid probe, switch accounts or silently select another effort. +Any necessary evidence-backed prior update is a separate reviewed change. + +Offline validation (does not evaluate those account/prior gates): + +```bash +fixture=examples/supervise/qualification-grok-lower-effort.json +sha256sum "$fixture" > "$OUT/fixture-sha256.txt" +"$maco" supervise plan "$fixture" --repo "$repo" --json > "$OUT/checked-plan.json" || exit 1 +``` + +After the exact eligibility gates pass, run with the existing managed account: + +```bash +export MACO_GROK_BIN="$(readlink -f "$(command -v grok)")" +test -f "$MACO_GROK_BIN" && test ! -L "$MACO_GROK_BIN" || exit 1 +unset MACO_GROK_INTERACTION_PROTOCOL +"$MACO_GROK_BIN" --version > "$OUT/grok-version.txt" +sha256sum "$MACO_GROK_BIN" > "$OUT/grok-sha256.txt" +run_id=qualification-grok-low-01 +if timeout --signal=TERM --kill-after=30s 3600 systemd-run --user --scope --quiet -p Delegate=yes \ + --working-directory="$repo" -- env MACO_GROK_BIN="$MACO_GROK_BIN" \ + "$maco" supervise run "$fixture" --runtime codex --repo "$repo" \ + --run-id "$run_id" --codex-bin codex --max-concurrent-children 1 --provider-inflight-limit 1 \ + --machine-global-config "$config" --machine-global-runtime-root-id runtime --json \ + > "$OUT/run.json" 2> "$OUT/run.stderr"; then rc=0; else rc=$?; fi +printf '%s\n' "$rc" > "$OUT/run.exit" +if "$maco" supervise collect "$run_id" --repo "$repo" --json \ + > "$OUT/collect.json" 2> "$OUT/collect.stderr"; then rc=0; else rc=$?; fi +printf '%s\n' "$rc" > "$OUT/collect.exit" +``` + +The 160,000-token ceiling, positive role estimates, 1,200-second child timeout +and zero retries bound this attempt; they do not assert a strict spending cap. +Use `--prior-data` only if an accepted evidence-backed repository-relative +snapshot already establishes the exact lower-effort eligibility; retain its +digest. Do not fabricate a snapshot to get through selection. + +Acceptance requires requested **and observed served** model/effort identity, +the opaque proof bound to exact executable, account, cwd, output/schema, +permissions, headless protocol and native-delegation denial; complete native +whole-invocation usage; genuine command/read/edit/check observations; and a +valid terminal WorkerReport. Only +`examples/supervise/qualification-data/test_grok_ledger.py` may change. Independent +reviewers must check admission, cumulative usage, replay and unknown-usage +behavior, inspect the candidate manifest/diff, and independently accept or reject. All default +lenses, final control authority and exact owned-process quiescence remain required. + +The independent reviewer runs the recognized test target against the actual +candidate and checks that existing test bodies and signatures are preserved: + +```bash +python3 -B -m unittest discover -s examples/supervise/qualification-data -p test_grok_ledger.py -v +git diff --check +``` + +It must reject admission `(100,0,60,60)`, accept `(100,20,30,50)`, retain a floor +of 40 for notices `20,35,35,40,35`, and settle A=40 exactly once while releasing +A and recording settled[A]=40. Unknown usage and conflicting replay refuse +before effects. The seed intentionally fails before repair; passing unchanged +or deleted tests is not qualification. No generic authored-check expansion is needed. + +Reuse canonical-effort/protocol separation and principal/effort drift tests, +plus existing missing/mismatched identity, absent usage, invalid schema, +widened path and native-delegation refusal regressions before release. Retain +any real negative-case receipts; providerless negatives alone do not establish +real selected-account qualification. Reject ambiguous `deep`, unknown effort, +ACP/headless mismatch, launch drift and silent upgrades. Headless containment +does not qualify ACP (#616). A prior `xhigh` mixed run, native CLI success or +working account is insufficient. One accepted run makes no cost/quality, +quota-saving or model-equivalence claim; #631 requires separate measurements. diff --git a/docs/qualification/nested-codex-budget.md b/docs/qualification/nested-codex-budget.md new file mode 100644 index 00000000..74d0d583 --- /dev/null +++ b/docs/qualification/nested-codex-budget.md @@ -0,0 +1,95 @@ +# Nested writable token-control qualification (#714) + +Status: prepared; real within-turn exhaustion is unproven. Follow the +[shared preparation and evidence contract](README.md) before running. +The [fixture](../../examples/supervise/nested-codex-budget-qualification.json) +has one execution parent and exactly two disjoint terminal Workers, requested +serially, on `gpt-6.1-sol` / `medium`; independent control/review roles retain +`xhigh`. Native runtime delegation is prohibited; nested dispatch goes through +the authenticated coordinator channel. No retries or gate corrections are +authorized by the fixture. + +The finite ceiling is 32,000 run tokens, with admission estimates of 4,000 for +the parent/reviewer and 1,000 per Worker. Each child has a 1,200-second timeout; +the outer command has a 3,600-second deadline. Estimates do not bound actual +consumption and the Worker live grant is the remaining admitted allowance, +not its 1,000-token estimate. The meaningful task repairs two deliberately +flawed Python test fixtures with disjoint ownership. It does not pad output +or request unlimited turns to force usage. + +```bash +fixture=examples/supervise/nested-codex-budget-qualification.json +run_id=qualification-nested-budget-01 +sha256sum "$fixture" > "$OUT/fixture-sha256.txt" +"$maco" supervise plan "$fixture" --repo "$repo" --json > "$OUT/checked-plan.json" || exit 1 +if timeout --signal=TERM --kill-after=30s 3600 systemd-run --user --scope --quiet -p Delegate=yes \ + --working-directory="$repo" -- "$maco" supervise run "$fixture" \ + --runtime codex --repo "$repo" --run-id "$run_id" --codex-bin codex \ + --max-concurrent-children 1 --provider-inflight-limit 1 \ + --machine-global-config "$config" --machine-global-runtime-root-id runtime --json \ + > "$OUT/run.json" 2> "$OUT/run.stderr"; then rc=0; else rc=$?; fi +printf '%s\n' "$rc" > "$OUT/run.exit" +if "$maco" supervise collect "$run_id" --repo "$repo" --json \ + > "$OUT/collect.json" 2> "$OUT/collect.stderr"; then rc=0; else rc=$?; fi +printf '%s\n' "$rc" > "$OUT/collect.exit" +``` + +Use a fresh run id and evidence directory for an explicitly authorized rerun. +A single run cannot guarantee exhaustion at the desired stage. If the parent +exhausts first, a Worker never launches, both Workers finish below the ceiling, +or the timeout fires without token exhaustion, record **inconclusive**, not pass. +Do not automatically retry, raise the ceiling or weaken admission. The operator +can prepare a separately reviewed finite ceiling from observed setup/Worker +usage if another run is authorized, preserving both plans and their evidence. + +Acceptance requires all of the following, cross-linked by exact run, assignment, +attempt, reservation, account, thread and turn identities: + +- Actual native writable sandbox, disjoint path claims, source/executable + identity, authenticated serial dispatch, journal and schema bindings. +- Before each Worker, settled usage, outstanding reservations, estimate and + exact remaining live grant. Capture multiple within-turn cumulative usage + notices correlated to that Worker's thread/turn; unrelated notices are excluded. +- An observed notice reaches or exceeds the live grant, triggers interrupt, + and receives a terminal completion/drain acknowledgement. Retain every later + trustworthy drain observation; final settlement must include the largest + trustworthy cumulative floor and must not sum cumulative notices. +- One settlement for the exact reservation, including rejected/partial output; + zero residual reservations and no additional provider dispatch after observed + exhaustion. A refused subsequent dispatch/reservation must be visible in the + production journal; do not launch a separate paid probe to manufacture it. +- Independent review of actual fixture changes and command observations, + expected rejection of incomplete output, complete accounted usage, and exact + owned child/cgroup quiescence. Timeout or process cancellation alone is not + token-control qualification. + +Both assigned files are recognized runnable test targets, so preflight does not +depend on a new generic authored-check capability. The independent reviewer +must run these commands against the actual candidate and verify existing test +bodies were preserved (a Worker can otherwise weaken its own test): + +```bash +python3 -B -m unittest discover -s examples/supervise/qualification-data -p test_admission.py -v +python3 -B -m unittest discover -s examples/supervise/qualification-data -p test_settlement.py -v +git diff --check +``` + +Admission must reject `(100, 0, 60, 60)` and accept `(100, 20, 30, 50)`; +cumulative notices `20,35,35,40` settle at 40, retaining 40 after a stale 35. +Report-persistence failure must still leave consumed=40, no active A, and +settled[A]=40. Identical replay changes nothing; conflicting replay refuses. +Unknown usage raises before effects. Both seeds intentionally fail before repair. + +The original failure on `e66870bdd507fd4f732719ba0679e69e90ec277d` remains: +220,000 ceiling; 137,035 settled before Worker two; 82,965 remaining; +16,000 reservation; 191,231 terminal Worker usage; 328,266 total (108,266 over). +It was delayed observation, not duplicate settlement. Preserve that evidence. +The correction interrupts **after an observed usage notice**; it does not prove +a hard provider-call spending limit, a monetary bound, or an absence of overshoot. + +Before live work, reuse the exact `managed_worker_live_budget` exhaustion/drain +and changed/stopped/released-grant regressions, plus +`nested_worker_final_live_budget_preserves_shortened_timeout_none_and_failed_report_floor` +in the admitted Linux environment. Providerless tests do not satisfy this gate. +Keep #692 settlement-order evidence separate. Closure requires the real +exhaustion evidence above and independent acceptance, not fixture preparation. diff --git a/docs/qualification/researcher-source-input.md b/docs/qualification/researcher-source-input.md new file mode 100644 index 00000000..2d1c01c7 --- /dev/null +++ b/docs/qualification/researcher-source-input.md @@ -0,0 +1,71 @@ +# Read-only supplied-source qualification (#689) + +Status: prepared; real supplied-source review is unproven. Follow the +[shared preparation and evidence contract](README.md). The +[fixture](../../examples/supervise/qualification-researcher-source-input.json) +uses one non-delegating read-only Researcher on `gpt-6.1-sol` / `medium` and +independent default review lenses. It declares exactly one nonempty snapshot +file, [source-packet.md](../../examples/supervise/qualification-data/source-packet.md), +SHA256 `1cfe51c5e96a38d3dabe431faf3b50e34b2512221c585e80d3b313cd3a87f53e`. +Its flawed ledger proposal gives the review concrete source observations and +counterexamples; it is not an empty diff or a receipt-only exercise. + +The packet must already be committed in the selected managed snapshot. Do not +stage an ambient parent file, infer mounts from task text, grant directory +visibility, or refresh the hash to excuse drift. The outer/inner profile probes +and release-time identity/hash revalidation must succeed before provider release. + +```bash +fixture=examples/supervise/qualification-researcher-source-input.json +run_id=qualification-researcher-source-01 +printf '%s %s\n' \ + 1cfe51c5e96a38d3dabe431faf3b50e34b2512221c585e80d3b313cd3a87f53e \ + examples/supervise/qualification-data/source-packet.md | sha256sum --check || exit 1 +git ls-files --error-unmatch examples/supervise/qualification-data/source-packet.md || exit 1 +sha256sum "$fixture" > "$OUT/fixture-sha256.txt" +"$maco" supervise plan "$fixture" --repo "$repo" --json > "$OUT/checked-plan.json" || exit 1 +if timeout --signal=TERM --kill-after=30s 3600 systemd-run --user --scope --quiet -p Delegate=yes \ + --working-directory="$repo" -- "$maco" supervise run "$fixture" \ + --runtime codex --repo "$repo" --run-id "$run_id" --codex-bin codex \ + --max-concurrent-children 1 --provider-inflight-limit 1 \ + --machine-global-config "$config" --machine-global-runtime-root-id runtime --json \ + > "$OUT/run.json" 2> "$OUT/run.stderr"; then rc=0; else rc=$?; fi +printf '%s\n' "$rc" > "$OUT/run.exit" +if "$maco" supervise collect "$run_id" --repo "$repo" --json \ + > "$OUT/collect.json" 2> "$OUT/collect.stderr"; then rc=0; else rc=$?; fi +printf '%s\n' "$rc" > "$OUT/collect.exit" +``` + +The plan has a finite 160,000-token ceiling, 4,000-token parent/Researcher/reviewer +estimates, no retries and a 1,200-second child timeout. These are admission +estimates, not provider-call caps. A timeout or insufficient allowance is a +retained failed/inconclusive attempt, not a reason to remove the bounds. + +Required acceptance evidence: + +- Exact source commit, packet bytes/hash, staged regular-file identity, + declaration/assignment receipt and successful actual outer and inner + containment visibility probes before release. No broader directory capability. +- Authentic contained read commands and results that inspect the actual packet. + The report must cite `admit`, `observe`, and `complete` with real line ranges, + demonstrate the two 60-token reservations against ceiling 100, distinguish + cumulative 20/35/35/40 observations from deltas, identify persistence-before- + settlement failure and unknown-to-zero coercion, and discuss duplicate replay. + Reviewers verify those observations against source, rather than accepting + repetition of the task or case list. +- Native Researcher schema, read-only/no-delegation enforcement, no candidate + writes, complete trustworthy usage, all independent lens results and final + acceptance. A clean diff alone is not successful review. A diff-only lens + must not claim it inspected a source packet it never received; its limited + evidence is explicit and cannot replace source-aware independent review. +- Exact owned process cleanup and released reservations, including failure + paths. Unknown helper ownership/usage remains conservative, never invented zero. + +Before dispatch, run the existing missing/changed/hash/symlink/authority, +write-denial and actual-profile source-input regressions, the pinned providerless +inner-sandbox case where installed, and the NotStarted versus unknown-helper +accounting/late-refusal regressions. Do not mutate live input capabilities to +conduct paid negative probes. Retain the historical missing-mount failures and +the 195,972-token failed review (41,321 Researcher, 154,651 Auditor); none of its +lenses inspected the packet. Incomplete historical evidence and prior 0/3 lens +results remain failures. This qualification does not close #629 or #680. diff --git a/examples/supervise/nested-codex-budget-qualification.json b/examples/supervise/nested-codex-budget-qualification.json new file mode 100644 index 00000000..35d7c04c --- /dev/null +++ b/examples/supervise/nested-codex-budget-qualification.json @@ -0,0 +1,348 @@ +{ + "version": 1, + "task": "Complete the two disjoint ledger repairs using exactly the declared nested Workers, serially: admission-worker then settlement-worker. Remain a coordinator and do not write their files yourself. Use only authenticated parent dispatch, never native delegation. Preserve native writable sandbox, exact path claims and the live invocation-bound budget. Stop on observed exhaustion and refuse subsequent dispatch; do not retry or raise the ceiling. Require independent unittest execution after each candidate and preserve rejected partial output as evidence. Update `examples/supervise/qualification-data/test_admission.py`. Read the entire file before editing. Repair can_admit to include outstanding reservations, and observed_floor to retain a monotonic cumulative maximum rather than summing notices. Preserve all existing tests and public function signatures; add boundary checks only if needed. Run `python3 -B -m unittest discover -s examples/supervise/qualification-data -p test_admission.py -v` and verify every named contract passes. Explain the numerical traces and correction in the terminal report. Journal every read, command and edit through the parent-bound operation, omitting optional --changed-path so the wrapper derives changes per operation. Run git diff --check and confirm only the assigned file changed. Never use native delegation, widen paths, alter credentials, or accept your own output. Return the native structured terminal report with truthful observations; report failed when blocked. Update `examples/supervise/qualification-data/test_settlement.py`. Read the entire file before editing. Repair complete to validate unknown usage before any effect, settle consumption and release the exact reservation before fallible report persistence, and handle identical replay idempotently using state[\"settled\"]. Preserve the original persistence error and reject conflicting replay. Preserve all existing tests and public function signatures; add boundary checks only if needed. Run `python3 -B -m unittest discover -s examples/supervise/qualification-data -p test_settlement.py -v` and verify every named contract passes. Explain the numerical traces and correction in the terminal report. Journal every read, command and edit through the parent-bound operation, omitting optional --changed-path so the wrapper derives changes per operation. Run git diff --check and confirm only the assigned file changed. Never use native delegation, widen paths, alter credentials, or accept your own output. Return the native structured terminal report with truthful observations; report failed when blocked.", + "max_depth": 3, + "max_child_assignments": 3, + "max_child_retries": 0, + "max_gate_corrections": 0, + "child_timeout_seconds": 1200, + "role_models": { + "supervisor": { + "model": "gpt-6.1-sol", + "reasoning_effort": "xhigh", + "unavailable_model_fallback": "fail_closed" + }, + "child_orchestrator": { + "model": "gpt-6.1-sol", + "reasoning_effort": "medium", + "unavailable_model_fallback": "fail_closed" + }, + "worker": { + "model": "gpt-6.1-sol", + "reasoning_effort": "medium", + "unavailable_model_fallback": "fail_closed" + }, + "researcher": { + "model": "gpt-6.1-sol", + "reasoning_effort": "medium", + "unavailable_model_fallback": "fail_closed" + }, + "gate_classifier": { + "model": "gpt-6.1-sol", + "reasoning_effort": "xhigh", + "unavailable_model_fallback": "fail_closed" + }, + "auditor": { + "model": "gpt-6.1-sol", + "reasoning_effort": "xhigh", + "unavailable_model_fallback": "fail_closed" + } + }, + "run_budget": { + "hard_tokens": 32000, + "role_token_reservations": { + "child_orchestrator": 4000, + "worker": 1000, + "researcher": 4000, + "auditor": 4000 + } + }, + "assignments": [ + { + "id": "ledger-budget-parent", + "phase": "execution", + "role": "child_orchestrator", + "role_category": "delegating_coordinator", + "runtime": "codex", + "assigned_paths": [ + "examples/supervise/qualification-data/test_admission.py", + "examples/supervise/qualification-data/test_settlement.py" + ], + "task": "Complete the two disjoint ledger repairs using exactly the declared nested Workers, serially: admission-worker then settlement-worker. Remain a coordinator and do not write their files yourself. Use only authenticated parent dispatch, never native delegation. Preserve native writable sandbox, exact path claims and the live invocation-bound budget. Stop on observed exhaustion and refuse subsequent dispatch; do not retry or raise the ceiling. Require independent unittest execution after each candidate and preserve rejected partial output as evidence. Update `examples/supervise/qualification-data/test_admission.py`. Read the entire file before editing. Repair can_admit to include outstanding reservations, and observed_floor to retain a monotonic cumulative maximum rather than summing notices. Preserve all existing tests and public function signatures; add boundary checks only if needed. Run `python3 -B -m unittest discover -s examples/supervise/qualification-data -p test_admission.py -v` and verify every named contract passes. Explain the numerical traces and correction in the terminal report. Journal every read, command and edit through the parent-bound operation, omitting optional --changed-path so the wrapper derives changes per operation. Run git diff --check and confirm only the assigned file changed. Never use native delegation, widen paths, alter credentials, or accept your own output. Return the native structured terminal report with truthful observations; report failed when blocked. Update `examples/supervise/qualification-data/test_settlement.py`. Read the entire file before editing. Repair complete to validate unknown usage before any effect, settle consumption and release the exact reservation before fallible report persistence, and handle identical replay idempotently using state[\"settled\"]. Preserve the original persistence error and reject conflicting replay. Preserve all existing tests and public function signatures; add boundary checks only if needed. Run `python3 -B -m unittest discover -s examples/supervise/qualification-data -p test_settlement.py -v` and verify every named contract passes. Explain the numerical traces and correction in the terminal report. Journal every read, command and edit through the parent-bound operation, omitting optional --changed-path so the wrapper derives changes per operation. Run git diff --check and confirm only the assigned file changed. Never use native delegation, widen paths, alter credentials, or accept your own output. Return the native structured terminal report with truthful observations; report failed when blocked.", + "worker_assignments": [ + { + "id": "admission-worker", + "role": "worker", + "role_category": "non_delegating_terminal_worker", + "assigned_paths": [ + "examples/supervise/qualification-data/test_admission.py" + ], + "task": "Update `examples/supervise/qualification-data/test_admission.py`. Read the entire file before editing. Repair can_admit to include outstanding reservations, and observed_floor to retain a monotonic cumulative maximum rather than summing notices. Preserve all existing tests and public function signatures; add boundary checks only if needed. Run `python3 -B -m unittest discover -s examples/supervise/qualification-data -p test_admission.py -v` and verify every named contract passes. Explain the numerical traces and correction in the terminal report. Journal every read, command and edit through the parent-bound operation, omitting optional --changed-path so the wrapper derives changes per operation. Run git diff --check and confirm only the assigned file changed. Never use native delegation, widen paths, alter credentials, or accept your own output. Return the native structured terminal report with truthful observations; report failed when blocked." + }, + { + "id": "settlement-worker", + "role": "worker", + "role_category": "non_delegating_terminal_worker", + "assigned_paths": [ + "examples/supervise/qualification-data/test_settlement.py" + ], + "task": "Update `examples/supervise/qualification-data/test_settlement.py`. Read the entire file before editing. Repair complete to validate unknown usage before any effect, settle consumption and release the exact reservation before fallible report persistence, and handle identical replay idempotently using state[\"settled\"]. Preserve the original persistence error and reject conflicting replay. Preserve all existing tests and public function signatures; add boundary checks only if needed. Run `python3 -B -m unittest discover -s examples/supervise/qualification-data -p test_settlement.py -v` and verify every named contract passes. Explain the numerical traces and correction in the terminal report. Journal every read, command and edit through the parent-bound operation, omitting optional --changed-path so the wrapper derives changes per operation. Run git diff --check and confirm only the assigned file changed. Never use native delegation, widen paths, alter credentials, or accept your own output. Return the native structured terminal report with truthful observations; report failed when blocked." + } + ] + } + ], + "resolved_routing_policy": { + "policy": { + "default_role": { + "minimum_effort": null, + "minimum_quality_basis_points": 6000, + "minimum_samples": 1, + "preferred_effort": null, + "require_measured_quality": false + }, + "objective": { + "accepted_cycle_cost_weight": 1, + "effort_distance_weight": 1, + "rejected_attempt_weight": 1, + "unknown_evidence_penalty": 1000000 + }, + "policy_id": "standard-routing-v1", + "role_preferences": {}, + "schema_version": 1, + "task_estimation": { + "boundedness_bands": [ + { + "max_complexity": 2499, + "max_dependency_edges": 0, + "max_scope_modules": 1, + "max_scope_paths": 1, + "value": "tightly_bounded" + }, + { + "max_complexity": 5499, + "max_dependency_edges": 4, + "max_scope_modules": 4, + "max_scope_paths": 8, + "value": "bounded" + }, + { + "max_complexity": 10000, + "max_dependency_edges": 2048, + "max_scope_modules": 2048, + "max_scope_paths": 2048, + "value": "cross_cutting" + } + ], + "complexity": { + "base": 500, + "per_assignment": 600, + "per_declared_kib": 100, + "per_dependency_edge": 700, + "per_fragment": 400, + "per_scope_module": 450, + "per_scope_path": 150 + }, + "context": { + "base_tokens": 512, + "bytes_per_estimated_token": 4, + "max_estimated_tokens": 4000000, + "per_assignment_tokens": 512, + "per_scope_module_tokens": 256, + "per_scope_path_tokens": 512 + }, + "context_bands": [ + { + "max_inclusive": 8192, + "value": "small" + }, + { + "max_inclusive": 32000, + "value": "medium" + }, + { + "max_inclusive": 128000, + "value": "large" + }, + { + "max_inclusive": 4000000, + "value": "long" + } + ], + "horizon": { + "base": 500, + "per_assignment": 650, + "per_declared_kib": 100, + "per_dependency_edge": 400, + "per_fragment": 350, + "per_scope_module": 150, + "per_scope_path": 80 + }, + "horizon_bands": [ + { + "max_inclusive": 2499, + "value": "short" + }, + { + "max_inclusive": 5999, + "value": "medium" + }, + { + "max_inclusive": 10000, + "value": "long" + } + ], + "max_assignments": 256, + "max_declaration_bytes": 262144, + "max_fragments": 256, + "max_rules": 64, + "max_scope_items": 2048, + "risk": { + "base": 500, + "per_assignment": 0, + "per_declared_kib": 0, + "per_dependency_edge": 150, + "per_fragment": 0, + "per_scope_module": 100, + "per_scope_path": 40 + }, + "risk_bands": [ + { + "max_inclusive": 2499, + "value": "low" + }, + { + "max_inclusive": 4999, + "value": "medium" + }, + { + "max_inclusive": 7499, + "value": "high" + }, + { + "max_inclusive": 10000, + "value": "critical" + } + ], + "rules": [ + { + "complexity_basis_points": 0, + "horizon_basis_points": 0, + "id": "declared-deletion", + "match_kind": "declared_task_contains_ascii_insensitive", + "pattern": "delete", + "risk_basis_points": 6000 + }, + { + "complexity_basis_points": 1500, + "horizon_basis_points": 2000, + "id": "declared-migration", + "match_kind": "declared_task_contains_ascii_insensitive", + "pattern": "migration", + "risk_basis_points": 3000 + }, + { + "complexity_basis_points": 500, + "horizon_basis_points": 1000, + "id": "declared-deployment", + "match_kind": "declared_task_contains_ascii_insensitive", + "pattern": "deploy", + "risk_basis_points": 2500 + }, + { + "complexity_basis_points": 1000, + "horizon_basis_points": 3500, + "id": "declared-multi-stage", + "match_kind": "declared_task_contains_ascii_insensitive", + "pattern": "multi-stage", + "risk_basis_points": 0 + }, + { + "complexity_basis_points": 0, + "horizon_basis_points": 4500, + "id": "declared-long-running", + "match_kind": "declared_task_contains_ascii_insensitive", + "pattern": "long-running", + "risk_basis_points": 0 + }, + { + "complexity_basis_points": 500, + "horizon_basis_points": 0, + "id": "declared-authentication-scope", + "match_kind": "scope_path_prefix", + "pattern": "src/account_authority", + "risk_basis_points": 3500 + } + ], + "score_cap_basis_points": 10000 + }, + "tree": { + "coordinated_complexity_min": 5000, + "coordinated_context_tokens_min": 32000, + "coordinated_horizon_min": 5000, + "coordinated_risk_min": 5000, + "coordinated_scope_paths_min": 2, + "max_depth": 4, + "max_workers": 8, + "min_independent_groups": 2 + }, + "unknown_access_policy": "unknown_access_for_productive_attempt" + }, + "sha256": "2e0608b9d3bce4ca0c9dcb75786d08d6f5f5b7105d6941b1eeb127e06894ed3e", + "source": "bundled-routing-policy" + }, + "task_routing_assessment": { + "declaration": { + "declared_task": "Complete the two disjoint ledger repairs using exactly the declared nested Workers, serially: admission-worker then settlement-worker. Remain a coordinator and do not write their files yourself. Use only authenticated parent dispatch, never native delegation. Preserve native writable sandbox, exact path claims and the live invocation-bound budget. Stop on observed exhaustion and refuse subsequent dispatch; do not retry or raise the ceiling. Require independent unittest execution after each candidate and preserve rejected partial output as evidence. Update `examples/supervise/qualification-data/test_admission.py`. Read the entire file before editing. Repair can_admit to include outstanding reservations, and observed_floor to retain a monotonic cumulative maximum rather than summing notices. Preserve all existing tests and public function signatures; add boundary checks only if needed. Run `python3 -B -m unittest discover -s examples/supervise/qualification-data -p test_admission.py -v` and verify every named contract passes. Explain the numerical traces and correction in the terminal report. Journal every read, command and edit through the parent-bound operation, omitting optional --changed-path so the wrapper derives changes per operation. Run git diff --check and confirm only the assigned file changed. Never use native delegation, widen paths, alter credentials, or accept your own output. Return the native structured terminal report with truthful observations; report failed when blocked. Update `examples/supervise/qualification-data/test_settlement.py`. Read the entire file before editing. Repair complete to validate unknown usage before any effect, settle consumption and release the exact reservation before fallible report persistence, and handle identical replay idempotently using state[\"settled\"]. Preserve the original persistence error and reject conflicting replay. Preserve all existing tests and public function signatures; add boundary checks only if needed. Run `python3 -B -m unittest discover -s examples/supervise/qualification-data -p test_settlement.py -v` and verify every named contract passes. Explain the numerical traces and correction in the terminal report. Journal every read, command and edit through the parent-bound operation, omitting optional --changed-path so the wrapper derives changes per operation. Run git diff --check and confirm only the assigned file changed. Never use native delegation, widen paths, alter credentials, or accept your own output. Return the native structured terminal report with truthful observations; report failed when blocked.", + "fragments": [ + { + "id": "admission-worker-scope", + "text": "Update `examples/supervise/qualification-data/test_admission.py`. Read the entire file before editing. Repair can_admit to include outstanding reservations, and observed_floor to retain a monotonic cumulative maximum rather than summing notices. Preserve all existing tests and public function signatures; add boundary checks only if needed. Run `python3 -B -m unittest discover -s examples/supervise/qualification-data -p test_admission.py -v` and verify every named contract passes. Explain the numerical traces and correction in the terminal report. Journal every read, command and edit through the parent-bound operation, omitting optional --changed-path so the wrapper derives changes per operation. Run git diff --check and confirm only the assigned file changed. Never use native delegation, widen paths, alter credentials, or accept your own output. Return the native structured terminal report with truthful observations; report failed when blocked." + }, + { + "id": "settlement-worker-scope", + "text": "Update `examples/supervise/qualification-data/test_settlement.py`. Read the entire file before editing. Repair complete to validate unknown usage before any effect, settle consumption and release the exact reservation before fallible report persistence, and handle identical replay idempotently using state[\"settled\"]. Preserve the original persistence error and reject conflicting replay. Preserve all existing tests and public function signatures; add boundary checks only if needed. Run `python3 -B -m unittest discover -s examples/supervise/qualification-data -p test_settlement.py -v` and verify every named contract passes. Explain the numerical traces and correction in the terminal report. Journal every read, command and edit through the parent-bound operation, omitting optional --changed-path so the wrapper derives changes per operation. Run git diff --check and confirm only the assigned file changed. Never use native delegation, widen paths, alter credentials, or accept your own output. Return the native structured terminal report with truthful observations; report failed when blocked." + } + ], + "assignments": [ + { + "id": "admission-worker", + "task": "Update `examples/supervise/qualification-data/test_admission.py`. Read the entire file before editing. Repair can_admit to include outstanding reservations, and observed_floor to retain a monotonic cumulative maximum rather than summing notices. Preserve all existing tests and public function signatures; add boundary checks only if needed. Run `python3 -B -m unittest discover -s examples/supervise/qualification-data -p test_admission.py -v` and verify every named contract passes. Explain the numerical traces and correction in the terminal report. Journal every read, command and edit through the parent-bound operation, omitting optional --changed-path so the wrapper derives changes per operation. Run git diff --check and confirm only the assigned file changed. Never use native delegation, widen paths, alter credentials, or accept your own output. Return the native structured terminal report with truthful observations; report failed when blocked.", + "fragment_ids": [ + "admission-worker-scope" + ], + "assigned_paths": [ + "examples/supervise/qualification-data/test_admission.py" + ], + "semantic_symbols": [], + "semantic_modules": [], + "decision_dependencies": [] + }, + { + "id": "settlement-worker", + "task": "Update `examples/supervise/qualification-data/test_settlement.py`. Read the entire file before editing. Repair complete to validate unknown usage before any effect, settle consumption and release the exact reservation before fallible report persistence, and handle identical replay idempotently using state[\"settled\"]. Preserve the original persistence error and reject conflicting replay. Preserve all existing tests and public function signatures; add boundary checks only if needed. Run `python3 -B -m unittest discover -s examples/supervise/qualification-data -p test_settlement.py -v` and verify every named contract passes. Explain the numerical traces and correction in the terminal report. Journal every read, command and edit through the parent-bound operation, omitting optional --changed-path so the wrapper derives changes per operation. Run git diff --check and confirm only the assigned file changed. Never use native delegation, widen paths, alter credentials, or accept your own output. Return the native structured terminal report with truthful observations; report failed when blocked.", + "fragment_ids": [ + "settlement-worker-scope" + ], + "assigned_paths": [ + "examples/supervise/qualification-data/test_settlement.py" + ], + "semantic_symbols": [], + "semantic_modules": [], + "decision_dependencies": [] + } + ] + }, + "declared_input_sha256": "d48d032011ff97b1d1c3d5735abbf3f0ef1665a7f7ae03f12508d7d24d7108e2", + "features": { + "scope_paths": 2, + "scope_modules": 0, + "dependency_edges": 0, + "independent_groups": 2, + "evidence": [ + "canonical_scope_and_disjointness_revalidated", + "fragment_coverage_revalidated", + "dependency_edges_are_declared_decision_incidences", + "independent_groups_are_conservative_decision_components", + "assessment_basis_policy_heuristic_not_observed", + "context_estimates_encoded_declared_inputs_and_policy_allowances_only", + "score_saturation_is_explicit_loaded_policy" + ], + "complexity": 3600, + "risk": 580, + "horizon": 3460, + "context_tokens": 4394 + }, + "policy_sha256": "2e0608b9d3bce4ca0c9dcb75786d08d6f5f5b7105d6941b1eeb127e06894ed3e", + "provenance": "policy_heuristic" + } +} diff --git a/examples/supervise/qualification-data/source-packet.md b/examples/supervise/qualification-data/source-packet.md new file mode 100644 index 00000000..e0b8849b --- /dev/null +++ b/examples/supervise/qualification-data/source-packet.md @@ -0,0 +1,42 @@ +# Reservation ledger review packet + +This deliberately flawed proposal is review data, not executable production code. +The ledger starts with a ceiling of 100, consumed=0 and reserved=0. Reservations +are estimates; observations are cumulative totals for one invocation, not deltas. +Each invocation has a unique reservation id. A terminal observation must be +settled once even when report persistence fails. Unknown usage is never zero. + +```python +def admit(ledger, reservation_id, estimate): + if ledger.consumed + estimate <= ledger.ceiling: + ledger.reserved += estimate + ledger.active[reservation_id] = estimate + return True + return False + +def observe(ledger, reservation_id, cumulative_tokens): + ledger.consumed += cumulative_tokens + +def complete(ledger, reservation_id, cumulative_tokens, report): + persist_report(report) + ledger.consumed += cumulative_tokens or 0 + ledger.reserved -= ledger.active.pop(reservation_id) +``` + +Review cases: + +1. Admit A=60, then B=60 before either completes. The second admission must + refuse because outstanding reservations participate in admission. +2. A reports cumulative observations 20, 35, 35, then terminal 40. Final + trustworthy consumption for A must be 40, not the sum of all notices. +3. A's terminal 40 arrives but persist_report raises an I/O error. Consumption + still settles and the reservation releases exactly once; no candidate passes. +4. A returns unknown usage. The ledger retains uncertainty and refuses any + guarantee requiring complete usage; it must not convert unknown to zero. +5. Completion for A is delivered again after restart. Reconstruction must + prevent duplicate consumption and must not pop an unrelated reservation. + +A useful review cites the actual function and line ranges, gives a concrete +counterexample for each defect, and proposes ordering/idempotency invariants. +The source must be inspected through the contained command channel. Merely +repeating the case list does not demonstrate inspection. diff --git a/examples/supervise/qualification-data/test_admission.py b/examples/supervise/qualification-data/test_admission.py new file mode 100644 index 00000000..87f5a746 --- /dev/null +++ b/examples/supervise/qualification-data/test_admission.py @@ -0,0 +1,24 @@ +"""Intentionally flawed qualification input; repair the functions, retain tests.""" + +import unittest + + +def can_admit(ceiling, consumed, reserved, estimate): + return consumed + estimate <= ceiling + + +def observed_floor(previous, cumulative): + return previous + cumulative + + +class AdmissionContract(unittest.TestCase): + def test_outstanding_reservations_count(self): + self.assertFalse(can_admit(100, 0, 60, 60)) + self.assertTrue(can_admit(100, 20, 30, 50)) + + def test_cumulative_observations_are_not_deltas(self): + floor = 0 + for notice in (20, 35, 35, 40): + floor = observed_floor(floor, notice) + self.assertEqual(floor, 40) + self.assertEqual(observed_floor(40, 35), 40) diff --git a/examples/supervise/qualification-data/test_grok_ledger.py b/examples/supervise/qualification-data/test_grok_ledger.py new file mode 100644 index 00000000..bbddd95f --- /dev/null +++ b/examples/supervise/qualification-data/test_grok_ledger.py @@ -0,0 +1,48 @@ +"""Intentionally flawed qualification input; repair the functions, retain tests.""" + +import unittest + + +def can_admit(ceiling, consumed, reserved, estimate): + return consumed + estimate <= ceiling + + +def observed_floor(previous, cumulative): + return previous + cumulative + + +def settle(state, reservation_id, cumulative): + state["consumed"] += cumulative or 0 + state["active"].pop(reservation_id) + + +class LedgerContract(unittest.TestCase): + def test_admission_counts_reservations(self): + self.assertFalse(can_admit(100, 0, 60, 60)) + self.assertTrue(can_admit(100, 20, 30, 50)) + + def test_cumulative_floor(self): + floor = 0 + for notice in (20, 35, 35, 40): + floor = observed_floor(floor, notice) + self.assertEqual(floor, 40) + self.assertEqual(observed_floor(40, 35), 40) + + def test_settlement_replay(self): + state = {"consumed": 0, "active": {"A": 60}, "settled": {}} + settle(state, "A", 40) + settle(state, "A", 40) + self.assertEqual(state["consumed"], 40) + self.assertEqual(state["active"], {}) + self.assertEqual(state["settled"], {"A": 40}) + with self.assertRaises(ValueError): + settle(state, "A", 41) + self.assertEqual(state["consumed"], 40) + self.assertEqual(state["active"], {}) + self.assertEqual(state["settled"], {"A": 40}) + + def test_unknown_usage(self): + state = {"consumed": 0, "active": {"A": 60}, "settled": {}} + with self.assertRaises(ValueError): + settle(state, "A", None) + self.assertEqual(state["active"], {"A": 60}) diff --git a/examples/supervise/qualification-data/test_settlement.py b/examples/supervise/qualification-data/test_settlement.py new file mode 100644 index 00000000..956ecbd9 --- /dev/null +++ b/examples/supervise/qualification-data/test_settlement.py @@ -0,0 +1,39 @@ +"""Intentionally flawed qualification input; repair completion, retain tests.""" + +import unittest + + +def complete(state, reservation_id, cumulative, persist): + persist() + state["consumed"] += cumulative or 0 + state["active"].pop(reservation_id) + + +class SettlementContract(unittest.TestCase): + def test_failure_still_settles_once_and_replay_is_safe(self): + state = {"consumed": 0, "active": {"A": 60}, "settled": {}} + + def fail(): + raise OSError("report unavailable") + + with self.assertRaises(OSError): + complete(state, "A", 40, fail) + self.assertEqual(state["consumed"], 40) + self.assertEqual(state["active"], {}) + self.assertEqual(state["settled"], {"A": 40}) + complete(state, "A", 40, lambda: None) + self.assertEqual(state["consumed"], 40) + self.assertEqual(state["active"], {}) + self.assertEqual(state["settled"], {"A": 40}) + with self.assertRaises(ValueError): + complete(state, "A", 41, lambda: self.fail("conflicting replay persisted")) + self.assertEqual(state["consumed"], 40) + self.assertEqual(state["active"], {}) + self.assertEqual(state["settled"], {"A": 40}) + + def test_unknown_usage_is_not_zero(self): + state = {"consumed": 0, "active": {"A": 60}, "settled": {}} + with self.assertRaises(ValueError): + complete(state, "A", None, lambda: None) + self.assertEqual(state["active"], {"A": 60}) + self.assertEqual(state["settled"], {}) diff --git a/examples/supervise/qualification-grok-lower-effort.json b/examples/supervise/qualification-grok-lower-effort.json new file mode 100644 index 00000000..9de4a9a2 --- /dev/null +++ b/examples/supervise/qualification-grok-lower-effort.json @@ -0,0 +1,310 @@ +{ + "version": 1, + "task": "Update `examples/supervise/qualification-data/test_grok_ledger.py`. Read the entire file before editing. Repair can_admit to include reserved usage, observed_floor to retain a monotonic cumulative maximum, and settle to reject unknown usage before effects and settle/release an exact reservation once, with safe identical replay and refused conflicting replay. Preserve all existing tests and public function signatures; add boundary checks only if needed. Run `python3 -B -m unittest discover -s examples/supervise/qualification-data -p test_grok_ledger.py -v` and verify every named contract passes. Explain the numerical traces and correction in the terminal report. Journal every read, command and edit through the parent-bound operation, omitting optional --changed-path so the wrapper derives changes per operation. Run git diff --check and confirm only the assigned file changed. Never use native delegation, widen paths, alter credentials, or accept your own output. Return the native structured terminal report with truthful observations; report failed when blocked.", + "max_depth": 3, + "max_child_assignments": 3, + "max_child_retries": 0, + "max_gate_corrections": 0, + "child_timeout_seconds": 1200, + "role_models": { + "supervisor": { + "model": "gpt-6.1-sol", + "reasoning_effort": "xhigh", + "unavailable_model_fallback": "fail_closed" + }, + "child_orchestrator": { + "model": "gpt-6.1-sol", + "reasoning_effort": "medium", + "unavailable_model_fallback": "fail_closed" + }, + "worker": { + "model": "grok-4.6", + "reasoning_effort": "low", + "unavailable_model_fallback": "fail_closed" + }, + "researcher": { + "model": "gpt-6.1-sol", + "reasoning_effort": "medium", + "unavailable_model_fallback": "fail_closed" + }, + "gate_classifier": { + "model": "gpt-6.1-sol", + "reasoning_effort": "xhigh", + "unavailable_model_fallback": "fail_closed" + }, + "auditor": { + "model": "gpt-6.1-sol", + "reasoning_effort": "xhigh", + "unavailable_model_fallback": "fail_closed" + } + }, + "run_budget": { + "hard_tokens": 160000, + "role_token_reservations": { + "child_orchestrator": 4000, + "worker": 1000, + "researcher": 4000, + "auditor": 4000 + } + }, + "assignments": [ + { + "id": "grok-ledger-worker", + "phase": "execution", + "role": "worker", + "role_category": "non_delegating_terminal_worker", + "runtime": "grok", + "assigned_paths": [ + "examples/supervise/qualification-data/test_grok_ledger.py" + ], + "task": "Update `examples/supervise/qualification-data/test_grok_ledger.py`. Read the entire file before editing. Repair can_admit to include reserved usage, observed_floor to retain a monotonic cumulative maximum, and settle to reject unknown usage before effects and settle/release an exact reservation once, with safe identical replay and refused conflicting replay. Preserve all existing tests and public function signatures; add boundary checks only if needed. Run `python3 -B -m unittest discover -s examples/supervise/qualification-data -p test_grok_ledger.py -v` and verify every named contract passes. Explain the numerical traces and correction in the terminal report. Journal every read, command and edit through the parent-bound operation, omitting optional --changed-path so the wrapper derives changes per operation. Run git diff --check and confirm only the assigned file changed. Never use native delegation, widen paths, alter credentials, or accept your own output. Return the native structured terminal report with truthful observations; report failed when blocked." + } + ], + "resolved_routing_policy": { + "policy": { + "default_role": { + "minimum_effort": null, + "minimum_quality_basis_points": 6000, + "minimum_samples": 1, + "preferred_effort": null, + "require_measured_quality": false + }, + "objective": { + "accepted_cycle_cost_weight": 1, + "effort_distance_weight": 1, + "rejected_attempt_weight": 1, + "unknown_evidence_penalty": 1000000 + }, + "policy_id": "standard-routing-v1", + "role_preferences": {}, + "schema_version": 1, + "task_estimation": { + "boundedness_bands": [ + { + "max_complexity": 2499, + "max_dependency_edges": 0, + "max_scope_modules": 1, + "max_scope_paths": 1, + "value": "tightly_bounded" + }, + { + "max_complexity": 5499, + "max_dependency_edges": 4, + "max_scope_modules": 4, + "max_scope_paths": 8, + "value": "bounded" + }, + { + "max_complexity": 10000, + "max_dependency_edges": 2048, + "max_scope_modules": 2048, + "max_scope_paths": 2048, + "value": "cross_cutting" + } + ], + "complexity": { + "base": 500, + "per_assignment": 600, + "per_declared_kib": 100, + "per_dependency_edge": 700, + "per_fragment": 400, + "per_scope_module": 450, + "per_scope_path": 150 + }, + "context": { + "base_tokens": 512, + "bytes_per_estimated_token": 4, + "max_estimated_tokens": 4000000, + "per_assignment_tokens": 512, + "per_scope_module_tokens": 256, + "per_scope_path_tokens": 512 + }, + "context_bands": [ + { + "max_inclusive": 8192, + "value": "small" + }, + { + "max_inclusive": 32000, + "value": "medium" + }, + { + "max_inclusive": 128000, + "value": "large" + }, + { + "max_inclusive": 4000000, + "value": "long" + } + ], + "horizon": { + "base": 500, + "per_assignment": 650, + "per_declared_kib": 100, + "per_dependency_edge": 400, + "per_fragment": 350, + "per_scope_module": 150, + "per_scope_path": 80 + }, + "horizon_bands": [ + { + "max_inclusive": 2499, + "value": "short" + }, + { + "max_inclusive": 5999, + "value": "medium" + }, + { + "max_inclusive": 10000, + "value": "long" + } + ], + "max_assignments": 256, + "max_declaration_bytes": 262144, + "max_fragments": 256, + "max_rules": 64, + "max_scope_items": 2048, + "risk": { + "base": 500, + "per_assignment": 0, + "per_declared_kib": 0, + "per_dependency_edge": 150, + "per_fragment": 0, + "per_scope_module": 100, + "per_scope_path": 40 + }, + "risk_bands": [ + { + "max_inclusive": 2499, + "value": "low" + }, + { + "max_inclusive": 4999, + "value": "medium" + }, + { + "max_inclusive": 7499, + "value": "high" + }, + { + "max_inclusive": 10000, + "value": "critical" + } + ], + "rules": [ + { + "complexity_basis_points": 0, + "horizon_basis_points": 0, + "id": "declared-deletion", + "match_kind": "declared_task_contains_ascii_insensitive", + "pattern": "delete", + "risk_basis_points": 6000 + }, + { + "complexity_basis_points": 1500, + "horizon_basis_points": 2000, + "id": "declared-migration", + "match_kind": "declared_task_contains_ascii_insensitive", + "pattern": "migration", + "risk_basis_points": 3000 + }, + { + "complexity_basis_points": 500, + "horizon_basis_points": 1000, + "id": "declared-deployment", + "match_kind": "declared_task_contains_ascii_insensitive", + "pattern": "deploy", + "risk_basis_points": 2500 + }, + { + "complexity_basis_points": 1000, + "horizon_basis_points": 3500, + "id": "declared-multi-stage", + "match_kind": "declared_task_contains_ascii_insensitive", + "pattern": "multi-stage", + "risk_basis_points": 0 + }, + { + "complexity_basis_points": 0, + "horizon_basis_points": 4500, + "id": "declared-long-running", + "match_kind": "declared_task_contains_ascii_insensitive", + "pattern": "long-running", + "risk_basis_points": 0 + }, + { + "complexity_basis_points": 500, + "horizon_basis_points": 0, + "id": "declared-authentication-scope", + "match_kind": "scope_path_prefix", + "pattern": "src/account_authority", + "risk_basis_points": 3500 + } + ], + "score_cap_basis_points": 10000 + }, + "tree": { + "coordinated_complexity_min": 5000, + "coordinated_context_tokens_min": 32000, + "coordinated_horizon_min": 5000, + "coordinated_risk_min": 5000, + "coordinated_scope_paths_min": 2, + "max_depth": 4, + "max_workers": 8, + "min_independent_groups": 2 + }, + "unknown_access_policy": "unknown_access_for_productive_attempt" + }, + "sha256": "2e0608b9d3bce4ca0c9dcb75786d08d6f5f5b7105d6941b1eeb127e06894ed3e", + "source": "bundled-routing-policy" + }, + "task_routing_assessment": { + "declaration": { + "declared_task": "Update `examples/supervise/qualification-data/test_grok_ledger.py`. Read the entire file before editing. Repair can_admit to include reserved usage, observed_floor to retain a monotonic cumulative maximum, and settle to reject unknown usage before effects and settle/release an exact reservation once, with safe identical replay and refused conflicting replay. Preserve all existing tests and public function signatures; add boundary checks only if needed. Run `python3 -B -m unittest discover -s examples/supervise/qualification-data -p test_grok_ledger.py -v` and verify every named contract passes. Explain the numerical traces and correction in the terminal report. Journal every read, command and edit through the parent-bound operation, omitting optional --changed-path so the wrapper derives changes per operation. Run git diff --check and confirm only the assigned file changed. Never use native delegation, widen paths, alter credentials, or accept your own output. Return the native structured terminal report with truthful observations; report failed when blocked.", + "fragments": [ + { + "id": "grok-ledger-worker-scope", + "text": "Update `examples/supervise/qualification-data/test_grok_ledger.py`. Read the entire file before editing. Repair can_admit to include reserved usage, observed_floor to retain a monotonic cumulative maximum, and settle to reject unknown usage before effects and settle/release an exact reservation once, with safe identical replay and refused conflicting replay. Preserve all existing tests and public function signatures; add boundary checks only if needed. Run `python3 -B -m unittest discover -s examples/supervise/qualification-data -p test_grok_ledger.py -v` and verify every named contract passes. Explain the numerical traces and correction in the terminal report. Journal every read, command and edit through the parent-bound operation, omitting optional --changed-path so the wrapper derives changes per operation. Run git diff --check and confirm only the assigned file changed. Never use native delegation, widen paths, alter credentials, or accept your own output. Return the native structured terminal report with truthful observations; report failed when blocked." + } + ], + "assignments": [ + { + "id": "grok-ledger-worker", + "task": "Update `examples/supervise/qualification-data/test_grok_ledger.py`. Read the entire file before editing. Repair can_admit to include reserved usage, observed_floor to retain a monotonic cumulative maximum, and settle to reject unknown usage before effects and settle/release an exact reservation once, with safe identical replay and refused conflicting replay. Preserve all existing tests and public function signatures; add boundary checks only if needed. Run `python3 -B -m unittest discover -s examples/supervise/qualification-data -p test_grok_ledger.py -v` and verify every named contract passes. Explain the numerical traces and correction in the terminal report. Journal every read, command and edit through the parent-bound operation, omitting optional --changed-path so the wrapper derives changes per operation. Run git diff --check and confirm only the assigned file changed. Never use native delegation, widen paths, alter credentials, or accept your own output. Return the native structured terminal report with truthful observations; report failed when blocked.", + "fragment_ids": [ + "grok-ledger-worker-scope" + ], + "assigned_paths": [ + "examples/supervise/qualification-data/test_grok_ledger.py" + ], + "semantic_symbols": [], + "semantic_modules": [], + "decision_dependencies": [] + } + ] + }, + "declared_input_sha256": "1a531ecfd9018e37ddd33095c2cfab494ef3dba2cb3bff6dfec3b40f03f85348", + "features": { + "scope_paths": 1, + "scope_modules": 0, + "dependency_edges": 0, + "independent_groups": 1, + "evidence": [ + "canonical_scope_and_disjointness_revalidated", + "fragment_coverage_revalidated", + "dependency_edges_are_declared_decision_incidences", + "independent_groups_are_conservative_decision_components", + "assessment_basis_policy_heuristic_not_observed", + "context_estimates_encoded_declared_inputs_and_policy_allowances_only", + "score_saturation_is_explicit_loaded_policy" + ], + "complexity": 2050, + "risk": 540, + "horizon": 1980, + "context_tokens": 2424 + }, + "policy_sha256": "2e0608b9d3bce4ca0c9dcb75786d08d6f5f5b7105d6941b1eeb127e06894ed3e", + "provenance": "policy_heuristic" + } +} diff --git a/examples/supervise/qualification-researcher-source-input.json b/examples/supervise/qualification-researcher-source-input.json new file mode 100644 index 00000000..53fb157a --- /dev/null +++ b/examples/supervise/qualification-researcher-source-input.json @@ -0,0 +1,316 @@ +{ + "version": 1, + "task": "Read `examples/supervise/qualification-data/source-packet.md` through the contained command channel and perform a substantive read-only review. Report function and actual line ranges, concrete counterexamples for all five cases, corrected ordering and idempotency invariants, and distinguish source defects from untested runtime claims. Do not merely echo the case list. Do not edit any file or delegate. Use authentic command observations and the native Researcher report schema with read_only and no_further_delegation. Independent reviewers must check the cited source before acceptance; an empty diff or a visibility receipt alone is insufficient.", + "max_depth": 3, + "max_child_assignments": 3, + "max_child_retries": 0, + "max_gate_corrections": 0, + "child_timeout_seconds": 1200, + "role_models": { + "supervisor": { + "model": "gpt-6.1-sol", + "reasoning_effort": "xhigh", + "unavailable_model_fallback": "fail_closed" + }, + "child_orchestrator": { + "model": "gpt-6.1-sol", + "reasoning_effort": "medium", + "unavailable_model_fallback": "fail_closed" + }, + "worker": { + "model": "gpt-6.1-sol", + "reasoning_effort": "medium", + "unavailable_model_fallback": "fail_closed" + }, + "researcher": { + "model": "gpt-6.1-sol", + "reasoning_effort": "medium", + "unavailable_model_fallback": "fail_closed" + }, + "gate_classifier": { + "model": "gpt-6.1-sol", + "reasoning_effort": "xhigh", + "unavailable_model_fallback": "fail_closed" + }, + "auditor": { + "model": "gpt-6.1-sol", + "reasoning_effort": "xhigh", + "unavailable_model_fallback": "fail_closed" + } + }, + "run_budget": { + "hard_tokens": 160000, + "role_token_reservations": { + "child_orchestrator": 4000, + "worker": 1000, + "researcher": 4000, + "auditor": 4000 + } + }, + "assignments": [ + { + "id": "source-packet-researcher", + "phase": "execution", + "role": "researcher", + "role_category": "read_only_researcher", + "runtime": "codex", + "assigned_paths": [ + "examples/supervise/qualification-data/source-packet.md" + ], + "task": "Read `examples/supervise/qualification-data/source-packet.md` through the contained command channel and perform a substantive read-only review. Report function and actual line ranges, concrete counterexamples for all five cases, corrected ordering and idempotency invariants, and distinguish source defects from untested runtime claims. Do not merely echo the case list. Do not edit any file or delegate. Use authentic command observations and the native Researcher report schema with read_only and no_further_delegation. Independent reviewers must check the cited source before acceptance; an empty diff or a visibility receipt alone is insufficient.", + "source_inputs": [ + { + "path": "examples/supervise/qualification-data/source-packet.md", + "sha256": "1cfe51c5e96a38d3dabe431faf3b50e34b2512221c585e80d3b313cd3a87f53e" + } + ] + } + ], + "resolved_routing_policy": { + "policy": { + "default_role": { + "minimum_effort": null, + "minimum_quality_basis_points": 6000, + "minimum_samples": 1, + "preferred_effort": null, + "require_measured_quality": false + }, + "objective": { + "accepted_cycle_cost_weight": 1, + "effort_distance_weight": 1, + "rejected_attempt_weight": 1, + "unknown_evidence_penalty": 1000000 + }, + "policy_id": "standard-routing-v1", + "role_preferences": {}, + "schema_version": 1, + "task_estimation": { + "boundedness_bands": [ + { + "max_complexity": 2499, + "max_dependency_edges": 0, + "max_scope_modules": 1, + "max_scope_paths": 1, + "value": "tightly_bounded" + }, + { + "max_complexity": 5499, + "max_dependency_edges": 4, + "max_scope_modules": 4, + "max_scope_paths": 8, + "value": "bounded" + }, + { + "max_complexity": 10000, + "max_dependency_edges": 2048, + "max_scope_modules": 2048, + "max_scope_paths": 2048, + "value": "cross_cutting" + } + ], + "complexity": { + "base": 500, + "per_assignment": 600, + "per_declared_kib": 100, + "per_dependency_edge": 700, + "per_fragment": 400, + "per_scope_module": 450, + "per_scope_path": 150 + }, + "context": { + "base_tokens": 512, + "bytes_per_estimated_token": 4, + "max_estimated_tokens": 4000000, + "per_assignment_tokens": 512, + "per_scope_module_tokens": 256, + "per_scope_path_tokens": 512 + }, + "context_bands": [ + { + "max_inclusive": 8192, + "value": "small" + }, + { + "max_inclusive": 32000, + "value": "medium" + }, + { + "max_inclusive": 128000, + "value": "large" + }, + { + "max_inclusive": 4000000, + "value": "long" + } + ], + "horizon": { + "base": 500, + "per_assignment": 650, + "per_declared_kib": 100, + "per_dependency_edge": 400, + "per_fragment": 350, + "per_scope_module": 150, + "per_scope_path": 80 + }, + "horizon_bands": [ + { + "max_inclusive": 2499, + "value": "short" + }, + { + "max_inclusive": 5999, + "value": "medium" + }, + { + "max_inclusive": 10000, + "value": "long" + } + ], + "max_assignments": 256, + "max_declaration_bytes": 262144, + "max_fragments": 256, + "max_rules": 64, + "max_scope_items": 2048, + "risk": { + "base": 500, + "per_assignment": 0, + "per_declared_kib": 0, + "per_dependency_edge": 150, + "per_fragment": 0, + "per_scope_module": 100, + "per_scope_path": 40 + }, + "risk_bands": [ + { + "max_inclusive": 2499, + "value": "low" + }, + { + "max_inclusive": 4999, + "value": "medium" + }, + { + "max_inclusive": 7499, + "value": "high" + }, + { + "max_inclusive": 10000, + "value": "critical" + } + ], + "rules": [ + { + "complexity_basis_points": 0, + "horizon_basis_points": 0, + "id": "declared-deletion", + "match_kind": "declared_task_contains_ascii_insensitive", + "pattern": "delete", + "risk_basis_points": 6000 + }, + { + "complexity_basis_points": 1500, + "horizon_basis_points": 2000, + "id": "declared-migration", + "match_kind": "declared_task_contains_ascii_insensitive", + "pattern": "migration", + "risk_basis_points": 3000 + }, + { + "complexity_basis_points": 500, + "horizon_basis_points": 1000, + "id": "declared-deployment", + "match_kind": "declared_task_contains_ascii_insensitive", + "pattern": "deploy", + "risk_basis_points": 2500 + }, + { + "complexity_basis_points": 1000, + "horizon_basis_points": 3500, + "id": "declared-multi-stage", + "match_kind": "declared_task_contains_ascii_insensitive", + "pattern": "multi-stage", + "risk_basis_points": 0 + }, + { + "complexity_basis_points": 0, + "horizon_basis_points": 4500, + "id": "declared-long-running", + "match_kind": "declared_task_contains_ascii_insensitive", + "pattern": "long-running", + "risk_basis_points": 0 + }, + { + "complexity_basis_points": 500, + "horizon_basis_points": 0, + "id": "declared-authentication-scope", + "match_kind": "scope_path_prefix", + "pattern": "src/account_authority", + "risk_basis_points": 3500 + } + ], + "score_cap_basis_points": 10000 + }, + "tree": { + "coordinated_complexity_min": 5000, + "coordinated_context_tokens_min": 32000, + "coordinated_horizon_min": 5000, + "coordinated_risk_min": 5000, + "coordinated_scope_paths_min": 2, + "max_depth": 4, + "max_workers": 8, + "min_independent_groups": 2 + }, + "unknown_access_policy": "unknown_access_for_productive_attempt" + }, + "sha256": "2e0608b9d3bce4ca0c9dcb75786d08d6f5f5b7105d6941b1eeb127e06894ed3e", + "source": "bundled-routing-policy" + }, + "task_routing_assessment": { + "declaration": { + "declared_task": "Read `examples/supervise/qualification-data/source-packet.md` through the contained command channel and perform a substantive read-only review. Report function and actual line ranges, concrete counterexamples for all five cases, corrected ordering and idempotency invariants, and distinguish source defects from untested runtime claims. Do not merely echo the case list. Do not edit any file or delegate. Use authentic command observations and the native Researcher report schema with read_only and no_further_delegation. Independent reviewers must check the cited source before acceptance; an empty diff or a visibility receipt alone is insufficient.", + "fragments": [ + { + "id": "source-packet-researcher-scope", + "text": "Read `examples/supervise/qualification-data/source-packet.md` through the contained command channel and perform a substantive read-only review. Report function and actual line ranges, concrete counterexamples for all five cases, corrected ordering and idempotency invariants, and distinguish source defects from untested runtime claims. Do not merely echo the case list. Do not edit any file or delegate. Use authentic command observations and the native Researcher report schema with read_only and no_further_delegation. Independent reviewers must check the cited source before acceptance; an empty diff or a visibility receipt alone is insufficient." + } + ], + "assignments": [ + { + "id": "source-packet-researcher", + "task": "Read `examples/supervise/qualification-data/source-packet.md` through the contained command channel and perform a substantive read-only review. Report function and actual line ranges, concrete counterexamples for all five cases, corrected ordering and idempotency invariants, and distinguish source defects from untested runtime claims. Do not merely echo the case list. Do not edit any file or delegate. Use authentic command observations and the native Researcher report schema with read_only and no_further_delegation. Independent reviewers must check the cited source before acceptance; an empty diff or a visibility receipt alone is insufficient.", + "fragment_ids": [ + "source-packet-researcher-scope" + ], + "assigned_paths": [ + "examples/supervise/qualification-data/source-packet.md" + ], + "semantic_symbols": [], + "semantic_modules": [], + "decision_dependencies": [] + } + ] + }, + "declared_input_sha256": "9338a2b03563eb7743471b657200447f8648d150d2608536c5c3223d6f4f6465", + "features": { + "scope_paths": 1, + "scope_modules": 0, + "dependency_edges": 0, + "independent_groups": 1, + "evidence": [ + "canonical_scope_and_disjointness_revalidated", + "fragment_coverage_revalidated", + "dependency_edges_are_declared_decision_incidences", + "independent_groups_are_conservative_decision_components", + "assessment_basis_policy_heuristic_not_observed", + "context_estimates_encoded_declared_inputs_and_policy_allowances_only", + "score_saturation_is_explicit_loaded_policy" + ], + "complexity": 1950, + "risk": 540, + "horizon": 1880, + "context_tokens": 2110 + }, + "policy_sha256": "2e0608b9d3bce4ca0c9dcb75786d08d6f5f5b7105d6941b1eeb127e06894ed3e", + "provenance": "policy_heuristic" + } +} From dfc4c5552dcee3c8e8f70abb991c57162562c65d Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:01:13 +0900 Subject: [PATCH 15/27] Enforce bundled task-shape ceilings across selector paths First slice of #724. - Encode typed task-shape bounds in the dated priors and intersect operator declarations with the bundled limits across catalog construction. - Apply shape and long-context gates before both selection paths and keep the effective bounds in replay input. - Preserve explicit qualified ceilings in observed catalogs, and keep the generated published contracts aligned with the optional ceiling field. - Add an optional versioned assignment-shape identity to attempt evidence; legacy records stay unbound. - Cover quota pressure, ceiling widening, strict input compatibility, replay and identity decoding. Per-assignment shape validation and dispatch/history integration follow in later changes. Validation: formatting, 26 exact regression tests and all-target compilation passed on Linux; Clippy and the full suites run in this PR's CI. --- .../supervisor-collect-report-v1.schema.json | 57 +++ .../supervisor-final-report-v1.schema.json | 57 +++ src/selection/data/priors-2026-08-07.json | 13 +- src/selection/mod.rs | 2 +- src/selection/schema.rs | 11 +- src/selection/selector.rs | 111 ++++-- src/selection/tests.rs | 143 +++++++- src/selection/types.rs | 98 +++++ src/supervise/outcome_history.rs | 31 ++ src/supervise/schema_artifacts.rs | 59 +++ src/supervise/selection_bridge.rs | 346 ++++++++++++------ 11 files changed, 773 insertions(+), 155 deletions(-) diff --git a/schemas/supervisor-collect-report-v1.schema.json b/schemas/supervisor-collect-report-v1.schema.json index 927909a9..c23a4a84 100644 --- a/schemas/supervisor-collect-report-v1.schema.json +++ b/schemas/supervisor-collect-report-v1.schema.json @@ -20743,6 +20743,63 @@ }, "type": "array", "uniqueItems": true + }, + "task_shape_ceiling": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "boundedness": { + "items": { + "enum": [ + "tightly_bounded", + "bounded", + "cross_cutting" + ], + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "maximum_context": { + "enum": [ + "small", + "medium", + "large", + "long" + ], + "type": "string" + }, + "maximum_horizon": { + "enum": [ + "short", + "medium", + "long" + ], + "type": "string" + }, + "maximum_risk": { + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "type": "string" + } + }, + "required": [ + "boundedness", + "maximum_risk", + "maximum_context", + "maximum_horizon" + ], + "type": "object" + }, + { + "type": "null" + } + ] } }, "required": [ diff --git a/schemas/supervisor-final-report-v1.schema.json b/schemas/supervisor-final-report-v1.schema.json index 26e4ae97..e7c7d689 100644 --- a/schemas/supervisor-final-report-v1.schema.json +++ b/schemas/supervisor-final-report-v1.schema.json @@ -20392,6 +20392,63 @@ }, "type": "array", "uniqueItems": true + }, + "task_shape_ceiling": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "boundedness": { + "items": { + "enum": [ + "tightly_bounded", + "bounded", + "cross_cutting" + ], + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "maximum_context": { + "enum": [ + "small", + "medium", + "large", + "long" + ], + "type": "string" + }, + "maximum_horizon": { + "enum": [ + "short", + "medium", + "long" + ], + "type": "string" + }, + "maximum_risk": { + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "type": "string" + } + }, + "required": [ + "boundedness", + "maximum_risk", + "maximum_context", + "maximum_horizon" + ], + "type": "object" + }, + { + "type": "null" + } + ] } }, "required": [ diff --git a/src/selection/data/priors-2026-08-07.json b/src/selection/data/priors-2026-08-07.json index 9372a85f..97d46510 100644 --- a/src/selection/data/priors-2026-08-07.json +++ b/src/selection/data/priors-2026-08-07.json @@ -1,8 +1,8 @@ { "schema_version": 2, "dataset_id": "maco-runtime-model-effort-priors", - "revision": "2026-10-22.1", - "published_on": "2026-10-22", + "revision": "2026-10-29.1", + "published_on": "2026-10-29", "objective_profiles": [ { "name": "accepted-task-total-cost", @@ -30,6 +30,7 @@ "prohibition_reason": null, "prohibited_authority_roles": [], "long_context_eligible": true, + "task_shape_ceiling": {"boundedness": ["tightly_bounded", "bounded", "cross_cutting"], "maximum_risk": "critical", "maximum_context": "long", "maximum_horizon": "long"}, "strong_gate_fallback_efforts": ["xhigh"], "strength_rank": 100, "class_fit": [ @@ -51,6 +52,7 @@ "prohibition_reason": null, "prohibited_authority_roles": ["delegating","acceptance_gate","review_auditor","audit","conflict_resolution","failure_classification","git_publication","unknown_judgment"], "long_context_eligible": false, + "task_shape_ceiling": {"boundedness": ["tightly_bounded", "bounded"], "maximum_risk": "medium", "maximum_context": "medium", "maximum_horizon": "medium"}, "strong_gate_fallback_efforts": [], "strength_rank": 20, "class_fit": [ @@ -72,6 +74,7 @@ "prohibition_reason": "project policy prohibits this model for every role", "prohibited_authority_roles": ["terminal_leaf","delegating","acceptance_gate","review_auditor","audit","conflict_resolution","failure_classification","git_publication","unknown_judgment"], "long_context_eligible": false, + "task_shape_ceiling": {"boundedness": ["tightly_bounded", "bounded"], "maximum_risk": "medium", "maximum_context": "medium", "maximum_horizon": "medium"}, "strong_gate_fallback_efforts": [], "strength_rank": 0, "class_fit": [ @@ -91,6 +94,7 @@ "prohibition_reason": null, "prohibited_authority_roles": ["delegating","acceptance_gate","review_auditor","audit","conflict_resolution","failure_classification","git_publication","unknown_judgment"], "long_context_eligible": false, + "task_shape_ceiling": {"boundedness": ["tightly_bounded", "bounded"], "maximum_risk": "medium", "maximum_context": "medium", "maximum_horizon": "medium"}, "strong_gate_fallback_efforts": [], "strength_rank": 30, "class_fit": [ @@ -110,6 +114,7 @@ "prohibition_reason": null, "prohibited_authority_roles": ["delegating","acceptance_gate","review_auditor","audit","conflict_resolution","failure_classification","git_publication","unknown_judgment"], "long_context_eligible": false, + "task_shape_ceiling": {"boundedness": ["tightly_bounded", "bounded"], "maximum_risk": "medium", "maximum_context": "medium", "maximum_horizon": "medium"}, "strong_gate_fallback_efforts": [], "strength_rank": 25, "class_fit": [ @@ -129,6 +134,7 @@ "prohibition_reason": null, "prohibited_authority_roles": ["delegating","acceptance_gate","review_auditor","audit","conflict_resolution","failure_classification","git_publication","unknown_judgment"], "long_context_eligible": false, + "task_shape_ceiling": {"boundedness": ["tightly_bounded", "bounded"], "maximum_risk": "medium", "maximum_context": "medium", "maximum_horizon": "medium"}, "strong_gate_fallback_efforts": [], "strength_rank": 25, "class_fit": [ @@ -148,6 +154,7 @@ "prohibition_reason": null, "prohibited_authority_roles": ["delegating","acceptance_gate","review_auditor","audit","conflict_resolution","failure_classification","git_publication","unknown_judgment"], "long_context_eligible": false, + "task_shape_ceiling": {"boundedness": ["tightly_bounded", "bounded"], "maximum_risk": "medium", "maximum_context": "medium", "maximum_horizon": "medium"}, "strong_gate_fallback_efforts": [], "strength_rank": 25, "class_fit": [ @@ -167,6 +174,7 @@ "prohibition_reason": null, "prohibited_authority_roles": [], "long_context_eligible": true, + "task_shape_ceiling": {"boundedness": ["tightly_bounded", "bounded", "cross_cutting"], "maximum_risk": "critical", "maximum_context": "long", "maximum_horizon": "long"}, "strong_gate_fallback_efforts": ["xhigh"], "strength_rank": 110, "class_fit": [ @@ -188,6 +196,7 @@ "prohibition_reason": "standing owner policy prohibits GPT-6 Astra (gpt-6-astra and equivalent aliases) for every role, including delegation, review, acceptance, escalation, fallback, and terminal work; no automatic expiry", "prohibited_authority_roles": ["terminal_leaf","delegating","acceptance_gate","review_auditor","audit","conflict_resolution","failure_classification","git_publication","unknown_judgment"], "long_context_eligible": false, + "task_shape_ceiling": {"boundedness": ["tightly_bounded", "bounded"], "maximum_risk": "medium", "maximum_context": "medium", "maximum_horizon": "medium"}, "strong_gate_fallback_efforts": [], "strength_rank": 0, "class_fit": [], diff --git a/src/selection/mod.rs b/src/selection/mod.rs index 21b12bad..435b6bed 100644 --- a/src/selection/mod.rs +++ b/src/selection/mod.rs @@ -17,7 +17,7 @@ pub use quota_input::{ }; pub(crate) use schema::selection_event_schema_value; pub use selector::{ - built_in_prior_dataset, measured_authority_eligibility, select, + built_in_prior_dataset, effective_task_shape_ceiling, measured_authority_eligibility, select, select_with_loaded_routing_policy, select_with_switch_cost_estimates, validate_prior_dataset, validate_prior_date, SelectionError, }; diff --git a/src/selection/schema.rs b/src/selection/schema.rs index 85958486..e9551cd5 100644 --- a/src/selection/schema.rs +++ b/src/selection/schema.rs @@ -325,7 +325,7 @@ fn one_shot_environment_fallback() -> Value { } fn model_prior() -> Value { - strict_object!( + let mut schema = strict_object!( "runtime" => nonempty_string(), "model" => nonempty_string(), "observed_on" => date(), @@ -341,7 +341,14 @@ fn model_prior() -> Value { "class_fit" => array(class_fit_prior()), "authority_evidence" => array(authority_evidence_prior()), "one_shot_environment_fallbacks" => array(one_shot_environment_fallback()), - ) + ); + schema["properties"]["task_shape_ceiling"] = nullable(strict_object!( + "boundedness" => set(boundedness(false)), + "maximum_risk" => risk(false), + "maximum_context" => context_size(false), + "maximum_horizon" => task_horizon(false), + )); + schema } fn prior_dataset() -> Value { diff --git a/src/selection/selector.rs b/src/selection/selector.rs index e543b92f..e86246c2 100644 --- a/src/selection/selector.rs +++ b/src/selection/selector.rs @@ -26,6 +26,42 @@ pub fn built_in_prior_dataset() -> Result { serde_json::from_str(BUILT_IN_PRIORS).map_err(|error| SelectionError::Data(error.to_string())) } +/// Operator declarations can narrow bundled bounds. Wider qualification needs +/// a separately reviewed update of the bundled capability evidence. +pub fn effective_task_shape_ceiling( + runtime: &str, + model: &str, + declared: Option<&TaskShapeCeiling>, +) -> Result { + let bundled = built_in_prior_dataset()? + .models + .into_iter() + .find(|prior| prior.runtime == runtime && prior.model == model) + .and_then(|prior| prior.task_shape_ceiling) + .or_else(|| { + (runtime == crate::optimizer::objective::DEFAULT_TERMINAL_RUNTIME + && model == crate::optimizer::objective::DEFAULT_TERMINAL_MODEL) + .then(TaskShapeCeiling::bounded_terminal) + }); + Ok(match (declared, bundled) { + (Some(declared), Some(bundled)) => declared.intersect(&bundled), + (Some(declared), None) => declared.clone(), + (None, Some(bundled)) => bundled, + (None, None) => TaskShapeCeiling::bounded_terminal(), + }) +} + +fn validate_task_shape_ceiling(ceiling: &TaskShapeCeiling) -> Result<(), SelectionError> { + if ceiling.boundedness.contains(&Boundedness::Unknown) + || ceiling.maximum_risk == RiskLevel::Unknown + || ceiling.maximum_context == ContextSize::Unknown + || ceiling.maximum_horizon == TaskHorizon::Unknown + { + return invalid("task_shape_ceiling must contain only known task bounds"); + } + Ok(()) +} + /// Validate an operator-provided prior snapshot before it can enter a run. /// The selector validates the same fields again on each normalized decision. pub fn validate_prior_dataset(priors: &PriorDataset) -> Result<(), SelectionError> { @@ -59,6 +95,9 @@ pub fn validate_prior_dataset(priors: &PriorDataset) -> Result<(), SelectionErro } } for prior in &priors.models { + if let Some(ceiling) = &prior.task_shape_ceiling { + validate_task_shape_ceiling(ceiling)?; + } validate_identifier("prior.runtime", &prior.runtime)?; validate_identifier("prior.model", &prior.model)?; validate_calendar_date("prior.observed_on", &prior.observed_on)?; @@ -160,6 +199,23 @@ pub fn select_with_loaded_routing_policy( let mut normalized = input.clone(); normalize_input(&mut normalized); validate_input(&normalized)?; + // Record the enforced bounds in replay input after rejecting malformed raw + // capabilities. A replay cannot widen a previously intersected catalog. + for catalog in &mut normalized.catalogs { + for model in &mut catalog.models { + let declared = normalized + .priors + .models + .iter() + .find(|prior| prior.runtime == catalog.runtime && prior.model == model.model); + effective_task_shape_ceiling( + &catalog.runtime, + &model.model, + declared.and_then(|prior| prior.task_shape_ceiling.as_ref()), + )? + .constrain(&mut model.capabilities); + } + } let mut normalized_switch_cost_evidence = switch_cost_evidence.to_vec(); normalized_switch_cost_evidence.sort_by(|left, right| left.candidate.cmp(&right.candidate)); validate_switch_cost_evidence(&normalized, &normalized_switch_cost_evidence)?; @@ -734,6 +790,9 @@ fn validate_input(input: &SelectionInput) -> Result<(), SelectionError> { } } for prior in &input.priors.models { + if let Some(ceiling) = &prior.task_shape_ceiling { + validate_task_shape_ceiling(ceiling)?; + } validate_identifier("prior.runtime", &prior.runtime)?; validate_identifier("prior.model", &prior.model)?; validate_calendar_date("prior.observed_on", &prior.observed_on)?; @@ -1026,6 +1085,30 @@ fn evaluate_candidate( ); } } + if !model + .capabilities + .boundedness + .contains(&input.task.boundedness) + || input.task.risk > model.capabilities.maximum_risk + || input.task.context > model.capabilities.maximum_context + || input.task.horizon > model.capabilities.maximum_horizon + { + reject( + &mut reasons, + IneligibilityCode::TaskShapeNotAdvertised, + "task shape exceeds the intersected capability ceiling", + ); + } + if input.task.context == ContextSize::Long + && (!model.capabilities.long_context + || prior.is_some_and(|prior| !prior.long_context_eligible)) + { + reject( + &mut reasons, + IneligibilityCode::LongContextProhibited, + "candidate lacks long-context qualification", + ); + } if let Some(receipt) = routing_policy { return evaluate_loaded_policy_candidate( input, @@ -1059,27 +1142,6 @@ fn evaluate_candidate( "unknown judgment authority is never eligible for evidence fallback", ); } - if !model - .capabilities - .boundedness - .contains(&input.task.boundedness) - || input.task.risk > model.capabilities.maximum_risk - || input.task.context > model.capabilities.maximum_context - || input.task.horizon > model.capabilities.maximum_horizon - { - reject( - &mut reasons, - IneligibilityCode::TaskShapeNotAdvertised, - "task shape exceeds advertised capability bounds", - ); - } - if input.task.context == ContextSize::Long && !model.capabilities.long_context { - reject( - &mut reasons, - IneligibilityCode::LongContextProhibited, - "candidate is not eligible for long context", - ); - } if !model .capabilities .authority_roles @@ -1119,13 +1181,6 @@ fn evaluate_candidate( ), Some(prior) => { strength_rank = prior.strength_rank; - if input.task.context == ContextSize::Long && !prior.long_context_eligible { - reject( - &mut reasons, - IneligibilityCode::LongContextProhibited, - "dated prior does not establish long-context eligibility", - ); - } let class_fit = prior.class_fit.iter().find(|class_fit| { class_fit.task_class == input.task.task_class && class_fit.effort == candidate.effort diff --git a/src/selection/tests.rs b/src/selection/tests.rs index abdf432d..6fd916d3 100644 --- a/src/selection/tests.rs +++ b/src/selection/tests.rs @@ -1167,12 +1167,151 @@ fn legacy_v2_selector_wire_is_explicitly_rejected_after_objective_split() { )); } +#[test] +fn cheap_lane_shape_ceilings_survive_pressure_and_loaded_policy() -> anyhow::Result<()> { + let receipt = loaded_policy_test_receipt()?; + for widened in [false, true] { + let mut input = base_input(); + input.task.risk = RiskLevel::Low; + input.task.boundedness = Boundedness::TightlyBounded; + input.task.context = ContextSize::Small; + input.task.horizon = TaskHorizon::Short; + configure_exhausted_quota(&mut input, ExhaustionBehavior::Degrade, &["grok", "cursor"]); + input.signals.budget_signal = BudgetSignal::Degrade; + for prior in &mut input.priors.models { + if prior.model == "gpt-5.6-sol" { + continue; + } + prior.long_context_eligible = true; + prior.task_shape_ceiling = widened.then(|| TaskShapeCeiling { + boundedness: [ + Boundedness::TightlyBounded, + Boundedness::Bounded, + Boundedness::CrossCutting, + ] + .into_iter() + .collect(), + maximum_risk: RiskLevel::Critical, + maximum_context: ContextSize::Long, + maximum_horizon: TaskHorizon::Long, + }); + } + for loaded in [false, true] { + let evaluate = |input: &SelectionInput| { + select_with_loaded_routing_policy(input, &[], loaded.then_some(&receipt)) + }; + let tight = evaluate(&input)?; + assert!(tight.choice.is_some(), "bounded task remains eligible"); + let mut unknown = input.clone(); + unknown.task.risk = RiskLevel::Unknown; + assert!(evaluate(&unknown).is_err()); + for catalog in &tight.normalized_input.catalogs { + for model in &catalog.models { + if model.model != "gpt-5.6-sol" { + assert_eq!(model.capabilities.maximum_risk, RiskLevel::Medium); + assert_eq!(model.capabilities.maximum_horizon, TaskHorizon::Medium); + } + } + } + for dimension in ["risk", "boundedness", "context", "horizon"] { + let mut broad = input.clone(); + match dimension { + "risk" => broad.task.risk = RiskLevel::High, + "boundedness" => broad.task.boundedness = Boundedness::CrossCutting, + "context" => broad.task.context = ContextSize::Large, + "horizon" => broad.task.horizon = TaskHorizon::Long, + _ => unreachable!(), + } + let decision = evaluate(&broad)?; + assert!(decision.choice.is_none(), "{dimension}, loaded={loaded}"); + for candidate in decision + .candidate_set + .iter() + .filter(|candidate| candidate.candidate.model != "gpt-5.6-sol") + { + assert!(!candidate.eligible); + assert!(candidate.ineligibility_reasons.iter().any(|reason| { + reason.code == IneligibilityCode::TaskShapeNotAdvertised + })); + } + assert_eq!(evaluate(&decision.normalized_input)?, decision); + } + } + } + Ok(()) +} + +#[test] +fn task_shape_ceiling_prior_compatibility_and_validation() -> anyhow::Result<()> { + let priors = built_in_prior_dataset()?; + validate_prior_dataset(&priors)?; + let prior = priors + .models + .iter() + .find(|prior| prior.model == "gpt-5.6-luna") + .unwrap(); + let mut legacy = serde_json::to_value(prior)?; + legacy.as_object_mut().unwrap().remove("task_shape_ceiling"); + let legacy: ModelPrior = serde_json::from_value(legacy)?; + assert!(legacy.task_shape_ceiling.is_none()); + assert_eq!( + effective_task_shape_ceiling(&legacy.runtime, &legacy.model, None)?, + TaskShapeCeiling::bounded_terminal() + ); + let mut invalid = priors.clone(); + invalid.models[0] + .task_shape_ceiling + .as_mut() + .unwrap() + .maximum_risk = RiskLevel::Unknown; + assert!(validate_prior_dataset(&invalid).is_err()); + let mut input = base_input(); + input.priors = invalid; + assert!(select(&input).is_err()); + let mut narrow = TaskShapeCeiling::bounded_terminal(); + narrow.maximum_risk = RiskLevel::Low; + let mut input = base_input(); + input + .priors + .models + .iter_mut() + .find(|model| model.model == prior.model) + .unwrap() + .task_shape_ceiling = Some(narrow); + let decision = select(&input)?; + let narrowed = decision + .candidate_set + .iter() + .find(|row| row.candidate.model == prior.model) + .unwrap(); + assert!(!narrowed.eligible); + assert!(narrowed + .ineligibility_reasons + .iter() + .any(|reason| { reason.code == IneligibilityCode::TaskShapeNotAdvertised })); + let unqualified = effective_task_shape_ceiling("unknown", "new-model", None)?; + assert_eq!(unqualified, TaskShapeCeiling::bounded_terminal()); + let wire = serde_json::to_value(select(&base_input())?)?; + let decoded: SelectionProvenance = serde_json::from_value(wire)?; + assert_eq!(select(&decoded.normalized_input)?, decoded); + let schema = selection_event_schema_value(); + let model_schema = &schema["properties"]["provenance"]["properties"]["normalized_input"] + ["properties"]["priors"]["properties"]["models"]["items"]; + assert!(model_schema["properties"]["task_shape_ceiling"].is_object()); + assert!(!model_schema["required"] + .as_array() + .unwrap() + .iter() + .any(|field| field == "task_shape_ceiling")); + Ok(()) +} + #[test] fn built_in_data_is_dated_and_keeps_policy_in_data() { let priors = built_in_prior_dataset().expect("built-in priors"); let _: &SelectorCalibration = &priors.objective_profiles[0]; - assert_eq!(priors.revision, "2026-10-22.1"); - assert_eq!(priors.published_on, "2026-10-22"); + assert_eq!(priors.revision, "2026-10-29.1"); + assert_eq!(priors.published_on, "2026-10-29"); assert_eq!(priors.schema_version, 2); assert_eq!(priors.models.len(), 9); assert_eq!(priors.objective_profiles[0].version, 3); diff --git a/src/selection/types.rs b/src/selection/types.rs index f9ff2070..36eba914 100644 --- a/src/selection/types.rs +++ b/src/selection/types.rs @@ -133,6 +133,54 @@ pub struct TaskProfile { pub authority_role: AuthorityRole, } +/// Identity reserved for a parent-validated assignment shape, including its +/// exact task, paths and frozen policy. A missing key is unproven, never a +/// match for another missing key. A profile or role alone cannot supply it. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(try_from = "AssignmentShapeIdentityWire")] +pub struct AssignmentShapeIdentity { + version: u32, + sha256: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AssignmentShapeIdentityWire { + version: u32, + sha256: String, +} + +impl AssignmentShapeIdentity { + /// Checks the key encoding only; the parent must validate its bound inputs. + pub fn v1(sha256: String) -> Result { + Self::try_from(AssignmentShapeIdentityWire { version: 1, sha256 }) + } + + pub fn sha256(&self) -> &str { + &self.sha256 + } +} + +impl TryFrom for AssignmentShapeIdentity { + type Error = &'static str; + + fn try_from(wire: AssignmentShapeIdentityWire) -> Result { + if wire.version != 1 + || wire.sha256.len() != 64 + || !wire + .sha256 + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err("unsupported or malformed assignment shape identity"); + } + Ok(Self { + version: wire.version, + sha256: wire.sha256, + }) + } +} + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct CandidateKey { @@ -325,6 +373,9 @@ pub struct ModelPrior { pub prohibition_reason: Option, pub prohibited_authority_roles: BTreeSet, pub long_context_eligible: bool, + /// Optional qualified task bounds. Omission cannot widen bundled bounds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub task_shape_ceiling: Option, pub strong_gate_fallback_efforts: BTreeSet, pub strength_rank: u16, pub class_fit: Vec, @@ -332,6 +383,53 @@ pub struct ModelPrior { pub one_shot_environment_fallbacks: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskShapeCeiling { + pub boundedness: BTreeSet, + pub maximum_risk: RiskLevel, + pub maximum_context: ContextSize, + pub maximum_horizon: TaskHorizon, +} + +impl TaskShapeCeiling { + pub fn bounded_terminal() -> Self { + Self { + boundedness: [Boundedness::TightlyBounded, Boundedness::Bounded] + .into_iter() + .collect(), + maximum_risk: RiskLevel::Medium, + maximum_context: ContextSize::Medium, + maximum_horizon: TaskHorizon::Medium, + } + } + + pub fn intersect(&self, other: &Self) -> Self { + Self { + boundedness: self + .boundedness + .intersection(&other.boundedness) + .copied() + .collect(), + maximum_risk: self.maximum_risk.min(other.maximum_risk), + maximum_context: self.maximum_context.min(other.maximum_context), + maximum_horizon: self.maximum_horizon.min(other.maximum_horizon), + } + } + + pub fn constrain(&self, capabilities: &mut CandidateCapabilities) { + capabilities.boundedness = capabilities + .boundedness + .intersection(&self.boundedness) + .copied() + .collect(); + capabilities.maximum_risk = capabilities.maximum_risk.min(self.maximum_risk); + capabilities.maximum_context = capabilities.maximum_context.min(self.maximum_context); + capabilities.maximum_horizon = capabilities.maximum_horizon.min(self.maximum_horizon); + capabilities.long_context &= self.maximum_context == ContextSize::Long; + } +} + #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct PriorDataset { diff --git a/src/supervise/outcome_history.rs b/src/supervise/outcome_history.rs index 2031d35b..31a0b101 100644 --- a/src/supervise/outcome_history.rs +++ b/src/supervise/outcome_history.rs @@ -34,6 +34,9 @@ pub(super) struct AttemptSelectionBinding { pub event_attempt: usize, pub normalized_input_sha256: String, pub task: TaskProfile, + /// Remains absent until the parent validates an assignment-specific shape. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub assignment_shape_identity: Option, pub requested_candidate: CandidateKey, } @@ -890,6 +893,7 @@ fn selection_binding_for_attempt( .value .clone(), task: event.provenance.normalized_task.clone(), + assignment_shape_identity: None, requested_candidate: choice.candidate.clone(), }) }, @@ -1444,6 +1448,7 @@ mod tests { horizon: TaskHorizon::Medium, authority_role: AuthorityRole::TerminalLeaf, }, + assignment_shape_identity: None, requested_candidate: candidate.clone(), }), requested_runtime: "codex".to_string(), @@ -1464,6 +1469,29 @@ mod tests { } } + #[test] + fn assignment_shape_identity_is_optional_and_preserved_without_inference() -> Result<()> { + let legacy = fixture(); + let wire = serde_json::to_value(&legacy)?; + assert!(wire["selection"].get("assignment_shape_identity").is_none()); + let decoded: AttemptOutcomeEvidence = serde_json::from_value(wire)?; + assert!(decoded + .selection + .unwrap() + .assignment_shape_identity + .is_none()); + let mut bound = legacy; + bound.selection.as_mut().unwrap().assignment_shape_identity = + Some(crate::selection::AssignmentShapeIdentity::v1("a".repeat(64)).unwrap()); + let decoded: AttemptOutcomeEvidence = serde_json::from_slice(&serde_json::to_vec(&bound)?)?; + assert_eq!(decoded, bound); + let mut wire = serde_json::to_value(&bound)?; + wire["selection"]["assignment_shape_identity"]["version"] = serde_json::json!(2); + assert!(serde_json::from_value::(wire).is_err()); + assert!(crate::selection::AssignmentShapeIdentity::v1("not-a-digest".into()).is_err()); + Ok(()) + } + #[derive(Clone, Copy, PartialEq, Eq)] enum AuthenticatedFixtureMode { Initial, @@ -1497,6 +1525,7 @@ mod tests { event_attempt: 0, normalized_input_sha256: decision.input_digests.normalized_input.value.clone(), task: decision.normalized_task.clone(), + assignment_shape_identity: None, requested_candidate: selected.candidate.clone(), }); evidence.requested_runtime = selected.candidate.runtime.clone(); @@ -2359,6 +2388,7 @@ mod tests { event_attempt: 0, normalized_input_sha256: "this-run-digest".to_string(), task, + assignment_shape_identity: None, requested_candidate: candidate.clone(), }); evidence.requested_runtime = candidate.runtime.clone(); @@ -2431,6 +2461,7 @@ mod tests { event_attempt: 0, normalized_input_sha256: "this-run-digest".to_string(), task, + assignment_shape_identity: None, requested_candidate: candidate.clone(), }); evidence.requested_runtime = candidate.runtime.clone(); diff --git a/src/supervise/schema_artifacts.rs b/src/supervise/schema_artifacts.rs index a8a00326..0dbefe9f 100644 --- a/src/supervise/schema_artifacts.rs +++ b/src/supervise/schema_artifacts.rs @@ -5376,6 +5376,65 @@ mod selection_schema_tests { Ok(()) } + #[test] + fn published_selection_prior_ceiling_is_optional_nullable_and_closed() -> Result<()> { + let decision = crate::selection::select(&crate::selection::selection_test_base_input())?; + let event = json!({ + "assignment_id": null, + "attempt": 0, + "role": "worker", + "primary_cause": "initial", + "provenance": decision, + }); + for tracked in [ + include_str!("../../schemas/supervisor-final-report-v1.schema.json"), + include_str!("../../schemas/supervisor-collect-report-v1.schema.json"), + ] { + let tracked: serde_json::Value = serde_json::from_str(tracked)?; + let schema = tracked["properties"]["role_economics_profile"]["properties"]["execution"] + ["properties"]["selection_decisions"]["items"] + .clone(); + let schema_id = "https://example.invalid/selection-prior-ceiling"; + let mut compiler = boon::Compiler::new(); + compiler.set_default_draft(boon::Draft::V2020_12); + compiler + .add_resource(schema_id, schema) + .expect("register event schema"); + let mut schemas = boon::Schemas::new(); + let index = compiler + .compile(schema_id, &mut schemas) + .expect("compile event schema"); + assert!(schemas.validate(&event, index).is_ok()); + let mut legacy = event.clone(); + for prior in legacy["provenance"]["normalized_input"]["priors"]["models"] + .as_array_mut() + .context("prior rows")? + { + prior + .as_object_mut() + .context("prior object")? + .remove("task_shape_ceiling"); + } + assert!(schemas.validate(&legacy, index).is_ok()); + let mut nullable = event.clone(); + nullable["provenance"]["normalized_input"]["priors"]["models"][0] + ["task_shape_ceiling"] = serde_json::Value::Null; + assert!(schemas.validate(&nullable, index).is_ok()); + for malformed in [ + json!({"boundedness":["bounded"],"maximum_risk":"high", "maximum_context":"long"}), + json!({"boundedness":["unknown"],"maximum_risk":"high", "maximum_context":"long","maximum_horizon":"long"}), + json!({"boundedness":["bounded"],"maximum_risk":"unknown", "maximum_context":"long","maximum_horizon":"long"}), + json!({"boundedness":["bounded"],"maximum_risk":"high", "maximum_context":"long","maximum_horizon":"long","extra_grant":true}), + ] { + let mut invalid = event.clone(); + invalid["provenance"]["normalized_input"]["priors"]["models"][0] + ["task_shape_ceiling"] = malformed; + assert!(schemas.validate(&invalid, index).is_err()); + } + } + Ok(()) + } + #[test] fn command_run_record_grok_stream_usage_evidence_schema_regression() -> Result<()> { use crate::runtime_adapter::grok::{ diff --git a/src/supervise/selection_bridge.rs b/src/supervise/selection_bridge.rs index 98edb5c1..b25d54aa 100644 --- a/src/supervise/selection_bridge.rs +++ b/src/supervise/selection_bridge.rs @@ -2605,6 +2605,7 @@ pub(super) fn base_selector_priors_with_terminal_worker_economics( .into_iter() .collect(), long_context_eligible: false, + task_shape_ceiling: Some(selection::TaskShapeCeiling::bounded_terminal()), strong_gate_fallback_efforts: BTreeSet::new(), strength_rank: 40, class_fit: vec![selection::ClassFitPrior { @@ -2970,7 +2971,7 @@ fn constructed_selection_catalogs( } else if let Some(gap) = &advertised.cursor_evidence_gap { catalogs.push(runtime_catalog_from_unavailable_priors( "cursor", gap, task, priors, - )); + )?); } } if runtime != SupervisorRuntime::Grok { @@ -3092,7 +3093,7 @@ fn runtime_catalog_from_observed_models( let mut models = Vec::new(); for observed_model in &observed.models { if let Some(catalog_model) = - catalog_model_from_observed(runtime, observed_model, task, priors) + catalog_model_from_observed(runtime, observed_model, task, priors)? { models.push(catalog_model); } @@ -3131,23 +3132,28 @@ fn catalog_model_from_observed( observed: &ObservedModel, task: &TaskProfile, priors: &selection::PriorDataset, -) -> Option { +) -> Result> { let mut supported_efforts = Vec::new(); if let Some(raw_efforts) = &observed.supported_efforts { for effort in raw_efforts { - supported_efforts.push(selector_effort_from_str(effort)?); + let Some(effort) = selector_effort_from_str(effort) else { + return Ok(None); + }; + supported_efforts.push(effort); } } supported_efforts.sort(); supported_efforts.dedup(); if supported_efforts.is_empty() { - return None; + return Ok(None); } - let capabilities = priors + let prior = priors .models .iter() - .find(|prior| prior.runtime == runtime && prior.model == observed.model_id) + .find(|prior| prior.runtime == runtime && prior.model == observed.model_id); + let mut capabilities = prior .map(|prior| capabilities_from_prior(prior, task)) + .transpose()? .unwrap_or_else(|| CandidateCapabilities { task_classes: BTreeSet::new(), authority_roles: BTreeSet::new(), @@ -3163,7 +3169,11 @@ fn catalog_model_from_observed( maximum_horizon: TaskHorizon::Long, long_context: false, }); - Some(CatalogModel { + if prior.is_none() { + selection::effective_task_shape_ceiling(runtime, &observed.model_id, None)? + .constrain(&mut capabilities); + } + Ok(Some(CatalogModel { model: observed.model_id.clone(), access: Some(observed.access), available: match observed.access { @@ -3176,14 +3186,13 @@ fn catalog_model_from_observed( }, supported_efforts, capabilities, - }) + })) } -#[cfg(target_os = "linux")] fn capabilities_from_prior( prior: &selection::ModelPrior, task: &TaskProfile, -) -> CandidateCapabilities { +) -> Result { let mut authority_roles = prior .authority_evidence .iter() @@ -3199,7 +3208,7 @@ fn capabilities_from_prior( { authority_roles.insert(task.authority_role); } - CandidateCapabilities { + let mut capabilities = CandidateCapabilities { task_classes: prior .class_fit .iter() @@ -3217,7 +3226,14 @@ fn capabilities_from_prior( maximum_context: ContextSize::Long, maximum_horizon: TaskHorizon::Long, long_context: prior.long_context_eligible, - } + }; + selection::effective_task_shape_ceiling( + &prior.runtime, + &prior.model, + prior.task_shape_ceiling.as_ref(), + )? + .constrain(&mut capabilities); + Ok(capabilities) } #[cfg_attr(not(target_os = "linux"), allow(unused_variables))] @@ -3333,15 +3349,15 @@ fn runtime_catalog_from_unavailable_priors( gap: &CursorCatalogEvidenceGap, task: &TaskProfile, priors: &selection::PriorDataset, -) -> RuntimeCatalog { +) -> Result { let mut models = priors .models .iter() .filter(|prior| prior.runtime == runtime_name) - .filter_map(|prior| catalog_model_from_prior(prior, task, false)) - .collect::>(); + .filter_map(|prior| catalog_model_from_prior(prior, task, false).transpose()) + .collect::>>()?; models.sort_by(|left, right| left.model.cmp(&right.model)); - RuntimeCatalog { + Ok(RuntimeCatalog { runtime: runtime_name.to_string(), revision: format!( "cursor-unavailable-sha256:{}", @@ -3349,7 +3365,7 @@ fn runtime_catalog_from_unavailable_priors( ), advertised_at: gap.observed_at_unix_millis.to_string(), models, - } + }) } fn pools_for_constructed_catalogs( @@ -3405,7 +3421,7 @@ fn runtime_catalog_from_advertised_slugs<'a>( if !advertised.contains(&prior.model) { continue; } - if let Some(model) = catalog_model_from_prior(prior, task, true) { + if let Some(model) = catalog_model_from_prior(prior, task, true)? { models.push(model); } } @@ -3422,7 +3438,7 @@ fn catalog_model_from_prior( prior: &selection::ModelPrior, task: &TaskProfile, available: bool, -) -> Option { +) -> Result> { let mut supported_efforts = prior .class_fit .iter() @@ -3443,48 +3459,15 @@ fn catalog_model_from_prior( .collect::>(); supported_efforts.sort(); if supported_efforts.is_empty() { - return None; + return Ok(None); } - let mut authority_roles = prior - .authority_evidence - .iter() - .map(|evidence| evidence.role) - .collect::>(); - if !prior.class_fit.is_empty() { - authority_roles.insert(AuthorityRole::TerminalLeaf); - } - if !prior.strong_gate_fallback_efforts.is_empty() - && !prior - .prohibited_authority_roles - .contains(&task.authority_role) - { - authority_roles.insert(task.authority_role); - } - Some(CatalogModel { + Ok(Some(CatalogModel { model: prior.model.clone(), available, access: None, supported_efforts, - capabilities: CandidateCapabilities { - task_classes: prior - .class_fit - .iter() - .map(|class_fit| class_fit.task_class.clone()) - .collect(), - authority_roles, - boundedness: [ - Boundedness::TightlyBounded, - Boundedness::Bounded, - Boundedness::CrossCutting, - ] - .into_iter() - .collect(), - maximum_risk: RiskLevel::Critical, - maximum_context: ContextSize::Long, - maximum_horizon: TaskHorizon::Long, - long_context: prior.long_context_eligible, - }, - }) + capabilities: capabilities_from_prior(prior, task)?, + })) } pub(super) fn runtime_catalog_from_priors( @@ -3502,69 +3485,9 @@ pub(super) fn runtime_catalog_from_priors( let available = catalog .availability(Some(&prior.model), runtime_from_name(runtime_name)?)? == RoleModelAvailability::Available; - let mut supported_efforts = prior - .class_fit - .iter() - .filter(|class_fit| class_fit.task_class == task.task_class) - .map(|class_fit| class_fit.effort) - .chain( - prior - .authority_evidence - .iter() - .filter(|evidence| { - evidence.task_class == task.task_class - && evidence.role == task.authority_role - }) - .map(|evidence| evidence.effort), - ) - .chain(prior.strong_gate_fallback_efforts.iter().copied()) - .collect::>() - .into_iter() - .collect::>(); - supported_efforts.sort(); - if supported_efforts.is_empty() { - continue; - } - let mut authority_roles = prior - .authority_evidence - .iter() - .map(|evidence| evidence.role) - .collect::>(); - if !prior.class_fit.is_empty() { - authority_roles.insert(AuthorityRole::TerminalLeaf); - } - if !prior.strong_gate_fallback_efforts.is_empty() - && !prior - .prohibited_authority_roles - .contains(&task.authority_role) - { - authority_roles.insert(task.authority_role); + if let Some(model) = catalog_model_from_prior(prior, task, available)? { + models.push(model); } - models.push(CatalogModel { - model: prior.model.clone(), - available, - access: None, - supported_efforts, - capabilities: CandidateCapabilities { - task_classes: prior - .class_fit - .iter() - .map(|class_fit| class_fit.task_class.clone()) - .collect(), - authority_roles, - boundedness: [ - Boundedness::TightlyBounded, - Boundedness::Bounded, - Boundedness::CrossCutting, - ] - .into_iter() - .collect(), - maximum_risk: RiskLevel::Critical, - maximum_context: ContextSize::Long, - maximum_horizon: TaskHorizon::Long, - long_context: prior.long_context_eligible, - }, - }); } models.sort_by(|left, right| left.model.cmp(&right.model)); let revision_material = @@ -5073,6 +4996,186 @@ mod tests { ) } + #[test] + fn cheap_lane_capabilities_cannot_be_widened_by_operator_priors() -> Result<()> { + let task = task_profile_for_role(AgentRole::Worker); + let mut priors = selector_priors_with_terminal_worker_economics()?; + let wide = selection::TaskShapeCeiling { + boundedness: [ + Boundedness::TightlyBounded, + Boundedness::Bounded, + Boundedness::CrossCutting, + ] + .into_iter() + .collect(), + maximum_risk: RiskLevel::Critical, + maximum_context: ContextSize::Long, + maximum_horizon: TaskHorizon::Long, + }; + for prior in &mut priors.models { + prior.task_shape_ceiling = Some(wide.clone()); + prior.long_context_eligible = true; + } + let advertised = runtime_catalog_from_advertised_slugs( + "codex", + ["gpt-5.6-luna"], + "fixture".into(), + "2026-10-29".into(), + &task, + &priors, + )?; + let membership = runtime_catalog_from_priors("codex", &codex_catalog()?, &task, &priors)?; + let unavailable = runtime_catalog_from_unavailable_priors( + "codex", + &CursorCatalogEvidenceGap { + message: "fixture".into(), + observed_at_unix_millis: 1, + }, + &task, + &priors, + )?; + for catalog in [advertised, membership, unavailable] { + let model = catalog + .models + .iter() + .find(|model| model.model == "gpt-5.6-luna") + .context("bounded prior catalog model")?; + assert_eq!(model.capabilities.maximum_risk, RiskLevel::Medium); + assert_eq!(model.capabilities.maximum_context, ContextSize::Medium); + assert_eq!(model.capabilities.maximum_horizon, TaskHorizon::Medium); + assert!(!model + .capabilities + .boundedness + .contains(&Boundedness::CrossCutting)); + assert!(!model.capabilities.long_context); + } + let terminal = priors + .models + .iter() + .find(|prior| prior.model == "grok-4.6") + .context("terminal economics prior")?; + assert_eq!( + capabilities_from_prior(terminal, &task)?.maximum_risk, + RiskLevel::Medium + ); + #[cfg(target_os = "linux")] + { + let observed = ObservedModel { + model_id: "gpt-5.6-luna".into(), + access: coding_agent_manager_lib::account_authority::ModelAccessOutcome::Available, + supported_efforts: Some(vec!["high".into()]), + default_effort: Some("high".into()), + }; + let model = catalog_model_from_observed("codex", &observed, &task, &priors)? + .context("observed bounded model")?; + assert_eq!(model.capabilities.maximum_risk, RiskLevel::Medium); + assert_eq!(model.capabilities.maximum_context, ContextSize::Medium); + assert_eq!(model.capabilities.maximum_horizon, TaskHorizon::Medium); + assert!(!model + .capabilities + .boundedness + .contains(&Boundedness::CrossCutting)); + assert!(!model.capabilities.long_context); + } + Ok(()) + } + + #[cfg(target_os = "linux")] + #[test] + fn account_observed_unbundled_prior_preserves_explicit_shape_ceiling() -> Result<()> { + let task = task_profile_for_role(AgentRole::Worker); + let mut priors = selector_priors_with_terminal_worker_economics()?; + let mut prior = priors + .models + .iter() + .find(|prior| prior.model == FRONTIER_PROFILE_MODEL) + .context("qualified prior fixture")? + .clone(); + prior.model = "qualified-unbundled-fixture".into(); + prior.long_context_eligible = true; + let model_id = prior.model.clone(); + priors.models.push(prior); + let observed = ObservedModel { + model_id: model_id.clone(), + access: coding_agent_manager_lib::account_authority::ModelAccessOutcome::Available, + supported_efforts: Some(vec!["high".into()]), + default_effort: Some("high".into()), + }; + let gap = CursorCatalogEvidenceGap { + message: "fixture".into(), + observed_at_unix_millis: 1, + }; + for explicit in [true, false] { + if !explicit { + priors.models.last_mut().unwrap().task_shape_ceiling = None; + } + let account = catalog_model_from_observed("codex", &observed, &task, &priors)? + .context("observed qualified model")?; + let advertised = runtime_catalog_from_advertised_slugs( + "codex", + [model_id.as_str()], + "fixture".into(), + "2026-10-29".into(), + &task, + &priors, + )?; + let membership = + runtime_catalog_from_priors("codex", &codex_catalog()?, &task, &priors)?; + let unavailable = + runtime_catalog_from_unavailable_priors("codex", &gap, &task, &priors)?; + for catalog in [advertised, membership, unavailable] { + let model = catalog + .models + .iter() + .find(|model| model.model == model_id) + .context("qualified catalog model")?; + assert_eq!(account.capabilities, model.capabilities); + } + assert_eq!( + account.capabilities.maximum_risk, + if explicit { + RiskLevel::Critical + } else { + RiskLevel::Medium + } + ); + assert_eq!( + account.capabilities.maximum_context, + if explicit { + ContextSize::Long + } else { + ContextSize::Medium + } + ); + assert_eq!( + account.capabilities.maximum_horizon, + if explicit { + TaskHorizon::Long + } else { + TaskHorizon::Medium + } + ); + assert_eq!( + account + .capabilities + .boundedness + .contains(&Boundedness::CrossCutting), + explicit + ); + assert_eq!(account.capabilities.long_context, explicit); + } + priors.models.retain(|prior| prior.model != model_id); + let unknown = catalog_model_from_observed("codex", &observed, &task, &priors)? + .context("unattributed observed model")?; + assert_eq!(unknown.capabilities.maximum_risk, RiskLevel::Medium); + assert_eq!(unknown.capabilities.maximum_context, ContextSize::Medium); + assert_eq!(unknown.capabilities.maximum_horizon, TaskHorizon::Medium); + assert!(!unknown.capabilities.long_context); + assert!(unknown.capabilities.task_classes.is_empty()); + assert!(unknown.capabilities.authority_roles.is_empty()); + Ok(()) + } + fn codex_catalog() -> Result { let priors = selection::built_in_prior_dataset()?; Ok(RuntimeModelCatalog::Codex( @@ -7025,6 +7128,7 @@ mod tests { event_attempt: 0, normalized_input_sha256: "this-run-digest".to_string(), task: task.clone(), + assignment_shape_identity: None, requested_candidate: candidate.clone(), }), requested_runtime: candidate.runtime.clone(), @@ -10942,7 +11046,9 @@ mod tests { .models .iter() .filter(|prior| prior.runtime == runtime_name(SupervisorRuntime::Grok)) - .filter_map(|prior| catalog_model_from_prior(prior, &task, false)) + .filter_map(|prior| catalog_model_from_prior(prior, &task, false).transpose()) + .collect::>>()? + .into_iter() .filter_map(|mut model| { model .supported_efforts From 6874ef378adaff1f36b730350de758bcaffe535f Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:01:56 +0900 Subject: [PATCH 16/27] Stop rejected Researcher evidence before parent review Contained Researcher attempts with rejected host execution evidence could still dispatch parent review. - Mark the final Researcher gate terminal when report or evidence problems remain after corrective retries. - Keep mandatory review for complete observations, including research that reports an unresolved finding. - Add controller regressions for absent, forged, mismatched, duplicate and unfinished observations and the complete-evidence path. Validation: formatting and five exact Linux tests passed; Clippy and the full platform suites run in this PR's CI. A live managed Linux Researcher run remains a separate acceptance gate. --- src/supervise/assignment_execution.rs | 4 +- src/supervise/tests/budget.rs | 161 ++++++++++++++++++++++++++ 2 files changed, 164 insertions(+), 1 deletion(-) diff --git a/src/supervise/assignment_execution.rs b/src/supervise/assignment_execution.rs index a017ab25..3da7bf83 100644 --- a/src/supervise/assignment_execution.rs +++ b/src/supervise/assignment_execution.rs @@ -6228,7 +6228,9 @@ fn decide_child_attempt( Ok(ChildAttemptDisposition::Finish { report: attempt_report, containment_verified: true, - gate_terminal: false, + // Parent review cannot authenticate rejected research execution evidence. + gate_terminal: assignment.role == AgentRole::Researcher + && !report_shape_problems.is_empty(), }) } diff --git a/src/supervise/tests/budget.rs b/src/supervise/tests/budget.rs index c5e392d3..d3153a0e 100644 --- a/src/supervise/tests/budget.rs +++ b/src/supervise/tests/budget.rs @@ -635,6 +635,167 @@ fn budget_integration_concurrent_scheduler_cannot_oversubscribe_and_drains_admit .contains("run budget stopped one or more new dispatches"))); } +#[cfg(target_os = "linux")] +#[test] +fn researcher_rejected_host_evidence_never_dispatches_parent_review() { + for case in ["absent", "forged", "exit", "duplicate", "unfinished"] { + exercise_researcher_parent_review_evidence_gate(case); + } +} + +#[cfg(target_os = "linux")] +#[test] +fn researcher_observed_failure_still_dispatches_parent_review() { + exercise_researcher_parent_review_evidence_gate("advisory-failure"); +} + +#[cfg(target_os = "linux")] +fn exercise_researcher_parent_review_evidence_gate(case: &str) { + use crate::external_agent::codex_app_server::{ + CommandExecutionEvidence, CommandExecutionObservation as Observation, + CommandExecutionObservationIssue, CommandExecutionSnapshot, CommandExecutionStatus, + TurnTerminalStatus, + }; + use crate::mutation_taxonomy::AssignmentProcessLaunchKind as LaunchKind; + + let _capability = install_budget_fixture_models(); + let (temp, repo_path) = injected_repository(); + let mut assignment = injected_named_assignment("research-evidence", "README.md"); + assignment.role = AgentRole::Researcher; + assignment.role_category = Some(RoleCategory::ReadOnlyResearcher); + let mut plan = injected_plan(assignment.clone(), 1); + inject_priced_process_roles(&mut plan, FRONTIER_PROFILE_MODEL, 1.0); + plan.role_models.insert( + AgentRole::Researcher, + plan.role_models[&AgentRole::ChildOrchestrator].clone(), + ); + let options = injected_options(&repo_path, temp.path(), "research-evidence-gate"); + let _prior = bind_priced_budget_fixture_prior(&repo_path).expect("bind fixture prior"); + let _selection = crate::supervise::scheduler::install_selected_researcher_budget_fixture( + &plan, + &options, + &temp, + priced_budget_fixture_profile().expect("authored fixture profile"), + 1, + ) + .expect("select Researcher and Auditor fixture"); + let child_invocations = AtomicUsize::new(0); + let audit_invocations = AtomicUsize::new(0); + let runner = |command: &ExternalAgentCommand| { + let mut child = injected_child_report(&assignment); + child.role = AgentRole::Researcher; + let mut inspection = injected_command_record(); + inspection.command = vec!["git status".to_string()]; + inspection.cwd = command.cwd.clone(); + inspection.timeout_seconds = 0; + inspection.duration_ms = 0; + child.commands_run.push(inspection); + child.validation_results = vec![ValidationResult { + name: "read-only inspection".to_string(), + status: ReviewStatus::Succeeded, + command: vec!["git status".to_string()], + message: None, + }]; + if case == "advisory-failure" { + child.status = ReviewStatus::Failed; + child.accepted = false; + child.rejected = true; + child.findings.push(Finding { + severity: FindingSeverity::Error, + message: "inspection found an unresolved problem".to_string(), + paths: assignment.assigned_paths.clone(), + }); + } + if command.assignment_process_launch_kind == Some(LaunchKind::ParentAuditor) { + audit_invocations.fetch_add(1, Ordering::SeqCst); + let mut audit = injected_auditor_report(&assignment, &child); + audit.id = command + .output_last_message + .file_stem() + .unwrap() + .to_str() + .unwrap() + .to_string(); + write_injected_json(&command.output_last_message, &audit); + return injected_verified_run(command); + } + child_invocations.fetch_add(1, Ordering::SeqCst); + let mut wire = serde_json::to_value(&child).unwrap(); + wire["read_only"] = json!(true); + wire["no_further_delegation"] = json!(true); + write_injected_json(&command.output_last_message, &wire); + let mut run = injected_verified_run(command); + let started = CommandExecutionSnapshot { + command: if case == "forged" { + "true" + } else { + "git status" + } + .to_string(), + cwd: command.cwd.to_string_lossy().into_owned(), + status: CommandExecutionStatus::InProgress, + exit_code: None, + }; + let observation = Observation::Complete { + item_id: "inspection".to_string(), + completed: CommandExecutionSnapshot { + status: if case == "exit" { + CommandExecutionStatus::Failed + } else { + CommandExecutionStatus::Completed + }, + exit_code: Some(i32::from(case == "exit")), + ..started.clone() + }, + started: started.clone(), + }; + let observations = match case { + "duplicate" => vec![observation.clone(), observation], + "unfinished" => vec![Observation::Incomplete { + item_id: "inspection".to_string(), + started: Some(started), + reason: CommandExecutionObservationIssue::MissingOrInvalidCompletedFields, + }], + _ => vec![observation], + }; + if case != "absent" { + run.set_codex_command_execution_evidence_for_test(CommandExecutionEvidence { + thread_id: assignment.id.clone(), + turn_id: "turn".to_string(), + turn_status: TurnTerminalStatus::Completed, + observations, + }); + } + run + }; + let report = run_supervisor_plan_with_budget_and_concurrent_runner( + plan, + SupervisorConsultantPlan::default(), + SupervisorBudgetConfig::default(), + options, + 1, + &runner, + ) + .expect("complete Researcher evidence gate fixture"); + assert_eq!(report.orchestrator_reports.len(), 1, "{case}: {report:#?}"); + let child = &report.orchestrator_reports[0]; + assert!(child.files_changed.is_empty(), "{case}: {child:#?}"); + assert!(child_invocations.load(Ordering::SeqCst) > 0, "{case}"); + if case == "advisory-failure" { + assert_eq!(audit_invocations.load(Ordering::SeqCst), 1, "{case}"); + assert_eq!(child.audit_reports.len(), 1, "{case}: {child:#?}"); + assert!(!report.success, "{case}: {report:#?}"); + } else { + assert_eq!(audit_invocations.load(Ordering::SeqCst), 0, "{case}"); + assert!(!report.success && child.rejected && report_failed(child)); + assert!(child.audit_reports.is_empty(), "{case}: {child:#?}"); + assert!(child.review_lens_aggregate.is_none(), "{case}: {child:#?}"); + assert!(child.findings.iter().any(|finding| finding + .message + .contains("researcher host command evidence rejected"))); + } +} + #[cfg(target_os = "linux")] #[test] fn budget_integration_concurrent_scheduler_waits_for_full_live_grant_and_resumes() { From a965f084f756bce3b24855af6447cfdf847891de Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:17:01 +0900 Subject: [PATCH 17/27] Aggregate role and lens selection diagnostics before dispatch - Evaluate every required role override and authored review lens against one frozen snapshot before binding any selection. - Report requested triples, assessed authority, rejection reasons, eligible efforts and input digests together. - Keep unobserved-catalog refusal evidence without suppressing available primary catalogs. - Prevent a failed preflight result from restoring launch authorization. - Add a selection-only JSON command that leaves unavailable inputs unknown and launches nothing. Validation: formatting and 17 exact tests, including the CLI integration test, passed on Linux; Clippy and the full suites run in CI. --- src/cli.rs | 28 ++ src/supervise.rs | 9 + src/supervise/scheduler.rs | 29 +- src/supervise/selection_bridge.rs | 336 +++++++++---- src/supervise/selection_preflight.rs | 525 +++++++++++++++++++++ src/supervise/selection_preflight_tests.rs | 422 +++++++++++++++++ tests/supervise_cli.rs | 131 +++++ 7 files changed, 1370 insertions(+), 110 deletions(-) create mode 100644 src/supervise/selection_preflight.rs create mode 100644 src/supervise/selection_preflight_tests.rs diff --git a/src/cli.rs b/src/cli.rs index 9ec9ef09..864604cd 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1073,6 +1073,18 @@ fn emit_supervisor_plan_error(error: anyhow::Error, json: bool) -> Result<()> { fn run_supervise_command(command: SuperviseSubcommand) -> Result<()> { match command { + SuperviseSubcommand::SelectionPreflight(args) => { + let report = supervise::supervisor_selection_preflight_file( + &args.plan_file, + args.runtime, + args.frozen_inputs.as_deref(), + )?; + print_query_report(&report, args.json)?; + if !report.compatible { + bail!("selection preflight refused: incompatible or unknown frozen inputs"); + } + Ok(()) + } SuperviseSubcommand::JournalAppend(args) => { supervise::run_worker_execution_journal_command_with_clock( &args.journal, @@ -1499,6 +1511,8 @@ enum SuperviseSubcommand { JournalAppend(WorkerJournalAppendArgs), /// Build a validated plan from a goal/spec, task file, or JSON supervisor plan. Plan(PlanSuperviseArgs), + /// Evaluate all reachable roles and custom lenses using frozen inputs, without dispatch. + SelectionPreflight(SelectionPreflightArgs), /// Run a supervisor plan with child orchestrators for a selected runtime. Run(RunSuperviseArgs), /// Re-run only evidence/report and parent audit stages for a preserved assignment diff. @@ -1568,6 +1582,20 @@ struct PlanSuperviseArgs { json: bool, } +#[derive(Debug, Args)] +struct SelectionPreflightArgs { + /// Supervisor plan to evaluate. + plan_file: PathBuf, + #[arg(long, value_enum, default_value = "codex")] + runtime: supervise::SupervisorRuntime, + /// FrozenSupervisorSelectionInputs JSON from a preflight report's frozen_inputs. + /// Omission reports unknown live inputs without fetching them. + #[arg(long, value_name = "FILE")] + frozen_inputs: Option, + #[arg(long)] + json: bool, +} + #[derive(Debug, Clone, Copy, Default, Args)] struct RunBudgetArgs { /// Hard ceiling for total provider tokens committed by this supervise run. diff --git a/src/supervise.rs b/src/supervise.rs index 95e5af76..7b8e71b5 100644 --- a/src/supervise.rs +++ b/src/supervise.rs @@ -293,6 +293,15 @@ mod scheduler; pub use scheduler::*; mod selection_bridge; +#[cfg(test)] +#[path = "supervise/selection_preflight_tests.rs"] +mod selection_preflight_tests; +pub use selection_bridge::selection_preflight::{ + evaluate_supervisor_selection_preflight, supervisor_selection_preflight_file, + FrozenRoleSelectionInput, FrozenSupervisorSelectionInputs, SelectionPreflightDiagnostic, + SelectionPreflightRejection, SelectionPreflightRequested, SelectionPreflightStatus, + SupervisorSelectionPreflightReport, +}; use selection_bridge::*; #[cfg(target_os = "linux")] pub(crate) use selection_bridge::{ diff --git a/src/supervise/scheduler.rs b/src/supervise/scheduler.rs index 577b64cd..a0303fcd 100644 --- a/src/supervise/scheduler.rs +++ b/src/supervise/scheduler.rs @@ -7014,6 +7014,23 @@ fn persist_supervisor_predispatch_failure( ) } +pub(super) fn seal_successful_preflight_decisions( + decisions: &[SupervisorSelectionEvent], + failure: Option<&SupervisorSelectionPreflightFailure>, +) -> Result<()> { + if failure.is_none() { + for decision in decisions { + if decision.provenance.status == crate::selection::DecisionStatus::Selected { + crate::selection::routing_policy::seal_selected_candidate( + decision.role.as_str(), + &decision.provenance, + )?; + } + } + } + Ok(()) +} + pub(super) fn run_supervisor_plan_with_runner_and_creation( loaded: LoadedSupervisorPlan, options: SupervisorRunOptions, @@ -7059,14 +7076,10 @@ pub(super) fn run_supervisor_plan_with_runner_and_creation( let _routing_policy_guard = crate::selection::routing_policy::bind_routing_policy( plan_metadata.resolved_routing_policy.clone(), )?; - for decision in &selection_decisions { - if decision.provenance.status == crate::selection::DecisionStatus::Selected { - crate::selection::routing_policy::seal_selected_candidate( - decision.role.as_str(), - &decision.provenance, - )?; - } - } + seal_successful_preflight_decisions( + &selection_decisions, + selection_preflight_failure.as_ref(), + )?; let preclaim_parked_assignment_ids = preclaim_decisions .iter() .filter(|decision| !decision.allows_path_claim()) diff --git a/src/supervise/selection_bridge.rs b/src/supervise/selection_bridge.rs index 98edb5c1..88cd7b19 100644 --- a/src/supervise/selection_bridge.rs +++ b/src/supervise/selection_bridge.rs @@ -45,6 +45,9 @@ use coding_agent_manager_lib::account_authority::{ }; use std::path::Path; +#[path = "selection_preflight.rs"] +pub(super) mod selection_preflight; + /// Account observation is a Linux-only CAM type. Non-Linux keeps /// `account_observation=None` and the advertised-catalog path. #[cfg(target_os = "linux")] @@ -1024,6 +1027,20 @@ pub(super) fn initialize_supervisor_selection_with_assessment( .unwrap_or( "verified Cursor runtime model catalog observation is missing without recorded failure evidence", ); + let report = selection_preflight::evaluate_supervisor_selection_preflight( + plan, + runtime, + selection_preflight::FrozenSupervisorSelectionInputs { + schema_version: 1, + plan_sha256: selection_preflight::plan_digest(plan)?, + roles: BTreeMap::new(), + unavailable: roles + .iter() + .map(|role| (*role, detail.to_string())) + .collect(), + }, + )?; + let aggregate = selection_preflight::refusal_message(&report)?; return Ok(SupervisorSelectionResolution { mode: if automatic { SupervisorSelectionMode::Automatic @@ -1036,121 +1053,158 @@ pub(super) fn initialize_supervisor_selection_with_assessment( role, kind: SupervisorSelectionPreflightFailureKind::FailClosed, message: format!( - "selected Cursor runtime requires a verified runtime-advertised model catalog before role '{}': {detail}", + "selected Cursor runtime requires a verified runtime-advertised model catalog before role '{}': {detail}; {aggregate}", role.as_str() ), }), }); } - let mut resolved = BTreeMap::new(); - let mut decisions = Vec::with_capacity(roles.len()); + let mut frozen_inputs = selection_preflight::FrozenSupervisorSelectionInputs { + schema_version: 1, + plan_sha256: selection_preflight::plan_digest(plan)?, + roles: BTreeMap::new(), + unavailable: BTreeMap::new(), + }; + let frozen_quota_ledger = + selection_preflight::freeze_quota_ledger(quota_context, quota_ledger)?; for role in roles.iter().copied() { - let configured = plan.role_models.get(&role); - let debug_override = configured - .map(|selection| { - debug_override_for_role( + let result = (|| -> Result<_> { + let debug_override = plan + .role_models + .get(&role) + .map(|selection| { + debug_override_for_role( + role, + authored_override_runtime(plan, role, runtime)?, + selection, + ) + }) + .transpose()?; + let mut input = selection_input_for_role_with_assessment_and_accounts( + SelectionInputForRoleArgs { role, - authored_override_runtime(plan, role, runtime)?, - selection, - ) - }) - .transpose()?; - let input = selection_input_for_role_with_assessment_and_accounts( - SelectionInputForRoleArgs { - role, + runtime, + catalog, + advertised, + admission, + resolved_objective_profile: &resolved_objective_profile, + quota_context: None, + quota_ledger: None, + account_observation, + signals: DynamicSignals { + retry_count: 0, + budget_signal: BudgetSignal::Continue, + previous_choice: None, + previous_catalog_digest: None, + environment_rejections: Vec::new(), + observed_cost_per_accepted_task_microunits: None, + }, + debug_override, + history, + }, + account_observations, + assessment, + Some(&routing_features), + frozen_policy.as_ref(), + )?; + selection_preflight::apply_frozen_quota( + &mut input, runtime, - catalog, - advertised, - admission, - resolved_objective_profile: &resolved_objective_profile, quota_context, - quota_ledger, - account_observation, - signals: DynamicSignals { - retry_count: 0, - budget_signal: BudgetSignal::Continue, - previous_choice: None, - previous_catalog_digest: None, - environment_rejections: Vec::new(), - observed_cost_per_accepted_task_microunits: None, - }, - debug_override, - history, - }, - account_observations, - assessment, - Some(&routing_features), - frozen_policy.as_ref(), - )?; - let routing_policy = frozen_policy.as_ref().map(|frozen| { - crate::selection::routing_policy::RoutingPolicyReceipt { - frozen: frozen.clone(), - role: role.as_str().to_string(), - features: routing_features.clone(), + frozen_quota_ledger.as_ref(), + )?; + let routing_policy = frozen_policy.as_ref().map(|frozen| { + crate::selection::routing_policy::RoutingPolicyReceipt { + frozen: frozen.clone(), + role: role.as_str().to_string(), + features: routing_features.clone(), + } + }); + Ok(selection_preflight::freeze_role_input( + input, + routing_policy, + )) + })(); + match result { + Ok(input) => { + frozen_inputs.roles.insert(role, input); } - }); - let mut decision = select_with_live_switch_cost_for_policy(&input, routing_policy.as_ref()) - .map_err(|error| { - anyhow!( - "automatic selector rejected role '{}': {error}", - role.as_str() - ) - })?; + Err(error) => { + frozen_inputs.unavailable.insert(role, format!("{error:#}")); + } + } + } + let report = + selection_preflight::evaluate_supervisor_selection_preflight(plan, runtime, frozen_inputs)?; + let mut decisions = Vec::with_capacity(roles.len()); + for diagnostic in &report.diagnostics { + if diagnostic.lens_id.is_some() { + continue; + } + let Some(mut decision) = diagnostic.decision.clone() else { + continue; + }; decision.outcome_history = history.map(|snapshot| snapshot.provenance.clone()); decision.operator_prior_data = super::prior_input::current_provenance(); - let primary_cause = if configured.is_some() { - SupervisorSelectionEventCause::DebugOverride - } else { - SupervisorSelectionEventCause::Initial - }; - let event = SupervisorSelectionEvent { + decisions.push(SupervisorSelectionEvent { assignment_id: None, attempt: 0, - role, - primary_cause, + role: diagnostic.role, + primary_cause: if plan.role_models.contains_key(&diagnostic.role) { + SupervisorSelectionEventCause::DebugOverride + } else { + SupervisorSelectionEventCause::Initial + }, provenance: decision, - }; - let choice = match executable_choice(&event.provenance, runtime, role)? { + }); + } + if !report.compatible { + let message = selection_preflight::refusal_message(&report)?; + // Preserve malformed-input errors while reporting every reachable failure. + if !report.frozen_inputs.unavailable.is_empty() { + bail!("{message}"); + } + let first = report + .diagnostics + .iter() + .find(|entry| entry.status != selection_preflight::SelectionPreflightStatus::Compatible) + .context("incompatible selection preflight omitted its diagnostic")?; + return Ok(SupervisorSelectionResolution { + mode: if automatic { + SupervisorSelectionMode::Automatic + } else { + SupervisorSelectionMode::DebugOverride + }, + decisions, + automatic_state: None, + selection_preflight_failure: Some(SupervisorSelectionPreflightFailure { + role: first.role, + kind: if first.rejection + == Some(selection_preflight::SelectionPreflightRejection::UnexecutableRuntime) + { + SupervisorSelectionPreflightFailureKind::ActiveRuntimeMismatch + } else { + SupervisorSelectionPreflightFailureKind::FailClosed + }, + message, + }), + }); + } + // All roles and lenses passed. Only now may selection authority be bound. + let mut resolved = BTreeMap::new(); + for event in &decisions { + let choice = match executable_choice(&event.provenance, runtime, event.role)? { ExecutableChoiceResolution::Executable(choice) => choice, - ExecutableChoiceResolution::PreflightFailure(failure) => { - decisions.push(event); - return Ok(SupervisorSelectionResolution { - mode: if automatic { - SupervisorSelectionMode::Automatic - } else { - SupervisorSelectionMode::DebugOverride - }, - decisions, - automatic_state: None, - selection_preflight_failure: Some(failure), - }); - } + ExecutableChoiceResolution::PreflightFailure(failure) => bail!("{}", failure.message), }; + route_live_four_arm_comparison(&event.provenance.normalized_input)?; + resolved.insert(event.role, role_selection_from_choice(choice)); + } + for event in &decisions { crate::selection::routing_policy::seal_selected_candidate( - role.as_str(), + event.role.as_str(), &event.provenance, )?; - if configured.is_some() { - let requested = &input - .debug_override - .as_ref() - .context("explicit role_models entry lost its selector debug override")? - .candidate; - if requested != &choice.candidate { - bail!( - "debug runtime/model/effort override for role '{}' was not applied exactly by the selector: requested '{}:{}:{:?}', selected '{}:{}:{:?}'", - role.as_str(), - requested.runtime, - requested.model, - requested.effort, - choice.candidate.runtime, - choice.candidate.model, - choice.candidate.effort, - ); - } - } - resolved.insert(role, role_selection_from_choice(choice)); - decisions.push(event); } plan.role_models = resolved; @@ -4800,7 +4854,7 @@ pub(super) fn selector_effort_as_str(effort: SelectorEffort) -> &'static str { } #[cfg(test)] -mod tests { +pub(super) mod tests { #[cfg(target_os = "linux")] use super::super::environment_observation::environment_cost_microunits_from_account_observe; use super::*; @@ -4818,8 +4872,8 @@ mod tests { } } - fn bind_fixture_routing_policy() -> Result - { + pub(in crate::supervise) fn bind_fixture_routing_policy( + ) -> Result { use crate::selection::routing_policy::{ bind_routing_policy, FrozenRoutingPolicy, LoadedRoutingPolicy, }; @@ -4848,6 +4902,75 @@ mod tests { Ok((assessment, features, frozen)) } + pub(in crate::supervise) fn frozen_preflight_fixture( + plan: &SupervisorPlan, + ) -> Result { + let (assessment, features, policy) = fixture_task_assessment(plan)?; + let mut roles = BTreeMap::new(); + for role in required_selector_roles(plan) { + let input = selection_input_for_role_with_assessment_and_accounts( + SelectionInputForRoleArgs { + role, + runtime: SupervisorRuntime::Codex, + catalog: &codex_catalog()?, + advertised: &AdvertisedCatalogSet::empty(), + admission: &test_admission(), + resolved_objective_profile: &default_resolved_profile(), + quota_context: None, + quota_ledger: None, + account_observation: None, + history: None, + debug_override: plan + .role_models + .get(&role) + .map(|value| debug_override_for_role(role, SupervisorRuntime::Codex, value)) + .transpose()?, + signals: DynamicSignals { + retry_count: 0, + budget_signal: BudgetSignal::Continue, + previous_choice: None, + previous_catalog_digest: None, + environment_rejections: Vec::new(), + observed_cost_per_accepted_task_microunits: None, + }, + }, + &[], + Some(&assessment), + Some(&features), + Some(&policy), + )?; + roles.insert( + role, + selection_preflight::freeze_role_input( + input, + Some(crate::selection::routing_policy::RoutingPolicyReceipt { + frozen: policy.clone(), + role: role.as_str().to_string(), + features: features.clone(), + }), + ), + ); + } + Ok(selection_preflight::FrozenSupervisorSelectionInputs { + schema_version: 1, + plan_sha256: selection_preflight::plan_digest(plan)?, + roles, + unavailable: BTreeMap::new(), + }) + } + + pub(in crate::supervise) fn initialize_preflight_fixture( + plan: &mut SupervisorPlan, + ) -> Result { + initialize_supervisor_selection( + plan, + SupervisorRuntime::Codex, + &codex_catalog()?, + &test_admission(), + &AdvertisedCatalogSet::empty(), + ) + } + fn fixture_task_assessment_for_policy( plan: &SupervisorPlan, frozen: &crate::selection::routing_policy::FrozenRoutingPolicy, @@ -6670,8 +6793,7 @@ mod tests { } #[test] - fn custom_review_lens_keeps_its_authored_triple_for_independent_dispatch_selection( - ) -> Result<()> { + fn custom_review_lens_refuses_its_incompatible_triple_before_dispatch() -> Result<()> { let _fixture_policy = bind_fixture_routing_policy()?; let catalog = codex_catalog()?; let auditor_prior = codex_prior_for(|prior| { @@ -6703,7 +6825,10 @@ mod tests { &test_admission(), &AdvertisedCatalogSet::empty(), )?; - assert!(resolution.selection_preflight_failure.is_none()); + let failure = resolution + .selection_preflight_failure + .context("custom lens preflight refusal")?; + assert!(failure.message.contains("custom-auditor")); assert_eq!(plan.review_lenses, authored); let mut precomputed = plan.clone(); precomputed.review_lenses[0].backend = ReviewLensBackendConfig::Precomputed { @@ -11614,7 +11739,14 @@ mod tests { assert!(failure.message.contains("--runtime codex")); assert!(failure.message.contains("docs/OPERATOR_QUICKSTART.md")); assert!(plan.role_models.is_empty()); - assert_eq!(resolution.decisions.len(), 1); + assert_eq!( + resolution.decisions.len(), + required_selector_roles(&plan).len() + ); + assert!(resolution + .decisions + .iter() + .all(|event| event.provenance.status == DecisionStatus::FailClosed)); let decision = &resolution.decisions[0].provenance; assert_eq!(decision.status, DecisionStatus::FailClosed); assert_eq!( diff --git a/src/supervise/selection_preflight.rs b/src/supervise/selection_preflight.rs new file mode 100644 index 00000000..e89100be --- /dev/null +++ b/src/supervise/selection_preflight.rs @@ -0,0 +1,525 @@ +//! Evaluation of frozen selection inputs without launch or selection authority. + +use super::*; + +const MAX_PREFLIGHT_ENTRIES: usize = 256; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FrozenRoleSelectionInput { + pub input: SelectionInput, + pub routing_policy: Option, + #[serde(default)] + pub switch_cost_evidence: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FrozenSupervisorSelectionInputs { + pub schema_version: u32, + pub plan_sha256: String, + pub roles: BTreeMap, + #[serde(default)] + pub unavailable: BTreeMap, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SelectionPreflightStatus { + Compatible, + Rejected, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SelectionPreflightRejection { + UnavailableInput, + InvalidInput, + InvalidContract, + ModelUnavailable, + EffortIncompatible, + Eligibility, + UnexecutableRuntime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SelectionPreflightRequested { + pub runtime: String, + pub model: Option, + pub effort: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SelectionPreflightDiagnostic { + pub role: AgentRole, + pub lens_id: Option, + pub requested: Option, + pub task: Option, + pub status: SelectionPreflightStatus, + pub rejection: Option, + pub message: String, + /// Only efforts for the requested runtime/model that passed every hard gate. + /// None means the frozen evidence could not establish eligibility. + pub eligible_efforts: Option>, + pub ineligibility_reasons: Vec, + pub decision: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SupervisorSelectionPreflightReport { + pub schema_version: u32, + pub compatible: bool, + pub diagnostics: Vec, + pub frozen_inputs: FrozenSupervisorSelectionInputs, +} + +pub(super) fn plan_digest(plan: &SupervisorPlan) -> Result { + Ok(crate::artifacts::state_auth::sha256_hex( + &serde_json::to_vec(plan)?, + )) +} + +pub(super) fn refusal_message(report: &SupervisorSelectionPreflightReport) -> Result { + let failures: Vec<_> = report + .diagnostics + .iter() + .filter(|entry| entry.status != SelectionPreflightStatus::Compatible) + .map(|entry| { + serde_json::json!({ + "role": entry.role, "lens_id": entry.lens_id, "requested": entry.requested, + "task": entry.task, "status": entry.status, "rejection": entry.rejection, + "message": entry.message, "eligible_efforts": entry.eligible_efforts, + "ineligibility_reasons": entry.ineligibility_reasons, + "input_digests": entry.decision.as_ref().map(|decision| &decision.input_digests), + }) + }) + .collect(); + Ok(format!( + "selection preflight refused: {}", + serde_json::to_string(&failures)? + )) +} + +pub(super) fn freeze_role_input( + mut input: SelectionInput, + routing_policy: Option, +) -> FrozenRoleSelectionInput { + selection::runtime_model_policy::constrain(&mut input); + if super::super::runtime_executables::captured_binding().is_some() { + super::super::runtime_executables::constrain_selection(&mut input); + } + let switch_cost_evidence = live_switch_cost_evidence(&input); + FrozenRoleSelectionInput { + input, + routing_policy, + switch_cost_evidence, + } +} + +pub(super) fn freeze_quota_ledger( + context: Option<&LiveQuotaSelectionContext>, + ledger: Option<&RunBudgetLedger>, +) -> Result> { + context + .map(|context| { + ledger + .context( + "operator quota selection requires the scheduler's attached run budget ledger", + )? + .quota_consumption_ledger(&context.config, crate::budget_ledger::unix_now()?) + .map_err(|error| { + anyhow!("failed to project attached operator quota ledger: {error}") + }) + }) + .transpose() +} + +pub(super) fn apply_frozen_quota( + input: &mut SelectionInput, + runtime: SupervisorRuntime, + context: Option<&LiveQuotaSelectionContext>, + ledger: Option<&crate::optimizer::quota_pools::ConsumptionLedger>, +) -> Result<()> { + if let Some(context) = context { + let source = live_quota_pool_for_runtime(context, runtime.as_str())?; + input.quota_source = Some(crate::optimizer::quota_pools::PoolReference { + runtime: source.runtime.clone(), + account: source.account.clone(), + window: source.window, + }); + selection::apply_fail_closed_quota_pools( + input, + &context.config.pools, + ledger.context("frozen quota ledger is unavailable")?, + )?; + input.operational_observations = Some(live_operational_observations(input)); + } + Ok(()) +} + +fn requested( + plan: &SupervisorPlan, + role: AgentRole, + runtime: SupervisorRuntime, +) -> Option { + plan.role_models + .get(&role) + .map(|value| SelectionPreflightRequested { + runtime: authored_override_runtime(plan, role, runtime) + .unwrap_or(runtime) + .as_str() + .to_string(), + model: value.model.clone(), + effort: value.reasoning_effort.clone(), + }) +} + +fn unavailable_diagnostic( + role: AgentRole, + lens_id: Option, + requested: Option, + message: String, +) -> SelectionPreflightDiagnostic { + SelectionPreflightDiagnostic { + role, + lens_id, + requested, + task: None, + status: SelectionPreflightStatus::Unknown, + rejection: Some(SelectionPreflightRejection::UnavailableInput), + message, + eligible_efforts: None, + ineligibility_reasons: Vec::new(), + decision: None, + } +} + +pub(in crate::supervise) fn selected_choice_rejection( + decision: &SelectionProvenance, +) -> Option { + match decision.choice.as_ref() { + None => Some(SelectionPreflightRejection::InvalidContract), + Some(choice) if runtime_from_name(&choice.candidate.runtime).is_err() => { + Some(SelectionPreflightRejection::UnexecutableRuntime) + } + Some(choice) + if decision + .normalized_input + .debug_override + .as_ref() + .is_some_and(|request| request.candidate != choice.candidate) => + { + Some(SelectionPreflightRejection::InvalidContract) + } + Some(_) => None, + } +} + +fn evaluate( + role: AgentRole, + lens_id: Option, + requested: Option, + frozen: &FrozenRoleSelectionInput, +) -> SelectionPreflightDiagnostic { + let mut diagnostic = unavailable_diagnostic(role, lens_id, requested, String::new()); + diagnostic.task = Some(frozen.input.task.clone()); + if frozen.input.catalogs.is_empty() { + diagnostic.message = + "frozen model catalog is unavailable; eligibility is unknown".to_string(); + return diagnostic; + } + let result = selection::select_with_loaded_routing_policy( + &frozen.input, + &frozen.switch_cost_evidence, + frozen.routing_policy.as_ref(), + ); + let decision = match result { + Ok(decision) => decision, + Err(error) => { + diagnostic.status = SelectionPreflightStatus::Rejected; + diagnostic.rejection = Some(SelectionPreflightRejection::InvalidInput); + diagnostic.message = error.to_string(); + return diagnostic; + } + }; + let input = &decision.normalized_input; + let candidate = input + .debug_override + .as_ref() + .map(|request| &request.candidate); + let model = candidate.and_then(|candidate| { + input + .catalogs + .iter() + .find(|catalog| catalog.runtime == candidate.runtime) + .and_then(|catalog| { + catalog + .models + .iter() + .find(|model| model.model == candidate.model) + }) + }); + let eligible_efforts = candidate.map(|requested| { + decision + .candidate_set + .iter() + .filter(|entry| { + entry.eligible + && entry.candidate.runtime == requested.runtime + && entry.candidate.model == requested.model + }) + .map(|entry| entry.candidate.effort) + .collect() + }); + diagnostic.eligible_efforts = eligible_efforts; + diagnostic.ineligibility_reasons = candidate + .and_then(|requested| { + decision + .candidate_set + .iter() + .find(|entry| &entry.candidate == requested) + }) + .map(|entry| entry.ineligibility_reasons.clone()) + .unwrap_or_default(); + if decision.status != DecisionStatus::Selected { + diagnostic.status = SelectionPreflightStatus::Rejected; + diagnostic.rejection = Some( + if candidate.is_some() + && model.is_none_or(|model| { + !model.available + || model.access == Some(selection::ModelAccessOutcome::Unavailable) + }) + { + SelectionPreflightRejection::ModelUnavailable + } else if candidate.zip(model).is_some_and(|(candidate, model)| { + !model.supported_efforts.contains(&candidate.effort) + }) { + SelectionPreflightRejection::EffortIncompatible + } else { + SelectionPreflightRejection::Eligibility + }, + ); + diagnostic.message = selection_refusal_message(&decision, role); + } else if let Some(rejection) = selected_choice_rejection(&decision) { + diagnostic.status = SelectionPreflightStatus::Rejected; + diagnostic.rejection = Some(rejection); + diagnostic.message = match rejection { + SelectionPreflightRejection::UnexecutableRuntime => { + "selected choice uses an unexecutable runtime" + } + _ => "selected choice is missing or differs from the explicit override", + } + .to_string(); + } else { + diagnostic.status = SelectionPreflightStatus::Compatible; + diagnostic.rejection = None; + diagnostic.message = decision.decision_reason.clone(); + } + diagnostic.decision = Some(decision); + diagnostic +} + +/// Evaluate an exact plan and frozen evidence. This function performs no I/O, +/// selection sealing, budget reservation, or workload dispatch. +pub fn evaluate_supervisor_selection_preflight( + plan: &SupervisorPlan, + runtime: SupervisorRuntime, + frozen: FrozenSupervisorSelectionInputs, +) -> Result { + if frozen.schema_version != 1 || frozen.plan_sha256 != plan_digest(plan)? { + bail!("selection preflight snapshot version or plan digest does not match"); + } + let roles = required_selector_roles(plan); + if roles.len().saturating_add(plan.review_lenses.len()) > MAX_PREFLIGHT_ENTRIES { + bail!("selection preflight exceeds the diagnostic entry limit"); + } + // A snapshot cannot mix policy, assessment, objective, or prior revisions. + if let Some((_, first)) = frozen.roles.iter().find(|(role, _)| roles.contains(role)) { + for (role, entry) in frozen.roles.iter().filter(|(role, _)| roles.contains(role)) { + if entry.input.priors != first.input.priors + || entry.input.objective_profile != first.input.objective_profile + || entry.input.resolved_objective_profile != first.input.resolved_objective_profile + || entry + .routing_policy + .as_ref() + .map(|receipt| (&receipt.frozen, &receipt.features)) + != first + .routing_policy + .as_ref() + .map(|receipt| (&receipt.frozen, &receipt.features)) + || role_for_task_profile(&entry.input.task)? != *role + || entry + .routing_policy + .as_ref() + .is_some_and(|receipt| receipt.role != role.as_str()) + { + bail!("selection preflight snapshot mixes frozen inputs or role authority"); + } + } + } + let mut diagnostics = Vec::new(); + for role in roles { + let request = requested(plan, role, runtime); + let Some(entry) = frozen.roles.get(&role) else { + let mut diagnostic = + unavailable_diagnostic( + role, + None, + request, + frozen.unavailable.get(&role).cloned().unwrap_or_else(|| { + "frozen role selection input is unavailable".to_string() + }), + ); + if let Some(selection) = plan.role_models.get(&role) { + if let Err(error) = authored_override_runtime(plan, role, runtime) + .and_then(|runtime| debug_override_for_role(role, runtime, selection)) + { + diagnostic.status = SelectionPreflightStatus::Rejected; + diagnostic.rejection = Some(SelectionPreflightRejection::InvalidContract); + diagnostic.message = error.to_string(); + } + } + diagnostics.push(diagnostic); + continue; + }; + let mut entry = entry.clone(); + // Saved evidence supplies capabilities, never an alternative override. + match plan + .role_models + .get(&role) + .map(|value| { + debug_override_for_role( + role, + authored_override_runtime(plan, role, runtime)?, + value, + ) + }) + .transpose() + { + Ok(value) => entry.input.debug_override = value, + Err(error) => { + let mut diagnostic = unavailable_diagnostic(role, None, request, error.to_string()); + diagnostic.task = Some(entry.input.task.clone()); + diagnostic.status = SelectionPreflightStatus::Rejected; + diagnostic.rejection = Some(SelectionPreflightRejection::InvalidContract); + diagnostics.push(diagnostic); + continue; + } + } + diagnostics.push(evaluate(role, None, request, &entry)); + } + if plan.review_lenses != default_supervisor_review_lenses() { + for lens in &plan.review_lenses { + let runtime_name = diagnostics + .iter() + .find(|entry| entry.role == AgentRole::Auditor && entry.lens_id.is_none()) + .and_then(|entry| entry.decision.as_ref()) + .and_then(|decision| decision.choice.as_ref()) + .map(|choice| choice.candidate.runtime.as_str()) + .unwrap_or( + authored_override_runtime(plan, AgentRole::Auditor, runtime) + .unwrap_or(runtime) + .as_str(), + ) + .to_string(); + let request = match &lens.backend { + ReviewLensBackendConfig::Model { + model, + reasoning_effort, + .. + } => Some(SelectionPreflightRequested { + runtime: runtime_name.clone(), + model: Some(model.clone()), + effort: reasoning_effort.clone(), + }), + ReviewLensBackendConfig::Precomputed { .. } => None, + }; + let Some(baseline) = frozen.roles.get(&AgentRole::Auditor) else { + diagnostics.push(unavailable_diagnostic( + AgentRole::Auditor, + Some(lens.id.clone()), + request, + "custom review lens requires the frozen Auditor selection input".to_string(), + )); + continue; + }; + let mut entry = baseline.clone(); + let contract = request.as_ref().and_then(|request| { + Some(CandidateKey { + runtime: request.runtime.clone(), + model: request.model.clone()?, + effort: selector_effort_from_str(request.effort.as_deref()?)?, + }) + }); + let Some(candidate) = contract.filter(|candidate| !candidate.model.is_empty()) else { + let mut diagnostic = unavailable_diagnostic(AgentRole::Auditor, Some(lens.id.clone()), request, + format!("custom review lens '{}' requires an explicit model and supported effort; precomputed lenses have no executable reasoning-effort field", lens.id)); + diagnostic.task = Some(entry.input.task.clone()); + diagnostic.status = SelectionPreflightStatus::Rejected; + diagnostic.rejection = Some(SelectionPreflightRejection::InvalidContract); + diagnostics.push(diagnostic); + continue; + }; + entry + .input + .constraints + .allowed_runtimes + .retain(|allowed| allowed == &runtime_name); + entry.input.constraints.allow_debug_override = true; + entry.input.debug_override = Some(DebugOverride { + candidate, + requested_by: "supervisor_plan.review_lenses".to_string(), + reason: format!( + "exact authored review lens '{}' after all hard eligibility gates", + lens.id + ), + }); + let mut diagnostic = + evaluate(AgentRole::Auditor, Some(lens.id.clone()), request, &entry); + if diagnostics.iter().any(|entry| { + entry.role == AgentRole::Auditor + && entry.lens_id.is_none() + && entry.status != SelectionPreflightStatus::Compatible + }) && diagnostic.status == SelectionPreflightStatus::Compatible + { + diagnostic.status = SelectionPreflightStatus::Rejected; + diagnostic.rejection = Some(SelectionPreflightRejection::InvalidContract); + diagnostic.message = + "custom review lens requires a selector-validated Auditor contract".to_string(); + } + diagnostics.push(diagnostic); + } + } + Ok(SupervisorSelectionPreflightReport { + schema_version: 1, + compatible: diagnostics + .iter() + .all(|entry| entry.status == SelectionPreflightStatus::Compatible), + diagnostics, + frozen_inputs: frozen, + }) +} + +/// Read only the supplied plan and optional frozen inputs. Missing live data +/// remains unknown; this entrypoint never acquires catalogs or account data. +pub fn supervisor_selection_preflight_file( + plan_file: &Path, + runtime: SupervisorRuntime, + frozen_inputs_file: Option<&Path>, +) -> Result { + let plan = super::super::plan_api::load_supervisor_plan_file(plan_file)?; + let frozen = match frozen_inputs_file { + Some(path) => serde_json::from_str(&BoundedRegularReader::read_tree_no_follow_utf8( + path, + 16 * 1024 * 1024, + )?)?, + None => FrozenSupervisorSelectionInputs { + schema_version: 1, + plan_sha256: plan_digest(&plan)?, + roles: BTreeMap::new(), + unavailable: BTreeMap::new(), + }, + }; + evaluate_supervisor_selection_preflight(&plan, runtime, frozen) +} diff --git a/src/supervise/selection_preflight_tests.rs b/src/supervise/selection_preflight_tests.rs new file mode 100644 index 00000000..876e0e3d --- /dev/null +++ b/src/supervise/selection_preflight_tests.rs @@ -0,0 +1,422 @@ +use super::*; +use crate::selection; +use selection_bridge::tests::{bind_fixture_routing_policy, frozen_preflight_fixture}; + +fn plan() -> Result { + Ok(serde_json::from_value(json!({ + "task": "inspect bounded source input", "assignments": [{ + "id": "inspect", "phase": "execution", "role": "researcher", "role_category": "read_only_researcher", + "assigned_paths": ["src/lib.rs"], "worker_assignments": [] + }], + "role_models": { + "researcher": {"model": "gpt-6.1-sol", "reasoning_effort": "high"}, + "auditor": {"model": "gpt-6.1-sol", "reasoning_effort": "high"} + }, + "review_lenses": [{"id": "authored", "information_scope": "full_child_transcript", + "backend": {"kind": "model", "backend_id": "codex", "model": "gpt-6.1-sol", "reasoning_effort": "high"}}] + }))?) +} + +#[test] +fn collects_researcher_auditor_and_lens_rejections_from_one_snapshot() -> Result<()> { + let _policy = bind_fixture_routing_policy()?; + let plan = plan()?; + let before = plan.clone(); + let seals = crate::selection::routing_policy::captured_selected_authorizations(); + let inputs = frozen_preflight_fixture(&plan)?; + let report = evaluate_supervisor_selection_preflight(&plan, SupervisorRuntime::Codex, inputs)?; + assert!(!report.compatible); + for (role, lens) in [ + (AgentRole::Researcher, None), + (AgentRole::Auditor, None), + (AgentRole::Auditor, Some("authored")), + ] { + let diagnostic = report + .diagnostics + .iter() + .find(|entry| entry.role == role && entry.lens_id.as_deref() == lens) + .context("diagnostic")?; + assert_eq!( + diagnostic.status, + SelectionPreflightStatus::Rejected, + "{diagnostic:?}" + ); + assert_eq!( + diagnostic.rejection, + Some(SelectionPreflightRejection::EffortIncompatible) + ); + assert!(diagnostic + .eligible_efforts + .as_ref() + .is_some_and(|efforts| efforts.contains(&selection::ReasoningEffort::Xhigh))); + assert!(diagnostic.task.is_some()); + assert!(diagnostic + .decision + .as_ref() + .is_some_and(|decision| !decision.input_digests.priors.value.is_empty())); + } + assert_eq!(plan, before); + let mut dispatch_plan = plan.clone(); + let resolution = selection_bridge::tests::initialize_preflight_fixture(&mut dispatch_plan)?; + let failure = resolution + .selection_preflight_failure + .context("aggregate refusal")?; + assert!(failure.message.contains("researcher")); + assert!(failure.message.contains("auditor")); + assert!(failure.message.contains("authored")); + assert_eq!(dispatch_plan, before); + assert_eq!( + seals, + crate::selection::routing_policy::captured_selected_authorizations() + ); + Ok(()) +} + +#[test] +fn unknown_catalog_does_not_launch_or_invent_efforts() -> Result<()> { + let _policy = bind_fixture_routing_policy()?; + let plan = plan()?; + let mut inputs = frozen_preflight_fixture(&plan)?; + let mut unknown_catalog = inputs.clone(); + unknown_catalog + .roles + .get_mut(&AgentRole::Researcher) + .context("researcher")? + .input + .catalogs + .clear(); + let unknown = + evaluate_supervisor_selection_preflight(&plan, SupervisorRuntime::Codex, unknown_catalog)?; + let researcher = unknown + .diagnostics + .iter() + .find(|entry| entry.role == AgentRole::Researcher) + .context("researcher")?; + assert_eq!(researcher.status, SelectionPreflightStatus::Unknown); + assert!(researcher.eligible_efforts.is_none()); + inputs.roles.clear(); + let report = evaluate_supervisor_selection_preflight(&plan, SupervisorRuntime::Codex, inputs)?; + assert!(!report.compatible); + assert!(report + .diagnostics + .iter() + .all(|entry| entry.status == SelectionPreflightStatus::Unknown + && entry.eligible_efforts.is_none() + && entry.decision.is_none())); + assert!(crate::selection::routing_policy::captured_selected_authorizations().is_empty()); + Ok(()) +} + +#[test] +fn compatible_selection_preserves_exact_choice_and_reachability() -> Result<()> { + let _policy = bind_fixture_routing_policy()?; + let mut plan = plan()?; + for value in plan.role_models.values_mut() { + value.reasoning_effort = Some("xhigh".to_string()); + } + if let ReviewLensBackendConfig::Model { + reasoning_effort, .. + } = &mut plan.review_lenses[0].backend + { + *reasoning_effort = Some("xhigh".to_string()); + } + plan.role_models.insert( + AgentRole::Worker, + RoleModelSelection { + model: Some("unavailable".to_string()), + reasoning_effort: Some("low".to_string()), + ..Default::default() + }, + ); + let inputs = frozen_preflight_fixture(&plan)?; + let report = evaluate_supervisor_selection_preflight(&plan, SupervisorRuntime::Codex, inputs)?; + assert!(report.compatible, "{:?}", report.diagnostics); + assert!(!report + .diagnostics + .iter() + .any(|entry| entry.role == AgentRole::Worker)); + for diagnostic in &report.diagnostics { + if let Some(requested) = &diagnostic.requested { + let choice = &diagnostic + .decision + .as_ref() + .context("decision")? + .choice + .as_ref() + .context("choice")? + .candidate; + assert_eq!(requested.model.as_deref(), Some(choice.model.as_str())); + assert_eq!(requested.runtime, choice.runtime); + assert_eq!(requested.effort.as_deref(), Some(choice.effort.as_str())); + } + } + plan.role_models.clear(); + let automatic = evaluate_supervisor_selection_preflight( + &plan, + SupervisorRuntime::Codex, + frozen_preflight_fixture(&plan)?, + )?; + assert!(automatic.compatible, "{:?}", automatic.diagnostics); + assert!(crate::selection::routing_policy::captured_selected_authorizations().is_empty()); + Ok(()) +} + +#[test] +fn distinguishes_model_availability_and_authority_rejection() -> Result<()> { + let _policy = bind_fixture_routing_policy()?; + let mut plan = plan()?; + plan.role_models + .get_mut(&AgentRole::Researcher) + .context("researcher")? + .model = Some("absent-model".to_string()); + let mut inputs = frozen_preflight_fixture(&plan)?; + let auditor = inputs + .roles + .get_mut(&AgentRole::Auditor) + .context("auditor")?; + let model = auditor + .input + .catalogs + .iter_mut() + .flat_map(|catalog| &mut catalog.models) + .find(|model| model.model == "gpt-6.1-sol") + .context("model")?; + model + .supported_efforts + .push(selection::ReasoningEffort::High); + for entry in inputs.roles.values_mut() { + entry + .input + .priors + .models + .iter_mut() + .find(|prior| prior.model == "gpt-6.1-sol") + .context("model prior")? + .prohibited_authority_roles + .insert(selection::AuthorityRole::ReviewAuditor); + } + let report = evaluate_supervisor_selection_preflight(&plan, SupervisorRuntime::Codex, inputs)?; + assert_eq!( + report + .diagnostics + .iter() + .find(|entry| entry.role == AgentRole::Researcher) + .context("researcher")? + .rejection, + Some(SelectionPreflightRejection::ModelUnavailable) + ); + let auditor = report + .diagnostics + .iter() + .find(|entry| entry.role == AgentRole::Auditor) + .context("auditor")?; + assert_eq!( + auditor.rejection, + Some(SelectionPreflightRejection::Eligibility) + ); + assert!(auditor + .ineligibility_reasons + .iter() + .any(|reason| reason.code == selection::IneligibilityCode::PolicyProhibited)); + Ok(()) +} + +#[test] +fn refuses_mixed_snapshot_revisions_and_plan_drift() -> Result<()> { + let _policy = bind_fixture_routing_policy()?; + let plan = plan()?; + let mut inputs = frozen_preflight_fixture(&plan)?; + let original = inputs.clone(); + inputs + .roles + .get_mut(&AgentRole::Researcher) + .context("researcher")? + .input + .priors + .revision + .push_str("-changed"); + assert!( + evaluate_supervisor_selection_preflight(&plan, SupervisorRuntime::Codex, inputs).is_err() + ); + let mut changed = plan.clone(); + changed.task.push_str(" changed"); + assert!( + evaluate_supervisor_selection_preflight(&changed, SupervisorRuntime::Codex, original) + .is_err() + ); + Ok(()) +} + +#[test] +fn unobserved_auxiliary_catalog_preserves_observed_primary_selection() -> Result<()> { + let _policy = bind_fixture_routing_policy()?; + let mut plan = plan()?; + plan.role_models.clear(); + plan.review_lenses = default_supervisor_review_lenses(); + let mut inputs = frozen_preflight_fixture(&plan)?; + for entry in inputs.roles.values_mut() { + entry + .switch_cost_evidence + .retain(|evidence| evidence.candidate.runtime != "grok"); + entry + .input + .catalogs + .retain(|catalog| catalog.runtime != "grok"); + entry.input.catalogs.push(selection::RuntimeCatalog { + runtime: "grok".to_string(), + revision: format!( + "account-observe-models-unobserved-sha256:{}", + "a".repeat(64) + ), + advertised_at: "2026-10-29T00:00:00Z".to_string(), + models: Vec::new(), + }); + if !entry.input.pools.iter().any(|pool| pool.runtime == "grok") { + let mut pool = entry.input.pools[0].clone(); + pool.runtime = "grok".to_string(); + entry.input.pools.push(pool); + } + entry + .input + .constraints + .allowed_runtimes + .insert("grok".to_string()); + } + let report = evaluate_supervisor_selection_preflight(&plan, SupervisorRuntime::Codex, inputs)?; + assert!(report.compatible); + for diagnostic in &report.diagnostics { + assert_eq!(diagnostic.status, SelectionPreflightStatus::Compatible); + let decision = diagnostic.decision.as_ref().context("selector decision")?; + assert_eq!(decision.status, selection::DecisionStatus::Selected); + assert_eq!( + decision + .choice + .as_ref() + .context("choice")? + .candidate + .runtime, + "codex" + ); + assert!(decision + .normalized_input + .catalogs + .iter() + .any(|catalog| { catalog.runtime == "grok" && catalog.models.is_empty() })); + } + assert!(crate::selection::routing_policy::captured_selected_authorizations().is_empty()); + Ok(()) +} + +#[test] +fn failed_preflight_retains_selected_decisions_without_sealing_them() -> Result<()> { + use super::scheduler::seal_successful_preflight_decisions; + let _policy = bind_fixture_routing_policy()?; + let mut plan = plan()?; + let resolution = selection_bridge::tests::initialize_preflight_fixture(&mut plan)?; + assert!(resolution.selection_preflight_failure.is_some()); + assert!(resolution + .decisions + .iter() + .any(|event| event.provenance.status == selection::DecisionStatus::Selected)); + assert!(resolution + .decisions + .iter() + .any(|event| event.provenance.status == selection::DecisionStatus::FailClosed)); + let before = crate::selection::routing_policy::captured_selected_authorizations(); + seal_successful_preflight_decisions( + &resolution.decisions, + resolution.selection_preflight_failure.as_ref(), + )?; + assert_eq!( + before, + crate::selection::routing_policy::captured_selected_authorizations() + ); + + plan.role_models.clear(); + plan.review_lenses = default_supervisor_review_lenses(); + let report = evaluate_supervisor_selection_preflight( + &plan, + SupervisorRuntime::Codex, + frozen_preflight_fixture(&plan)?, + )?; + assert!(report.compatible); + let decisions: Vec<_> = report + .diagnostics + .into_iter() + .map(|entry| { + Ok(SupervisorSelectionEvent { + assignment_id: None, + attempt: 0, + role: entry.role, + primary_cause: SupervisorSelectionEventCause::Initial, + provenance: entry.decision.context("selected decision")?, + }) + }) + .collect::>()?; + seal_successful_preflight_decisions(&decisions, None)?; + let authorizations = crate::selection::routing_policy::captured_selected_authorizations(); + assert_eq!(authorizations.len(), decisions.len()); + for event in decisions { + assert_eq!( + authorizations.get(event.role.as_str()), + event + .provenance + .choice + .as_ref() + .map(|choice| &choice.candidate) + ); + } + Ok(()) +} + +#[test] +fn override_mismatch_fails_closed_without_runtime_mismatch_classification() -> Result<()> { + use selection_bridge::selection_preflight::selected_choice_rejection; + let _policy = bind_fixture_routing_policy()?; + let mut plan = plan()?; + plan.role_models.clear(); + plan.review_lenses = default_supervisor_review_lenses(); + let report = evaluate_supervisor_selection_preflight( + &plan, + SupervisorRuntime::Codex, + frozen_preflight_fixture(&plan)?, + )?; + let mut decision = report + .diagnostics + .into_iter() + .find_map(|entry| entry.decision) + .context("selected decision")?; + let candidate = decision + .choice + .as_ref() + .context("choice")? + .candidate + .clone(); + decision.normalized_input.debug_override = Some(selection::DebugOverride { + candidate, + requested_by: "test".to_string(), + reason: "exact authored override".to_string(), + }); + assert_eq!(selected_choice_rejection(&decision), None); + decision.choice.as_mut().context("choice")?.candidate.model = "different-model".to_string(); + assert_eq!( + selected_choice_rejection(&decision), + Some(SelectionPreflightRejection::InvalidContract) + ); + decision + .choice + .as_mut() + .context("choice")? + .candidate + .runtime = "unexecutable-runtime".to_string(); + assert_eq!( + selected_choice_rejection(&decision), + Some(SelectionPreflightRejection::UnexecutableRuntime) + ); + decision.choice = None; + assert_eq!( + selected_choice_rejection(&decision), + Some(SelectionPreflightRejection::InvalidContract) + ); + Ok(()) +} diff --git a/tests/supervise_cli.rs b/tests/supervise_cli.rs index 057cb0c1..f26b4fe2 100644 --- a/tests/supervise_cli.rs +++ b/tests/supervise_cli.rs @@ -34,6 +34,137 @@ use tempfile::TempDir; const BIN: &str = env!("CARGO_BIN_EXE_multi-agent-coding-orchestrator"); +#[test] +fn selection_only_reports_all_override_failures_without_dispatch() -> Result<()> { + let temp = TempDir::new()?; + let repo_path = create_committed_repo(temp.path())?; + let plan_path = temp.path().join("selection-only.json"); + fs::write( + &plan_path, + serde_json::to_vec(&serde_json::json!({ + "version": 1, "task": "inspect bounded source", + "assignments": [{"id": "inspect", "phase": "execution", "role": "researcher", "role_category": "read_only_researcher", + "assigned_paths": ["README.md"], "worker_assignments": []}], + "role_models": { + "researcher": {"model": "gpt-6.1-sol", "reasoning_effort": "high"}, + "auditor": {"model": "gpt-6.1-sol", "reasoning_effort": "high"} + }, + "review_lenses": [{"id": "custom", "information_scope": "full_child_transcript", + "backend": {"kind": "model", "backend_id": "codex", "model": "gpt-6.1-sol", "reasoning_effort": "high"}}] + }))?, + )?; + let repo = Repository::open(&repo_path)?; + let index_before = fs::read(repo.path().join("index"))?; + let head_before = repo.head()?.target(); + let output = Command::new(BIN) + .current_dir(&repo_path) + .args([ + "supervise", + "selection-preflight", + path_str(&plan_path)?, + "--runtime", + "codex", + "--json", + ]) + .output()?; + assert!(!output.status.success()); + let report: Value = serde_json::from_slice(&output.stdout) + .with_context(|| String::from_utf8_lossy(&output.stderr).into_owned())?; + assert_eq!(report["compatible"], false); + let diagnostics = report["diagnostics"].as_array().context("diagnostics")?; + for role in ["researcher", "auditor"] { + assert!(diagnostics + .iter() + .any(|entry| entry["role"] == role && entry["lens_id"].is_null())); + } + assert!(diagnostics.iter().any(|entry| entry["lens_id"] == "custom")); + assert!(diagnostics + .iter() + .all(|entry| entry["status"] == "unknown" && entry["eligible_efforts"].is_null())); + // Supply the exact frozen capability/quality inputs without any live probe. + let priors = multi_agent_coding_orchestrator::selection::built_in_prior_dataset()?; + let objective = multi_agent_coding_orchestrator::objective_profile::ResolvedObjectiveProfile { + profile: multi_agent_coding_orchestrator::objective_profile::default_objective_profile() + .binding()?, + source: multi_agent_coding_orchestrator::objective_profile::ObjectiveProfileSource::BuiltIn, + }; + let mut inputs = report["frozen_inputs"].clone(); + for (role, authority) in [ + ("researcher", "terminal_leaf"), + ("auditor", "review_auditor"), + ] { + inputs["roles"][role] = serde_json::json!({ + "routing_policy": null, "switch_cost_evidence": [], + "input": { + "task": {"task_class": "review_gate", "risk": "critical", "boundedness": "bounded", + "context": "medium", "horizon": "short", "authority_role": authority}, + "catalogs": [{"runtime": "codex", "revision": "frozen-cli-fixture", "advertised_at": "2026-10-29", + "models": [{"model": "gpt-6.1-sol", "available": true, "supported_efforts": ["xhigh"], + "capabilities": {"task_classes": ["review_gate"], "authority_roles": [authority], + "boundedness": ["bounded"], "maximum_risk": "critical", "maximum_context": "long", + "maximum_horizon": "long", "long_context": true}}]}], + "pools": [{"runtime": "codex", "admission_open": true, "entitlement_capacity_units": 100, + "entitlement_remaining_units": 100, "pool_pressure_basis_points": 0, "observed_consumption_units": 0, + "marginal_cost_microunits": 0, "observation_revision": "frozen-cli-fixture", + "admission_provenance": "frozen fixture", "failover_provenance": null}], + "constraints": {"allowed_runtimes": ["codex"], "allowed_models": [], "forbidden_runtimes": [], + "forbidden_models": [], "forbidden_candidates": [], "allow_debug_override": true}, + "priors": priors, "objective_profile": {"name": priors.objective_profiles[0].name, + "version": priors.objective_profiles[0].version, "expected_digest": null}, + "resolved_objective_profile": objective, "outcomes": [], + "signals": {"retry_count": 0, "budget_signal": "continue", "previous_choice": null, + "previous_catalog_digest": null, "environment_rejections": []}, "debug_override": null + } + }); + } + let inputs_path = temp.path().join("frozen-inputs.json"); + fs::write(&inputs_path, serde_json::to_vec(&inputs)?)?; + let output = Command::new(BIN) + .current_dir(&repo_path) + .args([ + "supervise", + "selection-preflight", + path_str(&plan_path)?, + "--runtime", + "codex", + "--frozen-inputs", + path_str(&inputs_path)?, + "--json", + ]) + .output()?; + assert!(!output.status.success()); + let report: Value = serde_json::from_slice(&output.stdout) + .with_context(|| String::from_utf8_lossy(&output.stderr).into_owned())?; + let diagnostics = report["diagnostics"] + .as_array() + .context("frozen diagnostics")?; + for (role, lens) in [ + ("researcher", None), + ("auditor", None), + ("auditor", Some("custom")), + ] { + let entry = diagnostics + .iter() + .find(|entry| entry["role"] == role && entry["lens_id"].as_str() == lens) + .context("rejected triple")?; + assert_eq!(entry["status"], "rejected"); + assert_eq!(entry["rejection"], "effort_incompatible"); + assert_eq!(entry["eligible_efforts"], serde_json::json!(["xhigh"])); + assert!(entry["decision"]["input_digests"]["priors"]["value"].is_string()); + } + assert!( + !repo_path.join(".maco").exists(), + "selection-only must not create checkpoints, reservations, or dispatch artifacts" + ); + assert_eq!(fs::read(repo.path().join("index"))?, index_before); + assert_eq!(repo.head()?.target(), head_before); + assert_eq!( + fs::read_to_string(repo_path.join("README.md"))?, + "# Smoke\n" + ); + Ok(()) +} + #[test] fn field_guide_suggestion_public_contract_accepts_content_only_and_rejects_provenance() -> Result<()> { From ee7822de49a0d5ffd76f913783b85215fdb03db6 Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:34:58 +0900 Subject: [PATCH 18/27] Keep Codex turns open for bounded retry notifications Codex app-server emits `error` notifications with `willRetry: true` while it recovers a response stream. These previously ended the active turn, so a recoverable reconnect cancelled the worker and its auditors. - Validate and correlate these notices (thread and turn), cap them per turn, log bounded diagnostics, and keep waiting for the terminal event without granting progress, usage or approval. - Handle the turn-start response race with the same bounded hold. - `willRetry: false`, malformed notices and foreign IDs still fail closed exactly as before. - Add a sanitized 0.162.0 retry fixture and regressions for success with real usage, terminal failure, schema, correlation and limits. Validation: formatting, the focused tests and all 91 app-server module tests passed on Linux; Clippy and the full suites run in CI. --- src/codex_app_server.rs | 450 +++++++++++++++++- tests/fixtures/codex-0.162.0/CAPTURE.txt | 18 + .../fixtures/codex-0.162.0/retry-notice.jsonl | 1 + 3 files changed, 467 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/codex-0.162.0/retry-notice.jsonl diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index e768b3b9..9c4835d1 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -6,7 +6,7 @@ use crate::{ }; use serde_json::{json, Map, Value}; use std::{ - collections::{BTreeMap, BTreeSet}, + collections::{BTreeMap, BTreeSet, VecDeque}, fmt, io::{BufRead, BufReader, Read, Write}, sync::mpsc, @@ -31,6 +31,8 @@ const THREAD_STATUS_MAX_MESSAGES: usize = 32; const ACCOUNT_RATE_LIMIT_MAX_MESSAGES: usize = 64; const ACCOUNT_UPDATE_MAX_MESSAGES: usize = 64; const MODERATION_METADATA_MAX_MESSAGES: usize = 64; +const RETRY_NOTICE_MAX_MESSAGES: usize = 16; +const RETRY_NOTICE_MAX_BYTES: usize = 16 * 1024; const WORKER_REQUEST_TOOL: &str = "maco_worker_request"; const WORKER_REQUEST_ID_MAX_BYTES: usize = 128; const WORKER_REQUEST_RESULT_MAX_BYTES: usize = 64 * 1024; @@ -211,6 +213,119 @@ struct ThreadLifecycleNotices { started: bool, status_count: usize, pending_turn_started: Option, + pending_retry_notices: VecDeque, +} + +// The v2 ErrorNotification marks transient stream errors with willRetry=true. +// Validate original bytes before admitting that exception to terminal errors. +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct RetryNotice { + method: String, + params: RetryNoticeParams, + #[serde(default, rename = "emittedAtMs")] + emitted_at_ms: Option, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RetryNoticeParams { + error: RetryTurnError, + will_retry: bool, + thread_id: String, + turn_id: String, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RetryTurnError { + message: String, + codex_error_info: Option, + additional_details: Option, + misalignment: Option, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +enum RetryErrorInfo { + ContextWindowExceeded, + SessionBudgetExceeded, + UsageLimitExceeded, + RateLimitExceeded, + FlexUnavailable, + ServerOverloaded, + CyberPolicy, + MisalignmentPolicyViolation, + TooManyDenials, + HttpConnectionFailed { + #[serde(rename = "httpStatusCode")] + http_status_code: Option, + }, + ResponseStreamConnectionFailed { + #[serde(rename = "httpStatusCode")] + http_status_code: Option, + }, + InternalServerError, + Unauthorized, + BadRequest, + ThreadRollbackFailed, + SandboxError, + ResponseStreamDisconnected { + #[serde(rename = "httpStatusCode")] + http_status_code: Option, + }, + ResponseTooManyFailedAttempts { + #[serde(rename = "httpStatusCode")] + http_status_code: Option, + }, + ActiveTurnNotSteerable { + #[serde(rename = "turnKind")] + turn_kind: RetryTurnKind, + }, + Other, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +enum RetryTurnKind { + Review, + Compact, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RetryMisalignment { + error_type: Option, + detailed_explanation: Option, + steer: Option, + review_target: Option, +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct RetryMisalignmentSteer { + message: String, +} + +fn is_retry_notice(message: &Value) -> bool { + message.get("method").and_then(Value::as_str) == Some("error") + && message + .pointer("/params/willRetry") + .and_then(Value::as_bool) + == Some(true) +} + +fn valid_retry_notice(line: &[u8]) -> bool { + if line.len() > RETRY_NOTICE_MAX_BYTES { + return false; + } + matches!( + serde_json::from_slice::(line), + Ok(notice) if notice.method == "error" + && notice.params.will_retry + && !notice.params.thread_id.is_empty() + && !notice.params.turn_id.is_empty() + ) } // Account telemetry is asynchronous: 0.162.0 emits it while waiting for either @@ -1412,6 +1527,15 @@ impl ProtocolState { phase, message: format!("invalid JSON: {error}"), })?; + if matches!(phase, "turn/start" | "turn") + && is_retry_notice(&message) + && !valid_retry_notice(&line) + { + return Err(AppServerError::Malformed { + phase, + message: "retry notification is malformed or oversized".to_string(), + }); + } if matches!(phase, "thread/start" | "turn/start" | "turn") && message.get("method").and_then(Value::as_str) == Some("account/updated") { @@ -1945,6 +2069,25 @@ where } continue; } + if phase == "turn/start" && is_retry_notice(&message) { + let Some((thread_id, lifecycle)) = thread_started.as_mut() else { + return Err(AppServerError::Unexpected { + phase, + message: "retry notification arrived without a pending thread".to_string(), + }); + }; + require_exact_text(&message, &["params", "threadId"], thread_id, phase)?; + if lifecycle.pending_retry_notices.len() >= RETRY_NOTICE_MAX_MESSAGES { + return Err(AppServerError::Malformed { + phase, + message: "retry notifications exceeded their per-turn bound".to_string(), + }); + } + // No turn ID is acknowledged yet. Hold only bounded, schema-valid + // notices; drive_turn correlates them before logging or continuing. + lifecycle.pending_retry_notices.push_back(message); + continue; + } if phase == "turn/start" && message.get("method").and_then(Value::as_str) == Some("turn/started") { @@ -2251,10 +2394,15 @@ where let mut token_usage = None; let mut cache_write_tokens = 0; let mut reroute_message = None; + let mut retry_notice_count = 0usize; let mut budget_failure = None; let result = (|| loop { - let message = match lifecycle.pending_turn_started.take() { + let message = match lifecycle + .pending_turn_started + .take() + .or_else(|| lifecycle.pending_retry_notices.pop_front()) + { Some(message) => message, None => state.receive(transport, "turn", &|| { budget_failure.is_none() && cancelled() @@ -2262,6 +2410,7 @@ where }; // Drain observations only; no further approval, correction or steering calls. if budget_failure.is_some() + && !is_retry_notice(&message) && !matches!( message.get("method").and_then(Value::as_str), Some( @@ -3123,6 +3272,25 @@ where }); } "error" => { + if is_retry_notice(&message) { + validate_turn_correlation(params, thread_id, turn_id, "retry notification")?; + retry_notice_count += 1; + if retry_notice_count > RETRY_NOTICE_MAX_MESSAGES { + return Err(AppServerError::Malformed { + phase: "retry notification", + message: "retry notifications exceeded their per-turn bound" + .to_string(), + }); + } + // Diagnostics only: no deadline extension, progress, usage, + // approval, item evidence or terminal status is inferred. + tracing::info!( + retry_notice_count, + summary = %bounded_json_summary(¶ms["error"]), + "app-server retry notification" + ); + continue; + } return Err(AppServerError::Remote { phase: "turn", message: bounded_json_summary(&Value::Object(params.clone())), @@ -4436,6 +4604,284 @@ mod tests { const THREAD_SYSTEM_ERROR_CAPTURE: &str = include_str!("../tests/fixtures/codex-0.162.0/thread-system-error.jsonl"); + const RETRY_NOTICE_CAPTURE: &str = + include_str!("../tests/fixtures/codex-0.162.0/retry-notice.jsonl"); + + fn captured_retry_transport(notices: Vec, early: bool) -> FakeTransport { + let mut lines: Vec = CODEX_0162_AUDITOR_TURN + .lines() + .map(str::to_string) + .collect(); + let position = lines + .iter() + .position(|line| { + let message: Value = serde_json::from_str(line).unwrap(); + if early { + message["id"] == 3 + } else { + message["method"] == "item/started" + && message["params"]["item"]["type"] == "reasoning" + } + }) + .unwrap(); + lines.splice(position..position, notices); + FakeTransport { + incoming: lines + .into_iter() + .rev() + .map(|line| ReaderEvent::Line(line.into_bytes())) + .collect(), + ..FakeTransport::default() + } + } + + #[test] + fn captured_retry_notices_preserve_terminal_success_and_real_usage() { + let mut baseline_transport = captured_retry_transport(Vec::new(), false); + let baseline = run_captured_0162_transport(&mut baseline_transport).unwrap(); + assert!(baseline + .token_usage + .is_some_and(|usage| usage.input_tokens > 0)); + for early in [false, true] { + for count in [1, RETRY_NOTICE_MAX_MESSAGES] { + let notices = vec![RETRY_NOTICE_CAPTURE.trim().to_string(); count]; + let mut transport = captured_retry_transport(notices, early); + let mut outcome = run_captured_0162_transport(&mut transport).unwrap(); + assert!(transport.incoming.is_empty()); + assert_eq!(transport.sent, baseline_transport.sent); + assert_eq!( + outcome.messages_received, + baseline.messages_received + count + ); + assert_eq!( + outcome.bytes_received, + baseline.bytes_received + count * RETRY_NOTICE_CAPTURE.trim().len() + ); + outcome.messages_received = baseline.messages_received; + outcome.bytes_received = baseline.bytes_received; + // Every outcome field, including real cumulative usage and item + // evidence, comes from the original successful continuation. + assert_eq!(outcome, baseline); + } + } + } + + #[test] + fn retry_notices_do_not_hide_terminal_remote_errors() { + let mut messages = base_messages(); + messages.insert( + 2, + json!({"method":"thread/started","params":{"thread":{"id":"thread-1"}}}), + ); + messages.push(serde_json::from_str(RETRY_NOTICE_CAPTURE).unwrap()); + messages.extend(captured_0162_values(THREAD_SYSTEM_ERROR_CAPTURE)); + let mut transport = FakeTransport::from_values(messages); + let error = run_app_server_turn( + &mut transport, + &test_turn(), + AppServerLimits::default(), + &mut |_: ApprovalRequest| panic!("no approval authority"), + || false, + ) + .unwrap_err(); + assert!(matches!( + error, + AppServerError::Remote { phase: "turn", ref message } + if message.contains("serverOverloaded") && message.contains("at capacity") + )); + assert!(sent_interrupt(&transport)); + assert_eq!( + transport.incoming.len(), + 1, + "failed completion remains unread" + ); + } + + #[test] + fn retry_notice_cap_spans_turn_start_and_active_turn() { + for early in [false, true] { + let notices = + vec![RETRY_NOTICE_CAPTURE.trim().to_string(); RETRY_NOTICE_MAX_MESSAGES + 1]; + let mut transport = captured_retry_transport(notices, early); + assert!(matches!( + run_captured_0162_transport(&mut transport), + Err(AppServerError::Malformed { message, .. }) + if message.contains("per-turn bound") + )); + } + let notices = vec![RETRY_NOTICE_CAPTURE.trim().to_string(); RETRY_NOTICE_MAX_MESSAGES]; + let mut transport = captured_retry_transport(notices, true); + let position = transport + .incoming + .iter() + .position(|event| match event { + ReaderEvent::Line(line) => { + line == CODEX_0162_AUDITOR_TURN.lines().last().unwrap().as_bytes() + } + _ => false, + }) + .unwrap(); + transport.incoming.insert( + position + 1, + ReaderEvent::Line(RETRY_NOTICE_CAPTURE.trim().as_bytes().to_vec()), + ); + assert!(matches!( + run_captured_0162_transport(&mut transport), + Err(AppServerError::Malformed { + phase: "retry notification", + .. + }) + )); + } + + #[test] + fn retry_notices_require_correlated_thread_and_turn() { + for early in [false, true] { + for field in ["threadId", "turnId"] { + let mut notice: Value = serde_json::from_str(RETRY_NOTICE_CAPTURE).unwrap(); + notice["params"][field] = json!("foreign"); + let mut transport = captured_retry_transport(vec![notice.to_string()], early); + assert!(matches!( + run_captured_0162_transport(&mut transport), + Err(AppServerError::Unexpected { .. }) + )); + } + } + } + + #[test] + fn retry_notices_require_boolean_retry_and_strict_original_schema() { + let notice = RETRY_NOTICE_CAPTURE.trim(); + let mut invalid = Vec::new(); + for value in [ + json!(false), + json!(null), + json!("true"), + json!(1), + json!({}), + ] { + let mut malformed: Value = serde_json::from_str(notice).unwrap(); + malformed["params"]["willRetry"] = value; + invalid.push(malformed.to_string()); + } + invalid.push(notice.replace("\"willRetry\":true,", "")); + invalid.push(notice.replace("\"method\":", "\"id\":8,\"method\":")); + invalid.push(notice.replace("\"method\":", "\"extra\":true,\"method\":")); + invalid.push(notice.replace("\"willRetry\":", "\"extra\":true,\"willRetry\":")); + invalid.push(notice.replace("\"message\":", "\"extra\":true,\"message\":")); + invalid.push(notice.replace("\"httpStatusCode\":", "\"extra\":true,\"httpStatusCode\":")); + invalid.push(notice.replace("\"misalignment\":null", "\"misalignment\":{\"extra\":true}")); + invalid.push(notice.replace("\"message\":\"Reconnecting... 2/5\"", "\"message\":false")); + invalid.push(notice.replace( + "\"additionalDetails\":", + "\"additionalDetails\":false,\"additionalDetails\":", + )); + invalid.push(notice.replace("\"threadId\":", "\"threadId\":\"foreign\",\"threadId\":")); + invalid.push(notice.replace("\"turnId\":", "\"turnId\":\"foreign\",\"turnId\":")); + invalid.push(notice.replace("\"willRetry\":", "\"willRetry\":false,\"willRetry\":")); + invalid.push(notice.replace( + "\"httpStatusCode\":", + "\"httpStatusCode\":400,\"httpStatusCode\":", + )); + invalid.push(notice.replace("1793253072850", "\"invalid\"")); + for early in [false, true] { + for malformed in &invalid { + let mut transport = captured_retry_transport(vec![malformed.clone()], early); + assert!( + run_captured_0162_transport(&mut transport).is_err(), + "accepted {malformed}" + ); + } + } + } + + #[test] + fn retry_notices_preserve_message_aggregate_and_deadline_bounds() { + let mut padded = RETRY_NOTICE_CAPTURE.trim().as_bytes().to_vec(); + padded.resize(RETRY_NOTICE_MAX_BYTES, b' '); + assert!(valid_retry_notice(&padded)); + padded.push(b' '); + assert!(!valid_retry_notice(&padded)); + for early in [false, true] { + let mut transport = + captured_retry_transport(vec![String::from_utf8(padded.clone()).unwrap()], early); + assert!(matches!( + run_captured_0162_transport(&mut transport), + Err(AppServerError::Malformed { .. }) + )); + } + let notice: Value = serde_json::from_str(RETRY_NOTICE_CAPTURE).unwrap(); + let line_bytes = notice.to_string().len(); + for (max_line_bytes, max_total_bytes, max_messages) in [ + (line_bytes - 1, 4096, 10), + (line_bytes, line_bytes * 2 - 1, 10), + (line_bytes, 4096, 1), + ] { + let mut state = ProtocolState::new(AppServerLimits { + max_line_bytes, + max_total_bytes, + max_messages, + ..AppServerLimits::default() + }) + .unwrap(); + let deadline = state.deadline; + let mut transport = FakeTransport::from_values(vec![notice.clone(), notice.clone()]); + if max_line_bytes == line_bytes { + state.receive(&mut transport, "turn", &|| false).unwrap(); + } + assert!(state.receive(&mut transport, "turn", &|| false).is_err()); + assert_eq!(state.deadline, deadline); + } + } + + #[test] + fn retry_notice_alone_never_completes_or_supplies_usage() { + let mut messages = base_messages(); + messages.push(serde_json::from_str(RETRY_NOTICE_CAPTURE).unwrap()); + let mut transport = FakeTransport::from_values(messages); + let mut partial = None; + let error = run_app_server_turn_observed( + &mut transport, + &test_turn(), + AppServerLimits::default(), + &mut |_: ApprovalRequest| panic!("no approval authority"), + || false, + &mut partial, + &mut || panic!("no usage inferred"), + ) + .unwrap_err(); + assert!(matches!( + error, + AppServerError::ProtocolLoss { phase: "turn" } + )); + let partial = partial.unwrap(); + assert_eq!(partial.status, TurnTerminalStatus::Failed); + assert!(partial.token_usage.is_none()); + assert!(partial.final_message.is_none()); + assert_eq!(partial.completed_items, 0); + assert!(partial.item_outcomes.is_empty()); + assert!(partial.auto_reviews.is_empty()); + assert!(partial.command_execution_evidence.observations.is_empty()); + assert!(sent_interrupt(&transport)); + } + + #[test] + fn retry_notices_remain_unexpected_before_turn_start() { + for position in [0, 1] { + let mut messages = base_messages(); + messages.insert( + position, + serde_json::from_str(RETRY_NOTICE_CAPTURE).unwrap(), + ); + assert!(matches!( + run_command_observation_messages(messages), + Err(AppServerError::Unexpected { + phase: "initialize" | "thread/start", + .. + }) + )); + } + } #[test] fn captured_thread_system_error_surfaces_capacity_failure_without_success_or_usage() { diff --git a/tests/fixtures/codex-0.162.0/CAPTURE.txt b/tests/fixtures/codex-0.162.0/CAPTURE.txt index 15c34645..d6847931 100644 --- a/tests/fixtures/codex-0.162.0/CAPTURE.txt +++ b/tests/fixtures/codex-0.162.0/CAPTURE.txt @@ -115,3 +115,21 @@ These are legitimate systemError notifications, not evidence of a protocol version change. Tests supply a synthetic correlated startup. The variant without the error notification tests failed terminal handling separately; it does not claim that the live run succeeded or supplied token usage. + +2026-10-29 transient stream failure: +retry-notice.jsonl preserves the error frame supplied with the failure report +for e2e-codex-main-4219-c29d, captured with 0.162.0 after turn/started and the +user message item. Only thread/turn IDs are replaced; timestamp, error fields +and willRetry=true are unchanged. Regression tests splice this original frame +into the full successful auditor capture (including its real token usage), or +before the capacity-failure suffix. These are constructed continuations, not +evidence that the failed production run recovered or completed successfully. + +Protocol sources for rust-v0.162.0: +https://github.com/openai/codex/blob/rust-v0.162.0/codex-rs/app-server-protocol/src/protocol/v2/notification.rs +https://github.com/openai/codex/blob/rust-v0.162.0/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs +https://github.com/openai/codex/blob/rust-v0.162.0/codex-rs/app-server-protocol/src/protocol/v2/shared.rs +https://github.com/openai/codex/blob/rust-v0.162.0/codex-rs/app-server/src/bespoke_event_handling.rs +ErrorNotification has error: TurnError, will_retry: bool, thread_id and turn_id. +The StreamError handler emits will_retry=true without recording last_error; +the terminal error handler records last_error and emits will_retry=false. diff --git a/tests/fixtures/codex-0.162.0/retry-notice.jsonl b/tests/fixtures/codex-0.162.0/retry-notice.jsonl new file mode 100644 index 00000000..053f2580 --- /dev/null +++ b/tests/fixtures/codex-0.162.0/retry-notice.jsonl @@ -0,0 +1 @@ +{"method":"error","params":{"error":{"message":"Reconnecting... 2/5","codexErrorInfo":{"responseStreamDisconnected":{"httpStatusCode":null}},"additionalDetails":"workspace routing discovery unauthorized (401)","misalignment":null},"willRetry":true,"threadId":"thread-1","turnId":"turn-1"},"emittedAtMs":1793253072850} From dc3650ec6c8bdc3fe5475a69403f985cf7bda4ad Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:35:10 +0900 Subject: [PATCH 19/27] Bind authored checks to scheduler pre-claim admission Stacked on #758 (authored check declarations and receipts); GitHub retargets this PR to main once #758 merges. Authored nested plans can now use fixed independent Python checks to establish a deterministic verification path without recognized test names or static import edges. - Production preparation acquires immutable receipts, and scheduler admission revalidates the frozen declarations, repository snapshot, file and interpreter identities and fresh risk before any claim or dispatch. - Malformed or changed declarations and stale evidence fail closed; existing verification contracts keep their behavior. - Real-Git regressions cover two dynamic-import checks and serial and concurrent tampering refusals. Validation: formatting and 11 exact tests (42 tamper cases) passed on Linux; Clippy and the full suites run in CI. --- src/supervise/scheduler.rs | 91 ++-- .../scheduler/authored_check_admission.rs | 121 ++++++ .../scheduler/authored_check_tests.rs | 392 ++++++++++++++++++ .../scheduler/decomposition_tests.rs | 4 + src/supervise/scheduler/preclaim.rs | 169 +++++++- 5 files changed, 746 insertions(+), 31 deletions(-) create mode 100644 src/supervise/scheduler/authored_check_admission.rs create mode 100644 src/supervise/scheduler/authored_check_tests.rs diff --git a/src/supervise/scheduler.rs b/src/supervise/scheduler.rs index 577b64cd..5a46bb28 100644 --- a/src/supervise/scheduler.rs +++ b/src/supervise/scheduler.rs @@ -17,13 +17,14 @@ use coding_agent_manager_lib::paths::{project_dirs, stored_accounts_path}; #[cfg(target_os = "linux")] use coding_agent_manager_lib::providers::observe_selected_account; +mod authored_check_admission; mod preclaim; pub(super) use preclaim::assignment_requires_file_change; #[cfg(test)] use preclaim::evaluate_preclaim_viability; use preclaim::{ - evaluate_preclaim_with_metadata, parked_preclaim_outcome, persist_preclaim_decision, - preclaim_assignment, PreclaimDecision, PreclaimRunEvidence, + parked_preclaim_outcome, persist_preclaim_decision, preclaim_assignment, PreclaimDecision, + PreclaimRunEvidence, }; /// Admission policy for concurrently runnable supervisor children. @@ -443,6 +444,7 @@ struct AssignmentSchedulerContext<'context, 'writer> { struct SchedulerProgress { indexed_outcomes: Vec>, prepared_preclaim_decisions: Option>, + prepared_preclaim_evidence: Option, health_breaker: SwarmHealthCircuitBreaker, budget_prevented_dispatch: bool, budget_denied_assignment_indices: BTreeSet, @@ -472,6 +474,7 @@ impl SchedulerProgress { Ok(Self { indexed_outcomes: (0..assignment_count).map(|_| None).collect(), prepared_preclaim_decisions: None, + prepared_preclaim_evidence: None, health_breaker: SwarmHealthCircuitBreaker::default(), budget_prevented_dispatch: false, budget_denied_assignment_indices: BTreeSet::new(), @@ -1683,6 +1686,25 @@ fn schedule_preclaim_decision( index: usize, ) -> Result { if let Some(decision) = progress.prepared_preclaim_decision(index)? { + if decision.assignment_id != context.plan.assignments[index].id { + bail!("prepared pre-claim decision assignment binding changed"); + } + if decision.allows_path_claim() { + if let Some(evidence) = progress.prepared_preclaim_evidence.as_ref() { + evidence.revalidate_authored( + context.repo, + &context.plan.assignments[index], + &context.requested_plan.assignments, + context.assignment_metadata, + )?; + } else if context + .assignment_metadata + .authored_independent_checks + .contains_key(&context.plan.assignments[index].id) + { + bail!("authored check receipts were not retained for scheduler admission"); + } + } return Ok(decision); } let evidence = fallback_evidence.context( @@ -3555,29 +3577,43 @@ fn initialize_scheduler_evidence( fn evaluate_supervisor_preclaims( plan: &SupervisorPlan, requested_plan: &SupervisorPlan, - repo: &Path, - assignment_metadata: &AssignmentMetadata, - runtime: SupervisorRuntime, - execution_runtime: SupervisorExecutionRuntime, + evidence: &PreclaimRunEvidence, ) -> Vec { - let evidence = supervisor_preclaim_run_evidence(repo, runtime, execution_runtime); plan.assignments .iter() - .map(|assignment| { - let risk = evidence.risk_for(&assignment.assigned_paths); - evaluate_preclaim_with_metadata( - assignment, - &requested_plan.assignments, - evidence.repo_map.as_ref(), - risk.as_ref(), - evidence.runtime, - execution_runtime, - assignment_metadata, - ) - }) + .map(|assignment| evidence.evaluate(assignment, &requested_plan.assignments)) .collect() } +fn acquire_supervisor_preclaim_evidence( + repo: &Path, + runtime: SupervisorRuntime, + execution_runtime: SupervisorExecutionRuntime, + requested_plan: &SupervisorPlan, + consultant: &SupervisorConsultantPlan, + assignment_metadata: &AssignmentMetadata, + plan_metadata: &SupervisorPlanMetadata, +) -> Result { + let mut evidence = supervisor_preclaim_run_evidence(repo, runtime, execution_runtime) + .with_assignment_metadata(assignment_metadata); + if !assignment_metadata.authored_independent_checks.is_empty() { + let document = super::plan_api::supervisor_plan_value( + requested_plan, + consultant, + assignment_metadata, + plan_metadata, + )? + .to_string(); + evidence = evidence.with_authored_checks( + repo, + document, + &requested_plan.assignments, + assignment_metadata, + ); + } + Ok(evidence) +} + fn supervisor_preclaim_run_evidence( repo: &Path, runtime: SupervisorRuntime, @@ -3717,6 +3753,7 @@ struct PreparedSupervisorRun { selection_preflight_failure: Option, automatic_selection_state: Option, preclaim_decisions: Vec, + preclaim_evidence: PreclaimRunEvidence, budget_ledger: RunBudgetLedger, runtime: SupervisorRuntime, repo: PathBuf, @@ -3865,14 +3902,17 @@ fn prepare_supervisor_run( // later preparation failure still leaves durable gate evidence. let preclaim_runtime = preclaim_assessment_runtime(runtime, execution_runtime, worktree_creation); - let preclaim_decisions = evaluate_supervisor_preclaims( - &plan, - &requested_plan, + let preclaim_evidence = acquire_supervisor_preclaim_evidence( &repo, - &assignment_metadata, runtime, preclaim_runtime, - ); + &requested_plan, + &consultant, + &assignment_metadata, + &plan_metadata, + )?; + let preclaim_decisions = + evaluate_supervisor_preclaims(&plan, &requested_plan, &preclaim_evidence); persist_prepared_preclaim_decisions( &repo, &options.run_id, @@ -4017,6 +4057,7 @@ fn prepare_supervisor_run( selection_preflight_failure: selection.selection_preflight_failure, automatic_selection_state: selection.automatic_state, preclaim_decisions, + preclaim_evidence, budget_ledger, runtime, repo, @@ -7037,6 +7078,7 @@ pub(super) fn run_supervisor_plan_with_runner_and_creation( selection_preflight_failure, automatic_selection_state, preclaim_decisions, + preclaim_evidence, budget_ledger, runtime, repo, @@ -7223,6 +7265,7 @@ pub(super) fn run_supervisor_plan_with_runner_and_creation( .initial_selector_decisions = collected.selection_decisions.clone(); progress.budget_degradation.policy.this_run_run_dir = Some(run_dir.clone()); progress.install_preclaim_decisions(preclaim_decisions)?; + progress.prepared_preclaim_evidence = Some(preclaim_evidence); let scheduler_context = AssignmentSchedulerContext { plan: &plan, requested_plan: &requested_plan, diff --git a/src/supervise/scheduler/authored_check_admission.rs b/src/supervise/scheduler/authored_check_admission.rs new file mode 100644 index 00000000..f2700fdc --- /dev/null +++ b/src/supervise/scheduler/authored_check_admission.rs @@ -0,0 +1,121 @@ +//! Scheduler-only admission backed by freshly revalidated authored-check receipts. + +use super::*; +use crate::repo_semantic::SemanticRiskReport; +use anyhow::ensure; + +pub(super) struct AcquiredAuthoredChecks { + document: String, + requested_assignments: Vec, + metadata: AssignmentMetadata, + receipts: BTreeMap< + String, + std::result::Result, + >, +} + +/// This value is constructed only after receipt revalidation, never from metadata. +pub(super) struct AuthoredCheckAdmission<'a> { + assignment: &'a OrchestratorAssignment, +} + +impl AuthoredCheckAdmission<'_> { + pub(super) fn matches(&self, assignment: &OrchestratorAssignment) -> bool { + self.assignment == assignment + } +} + +impl AcquiredAuthoredChecks { + pub(super) fn acquire( + repo: &Path, + document: String, + requested_assignments: &[OrchestratorAssignment], + metadata: &AssignmentMetadata, + evidence: &PreclaimRunEvidence, + ) -> Self { + let receipts = metadata + .authored_independent_checks + .keys() + .map(|parent_id| { + let acquired = (|| -> Result<_> { + let loaded = + super::super::plan_api::parse_supervisor_plan_with_consultant_in_repo( + &document, + Some(repo), + )?; + ensure!( + loaded.plan.assignments == requested_assignments + && &loaded.assignment_metadata == metadata, + "authored check declarations differ from the frozen plan document" + ); + let parent = requested_assignments + .iter() + .find(|item| &item.id == parent_id) + .context("authored check parent is missing")?; + let risk = evidence + .risk_for(&parent.assigned_paths) + .context("authored checks require acquired semantic risk")?; + let fresh = crate::repo_semantic::scan_repository(repo)?; + ensure!( + risk == crate::repo_semantic::risk_report_for_paths( + &fresh, + parent.assigned_paths.iter() + ), + "authored check semantic risk changed during acquisition" + ); + authored_checks::acquire(repo, &document, parent_id, &risk) + })(); + ( + parent_id.clone(), + acquired.map_err(|error| format!("{error:#}")), + ) + }) + .collect(); + Self { + document, + requested_assignments: requested_assignments.to_vec(), + metadata: metadata.clone(), + receipts, + } + } + + pub(super) fn declares(&self, id: &str) -> bool { + self.receipts.contains_key(id) + } + + pub(super) fn admission<'a>( + &self, + repo: &Path, + assignment: &'a OrchestratorAssignment, + requested_assignments: &[OrchestratorAssignment], + metadata: &AssignmentMetadata, + risk: Option<&SemanticRiskReport>, + ) -> Result> { + ensure!( + requested_assignments == self.requested_assignments && metadata == &self.metadata, + "authored check requested plan or metadata changed" + ); + let requested = self + .requested_assignments + .iter() + .find(|item| item.id == assignment.id) + .context("authored check assignment is unrelated")?; + // Selection may resolve a runtime after the gate. Every authored task, + // nested Worker, scope and other assignment field must remain frozen. + let mut current = assignment.clone(); + current.runtime = requested.runtime; + ensure!(¤t == requested, "authored check assignment changed"); + let receipt = self + .receipts + .get(&assignment.id) + .context("authored check receipt is missing")? + .as_ref() + .map_err(|error| anyhow!("{error}"))?; + receipt.revalidate( + repo, + &self.document, + risk.context("authored check fresh semantic risk is missing")?, + )?; + Ok(AuthoredCheckAdmission { assignment }) + } +} diff --git a/src/supervise/scheduler/authored_check_tests.rs b/src/supervise/scheduler/authored_check_tests.rs new file mode 100644 index 00000000..7ddef6d3 --- /dev/null +++ b/src/supervise/scheduler/authored_check_tests.rs @@ -0,0 +1,392 @@ +use super::*; +use std::os::unix::fs::{symlink, PermissionsExt}; + +fn commit_snapshot(root: &Path) -> Result<()> { + let repo = Repository::open(root)?; + let mut index = repo.index()?; + index.add_all(["*"], git2::IndexAddOption::DEFAULT, None)?; + index.write()?; + let tree = repo.find_tree(index.write_tree()?)?; + let parent = repo.head()?.peel_to_commit()?; + let signature = Signature::now("Check tests", "checks@example.invalid")?; + repo.commit( + Some("HEAD"), + &signature, + &signature, + "Checks", + &tree, + &[&parent], + )?; + Ok(()) +} + +fn fixture(root: &Path) -> Result { + fs::write(root.join(".gitignore"), ".maco/\n")?; + fs::create_dir(root.join("src"))?; + fs::create_dir(root.join("checks"))?; + let python = ["/usr/bin/python3", "/bin/python3"] + .into_iter() + .find(|path| Path::new(path).is_file()) + .context("Python 3 is missing")?; + let mut workers = Vec::new(); + let mut checks = Vec::new(); + for subject in ["identifier", "duration"] { + let source = format!("src/{subject}.py"); + let check = format!("checks/check_{subject}.py"); + fs::write(root.join(&source), "value = 1\n")?; + fs::write(root.join(&check), format!( + "import importlib.util\nspec = importlib.util.spec_from_file_location('subject', '{source}')\nsubject = importlib.util.module_from_spec(spec)\nspec.loader.exec_module(subject)\nassert subject.value == 1\n" + ))?; + workers.push( + json!({"id":subject, "assigned_paths":[source], "task":"Repair the declared source"}), + ); + checks.push( + json!({"worker_id":subject, "assigned_paths":[source], "check_path":check, + "argv":[python, "-I", "-B", check]}), + ); + } + commit_snapshot(root)?; + super::super::super::plan_api::parse_supervisor_plan_with_consultant_in_repo( + &json!({"task":"Repair identifier and duration", "assignments":[{ + "id":"parent", "phase":"execution", "assigned_paths":["src/identifier.py", "src/duration.py"], + "task":"Repair identifier and duration within the declared source files", + "worker_assignments":workers, "authored_independent_checks":checks + }]}).to_string(), Some(root), + ) +} + +fn acquire_evidence(root: &Path, loaded: &LoadedSupervisorPlan) -> Result { + acquire_supervisor_preclaim_evidence( + root, + SupervisorRuntime::Codex, + SupervisorExecutionRuntime::Verified, + &loaded.plan, + &loaded.consultant, + &loaded.assignment_metadata, + &loaded.plan_metadata, + ) +} + +#[test] +fn dynamic_import_checks_bind_two_workers_before_claim() -> Result<()> { + with_valid_schedule_context!(context, vec![test_assignment("parent", "README.md")], 1, { + let mut loaded = fixture(context.repo)?; + let (assessment, frozen) = assessed_requested_preclaim_routing(&loaded.plan)?; + let _routing_policy = + crate::selection::routing_policy::bind_routing_policy(Some(frozen.clone()))?; + loaded.plan_metadata.task_routing_assessment = Some(assessment); + loaded.plan_metadata.resolved_routing_policy = Some(frozen); + let catalog = test_runtime_model_catalog(&loaded.plan, SupervisorRuntime::Codex)?; + #[cfg(target_os = "linux")] + let cam_temp = tempfile::tempdir()?; + #[cfg(target_os = "linux")] + let _cam = install_positive_injected_cam_fixture(&cam_temp, &catalog)?; + let mut options = test_options(context.repo, "authored-preparation"); + options.runtime = SupervisorRuntime::Codex; + let prepared = prepare_supervisor_run( + loaded.clone(), + &options, + 1, + SupervisorExecutionRuntime::Verified, + SupervisorWorktreeCreation::VerifiedTestOnly, + Ok(catalog), + )?; + assert!( + prepared.preclaim_decisions[0].allows_path_claim(), + "{}", + prepared.preclaim_decisions[0].reason + ); + assert!(!prepared.selection_decisions.is_empty()); + let evidence = prepared.preclaim_evidence; + let assignment = &loaded.plan.assignments[0]; + let risk = evidence + .risk_for(&assignment.assigned_paths) + .context("risk missing")?; + assert!(risk.dependency_impacts.is_empty()); + let without_receipt = preclaim::evaluate_preclaim_viability( + assignment, + &loaded.plan.assignments, + evidence.repo_map.as_ref(), + Some(&risk), + Some(SupervisorRuntime::Codex), + SupervisorExecutionRuntime::Verified, + ); + assert_eq!( + without_receipt.dimensions.clear_verification_path, + preclaim::ViabilityFinding::Unknown + ); + let decisions = evaluate_supervisor_preclaims(&loaded.plan, &loaded.plan, &evidence); + assert!(decisions[0].allows_path_claim(), "{}", decisions[0].reason); + assert_eq!( + decisions[0].evidence_source, + preclaim::PreclaimEvidenceSource::Acquired + ); + assert_eq!( + decisions[0].dimensions.clear_verification_path, + preclaim::ViabilityFinding::Yes + ); + let context = AssignmentSchedulerContext { + plan: &loaded.plan, + requested_plan: &loaded.plan, + assignment_metadata: &loaded.assignment_metadata, + execution_runtime: SupervisorExecutionRuntime::Verified, + worktree_creation: SupervisorWorktreeCreation::VerifiedTestOnly, + ..context + }; + let mut progress = SchedulerProgress::new(1, 1); + progress.install_preclaim_decisions(decisions)?; + progress.prepared_preclaim_evidence = Some(evidence); + assert!(schedule_preclaim_decision(&context, &progress, None, 0)?.allows_path_claim()); + for check in &loaded.assignment_metadata.authored_independent_checks["parent"] { + let status = std::process::Command::new(&check.argv[0]) + .args(&check.argv[1..]) + .current_dir(context.repo) + .status()?; + assert!(status.success()); + } + assert!(context.sync_store.snapshot()?.is_empty()); + assert!(context.manager.list()?.is_empty()); + assert_eq!(context.budget_ledger.report()?.reserved.tokens, 0); + assert_eq!(context.budget_ledger.report()?.consumed.tokens, 0); + }); + Ok(()) +} + +#[test] +fn tampered_check_or_plan_refuses_before_claim() -> Result<()> { + for max_children in [1, 2] { + for case in 0..21 { + with_valid_schedule_context!( + context, + vec![test_assignment("parent", "README.md")], + max_children, + { + let mut loaded = fixture(context.repo)?; + let runtime = tempfile::tempdir()?; + let interpreter = runtime.path().join("python3"); + if matches!(case, 7 | 8) { + let source = fs::canonicalize( + &loaded.assignment_metadata.authored_independent_checks["parent"][0] + .argv[0], + )?; + fs::copy(source, &interpreter)?; + fs::set_permissions(&interpreter, fs::Permissions::from_mode(0o755))?; + for check in loaded + .assignment_metadata + .authored_independent_checks + .get_mut("parent") + .unwrap() + { + check.argv[0] = interpreter.to_str().unwrap().to_string(); + } + } + let evidence = acquire_evidence(context.repo, &loaded)?; + let decisions = + evaluate_supervisor_preclaims(&loaded.plan, &loaded.plan, &evidence); + assert!( + decisions[0].allows_path_claim(), + "case {case}: {}", + decisions[0].reason + ); + let mut current = loaded.plan.clone(); + let mut requested = loaded.plan.clone(); + let mut metadata = loaded.assignment_metadata.clone(); + let check = context.repo.join("checks/check_identifier.py"); + match case { + 0 => fs::write(&check, "assert False\n")?, + 1 => { + fs::remove_file(&check)?; + symlink("../src/identifier.py", &check)?; + } + 2 => { + fs::remove_file(&check)?; + fs::hard_link(context.repo.join("src/identifier.py"), &check)?; + } + 3 => fs::remove_file(&check)?, + 4 => fs::write(&check, "")?, + 5 => fs::write(context.repo.join("unrelated.txt"), "snapshot drift\n")?, + 6 => { + fs::write(context.repo.join("README.md"), "new snapshot\n")?; + commit_snapshot(context.repo)?; + } + 7 => fs::set_permissions(&interpreter, fs::Permissions::from_mode(0o600))?, + 8 => { + let bytes = fs::read(&interpreter)?; + fs::remove_file(&interpreter)?; + fs::write(&interpreter, bytes)?; + fs::set_permissions(&interpreter, fs::Permissions::from_mode(0o755))?; + } + 9 => { + metadata + .authored_independent_checks + .get_mut("parent") + .unwrap()[0] + .argv[3] = "checks/check_duration.py".to_string() + } + 10 => { + metadata + .authored_independent_checks + .get_mut("parent") + .unwrap()[0] + .check_path = "checks/check_duration.py".to_string() + } + 11 => { + metadata + .authored_independent_checks + .get_mut("parent") + .unwrap()[0] + .worker_id = "unrelated".to_string() + } + 12 => { + metadata + .authored_independent_checks + .get_mut("parent") + .unwrap()[0] + .assigned_paths = vec!["README.md".to_string()] + } + 13 => metadata.authored_independent_checks.clear(), + 14 => { + current.assignments[0].worker_assignments[0].assigned_paths = + vec!["README.md".into()] + } + 15 => current.assignments[0] + .assigned_paths + .push("README.md".into()), + 16 => current.assignments[0].task = Some("unrelated task".to_string()), + 17 => { + requested.assignments[0].task = Some("changed frozen plan".to_string()) + } + 18 => current.assignments[0].id = "unrelated".to_string(), + 19 => { + fs::write(context.repo.join("src/identifier.py"), "value = 2\n")?; + commit_snapshot(context.repo)?; + } + 20 => fs::set_permissions(&check, fs::Permissions::from_mode(0o000))?, + _ => unreachable!(), + } + let context = AssignmentSchedulerContext { + plan: ¤t, + requested_plan: &requested, + assignment_metadata: &metadata, + execution_runtime: SupervisorExecutionRuntime::Verified, + worktree_creation: SupervisorWorktreeCreation::VerifiedTestOnly, + ..context + }; + let mut progress = SchedulerProgress::new(1, max_children); + progress.install_preclaim_decisions(decisions)?; + progress.prepared_preclaim_evidence = Some(evidence); + let cancellation = ProcessCancellation::new(); + let result = if max_children == 1 { + run_serial_assignment_schedule( + &context, + &mut progress, + &cancellation, + &Mutex::new(Vec::new()), + ) + } else { + run_concurrent_assignment_schedule( + &context, + &mut progress, + &cancellation, + &SemanticBlockGate::default(), + ) + }; + assert!( + result.is_err(), + "case {case}, concurrency {max_children} admitted drift" + ); + assert_eq!(progress.concurrency.finish().started_assignment_count, 0); + assert!(context.sync_store.snapshot()?.is_empty()); + assert!(context.manager.list()?.is_empty()); + assert_eq!(context.budget_ledger.report()?.reserved.tokens, 0); + assert_eq!(context.budget_ledger.report()?.consumed.tokens, 0); + assert!(!context + .run_dir + .join("assignments/parent.prompt.md") + .exists()); + } + ); + } + } + Ok(()) +} + +#[test] +fn authored_checks_require_fresh_risk_and_acquired_metadata() -> Result<()> { + with_valid_schedule_context!(context, vec![test_assignment("parent", "README.md")], 1, { + let loaded = fixture(context.repo)?; + let mut stale_map = crate::repo_semantic::scan_repository(context.repo)?; + stale_map.files.clear(); + let stale = PreclaimRunEvidence::verified_for_test( + crate::repo_map::scan_repository(context.repo)?, + stale_map, + SupervisorRuntime::Codex, + ); + let document = super::super::super::plan_api::supervisor_plan_value( + &loaded.plan, + &loaded.consultant, + &loaded.assignment_metadata, + &loaded.plan_metadata, + )? + .to_string(); + let stale = stale.with_authored_checks( + context.repo, + document.clone(), + &loaded.plan.assignments, + &loaded.assignment_metadata, + ); + let decision = stale.evaluate(&loaded.plan.assignments[0], &loaded.plan.assignments); + assert!(!decision.allows_path_claim()); + assert!(decision.reason.contains("semantic risk changed")); + + let restored = + super::super::super::plan_api::parse_supervisor_plan_with_consultant_in_repo( + &document, + Some(context.repo), + )?; + let declarations_only = PreclaimRunEvidence::acquire( + context.repo, + SupervisorRuntime::Codex, + SupervisorExecutionRuntime::Verified, + ) + .with_assignment_metadata(&restored.assignment_metadata); + assert!(!declarations_only + .evaluate(&restored.plan.assignments[0], &restored.plan.assignments) + .allows_path_claim()); + for case in 0..5 { + let mut changed = restored.assignment_metadata.clone(); + let check = &mut changed + .authored_independent_checks + .get_mut("parent") + .unwrap()[0]; + match case { + 0 => check.argv = vec!["/bin/sh".to_string(), "-c".to_string(), "true".to_string()], + 1 => check.assigned_paths = vec!["src/duration.py".to_string()], + 2 => check.worker_id = "unrelated".to_string(), + 3 => check.check_path = "src/identifier.py".to_string(), + 4 => check.argv[0] = "/missing/python3".to_string(), + _ => unreachable!(), + } + let evidence = PreclaimRunEvidence::acquire( + context.repo, + SupervisorRuntime::Codex, + SupervisorExecutionRuntime::Verified, + ) + .with_authored_checks( + context.repo, + document.clone(), + &restored.plan.assignments, + &changed, + ); + assert!( + !evidence + .evaluate(&restored.plan.assignments[0], &restored.plan.assignments) + .allows_path_claim(), + "metadata case {case}" + ); + } + assert!(context.sync_store.snapshot()?.is_empty()); + assert_eq!(context.budget_ledger.report()?.reserved.tokens, 0); + }); + Ok(()) +} diff --git a/src/supervise/scheduler/decomposition_tests.rs b/src/supervise/scheduler/decomposition_tests.rs index 8d25ae4f..dfed968b 100644 --- a/src/supervise/scheduler/decomposition_tests.rs +++ b/src/supervise/scheduler/decomposition_tests.rs @@ -4420,3 +4420,7 @@ fn finalize_does_not_coerce_environment_none_to_zero() { "environment None must not become a zero bucket in the average" ); } + +#[cfg(unix)] +#[path = "authored_check_tests.rs"] +mod authored_check_tests; diff --git a/src/supervise/scheduler/preclaim.rs b/src/supervise/scheduler/preclaim.rs index 1254fea1..a1e915cf 100644 --- a/src/supervise/scheduler/preclaim.rs +++ b/src/supervise/scheduler/preclaim.rs @@ -237,6 +237,8 @@ pub(super) struct PreclaimRunEvidence { pub(super) runtime: Option, execution_runtime: SupervisorExecutionRuntime, assignment_metadata: AssignmentMetadata, + authored_checks: Option, + repository: Option, } impl PreclaimRunEvidence { @@ -252,6 +254,8 @@ impl PreclaimRunEvidence { runtime: Some(runtime), execution_runtime, assignment_metadata: AssignmentMetadata::default(), + authored_checks: None, + repository: Some(repo.to_path_buf()), }; } Self { @@ -260,6 +264,8 @@ impl PreclaimRunEvidence { runtime: Some(runtime), execution_runtime, assignment_metadata: AssignmentMetadata::default(), + authored_checks: None, + repository: Some(repo.to_path_buf()), } } @@ -275,6 +281,8 @@ impl PreclaimRunEvidence { runtime: Some(runtime), execution_runtime: SupervisorExecutionRuntime::Verified, assignment_metadata: AssignmentMetadata::default(), + authored_checks: None, + repository: None, } } @@ -286,6 +294,8 @@ impl PreclaimRunEvidence { runtime: None, execution_runtime: SupervisorExecutionRuntime::Verified, assignment_metadata: AssignmentMetadata::default(), + authored_checks: None, + repository: None, } } @@ -294,13 +304,98 @@ impl PreclaimRunEvidence { self } + pub(super) fn with_authored_checks( + mut self, + repo: &Path, + document: String, + requested_assignments: &[OrchestratorAssignment], + metadata: &AssignmentMetadata, + ) -> Self { + self.authored_checks = Some( + super::authored_check_admission::AcquiredAuthoredChecks::acquire( + repo, + document, + requested_assignments, + metadata, + &self, + ), + ); + self.repository = Some(repo.to_path_buf()); + self.assignment_metadata = metadata.clone(); + self + } + + pub(super) fn has_authored_checks(&self, id: &str) -> bool { + self.assignment_metadata + .authored_independent_checks + .contains_key(id) + || self + .authored_checks + .as_ref() + .is_some_and(|checks| checks.declares(id)) + } + + pub(super) fn revalidate_authored( + &self, + repo: &Path, + assignment: &OrchestratorAssignment, + requested_assignments: &[OrchestratorAssignment], + metadata: &AssignmentMetadata, + ) -> Result<()> { + if !self.has_authored_checks(&assignment.id) + && !metadata + .authored_independent_checks + .contains_key(&assignment.id) + { + return Ok(()); + } + let map = crate::repo_semantic::scan_repository(repo)?; + let risk = risk_report_for_paths(&map, assignment.assigned_paths.iter()); + self.authored_checks + .as_ref() + .context("authored check receipts were not acquired")? + .admission( + repo, + assignment, + requested_assignments, + metadata, + Some(&risk), + )?; + Ok(()) + } + pub(super) fn evaluate( &self, assignment: &OrchestratorAssignment, requested_assignments: &[OrchestratorAssignment], ) -> PreclaimDecision { let risk = self.risk_for(&assignment.assigned_paths); - evaluate_preclaim_with_metadata( + if !self.has_authored_checks(&assignment.id) { + return evaluate_preclaim_with_metadata( + assignment, + requested_assignments, + self.repo_map.as_ref(), + risk.as_ref(), + self.runtime, + self.execution_runtime, + &self.assignment_metadata, + ); + } + let admission = (|| { + self.authored_checks + .as_ref() + .context("authored check receipts were not acquired")? + .admission( + self.repository + .as_deref() + .context("authored check repository is missing")?, + assignment, + requested_assignments, + &self.assignment_metadata, + risk.as_ref(), + ) + })(); + let mut decision = evaluate_preclaim_with_admission( assignment, requested_assignments, self.repo_map.as_ref(), @@ -308,7 +403,12 @@ impl PreclaimRunEvidence { self.runtime, self.execution_runtime, &self.assignment_metadata, - ) + admission.as_ref().ok(), + ); + if let Err(error) = admission { + refuse_authored_checks(&mut decision, &error); + } + decision } pub(super) fn risk_for(&self, paths: &[PathBuf]) -> Option { @@ -340,6 +440,8 @@ trait PreclaimAssessmentProvider { struct DeterministicPreclaimProvider<'a> { assignment_metadata: &'a AssignmentMetadata, runtime: Option, + authored_check_admission: + Option<&'a super::authored_check_admission::AuthoredCheckAdmission<'a>>, } struct SyntheticSimulationPreclaimProvider; @@ -399,6 +501,7 @@ impl PreclaimAssessmentProvider for DeterministicPreclaimProvider<'_> { requested_assignments, repo_map, risk_report, + self.authored_check_admission, ) }; let autonomously_completable = deterministic_autonomous_completion(assignment); @@ -541,7 +644,11 @@ fn deterministic_verification_path( requested_assignments: &[OrchestratorAssignment], repo_map: Option<&RepoMap>, risk_report: Option<&SemanticRiskReport>, + authored_check_admission: Option<&super::authored_check_admission::AuthoredCheckAdmission<'_>>, ) -> ViabilityFinding { + if authored_check_admission.is_some_and(|admission| admission.matches(assignment)) { + return ViabilityFinding::Yes; + } // Production callers supply the already validated requested plan. Require // the typed declaration to remain uniquely and exactly bound to that plan; // current assignment input alone cannot manufacture this verification path. @@ -1918,6 +2025,50 @@ pub(super) fn evaluate_preclaim_with_metadata( runtime: Option, execution_runtime: SupervisorExecutionRuntime, assignment_metadata: &AssignmentMetadata, +) -> PreclaimDecision { + let mut decision = evaluate_preclaim_with_admission( + assignment, + requested_assignments, + repo_map, + risk_report, + runtime, + execution_runtime, + assignment_metadata, + None, + ); + if assignment_metadata + .authored_independent_checks + .contains_key(&assignment.id) + { + refuse_authored_checks( + &mut decision, + &anyhow!("authored check receipts were not acquired"), + ); + } + decision +} + +fn refuse_authored_checks(decision: &mut PreclaimDecision, error: &anyhow::Error) { + decision.disposition = PreclaimDisposition::Park; + decision.triage_outcome = PreclaimTriageOutcome::Rejected; + decision.rejection_bucket = Some(PreclaimRejectionBucket::Invalid); + decision.confidence = PreclaimConfidence::High; + decision.dimensions.clear_verification_path = ViabilityFinding::No; + decision.authority = PreclaimDecisionAuthority::DeterministicPolicy; + decision.operator_override = None; + decision.reason = format!("authored independent checks refused before path claim: {error:#}"); +} + +#[allow(clippy::too_many_arguments)] +fn evaluate_preclaim_with_admission( + assignment: &OrchestratorAssignment, + requested_assignments: &[OrchestratorAssignment], + repo_map: Option<&RepoMap>, + risk_report: Option<&SemanticRiskReport>, + runtime: Option, + execution_runtime: SupervisorExecutionRuntime, + assignment_metadata: &AssignmentMetadata, + authored_check_admission: Option<&super::authored_check_admission::AuthoredCheckAdmission<'_>>, ) -> PreclaimDecision { match execution_runtime { SupervisorExecutionRuntime::NonpublishableSimulation => evaluate_with_provider( @@ -1939,6 +2090,7 @@ pub(super) fn evaluate_preclaim_with_metadata( &DeterministicPreclaimProvider { assignment_metadata, runtime, + authored_check_admission, }, ), } @@ -2362,14 +2514,17 @@ mod tests { fn researcher_source_preclaim_initial_fallback_and_recheck_are_verified() { let (_temp, map, loaded) = researcher_source_plan(); let assignment = &loaded.plan.assignments[0]; - let initial = evaluate_supervisor_preclaims( - &loaded.plan, - &loaded.plan, + let initial_evidence = acquire_supervisor_preclaim_evidence( &map.root, - &loaded.assignment_metadata, SupervisorRuntime::Codex, SupervisorExecutionRuntime::Verified, - ); + &loaded.plan, + &loaded.consultant, + &loaded.assignment_metadata, + &loaded.plan_metadata, + ) + .expect("acquire initial researcher pre-claim evidence"); + let initial = evaluate_supervisor_preclaims(&loaded.plan, &loaded.plan, &initial_evidence); assert_eq!(initial.len(), 1); assert!(initial[0].allows_path_claim(), "{}", initial[0].reason); assert!(initial[0].dimensions.all_positive()); From 8aba072009980d13618b7d19695c24447d8ada55 Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:41:51 +0900 Subject: [PATCH 20/27] Hold selected-account leases through desktop child reaping Stacked on #762 (validated authority response envelopes); GitHub retargets this PR to main once #762 merges. First slice of #720. - In-process provider launches validate and lease the frozen selected binding before adapter preparation or credential lookup. - The child and its lease move together to the desktop reaper; a confirmed exit releases the lease and an unknown exit keeps the use barrier. - Socket-backed provider launch stays unsupported until a service-owned operation exists. - Synthetic regressions cover child lifetime, normal and nonzero exits, stale bindings, pre-spawn failures and wait errors. Validation: core and desktop formatting and six exact core tests passed on Linux; the desktop tests and platform checks run in CI. --- .../core/src/providers/leased_launch.rs | 487 ++++++++++++++++++ account-manager/core/src/providers/mod.rs | 4 + account-manager/src-tauri/src/commands.rs | 42 +- .../src-tauri/src/commands_launch_tests.rs | 145 ++++++ 4 files changed, 663 insertions(+), 15 deletions(-) create mode 100644 account-manager/core/src/providers/leased_launch.rs create mode 100644 account-manager/src-tauri/src/commands_launch_tests.rs diff --git a/account-manager/core/src/providers/leased_launch.rs b/account-manager/core/src/providers/leased_launch.rs new file mode 100644 index 00000000..fbb49bd7 --- /dev/null +++ b/account-manager/core/src/providers/leased_launch.rs @@ -0,0 +1,487 @@ +//! Child custody for an exact, leased stored-account selection. + +use std::io; +use std::process::{Child, ExitStatus}; + +use crate::account_authority::{SelectedAccountBinding, SelectedUseLease, StoredAccountRegistry}; +use crate::error::{Error, Result}; +use crate::storage::CredentialStore; + +use super::{launch_spec_for, spawn_launch, ProviderAdapter}; + +/// A child and its selected-account use lease, transferred together to a reaper. +/// +/// Only a confirmed exit releases the lease. Dropping custody while exit is +/// unknown retains the lease until this application exits, so an unsuccessful +/// reaper cannot permit replacement of material still used by a child. +#[must_use = "the selected-account child must be reaped"] +pub struct LeasedChild { + child: Child, + binding: SelectedAccountBinding, + lease: Option, +} + +impl LeasedChild { + pub fn id(&self) -> u32 { + self.child.id() + } + + pub fn binding(&self) -> &SelectedAccountBinding { + &self.binding + } + + /// Wait for exit, retaining the lease if the wait fails. + pub fn wait(&mut self) -> io::Result { + let status = self.child.wait()?; + self.lease.take(); + Ok(status) + } + + /// Poll for exit, retaining the lease while alive or if the poll fails. + pub fn try_wait(&mut self) -> io::Result> { + let status = self.child.try_wait()?; + if status.is_some() { + self.lease.take(); + } + Ok(status) + } +} + +impl Drop for LeasedChild { + fn drop(&mut self) { + if let Some(lease) = self.lease.take() { + if !matches!(self.child.try_wait(), Ok(Some(_))) { + // Child::drop does not wait. Keep the active-use barrier even + // if custody is abandoned or a reaper cannot confirm exit. + std::mem::forget(lease); + } + } + } +} + +/// Validate and lease a frozen binding before adapter or credential resolution. +/// +/// The credential-store factory runs only when the adapter requires one, under +/// the same lease as material retrieval and spawn. Any pre-spawn failure drops +/// the lease normally. The returned custody exposes no unleased child handle. +pub fn spawn_selected_launch( + registry: &StoredAccountRegistry, + adapter: &dyn ProviderAdapter, + binding: &SelectedAccountBinding, + credential_store: impl FnOnce() -> Result>, +) -> Result { + if binding.provider_id != adapter.id() { + return Err(Error::UnknownProvider(binding.provider_id.clone())); + } + let lease = registry.acquire_selected_use(binding)?; + let account = registry.complete(adapter.id(), &binding.account_id)?; + let spec = launch_spec_for(adapter, &account)?; + let store = spec + .requires_credential() + .then(credential_store) + .transpose()?; + let child = spawn_launch(spec, &account, store.as_deref())?; + Ok(LeasedChild { + child, + binding: binding.clone(), + lease: Some(lease), + }) +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use std::time::{Duration, Instant}; + + use super::*; + use crate::model::{ + Account, AuthKind, InstallState, ProviderDescriptor, StoredAccountMaterial, + StoredAccountMetadata, + }; + use crate::providers::{ActivationMechanism, LaunchSpec}; + use crate::storage::{Secret, SecretRef}; + + const PROVIDER: &str = "lease-probe"; + const FAKE_SECRET: &[u8] = b"FAKE-selected-lease-secret"; + + struct ProbeAdapter { + registry: Arc, + binding: SelectedAccountBinding, + spec: LaunchSpec, + declarations: AtomicUsize, + refuse: bool, + } + + impl ProviderAdapter for ProbeAdapter { + fn id(&self) -> &'static str { + PROVIDER + } + + fn descriptor(&self) -> ProviderDescriptor { + unimplemented!("not used by launch fixture") + } + + fn config_paths(&self) -> Vec { + Vec::new() + } + + fn detect(&self) -> InstallState { + InstallState::Unknown + } + + fn list_accounts(&self) -> Result> { + Ok(Vec::new()) + } + + fn activation_mechanism(&self) -> ActivationMechanism { + ActivationMechanism::LaunchEnvironment + } + + fn activate_account(&self, _account_id: &str) -> Result<()> { + Err(Error::NotImplemented("fixture activate")) + } + + fn launch_spec(&self, account: &StoredAccountMetadata) -> Result { + self.declarations.fetch_add(1, Ordering::SeqCst); + assert_eq!(account.id, self.binding.account_id); + assert_eq!( + account.account_incarnation, + self.binding.account_incarnation + ); + assert_in_use(&self.registry); + if self.refuse { + return Err(Error::NotImplemented("fixture launch refusal")); + } + Ok(self.spec.clone()) + } + } + + struct ProbeStore { + registry: Arc, + reads: Arc, + missing: bool, + } + + impl CredentialStore for ProbeStore { + fn id(&self) -> &'static str { + "lease-fixture" + } + + fn is_available(&self) -> bool { + true + } + + fn put(&self, _key: &SecretRef, _secret: &Secret) -> Result<()> { + unimplemented!("fixture material is synthetic") + } + + fn get(&self, key: &SecretRef) -> Result> { + assert_eq!(*key, SecretRef::for_account(PROVIDER, "work")); + assert_in_use(&self.registry); + self.reads.fetch_add(1, Ordering::SeqCst); + Ok((!self.missing).then(|| Secret::new(FAKE_SECRET.to_vec()))) + } + + fn delete(&self, _key: &SecretRef) -> Result<()> { + unimplemented!("fixture does not delete material") + } + } + + fn assert_in_use(registry: &StoredAccountRegistry) { + assert!(matches!( + registry.begin_delete(PROVIDER, "work"), + Err(Error::AccountAuthorityBusy { .. }) + )); + assert!(matches!( + registry.select_complete(PROVIDER, "other"), + Err(Error::AccountAuthorityBusy { .. }) + )); + } + + fn fixture(directory: &Path) -> ProbeAdapter { + fixture_with_material(directory, StoredAccountMaterial::CredentialStore) + } + + fn fixture_with_material(directory: &Path, material: StoredAccountMaterial) -> ProbeAdapter { + let registry = Arc::new(StoredAccountRegistry::new(directory.join("accounts.json"))); + for account in ["work", "other"] { + registry + .begin_add(PROVIDER, account, account, AuthKind::ApiKey, material) + .unwrap(); + registry.complete_add(PROVIDER, account).unwrap(); + } + registry.select_complete(PROVIDER, "work").unwrap(); + let binding = registry.selected_binding(PROVIDER).unwrap().unwrap(); + let spec = LaunchSpec::new(std::env::current_exe().unwrap()) + .current_dir(directory) + .arg("--exact") + .arg("providers::leased_launch::tests::leased_child_probe") + .arg("--nocapture") + .set_plain_env("CAM_LEASE_PROBE_DIRECTORY", directory); + let spec = if material == StoredAccountMaterial::CredentialStore { + spec.set_secret_env("CAM_LEASE_PROBE_SECRET") + } else { + spec.remove_env("CAM_LEASE_PROBE_SECRET") + }; + ProbeAdapter { + registry, + binding, + spec, + declarations: AtomicUsize::new(0), + refuse: false, + } + } + + fn await_file(path: &Path) { + let deadline = Instant::now() + Duration::from_secs(30); + while !path.exists() { + assert!(Instant::now() < deadline, "child barrier timed out"); + std::thread::sleep(Duration::from_millis(10)); + } + } + + fn release_child(directory: &Path, payload: &[u8]) { + let pending = directory.join("release.pending"); + fs::write(&pending, payload).unwrap(); + fs::rename(pending, directory.join("release")).unwrap(); + } + + // Test cleanup signals only the child owned by this fixture, even on panic. + struct TestChild(LeasedChild); + + impl Drop for TestChild { + fn drop(&mut self) { + if self.0.lease.is_some() { + let _ = self.0.child.kill(); + let _ = self.0.wait(); + } + } + } + + #[test] + fn leased_child_probe() { + let Some(directory) = std::env::var_os("CAM_LEASE_PROBE_DIRECTORY") else { + return; + }; + if let Ok(secret) = std::env::var("CAM_LEASE_PROBE_SECRET") { + assert_eq!(secret.as_bytes(), FAKE_SECRET); + } + let directory = PathBuf::from(directory); + fs::write(directory.join("ready"), b"ready").unwrap(); + await_file(&directory.join("release")); + match fs::read(directory.join("release")).unwrap().as_slice() { + b"ok" => {} + b"nonzero" => panic!("requested nonzero child exit"), + _ => panic!("invalid child release payload"), + } + } + + #[test] + fn selected_material_is_held_until_confirmed_exit() { + for nonzero in [false, true] { + let directory = tempfile::tempdir().unwrap(); + let adapter = fixture(directory.path()); + let registry = Arc::clone(&adapter.registry); + let reads = Arc::new(AtomicUsize::new(0)); + let child = spawn_selected_launch(®istry, &adapter, &adapter.binding, || { + assert_in_use(®istry); + Ok(Box::new(ProbeStore { + registry: Arc::clone(®istry), + reads: Arc::clone(&reads), + missing: false, + })) + }) + .unwrap(); + let mut child = TestChild(child); + assert_eq!(child.0.binding(), &adapter.binding); + assert_eq!(reads.load(Ordering::SeqCst), 1); + await_file(&directory.path().join("ready")); + assert!(child.0.try_wait().unwrap().is_none()); + assert_in_use(®istry); + let reaper = std::thread::spawn(move || child.0.wait()); + assert_in_use(®istry); + release_child( + directory.path(), + if nonzero { + b"nonzero".as_slice() + } else { + b"ok" + }, + ); + let status = reaper.join().unwrap().unwrap(); + assert_eq!(status.success(), !nonzero); + registry.select_complete(PROVIDER, "other").unwrap(); + registry.begin_delete(PROVIDER, "work").unwrap(); + registry.finish_delete(PROVIDER, "work").unwrap(); + registry + .begin_add( + PROVIDER, + "work", + "Replacement", + AuthKind::ApiKey, + StoredAccountMaterial::CredentialStore, + ) + .unwrap(); + assert_ne!( + registry + .account(PROVIDER, "work") + .unwrap() + .account_incarnation, + adapter.binding.account_incarnation + ); + } + } + + #[test] + fn launch_failures_release_selected_use() { + for failure in ["adapter", "store", "material", "spawn"] { + let directory = tempfile::tempdir().unwrap(); + let mut adapter = fixture(directory.path()); + adapter.refuse = failure == "adapter"; + if failure == "spawn" { + adapter.spec.program = directory.path().join("missing-provider-executable"); + } + let registry = &adapter.registry; + let error = spawn_selected_launch(registry, &adapter, &adapter.binding, || { + assert_in_use(registry); + if failure == "store" { + return Err(Error::CredentialStoreUnavailable( + "fixture unavailable".into(), + )); + } + Ok(Box::new(ProbeStore { + registry: Arc::clone(registry), + reads: Arc::new(AtomicUsize::new(0)), + missing: failure == "material", + })) + }) + .err() + .expect("launch must fail"); + assert!(!error + .to_string() + .contains(std::str::from_utf8(FAKE_SECRET).unwrap())); + registry.select_complete(PROVIDER, "other").unwrap(); + registry.begin_delete(PROVIDER, "work").unwrap(); + registry.finish_delete(PROVIDER, "work").unwrap(); + } + } + + #[test] + fn vendor_home_poll_releases_only_after_exit() { + let directory = tempfile::tempdir().unwrap(); + let adapter = fixture_with_material(directory.path(), StoredAccountMaterial::VendorHome); + let registry = &adapter.registry; + let mut child = TestChild( + spawn_selected_launch(registry, &adapter, &adapter.binding, || { + panic!("vendor-home launch must not resolve a credential store") + }) + .unwrap(), + ); + await_file(&directory.path().join("ready")); + assert!(child.0.try_wait().unwrap().is_none()); + assert_in_use(registry); + release_child(directory.path(), b"ok"); + let deadline = Instant::now() + Duration::from_secs(30); + loop { + if let Some(status) = child.0.try_wait().unwrap() { + assert!(status.success()); + break; + } + assert!(Instant::now() < deadline, "child did not exit"); + assert_in_use(registry); + std::thread::sleep(Duration::from_millis(10)); + } + registry.begin_delete(PROVIDER, "work").unwrap(); + } + + #[test] + fn stale_binding_refuses_launch_before_resolution() { + let directory = tempfile::tempdir().unwrap(); + let adapter = fixture(directory.path()); + let registry = &adapter.registry; + registry.select_complete(PROVIDER, "other").unwrap(); + let error = spawn_selected_launch(registry, &adapter, &adapter.binding, || { + panic!("stale selection must not resolve a store") + }) + .err() + .unwrap(); + assert!(matches!(error, Error::StaleSelection { .. })); + registry.begin_delete(PROVIDER, "work").unwrap(); + registry.finish_delete(PROVIDER, "work").unwrap(); + registry + .begin_add( + PROVIDER, + "work", + "Replacement", + AuthKind::ApiKey, + StoredAccountMaterial::CredentialStore, + ) + .unwrap(); + registry.complete_add(PROVIDER, "work").unwrap(); + registry.select_complete(PROVIDER, "work").unwrap(); + let mut stale = adapter.binding.clone(); + // Even the current revision cannot authorize the old incarnation. + stale.selection_revision = registry.selection_revision(PROVIDER).unwrap(); + let error = spawn_selected_launch(registry, &adapter, &stale, || { + panic!("stale incarnation must not resolve a store") + }) + .err() + .unwrap(); + assert!(matches!(error, Error::StaleAccount { .. })); + stale.provider_id = "different-provider".into(); + assert!(matches!( + spawn_selected_launch(registry, &adapter, &stale, || { + panic!("provider mismatch must not resolve a store") + }), + Err(Error::UnknownProvider(_)) + )); + assert_eq!(adapter.declarations.load(Ordering::SeqCst), 0); + registry.begin_delete(PROVIDER, "work").unwrap(); + } + + #[cfg(unix)] + #[test] + fn wait_error_retains_selected_use() { + let directory = tempfile::tempdir().unwrap(); + let adapter = fixture(directory.path()); + let registry = &adapter.registry; + let mut child = spawn_selected_launch(registry, &adapter, &adapter.binding, || { + Ok(Box::new(ProbeStore { + registry: Arc::clone(registry), + reads: Arc::new(AtomicUsize::new(0)), + missing: false, + })) + }) + .unwrap(); + await_file(&directory.path().join("ready")); + release_child(directory.path(), b"ok"); + // Reap this exact test-owned PID outside Child to force ECHILD. The + // custody's wait cannot confirm exit and must retain its use barrier. + let pid = child.id() as libc::pid_t; + let mut status = 0; + loop { + // SAFETY: pid names only this fixture's child, and status is writable. + let waited = unsafe { libc::waitpid(pid, &mut status, 0) }; + if waited == pid { + break; + } + assert_eq!( + io::Error::last_os_error().kind(), + io::ErrorKind::Interrupted + ); + } + assert!(child.wait().is_err()); + assert!(child.try_wait().is_err()); + assert_in_use(registry); + // Match the reaper's error path: dropping unconfirmed custody must + // retain the use barrier until application exit. + drop(child); + assert!(matches!( + registry.begin_delete(PROVIDER, "work"), + Err(Error::AccountAuthorityBusy { .. }) + )); + } +} diff --git a/account-manager/core/src/providers/mod.rs b/account-manager/core/src/providers/mod.rs index ac721b03..295bf09e 100644 --- a/account-manager/core/src/providers/mod.rs +++ b/account-manager/core/src/providers/mod.rs @@ -28,6 +28,8 @@ pub use crate::account_authority::{ SelectedAccountBinding, SelectedUseLease, StoredAccountRegistry, }; +pub mod leased_launch; +pub use leased_launch::{spawn_selected_launch, LeasedChild}; pub mod observe; pub use observe::observe_selected_account; @@ -376,6 +378,8 @@ pub trait ProviderAdapter: Send + Sync { /// Stdio is inherited and never captured. Secret values are retrieved only for /// `SetSecret` entries immediately before `spawn`, applied only to this /// `Command`, and never included in an error or return value. +/// Stored-account callers should use [`spawn_selected_launch`] to retain the +/// exact selected-use lease throughout child custody. pub fn spawn_launch( spec: LaunchSpec, account: &StoredAccountMetadata, diff --git a/account-manager/src-tauri/src/commands.rs b/account-manager/src-tauri/src/commands.rs index 06ebde42..8f3c3858 100644 --- a/account-manager/src-tauri/src/commands.rs +++ b/account-manager/src-tauri/src/commands.rs @@ -381,31 +381,43 @@ pub fn launch_provider(provider_id: String) -> Result { return Err(Error::NotImplemented("launch_provider")); } let authority = account_authority()?; - let account = match &authority { - AccountAuthority::InProcess(registry) => registry - .selected(&provider_id)? - .ok_or_else(|| Error::NoSelectedAccount(provider_id.clone()))?, + launch_provider_with_authority(adapter.as_ref(), &authority) +} + +fn launch_provider_with_authority( + adapter: &dyn ProviderAdapter, + authority: &AccountAuthority, +) -> Result { + let registry = match authority { + AccountAuthority::InProcess(registry) => registry, AccountAuthority::Remote(_) => return Err(Error::NotImplemented("remote launch_provider")), }; - let spec = providers::launch_spec_for(adapter.as_ref(), &account)?; - let store = spec - .requires_credential() - .then(storage::default_store) - .transpose()?; - let mut child = providers::spawn_launch(spec, &account, store.as_deref())?; + let binding = registry + .selected_binding(adapter.id())? + .ok_or_else(|| Error::NoSelectedAccount(adapter.id().to_string()))?; + let child = + providers::spawn_selected_launch(registry, adapter, &binding, storage::default_store)?; let process = LaunchedProcess { - provider_id, - account_id: account.id, + provider_id: binding.provider_id, + account_id: binding.account_id, process_id: child.id(), }; // Waiting outside the command keeps an exited CLI from becoming a zombie // while allowing the IPC call to return as soon as the process starts. - std::thread::spawn(move || { - let _ = child.wait(); - }); + let _ = reap_launch(child); Ok(process) } +fn reap_launch( + mut child: providers::LeasedChild, +) -> std::thread::JoinHandle> { + std::thread::spawn(move || child.wait()) +} + +#[cfg(test)] +#[path = "commands_launch_tests.rs"] +mod launch_tests; + /// One honest quota result for every registered provider. /// /// Empty adapter output is an explicit `no-signal`, while collection errors diff --git a/account-manager/src-tauri/src/commands_launch_tests.rs b/account-manager/src-tauri/src/commands_launch_tests.rs new file mode 100644 index 00000000..7c6ff018 --- /dev/null +++ b/account-manager/src-tauri/src/commands_launch_tests.rs @@ -0,0 +1,145 @@ +use super::*; +use crate::model::StoredAccountMaterial; +use std::time::{Duration, Instant}; + +const PROVIDER: &str = "desktop-lease-probe"; + +struct ProbeAdapter { + directory: PathBuf, +} + +impl ProviderAdapter for ProbeAdapter { + fn id(&self) -> &'static str { + PROVIDER + } + + fn descriptor(&self) -> ProviderDescriptor { + unimplemented!("not used by launch fixture") + } + + fn config_paths(&self) -> Vec { + Vec::new() + } + + fn detect(&self) -> crate::model::InstallState { + crate::model::InstallState::Unknown + } + + fn list_accounts(&self) -> Result> { + Ok(Vec::new()) + } + + fn activation_mechanism(&self) -> ActivationMechanism { + ActivationMechanism::LaunchEnvironment + } + + fn launch_spec(&self, _account: &StoredAccountMetadata) -> Result { + let registry = crate::account_authority::StoredAccountRegistry::new( + self.directory.join("accounts.json"), + ); + assert!(matches!( + registry.begin_delete(PROVIDER, "work"), + Err(Error::AccountAuthorityBusy { .. }) + )); + Ok(providers::LaunchSpec::new(std::env::current_exe()?) + .current_dir(&self.directory) + .arg("--exact") + .arg("commands::launch_tests::launch_child_probe") + .arg("--nocapture") + .set_plain_env("CAM_DESKTOP_LEASE_BARRIER", &self.directory)) + } + + fn activate_account(&self, _account_id: &str) -> Result<()> { + Err(Error::NotImplemented("fixture activate")) + } +} + +fn await_file(path: &Path) { + let deadline = Instant::now() + Duration::from_secs(30); + while !path.exists() { + assert!(Instant::now() < deadline, "desktop child barrier timed out"); + std::thread::sleep(Duration::from_millis(10)); + } +} + +#[test] +fn launch_child_probe() { + let Some(directory) = std::env::var_os("CAM_DESKTOP_LEASE_BARRIER") else { + return; + }; + let directory = PathBuf::from(directory); + fs::write(directory.join("ready"), b"ready").unwrap(); + await_file(&directory.join("release")); +} + +#[test] +fn launch_reaper_retains_exact_selected_use_lease() { + let directory = tempfile::tempdir().unwrap(); + let adapter = ProbeAdapter { + directory: directory.path().to_path_buf(), + }; + let registry = crate::account_authority::StoredAccountRegistry::new( + directory.path().join("accounts.json"), + ); + registry + .begin_add( + PROVIDER, + "work", + "Work", + AuthKind::OAuth, + StoredAccountMaterial::VendorHome, + ) + .unwrap(); + registry.complete_add(PROVIDER, "work").unwrap(); + registry.select_complete(PROVIDER, "work").unwrap(); + let expected = registry.selected_binding(PROVIDER).unwrap().unwrap(); + let authority = AccountAuthority::InProcess(registry); + let process = launch_provider_with_authority(&adapter, &authority).unwrap(); + let registry = authority.in_process_registry().unwrap(); + assert_eq!(process.provider_id, expected.provider_id); + assert_eq!(process.account_id, expected.account_id); + assert_ne!(process.process_id, 0); + await_file(&directory.path().join("ready")); + assert_eq!(registry.selected_binding(PROVIDER).unwrap(), Some(expected)); + let deletion = registry.begin_delete(PROVIDER, "work"); + // Always release the fixture child before asserting, including on failure. + fs::write(directory.path().join("release"), b"ok").unwrap(); + assert!(matches!(deletion, Err(Error::AccountAuthorityBusy { .. }))); + let deadline = Instant::now() + Duration::from_secs(30); + loop { + match registry.begin_delete(PROVIDER, "work") { + Ok(()) => break, + Err(Error::AccountAuthorityBusy { .. }) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(10)); + } + result => panic!("desktop reaper did not release lease: {result:?}"), + } + } + registry.finish_delete(PROVIDER, "work").unwrap(); +} + +#[cfg(unix)] +#[test] +fn socket_backed_launch_remains_refused() { + use std::os::unix::fs::PermissionsExt; + use std::os::unix::net::UnixListener; + + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().canonicalize().unwrap(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap(); + let socket = root.join("account.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + fs::set_permissions(&socket, fs::Permissions::from_mode(0o600)).unwrap(); + listener.set_nonblocking(true).unwrap(); + let authority = + AccountAuthority::Remote(authority_client::AuthorityClient::connect(&socket).unwrap()); + let adapter = ProbeAdapter { directory: root }; + assert!(matches!( + launch_provider_with_authority(&adapter, &authority), + Err(Error::NotImplemented("remote launch_provider")) + )); + assert_eq!( + listener.accept().unwrap_err().kind(), + io::ErrorKind::WouldBlock + ); +} From 7ada2f0dbb61f4a964c020671f179c57095c7f6a Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:43:16 +0900 Subject: [PATCH 21/27] Keep main's adaptive systemd attach allowance Follow-up fix. --- src/process_runner.rs | 3 +++ src/process_runner/part3.rs | 8 ++++---- src/process_runner/tests.rs | 13 +++++++------ 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/process_runner.rs b/src/process_runner.rs index 224c1546..28373f81 100644 --- a/src/process_runner.rs +++ b/src/process_runner.rs @@ -98,6 +98,9 @@ const TERMINATE_GRACE: Duration = Duration::from_millis(100); const EXIT_AND_DRAIN_GRACE: Duration = Duration::from_millis(500); #[cfg(target_os = "linux")] const SYSTEMD_OPERATION_GRACE: Duration = Duration::from_secs(3); +// Attachment alone may use this ceiling, always inside the original operation deadline. +#[cfg(target_os = "linux")] +const SYSTEMD_ATTACH_CEILING: Duration = Duration::from_secs(10); #[cfg(target_os = "linux")] const SYSTEMD_SLOT_WAIT: Duration = Duration::from_secs(30); // Keep both supervise admission and strict containment deterministic in the unit-test binary. diff --git a/src/process_runner/part3.rs b/src/process_runner/part3.rs index bb489d5b..e34cf0fc 100644 --- a/src/process_runner/part3.rs +++ b/src/process_runner/part3.rs @@ -2375,7 +2375,7 @@ fn systemd_attach_deadline(now: Instant, operation_deadline: Option) -> let allowance = match operation_deadline { Some(deadline) => { let remaining = deadline.checked_duration_since(now).filter(|r| !r.is_zero()).ok_or_else(|| io::Error::new(io::ErrorKind::TimedOut, "systemd attachment operation deadline is exhausted"))?; - remaining.min(SYSTEMD_OPERATION_GRACE) + remaining.min(SYSTEMD_OPERATION_GRACE.max(SYSTEMD_ATTACH_CEILING.min(remaining / 2))) } None => SYSTEMD_OPERATION_GRACE, }; @@ -4153,9 +4153,9 @@ mod start_gate_deadline_phase_tests { use std::time::{Duration, Instant}; #[test] - fn attach_budget_preserves_three_second_grace_and_clips_without_renewal() { + fn adaptive_attach_budget_scales_and_clips_without_renewal() { let now = Instant::now(); - for (remaining, expected) in [(40, 3), (12, 3), (6, 3), (3, 3), (1, 1)] { + for (remaining, expected) in [(40, 10), (12, 6), (6, 3), (1, 1)] { let operation = now + Duration::from_secs(remaining); let deadline = systemd_attach_deadline(now, Some(operation)).unwrap(); assert_eq!(deadline, now + Duration::from_secs(expected)); @@ -4170,7 +4170,7 @@ mod start_gate_deadline_phase_tests { } assert_eq!(systemd_attach_deadline(now, None).unwrap(), now + SYSTEMD_OPERATION_GRACE); if let Some(large) = now.checked_add(Duration::from_secs(u32::MAX as u64)) { - assert_eq!(systemd_attach_deadline(large, Some(large + Duration::from_secs(12))).unwrap(), large + Duration::from_secs(3)); + assert_eq!(systemd_attach_deadline(large, Some(large + Duration::from_secs(12))).unwrap(), large + Duration::from_secs(6)); } } diff --git a/src/process_runner/tests.rs b/src/process_runner/tests.rs index a78f146f..ef0f7709 100644 --- a/src/process_runner/tests.rs +++ b/src/process_runner/tests.rs @@ -3248,13 +3248,14 @@ fn systemd_withheld_attach_stages_retain_observed_evidence_after_rollback() { let now = Instant::now(); let operation_deadline = now + Duration::from_secs(40); + let attach_deadline = systemd_attach_deadline(now, Some(operation_deadline)).unwrap(); ATTACH_TEST_NOW.with(|clock| clock.set(Some(now))); ATTACH_TEST_HOOK.with(|hook| { *hook.borrow_mut() = Some(Box::new(move |phase| { if phase == "before_attach_budget_check" { // Hold the fixture at precisely this observed transition until - // the original setup grace expires, without a wall-clock wait. - ATTACH_TEST_NOW.with(|clock| clock.set(Some(now + Duration::from_secs(3)))); + // the selected attachment deadline expires, without a wall-clock wait. + ATTACH_TEST_NOW.with(|clock| clock.set(Some(attach_deadline))); } })); }); @@ -3305,9 +3306,9 @@ fn systemd_withheld_attach_stages_retain_observed_evidence_after_rollback() { stage >= 5, stage >= 6, stage == 7, stage >= 8, ); assert!(message.contains(&expected), "stage {stage}: {message}"); - assert!(message.contains("attach_allowance_ms=3000"), "{message}"); + assert!(message.contains("attach_allowance_ms=10000"), "{message}"); assert!( - message.contains("operation_remaining_ms=37000"), + message.contains("operation_remaining_ms=30000"), "{message}" ); assert!( @@ -6169,7 +6170,7 @@ fn systemd_attach_delayed_ready_retains_full_ownership_and_sandbox_proof() { } else if phase == "after_sandbox_verification" { observed.set(true); let base = selected_at.expect("capture the deadline-selection clock"); - ATTACH_TEST_NOW.with(|clock| clock.set(Some(base + Duration::from_secs(2)))); + ATTACH_TEST_NOW.with(|clock| clock.set(Some(base + Duration::from_secs(4)))); } })) }); @@ -6184,7 +6185,7 @@ fn systemd_attach_delayed_ready_retains_full_ownership_and_sandbox_proof() { ) .with_timeout(Some(Duration::from_secs(12))), ) - .expect("strict production path must pass with two-second injected readiness"); + .expect("strict production path must pass with four-second injected readiness"); assert!(advanced.get(), "must reach real sandbox verification"); assert!(output.status.unwrap().success() && !output.timed_out); assert!(output.safety_evidence_verified(), "{output:?}"); From 37f7e17a918a01f2df8e8e4f9be06dde60f5cd9d Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:43:25 +0900 Subject: [PATCH 22/27] Require the task-shape ceiling as a nullable selector field Follow-up fix. --- .../supervisor-collect-report-v1.schema.json | 1 + .../supervisor-final-report-v1.schema.json | 1 + src/selection/schema.rs | 17 +++++---- src/selection/tests.rs | 36 ++++++++++++++++++- src/selection/types.rs | 5 +-- src/supervise/schema_artifacts.rs | 4 +-- 6 files changed, 50 insertions(+), 14 deletions(-) diff --git a/schemas/supervisor-collect-report-v1.schema.json b/schemas/supervisor-collect-report-v1.schema.json index c23a4a84..9115daf1 100644 --- a/schemas/supervisor-collect-report-v1.schema.json +++ b/schemas/supervisor-collect-report-v1.schema.json @@ -20813,6 +20813,7 @@ "prohibition_reason", "prohibited_authority_roles", "long_context_eligible", + "task_shape_ceiling", "strong_gate_fallback_efforts", "strength_rank", "class_fit", diff --git a/schemas/supervisor-final-report-v1.schema.json b/schemas/supervisor-final-report-v1.schema.json index e7c7d689..9117c0ff 100644 --- a/schemas/supervisor-final-report-v1.schema.json +++ b/schemas/supervisor-final-report-v1.schema.json @@ -20462,6 +20462,7 @@ "prohibition_reason", "prohibited_authority_roles", "long_context_eligible", + "task_shape_ceiling", "strong_gate_fallback_efforts", "strength_rank", "class_fit", diff --git a/src/selection/schema.rs b/src/selection/schema.rs index e9551cd5..2b704da3 100644 --- a/src/selection/schema.rs +++ b/src/selection/schema.rs @@ -325,7 +325,7 @@ fn one_shot_environment_fallback() -> Value { } fn model_prior() -> Value { - let mut schema = strict_object!( + strict_object!( "runtime" => nonempty_string(), "model" => nonempty_string(), "observed_on" => date(), @@ -336,19 +336,18 @@ fn model_prior() -> Value { "prohibition_reason" => nullable(nonempty_string()), "prohibited_authority_roles" => set(authority_role()), "long_context_eligible" => json!({"type": "boolean"}), + "task_shape_ceiling" => nullable(strict_object!( + "boundedness" => set(boundedness(false)), + "maximum_risk" => risk(false), + "maximum_context" => context_size(false), + "maximum_horizon" => task_horizon(false), + )), "strong_gate_fallback_efforts" => set(reasoning_effort()), "strength_rank" => nonnegative_integer(), "class_fit" => array(class_fit_prior()), "authority_evidence" => array(authority_evidence_prior()), "one_shot_environment_fallbacks" => array(one_shot_environment_fallback()), - ); - schema["properties"]["task_shape_ceiling"] = nullable(strict_object!( - "boundedness" => set(boundedness(false)), - "maximum_risk" => risk(false), - "maximum_context" => context_size(false), - "maximum_horizon" => task_horizon(false), - )); - schema + ) } fn prior_dataset() -> Value { diff --git a/src/selection/tests.rs b/src/selection/tests.rs index 6fd916d3..52dd5962 100644 --- a/src/selection/tests.rs +++ b/src/selection/tests.rs @@ -1254,6 +1254,12 @@ fn task_shape_ceiling_prior_compatibility_and_validation() -> anyhow::Result<()> legacy.as_object_mut().unwrap().remove("task_shape_ceiling"); let legacy: ModelPrior = serde_json::from_value(legacy)?; assert!(legacy.task_shape_ceiling.is_none()); + let legacy_wire = serde_json::to_value(&legacy)?; + assert_eq!( + legacy_wire.get("task_shape_ceiling"), + Some(&serde_json::Value::Null) + ); + assert_eq!(serde_json::from_value::(legacy_wire)?, legacy); assert_eq!( effective_task_shape_ceiling(&legacy.runtime, &legacy.model, None)?, TaskShapeCeiling::bounded_terminal() @@ -1298,7 +1304,7 @@ fn task_shape_ceiling_prior_compatibility_and_validation() -> anyhow::Result<()> let model_schema = &schema["properties"]["provenance"]["properties"]["normalized_input"] ["properties"]["priors"]["properties"]["models"]["items"]; assert!(model_schema["properties"]["task_shape_ceiling"].is_object()); - assert!(!model_schema["required"] + assert!(model_schema["required"] .as_array() .unwrap() .iter() @@ -1620,6 +1626,24 @@ fn astra_prohibition_cannot_be_bypassed_by_environment_fallback_or_debug_overrid .priors .models .retain(|prior| prior.model != target.model); + if authority != AuthorityRole::TerminalLeaf { + let broad = select_with_loaded_routing_policy(&no_prior, &[], Some(&receipt))?; + assert!(broad.environment_fallback.is_none()); + assert!(broad + .candidate_set + .iter() + .filter(|evaluation| evaluation.candidate.model == target.model) + .all(|evaluation| !evaluation.eligible + && evaluation.ineligibility_reasons.iter().any(|reason| { + reason.code == IneligibilityCode::TaskShapeNotAdvertised + }))); + } + // Isolate authority from the independent bundled shape ceiling. + input.task.risk = RiskLevel::Medium; + input.task.boundedness = Boundedness::Bounded; + input.task.context = ContextSize::Medium; + input.task.horizon = TaskHorizon::Medium; + no_prior.task = input.task.clone(); let control = select_with_loaded_routing_policy(&no_prior, &[], Some(&receipt))?; assert_eq!( control @@ -1689,6 +1713,16 @@ fn astra_prohibition_cannot_be_bypassed_by_environment_fallback_or_debug_overrid for routing in [None, Some(&receipt)] { let decision = select_with_loaded_routing_policy(&input, &[], routing)?; assert!(decision.environment_fallback.is_none()); + assert!(decision + .candidate_set + .iter() + .filter(|evaluation| evaluation.candidate.model == target.model) + .all(|evaluation| !evaluation.eligible + && evaluation.score.is_none() + && evaluation + .ineligibility_reasons + .iter() + .any(|reason| { reason.code == IneligibilityCode::PolicyProhibited }))); assert!(decision .choice .is_none_or(|choice| choice.candidate.model == "gpt-6.1-sol")); diff --git a/src/selection/types.rs b/src/selection/types.rs index 36eba914..536032dd 100644 --- a/src/selection/types.rs +++ b/src/selection/types.rs @@ -373,8 +373,9 @@ pub struct ModelPrior { pub prohibition_reason: Option, pub prohibited_authority_roles: BTreeSet, pub long_context_eligible: bool, - /// Optional qualified task bounds. Omission cannot widen bundled bounds. - #[serde(default, skip_serializing_if = "Option::is_none")] + /// Optional qualified task bounds, serialized as null when absent. + /// Legacy omission cannot widen bundled bounds. + #[serde(default)] pub task_shape_ceiling: Option, pub strong_gate_fallback_efforts: BTreeSet, pub strength_rank: u16, diff --git a/src/supervise/schema_artifacts.rs b/src/supervise/schema_artifacts.rs index 0dbefe9f..bc1e1ada 100644 --- a/src/supervise/schema_artifacts.rs +++ b/src/supervise/schema_artifacts.rs @@ -5377,7 +5377,7 @@ mod selection_schema_tests { } #[test] - fn published_selection_prior_ceiling_is_optional_nullable_and_closed() -> Result<()> { + fn published_selection_prior_ceiling_is_required_nullable_and_closed() -> Result<()> { let decision = crate::selection::select(&crate::selection::selection_test_base_input())?; let event = json!({ "assignment_id": null, @@ -5415,7 +5415,7 @@ mod selection_schema_tests { .context("prior object")? .remove("task_shape_ceiling"); } - assert!(schemas.validate(&legacy, index).is_ok()); + assert!(schemas.validate(&legacy, index).is_err()); let mut nullable = event.clone(); nullable["provenance"]["normalized_input"]["priors"]["models"][0] ["task_shape_ceiling"] = serde_json::Value::Null; From 1dafafec473f4b5f89a0cb55cf5898af659e85de Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:49:03 +0900 Subject: [PATCH 23/27] Bind supervised executor selections and refuse unconfigured SSH Stacked on #770 (authored checks in assignment metadata and plan digests); GitHub retargets this PR to main once #770 and #758 merge. First slice of #717. - Plans can declare a strict executor selection for individual assignments and nested Workers. - Omitted or explicit Local selections keep current behavior and canonical bindings. - SSH target identity is kept in normalized plans and checkpoint digests. - Supervised SSH execution is refused before launch until authenticated target bindings and the production bridge exist; plan-only loading also refuses SSH, so a selection can never silently become a Local launch. Validation: formatting and ten exact Linux tests passed; Clippy and the full suites run in CI. --- src/executor/ssh.rs | 20 ++- src/supervise.rs | 7 + src/supervise/assignment_executor.rs | 83 +++++++++ src/supervise/plan_api.rs | 37 +++- src/supervise/scheduler.rs | 1 + src/supervise/tests/plan_runtime.rs | 245 +++++++++++++++++++++++++++ 6 files changed, 385 insertions(+), 8 deletions(-) create mode 100644 src/supervise/assignment_executor.rs diff --git a/src/executor/ssh.rs b/src/executor/ssh.rs index 3343169e..5fa2b344 100644 --- a/src/executor/ssh.rs +++ b/src/executor/ssh.rs @@ -45,13 +45,23 @@ pub struct SshConfig { } impl SshConfig { - pub fn new(input: SshConfigInput) -> Result { - super::validate_opaque_id("host_id", &input.host_id)?; - validate_host(&input.host)?; - validate_user(&input.user)?; - if input.port == 0 { + pub(crate) fn validate_target_identity( + host_id: &str, + host: &str, + user: &str, + port: u16, + ) -> Result<()> { + super::validate_opaque_id("host_id", host_id)?; + validate_host(host)?; + validate_user(user)?; + if port == 0 { bail!("SSH port must be greater than zero"); } + Ok(()) + } + + pub fn new(input: SshConfigInput) -> Result { + Self::validate_target_identity(&input.host_id, &input.host, &input.user, input.port)?; validate_trust_file("identity_file", &input.identity_file, true)?; validate_trust_file("known_hosts_file", &input.known_hosts_file, false)?; validate_remote_absolute_path("remote_root", &input.remote_root)?; diff --git a/src/supervise.rs b/src/supervise.rs index c1761236..145bb28e 100644 --- a/src/supervise.rs +++ b/src/supervise.rs @@ -1,3 +1,4 @@ +use crate::external_agent::executor::ExecutorSelection; #[cfg(target_os = "linux")] use crate::external_agent::CODEX_WRITABLE_ROOT_PROTECTED_MOUNT_TARGETS; #[cfg(test)] @@ -253,6 +254,7 @@ pub(crate) fn live_quota_concurrency_bound( } mod plan_api; pub use plan_api::*; +mod assignment_executor; pub mod authored_checks; pub(crate) use plan_api::{ load_supervisor_plan_file_in_repo, task_routing_assessment_from_plan_document, @@ -1990,6 +1992,8 @@ struct AssignmentMetadata { source_inputs: BTreeMap>, authored_independent_checks: BTreeMap>, + /// Parent assignment and optional nested Worker id; omission selects Local. + executors: BTreeMap<(String, Option), ExecutorSelection>, /// Assignment-level mechanical duty for a direct Worker (empty nested list). /// Nested `worker_assignments` keep [`WorkerAssignmentMetadata::mechanical_duty`]. direct_mechanical_duties: BTreeMap, @@ -2037,6 +2041,8 @@ impl AssignmentMetadata { } fn retain_assignment(&mut self, assignment_id: &str) { + self.executors + .retain(|(owner, _), _| owner == assignment_id); self.workers.retain(|(owner, _), _| owner == assignment_id); self.source_inputs.retain(|owner, _| owner == assignment_id); self.authored_independent_checks @@ -2056,6 +2062,7 @@ impl From> for AssignmentMe reasoning_efforts: BTreeMap::new(), source_inputs: BTreeMap::new(), authored_independent_checks: BTreeMap::new(), + executors: BTreeMap::new(), direct_mechanical_duties: BTreeMap::new(), } } diff --git a/src/supervise/assignment_executor.rs b/src/supervise/assignment_executor.rs new file mode 100644 index 00000000..84a19a49 --- /dev/null +++ b/src/supervise/assignment_executor.rs @@ -0,0 +1,83 @@ +use super::*; + +impl AssignmentMetadata { + pub(super) fn executor( + &self, + assignment_id: &str, + worker_id: Option<&str>, + ) -> ExecutorSelection { + self.executors + .get(&(assignment_id.to_string(), worker_id.map(str::to_string))) + .cloned() + .unwrap_or_default() + } +} + +pub(super) fn parse_assignment_executor( + raw: &Value, + assignment_id: &str, + worker_id: Option<&str>, + metadata: &mut AssignmentMetadata, +) -> Result<()> { + let Some(raw_executor) = raw.get("executor") else { + return Ok(()); + }; + let selection: ExecutorSelection = + serde_json::from_value(raw_executor.clone()).with_context(|| { + format!( + "assignment '{}' executor is invalid", + worker_id.unwrap_or(assignment_id) + ) + })?; + if let Some(target) = selection.ssh_target() { + crate::executor::SshConfig::validate_target_identity( + target.host_id.as_str(), + target.endpoint.as_str(), + target.user.as_str(), + target.port.get(), + ) + .context("invalid supervised SSH target identity")?; + } + // Canonicalize explicit Local to omission so existing plan/checkpoint bindings + // remain stable. A typed remote declaration is retained, never rewritten Local. + if selection != ExecutorSelection::Local { + metadata.executors.insert( + (assignment_id.to_string(), worker_id.map(str::to_string)), + selection, + ); + } + Ok(()) +} + +pub(super) fn serialize_assignment_executor( + value: &mut Value, + assignment_id: &str, + worker_id: Option<&str>, + metadata: &AssignmentMetadata, +) -> Result<()> { + let selection = metadata.executor(assignment_id, worker_id); + if selection != ExecutorSelection::Local { + value + .as_object_mut() + .context("normalized assignment is not an object")? + .insert("executor".into(), serde_json::to_value(selection)?); + } + Ok(()) +} + +pub(super) fn validate_assignment_executors_pre_dispatch( + metadata: &AssignmentMetadata, +) -> Result<()> { + for ((assignment_id, worker_id), selection) in &metadata.executors { + if let Some(target) = selection.ssh_target() { + bail!( + "assignment '{}' SSH target '{}' is unknown or untrusted for supervised execution: \ + no authenticated target binding or production executor bridge is configured; \ + refusing before launch without Local fallback", + worker_id.as_deref().unwrap_or(assignment_id), + target.host_id.as_str() + ); + } + } + Ok(()) +} diff --git a/src/supervise/plan_api.rs b/src/supervise/plan_api.rs index b51020a8..e93962e0 100644 --- a/src/supervise/plan_api.rs +++ b/src/supervise/plan_api.rs @@ -133,6 +133,7 @@ fn validate_execution_target_pre_dispatch( loaded: &LoadedSupervisorPlan, allow_primary_worktree: bool, ) -> Result<()> { + assignment_executor::validate_assignment_executors_pre_dispatch(&loaded.assignment_metadata)?; validate_execution_target_opt_in( loaded.plan_metadata.execution_target.as_ref(), allow_primary_worktree, @@ -143,7 +144,9 @@ pub fn supervisor_plan_from_task_file( repo: impl AsRef, task_file: impl AsRef, ) -> Result { - Ok(supervisor_plan_and_consultant_from_task_file(repo, task_file)?.plan) + let loaded = supervisor_plan_and_consultant_from_task_file(repo, task_file)?; + assignment_executor::validate_assignment_executors_pre_dispatch(&loaded.assignment_metadata)?; + Ok(loaded.plan) } pub fn supervisor_plan_from_goal_spec( @@ -3636,14 +3639,18 @@ fn normalized_report_fragment_ids( } pub fn load_supervisor_plan_file(path: impl AsRef) -> Result { - Ok(load_supervisor_plan_file_with_consultant(path, None)?.plan) + let loaded = load_supervisor_plan_file_with_consultant(path, None)?; + assignment_executor::validate_assignment_executors_pre_dispatch(&loaded.assignment_metadata)?; + Ok(loaded.plan) } pub(crate) fn load_supervisor_plan_file_in_repo( path: impl AsRef, repo: impl AsRef, ) -> Result { - Ok(load_supervisor_plan_file_with_consultant(path, Some(repo.as_ref()))?.plan) + let loaded = load_supervisor_plan_file_with_consultant(path, Some(repo.as_ref()))?; + assignment_executor::validate_assignment_executors_pre_dispatch(&loaded.assignment_metadata)?; + Ok(loaded.plan) } pub(super) fn load_supervisor_plan_file_with_consultant( @@ -4372,6 +4379,12 @@ fn collect_assignment_metadata( .trim(); let assignment = assignments_by_id.get(raw_id).copied(); if let Some(assignment) = assignment { + assignment_executor::parse_assignment_executor( + raw_assignment, + &assignment.id, + None, + metadata_by_worker, + )?; if let Some(raw_checks) = raw_assignment.get("authored_independent_checks") { let checks = serde_json::from_value(raw_checks.clone()) .context("invalid authored_independent_checks")?; @@ -4436,6 +4449,12 @@ fn collect_assignment_metadata( let Some(worker) = workers_by_id.get(raw_worker_id).copied() else { continue; }; + assignment_executor::parse_assignment_executor( + raw_worker, + &assignment.id, + Some(&worker.id), + metadata_by_worker, + )?; let mut metadata: WorkerAssignmentMetadata = serde_json::from_value(raw_worker.clone()) .with_context(|| { format!( @@ -4514,6 +4533,12 @@ pub(super) fn supervisor_plan_value( .and_then(Value::as_array_mut) .context("normalized supervisor plan assignments did not serialize to an array")?; for (assignment_value, assignment) in assignments.iter_mut().zip(&plan.assignments) { + assignment_executor::serialize_assignment_executor( + assignment_value, + &assignment.id, + None, + assignment_metadata, + )?; if let Some(checks) = assignment_metadata .authored_independent_checks .get(&assignment.id) @@ -4557,6 +4582,12 @@ pub(super) fn supervisor_plan_value( .and_then(Value::as_array_mut) .context("normalized worker_assignments did not serialize to an array")?; for (worker_value, worker) in workers.iter_mut().zip(&assignment.worker_assignments) { + assignment_executor::serialize_assignment_executor( + worker_value, + &assignment.id, + Some(&worker.id), + assignment_metadata, + )?; if let Some(fragments) = plan_metadata .spec_fragment_ids_by_assignment .get(&worker.id) diff --git a/src/supervise/scheduler.rs b/src/supervise/scheduler.rs index 5a46bb28..11e5ff70 100644 --- a/src/supervise/scheduler.rs +++ b/src/supervise/scheduler.rs @@ -3775,6 +3775,7 @@ fn prepare_supervisor_run( worktree_creation: SupervisorWorktreeCreation<'_>, runtime_model_catalog: RuntimeModelCatalogAcquisition, ) -> Result { + assignment_executor::validate_assignment_executors_pre_dispatch(&loaded.assignment_metadata)?; let LoadedSupervisorPlan { mut plan, consultant, diff --git a/src/supervise/tests/plan_runtime.rs b/src/supervise/tests/plan_runtime.rs index e07ff4e2..fd2e4da5 100644 --- a/src/supervise/tests/plan_runtime.rs +++ b/src/supervise/tests/plan_runtime.rs @@ -1234,6 +1234,251 @@ fn assignment_reasoning_effort_round_trips_and_rejects_unknown_values() { ); } +fn ssh_executor_selection_json() -> Value { + json!({ + "kind": "ssh", + "target": { + "host_id": "build-host", + "endpoint": "build.example.invalid", + "user": "runner", + "port": 22, + "helper": "/usr/libexec/maco-helper", + "root": "/srv/maco" + } + }) +} + +fn executor_plan_digest(loaded: &LoadedSupervisorPlan) -> String { + normalized_supervisor_plan_sha256( + &loaded.plan, + &loaded.consultant, + &loaded.assignment_metadata, + &loaded.plan_metadata, + ) + .expect("bind executor plan") +} + +#[test] +fn assignment_executor_omission_and_explicit_local_preserve_plan_binding() { + let mut document: Value = serde_json::from_slice(&bounded_loader_plan_json()).unwrap(); + let omitted = parse_supervisor_plan_with_consultant(&document.to_string()).unwrap(); + assert_eq!( + omitted.assignment_metadata.executor("child-a", None), + ExecutorSelection::Local + ); + let normalized = supervisor_plan_value( + &omitted.plan, + &omitted.consultant, + &omitted.assignment_metadata, + &omitted.plan_metadata, + ) + .unwrap(); + assert!(normalized["assignments"][0].get("executor").is_none()); + document["assignments"][0]["executor"] = json!({"kind": "local"}); + let explicit = parse_supervisor_plan_with_consultant(&document.to_string()).unwrap(); + assert_eq!(explicit, omitted); + assert_eq!( + executor_plan_digest(&explicit), + executor_plan_digest(&omitted) + ); + assignment_executor::validate_assignment_executors_pre_dispatch(&explicit.assignment_metadata) + .expect("Local remains admitted"); +} + +#[test] +fn explicit_ssh_selection_roundtrips_and_never_falls_back() { + let mut document: Value = serde_json::from_slice(&bounded_loader_plan_json()).unwrap(); + let local = parse_supervisor_plan_with_consultant(&document.to_string()).unwrap(); + document["assignments"][0]["executor"] = ssh_executor_selection_json(); + let remote = parse_supervisor_plan_with_consultant(&document.to_string()).unwrap(); + let normalized = supervisor_plan_value( + &remote.plan, + &remote.consultant, + &remote.assignment_metadata, + &remote.plan_metadata, + ) + .unwrap(); + assert_eq!( + normalized["assignments"][0]["executor"], + ssh_executor_selection_json() + ); + assert_eq!( + parse_supervisor_plan_with_consultant(&normalized.to_string()).unwrap(), + remote + ); + let digest = executor_plan_digest(&remote); + assert_ne!(digest, executor_plan_digest(&local)); + for (field, replacement) in [ + ("host_id", json!("other-host")), + ("endpoint", json!("other.example.invalid")), + ("user", json!("other-runner")), + ("port", json!(2222)), + ("helper", json!("/opt/maco-helper")), + ("root", json!("/srv/other")), + ] { + let mut changed = document.clone(); + changed["assignments"][0]["executor"]["target"][field] = replacement; + let loaded = parse_supervisor_plan_with_consultant(&changed.to_string()).unwrap(); + assert_ne!(digest, executor_plan_digest(&loaded), "unbound {field}"); + } + + let temp = tempfile::tempdir().unwrap(); + let repo = temp.path().join("uncreated-repository"); + let options = injected_options(&repo, temp.path(), "ssh-must-not-launch"); + let error = run_supervisor_plan_with_runner_and_creation( + remote, + options, + 1, + SupervisorExecutionRuntime::Verified, + SupervisorWorktreeCreation::ExistingOnly, + Ok(RuntimeModelCatalog::LocalDeterministicFake), + &|_, _, _| panic!("SSH selection must never reach the local runner"), + ) + .expect_err("SSH must refuse before repository preparation or launch"); + let diagnostic = format!("{error:#}"); + for expected in [ + "child-a", + "build-host", + "unknown or untrusted", + "production executor bridge", + "without Local fallback", + ] { + assert!(diagnostic.contains(expected), "{diagnostic}"); + } + assert!( + !repo.exists(), + "refusal must precede worktree/artifact creation" + ); +} + +#[test] +fn assignment_executor_rejects_unknown_kinds_and_invalid_ssh_targets() { + let mut invalid = vec![ + Value::Null, + json!("ssh"), + json!({"kind": "unknown"}), + json!({"kind": "local", "target": {}}), + json!({"kind": "ssh"}), + json!({"kind": "ssh", "target": "unknown-host"}), + ]; + for (field, replacement) in [ + ("host_id", json!("")), + ("endpoint", json!("-option")), + ("endpoint", json!("host;command")), + ("user", json!("-option")), + ("port", json!(0)), + ("root", json!("/srv/../other")), + ("helper", json!("relative/helper")), + ("identity_file", json!("/private/key")), + ("known_hosts_file", json!("/private/hosts")), + ] { + let mut selection = ssh_executor_selection_json(); + selection["target"][field] = replacement; + invalid.push(selection); + } + for selection in invalid { + let mut document: Value = serde_json::from_slice(&bounded_loader_plan_json()).unwrap(); + document["assignments"][0]["executor"] = selection.clone(); + parse_supervisor_plan_with_consultant(&document.to_string()) + .expect_err(&format!("invalid executor accepted: {selection}")); + } +} + +#[test] +fn ssh_executor_cannot_be_erased_by_plan_only_loaders() { + let temp = tempfile::tempdir().unwrap(); + Repository::init(temp.path()).unwrap(); + let plan_file = temp.path().join("plan.json"); + let mut document: Value = serde_json::from_slice(&bounded_loader_plan_json()).unwrap(); + document["assignments"][0]["executor"] = ssh_executor_selection_json(); + fs::write(&plan_file, document.to_string()).unwrap(); + for result in [ + load_supervisor_plan_file(&plan_file), + load_supervisor_plan_file_in_repo(&plan_file, temp.path()), + supervisor_plan_from_task_file(temp.path(), &plan_file), + ] { + let error = result.expect_err("plan-only API must not discard SSH selection"); + assert!(format!("{error:#}").contains("without Local fallback")); + } + let normalized = supervisor_plan_document_from_task_file(temp.path(), &plan_file) + .expect("document API preserves selection without granting launch authority"); + assert_eq!( + normalized["assignments"][0]["executor"], + ssh_executor_selection_json() + ); +} + +#[test] +fn recursive_and_nested_worker_executors_remain_bound_and_fail_closed() { + let source = json!({ + "version": 1, "task": "recursive executor selection", "max_depth": 3, + "max_child_assignments": 2, + "assignments": [{ + "id": "parent", "phase": "execution", "assigned_paths": ["src/root.rs"], + "worker_assignments": [{"id": "worker", "assigned_paths": ["src/root.rs"]}], + "authored_independent_checks": [{ + "worker_id": "worker", "assigned_paths": ["src/root.rs"], + "check_path": "checks/check_root.py", + "argv": ["/usr/bin/python3", "-I", "-B", "checks/check_root.py"] + }], + "child_assignments": [{ + "id": "child", "phase": "execution", "assigned_paths": ["src/child.rs"], + "worker_assignments": [] + }] + }] + }); + let local = parse_supervisor_plan_with_consultant(&source.to_string()).unwrap(); + for nested_worker in [false, true] { + let mut document = source.clone(); + let (owner, worker) = if nested_worker { + document["assignments"][0]["worker_assignments"][0]["executor"] = + ssh_executor_selection_json(); + ("parent", Some("worker")) + } else { + document["assignments"][0]["child_assignments"][0]["executor"] = + ssh_executor_selection_json(); + ("child", None) + }; + let loaded = parse_supervisor_plan_with_consultant(&document.to_string()).unwrap(); + assert!(loaded + .assignment_metadata + .executor(owner, worker) + .ssh_target() + .is_some()); + assert_eq!( + loaded.assignment_metadata.executor("parent", None), + ExecutorSelection::Local + ); + assert_ne!(executor_plan_digest(&loaded), executor_plan_digest(&local)); + let normalized = supervisor_plan_value( + &loaded.plan, + &loaded.consultant, + &loaded.assignment_metadata, + &loaded.plan_metadata, + ) + .unwrap(); + assert_eq!( + normalized["assignments"][0]["authored_independent_checks"], + source["assignments"][0]["authored_independent_checks"] + ); + let reparsed = parse_supervisor_plan_with_consultant(&normalized.to_string()).unwrap(); + assert_eq!(reparsed, loaded); + assert_eq!( + executor_plan_digest(&reparsed), + executor_plan_digest(&loaded) + ); + assignment_executor::validate_assignment_executors_pre_dispatch( + &reparsed.assignment_metadata, + ) + .expect_err("nested SSH must also refuse"); + let mut retained = loaded.assignment_metadata.clone(); + retained.retain_assignment(owner); + assert_eq!(retained.executors, loaded.assignment_metadata.executors); + retained.retain_assignment("unrelated"); + assert!(retained.executors.is_empty()); + } +} + #[test] fn supervisor_plan_loads_executable_stacked_review_lens_configuration() { let mut value = From 60cd9e5de45dfdbbc2a283ca8d551468a5709b91 Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:04:38 +0900 Subject: [PATCH 24/27] Keep selector input errors and procedural lens journaling fail-closed Follow-up fix. --- src/supervise/selection_bridge.rs | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/supervise/selection_bridge.rs b/src/supervise/selection_bridge.rs index 88cd7b19..e6e7d429 100644 --- a/src/supervise/selection_bridge.rs +++ b/src/supervise/selection_bridge.rs @@ -1158,17 +1158,31 @@ pub(super) fn initialize_supervisor_selection_with_assessment( provenance: decision, }); } - if !report.compatible { + // An unavailable authored lens is refused by the existing review dispatch + // path, which records its procedural verdict and journal entry. The pure + // preflight report still includes that diagnostic without granting a lens + // launch authorization here. + let first_failure = report.diagnostics.iter().find(|entry| { + entry.status != selection_preflight::SelectionPreflightStatus::Compatible + && !(entry.lens_id.is_some() + && entry.rejection + == Some(selection_preflight::SelectionPreflightRejection::ModelUnavailable)) + }); + if let Some(first) = first_failure { let message = selection_preflight::refusal_message(&report)?; // Preserve malformed-input errors while reporting every reachable failure. if !report.frozen_inputs.unavailable.is_empty() { bail!("{message}"); } - let first = report - .diagnostics - .iter() - .find(|entry| entry.status != selection_preflight::SelectionPreflightStatus::Compatible) - .context("incompatible selection preflight omitted its diagnostic")?; + if let Some(invalid) = report.diagnostics.iter().find(|entry| { + entry.rejection == Some(selection_preflight::SelectionPreflightRejection::InvalidInput) + }) { + bail!( + "automatic selector rejected role '{}': {}; {message}", + invalid.role.as_str(), + invalid.message + ); + } return Ok(SupervisorSelectionResolution { mode: if automatic { SupervisorSelectionMode::Automatic @@ -1190,7 +1204,7 @@ pub(super) fn initialize_supervisor_selection_with_assessment( }), }); } - // All roles and lenses passed. Only now may selection authority be bound. + // All role and nonprocedural lens gates passed. Only now bind role authority. let mut resolved = BTreeMap::new(); for event in &decisions { let choice = match executable_choice(&event.provenance, runtime, event.role)? { From a4468b5fe03c5c21943bef2077537f4dd7ef883e Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:04:47 +0900 Subject: [PATCH 25/27] Pin a standalone fixture in the native exec contract test The mechanical native-exec contract test pinned the whole debug libtest image as its target. As the crate grew, that image reached about 253.8 MiB, close to the 256 MiB product bound for pinned executables, and an integration build that added more code failed the test with `FileTooLarge`. - Compile a small standalone Rust fixture as the pinned target and keep libtest only as the isolated launcher. - Keep the exact argv and environment, null stdin, relay descriptor identity and closure, and request-consumption assertions. - The production executable bound and behavior are unchanged. Validation: formatting and the mechanical native, sealed-native and sealed-script exec tests passed on Linux; Clippy and the full suites run in CI. --- Cargo.toml | 3 +- src/pinned_exec.rs | 69 +++++++------------ tests/fixtures/pinned_exec/native_contract.rs | 57 +++++++++++++++ 3 files changed, 83 insertions(+), 46 deletions(-) create mode 100644 tests/fixtures/pinned_exec/native_contract.rs diff --git a/Cargo.toml b/Cargo.toml index 7e5740d2..79556ad5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,6 +79,5 @@ criterion = "0.8.2" debug = "line-tables-only" [profile.test] -# Test binaries are self-sealed and re-executed under the production 256MiB bound. -# Omit test debuginfo instead of raising MAX_EXECUTABLE_BYTES. +# Keep test artifacts small; native-exec tests pin a standalone target image. debug = 0 diff --git a/src/pinned_exec.rs b/src/pinned_exec.rs index 2354bb7c..b803234c 100644 --- a/src/pinned_exec.rs +++ b/src/pinned_exec.rs @@ -3386,46 +3386,6 @@ mod tests { #[test] fn mechanical_native_exec_preserves_argv_env_null_stdin_and_closes_relay_fds() { const TEST: &str = "pinned_exec::tests::mechanical_native_exec_preserves_argv_env_null_stdin_and_closes_relay_fds"; - let target_argv0 = env::args_os().next() == Some(OsString::from("/native-contract-argv0")); - if target_argv0 || env::var_os("MACO_NATIVE_CONTRACT_TARGET").is_some() { - // Either target discriminator enters mandatory assertions, not a - // success fallback. Lost environment must fail rather than recurse. - assert!(env::var_os("MACO_NATIVE_CONTRACT_TARGET").is_some()); - assert_eq!( - env::args_os().collect::>(), - [ - OsString::from("/native-contract-argv0"), - OsString::from("--exact"), - OsString::from(TEST), - OsString::from("--nocapture") - ] - ); - assert_eq!( - env::var("MACO_NATIVE_CONTRACT_VALUE").expect("explicit value"), - "bounded value" - ); - assert!(env::var_os("MACO_NATIVE_CONTRACT_CHILD").is_none()); - assert!(env::var_os("HOME").is_none()); - let mut byte = [0]; - assert_eq!(io::stdin().read(&mut byte).expect("null stdin"), 0); - let relay = env::var("MACO_NATIVE_CONTRACT_RELAY").expect("test image identity"); - for fd in [4, 51] { - // SAFETY: plain stat storage is initialized by fstat for the - // numeric FD; failure must be EBADF, never an ignored error. - let mut metadata: libc::stat = unsafe { std::mem::zeroed() }; - if unsafe { libc::fstat(fd, &mut metadata) } == 0 { - assert_ne!( - format!("{}:{}", metadata.st_dev, metadata.st_ino), - relay, - "an inherited native image copy survived exec" - ); - } else { - assert_eq!(io::Error::last_os_error().raw_os_error(), Some(libc::EBADF)); - } - } - println!("mechanical-native-contract-ok"); - return; - } if let Some(path) = env::var_os("MACO_NATIVE_CONTRACT_CHILD") { // Reproduce the guardian's non-CLOEXEC stdin copy and an extra // relayed copy. Both must disappear in the final native image. @@ -3441,14 +3401,34 @@ mod tests { panic!("mechanical native exec returned"); } let temp = tempfile::tempdir().expect("held request owner"); - let executable = env::current_exe().expect("libtest executable"); + // Pin only this standalone target, so libtest growth cannot exceed the + // production executable bound. The libtest child still runs the exec path. + let source = temp.path().join("native_contract.rs"); + fs::write( + &source, + include_str!("../tests/fixtures/pinned_exec/native_contract.rs"), + ) + .expect("standalone native contract source"); + let executable = temp.path().join("native_contract"); + let compiler = env::var_os("RUSTC").unwrap_or_else(|| OsString::from("rustc")); + let build = std::process::Command::new(compiler) + .args(["--edition=2021", "-C", "debuginfo=0", "-o"]) + .arg(&executable) + .arg(&source) + .output() + .expect("build standalone native contract target"); + assert!( + build.status.success(), + "native contract build failed: {}", + String::from_utf8_lossy(&build.stderr) + ); let pinned = PinnedDirectExecutable::capture_for_test(&executable).expect("native image"); let args = [ OsString::from("--exact"), OsString::from(TEST), OsString::from("--nocapture"), ]; - let image = materialize_sealed(&pinned.source).expect("sealed libtest image"); + let image = materialize_sealed(&pinned.source).expect("sealed native contract image"); let relay_metadata = image.file.metadata().expect("test image metadata"); let environment = [ ( @@ -3480,7 +3460,7 @@ mod tests { let path = temp.path().join(PINNED_EXEC_DESCRIPTOR_NAME); fs::write(&path, encoded.bytes()).expect("owned descriptor"); fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("private mode"); - let result = std::process::Command::new(&executable) + let result = std::process::Command::new(env::current_exe().expect("libtest executable")) .args(["--exact", TEST, "--nocapture"]) .env_clear() .env("MACO_NATIVE_CONTRACT_CHILD", &path) @@ -3492,7 +3472,8 @@ mod tests { .expect("owned provider-free child"); assert!( result.status.success(), - "provider-free mechanical native contract failed" + "provider-free mechanical native contract failed: {}", + String::from_utf8_lossy(&result.stderr) ); assert!(String::from_utf8_lossy(&result.stdout).contains("mechanical-native-contract-ok")); assert!(!path.exists(), "request was consumed by identity"); diff --git a/tests/fixtures/pinned_exec/native_contract.rs b/tests/fixtures/pinned_exec/native_contract.rs new file mode 100644 index 00000000..21bca87a --- /dev/null +++ b/tests/fixtures/pinned_exec/native_contract.rs @@ -0,0 +1,57 @@ +//! Standalone Linux exec target, compiled without the main library or libtest. + +use std::{ + env, + ffi::OsString, + fs::File, + io::{self, Read}, + mem::ManuallyDrop, + os::{fd::FromRawFd, unix::fs::MetadataExt}, +}; + +unsafe extern "C" { + fn fcntl(fd: i32, command: i32, ...) -> i32; +} + +fn main() { + const TEST: &str = "pinned_exec::tests::mechanical_native_exec_preserves_argv_env_null_stdin_and_closes_relay_fds"; + assert_eq!( + env::args_os().collect::>(), + [ + OsString::from("/native-contract-argv0"), + OsString::from("--exact"), + OsString::from(TEST), + OsString::from("--nocapture"), + ] + ); + assert_eq!(env::var("MACO_NATIVE_CONTRACT_TARGET").unwrap(), "1"); + assert_eq!( + env::var("MACO_NATIVE_CONTRACT_VALUE").expect("explicit value"), + "bounded value" + ); + assert!(env::var_os("MACO_NATIVE_CONTRACT_CHILD").is_none()); + assert!(env::var_os("HOME").is_none()); + let mut byte = [0]; + assert_eq!(io::stdin().read(&mut byte).expect("null stdin"), 0); + let relay = env::var("MACO_NATIVE_CONTRACT_RELAY").expect("test image identity"); + for fd in [4, 51] { + // Linux F_GETFD and EBADF; keep this fixture independent of libc crates. + const F_GETFD: i32 = 1; + const EBADF: i32 = 9; + // SAFETY: F_GETFD reads flags for this numeric fd without accessing memory. + if unsafe { fcntl(fd, F_GETFD) } >= 0 { + // SAFETY: fcntl verified this fd is live. This single-threaded target + // only borrows it for metadata; ManuallyDrop preserves its ownership. + let file = ManuallyDrop::new(unsafe { File::from_raw_fd(fd) }); + let metadata = file.metadata().expect("inspect inherited fd"); + assert_ne!( + format!("{}:{}", metadata.dev(), metadata.ino()), + relay, + "an inherited native image copy survived exec" + ); + } else { + assert_eq!(io::Error::last_os_error().raw_os_error(), Some(EBADF)); + } + } + println!("mechanical-native-contract-ok"); +} From cf0779026fe7517f8cc4498679874a7b6deb97f0 Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:04:00 +0900 Subject: [PATCH 26/27] Update libz-sys for RUSTSEC-2026-0418 RUSTSEC-2026-0418 (published 2026-10-28): the `libz-sys` 1.1.28 build script leaks environment variables into the build directory. `cargo audit --deny warnings` in the supply-chain workflow now fails on main. - Update `libz-sys` to 1.1.31, the first fixed release, with `cargo update -p libz-sys --precise 1.1.31`. It is pulled in through `git2` and `libgit2-sys`, whose `^1.1` requirement already allows it. - No other dependency changes; the desktop workspace lockfile does not contain `libz-sys`. Validation: `cargo audit --deny warnings`, `cargo deny` (advisories, bans, licenses, sources), the desktop supply-chain checks and `cargo check --locked` passed on Linux; MSRV and packaging run in CI. --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1b24d800..c8c570f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1387,9 +1387,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.28" +version = "1.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" +checksum = "ff20308ecabd635a2dc1dd5834a86c462cca20efba1d5d3ddab1f8aea3455cb2" dependencies = [ "cc", "libc", From 763cc1ce8d50ad68e27d3987a565c2c64cc101eb Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Fri, 30 Oct 2026 02:28:24 +0900 Subject: [PATCH 27/27] Scale the merge-apply validation readiness wait in tests The integration test `merge_apply_revalidates_clean_committed_primary_after_candidate_validation` failed intermittently on loaded ubuntu-26.04 runners (merge test binary 832-843 s instead of the usual 611-638 s) with `candidate validation sandbox for apply ... did not become ready before timeout`. The error came from the test helper, which allowed a fixed 30 s from spawning apply until the paused validation command wrote its readiness marker. That window covers preview capture, the repository lock, isolated index setup, base materialization, patch application and two integrity captures, each a separately contained Git operation, so it grows with runner load. - Replace the fixed wait with a bounded test-only budget derived from the measured preview latency, with a floor and a ceiling, and report the budget on timeout. - Fail promptly when apply has already exited, and keep the exact marker and containment proof and the kill/wait cleanup. - Add virtual-time regressions for slow readiness, a missing marker, budget bounds and late probes. Production deadlines and behavior are unchanged; only `tests/merge_cli.rs` changes. Validation: formatting, the new regressions and the affected integration tests passed on Linux. --- tests/merge_cli.rs | 158 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 139 insertions(+), 19 deletions(-) diff --git a/tests/merge_cli.rs b/tests/merge_cli.rs index a87a56b7..d5e718d7 100644 --- a/tests/merge_cli.rs +++ b/tests/merge_cli.rs @@ -1282,6 +1282,7 @@ fn merge_apply_revalidates_clean_committed_primary_after_candidate_validation() .context("edit worktree")?; let runtime_root = candidate_validation_runtime_root()?; let existing_runtime_entries = candidate_validation_runtime_entries(&runtime_root)?; + let preview_started = Instant::now(); let (apply_args, _review_directory) = bind_reviewed_preview_if_needed(&[ "merge", "apply", @@ -1295,14 +1296,19 @@ fn merge_apply_revalidates_clean_committed_primary_after_candidate_validation() "--force-stale-base", "--json", ])?; + let readiness_budget = candidate_validation_readiness_budget(preview_started.elapsed()); let mut apply = Command::new(BIN) .args(apply_args) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .context("start merge apply")?; - let validation_sandbox = - wait_for_candidate_validation_ready(&mut apply, &runtime_root, &existing_runtime_entries)?; + let validation_sandbox = wait_for_candidate_validation_ready( + &mut apply, + &runtime_root, + &existing_runtime_entries, + readiness_budget, + )?; fs::write( repo_path.join("src/lib.rs"), @@ -1424,6 +1430,7 @@ fn pr_publish_cannot_run_while_merge_apply_validates_candidate() -> Result<()> { let runtime_root = candidate_validation_runtime_root()?; let existing_runtime_entries = candidate_validation_runtime_entries(&runtime_root)?; + let preview_started = Instant::now(); let (apply_args, _review_directory) = bind_reviewed_preview_if_needed(&[ "merge", "apply", @@ -1436,14 +1443,19 @@ fn pr_publish_cannot_run_while_merge_apply_validates_candidate() -> Result<()> { PAUSED_CANDIDATE_VALIDATION_COMMAND, "--json", ])?; + let readiness_budget = candidate_validation_readiness_budget(preview_started.elapsed()); let mut apply = Command::new(BIN) .args(apply_args) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .context("start merge apply")?; - let validation_sandbox = - wait_for_candidate_validation_ready(&mut apply, &runtime_root, &existing_runtime_entries)?; + let validation_sandbox = wait_for_candidate_validation_ready( + &mut apply, + &runtime_root, + &existing_runtime_entries, + readiness_budget, + )?; let publish = Command::new(BIN) .args([ @@ -2771,15 +2783,31 @@ fn candidate_validation_runtime_entries(runtime_root: &Path) -> Result Duration { + // Apply repeats two preview captures, then materializes and captures matching sandbox + // integrity snapshots before dispatching validation. Use four observed preview durations + // to cover that work on slow runners, retaining the fast-host floor and a finite ceiling + // equal to the validation command's own ten-minute timeout. Production budgets are unchanged. + preview_elapsed + .saturating_mul(4) + .clamp(Duration::from_secs(30), Duration::from_secs(600)) +} + fn wait_for_candidate_validation_ready( apply: &mut Child, runtime_root: &Path, existing_runtime_entries: &BTreeSet, + budget: Duration, ) -> Result { - let result = (|| { - let prefix = format!("maco-candidate-validation-{}-", apply.id()); - let deadline = Instant::now() + Duration::from_secs(30); - loop { + let apply_pid = apply.id(); + let prefix = format!("maco-candidate-validation-{apply_pid}-"); + let result = wait_for_candidate_validation_ready_with_clock( + apply_pid, + budget, + || { + if let Some(status) = apply.try_wait().context("poll merge apply")? { + bail!("merge apply exited with {status} before candidate validation became ready"); + } let mut matches = Vec::new(); for name in candidate_validation_runtime_entries(runtime_root)? { if existing_runtime_entries.contains(&name) @@ -2802,25 +2830,21 @@ fn wait_for_candidate_validation_ready( if matches.len() > 1 { anyhow::bail!( "found multiple fresh candidate validation sandboxes for apply pid {}", - apply.id() + apply_pid ); } if let Some(sandbox) = matches.pop() { if fs::symlink_metadata(sandbox.join("validation-ready")) .is_ok_and(|metadata| metadata.file_type().is_file()) { - return Ok(sandbox); + return Ok(Some(sandbox)); } } - if Instant::now() >= deadline { - anyhow::bail!( - "candidate validation sandbox for apply pid {} did not become ready before timeout", - apply.id() - ); - } - thread::sleep(Duration::from_millis(20)); - } - })(); + Ok(None) + }, + Instant::now, + thread::sleep, + ); if result.is_err() { let _ = apply.kill(); let _ = apply.wait(); @@ -2828,6 +2852,102 @@ fn wait_for_candidate_validation_ready( result } +fn wait_for_candidate_validation_ready_with_clock( + apply_pid: u32, + budget: Duration, + mut probe: impl FnMut() -> Result>, + mut now: impl FnMut() -> Instant, + mut pause: impl FnMut(Duration), +) -> Result { + let deadline = now() + budget; + loop { + if now() >= deadline { + bail!( + "candidate validation sandbox for apply pid {apply_pid} did not become ready before timeout (budget {budget:?})" + ); + } + let ready = probe()?; + let observed = now(); + if observed >= deadline { + bail!( + "candidate validation sandbox for apply pid {apply_pid} did not become ready before timeout (budget {budget:?})" + ); + } + if let Some(sandbox) = ready { + return Ok(sandbox); + } + pause(Duration::from_millis(20).min(deadline - observed)); + } +} + +#[test] +fn candidate_validation_readiness_budget_scales_with_preview_latency() { + for (preview_seconds, budget_seconds) in [(0, 30), (5, 30), (20, 80), (150, 600)] { + assert_eq!( + candidate_validation_readiness_budget(Duration::from_secs(preview_seconds)), + Duration::from_secs(budget_seconds) + ); + } + assert_eq!( + candidate_validation_readiness_budget(Duration::MAX), + Duration::from_secs(600) + ); +} + +#[test] +fn candidate_validation_readiness_wait_obeys_absolute_adaptive_deadline() -> Result<()> { + use std::cell::Cell; + + let start = Instant::now(); + // A real marker after 35 seconds must outlive the old fixed wait on a slow host. + // A missing marker, even with repeated successful probes, must still time out. + for (preview_seconds, ready_at, expected_ready) in [ + (20, Some(35), true), + (0, Some(35), false), + (20, Some(80), false), + (20, None, false), + (200, None, false), + ] { + let elapsed = Cell::new(Duration::ZERO); + let budget = candidate_validation_readiness_budget(Duration::from_secs(preview_seconds)); + let result = wait_for_candidate_validation_ready_with_clock( + 42, + budget, + || { + Ok(ready_at + .filter(|seconds| elapsed.get() >= Duration::from_secs(*seconds)) + .map(|_| PathBuf::from("sandbox"))) + }, + || start + elapsed.get(), + |duration| elapsed.set(elapsed.get() + duration), + ); + assert_eq!(result.is_ok(), expected_ready); + if expected_ready { + assert_eq!(result?, PathBuf::from("sandbox")); + assert_eq!(elapsed.get(), Duration::from_secs(35)); + } else { + assert!(result.unwrap_err().to_string().contains("before timeout")); + assert_eq!(elapsed.get(), budget); + } + } + + // A probe that returns a marker only after using up the budget is also too late. + let elapsed = Cell::new(Duration::ZERO); + let budget = candidate_validation_readiness_budget(Duration::ZERO); + let result = wait_for_candidate_validation_ready_with_clock( + 42, + budget, + || { + elapsed.set(budget); + Ok(Some(PathBuf::from("sandbox"))) + }, + || start + elapsed.get(), + |_| panic!("a late probe must not renew the deadline"), + ); + assert!(result.unwrap_err().to_string().contains("before timeout")); + Ok(()) +} + fn run_success_json(args: &[&str]) -> Result { let (args, _review_directory) = bind_reviewed_preview_if_needed(args)?; let output = Command::new(BIN).args(&args).output().context("run maco")?;