diff --git a/.github/workflows/ark-turn.yml b/.github/workflows/ark-turn.yml index dab0b4be4e..e2839df5f9 100644 --- a/.github/workflows/ark-turn.yml +++ b/.github/workflows/ark-turn.yml @@ -48,8 +48,7 @@ jobs: tests/test_dsh_goal_mode.py tests/test_turn_managed_executor_binding.py tests/test_ark_managed_agent_host.py tests/test_loopx_turn_driver.py tests/test_collaboration_mcp.py - tests/test_auto_research_artifact_receipt.py - tests/test_worker_command_validation.py tests/test_workspace_story_demo.py + tests/test_workspace_story_demo.py tests/test_local_delegation.py tests/test_managed_research_scenario.py tests/test_managed_research_team.py - name: Lint optional package and example diff --git a/apps/desktop/loopx-control-plane/src-tauri/Cargo.lock b/apps/desktop/loopx-control-plane/src-tauri/Cargo.lock index 76eef452a1..18dcedf15f 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/Cargo.lock +++ b/apps/desktop/loopx-control-plane/src-tauri/Cargo.lock @@ -19,15 +19,15 @@ dependencies = [ [[package]] name = "alloc-no-stdlib" -version = "2.0.4" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" +checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a" [[package]] name = "alloc-stdlib" -version = "0.2.4" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" dependencies = [ "alloc-no-stdlib", ] @@ -47,15 +47,6 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - [[package]] name = "async-broadcast" version = "0.7.2" @@ -234,6 +225,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bit-set" version = "0.8.0" @@ -315,9 +312,9 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.4" +version = "9.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +checksum = "627b7cfb0578f0eafce087a90fcd40add1231900b69e4d4de2d456260a4f5d81" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -326,9 +323,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.3" +version = "6.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +checksum = "97d9071e5b45944c5a9c06baf13a7a65ffa2c88c204015967852c90869692c05" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -429,12 +426,13 @@ dependencies = [ [[package]] name = "cargo_toml" -version = "0.22.3" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +checksum = "82f4b26e751e711a5302649417f2da046dce6391b2ea30a4820f37462314f0b9" dependencies = [ + "semver", "serde", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", ] [[package]] @@ -455,13 +453,13 @@ checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" [[package]] name = "cfb" -version = "0.7.3" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3" dependencies = [ - "byteorder", "fnv", "uuid", + "web-time", ] [[package]] @@ -656,9 +654,9 @@ dependencies = [ [[package]] name = "cssparser" -version = "0.36.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" dependencies = [ "cssparser-macros", "dtoa-short", @@ -669,29 +667,19 @@ dependencies = [ [[package]] name = "cssparser-macros" -version = "0.6.1" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" dependencies = [ "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "ctor" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" -dependencies = [ - "ctor-proc-macro", - "dtor", -] - -[[package]] -name = "ctor-proc-macro" -version = "0.0.7" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" [[package]] name = "darling" @@ -778,17 +766,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "derive_more" version = "2.1.1" @@ -834,9 +811,9 @@ dependencies = [ [[package]] name = "dirs" -version = "6.0.0" +version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" dependencies = [ "dirs-sys", ] @@ -901,9 +878,9 @@ dependencies = [ [[package]] name = "dom_query" -version = "0.27.0" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc" dependencies = [ "bit-set", "cssparser", @@ -938,21 +915,6 @@ dependencies = [ "dtoa", ] -[[package]] -name = "dtor" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" - [[package]] name = "dunce" version = "1.0.5" @@ -1572,9 +1534,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "html5ever" -version = "0.38.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" dependencies = [ "log", "markup5ever", @@ -1714,12 +1676,12 @@ dependencies = [ [[package]] name = "ico" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +checksum = "d75e94d66081e4d8dcd098bd8dc5254800fea14a26389b298410158d834de167" dependencies = [ "byteorder", - "png 0.17.16", + "png", ] [[package]] @@ -1857,9 +1819,9 @@ dependencies = [ [[package]] name = "infer" -version = "0.19.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +checksum = "f4200d433cbd5178df7797c9c2e75b348b728e39631cf14520d1e2fc424201f4" dependencies = [ "cfb", ] @@ -2058,21 +2020,21 @@ dependencies = [ [[package]] name = "json-patch" -version = "3.0.1" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72" dependencies = [ "jsonptr", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.20", ] [[package]] name = "jsonptr" -version = "0.6.3" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" dependencies = [ "serde", "serde_json", @@ -2080,13 +2042,12 @@ dependencies = [ [[package]] name = "keyboard-types" -version = "0.7.0" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" dependencies = [ "bitflags 2.13.1", "serde", - "unicode-segmentation", ] [[package]] @@ -2183,7 +2144,7 @@ dependencies = [ "command-group", "flate2", "libc", - "minisign-verify", + "minisign-verify 0.2.5", "objc2", "open", "pep440_rs", @@ -2199,7 +2160,7 @@ dependencies = [ "tauri-plugin-single-instance", "tauri-plugin-updater", "tempfile", - "zip 9.0.5", + "zip", ] [[package]] @@ -2218,9 +2179,9 @@ dependencies = [ [[package]] name = "markup5ever" -version = "0.38.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" dependencies = [ "log", "tendril", @@ -2254,6 +2215,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" +[[package]] +name = "minisign-verify" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2287,9 +2254,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.19.3" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +checksum = "cca139e57da4383727f189e43e66e84fa372347027fc996f9f1a007ec0a37996" dependencies = [ "crossbeam-channel", "dpi", @@ -2300,7 +2267,7 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation", "once_cell", - "png 0.18.1", + "png", "serde", "thiserror 2.0.20", "windows-sys 0.61.2", @@ -2321,6 +2288,12 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -2431,6 +2404,7 @@ dependencies = [ "objc2", "objc2-core-foundation", "objc2-foundation", + "objc2-quartz-core", ] [[package]] @@ -2831,19 +2805,6 @@ dependencies = [ "time", ] -[[package]] -name = "png" -version = "0.17.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" -dependencies = [ - "bitflags 1.3.2", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide 0.8.9", -] - [[package]] name = "png" version = "0.18.1" @@ -3372,9 +3333,9 @@ dependencies = [ [[package]] name = "selectors" -version = "0.36.1" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" dependencies = [ "bitflags 2.13.1", "cssparser", @@ -3700,6 +3661,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_vcruntime" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7589a1859b09232522bb9edf076ca7fcf14ded96685d3fa629db73023ffbb7" + [[package]] name = "string_cache" version = "0.9.0" @@ -3738,9 +3705,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "swift-rs" -version = "1.0.7" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +checksum = "cefd87076fd385308ee4aff597256c902f1fbb4d031de1558efc12f8ffefadd9" dependencies = [ "base64 0.21.7", "serde", @@ -3835,9 +3802,9 @@ dependencies = [ [[package]] name = "tao" -version = "0.35.3" +version = "0.37.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +checksum = "d5777b06fe6435126ae8d816eba269e050e1e10d302cf5461e9c49eccabc3548" dependencies = [ "bitflags 2.13.1", "block2", @@ -3855,20 +3822,20 @@ dependencies = [ "libc", "log", "ndk", + "ndk-context", "ndk-sys", "objc2", "objc2-app-kit", "objc2-foundation", "objc2-ui-kit", - "once_cell", "parking_lot", "percent-encoding", "raw-window-handle", "tao-macros", "unicode-segmentation", "url", - "windows", - "windows-core 0.61.2", + "windows 0.62.2", + "windows-core 0.62.2", "windows-version", "x11-dl", ] @@ -3903,9 +3870,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.11.5" +version = "2.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +checksum = "5fb0244332cfb9cee7e2a568bf5da29a36ed8fdbd717f6f47a66b6b7b358c072" dependencies = [ "anyhow", "bytes", @@ -3949,14 +3916,14 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.62.2", ] [[package]] name = "tauri-build" -version = "2.6.3" +version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +checksum = "adddb0e648752aaac5da9c3c3c6a68e1e87cab55e3b757b18a9441ef8c3ac614" dependencies = [ "anyhow", "cargo_toml", @@ -3968,6 +3935,8 @@ dependencies = [ "semver", "serde", "serde_json", + "static_vcruntime", + "strsim", "tauri-utils", "tauri-winres", "walkdir", @@ -3975,16 +3944,16 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.6.3" +version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +checksum = "fb37631cf71dab5e8ab55e1bdd701e18a52653d90b78e71d0e5598b1e0a61316" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "brotli", "ico", "json-patch", "plist", - "png 0.17.16", + "png", "proc-macro2", "quote", "semver", @@ -4002,9 +3971,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.6.3" +version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +checksum = "0cc38db58854c10fb5f91a887ed8bf82d3239bb92b5d8db86eda09e96d9963d1" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -4016,9 +3985,9 @@ dependencies = [ [[package]] name = "tauri-plugin" -version = "2.6.3" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +checksum = "67fb21a77e8f94e8beb3325e583102488659365af0a3948b7ea244c5b779c291" dependencies = [ "anyhow", "glob", @@ -4067,18 +4036,19 @@ dependencies = [ [[package]] name = "tauri-plugin-updater" -version = "2.11.0" +version = "2.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b28d8cabdeb0564f03ae261963de4bc3d98321cd3d213e76a81b7d344e5df606" +checksum = "d957abbcb3f9f6c76d22a3a0f012f30880640d0bccb5634d2b12a9937e4c872b" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "dirs", "flate2", "futures-util", "http", "infer", + "libc", "log", - "minisign-verify", + "minisign-verify 0.3.0", "osakit", "percent-encoding", "reqwest", @@ -4094,15 +4064,15 @@ dependencies = [ "time", "tokio", "url", - "windows-sys 0.60.2", - "zip 4.6.1", + "windows-sys 0.61.2", + "zip", ] [[package]] name = "tauri-runtime" -version = "2.11.3" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +checksum = "7c55766d99cea69055904d1735b93c2049302b2e09effd462120deb5581d78e9" dependencies = [ "cookie", "dpi", @@ -4120,14 +4090,14 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.62.2", ] [[package]] name = "tauri-runtime-wry" -version = "2.11.4" +version = "2.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +checksum = "e1aa9d8b7a120f879f44fdc1053f7b7dbf416bf0bbc6a72b714dec46b782b52f" dependencies = [ "gtk", "http", @@ -4135,7 +4105,6 @@ dependencies = [ "log", "objc2", "objc2-app-kit", - "once_cell", "percent-encoding", "raw-window-handle", "softbuffer", @@ -4145,15 +4114,15 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.62.2", "wry", ] [[package]] name = "tauri-utils" -version = "2.9.3" +version = "2.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +checksum = "362ab21cd4cc713f2a02b1a9b5688198b1541cfb6907c4281e9190e0f0ef4aa3" dependencies = [ "anyhow", "brotli", @@ -4205,7 +4174,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" dependencies = [ "thiserror 2.0.20", - "windows", + "windows 0.61.3", "windows-version", ] @@ -4375,21 +4344,6 @@ dependencies = [ "toml_edit 0.20.2", ] -[[package]] -name = "toml" -version = "0.9.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" -dependencies = [ - "indexmap 2.14.0", - "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 0.7.15", -] - [[package]] name = "toml" version = "1.1.4+spec-1.1.0" @@ -4414,15 +4368,6 @@ dependencies = [ "serde", ] -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -4561,9 +4506,9 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.24.2" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" +checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72" dependencies = [ "crossbeam-channel", "dirs", @@ -4575,7 +4520,7 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation", "once_cell", - "png 0.18.1", + "png", "serde", "thiserror 2.0.20", "windows-sys 0.61.2", @@ -4616,47 +4561,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "unic-char-property" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" -dependencies = [ - "unic-char-range", -] - -[[package]] -name = "unic-char-range" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" - -[[package]] -name = "unic-common" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" - -[[package]] -name = "unic-ucd-ident" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" -dependencies = [ - "unic-char-property", - "unic-char-range", - "unic-ucd-version", -] - -[[package]] -name = "unic-ucd-version" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" -dependencies = [ - "unic-common", -] - [[package]] name = "unicode-ident" version = "1.0.24" @@ -4702,13 +4606,13 @@ dependencies = [ [[package]] name = "urlpattern" -version = "0.3.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +checksum = "df16f50ef4cc145211879a3867ba757076b25dfee812040dcb0658bd9ae7904b" dependencies = [ + "icu_properties", "regex", "serde", - "unic-ucd-ident", "url", ] @@ -4874,6 +4778,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.6" @@ -4941,16 +4855,14 @@ dependencies = [ [[package]] name = "webview2-com" -version = "0.38.2" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +checksum = "3f89fca7a704cee10dcb3654c1dbb8941d1783132f1917358af75bec37a7d7e6" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", - "windows-core 0.61.2", - "windows-implement", - "windows-interface", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] @@ -4966,13 +4878,13 @@ dependencies = [ [[package]] name = "webview2-com-sys" -version = "0.38.2" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +checksum = "b3a07132775117d6065853d9d1178157b8c90e228de47129d6bce2c7edebedfb" dependencies = [ "thiserror 2.0.20", - "windows", - "windows-core 0.61.2", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] @@ -5008,16 +4920,17 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "window-vibrancy" -version = "0.6.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +checksum = "111e51caca442cafd9bab396628ac4d814880eaea4e63dfd76a12e9f342b5580" dependencies = [ "objc2", "objc2-app-kit", "objc2-core-foundation", "objc2-foundation", + "objc2-quartz-core", "raw-window-handle", - "windows-sys 0.59.0", + "windows-sys 0.61.2", "windows-version", ] @@ -5027,11 +4940,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -5043,6 +4968,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -5077,7 +5011,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -5124,6 +5069,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-registry" version = "0.6.1" @@ -5273,6 +5228,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-version" version = "0.1.7" @@ -5429,12 +5393,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" - [[package]] name = "winnow" version = "1.0.4" @@ -5468,11 +5426,11 @@ checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wry" -version = "0.55.1" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +checksum = "759c50ddad0ef9913bb5496cb9d509a50433a3b78cb0238d61105526589cf0e9" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "block2", "cookie", "crossbeam-channel", @@ -5493,7 +5451,6 @@ dependencies = [ "objc2-foundation", "objc2-ui-kit", "objc2-web-kit", - "once_cell", "percent-encoding", "raw-window-handle", "sha2 0.10.9", @@ -5504,8 +5461,8 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", - "windows-core 0.61.2", + "windows 0.62.2", + "windows-core 0.62.2", "windows-version", "x11-dl", ] @@ -5714,18 +5671,6 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "zip" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" -dependencies = [ - "arbitrary", - "crc32fast", - "indexmap 2.14.0", - "memchr", -] - [[package]] name = "zip" version = "9.0.5" diff --git a/apps/desktop/loopx-control-plane/src-tauri/Cargo.toml b/apps/desktop/loopx-control-plane/src-tauri/Cargo.toml index 4c7deccf02..c6c95f6135 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/Cargo.toml +++ b/apps/desktop/loopx-control-plane/src-tauri/Cargo.toml @@ -25,7 +25,7 @@ serde_json = "1.0" tauri = { version = "2.11.1", features = [] } tauri-plugin-single-instance = "2.4.3" tauri-plugin-notification = "2" -tauri-plugin-updater = "~2.11" +tauri-plugin-updater = "~2.14.1" flate2 = "1.1.10" tar = "0.4.46" sha2 = "0.11.0" diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/service_endpoints.rs b/apps/desktop/loopx-control-plane/src-tauri/src/service_endpoints.rs index a6546b764c..7b36578e75 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/service_endpoints.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/service_endpoints.rs @@ -46,6 +46,31 @@ impl ServiceEndpoints { }) } + #[cfg(all(test, unix))] + pub(crate) fn allocate_recovery_fixture() -> std::io::Result { + // Keep the recovery fixtures outside the default ephemeral port range. + // A parallel test's bind-to-zero must not take the port between this + // reservation and the simulated Core child binding it. + let start = 20_000 + (std::process::id() % 128) as u16 * 32; + let mut available = + (start..start + 32).filter_map(|port| TcpListener::bind(("127.0.0.1", port)).ok()); + let mut reserve = || { + available.next().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::AddrInUse, + "no free loopback port for the service recovery fixture", + ) + }) + }; + let status = reserve()?; + let chat = reserve()?; + Ok(Self { + status: status.local_addr()?.port(), + chat: chat.local_addr()?.port(), + isolated: true, + }) + } + pub fn port(self, kind: ServiceKind) -> u16 { match kind { ServiceKind::Status => self.status, diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/services_tests.rs b/apps/desktop/loopx-control-plane/src-tauri/src/services_tests.rs index 1e1c26a263..7f41c3979c 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/services_tests.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/services_tests.rs @@ -762,7 +762,7 @@ while True: executable: executable.to_string_lossy().into_owned(), identity: Some(identity), }, - ServiceEndpoints::allocate(true).unwrap(), + ServiceEndpoints::allocate_recovery_fixture().unwrap(), ) } diff --git a/apps/presentation/dashboard/smoke/goal-acceptance-contract-browser-smoke.mjs b/apps/presentation/dashboard/smoke/goal-acceptance-contract-browser-smoke.mjs index a9d1822eb2..d629b63f58 100644 --- a/apps/presentation/dashboard/smoke/goal-acceptance-contract-browser-smoke.mjs +++ b/apps/presentation/dashboard/smoke/goal-acceptance-contract-browser-smoke.mjs @@ -22,30 +22,15 @@ const payload = writeAuthorityOnly ? null : JSON.parse(execFileSync(python, ["-c import runpy, tempfile, json from pathlib import Path from pytest import MonkeyPatch -from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection from loopx.control_plane.effect_runtime import restart_effect_runtime -from loopx.control_plane.todos.active_state_todo_parser import parse_todo_source -from loopx.control_plane.todos.todo_summary import canonical_todo_read_record, structured_todo_item from loopx.status import collect_status -from tests.control_plane.canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime +from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime fixture = runpy.run_path('tests/test_delivery_review.py') -refresh = fixture['make_project'].__globals__['refresh_state_run'] -def initialize_and_refresh(**kwargs): - # The standalone caller has no pytest setup: initialize the same real - # canonical provider before the shared fixture's first operational read. - groups, _, _ = parse_todo_source(kwargs['state_file'].read_text()) - records = [canonical_todo_read_record(structured_todo_item(item, role=role, - source_section=item['source_section'])) for role, rows in groups.items() for item in rows] - initialize_canonical_authority(Path(kwargs['runtime_root_override']), kwargs['goal_id'], - build_todo_runtime_shadow_projection(goal_id=kwargs['goal_id'], todos=records, - handoff_mode='soft_claim'), state_path=kwargs['state_file'], provider='sqlite') - return refresh(**kwargs) -fixture['make_project'].__globals__['refresh_state_run'] = initialize_and_refresh with tempfile.TemporaryDirectory() as directory: with MonkeyPatch.context() as patch: isolate_sqlite_runtime(Path(directory), patch) try: - registry, runtime = fixture['make_project'](Path(directory)) + registry, runtime = fixture['make_project'](Path(directory), provider='sqlite') print(json.dumps(collect_status(registry_path=registry, runtime_root_override=str(runtime), limit=20, goal_id='release-demo'))) finally: restart_effect_runtime(wait_for_exit=True) @@ -312,7 +297,9 @@ try { for (const [status, en, zh] of [["unverified", "Artifact checks not verified", "产物检查未验证"], ["failed", "Artifact checks failed", "产物检查失败"], ["stale", "Artifact checks stale", "产物检查已过期"], ["partial", "Task checks passed; Goal-wide verification unknown", "任务检查通过;Goal 整体验证未知"], ["accepted", "Artifact checks passed", "产物检查通过"]]) { await refreshSnapshot(verifiedContract(status)); await section.getByText(locale === "en" ? en : zh, { exact: true }).first().waitFor(); - assert.ok((await section.innerText()).includes(locale === "en" ? "independent review" : "独立复核")); + await section.getByRole("button", {name: locale === "en" ? "Delivery check scope" : "交付检查范围"}).focus(); + await section.getByRole("tooltip").getByText(locale === "en" ? /independent review/ : /独立复核/).waitFor(); + await page.keyboard.press("Escape"); } await refreshSnapshot({ ...contract, scope: { kind: "selected_work", todo_ids: contract.tasks.map(task => task.todo_id) } }); await section.getByText(locale === "en" ? "Only explicitly selected tasks" : "仅明确选定的任务", { exact: true }).waitFor(); diff --git a/apps/presentation/dashboard/smoke/workspace-command-palette-browser-smoke.mjs b/apps/presentation/dashboard/smoke/workspace-command-palette-browser-smoke.mjs index 220874cd9a..e7a48f7cde 100644 --- a/apps/presentation/dashboard/smoke/workspace-command-palette-browser-smoke.mjs +++ b/apps/presentation/dashboard/smoke/workspace-command-palette-browser-smoke.mjs @@ -73,8 +73,11 @@ try { assert.equal(await launcher.evaluate(element => element === document.activeElement), true); await launcher.click(); await dialogFor(page).waitFor({state: 'visible'}); - await dialogFor(page).getByRole('combobox').fill('map legacy'); - await page.keyboard.press('Enter'); + const todoSearch = dialogFor(page).getByRole('combobox'); + await todoSearch.fill('map legacy'); + await dialogFor(page).getByRole('option', {selected: true}).waitFor({state: 'visible'}); + assert.equal(await dialogFor(page).getByRole('option').count(), 1); + await todoSearch.press('Enter'); await dialogFor(page).waitFor({state: 'hidden'}); await page.waitForURL(/todo=showcase-map-columns/); await page.locator('[data-context-drawer]').waitFor({state: 'visible'}); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/workspace-command-palette.tsx b/apps/presentation/dashboard/src/features/personal-workspace/workspace-command-palette.tsx index 59d9d027f4..e966d7d43c 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/workspace-command-palette.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/workspace-command-palette.tsx @@ -1,5 +1,5 @@ import {ContextHelp} from "./context-help"; -import { useEffect, useId, useRef, useState } from "react"; +import { useEffect, useId, useLayoutEffect, useRef, useState } from "react"; import { useWorkspaceI18n } from "./i18n"; import { filterWorkspaceCommands, isPaletteShortcut, type WorkspaceCommandEntry } from "./workspace-command-entries"; import "./workspace-command-palette.css"; @@ -41,9 +41,10 @@ export function WorkspaceCommandPalette({ entries, open, enabled, readOnly, unav return () => document.removeEventListener("keydown", shortcut, true); }, [enabled, open, onOpen, onClose]); - useEffect(() => { + useLayoutEffect(() => { const dialog = dialogRef.current; if (!open || !dialog) return; + // Reset the previous search before the newly focused input can receive keys. returnFocus.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; composing.current = false; outsidePointer.current = null; diff --git a/docs/architecture/rfcs/STATUS.md b/docs/architecture/rfcs/STATUS.md index 4d28fa41ad..fc9a00e3fc 100644 --- a/docs/architecture/rfcs/STATUS.md +++ b/docs/architecture/rfcs/STATUS.md @@ -31,7 +31,7 @@ appendix may keep dated history, but no dated log heading may precede it. | [RFC: Capable Agent Manager and Semantic Work Handoff (v0)](capable-manager-semantic-handoff-v0.md) | Accepted | [Personal Follow-through independent proposal](personal-follow-through-v0.md) | [2 entries](ledger/capable-manager-semantic-handoff-v0/) | | [RFC: Complete State Recovery and Controlled Reactivation (v0)](complete-state-recovery-v0.md) | Accepted | none | — | | [RFC: Composable State Machines and Recovery Verification (v0)](composable-state-machines-recovery-verification-v0.md) | Accepted | none | — | -| [RFC: Controller / Executor Sessions and One Governed Work Contract (v0)](controller-executor-unified-contract-v0.md) | Accepted | none | [5 entries](ledger/controller-executor-unified-contract-v0/) | +| [RFC: Router–Controller–Worker Architecture and One Governed Work Contract (v0)](controller-executor-unified-contract-v0.md) | Accepted | none | [5 entries](ledger/controller-executor-unified-contract-v0/) | | [Explicit Todo continuation: Stage A](cross-session-memory-substrate-v0.md) | Accepted | none | — | | [RFC: LoopX Desktop Execution Frontends v0](desktop-execution-frontends-v0.md) | Accepted | none | — | | [RFC: External Evidence Research Capability v0](external-evidence-research-capability-v0.md) | Accepted | none | [3 entries](ledger/external-evidence-research-capability-v0/) | diff --git a/docs/architecture/rfcs/STATUS.zh-CN.md b/docs/architecture/rfcs/STATUS.zh-CN.md index 952c0ca578..109611dc37 100644 --- a/docs/architecture/rfcs/STATUS.zh-CN.md +++ b/docs/architecture/rfcs/STATUS.zh-CN.md @@ -28,7 +28,7 @@ | [RFC:强能力 Agent 管家与语义工作交接(v0)](capable-manager-semantic-handoff-v0.zh-CN.md) | 已接受 | [个人事项持续跟进独立提案](personal-follow-through-v0.zh-CN.md) | [2 条](ledger/capable-manager-semantic-handoff-v0/) | | [RFC:完整状态恢复与受控重新激活(v0)](complete-state-recovery-v0.zh-CN.md) | 已接受 | 无 | — | | [RFC:可组合状态机与恢复验证(v0)](composable-state-machines-recovery-verification-v0.zh-CN.md) | 已接受 | 无 | — | -| [RFC:Controller / Executor 会话与统一受治理工作契约(v0)](controller-executor-unified-contract-v0.zh-CN.md) | 已接受 | 无 | [5 条](ledger/controller-executor-unified-contract-v0/) | +| [RFC:Router—Controller—Worker 架构与统一受治理工作契约(v0)](controller-executor-unified-contract-v0.zh-CN.md) | 已接受 | 无 | [5 条](ledger/controller-executor-unified-contract-v0/) | | [显式 Todo 接续:阶段 A](cross-session-memory-substrate-v0.zh-CN.md) | 已接受 | 无 | — | | [RFC:LoopX 桌面执行前端 v0](desktop-execution-frontends-v0.zh-CN.md) | 已接受 | 无 | — | | [RFC:外部证据研究能力 v0](external-evidence-research-capability-v0.zh-CN.md) | 已接受 | 无 | [3 条](ledger/external-evidence-research-capability-v0/) | diff --git a/docs/architecture/rfcs/controller-executor-unified-contract-v0.md b/docs/architecture/rfcs/controller-executor-unified-contract-v0.md index 4be416809b..e34474c9d0 100644 --- a/docs/architecture/rfcs/controller-executor-unified-contract-v0.md +++ b/docs/architecture/rfcs/controller-executor-unified-contract-v0.md @@ -2,7 +2,7 @@ -- **RFC status:** Existing design accepted; router / binding-unit extension proposed +- **RFC status:** Accepted, existing contract. Router and binding-unit extensions are proposed. - **Supersedes / closes:** none - **Delivery maturity:** Proposal; no new runtime, schema or default is shipped by this document. - **Authors / owners:** LoopX maintainers; control-plane and runtime owners @@ -168,9 +168,9 @@ intentionally have no LoopX Core and must remain uncontaminated controls. -### 2026-11-06 audit: reusable foundations and remaining gaps +### Current delivery audit -This audit is pinned to main `f91e26d91d3e4e1b28b69f5881a059ea78891082`. +The 2026-11-06 audit is pinned to main `f91e26d91d3e4e1b28b69f5881a059ea78891082`. A mechanism present in source establishes an implementation boundary, not a qualified dual-session journey or benefit. diff --git a/docs/architecture/rfcs/controller-executor-unified-contract-v0.zh-CN.md b/docs/architecture/rfcs/controller-executor-unified-contract-v0.zh-CN.md index d8ee8518c0..9f638f5242 100644 --- a/docs/architecture/rfcs/controller-executor-unified-contract-v0.zh-CN.md +++ b/docs/architecture/rfcs/controller-executor-unified-contract-v0.zh-CN.md @@ -2,7 +2,7 @@ -- **RFC 状态:** 既有设计 Accepted;本次 Router / 绑定单元扩展待评审 +- **RFC 状态:** 已接受,适用于既有契约。Router 与绑定单元扩展待评审。 - **替代 / 关闭:** 无 - **交付成熟度:** Proposal;本文不交付新运行时、schema 或默认行为。 - **作者 / 负责人:** LoopX 维护者、控制面与运行时 owner @@ -137,9 +137,9 @@ flowchart TB -### 2026-11-06 核对:可复用什么,还缺什么 +### 当前交付的源码核对 -以下检查绑定 main `f91e26d91d3e4e1b28b69f5881a059ea78891082`。 +以下 2026-11-06 核对绑定 main `f91e26d91d3e4e1b28b69f5881a059ea78891082`。 代码中存在机制只证明已有实现边界,不代表双会话用户旅程或收益已经验收。 | 能力 | 源码事实 / 在途工作 | 对本设计的决定 | diff --git a/docs/book/chapters/00-reading-guide.md b/docs/book/chapters/00-reading-guide.md index 9e8bb8f350..0f1cde6858 100644 --- a/docs/book/chapters/00-reading-guide.md +++ b/docs/book/chapters/00-reading-guide.md @@ -180,7 +180,7 @@ lifecycle 选择,不是所有贡献的默认终点。 ## 版本基线 -当前内容面向 LoopX release `v2.2.0`;本地命令示例已按 `loopx 2.2.0` 的 +当前内容面向 LoopX release `v2.3.0`;本地命令示例已按 `loopx 2.3.0` 的 公开 CLI 与协议表面复核。该版本要求 Python 3.11+ 与 Node.js 22.22.3+。LoopX 会自动管理 空闲后退出的 TypeScript Effect runtime,用户不需要手工维护 daemon。 diff --git a/docs/book/en/chapters/00-reading-guide.md b/docs/book/en/chapters/00-reading-guide.md index 0f56ad1ef9..029bcc7ec4 100644 --- a/docs/book/en/chapters/00-reading-guide.md +++ b/docs/book/en/chapters/00-reading-guide.md @@ -199,8 +199,8 @@ Do not bypass a newer permission or lifecycle check just to make an older exampl ## Version baseline -This book targets LoopX release `v2.2.0`. Local command examples were checked against -the public `loopx 2.2.0` CLI and protocol surface. It requires Python 3.11+ and Node.js 22.22.3+. +This book targets LoopX release `v2.3.0`. Local command examples were checked against +the public `loopx 2.3.0` CLI and protocol surface. It requires Python 3.11+ and Node.js 22.22.3+. LoopX starts and reuses its managed, idle-exiting TypeScript Effect runtime automatically; users do not operate that runtime as a manual daemon. diff --git a/docs/book/en/index.md b/docs/book/en/index.md index 9ae2b0f5c1..227db23bc9 100644 --- a/docs/book/en/index.md +++ b/docs/book/en/index.md @@ -79,7 +79,7 @@ versioned or optional functionality, not the default shape of every contribution - Source format: Markdown - Site generator: MkDocs Material - Hosting: GitHub Pages -- LoopX release anchor: `v2.2.0` +- LoopX release anchor: `v2.3.0` - Runtime prerequisites: Python 3.11+ and Node.js 22.22.3+ The official public protocols remain authoritative for protocol facts. Commands that change across diff --git a/docs/book/index.md b/docs/book/index.md index c7a655f613..c43901c59c 100644 --- a/docs/book/index.md +++ b/docs/book/index.md @@ -70,7 +70,7 @@ Extension 只是可独立版本化和交付的一种路径。 - 正文格式:Markdown; - 站点生成器:MkDocs Material; - 在线发布:GitHub Pages; -- LoopX 发布锚点:`v2.2.0`; +- LoopX 发布锚点:`v2.3.0`; - 运行时前提:Python 3.11+ 与 Node.js 22.22.3+。 协议解释以 LoopX 官方公开合同为事实源。易变化的命令仍以对应发布物、官方文档和当前 diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index 360d493814..9c16c7e517 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -1848,6 +1848,22 @@ not shorten production commands or qualify long-path output under short-path cap 独立的真实长路径命令完整性检查。这仅统一测量布局,不缩短生产命令,也不将 长路径输出冒充短路径预算已通过。 +Exposing the validator's stride review citation limit adds 136 characters +only to the crowded `turn plan` fixture. With the same scenario roots, the +small/crowded outputs measure 10,553/21,849 before that projection and +10,553/21,985 after it, on both the integration base and candidate. The +11,432-character growth exceeded the former 11,400-character allowance. +The fixed semantic allowance moves from 9,300 to 9,436, preserving 104 +characters of measured headroom, the per-Todo allowance, and the absolute +character and line ceilings. The added field exposes the existing validator +limit independently for previous-decision citations and Explore citations. + +同一夹具中,集成基线和候选版的 `turn plan` 输出一致。展示 Stride 评审引用上限后, +小场景仍为 10,553 字符,拥挤场景从 21,849 增至 21,985 字符。实际增长 11,432 +字符,超过原 11,400 字符上限。仅将固定语义预算从 9,300 调到 9,436,保留 104 +字符余量、每条 Todo 的增长预算以及总字符数和行数限制。新增字段分别说明既有的前次 +决策引用上限和 Explore 引用上限。 + The PR-review packet's `semantic_alignment` rule consumes this evidence through the existing `validation_matrix` and `observable_semantics` rows. It does not add a separate budget receipt or approval gate. The result checker verifies diff --git a/docs/product/releases/v2.3.0.md b/docs/product/releases/v2.3.0.md new file mode 100644 index 0000000000..1d899acfc5 --- /dev/null +++ b/docs/product/releases/v2.3.0.md @@ -0,0 +1,692 @@ +# LoopX 2.3.0 + +## At a Glance + +Check what a Goal actually delivered, pick the next research step using comparable evidence, and find the source behind a result in the Workspace. + +### Upgrade + +Use the installation owner shown by the update check. Existing archive installations keep their archive update path. + +```bash +loopx update check +loopx update apply +loopx --version +loopx doctor +``` + +### Highlights + +- **Check a Goal against its commitments.** Open its acceptance record to see declared checks and evidence before making an overall decision. Local Todos can still finish as each delivers its own result. [#8161](https://github.com/loopx-project/loopx/pull/8161), [#8178](https://github.com/loopx-project/loopx/pull/8178) +- **Choose the next research probe from actual results.** Enable Yardstick on a Goal with Explore evidence, then compare distinct candidates tested on the same target and measurement profile. [#8202](https://github.com/loopx-project/loopx/pull/8202), [#8207](https://github.com/loopx-project/loopx/pull/8207), [#8261](https://github.com/loopx-project/loopx/pull/8261) +- **Review the original evidence and results.** Inspect the captured Turn range, compare two captured usages, download the readable evidence report, or open a report edition from Home. [#7882](https://github.com/loopx-project/loopx/pull/7882), [#7973](https://github.com/loopx-project/loopx/pull/7973), [#8017](https://github.com/loopx-project/loopx/pull/8017), [#8116](https://github.com/loopx-project/loopx/pull/8116), [#8146](https://github.com/loopx-project/loopx/pull/8146) +- **Correct a pending request before it runs.** Edit or cancel a queued message from the conversation, steer an active Turn when the session supports it, and read back the answer in its original conversation. [#8059](https://github.com/loopx-project/loopx/pull/8059), [#8096](https://github.com/loopx-project/loopx/pull/8096), [#8121](https://github.com/loopx-project/loopx/pull/8121), [#8269](https://github.com/loopx-project/loopx/pull/8269) + +### Replan Fixes + +- A pre-bound heartbeat now keeps the actual Turn identity from its producer through admission and settlement. An expired execution claim surfaces its recovery action. [#8232](https://github.com/loopx-project/loopx/pull/8232), [#7912](https://github.com/loopx-project/loopx/pull/7912) +- Current decision cues appear before supporting material. A staged read stays on its original capture across recovery, and composed settlement failures retain the concrete diagnostic. [#7984](https://github.com/loopx-project/loopx/pull/7984), [#7985](https://github.com/loopx-project/loopx/pull/7985), [#8267](https://github.com/loopx-project/loopx/pull/8267) +- Finish the original Turn after its completed Todo moves to the archive. [#8264](https://github.com/loopx-project/loopx/pull/8264) +- A native Host retry keeps the live guard's original capture and managed claim identity. If a saved capture lacks a verified binding, keep its existing claim receipt and obtain a fresh guard for the same Turn if preparation is still needed. [#8279](https://github.com/loopx-project/loopx/pull/8279) +- SSH Goals use the compact activation prompt with inherited context delivery. Windows Doctor reads drive-qualified wheel records using the native path parser. + +### Contributors + +- [@BigDataDZ](https://github.com/BigDataDZ) — Native host discovery, installer quoting, Windows path handling and Doctor identity diagnostics. [#7886](https://github.com/loopx-project/loopx/pull/7886) +- [@cocolord](https://github.com/cocolord) — CLI demand loading, native context and recovery fixtures, Issue-Fix redirect verification, and original-Turn closeout after automatic archival. [#7855](https://github.com/loopx-project/loopx/pull/7855), [#8264](https://github.com/loopx-project/loopx/pull/8264) +- [@Duang777](https://github.com/Duang777) — Status retry journal and Lark Inbox recovery on damaged state. [#7693](https://github.com/loopx-project/loopx/pull/7693) +- [@gcl-coder](https://github.com/gcl-coder) — Windows settlement qualification, bound scheduler fallback and bounded Git discovery. [#7920](https://github.com/loopx-project/loopx/pull/7920) +- [@HaoooLee](https://github.com/HaoooLee) — Strict registry checks when repository tracking is unknown. [#8162](https://github.com/loopx-project/loopx/pull/8162) +- [@hhyykk](https://github.com/hhyykk) — Owner attention, Issue-Fix correction and original return, and TypeScript composition of Todo and quota reads. [#7860](https://github.com/loopx-project/loopx/pull/7860) +- [@JasonBuildAI](https://github.com/JasonBuildAI) — Host recovery encoding, owner review vocabulary and credential redaction in shared Lark sinks. [#8027](https://github.com/loopx-project/loopx/pull/8027) +- [@Jiaqiiii523](https://github.com/Jiaqiiii523) — Native CLI capture fixtures and canonical receipt and handoff vocabularies. [#8182](https://github.com/loopx-project/loopx/pull/8182) +- [@keoyyo85](https://github.com/keoyyo85) — Quota settlement import isolation and architecture coverage. [#8054](https://github.com/loopx-project/loopx/pull/8054) +- [@LIHUA919](https://github.com/LIHUA919) — Rune source-verification pilot and native recovery and Antigravity admission readbacks. [#7875](https://github.com/loopx-project/loopx/pull/7875) +- [@NIU-123370](https://github.com/NIU-123370) — Bilingual RFC mirror checks, Anti-Entropy help and the release timeline. [#8128](https://github.com/loopx-project/loopx/pull/8128) +- [@songoow](https://github.com/songoow) — Independent Claude Code benchmark runner, expired-claim and Goal feedback recovery, and FrontierCS-2 compatibility. [#7082](https://github.com/loopx-project/loopx/pull/7082) +- [@steven-kid](https://github.com/steven-kid) — Captured Research Turn costs and ranges, readable evidence downloads, and keyboard access in desktop settings. [#7882](https://github.com/loopx-project/loopx/pull/7882) +- [@Weimr2627](https://github.com/Weimr2627) — Status compatibility exports and degraded Git registry fixtures. [#8160](https://github.com/loopx-project/loopx/pull/8160) +- [@xiaobaifly7](https://github.com/xiaobaifly7) — Status readback that distinguishes unavailable history from absent Goal status. [#7917](https://github.com/loopx-project/loopx/pull/7917) +- [@xyx2002OvO](https://github.com/xyx2002OvO) — Private runtime I/O and Windows long path support. [#7603](https://github.com/loopx-project/loopx/pull/7603) +- [@zxdvd](https://github.com/zxdvd) — Strict Codex Turn result schemas. [#7976](https://github.com/loopx-project/loopx/pull/7976) + +## Release Decision + +**Who should upgrade:** Operators using managed Goals, the Research Workspace, or a supported native host can use the clearer acceptance, source evidence, and request recovery. Windows desktop preview users should install the new stable desktop bundle manually. An existing deployment with a custom ACP endpoint named `antigravity` should rename that endpoint before upgrading. + +**What this release solves:** Goal acceptance can use retained checks and evidence. The Workspace makes captured Turn evidence, report editions, and the original conversation easier to follow. An opted-in research Turn can evaluate the next target against matched measurements. + +**Breaking changes:** Yes. The built-in Antigravity executor reserves the id `antigravity`. Rename an existing custom ACP endpoint using that id. Existing canonical File and SQLite Goals retain their storage owner. Inherited Stride can apply its evidence-conditioned guidance. Use the Goal or machine setting to opt out explicitly. [#7832](https://github.com/loopx-project/loopx/pull/7832), [#7928](https://github.com/loopx-project/loopx/pull/7928) + +**How to verify:** Expect `loopx 2.3.0` and the expected installation owner in Doctor. Open a known Goal and inspect its acceptance record, then open the original source of one Research result. + +**Contributors:** Maintained by [@huangruiteng](https://github.com/huangruiteng). See the community contributions below. + +```bash +loopx --version +loopx doctor +loopx --format json goal-acceptance inspect --goal-id "$goal_id" +``` + +## State Kernel & Control Plane + +- Goal owners can configure and verify checks against retained commitments with an explicit configuration and a revision-bound readback. Existing local Todo acceptance continues under its own declaration. [#8161](https://github.com/loopx-project/loopx/pull/8161), [#8178](https://github.com/loopx-project/loopx/pull/8178) +- Heartbeat and settlement retain the original Turn and its diagnostic through prequota, admission and bound reads. Retry from the committed capture, and recover an expired execution claim through its declared owner. [#7912](https://github.com/loopx-project/loopx/pull/7912), [#8232](https://github.com/loopx-project/loopx/pull/8232), [#8240](https://github.com/loopx-project/loopx/pull/8240), [#8267](https://github.com/loopx-project/loopx/pull/8267) +- Inherited Stride guidance uses evidence and work shape. Opt-in Yardstick adds a scoped comparison from distinct findings that share an acceptance reference and measurement profile. Proxy ordering is advisory, and a comparison only appears when the qualifying pair exists. [#7928](https://github.com/loopx-project/loopx/pull/7928), [#8202](https://github.com/loopx-project/loopx/pull/8202), [#8207](https://github.com/loopx-project/loopx/pull/8207), [#8261](https://github.com/loopx-project/loopx/pull/8261), [#8268](https://github.com/loopx-project/loopx/pull/8268) + +## Capabilities & Workflows + +- Research shows the captured Turn range and available usage evidence, provides a readable report, and preserves a bookmarked result while part of the source is unavailable. Home can open an existing report edition. [#7882](https://github.com/loopx-project/loopx/pull/7882), [#7973](https://github.com/loopx-project/loopx/pull/7973), [#8017](https://github.com/loopx-project/loopx/pull/8017), [#8116](https://github.com/loopx-project/loopx/pull/8116), [#8146](https://github.com/loopx-project/loopx/pull/8146), [#8226](https://github.com/loopx-project/loopx/pull/8226) +- Conversations can steer a supported active Turn and edit or cancel pending messages. Recovered native replies keep the request and answer together. Use the original conversation to review the result. [#8059](https://github.com/loopx-project/loopx/pull/8059), [#8096](https://github.com/loopx-project/loopx/pull/8096), [#8121](https://github.com/loopx-project/loopx/pull/8121), [#8144](https://github.com/loopx-project/loopx/pull/8144), [#8269](https://github.com/loopx-project/loopx/pull/8269) +- Issue-Fix verifies correction events and can return an eligible result to its original request. Failed Todos can produce owner attention without assigning a new worker. Anti-Entropy is available for tasks that explicitly declare a retirement basis. [#7860](https://github.com/loopx-project/loopx/pull/7860), [#7861](https://github.com/loopx-project/loopx/pull/7861), [#8129](https://github.com/loopx-project/loopx/pull/8129) + +## Quality & Testing + +- Windows settlement rendering has a dedicated CI qualification path. Native policy and recovery fixtures follow the actual CLI readers and keep failure and unknown states visible. [#7855](https://github.com/loopx-project/loopx/pull/7855), [#7920](https://github.com/loopx-project/loopx/pull/7920), [#8228](https://github.com/loopx-project/loopx/pull/8228), [#8267](https://github.com/loopx-project/loopx/pull/8267) +- Package and runtime snapshot checks account for artifact size and the previous App exiting before upgrade relaunch. [#8064](https://github.com/loopx-project/loopx/pull/8064), [#8067](https://github.com/loopx-project/loopx/pull/8067) +- The Windows desktop updater uses `zip 9.0.5` through Tauri Updater 2.14.1. [Upstream security advisory](https://github.com/advisories/GHSA-m665-v7pm-7grj) + +## Benchmarks & Integrations + +- Select the native Antigravity executor in an existing Steward or Chat conversation. Codex account settings can separately choose the Steward's API role while working Agents retain subscription-first policy. [#7832](https://github.com/loopx-project/loopx/pull/7832), [#7837](https://github.com/loopx-project/loopx/pull/7837), [#8014](https://github.com/loopx-project/loopx/pull/8014), [#8035](https://github.com/loopx-project/loopx/pull/8035) +- Benchmark maintainers can opt into an independent Claude Code runner, one declared settled-work handoff, or sampled local work probes. Campaign reports can retain both score histories and an explicit small terminal duration tolerance. [#7082](https://github.com/loopx-project/loopx/pull/7082), [#8070](https://github.com/loopx-project/loopx/pull/8070), [#8089](https://github.com/loopx-project/loopx/pull/8089), [#8258](https://github.com/loopx-project/loopx/pull/8258), [#8263](https://github.com/loopx-project/loopx/pull/8263) +- The Rune pilot compares source-cited context with direct search through the existing Issue-Fix planning reader. It is a source-checkout experiment. This release makes no benchmark-score or long-horizon cost improvement claim. [#7875](https://github.com/loopx-project/loopx/pull/7875) + +## Community Contributors + +- [@BigDataDZ](https://github.com/BigDataDZ) — Native host discovery, installer quoting, Windows path handling and Doctor identity diagnostics. [#7886](https://github.com/loopx-project/loopx/pull/7886), [#7887](https://github.com/loopx-project/loopx/pull/7887), [#7888](https://github.com/loopx-project/loopx/pull/7888), [#8107](https://github.com/loopx-project/loopx/pull/8107), [#8108](https://github.com/loopx-project/loopx/pull/8108) +- [@cocolord](https://github.com/cocolord) — CLI demand loading, native context and recovery fixtures, Issue-Fix redirect verification, and original-Turn closeout after automatic archival. [#7855](https://github.com/loopx-project/loopx/pull/7855), [#7878](https://github.com/loopx-project/loopx/pull/7878), [#7988](https://github.com/loopx-project/loopx/pull/7988), [#7991](https://github.com/loopx-project/loopx/pull/7991), [#7993](https://github.com/loopx-project/loopx/pull/7993), [#7996](https://github.com/loopx-project/loopx/pull/7996), [#8001](https://github.com/loopx-project/loopx/pull/8001), [#8012](https://github.com/loopx-project/loopx/pull/8012), [#8180](https://github.com/loopx-project/loopx/pull/8180), [#8185](https://github.com/loopx-project/loopx/pull/8185), [#8203](https://github.com/loopx-project/loopx/pull/8203), [#8212](https://github.com/loopx-project/loopx/pull/8212), [#8218](https://github.com/loopx-project/loopx/pull/8218), [#8222](https://github.com/loopx-project/loopx/pull/8222), [#8228](https://github.com/loopx-project/loopx/pull/8228), [#8233](https://github.com/loopx-project/loopx/pull/8233), [#8240](https://github.com/loopx-project/loopx/pull/8240), [#8257](https://github.com/loopx-project/loopx/pull/8257), [#8264](https://github.com/loopx-project/loopx/pull/8264) +- [@Duang777](https://github.com/Duang777) — Status retry journal and Lark Inbox recovery on damaged state. [#7693](https://github.com/loopx-project/loopx/pull/7693), [#7979](https://github.com/loopx-project/loopx/pull/7979) +- [@gcl-coder](https://github.com/gcl-coder) — Windows settlement qualification, bound scheduler fallback and bounded Git discovery. [#7920](https://github.com/loopx-project/loopx/pull/7920), [#7955](https://github.com/loopx-project/loopx/pull/7955), [#8083](https://github.com/loopx-project/loopx/pull/8083), [#8084](https://github.com/loopx-project/loopx/pull/8084), [#8115](https://github.com/loopx-project/loopx/pull/8115), [#8152](https://github.com/loopx-project/loopx/pull/8152), [#8153](https://github.com/loopx-project/loopx/pull/8153), [#8154](https://github.com/loopx-project/loopx/pull/8154) +- [@HaoooLee](https://github.com/HaoooLee) — Strict registry checks when repository tracking is unknown. [#8162](https://github.com/loopx-project/loopx/pull/8162) +- [@hhyykk](https://github.com/hhyykk) — Owner attention, Issue-Fix correction and original return, and TypeScript composition of Todo and quota reads. [#7860](https://github.com/loopx-project/loopx/pull/7860), [#7861](https://github.com/loopx-project/loopx/pull/7861), [#7870](https://github.com/loopx-project/loopx/pull/7870), [#7877](https://github.com/loopx-project/loopx/pull/7877), [#7881](https://github.com/loopx-project/loopx/pull/7881), [#7892](https://github.com/loopx-project/loopx/pull/7892), [#7893](https://github.com/loopx-project/loopx/pull/7893), [#7894](https://github.com/loopx-project/loopx/pull/7894), [#7895](https://github.com/loopx-project/loopx/pull/7895), [#7900](https://github.com/loopx-project/loopx/pull/7900), [#7901](https://github.com/loopx-project/loopx/pull/7901), [#7904](https://github.com/loopx-project/loopx/pull/7904) +- [@JasonBuildAI](https://github.com/JasonBuildAI) — Host recovery encoding, owner review vocabulary and credential redaction in shared Lark sinks. [#8027](https://github.com/loopx-project/loopx/pull/8027), [#8029](https://github.com/loopx-project/loopx/pull/8029), [#8031](https://github.com/loopx-project/loopx/pull/8031), [#8181](https://github.com/loopx-project/loopx/pull/8181) +- [@Jiaqiiii523](https://github.com/Jiaqiiii523) — Native CLI capture fixtures and canonical receipt and handoff vocabularies. [#8182](https://github.com/loopx-project/loopx/pull/8182), [#8183](https://github.com/loopx-project/loopx/pull/8183), [#8184](https://github.com/loopx-project/loopx/pull/8184) +- [@keoyyo85](https://github.com/keoyyo85) — Quota settlement import isolation and architecture coverage. [#8054](https://github.com/loopx-project/loopx/pull/8054), [#8055](https://github.com/loopx-project/loopx/pull/8055) +- [@LIHUA919](https://github.com/LIHUA919) — Rune source-verification pilot and native recovery and Antigravity admission readbacks. [#7875](https://github.com/loopx-project/loopx/pull/7875), [#8093](https://github.com/loopx-project/loopx/pull/8093), [#8094](https://github.com/loopx-project/loopx/pull/8094) +- [@NIU-123370](https://github.com/NIU-123370) — Bilingual RFC mirror checks, Anti-Entropy help and the release timeline. [#8128](https://github.com/loopx-project/loopx/pull/8128), [#8129](https://github.com/loopx-project/loopx/pull/8129), [#8130](https://github.com/loopx-project/loopx/pull/8130) +- [@songoow](https://github.com/songoow) — Independent Claude Code benchmark runner, expired-claim and Goal feedback recovery, and FrontierCS-2 compatibility. [#7082](https://github.com/loopx-project/loopx/pull/7082), [#7912](https://github.com/loopx-project/loopx/pull/7912), [#7914](https://github.com/loopx-project/loopx/pull/7914), [#7916](https://github.com/loopx-project/loopx/pull/7916), [#7954](https://github.com/loopx-project/loopx/pull/7954), [#7958](https://github.com/loopx-project/loopx/pull/7958) +- [@steven-kid](https://github.com/steven-kid) — Captured Research Turn costs and ranges, readable evidence downloads, and keyboard access in desktop settings. [#7882](https://github.com/loopx-project/loopx/pull/7882), [#7973](https://github.com/loopx-project/loopx/pull/7973), [#8017](https://github.com/loopx-project/loopx/pull/8017), [#8019](https://github.com/loopx-project/loopx/pull/8019) +- [@Weimr2627](https://github.com/Weimr2627) — Status compatibility exports and degraded Git registry fixtures. [#8160](https://github.com/loopx-project/loopx/pull/8160), [#8163](https://github.com/loopx-project/loopx/pull/8163) +- [@xiaobaifly7](https://github.com/xiaobaifly7) — Status readback that distinguishes unavailable history from absent Goal status. [#7917](https://github.com/loopx-project/loopx/pull/7917) +- [@xyx2002OvO](https://github.com/xyx2002OvO) — Private runtime I/O and Windows long path support. [#7603](https://github.com/loopx-project/loopx/pull/7603) +- [@zxdvd](https://github.com/zxdvd) — Strict Codex Turn result schemas. [#7976](https://github.com/loopx-project/loopx/pull/7976) + +## Documentation & Compatibility + +- Rename a custom ACP endpoint using the reserved `antigravity` id before installing the new package. Keep the existing canonical File or SQLite data store and inspect Doctor before and after updating. Follow the versioned 2.x migration instructions for any remaining Markdown-only Goal. [#7832](https://github.com/loopx-project/loopx/pull/7832), [#8130](https://github.com/loopx-project/loopx/pull/8130) +- Managed context readers now put the current action first. Resume from the same capture path and digest after a read interruption. Explicitly disabled context delivery remains available through the existing setting. [#7937](https://github.com/loopx-project/loopx/pull/7937), [#7938](https://github.com/loopx-project/loopx/pull/7938), [#7984](https://github.com/loopx-project/loopx/pull/7984), [#7985](https://github.com/loopx-project/loopx/pull/7985) +- Removed legacy tmux demonstration launchers and auto-research demos from the maintained examples. The current host and benchmark guides describe the supported routes. [#8190](https://github.com/loopx-project/loopx/pull/8190) + +## Optional Capability Activation & Use + +### Goal commitment checks + +**Activation:** Inspect the Goal, then configure its owner-approved check document using the observed provider revision. Execute verification when the current Goal owner authorizes the declared checks. + +**Validation:** Inspect again and compare each check and evidence reference with the Goal's declared acceptance. + +**Disable / rollback:** Use clear-checks with the current provider revision to remove executable checks from this Goal. + +**Authority boundary:** Verification uses the declared checker authority. Goal checks preserve the existing local Todo lifecycle. + +**Docs:** [Versioned documentation](https://github.com/loopx-project/loopx/blob/v2.3.0/docs/reference/goal-acceptance-observations.md#owner-pinned-oracle-and-exact-versions) + +```bash +loopx --format json goal-acceptance inspect --goal-id "$goal_id" +loopx goal-acceptance configure --goal-id "$goal_id" --document owner-contract.json --expected-provider-revision "$revision" --execute +loopx goal-acceptance verify --goal-id "$goal_id" --execute +loopx --format json goal-acceptance inspect --goal-id "$goal_id" +loopx goal-acceptance clear-checks --goal-id "$goal_id" --expected-provider-revision "$current_revision" --execute +``` + +### Yardstick decisions + +**Activation:** On a Goal with Explore evidence, enable the independent Yardstick setting. The ordinary Turn cue is off until enabled for the effective profile. + +**Validation:** Read the effective Goal configuration, then open a Research finding with the recorded target and measurement. Look for a comparison only after two qualifying results. + +**Disable / rollback:** Disable the Goal option. Earlier findings remain readable. + +**Authority boundary:** Advice uses the recorded matching scope. Current acceptance and execution authority govern the next action. + +**Docs:** [Versioned documentation](https://github.com/loopx-project/loopx/blob/v2.3.0/docs/architecture/rfcs/target-yardstick-stride-decisions-v0.md#m1-implementation-checkpoint) + +```bash +loopx configure-goal --goal-id "$goal_id" --execution-yardstick-enabled --execute +loopx --format json configure-goal --goal-id "$goal_id" +loopx configure-goal --goal-id "$goal_id" --no-execution-yardstick-enabled --execute +``` + +### Evidence-conditioned Stride + +**Activation:** The inherited strategy can use evidence_conditioned_v1. Set the strategy explicitly to pin it for a Goal. + +**Validation:** Read the effective execution profile. At the next review, verify its evidence-linked recommendation against the Goal. + +**Disable / rollback:** Set the Goal strategy to off, or use the existing clear option to restore inheritance. + +**Authority boundary:** The strategy recommends a next choice under the existing approval, cost and Stop conditions. + +**Docs:** [Versioned documentation](https://github.com/loopx-project/loopx/blob/v2.3.0/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md) + +```bash +loopx configure-goal --goal-id "$goal_id" --execution-stride-strategy evidence_conditioned_v1 --execute +loopx --format json configure-goal --goal-id "$goal_id" +loopx configure-goal --goal-id "$goal_id" --execution-stride-strategy off --execute +``` + +### Progressive context recovery + +**Activation:** Use the existing Context Delivery setting or request context delivery on a supported host for the next ordinary guard. + +**Validation:** Open the saved capture using the path and digest from that guard. Select only the current fields the next action requires. + +**Disable / rollback:** Disable the setting or pass --context-delivery off on the next ordinary guard. Complete in-flight recovery from its original capture. + +**Authority boundary:** A read recovers the admitted Turn's facts. The original guard and settlement retain their authority. + +**Docs:** [Versioned documentation](https://github.com/loopx-project/loopx/blob/v2.3.0/docs/reference/required-work-context.md) + +```bash +loopx --format json quota context-read --capture-file "$capture" --capture-sha256 "$capture_sha" --select /effective_action +``` + +### Antigravity conversations + +**Activation:** Sign in to Antigravity, then open Settings → Steward → Models and executor. Select antigravity, an account-offered Gemini 4 Argon tier and the supported effort. Preview and apply. + +**Validation:** Reload the settings to verify the effective executor, then send an ordinary test question in the same conversation and check the returned reply. + +**Disable / rollback:** Select Codex again in the same editor. If you installed the agy slash facade for a separate CLI session, remove it with the matching uninstall command. + +**Authority boundary:** Native permission decisions remain with the host. Restricted Chat selects the restricted tool policy. + +**Docs:** [Versioned documentation](https://github.com/loopx-project/loopx/blob/v2.3.0/docs/reference/protocols/host-mode-plan-v0.md) + +```bash +loopx doctor +loopx slash-commands --install --surface agy +loopx slash-commands --uninstall --surface agy +``` + +### Codex account API priority + +**Activation:** Install the optional account package into the LoopX Python environment and restart its serving owner. In Settings → Model Provider configuration, select Steward API use → Prefer API with an existing authorized API key. + +**Validation:** Reload the selector and check its saved value. Read the working-Agent policy separately. + +**Disable / rollback:** Select Disabled for the Steward's API role. The existing worker fallback setting controls working Agents separately. + +**Authority boundary:** Select an API policy only with a key authorized for that use. The next Turn uses the new route and an in-flight request retains its original route. + +**Docs:** [Versioned documentation](https://github.com/loopx-project/loopx/blob/v2.3.0/packages/loopx-codex-provider-routing/NATIVE.md) + +```bash +python3 -m pip install ./packages/loopx-codex-provider-routing +loopx-codex-accounts status +``` + +### Research evidence and report readback + +**Activation:** Use the Goal's Explore evidence mode if it should collect new evidence. In Research, open a captured finding and select its Turn range, report download or comparison. Home opens editions of existing reports. + +**Validation:** Check the original run, retained version, token source and explicitly unknown fields in the readback. + +**Disable / rollback:** Set the Goal's Explore mode to off to stop new Explore capture. Keep retained evidence readable. + +**Authority boundary:** Reports and usage comparisons read the source available to that Goal. Actual usage and estimates keep separate provenance. + +**Docs:** [Versioned documentation](https://github.com/loopx-project/loopx/blob/v2.3.0/loopx/capabilities/explore/README.md) + +```bash +loopx configure-goal --goal-id "$goal_id" --explore-mode evidence --execute +loopx --format json configure-goal --goal-id "$goal_id" +loopx configure-goal --goal-id "$goal_id" --explore-mode off --execute +``` + +### Anti-Entropy retirement checks + +**Activation:** Declare a task-scoped retirement_basis or anti-entropy-governance requirement, with explicit target paths or symbols and the post-change workspace. + +**Validation:** Inspect the exact diff with the bound workspace, then use verify for the delivery gate's retirement evidence. + +**Disable / rollback:** Remove the explicit opt-in from future tasks and omit the check for unrelated work. + +**Authority boundary:** Inspection is read-only. Change-quality retains delivery authority, and missing workspace evidence prevents certification. + +**Docs:** [Versioned documentation](https://github.com/loopx-project/loopx/blob/v2.3.0/loopx/capabilities/anti_entropy/README.md) + +```bash +loopx anti-entropy inspect --goal-id "$goal_id" --diff-file diff.patch --basis-json basis.json --workspace "$workspace" --format json +loopx anti-entropy verify --goal-id "$goal_id" --diff-file diff.patch --basis-json basis.json --workspace "$workspace" --format json +``` + +### Rune repository context pilot + +**Activation:** From the source checkout, use --prepare-rune with the documented pinned revision and an empty output destination. + +**Validation:** Read the report's citation hashes, unavailable rows and Issue-Fix workflow-plan readback. + +**Disable / rollback:** Omit --prepare-rune and --rune-source to run the direct-search control only. + +**Authority boundary:** This no-model experiment uses a disposable source export. References are advisory inputs to Issue-Fix. + +**Docs:** [Versioned documentation](https://github.com/loopx-project/loopx/blob/v2.3.0/examples/repository-context-pilot/README.md) + +```bash +node --experimental-strip-types examples/repository-context-pilot/run.ts --repository . --revision 789694fa42972db17acd47aa2604b1907060028d --out "$pilot_out" --prepare-rune --loopx-python "$PWD/.venv/bin/python" +``` + +### Native Claude Code benchmark runner + +**Activation:** Select benchmark.edgebench.claude_run explicitly with the documented pinned native binary, digest, provider source and isolated judge. Supply provider configuration through the process environment. + +**Validation:** Inspect the launcher flags, then check the chosen session, result and Stop continuation in an authorized trial. + +**Disable / rollback:** Stop that trial's launcher through the provider's cleanup path and use the ordinary runner for future work. + +**Authority boundary:** The selected provider owns submission and deadline. Use non-secret generated task files under the documented provider permissions. + +**Docs:** [Versioned documentation](https://github.com/loopx-project/loopx/blob/v2.3.0/benchmark/runtime/NATIVE_CLAUDE.md) + +```bash +python -m benchmark.edgebench.claude_run --help +``` + +### Benchmark checkpoint and local probes + +**Activation:** For an admitted heartbeat trial, declare settled_work_capture_once on its fresh_replan request for one named handoff. In SForge, explicitly select --execution-observation local_probe for an authorized worker. + +**Validation:** Read the original checkpoint_handoff and worker/trial receipts. A sampled Linux memory comparison retains the candidate and input identity. + +**Disable / rollback:** Omit the one-time declaration and the execution-observation flag on future trials. + +**Authority boundary:** The existing worker budget and provider own execution. Memory samples are estimates, and a missed handoff cannot be backfilled from later work. + +**Docs:** [Versioned documentation](https://github.com/loopx-project/loopx/blob/v2.3.0/benchmark/README.md) + +```bash +python -m benchmark.edgebench.run --help +``` + +### Benchmark campaign reports + +**Activation:** Supply an explicit campaign selection. Set duration_policy.makeup_tolerance_ms if the report should waive a small terminal gap, and pass --score-curves to emit HTML. + +**Validation:** Check the duration disposition, actual remaining time and observed score sequence in the outputs. + +**Disable / rollback:** Remove the tolerance or set it to zero, and omit --score-curves if HTML is unnecessary. + +**Authority boundary:** The local report derives an operator view from observed evidence. Runtime deadlines, run integrity and scoring keep their provider authority. + +**Docs:** [Versioned documentation](https://github.com/loopx-project/loopx/blob/v2.3.0/docs/reference/benchmark-campaign-report.md) + +```bash +node --experimental-strip-types scripts/benchmark-campaign-report.ts campaign-input.json reports/campaign --score-curves +``` + +### Issue-Fix correction and return + +**Activation:** Choose the explicit Issue-Fix capability route when starting work on a named issue. Follow the capability plan for correction evidence and original request eligibility. + +**Validation:** Inspect the existing workflow plan for the Goal. Verify that the correction source and result destination identify the original request. + +**Disable / rollback:** Omit the Issue-Fix route for ordinary work. Use the original Goal controls for an active request. + +**Authority boundary:** A plan has no delivery authority. Repository changes and original conversation returns require their existing authorizations. + +**Docs:** [Versioned documentation](https://github.com/loopx-project/loopx/blob/v2.3.0/loopx/capabilities/issue_fix/README.md) + +```bash +loopx issue-fix workflow-plan --goal-id "$goal_id" +loopx start-goal --guided --project . --capability-route issue-fix --goal-text "$issue_request" +``` + +## Install / Update + +For a new PyPI environment, install the package, then install the workflow entry points for the host you use. + +```bash +python3 -m pip install 'loopx==2.3.0' +loopx workflow-skills --install +loopx slash-commands --install +loopx doctor +``` + +For an existing installation, inspect the owner and plan first. Apply the update through that owner. + +```bash +loopx update check +loopx update plan +loopx update apply +loopx --version +loopx doctor +``` + +## 中文摘要 + +在 Goal 里核对实际交付,在研究工作台对照同条件候选,并回到原始记录查看结果来源。已有安装先执行 `loopx update check`,由当前安装方式执行 `loopx update apply`。 + +- 到 Goal 的验收页查看承诺、检查和读回证据。局部 Todo 仍按自身交付结案。 [#8161](https://github.com/loopx-project/loopx/pull/8161), [#8178](https://github.com/loopx-project/loopx/pull/8178) +- 给已有 Explore 证据的 Goal 启用 Yardstick,在同一目标和测量条件下对照候选。 [#8202](https://github.com/loopx-project/loopx/pull/8202), [#8207](https://github.com/loopx-project/loopx/pull/8207), [#8261](https://github.com/loopx-project/loopx/pull/8261) +- 打开研究记录查看捕获的 Turn 范围、用量对照和可读证据报告,也可以从首页回看一版报告。 [#7882](https://github.com/loopx-project/loopx/pull/7882), [#7973](https://github.com/loopx-project/loopx/pull/7973), [#8017](https://github.com/loopx-project/loopx/pull/8017), [#8116](https://github.com/loopx-project/loopx/pull/8116), [#8146](https://github.com/loopx-project/loopx/pull/8146) +- 在对话里编辑或取消未运行的消息,在支持的会话继续引导当前 Turn,最后回原对话核对答案。 [#8059](https://github.com/loopx-project/loopx/pull/8059), [#8096](https://github.com/loopx-project/loopx/pull/8096), [#8121](https://github.com/loopx-project/loopx/pull/8121), [#8269](https://github.com/loopx-project/loopx/pull/8269) +- 复盘和恢复沿用实际分配的 Turn 与原捕获。过期 claim 会显示恢复动作,结算失败保留诊断。 [#7912](https://github.com/loopx-project/loopx/pull/7912), [#7984](https://github.com/loopx-project/loopx/pull/7984), [#7985](https://github.com/loopx-project/loopx/pull/7985), [#8232](https://github.com/loopx-project/loopx/pull/8232), [#8267](https://github.com/loopx-project/loopx/pull/8267) +- 已完成的 Todo 自动归档后,仍可沿原 Turn 结算。 [#8264](https://github.com/loopx-project/loopx/pull/8264) +- 原生 Host 重试沿用实时准入的原捕获和托管领取身份。缺少可验证绑定的快照仍可读取,保留已有回执。仍需准备时,为同一 Turn 获取新的 guard。 [#8279](https://github.com/loopx-project/loopx/pull/8279) +- SSH Goal 在继承上下文设置时使用紧凑提示。Windows Doctor 能读回带盘符的 wheel 安装记录。 + +### 贡献者 + +- [@BigDataDZ](https://github.com/BigDataDZ) — 原生宿主发现、安装命令引用、Windows 长路径和 Doctor 身份诊断. [#7886](https://github.com/loopx-project/loopx/pull/7886) +- [@cocolord](https://github.com/cocolord) — CLI 按需加载、原生上下文与恢复夹具、Issue-Fix 重定向验证及 Todo 自动归档后的原 Turn 结算。 [#7855](https://github.com/loopx-project/loopx/pull/7855), [#8264](https://github.com/loopx-project/loopx/pull/8264) +- [@Duang777](https://github.com/Duang777) — 状态重试日志和飞书 Inbox 损坏状态处理. [#7693](https://github.com/loopx-project/loopx/pull/7693) +- [@gcl-coder](https://github.com/gcl-coder) — Windows 结算验证、调度回退绑定和 Git 发现时限. [#7920](https://github.com/loopx-project/loopx/pull/7920) +- [@HaoooLee](https://github.com/HaoooLee) — 跟踪状态未知时的严格 registry 检查. [#8162](https://github.com/loopx-project/loopx/pull/8162) +- [@hhyykk](https://github.com/hhyykk) — 负责人提醒、Issue-Fix 修订与原路返回、Todo 与 quota 读回的 TypeScript 合成. [#7860](https://github.com/loopx-project/loopx/pull/7860) +- [@JasonBuildAI](https://github.com/JasonBuildAI) — 宿主恢复编码、owner 评审术语和飞书共享投影凭据脱敏. [#8027](https://github.com/loopx-project/loopx/pull/8027) +- [@Jiaqiiii523](https://github.com/Jiaqiiii523) — 原生 CLI 捕获夹具与回执、交接术语统一. [#8182](https://github.com/loopx-project/loopx/pull/8182) +- [@keoyyo85](https://github.com/keoyyo85) — 结算命令加载隔离和架构覆盖. [#8054](https://github.com/loopx-project/loopx/pull/8054) +- [@LIHUA919](https://github.com/LIHUA919) — Rune 源码核验试验、原生恢复和 Antigravity 准入读回. [#7875](https://github.com/loopx-project/loopx/pull/7875) +- [@NIU-123370](https://github.com/NIU-123370) — 双语 RFC 镜像检查、Anti-Entropy 帮助和版本时间线. [#8128](https://github.com/loopx-project/loopx/pull/8128) +- [@songoow](https://github.com/songoow) — 独立 Claude Code 基准 runner、过期 claim 与 Goal 反馈恢复、FrontierCS-2 兼容. [#7082](https://github.com/loopx-project/loopx/pull/7082) +- [@steven-kid](https://github.com/steven-kid) — 研究 Turn 成本与范围、可读证据下载和桌面设置键盘可用性. [#7882](https://github.com/loopx-project/loopx/pull/7882) +- [@Weimr2627](https://github.com/Weimr2627) — 状态兼容导出和 Git registry 降级夹具. [#8160](https://github.com/loopx-project/loopx/pull/8160) +- [@xiaobaifly7](https://github.com/xiaobaifly7) — 状态读回区分历史不可用与 Goal 状态缺失. [#7917](https://github.com/loopx-project/loopx/pull/7917) +- [@xyx2002OvO](https://github.com/xyx2002OvO) — 私有运行时 I/O 和 Windows 长路径支持. [#7603](https://github.com/loopx-project/loopx/pull/7603) +- [@zxdvd](https://github.com/zxdvd) — 严格的 Codex Turn 结果 schema. [#7976](https://github.com/loopx-project/loopx/pull/7976) + +### 升级决策 + +**谁需要升级:**使用受管 Goal、研究工作台或原生宿主的用户。Windows 桌面预览版用户应手动安装新版稳定版桌面包。配置了同名自定义 ACP endpoint 的用户先将 `antigravity` 改名。 + +**解决了什么:**在 Goal 核对承诺与证据。在研究结果中查看原 Turn、报告和下一步的可比测量。在原会话恢复和追踪请求。 + +**是否有破坏性变更:**有。内置 Antigravity 占用 `antigravity` 标识,同名自定义 ACP endpoint 需改名。原有 canonical File 或 SQLite 由原存储 owner 继续管理。可在 Goal 或本机设置中关闭继承的证据条件式 Stride。 [#7832](https://github.com/loopx-project/loopx/pull/7832), [#7928](https://github.com/loopx-project/loopx/pull/7928) + +**如何验证:**CLI 应显示 `loopx 2.3.0`。用 Doctor 确认安装 owner,再读回一个已有 Goal 的验收及一项研究结果的原始证据。 + +**贡献者:**版本维护者为 [@huangruiteng](https://github.com/huangruiteng),具体社区贡献见下方。 + +### 状态内核与控制面 + +- Goal 承诺可以配置检查并按 revision 核对结果,局部 Todo 仍按自己的验收声明完成。 [#8161](https://github.com/loopx-project/loopx/pull/8161), [#8178](https://github.com/loopx-project/loopx/pull/8178) +- 心跳预检、准入和结算沿用实际 Turn,恢复保持原捕获及错误诊断。 [#7912](https://github.com/loopx-project/loopx/pull/7912), [#8232](https://github.com/loopx-project/loopx/pull/8232), [#8240](https://github.com/loopx-project/loopx/pull/8240), [#8267](https://github.com/loopx-project/loopx/pull/8267) +- 继承的 Stride 依据证据和工作形态提供指引。显式开启 Yardstick 后,满足同目标、同验收与同测量条件的不同候选才参与对照。 [#7928](https://github.com/loopx-project/loopx/pull/7928), [#8202](https://github.com/loopx-project/loopx/pull/8202), [#8207](https://github.com/loopx-project/loopx/pull/8207), [#8261](https://github.com/loopx-project/loopx/pull/8261), [#8268](https://github.com/loopx-project/loopx/pull/8268) + +### 能力与工作流 + +- 从研究记录查看已捕获的 Turn 范围、用量与可读报告,补读期间保留书签结果。首页可以回看报告版本。 [#7882](https://github.com/loopx-project/loopx/pull/7882), [#7973](https://github.com/loopx-project/loopx/pull/7973), [#8017](https://github.com/loopx-project/loopx/pull/8017), [#8116](https://github.com/loopx-project/loopx/pull/8116), [#8146](https://github.com/loopx-project/loopx/pull/8146), [#8226](https://github.com/loopx-project/loopx/pull/8226) +- 对话支持修改或撤销待发送项,并可在受支持的会话中引导当前 Turn。恢复后仍从原请求读回答案。 [#8059](https://github.com/loopx-project/loopx/pull/8059), [#8096](https://github.com/loopx-project/loopx/pull/8096), [#8121](https://github.com/loopx-project/loopx/pull/8121), [#8144](https://github.com/loopx-project/loopx/pull/8144), [#8269](https://github.com/loopx-project/loopx/pull/8269) +- Issue-Fix 可验证修订事件并回送原请求。失败 Todo 能提醒负责人。显式声明退役对象的任务可以用 Anti-Entropy 检查源码。 [#7860](https://github.com/loopx-project/loopx/pull/7860), [#7861](https://github.com/loopx-project/loopx/pull/7861), [#8129](https://github.com/loopx-project/loopx/pull/8129) + +### 质量与测试 + +- Windows 结算渲染有独立 CI 检查。原生政策、恢复和结算验证使用真实 CLI 读回并保留失败与未知。 [#7855](https://github.com/loopx-project/loopx/pull/7855), [#7920](https://github.com/loopx-project/loopx/pull/7920), [#8228](https://github.com/loopx-project/loopx/pull/8228), [#8267](https://github.com/loopx-project/loopx/pull/8267) +- 打包检查覆盖运行时资产大小,升级重启会等待旧 App 退出。 [#8064](https://github.com/loopx-project/loopx/pull/8064), [#8067](https://github.com/loopx-project/loopx/pull/8067) +- Windows 桌面更新器通过 Tauri Updater 2.14.1 使用 `zip 9.0.5`。 [上游安全公告](https://github.com/advisories/GHSA-m665-v7pm-7grj) + +### 基准与集成 + +- 可在原管家和对话配置中选择 Antigravity。Codex 管家可单独选择 API 优先级,执行 Agent 继续按订阅策略路由。 [#7832](https://github.com/loopx-project/loopx/pull/7832), [#7837](https://github.com/loopx-project/loopx/pull/7837), [#8014](https://github.com/loopx-project/loopx/pull/8014), [#8035](https://github.com/loopx-project/loopx/pull/8035) +- 维护者可显式选择 Claude Code runner、一次结算后交接或本地采样探针。报告支持分数轨迹和小额时长容差。 [#7082](https://github.com/loopx-project/loopx/pull/7082), [#8070](https://github.com/loopx-project/loopx/pull/8070), [#8089](https://github.com/loopx-project/loopx/pull/8089), [#8258](https://github.com/loopx-project/loopx/pull/8258), [#8263](https://github.com/loopx-project/loopx/pull/8263) +- Rune 是源码对照试验,经既有 Issue-Fix 路径读取经核验的引用。本版没有基准提分或完整成本改善结论。 [#7875](https://github.com/loopx-project/loopx/pull/7875) + +### 社区贡献者 + +- [@BigDataDZ](https://github.com/BigDataDZ) — 原生宿主发现、安装命令引用、Windows 长路径和 Doctor 身份诊断. [#7886](https://github.com/loopx-project/loopx/pull/7886), [#7887](https://github.com/loopx-project/loopx/pull/7887), [#7888](https://github.com/loopx-project/loopx/pull/7888), [#8107](https://github.com/loopx-project/loopx/pull/8107), [#8108](https://github.com/loopx-project/loopx/pull/8108) +- [@cocolord](https://github.com/cocolord) — CLI 按需加载、原生上下文与恢复夹具、Issue-Fix 重定向验证及 Todo 自动归档后的原 Turn 结算。 [#7855](https://github.com/loopx-project/loopx/pull/7855), [#7878](https://github.com/loopx-project/loopx/pull/7878), [#7988](https://github.com/loopx-project/loopx/pull/7988), [#7991](https://github.com/loopx-project/loopx/pull/7991), [#7993](https://github.com/loopx-project/loopx/pull/7993), [#7996](https://github.com/loopx-project/loopx/pull/7996), [#8001](https://github.com/loopx-project/loopx/pull/8001), [#8012](https://github.com/loopx-project/loopx/pull/8012), [#8180](https://github.com/loopx-project/loopx/pull/8180), [#8185](https://github.com/loopx-project/loopx/pull/8185), [#8203](https://github.com/loopx-project/loopx/pull/8203), [#8212](https://github.com/loopx-project/loopx/pull/8212), [#8218](https://github.com/loopx-project/loopx/pull/8218), [#8222](https://github.com/loopx-project/loopx/pull/8222), [#8228](https://github.com/loopx-project/loopx/pull/8228), [#8233](https://github.com/loopx-project/loopx/pull/8233), [#8240](https://github.com/loopx-project/loopx/pull/8240), [#8257](https://github.com/loopx-project/loopx/pull/8257), [#8264](https://github.com/loopx-project/loopx/pull/8264) +- [@Duang777](https://github.com/Duang777) — 状态重试日志和飞书 Inbox 损坏状态处理. [#7693](https://github.com/loopx-project/loopx/pull/7693), [#7979](https://github.com/loopx-project/loopx/pull/7979) +- [@gcl-coder](https://github.com/gcl-coder) — Windows 结算验证、调度回退绑定和 Git 发现时限. [#7920](https://github.com/loopx-project/loopx/pull/7920), [#7955](https://github.com/loopx-project/loopx/pull/7955), [#8083](https://github.com/loopx-project/loopx/pull/8083), [#8084](https://github.com/loopx-project/loopx/pull/8084), [#8115](https://github.com/loopx-project/loopx/pull/8115), [#8152](https://github.com/loopx-project/loopx/pull/8152), [#8153](https://github.com/loopx-project/loopx/pull/8153), [#8154](https://github.com/loopx-project/loopx/pull/8154) +- [@HaoooLee](https://github.com/HaoooLee) — 跟踪状态未知时的严格 registry 检查. [#8162](https://github.com/loopx-project/loopx/pull/8162) +- [@hhyykk](https://github.com/hhyykk) — 负责人提醒、Issue-Fix 修订与原路返回、Todo 与 quota 读回的 TypeScript 合成. [#7860](https://github.com/loopx-project/loopx/pull/7860), [#7861](https://github.com/loopx-project/loopx/pull/7861), [#7870](https://github.com/loopx-project/loopx/pull/7870), [#7877](https://github.com/loopx-project/loopx/pull/7877), [#7881](https://github.com/loopx-project/loopx/pull/7881), [#7892](https://github.com/loopx-project/loopx/pull/7892), [#7893](https://github.com/loopx-project/loopx/pull/7893), [#7894](https://github.com/loopx-project/loopx/pull/7894), [#7895](https://github.com/loopx-project/loopx/pull/7895), [#7900](https://github.com/loopx-project/loopx/pull/7900), [#7901](https://github.com/loopx-project/loopx/pull/7901), [#7904](https://github.com/loopx-project/loopx/pull/7904) +- [@JasonBuildAI](https://github.com/JasonBuildAI) — 宿主恢复编码、owner 评审术语和飞书共享投影凭据脱敏. [#8027](https://github.com/loopx-project/loopx/pull/8027), [#8029](https://github.com/loopx-project/loopx/pull/8029), [#8031](https://github.com/loopx-project/loopx/pull/8031), [#8181](https://github.com/loopx-project/loopx/pull/8181) +- [@Jiaqiiii523](https://github.com/Jiaqiiii523) — 原生 CLI 捕获夹具与回执、交接术语统一. [#8182](https://github.com/loopx-project/loopx/pull/8182), [#8183](https://github.com/loopx-project/loopx/pull/8183), [#8184](https://github.com/loopx-project/loopx/pull/8184) +- [@keoyyo85](https://github.com/keoyyo85) — 结算命令加载隔离和架构覆盖. [#8054](https://github.com/loopx-project/loopx/pull/8054), [#8055](https://github.com/loopx-project/loopx/pull/8055) +- [@LIHUA919](https://github.com/LIHUA919) — Rune 源码核验试验、原生恢复和 Antigravity 准入读回. [#7875](https://github.com/loopx-project/loopx/pull/7875), [#8093](https://github.com/loopx-project/loopx/pull/8093), [#8094](https://github.com/loopx-project/loopx/pull/8094) +- [@NIU-123370](https://github.com/NIU-123370) — 双语 RFC 镜像检查、Anti-Entropy 帮助和版本时间线. [#8128](https://github.com/loopx-project/loopx/pull/8128), [#8129](https://github.com/loopx-project/loopx/pull/8129), [#8130](https://github.com/loopx-project/loopx/pull/8130) +- [@songoow](https://github.com/songoow) — 独立 Claude Code 基准 runner、过期 claim 与 Goal 反馈恢复、FrontierCS-2 兼容. [#7082](https://github.com/loopx-project/loopx/pull/7082), [#7912](https://github.com/loopx-project/loopx/pull/7912), [#7914](https://github.com/loopx-project/loopx/pull/7914), [#7916](https://github.com/loopx-project/loopx/pull/7916), [#7954](https://github.com/loopx-project/loopx/pull/7954), [#7958](https://github.com/loopx-project/loopx/pull/7958) +- [@steven-kid](https://github.com/steven-kid) — 研究 Turn 成本与范围、可读证据下载和桌面设置键盘可用性. [#7882](https://github.com/loopx-project/loopx/pull/7882), [#7973](https://github.com/loopx-project/loopx/pull/7973), [#8017](https://github.com/loopx-project/loopx/pull/8017), [#8019](https://github.com/loopx-project/loopx/pull/8019) +- [@Weimr2627](https://github.com/Weimr2627) — 状态兼容导出和 Git registry 降级夹具. [#8160](https://github.com/loopx-project/loopx/pull/8160), [#8163](https://github.com/loopx-project/loopx/pull/8163) +- [@xiaobaifly7](https://github.com/xiaobaifly7) — 状态读回区分历史不可用与 Goal 状态缺失. [#7917](https://github.com/loopx-project/loopx/pull/7917) +- [@xyx2002OvO](https://github.com/xyx2002OvO) — 私有运行时 I/O 和 Windows 长路径支持. [#7603](https://github.com/loopx-project/loopx/pull/7603) +- [@zxdvd](https://github.com/zxdvd) — 严格的 Codex Turn 结果 schema. [#7976](https://github.com/loopx-project/loopx/pull/7976) + +### 文档与兼容性 + +- 升级前重命名同名自定义 `antigravity` ACP endpoint。保持原 canonical 存储,用 Doctor 确认升级前后的安装路径。仍有 Markdown-only Goal 时按 2.x 迁移指南处理。 [#7832](https://github.com/loopx-project/loopx/pull/7832), [#8130](https://github.com/loopx-project/loopx/pull/8130) +- 上下文读回优先显示当前动作。中断后用同一捕获的路径和摘要继续。原设置仍提供显式关闭。 [#7937](https://github.com/loopx-project/loopx/pull/7937), [#7938](https://github.com/loopx-project/loopx/pull/7938), [#7984](https://github.com/loopx-project/loopx/pull/7984), [#7985](https://github.com/loopx-project/loopx/pull/7985) +- 已退休的 tmux 和 auto-research 示例启动器从维护示例中移除,使用当前宿主和基准指南中的入口。 [#8190](https://github.com/loopx-project/loopx/pull/8190) + +### 可选能力启用与使用 + +#### Goal commitment checks + +**启用:** Goal 承诺检查。先读回 Goal,再按当前 provider revision 保存 owner 批准的检查文档。经该 Goal 授权后执行声明的检查。 + +**验证:** 再次 inspect,逐条对照 Goal 的验收声明与证据。 + +**停用 / 回退:** 用当前 provider revision 运行 clear-checks 移除该 Goal 的可执行检查。 + +**权限边界:** 检查使用各自声明的权限。局部 Todo 继续按自身规则结案。 + +**文档:** [本版文档](https://github.com/loopx-project/loopx/blob/v2.3.0/docs/reference/goal-acceptance-observations.md#owner-pinned-oracle-and-exact-versions) + +```bash +loopx --format json goal-acceptance inspect --goal-id "$goal_id" +loopx goal-acceptance configure --goal-id "$goal_id" --document owner-contract.json --expected-provider-revision "$revision" --execute +loopx goal-acceptance verify --goal-id "$goal_id" --execute +loopx --format json goal-acceptance inspect --goal-id "$goal_id" +loopx goal-acceptance clear-checks --goal-id "$goal_id" --expected-provider-revision "$current_revision" --execute +``` + +#### Yardstick decisions + +**启用:** Yardstick 工作选择。为已有 Explore 证据的 Goal 打开独立 Yardstick 设置。普通 Turn 指引在有效配置启用后才出现。 + +**验证:** 读回 Goal 的有效配置,再看研究记录中的目标和测量。有两条合格结果时核对对照。 + +**停用 / 回退:** 关闭该 Goal 的 Yardstick。已有研究记录仍可读。 + +**权限边界:** 建议受相同测量范围约束,原验收与执行授权仍决定下一步。 + +**文档:** [本版文档](https://github.com/loopx-project/loopx/blob/v2.3.0/docs/architecture/rfcs/target-yardstick-stride-decisions-v0.md#m1-implementation-checkpoint) + +```bash +loopx configure-goal --goal-id "$goal_id" --execution-yardstick-enabled --execute +loopx --format json configure-goal --goal-id "$goal_id" +loopx configure-goal --goal-id "$goal_id" --no-execution-yardstick-enabled --execute +``` + +#### Evidence-conditioned Stride + +**启用:** 证据条件式 Stride。继承策略可采用 evidence_conditioned_v1。可为 Goal 显式固定策略。 + +**验证:** 读回有效执行配置,下一次复盘时把建议和 Goal 证据逐项对照。 + +**停用 / 回退:** 将 Goal 策略设为 off,或用既有 clear 参数恢复继承。 + +**权限边界:** 策略提供下一步建议,仍遵守审批、成本和停止条件。 + +**文档:** [本版文档](https://github.com/loopx-project/loopx/blob/v2.3.0/docs/architecture/rfcs/hierarchical-agent-stride-control-v0.md) + +```bash +loopx configure-goal --goal-id "$goal_id" --execution-stride-strategy evidence_conditioned_v1 --execute +loopx --format json configure-goal --goal-id "$goal_id" +loopx configure-goal --goal-id "$goal_id" --execution-stride-strategy off --execute +``` + +#### Progressive context recovery + +**启用:** 渐进式上下文恢复。通过已有上下文交付设置,或在支持的宿主对下一次普通 guard 指定 context delivery。 + +**验证:** 用 guard 给出的路径和摘要读取保存的捕获。只补读下一步依赖的当前字段。 + +**停用 / 回退:** 关闭设置或在下次普通 guard 传 --context-delivery off。正在恢复的 Turn 沿原捕获完成。 + +**权限边界:** 读取仅恢复已准入 Turn 的事实,准入与结算仍由原流程负责。 + +**文档:** [本版文档](https://github.com/loopx-project/loopx/blob/v2.3.0/docs/reference/required-work-context.md) + +```bash +loopx --format json quota context-read --capture-file "$capture" --capture-sha256 "$capture_sha" --select /effective_action +``` + +#### Antigravity conversations + +**启用:** Antigravity 对话。在 Antigravity 登录,在「设置 → 管家 → 模型与执行器」选 antigravity、账号支持的 Gemini 4 Argon 档位和推理强度,预览后应用。 + +**验证:** 刷新设置检查有效执行器,在同一对话中问一个普通问题并回看原答案。 + +**停用 / 回退:** 在原设置切回 Codex。如果单独为 agy CLI 装了 slash facade,用对应命令卸载。 + +**权限边界:** 宿主负责原生权限决策。受限 Chat 使用受限工具策略。 + +**文档:** [本版文档](https://github.com/loopx-project/loopx/blob/v2.3.0/docs/reference/protocols/host-mode-plan-v0.md) + +```bash +loopx doctor +loopx slash-commands --install --surface agy +loopx slash-commands --uninstall --surface agy +``` + +#### Codex account API priority + +**启用:** Codex 账号 API 优先级。在 LoopX 的 Python 环境安装可选账号扩展并重启服务,在「设置 → 模型提供方」用已授权 key 选择「管家 API → API 优先」。 + +**验证:** 刷新读回选择项,另行检查执行 Agent 的订阅优先策略。 + +**停用 / 回退:** 将管家 API 角色设为「禁用」。执行 Agent 按其独立 fallback 设置管理。 + +**权限边界:** 使用经授权的 key。新选择从下一个 Turn 生效,进行中的请求保持原路由。 + +**文档:** [本版文档](https://github.com/loopx-project/loopx/blob/v2.3.0/packages/loopx-codex-provider-routing/NATIVE.md) + +```bash +python3 -m pip install ./packages/loopx-codex-provider-routing +loopx-codex-accounts status +``` + +#### Research evidence and report readback + +**启用:** 研究证据与报告读回。需要捕获新证据时,为该 Goal 选 Explore evidence。研究详情可打开 Turn 范围、下载报告或查看对照,首页可回看已有报告。 + +**验证:** 读回原运行、保留版本、token 来源和未知字段。 + +**停用 / 回退:** 将 Explore 模式设为 off 停止新的捕获,保留原有证据。 + +**权限边界:** 报告与对照读取该 Goal 有权访问的来源。实测用量与估算分别保留出处。 + +**文档:** [本版文档](https://github.com/loopx-project/loopx/blob/v2.3.0/loopx/capabilities/explore/README.md) + +```bash +loopx configure-goal --goal-id "$goal_id" --explore-mode evidence --execute +loopx --format json configure-goal --goal-id "$goal_id" +loopx configure-goal --goal-id "$goal_id" --explore-mode off --execute +``` + +#### Anti-Entropy retirement checks + +**启用:** Anti-Entropy 退役检查。为该任务明确声明 retirement_basis 或 anti-entropy-governance 要求,给出目标路径或符号及修改后工作区。 + +**验证:** 对绑定工作区与原始 diff 先 inspect,再用 verify 获取退役验证证据。 + +**停用 / 回退:** 在后续任务移除显式启用声明,无关任务不调用该检查。 + +**权限边界:** 检查只读,交付由 change-quality 判定,缺少工作区源码时不能出具认证。 + +**文档:** [本版文档](https://github.com/loopx-project/loopx/blob/v2.3.0/loopx/capabilities/anti_entropy/README.md) + +```bash +loopx anti-entropy inspect --goal-id "$goal_id" --diff-file diff.patch --basis-json basis.json --workspace "$workspace" --format json +loopx anti-entropy verify --goal-id "$goal_id" --diff-file diff.patch --basis-json basis.json --workspace "$workspace" --format json +``` + +#### Rune repository context pilot + +**启用:** Rune 仓库上下文试验。从源码 checkout 运行,显式传 --prepare-rune、文档中的固定源码版本和新输出目录。 + +**验证:** 核对报告里的引用摘要、不可用行和 Issue-Fix workflow-plan 读回。 + +**停用 / 回退:** 省略 --prepare-rune 和 --rune-source,只跑直接搜索对照。 + +**权限边界:** 无模型试验在一次性导出目录执行,所得引用只作为 Issue-Fix 的参考证据。 + +**文档:** [本版文档](https://github.com/loopx-project/loopx/blob/v2.3.0/examples/repository-context-pilot/README.md) + +```bash +node --experimental-strip-types examples/repository-context-pilot/run.ts --repository . --revision 789694fa42972db17acd47aa2604b1907060028d --out "$pilot_out" --prepare-rune --loopx-python "$PWD/.venv/bin/python" +``` + +#### Native Claude Code benchmark runner + +**启用:** Claude Code 原生基准 runner。显式选 benchmark.edgebench.claude_run,按文档固定原生二进制、摘要、provider 源码和隔离评测环境,在进程环境提供凭据。 + +**验证:** 先查看启动参数,再从已授权试验读回会话、结果与 Stop 接续。 + +**停用 / 回退:** 按 provider 的清理入口停止该次试验,后续使用普通 runner。 + +**权限边界:** 提交和期限由所选 provider 管理,按已声明的权限处理非秘密任务文件。 + +**文档:** [本版文档](https://github.com/loopx-project/loopx/blob/v2.3.0/benchmark/runtime/NATIVE_CLAUDE.md) + +```bash +python -m benchmark.edgebench.claude_run --help +``` + +#### Benchmark checkpoint and local probes + +**启用:** 基准交接与本地探针。为已准入的 heartbeat 试验在 fresh_replan 请求中声明一次 settled_work_capture_once;SForge 在获准 worker 上显式选 --execution-observation local_probe。 + +**验证:** 读取原 checkpoint_handoff 与 worker/trial 回执。Linux 内存采样应绑定原候选和输入。 + +**停用 / 回退:** 后续试验省略一次性声明及 execution-observation 参数。 + +**权限边界:** 原 worker 预算与 provider 管理执行。采样是估计,错过的交接点不能由后续工作补录。 + +**文档:** [本版文档](https://github.com/loopx-project/loopx/blob/v2.3.0/benchmark/README.md) + +```bash +python -m benchmark.edgebench.run --help +``` + +#### Benchmark campaign reports + +**启用:** 基准试验报告。提供明确的试验选择,按需设置 duration_policy.makeup_tolerance_ms 允许小额终态缺口,显式加 --score-curves 生成 HTML。 + +**验证:** 在产物中核对时长分类、实际缺口与分数观测序列。 + +**停用 / 回退:** 移除容差或设为零,不需要 HTML 时省略 --score-curves。 + +**权限边界:** 本地报告从已有证据生成视图,期限、试验完整性和评分由原 provider 判定。 + +**文档:** [本版文档](https://github.com/loopx-project/loopx/blob/v2.3.0/docs/reference/benchmark-campaign-report.md) + +```bash +node --experimental-strip-types scripts/benchmark-campaign-report.ts campaign-input.json reports/campaign --score-curves +``` + +#### Issue-Fix correction and return + +**启用:** Issue-Fix 修订验证与原路返回。对指定 issue 显式选 Issue-Fix 路由,按其工作计划核对修订证据与原请求。 + +**验证:** 查看已有 Goal 的工作计划,确认修订来源和结果接收位置绑定原请求。 + +**停用 / 回退:** 普通工作省略 Issue-Fix 路由,已在执行的请求沿原 Goal 控制。 + +**权限边界:** 预览不授予投递权限,代码操作和原路回送各自遵守已有授权。 + +**文档:** [本版文档](https://github.com/loopx-project/loopx/blob/v2.3.0/loopx/capabilities/issue_fix/README.md) + +```bash +loopx issue-fix workflow-plan --goal-id "$goal_id" +loopx start-goal --guided --project . --capability-route issue-fix --goal-text "$issue_request" +``` + +### 发布验证 + +- Package version and tag: `2.3.0` / `v2.3.0`. +- The GitHub release records the qualified source and distribution verification. + +[Compare v2.2.0 and v2.3.0](https://github.com/loopx-project/loopx/compare/v2.2.0...v2.3.0) diff --git a/docs/quota-allocation.md b/docs/quota-allocation.md index 3fb88f6053..20e25c1b01 100644 --- a/docs/quota-allocation.md +++ b/docs/quota-allocation.md @@ -868,6 +868,22 @@ including `generic_cli`, `outer_controller`, and native CLI profiles. An explici asking for the selection it just accepted. This does not enable `--begin-turn` for those hosts or change legacy calls without a Turn identity. +When a visible Goal guard finds work that needs a Turn but has no Turn +identity, `cli_channel.turn_binding_required` makes its guard the next action. +Run that command before working on the selected Todo or replan obligation. +Only the resulting admission can allow work or supply settlement commands. +If the guard requires explicit Todo selection, its selection command includes +the Turn binding so both can be qualified together. +`loopx turn plan` still previews the external driver's route and authoring +contract. Execution goes through that driver's launch and settlement checks. + +可见 Goal 的 guard 找到需要绑定 Turn 的工作时,若还没有 Turn 身份, +`cli_channel.turn_binding_required` 表示下一步是执行它给出的 guard 命令。 +先完成这一步,再处理选中的 Todo 或重规划义务。后续准入结果才决定能否执行工作, +并提供对应的结算命令。若需要显式选择 Todo,选择命令会携带 Turn 参数, +在一次调用中校验选择并绑定 Turn。 +`loopx turn plan` 仍可预览外部 Turn 驱动器的路线和提交约定,执行时由该驱动器检查启动和结算条件。 + Hosts that already support `--begin-turn` may combine it with `--todo-id` to qualify a known choice on the initial guard. Reuse the returned Turn identity for recovery; beginning a Turn does not claim a lease or bypass admission. diff --git a/examples/auto-research-artifact-receipt-smoke.py b/examples/auto-research-artifact-receipt-smoke.py deleted file mode 100644 index fddab46df7..0000000000 --- a/examples/auto-research-artifact-receipt-smoke.py +++ /dev/null @@ -1,204 +0,0 @@ -#!/usr/bin/env python3 -"""Exercise the Auto Research Wish-to-Artifact receipt lifecycle.""" - -from __future__ import annotations - -import sys -import tempfile -from pathlib import Path -from typing import Any - - -REPO_ROOT = Path(__file__).resolve().parents[1] -if str(REPO_ROOT) not in sys.path: - sys.path.insert(0, str(REPO_ROOT)) - -from examples.auto_research_lightweight_fixture import ( # noqa: E402 - AGENT_ID, - GOAL_ID, - HYPOTHESIS_ID, - HYPOTHESIS_TEXT, - MECHANISM_FAMILY, - TODO_ID, - eval_result, - research_contract, - run_auto_research_cli, - write_json, - write_registry, -) - - -PROMOTER = "evaluator-promoter" -REVIEWER = "independent-reviewer" -EXPERIMENT_REF = "artifact:state-a2a-experiment" -REPORT_REF = "artifact:state-a2a-report" - - -def delivery_contract() -> dict[str, Any]: - return { - "schema_version": "auto_research_delivery_contract_v0", - "contract_ref": "contract:state-a2a-evidence", - "wish": { - "original_text": ( - "Verify whether state-mediated handoffs improve the shared " - "research candidate." - ), - "intent_summary": ( - "Run a bounded dev and holdout comparison and deliver the " - "experiment plus a result report." - ), - "assumptions": [ - "The public fixture metric represents useful progress." - ], - "non_goals": [ - "Do not publish or deploy the experimental candidate." - ], - }, - "research_contract": research_contract(), - "required_artifacts": [ - { - "artifact_ref": EXPERIMENT_REF, - "kind": "experiment", - "description": "Runnable state-mediated comparison.", - "required": True, - }, - { - "artifact_ref": REPORT_REF, - "kind": "report", - "description": "Evidence-backed result and limitation report.", - "required": True, - }, - ], - "acceptance_criteria": [ - { - "criterion_id": "heldout_result", - "description": ( - "The candidate has a terminal held-out decision with " - "independent review." - ), - "hypothesis_id": HYPOTHESIS_ID, - "required_artifact_refs": [EXPERIMENT_REF, REPORT_REF], - "requires_independent_review": True, - "required": True, - } - ], - "failure_policy": { - "fallback_artifact_refs": [REPORT_REF], - "reentry_conditions": [ - "Provide a new public evaluator or revised held-out task set." - ], - }, - } - -def main() -> int: - with tempfile.TemporaryDirectory() as temp_dir: - temp = Path(temp_dir) - registry = write_registry( - temp, - registered_agents=[AGENT_ID, PROMOTER, REVIEWER], - ) - contract_path = temp / "delivery-contract.public.json" - dev_path = temp / "dev-result.public.json" - holdout_path = temp / "holdout-result.public.json" - write_json(contract_path, delivery_contract()) - dev = eval_result("dev") - holdout = eval_result("holdout") - dev["artifact_refs"].extend([EXPERIMENT_REF, REPORT_REF]) - holdout["artifact_refs"].extend([EXPERIMENT_REF, REPORT_REF]) - write_json(dev_path, dev) - write_json(holdout_path, holdout) - - packet = run_auto_research_cli( - registry, - "evidence", - "--contract", - str(contract_path), - "--eval-result", - str(dev_path), - "--eval-result", - str(holdout_path), - "--hypothesis-id", - HYPOTHESIS_ID, - "--todo-id", - TODO_ID, - "--agent-id", - AGENT_ID, - "--claimed-by", - AGENT_ID, - "--mechanism-family", - MECHANISM_FAMILY, - "--hypothesis", - HYPOTHESIS_TEXT, - ) - assert packet["delivery_contract"]["wish"]["wish_id"].startswith("wish_") - assert packet["contract_lineage"]["contract_revision"].startswith( - "sha256:" - ) - packet_path = temp / "evidence-packet.public.json" - write_json(packet_path, packet) - first_append = run_auto_research_cli( - registry, - "append-evidence", - "--packet", - str(packet_path), - ) - second_append = run_auto_research_cli( - registry, - "append-evidence", - "--packet", - str(packet_path), - ) - assert first_append["counts_by_kind"]["validation"] == 1, first_append - assert second_append["appended_count"] == 0, second_append - - run_auto_research_cli( - registry, - "decide", - "--goal-id", - GOAL_ID, - "--hypothesis-id", - HYPOTHESIS_ID, - "--outcome", - "promoted", - "--reason", - "holdout_validated", - "--agent-id", - PROMOTER, - "--evidence-ref", - "decision:heldout", - "--execute", - ) - run_auto_research_cli( - registry, - "review", - "--goal-id", - GOAL_ID, - "--hypothesis-id", - HYPOTHESIS_ID, - "--reviewer-agent-id", - REVIEWER, - "--verdict", - "approve", - "--require-independent", - "--evidence-ref", - "review:independent", - "--execute", - ) - receipt = run_auto_research_cli( - registry, - "artifact-receipt", - "--contract", - str(contract_path), - ) - assert receipt["status"] == "verified", receipt - assert receipt["failure_feedback"] is None, receipt - assert receipt["artifacts"]["missing_required_refs"] == [], receipt - assert receipt["acceptance_results"][0]["status"] == "verified", receipt - assert receipt["boundary"]["automatic_skill_promotion"] is False, receipt - - print("auto-research-artifact-receipt-smoke ok") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/examples/auto-research-evidence-writer-smoke.py b/examples/auto-research-evidence-writer-smoke.py deleted file mode 100644 index 7a41a526ee..0000000000 --- a/examples/auto-research-evidence-writer-smoke.py +++ /dev/null @@ -1,176 +0,0 @@ -#!/usr/bin/env python3 -"""Smoke-test public-safe auto-research evidence packet generation.""" - -from __future__ import annotations - -import json -import subprocess -import sys -import tempfile -from pathlib import Path -from typing import Any - - -REPO_ROOT = Path(__file__).resolve().parents[1] -if str(REPO_ROOT) not in sys.path: - sys.path.insert(0, str(REPO_ROOT)) - -from examples.auto_research_lightweight_fixture import ( # noqa: E402 - AGENT_ID, - GROUNDING_REF, - HYPOTHESIS_ID, - HYPOTHESIS_TEXT, - MECHANISM_FAMILY, - METRIC_NAME, - TODO_ID, - eval_result, - write_contract_and_results, - write_json, -) - - -def assert_public_safe(payload: Any) -> None: - text = json.dumps(payload, sort_keys=True) if not isinstance(payload, str) else payload - forbidden = [ - "/" + "Users/", - "/" + "private/", - "/" + "tmp/", - "lark" + "office", - "byte" + "dance", - "http://", - "https://", - "api" + "_key", - "pass" + "word", - "sec" + "ret", - ] - leaked = [needle for needle in forbidden if needle.lower() in text.lower()] - assert not leaked, leaked - - -def run_json(args: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]: - return subprocess.run( - [sys.executable, *args], - cwd=REPO_ROOT, - check=check, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - - -def run_evidence_cli( - contract: Path, - paths: list[Path], - *, - check: bool = True, -) -> subprocess.CompletedProcess[str]: - args = [ - "-m", - "loopx.cli", - "--format", - "json", - "auto-research", - "evidence", - "--contract", - str(contract), - "--hypothesis-id", - HYPOTHESIS_ID, - "--todo-id", - TODO_ID, - "--agent-id", - AGENT_ID, - "--claimed-by", - AGENT_ID, - "--mechanism-family", - MECHANISM_FAMILY, - "--hypothesis", - HYPOTHESIS_TEXT, - "--grounding-ref", - GROUNDING_REF, - "--branch-ref", - "codex/auto-research-evidence-writer-smoke", - ] - for path in paths: - args.extend(["--eval-result", str(path)]) - return run_json(args, check=check) - - -def main() -> None: - with tempfile.TemporaryDirectory() as temp_dir: - temp = Path(temp_dir) - contract, dev, holdout = write_contract_and_results(temp) - - result = run_evidence_cli(contract, [dev, holdout]) - payload = json.loads(result.stdout) - assert payload["ok"] is True, payload - assert payload["schema_version"] == "auto_research_evidence_packet_v0", payload - assert payload["hypothesis"]["schema_version"] == "research_hypothesis_v0", payload - assert payload["hypothesis"]["status"] == "supported", payload - assert payload["summary"]["splits"] == ["dev", "holdout"], payload - assert payload["summary"]["negative_evidence_count"] == 0, payload - assert payload["summary"]["needs_retry_count"] == 0, payload - assert payload["summary"]["protected_scope_clean"] is True, payload - assert len(payload["evidence_events"]) == 2, payload - assert {event["schema_version"] for event in payload["evidence_events"]} == { - "research_evidence_event_v0" - }, payload - assert {event["split"] for event in payload["evidence_events"]} == {"dev", "holdout"}, payload - assert all(event["raw_logs_recorded"] is False for event in payload["evidence_events"]), payload - assert all(event["private_artifacts_recorded"] is False for event in payload["evidence_events"]), payload - assert any( - "branch:codex/auto-research-evidence-writer-smoke" in event["artifact_refs"] - for event in payload["evidence_events"] - ), payload - assert_public_safe(payload) - - retry = temp / "retry.json" - write_json( - retry, - { - "schema_version": "auto_research_lightweight_eval_result_v0", - "split": "dev", - "metric": { - "name": METRIC_NAME, - "direction": "maximize", - "value": None, - "baseline": 1.0, - }, - "eval_status": "failed_to_run", - "primary_metric_status": "inconclusive", - "artifact_refs": ["public_metric:dev:retry"], - "protected_scope_clean": True, - "no_upload": True, - }, - ) - retry_payload = json.loads(run_evidence_cli(contract, [retry]).stdout) - assert retry_payload["hypothesis"]["status"] == "needs_retry", retry_payload - assert retry_payload["summary"]["needs_retry_count"] == 1, retry_payload - assert retry_payload["summary"]["negative_evidence_count"] == 0, retry_payload - assert retry_payload["evidence_events"][0]["eval_status"] == "failed_to_run", retry_payload - assert_public_safe(retry_payload) - - dirty = temp / "dirty.json" - dirty_payload = eval_result("dev") - dirty_payload["protected_scope_clean"] = False - write_json(dirty, dirty_payload) - contradicted = json.loads(run_evidence_cli(contract, [dirty]).stdout) - assert contradicted["hypothesis"]["status"] == "contradicted", contradicted - assert contradicted["summary"]["negative_evidence_count"] == 1, contradicted - assert contradicted["summary"]["protected_scope_clean"] is False, contradicted - assert_public_safe(contradicted) - - bad = temp / "bad.json" - bad_payload = eval_result("dev") - bad_payload["artifact_refs"] = ["/" + "Users/example/raw.log"] - write_json(bad, bad_payload) - blocked = run_evidence_cli(contract, [bad], check=False) - blocked_payload = json.loads(blocked.stdout) - assert blocked.returncode == 1, blocked_payload - assert blocked_payload["ok"] is False, blocked_payload - assert "public alias" in blocked_payload["error"], blocked_payload - - print("auto-research-evidence-writer-smoke ok") - - -if __name__ == "__main__": - main() diff --git a/examples/auto-research-knn-continuation-smoke.py b/examples/auto-research-knn-continuation-smoke.py deleted file mode 100644 index 31920fd4fa..0000000000 --- a/examples/auto-research-knn-continuation-smoke.py +++ /dev/null @@ -1,119 +0,0 @@ -#!/usr/bin/env python3 -"""Smoke-test the KNN preset does not imply fake continuation metrics.""" - -from __future__ import annotations - -import json -import os -import subprocess -import sys -import tempfile -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] -QUESTION = "如何提升 KNN 精确近邻检索速度?" - - -def main() -> int: - with tempfile.TemporaryDirectory() as temp_dir: - temp = Path(temp_dir) - registry = temp / "registry.global.json" - runtime_root = temp / "runtime" - workspace = temp / "workspace" - workspace.mkdir() - registry.write_text( - json.dumps({"common_runtime_root": str(runtime_root), "goals": []}), - encoding="utf-8", - ) - env = os.environ.copy() - env["PYTHONPATH"] = str(ROOT) - env["PYTHONDONTWRITEBYTECODE"] = "1" - result = subprocess.run( - [ - sys.executable, - "-m", - "loopx.cli", - "--registry", - str(registry), - "--runtime-root", - str(runtime_root), - "--format", - "json", - "auto-research", - "start", - QUESTION, - "--preset", - "knn-demo", - "--language", - "zh", - "--execute", - "--headless", - "--worker-loop-rounds", - "1", - ], - cwd=workspace, - env=env, - check=False, - capture_output=True, - text=True, - ) - if result.returncode != 0: - raise AssertionError( - f"knn preset start failed rc={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" - ) - payload = json.loads(result.stdout) - - preset = payload["preset_context"] - assert preset["preset_id"] == "knn-demo", preset - assert preset["baseline_source"] == "generated_knn_benchmark_workspace", preset - assert preset["question_text_supplies_baseline"] is False, preset - assert preset["metric_name"] == "speedup", preset - assert preset["baseline_metric"] == 1.0, preset - assert preset["editable_scope"] == ["solution.py"], preset - assert preset["protected_scope"] == ["task.py", "eval.py", "eval.sh"], preset - assert preset["dev_eval_command"] == "bash eval.sh dev", preset - assert preset["holdout_eval_command"] == "bash eval.sh test", preset - - seeded_todos = payload["visible_control_plane"]["seeded_todos"] - assert [todo["action_kind"] for todo in seeded_todos] == [ - "write_research_contract", - "propose_hypothesis", - "run_dev_eval", - "summarize_evidence", - ], seeded_todos - expected_resume = [None] + [ - f"todo_done:{todo['todo_id']}" for todo in seeded_todos[:-1] - ] - actual_resume = [todo["resume_when"] for todo in seeded_todos] - assert actual_resume == expected_resume, seeded_todos - - worker_loop = payload["worker_loop"] - assert worker_loop["selected_actions"] == ["write_research_contract"], worker_loop - for agent_id in ("hypothesis-proposer", "research-executor", "evaluator-promoter"): - waiting_turn = next( - turn for turn in worker_loop["turns"] if turn["agent_id"] == agent_id - ) - assert waiting_turn["mode"] == "no_action", worker_loop - assert waiting_turn["selected_action"] is None, worker_loop - assert all(turn.get("dev_metric") is None for turn in worker_loop["turns"]), worker_loop - assert all(turn.get("holdout_metric") is None for turn in worker_loop["turns"]), worker_loop - - collective = payload["collective_research_rounds"] - assert collective["multi_round_research_verified"] is False, collective - assert collective["holdout_improvement_count"] == 0, collective - assert collective["dev_metric_sequence"] == [], collective - assert collective["holdout_metric_sequence"] == [], collective - - tonight = payload["tonight_experience"] - assert tonight["positive_result"] is False, tonight - assert tonight["ready"] is False, tonight - assert tonight["positive_result_basis"] == "requires_visible_lane_authored_evidence", tonight - assert tonight["dev_metric"] is None, tonight - assert tonight["holdout_metric"] is None, tonight - assert payload["public_boundary"]["raw_logs_recorded"] is False, payload - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/examples/auto-research-rollout-append-smoke.py b/examples/auto-research-rollout-append-smoke.py deleted file mode 100644 index b2e8a3a2d9..0000000000 --- a/examples/auto-research-rollout-append-smoke.py +++ /dev/null @@ -1,206 +0,0 @@ -#!/usr/bin/env python3 -"""Smoke-test appending auto-research evidence packets into rollout events.""" - -from __future__ import annotations - -import json -import subprocess -import sys -import tempfile -from pathlib import Path -from typing import Any - - -REPO_ROOT = Path(__file__).resolve().parents[1] -if str(REPO_ROOT) not in sys.path: - sys.path.insert(0, str(REPO_ROOT)) - -from loopx.rollout_event_log import load_rollout_events, rollout_event_log_path # noqa: E402 - -from examples.auto_research_lightweight_fixture import ( # noqa: E402 - AGENT_ID, - GROUNDING_REF, - HYPOTHESIS_ID, - HYPOTHESIS_TEXT, - MECHANISM_FAMILY, - TODO_ID, - write_contract_and_results, -) - - -def assert_public_safe(payload: Any) -> None: - text = json.dumps(payload, sort_keys=True) if not isinstance(payload, str) else payload - forbidden = [ - "/" + "Users/", - "/" + "private/", - "/" + "tmp/", - "lark" + "office", - "byte" + "dance", - "http://", - "https://", - "api" + "_key", - "pass" + "word", - "sec" + "ret", - ] - leaked = [needle for needle in forbidden if needle.lower() in text.lower()] - assert not leaked, leaked - - -def run_json(args: list[str]) -> dict[str, Any]: - result = subprocess.run( - [sys.executable, *args], - cwd=REPO_ROOT, - check=True, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - assert_public_safe(result.stdout) - return json.loads(result.stdout) - - -def evidence_packet(contract: Path, dev: Path, holdout: Path) -> dict[str, Any]: - return run_json( - [ - "-m", - "loopx.cli", - "--format", - "json", - "auto-research", - "evidence", - "--contract", - str(contract), - "--eval-result", - str(dev), - "--eval-result", - str(holdout), - "--hypothesis-id", - HYPOTHESIS_ID, - "--todo-id", - TODO_ID, - "--agent-id", - AGENT_ID, - "--claimed-by", - AGENT_ID, - "--mechanism-family", - MECHANISM_FAMILY, - "--hypothesis", - HYPOTHESIS_TEXT, - "--grounding-ref", - GROUNDING_REF, - "--branch-ref", - "codex/auto-research-rollout-append-smoke", - ] - ) - - -def append_packet( - registry: Path, - packet: Path, - *, - dry_run: bool = False, - output: Path | None = None, -) -> dict[str, Any]: - args = [ - "-m", - "loopx.cli", - "--registry", - str(registry), - "--format", - "json", - "auto-research", - "append-evidence", - "--packet", - str(packet), - ] - if dry_run: - args.append("--dry-run") - if output is not None: - args.extend(["--output", str(output)]) - return run_json(args) - - -def main() -> None: - with tempfile.TemporaryDirectory() as temp_dir: - temp = Path(temp_dir) - runtime_root = temp / "runtime" - registry = temp / "registry.json" - registry.write_text( - json.dumps( - { - "schema_version": "0.1", - "common_runtime_root": str(runtime_root), - "goals": [ - { - "id": "loopx-meta", - "domain": "auto_research_smoke", - "status": "active", - "repo": str(REPO_ROOT), - "state_file": "ACTIVE_GOAL_STATE.md", - } - ], - }, - indent=2, - sort_keys=True, - ), - encoding="utf-8", - ) - - packet_path = temp / "packet.json" - contract, dev, holdout = write_contract_and_results(temp) - packet_path.write_text( - json.dumps(evidence_packet(contract, dev, holdout), indent=2, sort_keys=True) + "\n", - encoding="utf-8", - ) - - dry = append_packet(registry, packet_path, dry_run=True) - assert dry["schema_version"] == "auto_research_rollout_append_v0", dry - assert dry["dry_run"] is True, dry - assert dry["event_count"] == 3, dry - assert dry["would_append_count"] == 3, dry - assert dry["appended_count"] == 0, dry - assert dry["counts_by_kind"] == { - "research_evidence": 2, - "research_hypothesis": 1, - }, dry - assert_public_safe(dry) - - append_output = temp / "append-result.public.json" - first = append_packet(registry, packet_path, output=append_output) - assert first["dry_run"] is False, first - assert first["event_count"] == 3, first - assert first["appended_count"] == 3, first - assert first["skipped_existing_count"] == 0, first - assert append_output.is_file(), first - assert json.loads(append_output.read_text(encoding="utf-8")) == first, first - assert_public_safe(first) - - second = append_packet(registry, packet_path) - assert second["appended_count"] == 0, second - assert second["would_append_count"] == 0, second - assert second["skipped_existing_count"] == 3, second - assert_public_safe(second) - - events = load_rollout_events(rollout_event_log_path(runtime_root, first["goal_id"])) - assert len(events) == 3, events - assert {event["event_kind"] for event in events} == { - "research_hypothesis", - "research_evidence", - }, events - hypothesis = [event for event in events if event["event_kind"] == "research_hypothesis"][0] - evidence = [event for event in events if event["event_kind"] == "research_evidence"] - assert hypothesis["classification"] == "research_hypothesis_v0", hypothesis - assert hypothesis["details"]["evidence_event_count"] == 2, hypothesis - assert {event["classification"] for event in evidence} == { - "research_evidence_event_v0" - }, evidence - assert {event["details"]["split"] for event in evidence} == {"dev", "holdout"}, evidence - assert all(event["boundary"]["raw_logs_recorded"] is False for event in events), events - assert all(event["boundary"]["absolute_paths_recorded"] is False for event in events), events - assert_public_safe(events) - - print("auto-research-rollout-append-smoke ok") - - -if __name__ == "__main__": - main() diff --git a/examples/auto-research-terminal-results-cli-smoke.py b/examples/auto-research-terminal-results-cli-smoke.py deleted file mode 100644 index 93de43666a..0000000000 --- a/examples/auto-research-terminal-results-cli-smoke.py +++ /dev/null @@ -1,175 +0,0 @@ -#!/usr/bin/env python3 -"""Exercise the shipped Auto Research terminal-result CLI lifecycle.""" - -from __future__ import annotations - -import json -import sys -import tempfile -from pathlib import Path - - -REPO_ROOT = Path(__file__).resolve().parents[1] -if str(REPO_ROOT) not in sys.path: - sys.path.insert(0, str(REPO_ROOT)) - -from examples.auto_research_lightweight_fixture import ( # noqa: E402 - AGENT_ID, - GOAL_ID, - HYPOTHESIS_ID, - HYPOTHESIS_TEXT, - MECHANISM_FAMILY, - TODO_ID, - run_auto_research_cli, - write_contract_and_results, - write_registry, -) - - -PROMOTER = "evaluator-promoter" -REVIEWER = "independent-reviewer" - -def main() -> int: - with tempfile.TemporaryDirectory() as temp_dir: - temp = Path(temp_dir) - registry = write_registry( - temp, - registered_agents=[AGENT_ID, PROMOTER, REVIEWER], - ) - contract, dev, holdout = write_contract_and_results(temp) - packet_path = temp / "packet.json" - packet = run_auto_research_cli( - registry, - "evidence", - "--contract", - str(contract), - "--eval-result", - str(dev), - "--eval-result", - str(holdout), - "--hypothesis-id", - HYPOTHESIS_ID, - "--todo-id", - TODO_ID, - "--agent-id", - AGENT_ID, - "--claimed-by", - AGENT_ID, - "--mechanism-family", - MECHANISM_FAMILY, - "--hypothesis", - HYPOTHESIS_TEXT, - ) - packet_path.write_text(json.dumps(packet), encoding="utf-8") - run_auto_research_cli( - registry, - "append-evidence", - "--packet", - str(packet_path), - ) - unregistered = run_auto_research_cli( - registry, - "decide", - "--goal-id", - GOAL_ID, - "--hypothesis-id", - HYPOTHESIS_ID, - "--outcome", - "promoted", - "--reason", - "holdout_validated", - "--agent-id", - "unregistered-promoter", - "--execute", - expect_ok=False, - ) - assert "not registered" in unregistered["error"], unregistered - run_auto_research_cli( - registry, - "decide", - "--goal-id", - GOAL_ID, - "--hypothesis-id", - HYPOTHESIS_ID, - "--outcome", - "promoted", - "--reason", - "holdout_validated", - "--agent-id", - PROMOTER, - "--evidence-ref", - "decision:heldout", - "--execute", - ) - self_review = run_auto_research_cli( - registry, - "review", - "--goal-id", - GOAL_ID, - "--hypothesis-id", - HYPOTHESIS_ID, - "--reviewer-agent-id", - PROMOTER, - "--verdict", - "approve", - "--execute", - ) - assert self_review["review"]["independent"] is False, self_review - run_auto_research_cli( - registry, - "review", - "--goal-id", - GOAL_ID, - "--hypothesis-id", - HYPOTHESIS_ID, - "--reviewer-agent-id", - REVIEWER, - "--verdict", - "approve", - "--require-independent", - "--evidence-ref", - "review:independent", - "--execute", - ) - result = run_auto_research_cli( - registry, - "results", - "--goal-id", - GOAL_ID, - "--hypothesis-id", - HYPOTHESIS_ID, - "--include-history", - ) - assert result["results"][0]["finding_status"] == "confirmed", result - first = run_auto_research_cli( - registry, - "project-results", - "--goal-id", - GOAL_ID, - "--execute", - ) - second = run_auto_research_cli( - registry, - "project-results", - "--goal-id", - GOAL_ID, - "--execute", - ) - assert first["appended_count"] > 0, first - assert second["appended_count"] == 0, second - assert second["reused_count"] == second["event_count"], second - readback = run_auto_research_cli( - registry, - "results", - "--goal-id", - GOAL_ID, - "--hypothesis-id", - HYPOTHESIS_ID, - ) - assert readback["explore_projection"]["state"] == "current", readback - print("auto-research-terminal-results-cli-smoke ok") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/examples/backlog-hygiene-smoke.py b/examples/backlog-hygiene-smoke.py index a6d82a9366..5ff14f5692 100644 --- a/examples/backlog-hygiene-smoke.py +++ b/examples/backlog-hygiene-smoke.py @@ -11,6 +11,10 @@ REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from tests.control_plane.cli_output_fixture import read_cli_context_for_oracle # noqa: E402 sys.path.insert(0, str(REPO_ROOT / "tests" / "control_plane")) from canonical_authority_fixture import initialize_canonical_source_fixture # noqa: E402 @@ -36,7 +40,8 @@ def run_cli(*args: str, registry_path: Path, runtime: Path) -> dict: capture_output=True, text=True, ) - return json.loads(result.stdout) + body, _ = read_cli_context_for_oracle(result.stdout) + return json.loads(body) def write_fixture( diff --git a/examples/blocker-push-runtime-smoke.py b/examples/blocker-push-runtime-smoke.py index 14ba2be387..c5513f743b 100644 --- a/examples/blocker-push-runtime-smoke.py +++ b/examples/blocker-push-runtime-smoke.py @@ -19,6 +19,10 @@ REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from tests.control_plane.cli_output_fixture import read_cli_context_for_oracle # noqa: E402 GOAL_ID = "focus-wait-owner-blocker" USER_TODO = "Provide new owner evidence, a clean baseline, or external eval before delivery resumes." @@ -143,7 +147,8 @@ def run_cli(registry: Path, runtime_root: Path, *args: str) -> dict: text=True, capture_output=True, ) - return json.loads(result.stdout) + body, _ = read_cli_context_for_oracle(result.stdout) + return json.loads(body) def main() -> int: diff --git a/examples/control_plane/heartbeat-prompt-smoke.py b/examples/control_plane/heartbeat-prompt-smoke.py index 1cf78ad3e1..4c373560d5 100644 --- a/examples/control_plane/heartbeat-prompt-smoke.py +++ b/examples/control_plane/heartbeat-prompt-smoke.py @@ -927,6 +927,8 @@ def main() -> int: GOAL_ID, "--active-state", str(ACTIVE_STATE), + "--context-delivery", + "auto", ], cwd=REPO_ROOT, check=True, @@ -939,6 +941,7 @@ def main() -> int: active_state=ACTIVE_STATE, reward_memory_enabled=False, registry_path=cli_registry, + context_delivery="auto", ) assert cli_payload["task_body"] == cli_expected_payload["task_body"], cli_payload assert set(cli_payload) == { @@ -965,6 +968,8 @@ def main() -> int: GOAL_ID, "--active-state", str(ACTIVE_STATE), + "--context-delivery", + "auto", "--full", ], cwd=REPO_ROOT, @@ -979,6 +984,7 @@ def main() -> int: full=True, reward_memory_enabled=False, registry_path=cli_registry, + context_delivery="auto", ) assert cli_full_payload["task_body"] == cli_full_expected_payload["task_body"], cli_full_payload assert cli_full_payload["thin"] is False, cli_full_payload @@ -999,6 +1005,8 @@ def main() -> int: GOAL_ID, "--active-state", str(ACTIVE_STATE), + "--context-delivery", + "auto", "--compact", ], cwd=REPO_ROOT, @@ -1013,6 +1021,7 @@ def main() -> int: compact=True, reward_memory_enabled=False, registry_path=cli_registry, + context_delivery="auto", ) assert cli_compact_payload["task_body"] == cli_compact_expected_payload["task_body"], cli_compact_payload assert cli_compact_payload["compact"] is True, cli_compact_payload @@ -1031,6 +1040,8 @@ def main() -> int: GOAL_ID, "--active-state", str(ACTIVE_STATE), + "--context-delivery", + "auto", "--brief", ], cwd=REPO_ROOT, @@ -1045,6 +1056,7 @@ def main() -> int: brief=True, reward_memory_enabled=False, registry_path=cli_registry, + context_delivery="auto", ) assert cli_brief_payload["task_body"] == cli_brief_expected_payload["task_body"], cli_brief_payload assert cli_brief_payload["brief"] is True, cli_brief_payload @@ -1064,6 +1076,8 @@ def main() -> int: GOAL_ID, "--active-state", str(ACTIVE_STATE), + "--context-delivery", + "auto", "--thin", ], cwd=REPO_ROOT, @@ -1078,6 +1092,7 @@ def main() -> int: thin=True, reward_memory_enabled=False, registry_path=cli_registry, + context_delivery="auto", ) assert cli_thin_payload["task_body"] == cli_thin_expected_payload["task_body"], cli_thin_payload assert cli_thin_payload["schema_version"] == HEARTBEAT_AGENT_INPUT_SCHEMA_VERSION diff --git a/examples/control_plane/peer-agent-workspace-guard-smoke.py b/examples/control_plane/peer-agent-workspace-guard-smoke.py index 81e1b9e675..7334292e9f 100644 --- a/examples/control_plane/peer-agent-workspace-guard-smoke.py +++ b/examples/control_plane/peer-agent-workspace-guard-smoke.py @@ -12,6 +12,10 @@ REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from tests.control_plane.cli_output_fixture import read_cli_context_for_oracle # noqa: E402 sys.path.insert(0, str(REPO_ROOT)) from loopx.control_plane.testing.canary_harness import import_fixture_authority # noqa: E402 @@ -57,7 +61,8 @@ def run_cli(cwd: Path, *args: str, registry_path: Path, runtime: Path) -> dict: env=env, ) assert result.returncode == 0, result.stdout + result.stderr - return json.loads(result.stdout) + body, _ = read_cli_context_for_oracle(result.stdout) + return json.loads(body) def write_fixture(root: Path) -> tuple[Path, Path, Path, Path, Path, Path]: diff --git a/examples/control_plane/quota-cleared-blocker-successor-gate-smoke.py b/examples/control_plane/quota-cleared-blocker-successor-gate-smoke.py index 9109364ed0..3561c67771 100644 --- a/examples/control_plane/quota-cleared-blocker-successor-gate-smoke.py +++ b/examples/control_plane/quota-cleared-blocker-successor-gate-smoke.py @@ -238,7 +238,7 @@ def value_successor() -> dict: todo_id="todo_value_successor", text="[P0] Continue value exploration with the reviewed connector scope.", claimed_by=BLOCKED_AGENT, - resume_when="todo_done:todo_review_gate", + unblocks_todo_id="todo_review_gate", ) diff --git a/examples/control_plane/quota-no-git-status-smoke.py b/examples/control_plane/quota-no-git-status-smoke.py index 881d48b5b8..cadf082c9c 100644 --- a/examples/control_plane/quota-no-git-status-smoke.py +++ b/examples/control_plane/quota-no-git-status-smoke.py @@ -15,6 +15,8 @@ if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) +from tests.control_plane.cli_output_fixture import read_cli_context_for_oracle # noqa: E402 + from loopx.control_plane.testing.canary_harness import import_fixture_authority # noqa: E402 GOAL_ID = "no-git-quota-fixture" @@ -88,7 +90,8 @@ def run_cli(registry_path: Path, *args: str, env: dict[str, str] | None = None) text=True, capture_output=True, ) - return json.loads(result.stdout) + body, _ = read_cli_context_for_oracle(result.stdout) + return json.loads(body) def main() -> int: diff --git a/examples/control_plane/quota-spend-agent-identity-smoke.py b/examples/control_plane/quota-spend-agent-identity-smoke.py index 4e6a4ef43d..a60ea0b39a 100644 --- a/examples/control_plane/quota-spend-agent-identity-smoke.py +++ b/examples/control_plane/quota-spend-agent-identity-smoke.py @@ -18,6 +18,8 @@ if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) +from tests.control_plane.cli_output_fixture import read_cli_context_for_oracle # noqa: E402 + from loopx.control_plane.quota.slot_accounting import ( # noqa: E402 _latest_unspent_turn_settlement_run, ) @@ -63,7 +65,8 @@ def run_quota(root: Path, registry_path: Path, runtime: Path, *args: str) -> tup text=True, ) assert result.stdout, result.stderr - return json.loads(result.stdout), result.returncode + body, _ = read_cli_context_for_oracle(result.stdout) + return json.loads(body), result.returncode def is_state_or_accounting_command(action: str) -> bool: diff --git a/examples/control_plane/shared-runtime-material-projection-smoke.py b/examples/control_plane/shared-runtime-material-projection-smoke.py index dbbf4d00f0..6ccacfec15 100644 --- a/examples/control_plane/shared-runtime-material-projection-smoke.py +++ b/examples/control_plane/shared-runtime-material-projection-smoke.py @@ -15,6 +15,8 @@ if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) +from tests.control_plane.cli_output_fixture import read_cli_context_for_oracle # noqa: E402 + from loopx import dreaming # noqa: E402 from loopx import operator_gate as operator_gate_module # noqa: E402 from loopx import project_map # noqa: E402 @@ -137,7 +139,8 @@ def run_cli(registry: Path, runtime: Path, *args: str, runtime_override: bool = env={**os.environ, "PYTHONPATH": str(REPO_ROOT), "LOOPX_RUNTIME_ROOT": str(runtime)}, ) assert result.returncode == expected_code, result.stdout + result.stderr - return json.loads(result.stdout) + body, _ = read_cli_context_for_oracle(result.stdout) + return json.loads(body) def append_dreaming_proposal(runtime: Path) -> str: diff --git a/examples/control_plane/status-markdown-smoke.py b/examples/control_plane/status-markdown-smoke.py index 2d1e7fe5e3..3ae72f426c 100644 --- a/examples/control_plane/status-markdown-smoke.py +++ b/examples/control_plane/status-markdown-smoke.py @@ -1334,7 +1334,8 @@ def main() -> int: assert_project_asset_secret_scanner_boundaries() assert_promotion_readiness_full_scan_fallback() assert_promotion_readiness_warning_in_quota_guard() - assert_status_agent_lane_next_action_projection() + with tempfile.TemporaryDirectory(prefix="loopx-status-agent-lane-") as tmp: + assert_status_agent_lane_next_action_projection(Path(tmp)) assert_status_agent_lane_todo_summary_display_compaction() assert_status_agent_member_selected_lane_claim_survives_truncated_claim_list() assert_status_agent_lane_vision_lookback_survives_display_trim() diff --git a/examples/control_plane/status_markdown_agent_lane_fixtures.py b/examples/control_plane/status_markdown_agent_lane_fixtures.py index e5a247036c..cf07807dce 100644 --- a/examples/control_plane/status_markdown_agent_lane_fixtures.py +++ b/examples/control_plane/status_markdown_agent_lane_fixtures.py @@ -2,20 +2,24 @@ from __future__ import annotations +from pathlib import Path + from loopx.cli_commands.status import ( _status_collection_limit_for_agent_lane, _trim_run_history_for_status_display, attach_agent_lane_next_actions, ) +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection from loopx.control_plane.todos.quota_summary import ( compact_agent_lane_todos_for_status_display, ) from loopx.review_packet import build_review_packet from loopx.presentation.renderers.status_markdown import render_status_markdown from loopx.status import project_asset_todo_summary +from tests.control_plane.canonical_authority_fixture import initialize_canonical_authority -def assert_status_agent_lane_next_action_projection() -> None: +def assert_status_agent_lane_next_action_projection(root: Path) -> None: goal_id = "agent-lane-status-fixture" primary_action = "[P0] Continue the primary controller benchmark route." stale_run_action = "[P0] Monitor the primary controller route before changing Next Action." @@ -29,6 +33,9 @@ def assert_status_agent_lane_next_action_projection() -> None: "index": 2, "role": "agent", "status": "open", + "done": False, + "archive_state": "active", + "source_section": "Agent Todo", "priority": "P0", "task_class": "advancement_task", "action_kind": "codex_cli_tui_continuation", @@ -42,11 +49,25 @@ def assert_status_agent_lane_next_action_projection() -> None: "index": 1, "role": "agent", "status": "open", + "done": False, + "archive_state": "active", + "source_section": "Agent Todo", "priority": "P0", "task_class": "advancement_task", "claimed_by": "codex-main-control", "text": primary_action, } + runtime = root / "runtime" + state_path = root / "ACTIVE_GOAL_STATE.md" + state_path.write_text("# Status lane fixture\n", encoding="utf-8") + initialize_canonical_authority( + runtime, + goal_id, + build_todo_runtime_shadow_projection( + goal_id=goal_id, todos=[primary_todo, side_todo], handoff_mode="soft_claim", leases=[], + ), + state_path=state_path, + ) agent_todos = { "schema_version": "todo_summary_v0", "open_count": 2, @@ -74,7 +95,7 @@ def assert_status_agent_lane_next_action_projection() -> None: payload = { "ok": True, "registry": "./fixtures/registry.json", - "runtime_root": "./fixtures/runtime", + "runtime_root": str(runtime), "goal_count": 1, "run_count": 1, "contract": {"ok": True, "summary": {"errors": 0, "warnings": 0, "checks": 0}}, diff --git a/examples/control_plane/todo-archive-completed-smoke.py b/examples/control_plane/todo-archive-completed-smoke.py index 3d6d5329f9..90b2dd4986 100644 --- a/examples/control_plane/todo-archive-completed-smoke.py +++ b/examples/control_plane/todo-archive-completed-smoke.py @@ -369,13 +369,14 @@ def main() -> int: ) completed_projection = parse_active_state_todos(state_path.read_text(encoding="utf-8")) - completion_warning = completed_todo_archive_warning( - completed_projection["agent_todos"] - ) - assert completion_warning is not None, completed_projection - assert completion_warning["active_done_count"] == done_before_completion + 1, ( - completion_warning - ) + auto_archive = completed_with_successor["auto_archive"] + assert auto_archive["role"] == "agent", auto_archive + assert auto_archive["active_done_before"] == done_before_completion + 1, auto_archive + assert auto_archive["active_done_after"] == auto_archive["max_active_done"] == 10, auto_archive + assert auto_archive["moved_count"] == done_before_completion + 1 - 10, auto_archive + active_done = completed_todo_count(completed_projection["agent_todos"]) + assert active_done == auto_archive["active_done_after"], completed_projection + assert completed_todo_archive_warning(completed_projection["agent_todos"]) is None archive_completed = run_cli( registry_path, @@ -388,13 +389,9 @@ def main() -> int: "0", "--execute", ) - assert archive_completed["active_done_before"] == completion_warning["active_done_count"], ( - archive_completed - ) + assert archive_completed["active_done_before"] == active_done, archive_completed assert archive_completed["active_done_after"] == 0, archive_completed - assert archive_completed["moved_count"] == completion_warning["active_done_count"], ( - archive_completed - ) + assert archive_completed["moved_count"] == active_done, archive_completed archived_lineage = state_path.read_text(encoding="utf-8") assert archived_lineage.index("## Completed Work Archive") < archived_lineage.index( diff --git a/examples/control_plane/work-lane-contract-smoke.py b/examples/control_plane/work-lane-contract-smoke.py index 657e9be31f..0570027dc3 100644 --- a/examples/control_plane/work-lane-contract-smoke.py +++ b/examples/control_plane/work-lane-contract-smoke.py @@ -3,6 +3,7 @@ from __future__ import annotations +import shlex import sys from pathlib import Path @@ -1771,12 +1772,18 @@ def assert_side_agent_replans_when_deferred_successor_is_ready() -> None: assert contract["cli_channel"]["spend_after_validation"] is True, contract actions = contract["cli_channel"]["next_cli_actions"] assert len(actions) == 3, actions - assert "todo_issue_surface_deferred" in actions[0], actions - assert "successor_replan_recorded" in actions[1], actions - assert "loopx refresh-state" in actions[1], actions - assert "--agent-id codex-side-bypass" in actions[1], actions - assert "loopx --format json quota spend-slot" in actions[2], actions - assert "--agent-id codex-side-bypass" in actions[2], actions + reopen = shlex.split(actions[0]) + assert reopen[:3] == ["loopx", "todo", "update"], actions + assert reopen[reopen.index("--todo-id") + 1] == "todo_issue_surface_deferred", actions + assert reopen[reopen.index("--status") + 1] == "open" and "--clear-resume-when" in reopen, actions + reentry = shlex.split(actions[1]) + assert reentry[:5] == ["loopx", "--format", "json", "quota", "should-run"], actions + assert reentry[reentry.index("--todo-id") + 1] == "todo_issue_surface_deferred", actions + assert reentry[reentry.index("--agent-id") + 1] == "codex-side-bypass", actions + assert reentry[reentry.index("--turn-instance-id") + 1] == "${LOOPX_TURN:?}", actions + assert "Only if it admits this same Todo" in actions[2], actions + assert "fresh execution claim/lease" in actions[2], actions + assert not any("spend-slot" in action or "refresh-state" in action for action in actions), actions assert guard["automation_liveness"]["automation_action"] == "execute_bounded_work", guard markdown = render_quota_should_run_markdown(guard) assert "agent_scope_frontier: action=successor_replan_required" in markdown, markdown diff --git a/examples/dashboard-goal-acceptance-browser-smoke.mjs b/examples/dashboard-goal-acceptance-browser-smoke.mjs index caa14420a9..34b2864e22 100644 --- a/examples/dashboard-goal-acceptance-browser-smoke.mjs +++ b/examples/dashboard-goal-acceptance-browser-smoke.mjs @@ -38,12 +38,16 @@ try { await page.getByRole("button", {name: language === "en" ? "Overview" : "概览", exact: true}).click(); const card = page.locator(".personal-goal-acceptance"); await card.getByText("Independent verification report", {exact: true}).waitFor(); + for (const evidence of ["vision_patch.acceptance_summary", "Independent verification report"]) { + await card.locator(".personal-acceptance-observation").filter({hasText: evidence}).locator("summary").click(); + } assert.match(await card.innerText(), /agent-a/); assert.match(await card.innerText(), /vision_patch.acceptance_summary/); assert.match(await card.innerText(), language === "en" ? /Final outcome claim present: Failed/ : /最终成果声明齐全: 未通过/); assert.match(await card.innerText(), language === "en" ? /Valid path decision: Passed/ : /路径决策有效: 通过/); - assert.match(await card.innerText(), language === "en" ? /do not prove Goal acceptance/ : /不能证明 Goal 已通过验收/); - await card.locator("summary").click(); + await card.locator(".personal-detail-card-title .context-help-trigger").focus(); + await card.getByRole("tooltip").getByText(language === "en" ? /do not prove Goal acceptance/ : /不能证明 Goal 已通过验收/).waitFor(); + await page.keyboard.press("Escape"); await card.locator(".personal-detail-card-title").scrollIntoViewIfNeeded(); assert.equal(await card.evaluate(el => el.scrollWidth > el.clientWidth + 2), false, "Acceptance content must wrap on mobile"); await page.screenshot({path: resolve(output, `${packaged ? "packaged" : "development"}-${language}.png`)}); @@ -70,7 +74,8 @@ try { if (observation === current) { await refreshedCard.getByText("Independent verification report", {exact: true}).waitFor(); } else { - await refreshedCard.getByText("Acceptance observations are unavailable. Goal completion is unknown.", {exact: true}).waitFor(); + await refreshedCard.getByRole("button", {name: "Acceptance observations unavailable", exact: true}).focus(); + await refreshedCard.getByRole("tooltip").getByText("Acceptance observations are unavailable. Goal completion is unknown.", {exact: true}).waitFor(); assert.equal(await refreshedCard.locator(".personal-acceptance-observation").count(), 0, "Invalid source cannot retain prior gap rows"); } } diff --git a/examples/dreaming-proposal-decision-smoke.py b/examples/dreaming-proposal-decision-smoke.py index f12326dd49..fc1c79a7bd 100644 --- a/examples/dreaming-proposal-decision-smoke.py +++ b/examples/dreaming-proposal-decision-smoke.py @@ -185,7 +185,6 @@ def assert_decisions(provider: str) -> None: assert approve["dreaming_decision"]["todo_added"] is True, approve state_after_approve = state_path.read_text(encoding="utf-8") assert "[P1] Implement the approved dreaming follow-up" in state_after_approve - assert "dreaming_fixture_approve" in state_after_approve assert approve["side_effects"]["active_state_mutated"] is True, approve assert approve["side_effects"]["runtime_history_appended"] is True, approve assert approve["side_effects"]["quota_spent"] is False, approve @@ -197,6 +196,9 @@ def assert_decisions(provider: str) -> None: promoted = next(row for row in approved['todos'] if row['todo_id'] == todo_id) assert promoted['text'] == '[P1] Implement the approved dreaming follow-up as normal delivery.' assert promoted['status'] == 'open' and promoted['task_class'] == 'advancement_task' + assert promoted['evidence'] == 'dreaming_proposal:dreaming_fixture_approve' + decision_record = json.loads(index_path.read_text(encoding="utf-8").splitlines()[-1]) + assert decision_record["dreaming_decision"] == approve["dreaming_decision"] original_ids = {row['todo_id'] for row in original['todos']} assert [row for row in approved['todos'] if row['todo_id'] in original_ids] == original['todos'] diff --git a/examples/lark-goal-channel-human-gate-delivery-smoke.py b/examples/lark-goal-channel-human-gate-delivery-smoke.py index 15c1790339..6140b0f619 100644 --- a/examples/lark-goal-channel-human-gate-delivery-smoke.py +++ b/examples/lark-goal-channel-human-gate-delivery-smoke.py @@ -81,6 +81,9 @@ def write_fake_lark_cli(root: Path) -> tuple[Path, Path]: method = request.get("method") if method == "initialize": result = {} + elif method == "config/read": + assert request["params"]["includeLayers"] is False + result = {"config": {}} elif method in ("thread/start", "thread/resume"): assert request["params"].get("approvalPolicy") == "never" result = {"thread": {"id": "synthetic-notice-thread"}} diff --git a/examples/personal-workspace-browser/chat-recovery.mjs b/examples/personal-workspace-browser/chat-recovery.mjs index 5f2e3e1d4b..dc80c92988 100644 --- a/examples/personal-workspace-browser/chat-recovery.mjs +++ b/examples/personal-workspace-browser/chat-recovery.mjs @@ -441,35 +441,60 @@ export const chatRecoveryScenario = { // with the running Turn. The page must adopt that Turn with its // controls and keep the draft; the owner can explicitly queue it next. const busySessionId = api.turnRequests.at(-1).sessionId; + const busyChannelId = page.__loopxRuntime.sessions.get(busySessionId).channel_id; + const submissionRoute = `**/api/chat/sessions/${busySessionId}/turns`; + // Keep the reported Turn pending through the readback assertion. The + // fixture's ordinary event timer can otherwise finish it mid-check. + const holdTurnCompletion = async (turnId) => { + const path = `${submissionRoute}/${turnId}/events`; + const heldEvents = []; + await page.route(path, (route) => { heldEvents.push(route); }); + return async () => { + await page.unroute(path); + for (const route of heldEvents) await route.fallback(); + }; + }; + const queuePosts = []; + const competeOnSubmit = (turnId, rejected) => async (route) => { + const request = route.request(); + if (request.method() !== "POST") return route.fallback(); + const body = request.postDataJSON(); + if (body.delivery === "queue") { + queuePosts.push(body); + // The transport retries one 5xx with the same ingress before the + // composer can surface the still-unconfirmed delivery. + if (queuePosts.length <= 2) { + return route.fulfill({ status: 503, json: { ok: false, error: "queue acknowledgement unavailable" } }); + } + return route.fulfill({ status: 429, json: { ok: false, error: "Session queue is full; the message was not accepted.", + error_code: "session_queue_full", delivery_state: "not_delivered", turn_replay_safe: true } }); + } + rejected(); + // A poll before this submission must still see the prior idle Session. + const session = page.__loopxRuntime.sessions.get(busySessionId); + page.__loopxRuntime.sessions.set(busySessionId, { ...session, active_turn_id: turnId, status: "busy" }); + return route.fulfill({ status: 409, + json: { ok: false, error: "another turn is already running for this session", active_turn_id: turnId } }); + }; const foreignTurnId = `turn-foreign-${Date.now()}`; const foreignTurnControl = page.locator(`.personal-composer-stop[data-turn-id="${foreignTurnId}"]`); + const releaseForeignTurn = await holdTurnCompletion(foreignTurnId); page.__loopxRuntime.turnMessages.set(foreignTurnId, "另一页面发起的中断控制回合"); - page.__loopxRuntime.sessions.set(busySessionId, { ...page.__loopxRuntime.sessions.get(busySessionId), active_turn_id: foreignTurnId, status: "busy" }); let rejectedPosts = 0; - await page.route(`**/api/chat/sessions/${busySessionId}/turns`, async (route) => { - if (route.request().method() !== "POST") return route.fallback(); - rejectedPosts += 1; - await route.fulfill({ contentType: "application/json", status: 409, - json: { ok: false, error: "another turn is already running for this session", active_turn_id: foreignTurnId } }); - }); + await page.route(submissionRoute, competeOnSubmit(foreignTurnId, () => { rejectedPosts += 1; })); // Hold the conversation's own Session re-read that follows the 409, so // the check covers the handoff before the recovery adopts the Turn, not // only after. const heldReads = []; let holdReads = false; await page.route("**/api/chat/sessions?*", async (route) => { - if (!holdReads || route.request().method() !== "GET" - || !new URL(route.request().url()).searchParams.has("channel_id")) return route.fallback(); + if (!holdReads || !rejectedPosts || route.request().method() !== "GET" + || new URL(route.request().url()).searchParams.get("channel_id") !== busyChannelId) return route.fallback(); heldReads.push(route); }); const draft = "这条消息在另一回合运行时发出"; const composerInput = page.getByLabel("向 LoopX 发送消息"); const sendButton = page.getByRole("button", { name: "发送", exact: true }); - // The conversation hides the quick-prompt strip once it has messages, and - // the rejected send below adds one, so read them while they are still up. - // The quick-prompt strip renders only while a conversation is empty. This - // Goal's chat already has history, so the handoff's effect on those - // prompts is covered where a fresh Goal shows them, not here. await composerInput.fill(draft); holdReads = true; await sendButton.click(); @@ -480,6 +505,30 @@ export const chatRecoveryScenario = { await page.waitForTimeout(100); } if (rejectedPosts !== 1) throw new Error(`The composer posted ${rejectedPosts} times before the recovery adopted the Turn`); + if (queuePosts.length) throw new Error("The ordinary 409 unexpectedly dispatched a queue request"); + // Deliberately lose a queue acknowledgement. A changed draft must stay + // local until the original identity receives a definite non-delivery. + const queueFeedback = page.locator(".personal-action-feedback"); + const queuedDraft = "这条队列请求正在等待回执"; + await composerInput.fill(queuedDraft); + await sendButton.click(); + await queueFeedback.filter({ hasText: "queue acknowledgement unavailable" }).waitFor(); + const newDraft = "保留的另一条草稿"; + await composerInput.fill(newDraft); + await sendButton.click(); + await queueFeedback.filter({ hasText: "上条投递尚未确认" }).waitFor(); + if (queuePosts.length !== 2 || await composerInput.inputValue() !== newDraft) { + throw new Error("An uncertain queue request did not fence the replacement draft"); + } + await composerInput.fill(""); + await page.getByRole("button", { name: "恢复未确认的消息", exact: true }).click(); + await sendButton.click(); + await queueFeedback.filter({ hasText: "Session queue is full; the message was not accepted." }).waitFor(); + if (queuePosts.length !== 3 || !queuePosts[0].client_turn_id + || queuePosts.some(post => post.client_turn_id !== queuePosts[0].client_turn_id || post.message !== queuedDraft)) { + throw new Error("Queue recovery changed the original message or request identity"); + } + await composerInput.fill(draft); holdReads = false; for (const route of heldReads.splice(0)) await route.fallback(); await page.unroute("**/api/chat/sessions?*"); @@ -494,10 +543,11 @@ export const chatRecoveryScenario = { if (await page.locator(".personal-channel-timeline .personal-message").filter({ hasText: draft }).count()) { throw new Error("A message the service did not accept stayed in the conversation"); } + await releaseForeignTurn(); await foreignTurnControl.waitFor({ state: "hidden", timeout: 10_000 }); if (await sendButton.isDisabled()) throw new Error("Send stayed blocked after the adopted Turn completed"); if (rejectedPosts !== 1) throw new Error(`The composer posted ${rejectedPosts} times into a running Turn`); - await page.unroute(`**/api/chat/sessions/${busySessionId}/turns`); + await page.unroute(submissionRoute); await composerInput.fill(""); pass("composer-running-turn-409", "A 409 hands off the reported Turn with its controls and draft, and offers explicit queuing without dispatching automatically"); // A failed Session read after the 409 says nothing about the reported @@ -505,21 +555,16 @@ export const chatRecoveryScenario = { // treat the failure as the Turn having ended. const retriedTurnId = `turn-foreign-retry-${Date.now()}`; const retriedTurnControl = page.locator(`.personal-composer-stop[data-turn-id="${retriedTurnId}"]`); + const releaseRetriedTurn = await holdTurnCompletion(retriedTurnId); page.__loopxRuntime.turnMessages.set(retriedTurnId, "读取失败后仍在运行的中断控制回合"); - page.__loopxRuntime.sessions.set(busySessionId, { ...page.__loopxRuntime.sessions.get(busySessionId), active_turn_id: retriedTurnId, status: "busy" }); let retryRejectedPosts = 0; - await page.route(`**/api/chat/sessions/${busySessionId}/turns`, async (route) => { - if (route.request().method() !== "POST") return route.fallback(); - retryRejectedPosts += 1; - await route.fulfill({ contentType: "application/json", status: 409, - json: { ok: false, error: "another turn is already running for this session", active_turn_id: retriedTurnId } }); - }); + await page.route(submissionRoute, competeOnSubmit(retriedTurnId, () => { retryRejectedPosts += 1; })); let failedReads = 0; const heldRetryReads = []; let failReads = false; await page.route("**/api/chat/sessions?*", async (route) => { - if (!failReads || route.request().method() !== "GET" - || !new URL(route.request().url()).searchParams.has("channel_id")) return route.fallback(); + if (!failReads || !retryRejectedPosts || route.request().method() !== "GET" + || new URL(route.request().url()).searchParams.get("channel_id") !== busyChannelId) return route.fallback(); if (failedReads === 0) { failedReads += 1; return route.fulfill({ contentType: "application/json", status: 503, json: { ok: false, error: "chat store temporarily unavailable" } }); @@ -546,10 +591,11 @@ export const chatRecoveryScenario = { throw new Error("The retried recovery added a second pending reply instead of adopting the handoff reply"); } if (await composerInput.inputValue() !== draft) throw new Error("The draft rejected by a running Turn was not kept across the failed read"); + await releaseRetriedTurn(); await retriedTurnControl.waitFor({ state: "hidden", timeout: 10_000 }); if (await sendButton.isDisabled()) throw new Error("Send stayed blocked after the retried recovery saw the Turn complete"); if (retryRejectedPosts !== 1) throw new Error(`The composer posted ${retryRejectedPosts} times into a running Turn`); - await page.unroute(`**/api/chat/sessions/${busySessionId}/turns`); + await page.unroute(submissionRoute); await composerInput.fill(""); pass("composer-running-turn-409-read-failure", "A failed read retries the reported Turn with its controls and draft, while preserving explicit next-Turn queuing"); // Switch back to an observed steering-capable executor before exercising @@ -557,44 +603,65 @@ export const chatRecoveryScenario = { page.__loopxRuntime.sessions.set(busySessionId, { ...page.__loopxRuntime.sessions.get(busySessionId), adapter_kind: "codex_app_server" }); await page.reload({ waitUntil: "domcontentloaded" }); await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click(); + // An enabled Send button also allows queueing. Wait for the original + // Session's completed reply and for its active-Turn control to clear. + await page.locator(`.personal-channel-timeline [data-message-session="${busySessionId}"][data-message-id="${retriedTurnId}-assistant"]`).waitFor({ state: "visible" }); + await page.locator(".personal-composer-stop").waitFor({ state: "hidden" }); // The pending reply shows Adjust/Interrupt from the 409 on, so before any // Session read returns, both must act on the exact reported Turn. const controlledTurnId = `turn-foreign-controls-${Date.now()}`; const controlledTurnControl = page.locator(`.personal-composer-stop[data-turn-id="${controlledTurnId}"]`); + const controlledTurnEvents = `**/api/chat/sessions/${busySessionId}/turns/${controlledTurnId}/events`; + const heldControlEvents = []; + // Keep this Turn running until the operator interrupts it. + await page.route(controlledTurnEvents, (route) => { heldControlEvents.push(route); }); page.__loopxRuntime.turnMessages.set(controlledTurnId, "交接期间可调整和中断的中断控制回合"); - page.__loopxRuntime.sessions.set(busySessionId, { ...page.__loopxRuntime.sessions.get(busySessionId), active_turn_id: controlledTurnId, status: "busy" }); let controlRejectedPosts = 0; - await page.route(`**/api/chat/sessions/${busySessionId}/turns`, async (route) => { - if (route.request().method() !== "POST") return route.fallback(); - controlRejectedPosts += 1; - await route.fulfill({ contentType: "application/json", status: 409, - json: { ok: false, error: "another turn is already running for this session", active_turn_id: controlledTurnId } }); - }); + await page.route(submissionRoute, competeOnSubmit(controlledTurnId, () => { controlRejectedPosts += 1; })); + let holdControlReads = true; const heldControlReads = []; await page.route("**/api/chat/sessions?*", async (route) => { - if (route.request().method() !== "GET") return route.fallback(); + const request = route.request(); + if (!holdControlReads || !controlRejectedPosts || request.method() !== "GET" + || new URL(request.url()).searchParams.get("channel_id") !== busyChannelId) return route.fallback(); heldControlReads.push(route); }); const steers = []; await page.route("**/steer", async (route) => { const body = route.request().postDataJSON(); const [sessionId, turnId] = new URL(route.request().url()).pathname.match(/sessions\/([^/]+)\/turns\/([^/]+)\/steer/).slice(1); - steers.push({ sessionId, turnId }); + steers.push({ sessionId, turnId, message: body.message }); await route.fulfill({ json: { ok: true, session_id: sessionId, turn_id: turnId, client_ingress_id: body.client_ingress_id, status: "delivered", created: true } }); }); const interruptsBefore = api.interrupts.length; await composerInput.fill(draft); await sendButton.click(); for (let attempt = 0; attempt < 100 && (!controlRejectedPosts || !heldControlReads.length); attempt += 1) await page.waitForTimeout(50); - if (!heldControlReads.length) throw new Error("The 409 did not make the page re-read the Session"); + if (controlRejectedPosts !== 1 || !heldControlReads.length) { + throw new Error(`Expected one rejected POST and a held Session read, got ${controlRejectedPosts} POSTs and ${heldControlReads.length} reads`); + } await controlledTurnControl.waitFor({ state: "visible", timeout: 5_000 }); + await page.waitForFunction(({ label, value }) => document.querySelector(`textarea[aria-label="${label}"]`)?.value === value, + { label: "向 LoopX 发送消息", value: draft }, { timeout: 5_000 }); const handoffReply = page.locator(".personal-message").filter({ has: page.getByRole("button", { name: "中断本轮", exact: true }) }); await handoffReply.getByRole("button", { name: "调整本轮", exact: true }).click(); - await composerInput.fill("交接期间先核对依赖。"); - await composerInput.press("Meta+Enter"); + // Wait for React to commit the replacement draft, rather than pressing + // the shortcut against the retained draft's already-enabled button. + const adjustment = "交接期间先核对依赖。"; + await composerInput.fill(""); + await page.locator(".personal-composer-send:disabled").waitFor({ state: "visible", timeout: 5_000 }); + await composerInput.fill(adjustment); + await page.locator(".personal-composer-send:enabled").waitFor({ state: "visible", timeout: 5_000 }); + const [steerReceipt] = await Promise.all([ + page.waitForResponse((response) => response.request().method() === "POST" + && new URL(response.url()).pathname.endsWith("/steer"), { timeout: 5_000 }), + composerInput.press("Meta+Enter"), + ]); + if (!steerReceipt.ok()) throw new Error(`Steering failed with HTTP ${steerReceipt.status()}`); await page.locator(".personal-action-feedback").filter({hasText: "执行器已接收本轮追加指令。"}).waitFor({ timeout: 5_000 }); - if (steers.length !== 1 || steers[0].sessionId !== busySessionId || steers[0].turnId !== controlledTurnId) { - throw new Error(`Adjust during the handoff did not reach the reported Turn: ${JSON.stringify(steers)}`); + if (steers.length !== 1 || steers[0].sessionId !== busySessionId + || steers[0].turnId !== controlledTurnId || steers[0].message !== adjustment) { + throw new Error(`Adjust during the handoff did not deliver the new draft to the reported Turn: ${JSON.stringify(steers)}`); } if (!(await sendButton.isDisabled())) throw new Error("Send reopened after adjusting the handed-off Turn"); await handoffReply.getByRole("button", { name: "中断本轮", exact: true }).click(); @@ -605,10 +672,13 @@ export const chatRecoveryScenario = { } await page.locator(".personal-message").filter({ hasText: "已中断。你可以在当前会话继续发送消息。" }).last().waitFor({ timeout: 5_000 }); await controlledTurnControl.waitFor({ state: "hidden", timeout: 5_000 }); + await Promise.all(heldControlEvents.splice(0).map((route) => route.abort().catch(() => {}))); + await page.unroute(controlledTurnEvents); if (await composerInput.inputValue() !== "") throw new Error("The accepted shared-composer adjustment did not clear"); await composerInput.fill("中断后的后续消息草稿"); if (await sendButton.isDisabled()) throw new Error("A new follow-up draft stayed blocked after interruption"); if (await composerInput.inputValue() !== "中断后的后续消息草稿") throw new Error("The new follow-up draft was not kept after interruption"); + holdControlReads = false; for (const route of heldControlReads.splice(0)) await route.fallback(); await page.unroute("**/api/chat/sessions?*"); await page.waitForTimeout(500); @@ -616,9 +686,9 @@ export const chatRecoveryScenario = { throw new Error("A late Session read revived the interrupted handoff Turn"); } if (await sendButton.isDisabled()) throw new Error("A late Session read closed Send after the interrupt"); - if (controlRejectedPosts !== 1) throw new Error(`The composer posted ${controlRejectedPosts} times during the handoff`); + if (controlRejectedPosts !== 1 || queuePosts.length !== 3) throw new Error("The handoff dispatched an unexpected submission"); await page.unroute("**/steer"); - await page.unroute(`**/api/chat/sessions/${busySessionId}/turns`); + await page.unroute(submissionRoute); await composerInput.fill(""); pass("composer-running-turn-409-controls", "Before any Session read returns, Adjust and Interrupt on the 409 handoff reply reach the exact reported Session and Turn, and the interrupt settles the reply and reopens Send"); if (failures.length) throw new Error(failures.join(" | ")); diff --git a/examples/personal-workspace-browser/codex-attached.mjs b/examples/personal-workspace-browser/codex-attached.mjs index e7fed54250..1c00ba53ab 100644 --- a/examples/personal-workspace-browser/codex-attached.mjs +++ b/examples/personal-workspace-browser/codex-attached.mjs @@ -722,9 +722,21 @@ async function runConversation(browser, uncertain) { assert.ok(await panel.locator('article').count() <= 300); // Returning through newer tool pages must preserve the same disclosure and // keep every original row reachable, rather than remounting a closed group. + const settledTranscript = panel.locator('.attached-conversation-messages[aria-busy="false"]'); for (let pageNumber = 0; pageNumber < 5 && !await longDialogue.count(); pageNumber++) { - const read = page.waitForResponse(response => new URL(response.url()).searchParams.has('after')); - await scrollHistory(page,panel,1); await read; + // A prior read may still be applying its retained window. Scroll after + // the transcript is ready to consume another page gesture. + await settledTranscript.waitFor(); + const read = page.waitForResponse(response => { + const source = new URL(response.url()); + return source.pathname === '/api/chat/codex-attached' && source.searchParams.has('after'); + }); + await scrollHistory(page,panel,1); + const response = await read; + assert.ok(response.ok(), 'The original source returns the requested newer page'); + const tail = (await response.json()).messages.at(-1); + if (tail) await transcript.locator(`article[data-record-id="${tail.id}"]`).waitFor({state:'attached'}); + await settledTranscript.waitFor(); await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); assert.equal(await expandedGroup.evaluate(node => node.isConnected && node.open),true,'Newer pages preserve the expanded group'); } diff --git a/examples/personal-workspace-browser/conversation-activity.mjs b/examples/personal-workspace-browser/conversation-activity.mjs index 3410ffd3c5..315d83f37d 100644 --- a/examples/personal-workspace-browser/conversation-activity.mjs +++ b/examples/personal-workspace-browser/conversation-activity.mjs @@ -46,11 +46,11 @@ export const conversationActivityScenario = { const turn = await send("请检查当前任务状态,并说明下一步。"); await page.getByRole("status", {name: "正在回复", exact: true}).waitFor(); const pending = page.locator(".personal-message").filter({ has: page.getByRole("button", { name: "中断本轮", exact: true }) }); - await page.clock.install(); - await page.clock.fastForward(25000); - // Compare the same instant across views; clock.install alone keeps - // advancing and can cross a second boundary while navigation completes. - await page.clock.pauseAt(await page.evaluate(() => Date.now())); + const beforeIdle = await page.evaluate(() => Date.now()); + await page.clock.install({ time: beforeIdle }); + // Advance to a future instant and pause it in one step so navigation + // compares the same elapsed time without racing the running clock. + await page.clock.pauseAt(beforeIdle + 25_000); await pending.locator(".personal-message-quiet").waitFor(); const elapsedBefore = await pending.locator(".personal-message-elapsed").textContent(); await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: "总览", exact: true }).click(); diff --git a/examples/personal-workspace-browser/durable-goal-order.mjs b/examples/personal-workspace-browser/durable-goal-order.mjs index 65554a94bf..a3f9214512 100644 --- a/examples/personal-workspace-browser/durable-goal-order.mjs +++ b/examples/personal-workspace-browser/durable-goal-order.mjs @@ -7,7 +7,7 @@ import { join, resolve } from 'node:path'; import { createInterface } from 'node:readline'; import { repoRoot, outputDir } from './fixture.mjs'; import { openWorkspacePage } from './scenario-context.mjs'; -import { resolveTestPython } from '../../scripts/test-python.mjs'; +import { testPythonCommand } from '../../scripts/test-python.mjs'; // Assets and preference IO use the real packaged handler. Unrelated Goal and // chat APIs use the existing synthetic workspace; no active runtime is touched. @@ -20,8 +20,9 @@ print(json.dumps({'port':s.server_address[1]}),flush=True) s.serve_forever() `; async function start(root) { - const child = spawn(resolveTestPython({ repoRoot }), ['-u', '-c', serverCode, root], { - cwd: repoRoot, env: { ...process.env, PYTHONPATH: repoRoot }, stdio: ['ignore', 'pipe', 'pipe'], + const python = testPythonCommand(serverCode, { repoRoot }); + const child = spawn(python.command, [...python.args, root], { + cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'], }); let stderr = ''; child.stderr.on('data', data => { stderr += data; }); @@ -37,7 +38,19 @@ async function start(root) { } catch (error) { child.kill(); throw error; } finally { clearTimeout(timer); lines.close(); } } -async function stop(service) { if (service.child.exitCode === null) { service.child.kill(); await once(service.child, 'exit'); } } +async function stop(service, contexts) { + try { + // Detach routes before shutting down the backend they can still be reading. + await Promise.all(contexts.splice(0).map(context => context.close())); + } finally { + const child = service?.child; + if (child?.exitCode === null && child.signalCode === null) { + const exited = once(child, 'exit'); + child.kill(); + await exited; + } + } +} const rows = page => page.locator('.personal-goal-list:not(.is-stopped) .personal-goal-row'); const order = page => rows(page).evaluateAll(elements => elements.map(e => e.dataset.reorderGoal)); async function expectOrder(page, expected) { @@ -91,7 +104,7 @@ export async function runDurableGoalOrder(browser) { await expectOrder(first, moved); await first.screenshot({ path: resolve(outputDir, 'goal-order-before-restart.png'), animations: 'disabled' }); const oldUrl = service.url; - await stop(service); + await stop(service, contexts); service = await start(root); assert.notEqual(service.url, oldUrl, 'Restart must use a different browser origin'); const second = await open(service.url); @@ -144,7 +157,7 @@ export async function runDurableGoalOrder(browser) { const existing = await stored(); const staleCache = await open(service.url, initial); await expectOrder(staleCache, existing); - await stop(service); + await stop(service, contexts); const migrationRoot = join(root, 'migration'); service = await start(migrationRoot); const migrated = await open(service.url, moved); @@ -157,9 +170,8 @@ export async function runDurableGoalOrder(browser) { assert.deepEqual(JSON.parse(await readFile(migrationFile, 'utf8'))['/status.json'], moved); return 'Pointer/keyboard order survives refresh and real service restart on a different port; delayed reads, rapid moves, save failure/retry and legacy cache migration verified against the real private file store.'; } finally { - for (const context of contexts) await context.close(); - if (service) await stop(service); - await rm(root, { recursive: true, force: true }); + try { await stop(service, contexts); } + finally { await rm(root, { recursive: true, force: true }); } } } async function pageReady(page) { await rows(page).nth(4).waitFor(); } diff --git a/examples/personal-workspace-browser/fresh-install-backend.py b/examples/personal-workspace-browser/fresh-install-backend.py index 73a3cbcd82..d88661477f 100644 --- a/examples/personal-workspace-browser/fresh-install-backend.py +++ b/examples/personal-workspace-browser/fresh-install-backend.py @@ -29,8 +29,11 @@ if "--no-agent" not in sys.argv[2:]: (bins / "codex").write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") (bins / "codex").chmod(0o755) -for variable in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY"): +for variable in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "ANTIGRAVITY_CSRF_TOKEN"): os.environ.pop(variable, None) +# A configured endpoint without credentials is unavailable and cannot fall +# back to discovering a language server in the operator's live session. +os.environ["ANTIGRAVITY_LS_URL"] = "http://127.0.0.1:1" os.environ["LOOPX_USAGE_PING"] = "0" os.environ["DO_NOT_TRACK"] = "1" with socket.socket() as probe: diff --git a/examples/personal-workspace-browser/research-history-backend.py b/examples/personal-workspace-browser/research-history-backend.py index 5c7f8774d8..2e5cfac03b 100644 --- a/examples/personal-workspace-browser/research-history-backend.py +++ b/examples/personal-workspace-browser/research-history-backend.py @@ -6,6 +6,7 @@ model dispatch is never called. Explicit fixture paths support regressions. """ import argparse +from contextlib import suppress import hashlib import json from pathlib import Path @@ -187,6 +188,10 @@ def do_GET(self): def do_POST(self): metrics["mutation_attempts"] += 1 + # Drain bounded request bodies before closing the connection. + self.connection.settimeout(5.0) + with suppress(ValueError): + self._read_json() self._send_error("The research display fixture is read-only.", status=405) do_PUT = do_POST diff --git a/examples/personal-workspace-browser/runtime-readiness-backend.py b/examples/personal-workspace-browser/runtime-readiness-backend.py index 38bfdcde4f..f9e3e8db69 100644 --- a/examples/personal-workspace-browser/runtime-readiness-backend.py +++ b/examples/personal-workspace-browser/runtime-readiness-backend.py @@ -25,8 +25,11 @@ bins = root / "bin" bins.mkdir(parents=True, exist_ok=True) (root / "project").mkdir(exist_ok=True) -for variable in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY"): +for variable in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "ANTIGRAVITY_CSRF_TOKEN"): os.environ.pop(variable, None) +# Isolate native discovery just as the explicit CLI paths isolate Codex and +# Claude. With no credential this loopback endpoint is never contacted. +os.environ["ANTIGRAVITY_LS_URL"] = "http://127.0.0.1:1" os.environ["LOOPX_USAGE_PING"] = "0" os.environ["DO_NOT_TRACK"] = "1" if "--with-goal" in sys.argv[2:]: diff --git a/examples/personal-workspace-browser/server-startup.test.mjs b/examples/personal-workspace-browser/server-startup.test.mjs index f179c0f9a9..d13ce4506e 100644 --- a/examples/personal-workspace-browser/server-startup.test.mjs +++ b/examples/personal-workspace-browser/server-startup.test.mjs @@ -10,7 +10,12 @@ try { process.env.LOOPX_PERSONAL_WORKSPACE_PACKAGED = "1"; process.env.LOOPX_PERSONAL_WORKSPACE_PORT = String(foreign.address().port); const {startServer} = await import("./fixture.mjs"); - await assert.rejects(startServer(), /Packaged workspace server exited/); + await assert.rejects(startServer(), error => { + assert.match(error.message, /Packaged workspace server exited/); + const finalError = error.message.trimEnd().split("\n").at(-1); + assert.match(finalError, /^OSError: \[(?:Errno (?:48|98)|WinError 10048)\]/); + return true; + }); assert.equal(await (await fetch(`http://127.0.0.1:${foreign.address().port}/chat/`)).text(), "unrelated page"); } finally { foreign.closeAllConnections(); diff --git a/examples/project/configure-goal-smoke.py b/examples/project/configure-goal-smoke.py index a3dbcdc503..68ebdb0e7a 100644 --- a/examples/project/configure-goal-smoke.py +++ b/examples/project/configure-goal-smoke.py @@ -332,6 +332,8 @@ def main() -> int: "count_unit": "effective_turns", "count": 6, "planning_horizon_goal": True, + "stride_strategy": "evidence_conditioned_v1", + "yardstick_enabled": False, } # Goal-scoped cadence is omitted when no explicit override is present; # the machine default remains discoverable through the default field. @@ -547,6 +549,8 @@ def main() -> int: registry_path, "quota", "should-run", + "--context-delivery", + "off", "--goal-id", GOAL_ID, "--agent-id", @@ -934,6 +938,8 @@ def main() -> int: registry_path, "quota", "should-run", + "--context-delivery", + "off", "--goal-id", GOAL_ID, ) diff --git a/examples/project/goal-vision-closed-successor-smoke.py b/examples/project/goal-vision-closed-successor-smoke.py index fc5180b45c..3007c40ea7 100644 --- a/examples/project/goal-vision-closed-successor-smoke.py +++ b/examples/project/goal-vision-closed-successor-smoke.py @@ -11,6 +11,10 @@ REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from tests.control_plane.cli_output_fixture import read_cli_context_for_oracle # noqa: E402 sys.path.insert(0, str(REPO_ROOT)) from loopx.control_plane.testing.canary_harness import import_fixture_authority # noqa: E402 @@ -39,7 +43,8 @@ def run_cli(registry: Path, runtime: Path, *args: str) -> dict[str, object]: ) if completed.returncode != 0: raise AssertionError(completed.stderr or completed.stdout) - return json.loads(completed.stdout) + body, _ = read_cli_context_for_oracle(completed.stdout) + return json.loads(body) with tempfile.TemporaryDirectory(prefix="loopx-closed-vision-successor-") as raw_temp: diff --git a/examples/project/goal-vision-replan-contract-smoke.py b/examples/project/goal-vision-replan-contract-smoke.py index 8ea8116482..af5e34f6ec 100644 --- a/examples/project/goal-vision-replan-contract-smoke.py +++ b/examples/project/goal-vision-replan-contract-smoke.py @@ -193,10 +193,10 @@ def main() -> int: repair_patterns, [ "vision_replan_writeback_gap", - "no `goal_frontier_projection.acceptance_gaps[]`", - "--agent-id ", - "--vision-unchanged-reason", - "vision_checkpoint_missing", + "use the matching outcome", + "retain unaffected requirements", + "fresh guard required by changed acceptance", + "without replacing the admitted Turn or replaying effects", ], source=REPAIR_PATTERNS, ) diff --git a/examples/project/project-agent-adoption-smoke.py b/examples/project/project-agent-adoption-smoke.py index f088d467c3..2c3cc17c55 100644 --- a/examples/project/project-agent-adoption-smoke.py +++ b/examples/project/project-agent-adoption-smoke.py @@ -23,6 +23,8 @@ if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) +from tests.control_plane.cli_output_fixture import read_cli_context_for_oracle # noqa: E402 + from loopx.control_plane.testing.canary_harness import import_fixture_authority # noqa: E402 GOAL_ID = "adoption-main-control" @@ -110,7 +112,8 @@ def run_cli(root: Path, registry_path: Path, *args: str, check: bool = True) -> text=True, capture_output=True, ) - return json.loads(result.stdout) + body, _ = read_cli_context_for_oracle(result.stdout) + return json.loads(body) def quota_should_run( diff --git a/examples/semantic-vocabulary-drift-smoke.py b/examples/semantic-vocabulary-drift-smoke.py index f8f7e2c4fc..69e07e9cde 100755 --- a/examples/semantic-vocabulary-drift-smoke.py +++ b/examples/semantic-vocabulary-drift-smoke.py @@ -243,7 +243,7 @@ "same_runtime_forks": 1, "same_runtime_fork_definitions": 3, "conflicting_values": 16, - "conflicting_definitions": 54, + "conflicting_definitions": 52, "schema_version_same_runtime_forks": 0, "multi_value_twins": 8, "multi_value_forks": 2, diff --git a/examples/state-migration-smoke.py b/examples/state-migration-smoke.py index 658717cf9f..95c7cf3b89 100644 --- a/examples/state-migration-smoke.py +++ b/examples/state-migration-smoke.py @@ -12,6 +12,10 @@ REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from tests.control_plane.cli_output_fixture import read_cli_context_for_oracle # noqa: E402 OLD_GOAL_ID = "goal-harness-meta" NEW_GOAL_ID = "loopx-meta" OTHER_GOAL_ID = "agent-harness-side-bypass" @@ -26,7 +30,8 @@ def run_loopx(*args: str, cwd: Path, env: dict[str, str]) -> dict: capture_output=True, text=True, ) - return json.loads(result.stdout) + body, _ = read_cli_context_for_oracle(result.stdout) + return json.loads(body) def write_json(path: Path, payload: dict) -> None: diff --git a/loopx/__init__.py b/loopx/__init__.py index f00e8306a3..2f01aed637 100644 --- a/loopx/__init__.py +++ b/loopx/__init__.py @@ -2,4 +2,4 @@ __all__ = ["__version__"] -__version__ = "2.2.0" +__version__ = "2.3.0" diff --git a/loopx/capabilities/issue_fix/cli.py b/loopx/capabilities/issue_fix/cli.py index c62c54d245..b35aff1f10 100644 --- a/loopx/capabilities/issue_fix/cli.py +++ b/loopx/capabilities/issue_fix/cli.py @@ -5,7 +5,6 @@ from pathlib import Path from typing import Any -from ...control_plane.projects.registry_codec import load_project_registry from ...agent_registry import load_goal_from_registry from ...boundary_authority import checkpointed_boundary_authority_summary from ...control_plane.runtime.time import now_utc_iso @@ -31,6 +30,7 @@ from .candidate_evidence import collect_public_github_candidate_preflight_input from .correction_settlement import ( _correction_todo_readback_receipt, + _require_origin_registry, _resolve_origin_request_identity, _settle_maintainer_correction_entries, ) @@ -1325,12 +1325,7 @@ def handle_issue_fix_command( raise ValueError("origin_request_conflict") if not origin_registry.is_file() or not origin_runtime.is_dir(): raise ValueError("origin registry and runtime root must exist") - try: - origin_profile = load_project_registry(origin_registry).get("profile_id") - except (ValueError, OSError) as exc: - raise ValueError("origin registry is unreadable") from exc - if origin_profile != "source_session_v1": - raise ValueError("origin registry requires source_session_v1") + _require_origin_registry(origin_registry) candidate = {"request_id": args.origin_request_id, "registry_path": str(origin_registry), "runtime_root": str(origin_runtime), **_resolve_origin_request_identity(origin_runtime, args.origin_request_id), diff --git a/loopx/capabilities/issue_fix/correction_settlement.py b/loopx/capabilities/issue_fix/correction_settlement.py index 17b12cffd7..55478c4316 100644 --- a/loopx/capabilities/issue_fix/correction_settlement.py +++ b/loopx/capabilities/issue_fix/correction_settlement.py @@ -9,7 +9,10 @@ from ...agent_registry import load_goal_from_registry from ...control_plane.goals.activation import goal_is_stopped -from ...control_plane.projects.registry_codec import load_project_registry +from ...control_plane.projects.registry_codec import ( + SOURCE_SESSION_PROFILE_ID, + load_project_registry, +) from ...control_plane.runtime.time import now_utc_iso from ...control_plane.todos.mutation_api import add_goal_todo from ...domain_packs.issue_fix import ( @@ -17,7 +20,6 @@ read_issue_fix_pr_lifecycle_ledger_row, upsert_issue_fix_pr_lifecycle_ledger_jsonl, ) -from ...paths import resolve_runtime_root from .pr_lifecycle import recheck_issue_fix_maintainer_correction_effect_gates @@ -32,6 +34,16 @@ } +def _require_origin_registry(registry_path: Path) -> None: + """Check the origin profile before looking up the request in its store.""" + try: + profile = load_project_registry(registry_path).get("profile_id") + except (ValueError, OSError) as exc: + raise ValueError("origin registry is unreadable") from exc + if profile != SOURCE_SESSION_PROFILE_ID: + raise ValueError(f"origin registry requires {SOURCE_SESSION_PROFILE_ID}") + + def _goal_stopped_for_correction(registry_path: Path | None, goal_id: str | None) -> bool: if registry_path is None or not goal_id: return False @@ -195,6 +207,8 @@ def _settle_maintainer_correction_entries( raise ValueError("origin request identity changed") with collaboration_goal_scope(Path(origin["registry_path"]), goal_id=identity["goal_id"], agents=(), require_active=False) as scope: + if scope.profile_id != SOURCE_SESSION_PROFILE_ID: + raise ValueError("origin registry profile changed") goal_ref = identity.get("goal_ref") or scope.current_goal_ref or {"goal_id": identity["goal_id"]} if origin.get("goal_ref") and goal_ref != origin["goal_ref"]: raise ValueError("origin request identity changed") @@ -316,7 +330,7 @@ def _settle_maintainer_correction_entries( entries = issue_fix_maintainer_correction_entries(row) payload["maintainer_corrections"] = entries if origin: - from ...todos import list_goal_todos + from ...control_plane.todos.list_readback import list_goal_todos from ...control_plane.collaboration.inbox import reply_status from ..manager_context.roundtrip import report @@ -330,9 +344,7 @@ def _settle_maintainer_correction_entries( continue returns = entry.setdefault("returns", []) operation_id = entry["operation_id"] - origin_root = resolve_runtime_root( - load_project_registry(Path(origin["registry_path"])), - origin["runtime_root"], registry_path=Path(origin["registry_path"])) + origin_root = Path(origin["runtime_root"]) pending = entry.get("pending_return") pending_head = pending.get("head_sha") if ( isinstance(pending, dict) diff --git a/loopx/capabilities/native_chat/steward_turn.py b/loopx/capabilities/native_chat/steward_turn.py index 623ac4c0b6..21f5b7c78d 100644 --- a/loopx/capabilities/native_chat/steward_turn.py +++ b/loopx/capabilities/native_chat/steward_turn.py @@ -364,20 +364,31 @@ def _task_lease_proof(self, request: Mapping[str, Any], lease: Mapping[str, Any] @contextmanager def _task_execution_lease(self, request: Mapping[str, Any], turn_id: str, *, recovery: bool = False, - admission_retry: bool = False): + admission_retry: bool = False, claim_args: list[str] | None = None): """Both generation and cached validation use the original Todo owner.""" from .steward_commands import native_command actor = request["actor"] - retained = native_command(self.controller, ["task-lease", "inspect", "--goal-id", actor["goal_id"], - "--todo-id", request["todo_id"]]).get("lease") - version = retained["version"] if retained else 0 - suffix = (".validation-recovery." + str(version) if recovery else - ".admission-retry." + turn_id if admission_retry else - ".admission-retry" if request.get("resume_turn_key") else "") - lease = native_command(self.controller, ["task-lease", "acquire", "--goal-id", actor["goal_id"], - "--todo-id", request["todo_id"], "--owner", actor["agent_id"], - "--idempotency-key", request["turn_instance_id"] + suffix, "--ttl-seconds", "3600", - "--expected-version", str(version)])["lease"] + if claim_args is not None: + operation_id = f"todo-claim:native-turn:{turn_id}" + claim = native_command(self.controller, [*claim_args, "--claim-operation-id", operation_id]) + if claim.get("claim_operation_id") != operation_id: + raise ValueError("native task preparation did not return its original claim") + receipts = native_command(self.controller, ["todo", "receipt", "--goal-id", actor["goal_id"], + "--operation-id", operation_id])["receipts"] + lease = receipts[0]["result"].get("lease") if len(receipts) == 1 else None + if not isinstance(lease, dict): + raise ValueError("native task preparation could not read back its original lease") + else: + retained = native_command(self.controller, ["task-lease", "inspect", "--goal-id", actor["goal_id"], + "--todo-id", request["todo_id"]]).get("lease") + version = retained["version"] if retained else 0 + suffix = (".validation-recovery." + str(version) if recovery else + ".admission-retry." + turn_id if admission_retry else + ".admission-retry" if request.get("resume_turn_key") else "") + lease = native_command(self.controller, ["task-lease", "acquire", "--goal-id", actor["goal_id"], + "--todo-id", request["todo_id"], "--owner", actor["agent_id"], + "--idempotency-key", request["turn_instance_id"] + suffix, "--ttl-seconds", "3600", + "--expected-version", str(version)])["lease"] try: yield lease finally: @@ -660,6 +671,7 @@ def execute(self, *, session_id: str, turn_id: str, adapter: Any, governance_work = False managed_answer = "" managed_lease, managed_lease_request = None, None + preparation_status = None def report_lease_proof(): return self._task_lease_proof(request, report_lease) @@ -681,6 +693,35 @@ def session_binding(envelope: Mapping[str, Any]) -> dict[str, Any]: return {"schema_version": "loopx_turn_session_binding_v0", "goal_id": actor["goal_id"], "agent_id": actor["agent_id"]} + def prepare_native_task(decision: Mapping[str, Any], envelope: Mapping[str, Any]) -> str | None: + nonlocal managed_lease, managed_lease_request, preparation_status + from ...control_plane.turn_driver.codex_execution_preparation import compact_execution_preparation_request + + preparation = compact_execution_preparation_request(decision) + if preparation is None: + return None + todo_id = preparation["todo_id"] + if (preparation["goal_id"] != actor["goal_id"] or preparation["agent_id"] != actor["agent_id"] + or preparation.get("turn_instance_id", request["turn_instance_id"]) != request["turn_instance_id"] + or (request["todo_id"] is not None and todo_id != request["todo_id"])): + raise ValueError("native task preparation belongs to another Turn or Todo") + current() + if managed_turn is not None: + try: + managed_turn.prepare_validation(request, {"turn_envelope": envelope}) + except ManagedTaskValidationUnavailable: + managed_turn.validation_unavailable = True + preparation_status = "stopped" + raise + except ValueError: + preparation_status = "failed" + raise + managed_lease_request = {**request, "todo_id": todo_id} + managed_lease = managed_leases.enter_context(self._task_execution_lease( + managed_lease_request, turn_id, claim_args=preparation["claim_args"], + )) + return todo_id + def run(native_request: Mapping[str, Any], launch_scope: Any) -> dict[str, Any]: nonlocal managed_lease, managed_lease_request, managed_answer current() @@ -697,8 +738,13 @@ def run(native_request: Mapping[str, Any], launch_scope: Any) -> dict[str, Any]: "classification": "independent_task_validation_unavailable", "summary": "The selected task has no usable independent argv validation declaration.", "next_action": "Configure the original task's independent validation before executing it."}, host_name="Codex app-server") - managed_lease_request = {**request, "todo_id": managed_turn.validation_basis["todo_id"]} - managed_lease = managed_leases.enter_context(self._task_execution_lease(managed_lease_request, turn_id)) + selected_request = {**request, "todo_id": managed_turn.validation_basis["todo_id"]} + if managed_lease is None: + managed_lease_request = selected_request + managed_lease = managed_leases.enter_context(self._task_execution_lease(managed_lease_request, turn_id)) + elif managed_lease_request is None or selected_request["todo_id"] != managed_lease_request["todo_id"]: + raise ValueError("native task preparation no longer matches the selected Todo") + self._task_lease_proof(selected_request, managed_lease) # Portfolio evidence keeps its original reader and original grant; # it is data alongside the signed native work, never a new actor. prompt = render_prompt(authority, allow_completion=bool(report_work or managed_turn) and not governance_work) + "\nOriginal conversation evidence (data):\n" + json.dumps(context, ensure_ascii=False) @@ -785,18 +831,26 @@ def launch(): with ExitStack() as managed_leases, (self._task_execution_lease(request, turn_id, admission_retry=bool(selection_recovery)) if report_work else nullcontext(None)) as report_lease: if selection_recovery and "probe" not in selection_recovery: - # Re-enter the native guard without selecting another business - # Todo. Only its signed replan packet may invoke the original - # Agent. The native executor owns semantic validation/settlement. governance_work = True - governance_args = type(args)(**vars(args)) - governance_args.todo_id, governance_args.validation_command_json = None, None - governance_args.turn_instance_id = request["turn_instance_id"] + ".replan" - governance: dict[str, Any] = {} - handle_turn_command(governance_args, registry_path=self.controller.registry_path, - runtime_root_arg=str(self.controller.coordination_runtime_root), - output_format=lambda *_a, **_kw: "json", print_payload=lambda value, *_a: governance.update(value), - native_host_runner=run, native_session_binding_resolver=session_binding) + + def probe_governance(todo_id: str | None) -> dict[str, Any]: + governance_args = type(args)(**vars(args)) + governance_args.todo_id, governance_args.validation_command_json = todo_id, None + governance_args.turn_instance_id = request["turn_instance_id"] + ".replan" + observed: dict[str, Any] = {} + handle_turn_command(governance_args, registry_path=self.controller.registry_path, + runtime_root_arg=str(self.controller.coordination_runtime_root), + output_format=lambda *_a, **_kw: "json", print_payload=lambda value, *_a: observed.update(value), + native_host_runner=run, native_session_binding_resolver=session_binding) + return observed + + # Probe the original report first. Unbound selection can + # otherwise stop on an unrelated task needing its own lease. + # Only a current typed replan refusal opens the unbound route. + governance = probe_governance(request["todo_id"]) + if (governance.get("error_code") == "turn_todo_selection_deferred" + and (governance.get("selection_rejection") or {}).get("reason_code") == "autonomous_replan"): + governance = probe_governance(None) self.store.append_event(session_id, turn_id, kind="native_loopx.replan_result", payload=governance) if governance.get("status") != "committed" or governance.get("result_kind") != "replan_required": probe = self.report_selection_recovery(original, probe_turn=self.store.load_turn(session_id, turn_id)) @@ -825,10 +879,16 @@ def launch(): output_format=lambda *_a, **_kw: "json", print_payload=lambda value, *_a: result.update(value), native_host_runner=run, native_session_binding_resolver=session_binding, + native_execution_preparer=prepare_native_task if not report_work else None, **({"native_completion_lease_resolver": report_lease_proof, "native_task_validator": self._report_validator(request)} if report_work else {"native_task_validator": managed_turn.validator(request, turn_id=turn_id), "native_completion_lease_resolver": lambda: self._task_lease_proof(managed_lease_request, managed_lease)} if managed_turn else {})) + if preparation_status is not None: + # Validation stopped preparation before the claim or model call. + result["status"] = preparation_status + if preparation_status == "stopped": + result.update(ok=True, result_kind="user_action_required") try: self.store.append_event(session_id, turn_id, kind="native_loopx.result", payload=result) except OSError: diff --git a/loopx/capabilities/periodic_report/review.py b/loopx/capabilities/periodic_report/review.py index 7559912275..8fea9a6aa5 100644 --- a/loopx/capabilities/periodic_report/review.py +++ b/loopx/capabilities/periodic_report/review.py @@ -16,6 +16,7 @@ if TYPE_CHECKING: from ...chat_store import ChatSessionStore +from ...control_plane.content_digest import BARE_SHA256_PATTERN from ...control_plane.effect_runtime import effect_runtime_result from ...control_plane.todos.list_readback import list_goal_todos from ...control_plane.todos.mutation_api import add_goal_todo @@ -30,7 +31,7 @@ def _encoded(bundle: dict[str, Any]) -> bytes: def _path(root: Path, goal_id: str, digest: str) -> Path: - if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", goal_id) or not re.fullmatch(r"[a-f0-9]{64}", digest): + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", goal_id) or not BARE_SHA256_PATTERN.fullmatch(digest): raise ValueError("Invalid report review identity") return root / "goals" / goal_id / "periodic_reports" / "review-bundles" / (digest + ".json") diff --git a/loopx/chat_store.py b/loopx/chat_store.py index 884cdc7603..bd3a3c4808 100644 --- a/loopx/chat_store.py +++ b/loopx/chat_store.py @@ -18,9 +18,7 @@ require_matching_message_replay, resolve_attached_completion_replay, ) -from .capabilities.steward_executor.allocation import ( - normalize_manager_executor_allocation, -) +from .capabilities.steward_executor.allocation import normalize_manager_executor_allocation from .capabilities.native_chat.model_settings import ( MODEL_SELECTION_UNSET as _MODEL_SELECTION_UNSET, normalize_agent_model_id as normalize_agent_model_id, normalize_session_model_settings, session_model_settings_supported, normalize_session_model_changes, @@ -36,6 +34,7 @@ AcceptedManagedTurn, plan_managed_turn_acceptance, ) +from .control_plane.content_digest import BARE_SHA256_PATTERN from .file_lock import exclusive_file_lock, write_json_atomic @@ -517,7 +516,7 @@ def update_session(self, session_id: str, *, expected_statuses: set[str] | None or len(set(review_ids)) != len(review_ids) or review_ids[-1] != active["turn_id"] or not isinstance(parked["provider"], str) or not 1 <= len(parked["provider"]) <= 128 or type(parked["prefix_count"]) is not int or parked["prefix_count"] < 0 - or any(not isinstance(parked[key], str) or not re.fullmatch(r"[0-9a-f]{64}", parked[key]) + or any(not isinstance(parked[key], str) or not BARE_SHA256_PATTERN.fullmatch(parked[key]) for key in ("affinity_sha256", "prefix_sha256"))): raise ValueError("invalid parked working history or review Turn") if payload.get("answer_review") is True: diff --git a/loopx/cli_commands/project_lifecycle_refresh_state.py b/loopx/cli_commands/project_lifecycle_refresh_state.py index fc9989189f..ad0233bdef 100644 --- a/loopx/cli_commands/project_lifecycle_refresh_state.py +++ b/loopx/cli_commands/project_lifecycle_refresh_state.py @@ -979,8 +979,8 @@ def _settle_requested_refresh(payload, args, registry_path, goal_ref): return from .refresh_settlement import present_settled_refresh, settle_refreshed_turn - settle_refreshed_turn(payload, registry_path=registry_path, - runtime_root=resolve_runtime_root(load_registry(registry_path), args.runtime_root), + runtime_root = resolve_runtime_root(load_registry(registry_path), args.runtime_root) + settle_refreshed_turn(payload, registry_path=registry_path, runtime_root=runtime_root, goal_id=args.goal_id, agent_id=args.agent_id, todo_id=args.todo_id, replan_obligation_id=args.replan_obligation_id, turn_instance_id=args.turn_instance_id, goal_ref=goal_ref, @@ -991,8 +991,7 @@ def _settle_requested_refresh(payload, args, registry_path, goal_ref): if getattr(args, "settlement_receipt", False): from ..control_plane.quota.error_codes import QuotaDecisionTransportError try: - view = present_settled_refresh(payload, runtime_root=resolve_runtime_root( - load_registry(registry_path), args.runtime_root)) + view = present_settled_refresh(payload, runtime_root=runtime_root) except QuotaDecisionTransportError as exc: payload.update(ok=False, error=str(exc)) return # Exact committed receipts stay visible; never re-admit. diff --git a/loopx/cli_commands/quota_capture.py b/loopx/cli_commands/quota_capture.py index 4bf1e7449a..89aa1307ac 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -14,22 +14,30 @@ from pathlib import Path from typing import Any -from ..control_plane.content_digest import BARE_SHA256_PATTERN from ..control_plane.effect_runtime import EffectRuntimeRejected, EffectRuntimeRemoteError, EffectRuntimeStartupError from ..control_plane.quota.error_codes import QuotaCommandValidationError, QuotaDecisionTransportError from ..control_plane.turn_driver.decision_capture import ( context_index_fields as _context_index_fields, - context_selection_binding, write_initial_context_read_reference, - context_selection_view as _context_selection_view, - validate_handoff_socket, CONTEXT_CAPTURE_READ_GUIDANCE, CONTEXT_CAPTURE_PAGE_GUIDANCE, context_capture_read_command, + write_initial_context_read_reference, + validate_handoff_socket, CONTEXT_CAPTURE_READ_GUIDANCE, context_capture_read_command, CONTEXT_CAPTURE_NAVIGATION_GUIDANCE, read_context_capture_file as _read_capture, - read_context_read_reference, write_context_read_reference, private_capture_uid, read_turn_observation_text, + write_context_read_reference, private_capture_uid, read_turn_observation_text, ) from ..control_plane.turn_driver.native_context_profile import ( NATIVE_CONTEXT_FIRST_READ_FALLBACK_BYTES, NATIVE_CONTEXT_PAGE_BYTES, NATIVE_CONTEXT_PAGE_OUTPUT_BYTES, native_context_page_budget, ) +from .quota_context_reader import ( + _capture_page, + _capture_read_reference, + _native_context_read_bytes, + _native_context_reference, + _native_output_bytes, + _validate_context_page_window, + model_context_reference, + read_context_capture, +) def native_context_activation(args: argparse.Namespace) -> dict[str, Any]: @@ -41,475 +49,6 @@ def native_context_activation(args: argparse.Namespace) -> dict[str, Any]: raise QuotaCommandValidationError(str(exc)) from exc -def _capture_page(path: Path, raw: bytes, digest: str, *, offset: int, size: int, - output_limit: int | None, instruction: bool, - presentation: dict[str, Any] | None = None, - next_page_bytes: int | None = None, - next_page_output_bytes: int | None = None, - select: list[str] | None = None, - selection_sha256: str | None = None, - list_fields: bool = False, - list_depth: int | None = None, - initial_read: bool = False, - reference_file: Path | None = None, - reader_argv: list[str] | None = None) -> dict[str, Any]: - end = min(offset + size, len(raw)) - while end < len(raw) and raw[end] & 0xC0 == 0x80: - end -= 1 - content = raw[offset:end].decode("utf-8") - - def page(text: str) -> dict[str, Any]: - cursor = offset + len(text.encode("utf-8")) - result: dict[str, Any] = {**(presentation or {}), "ok": True, "schema_version": "context_capture_page_v1", "sha256": digest, - "offset": offset, "next_offset": cursor if cursor < len(raw) else None, - "total_bytes": len(raw), "eof": cursor == len(raw), "content": text} - if select is not None: - result["selection_sha256"] = selection_sha256 - # The new initial view already names addresses in content; its - # short continuation pins that view's digest. Existing explicit - # selectors retain their original metadata and continuation. - if not initial_read: - result["selected_pointers"] = select - if list_fields: - result["list_fields"] = True - result["read_instruction"] = ("This is selected detail from the original capture, not a complete context read. " - "Offsets and EOF describe only this selected view. Follow next_command to finish it; " - "keep both digests. Detail delivery does not establish a full-context baseline or grant authority.") - elif instruction or (offset == 0 and output_limit is not None): - # A digest-bound native reference may be read in a fresh shell - # without its session lookup. Deliver paging obligations with the - # first bounded page rather than repeating them in file metadata. - # Historical unbounded readers retain their exact wire shape. - result["read_instruction"] = CONTEXT_CAPTURE_PAGE_GUIDANCE - if reference_file is not None and select is not None: - # Selected pages have never been full-input receipts. Give this - # compact transport a distinct schema so old observers cannot - # mistake an omitted digest for a full capture observation. - result["schema_version"] = "context_capture_selection_page_v1" - for key in ("sha256", "selection_sha256", "selected_pointers"): - result.pop(key, None) - result["reference_file"] = str(reference_file) - result["read_instruction"] = ("Selected detail only. Follow next_command for contiguous bytes; " - "the immutable reference pins both complete digests, which the CLI verifies on every page. " - "Offsets and EOF describe only this selected view. " - "EOF is not full-context retention, authority or freshness.") - if cursor < len(raw): - result["next_command"] = context_capture_read_command(path, digest, offset=cursor, page_bytes=next_page_bytes or size, - max_page_output_bytes=next_page_output_bytes or output_limit, - select=None if initial_read or reference_file is not None else select, - selection_sha256=selection_sha256 if reference_file is None else None, - list_fields=list_fields if reference_file is None else False, - list_depth=list_depth if reference_file is None else None, - initial_read=initial_read if reference_file is None else False, reference_file=reference_file, - reader_argv=reader_argv) - return result - - def fits(result: dict[str, Any]) -> bool: - # Match cli_runtime.print_payload exactly, including print's newline. - return output_limit is None or len(json.dumps(result, ensure_ascii=False, - separators=(",", ":")).encode("utf-8")) + 1 <= output_limit - - result = page(content) - if fits(result): - return result - # EOF drops next_command: test it above before searching the monotone - # non-EOF prefix sizes. Never return a zero-progress continuation. - low, high = 1, len(content) - (end == len(raw)) - best = None - while low <= high: - middle = (low + high) // 2 - candidate = page(content[:middle]) - if fits(candidate): - best, low = candidate, middle + 1 - else: - high = middle - 1 - if best is None: - raise ValueError("max-page-output-bytes cannot fit page metadata and one UTF-8 character; increase the limit for this same capture") - return best - - - - - - - - -def _validate_context_page_window(offset: int, size: int, output_limit: int | None) -> None: - from ..control_plane.effect_runtime import MAX_REQUEST_BYTES - - maximum = max(8192, min(output_limit or 8192, MAX_REQUEST_BYTES)) - if type(offset) is not int or offset < 0 or type(size) is not int or not 256 <= size <= maximum: - raise ValueError(f"Page offset must be nonnegative and --page-bytes must be between 256 and {maximum}; " - "larger pages require a sufficient --max-page-output-bytes within the capture limit.") - - -def read_context_capture(args: argparse.Namespace, *, presentation: dict[str, Any] | None = None, - next_page_bytes: int | None = None, - next_page_output_bytes: int | None = None, - reader_argv: list[str] | None = None) -> dict[str, Any]: - """Bounded local IO over immutable JSON, not a new context/authority owner. - - Verify the complete byte digest on every page. The cursor addresses bytes - in that capture or its separately digest-bound selection, so a replacement - cannot silently mix two decisions or detail views. - EOF describes this page, never a model's complete-consumption receipt. - """ - try: - digest = getattr(args, "capture_sha256", None) - value = getattr(args, "capture_file", None) - reference_file = getattr(args, "reference_file", None) - pinned_selection = None - if reference_file: - if value or digest or getattr(args, "codex_context_dir", None) or getattr(args, "codex_context_root", None): - raise ValueError("reference-file cannot be combined with capture or native session lookup") - if args.format != "json": - raise ValueError("reference-file requires JSON output") - reference_file, reference = read_context_read_reference(reference_file) - value, digest = reference["path"], reference["sha256"] - pinned_selection = reference.get("selection") - offset, size = args.offset, args.page_bytes - output_limit = getattr(args, "max_page_output_bytes", None) - if output_limit is not None and (type(output_limit) is not int or output_limit <= 0 - or getattr(args, "format", None) != "json"): - raise ValueError("max-page-output-bytes requires JSON output and a positive integer") - if size is None: - if args.format == "json": - if output_limit is None: - output_limit = NATIVE_CONTEXT_PAGE_OUTPUT_BYTES - size = max(8192, min(NATIVE_CONTEXT_PAGE_BYTES, output_limit)) - else: - size = 4096 - from ..control_plane.turn_driver.codex_context import codex_context_directory - - context_dir = codex_context_directory(directory=getattr(args, "codex_context_dir", None), - root=getattr(args, "codex_context_root", None)) - if context_dir: - if value or digest or offset != 0: - raise ValueError("session lookup starts at offset zero without capture-file or capture-sha256; follow next_command afterwards") - from ..control_plane.turn_driver.codex_context import read_codex_context_reference - - value, digest = read_codex_context_reference(context_dir, - goal_id=getattr(args, "goal_id", None), agent_id=getattr(args, "agent_id", None)) - if not isinstance(digest, str) or BARE_SHA256_PATTERN.fullmatch(digest) is None or not value: - raise ValueError("context-read requires the original capture file and its exact SHA-256") - _validate_context_page_window(offset, size, output_limit) - path, raw, payload = _read_capture(value, digest) - list_fields = getattr(args, "list_fields", False) - list_depth = getattr(args, "list_depth", None) - if list_depth is not None and (not list_fields or type(list_depth) is not int or not 1 <= list_depth <= 8): - raise ValueError("list-depth requires --list and an integer from 1 to 8") - selectors = getattr(args, "select", None) - initial_read = getattr(args, "initial_read", False) - selection_digest = getattr(args, "selection_sha256", None) - if pinned_selection is not None: - if selectors is not None or initial_read or list_fields or list_depth is not None or selection_digest is not None: - raise ValueError("a pinned selection reference cannot change selectors; use the original capture reference") - if (set(pinned_selection) - {"present_goal_narrative"} != - {"select", "initial_read", "list_fields", "list_depth", "sha256"} - or type(pinned_selection["initial_read"]) is not bool or type(pinned_selection["list_fields"]) is not bool - or ("present_goal_narrative" in pinned_selection and ( - pinned_selection["present_goal_narrative"] is not True - or pinned_selection["list_fields"] or pinned_selection["initial_read"])) - or not isinstance(pinned_selection["sha256"], str) - or BARE_SHA256_PATTERN.fullmatch(pinned_selection["sha256"]) is None): - raise ValueError("reader selection reference is incomplete or unsupported") - if observation := reference.get("observed_read"): - from ..control_plane.turn_driver.native_first_read import observation_current - - if not observation_current(observation, path, digest): - if offset: - return {"ok": False, "reason": "Observed first-read scope changed during paging.", - "recovery_command": context_capture_read_command(path, digest, initial_read=True, - page_bytes=size, max_page_output_bytes=output_limit, reader_argv=reader_argv)} - # Restart in the ordinary complete view of this same capture. - # Subsequent pages must pin that view even if the journal changes. - pinned_selection, initial_read = None, True - try: - reference_file = write_context_read_reference(path, digest) - except OSError: - reference_file = None - elif offset == 0: - presentation = {**(presentation or {}), "previously_delivered_fields": list(observation["fields"]), - "field_recovery": "These unchanged fields still apply. If needed or uncertain, append --select " - "and the field pointer to recovery.command for its complete current body."} - if pinned_selection is not None: - selectors, initial_read = pinned_selection["select"], pinned_selection["initial_read"] - list_fields, list_depth = pinned_selection["list_fields"], pinned_selection["list_depth"] - selection_digest = pinned_selection["sha256"] - if list_depth is not None and (not list_fields or type(list_depth) is not int or not 1 <= list_depth <= 8): - raise ValueError("reader selection list-depth must be between 1 and 8 and requires list") - if initial_read and (selectors is not None or list_fields or list_depth is not None): - raise ValueError("initial-read cannot be combined with select or list") - if list_fields and selectors is None: - selectors = [""] - if initial_read and offset > 0 and selection_digest is None: - raise ValueError("initial-read continuation requires selection-sha256; follow next_command") - if selectors is None and not initial_read and selection_digest is not None: - raise ValueError("selection-sha256 requires --select") - if selectors is not None or initial_read: - if getattr(args, "format", None) != "json": - raise ValueError("selected context-read requires JSON output") - if offset > 0 and selection_digest is None: - raise ValueError("selected continuation requires selection-sha256; follow next_command") - pinned_explicit = pinned_selection is not None and not initial_read - plan, selected_raw, expected = _context_selection_view(payload, select=selectors, - initial_read=initial_read, list_fields=list_fields, list_depth=list_depth, - expected_sha256=selection_digest, preserve_selection_order=pinned_explicit, - present_goal_narrative=(pinned_selection.get("present_goal_narrative") is True - if pinned_selection is not None else None)) - if rejection := plan.get("rejected_selection"): - # TS alone resolves the nearest container. Reuse the original - # escaped tokens, without parsing diagnostic prose or traversing - # the payload again in Python. A failed batch delivers no body. - pointer = selectors[rejection["argument_index"] - 1] - parent = "/".join(pointer.split("/")[:rejection["parent_token_count"] + 1]) - command = context_capture_read_command(path, digest, select=[parent], list_fields=True, - page_bytes=next_page_bytes or size, - max_page_output_bytes=next_page_output_bytes or output_limit, - reader_argv=reader_argv) - return {"ok": False, "schema_version": "context_capture_page_v1", - "error_kind": "request_rejected", "diagnostic_code": rejection["code"], - "reason": rejection["message"], "recovery_command": command, - "recovery": "Use recovery_command to --list the nearest verified container in the same capture, " - "then retry the corrected batch. No values were delivered and no guard, admission or spend was performed."} - if selected_raw is None or expected is None: - raise ValueError("selected view is unavailable") - raw = selected_raw - selection_digest = expected - selectors = [item["pointer"] for item in plan["selections"]] - if reference_file is not None and pinned_selection is None: - try: - # Pin the resolved addresses, not the initial navigation - # recipe. Existing explicit readers can replay these exact - # bytes after a navigation change, with both digests checked. - reference_file = write_context_read_reference(path, digest, selection=context_selection_binding( - selectors, list_fields=list_fields, list_depth=list_depth, sha256=expected, - presented=bool(plan.get("presentation_pointers")))) - except OSError: - # A verified read must not require a writable capture root. - # Legacy cursors pin the same capture, selection and digest. - reference_file = None - if offset > len(raw) or (offset < len(raw) and raw[offset] & 0xC0 == 0x80): - raise ValueError("capture offset is outside the file or not a UTF-8 boundary; use the returned next offset") - return _capture_page(path, raw, digest, offset=offset, size=size, - output_limit=output_limit, instruction=bool(context_dir), - presentation=presentation, next_page_bytes=next_page_bytes, - next_page_output_bytes=next_page_output_bytes, select=selectors, selection_sha256=selection_digest, - list_fields=list_fields, list_depth=list_depth, initial_read=initial_read, reference_file=reference_file, - reader_argv=reader_argv) - except (OSError, ValueError, RecursionError, EffectRuntimeRemoteError, EffectRuntimeStartupError) as exc: - # Recovery is presentation-only: keep failures separate from quota's - # collection error path and retain the existing receipt/capture. - result = {"ok": False, "schema_version": "context_capture_page_v1", - "reason": str(exc) if isinstance(exc, (ValueError, EffectRuntimeRemoteError, EffectRuntimeStartupError)) - else "original capture could not be read", - "recovery": "Restore the original digest-bound capture; no guard, admission or spend was performed."} - if isinstance(exc, (EffectRuntimeRemoteError, EffectRuntimeStartupError)): - result["diagnostic_code"] = exc.diagnostic_code - result["recovery"] = ("Resolve the reported runtime failure, then retry reading the same digest-bound capture; " - "no guard, admission or spend was performed.") - if isinstance(exc, EffectRuntimeRemoteError): - result["error_kind"] = exc.error_kind - if exc.error_kind == "request_rejected": - result["recovery"] = ("Correct the rejected read request; for unknown fields use --list on a known parent. " - "Keep the original capture and digest; no guard, admission or spend was performed.") - return result - - -def _capture_read_reference(value: dict[str, Any], *, page_bytes: int, - output_bytes: int, reader_argv: list[str] | None = None) -> dict[str, str]: - """Keep file identity and the budgeted reader at the transport boundary. - - Navigation lives on the outer view; complete paging obligations accompany - the actual reader response. Neither belongs in every nested file reference. - """ - return {"path": value["path"], "sha256": value["sha256"], - "command": context_capture_read_command(Path(value["path"]), value["sha256"], - page_bytes=page_bytes, max_page_output_bytes=output_bytes, reader_argv=reader_argv)} - - -def model_context_reference(view: dict[str, Any], *, reader_argv: list[str] | None = None) -> dict[str, Any]: - """Present native navigation; machine references and journal bindings stay full. - - The descriptor holds complete digests locally. Legacy explicit readers and - full-page observation receipts retain their original transport shape. - """ - def command_budget(command: str) -> tuple[int, int]: - words = shlex.split(command) - page_bytes, output_bytes = native_context_page_budget() - if "--page-bytes" in words: - page_bytes = int(words[words.index("--page-bytes") + 1]) - if "--max-page-output-bytes" in words: - output_bytes = int(words[words.index("--max-page-output-bytes") + 1]) - return page_bytes, output_bytes - - page_bytes, output_bytes = command_budget(view["command"]) - - def reference(value: dict[str, Any], *, navigation: bool = False) -> dict[str, str]: - path = Path(value["path"]) - reader = write_context_read_reference(path, value["sha256"], navigation=value if navigation else None) - return {"reference_file": str(reader), "command": context_capture_read_command(path, value["sha256"], - page_bytes=page_bytes, max_page_output_bytes=output_bytes, reference_file=reader, - reader_argv=reader_argv)} - - result = {"ok": True, "schema_version": "context_capture_navigation_v1", "mode": view["mode"], - **reference(view, navigation=True), "total_bytes": view["total_bytes"], "recovery": reference(view["recovery"]), - "instruction": view["instruction"]} - if "context_delivery" in view: - result["context_delivery"] = view["context_delivery"] - if "baseline_reference" in view: - result["baseline_reference"] = reference(view["baseline_reference"]) - result["continuity_basis"] = view["continuity_basis"] - if "first_read_command" in view: - recovery = view["recovery"] - first_page_bytes, first_output_bytes = command_budget(view["first_read_command"]) - if view.get("first_read_reference"): - # The producer already pinned this same initial selection. Keep - # that existing cursor instead of asking the reader to replan it. - result["first_read_command"] = context_capture_read_command(Path(recovery["path"]), recovery["sha256"], - page_bytes=first_page_bytes, max_page_output_bytes=first_output_bytes, - reference_file=Path(view["first_read_reference"]), reader_argv=reader_argv) - else: - result["first_read_command"] = context_capture_read_command(Path(recovery["path"]), recovery["sha256"], - page_bytes=first_page_bytes, max_page_output_bytes=first_output_bytes, initial_read=True, - reference_file=Path(result["recovery"]["reference_file"]), reader_argv=reader_argv) - result["instruction"] += (" Append --select '' or --list to recovery.command for current fields. " - "Reader references keep complete digests locally; the CLI checks them on every page. " - "A reference or digest grants no authority or freshness.") - if len(_native_output_bytes(result)) > min(output_bytes, 7900): - return view # Preserve usable legacy recovery under a very small ceiling. - return result - - -def _native_context_reference(projected: dict[str, Any], reference: dict[str, Any], *, - mode: str, current: dict[str, Any], current_document: dict[str, Any], - total_bytes: int, metadata: dict[str, Any] | None, - progressive_enabled: bool, field_index: list[dict[str, Any]] | None, - initial_read_pointers: list[str] | None, - initial_read_reference: Path | None, - page_bytes: int, output_bytes: int, - first_read_budget: tuple[int, int], - page_attached: bool = False, - first_read_requested: bool = False, - reader_argv: list[str] | None = None) -> dict[str, Any]: - """Build the bounded native reference without publishing or registering it.""" - target = projected.get("target") if projected.get("schema_version") == "context_delivery_v1" else None - reference = _capture_read_reference(reference, page_bytes=page_bytes, output_bytes=output_bytes, - reader_argv=reader_argv) - result = {"ok": True, **(metadata or {}), "mode": mode, **reference, - **({"target": target} if target else {})} - if isinstance(result.get("recovery"), dict): - result["recovery"] = _capture_read_reference(result["recovery"], - page_bytes=page_bytes, output_bytes=output_bytes, reader_argv=reader_argv) - current_path = Path(current["path"]) - result.setdefault("recovery", _capture_read_reference(current, - page_bytes=page_bytes, output_bytes=output_bytes, reader_argv=reader_argv)) - if mode != "full" and projected.get("continuity_basis") == "captured_file": - result["continuity_basis"] = "captured_file" - result["baseline_reference"] = dict(projected["baseline_reference"]) - result["total_bytes"] = total_bytes - result["index_command"] = context_capture_read_command(current_path, current["sha256"], - page_bytes=min(page_bytes, 24000), max_page_output_bytes=min(output_bytes, 24000), list_fields=True, - reader_argv=reader_argv) - result["select_command"] = context_capture_read_command(current_path, current["sha256"], - page_bytes=page_bytes, max_page_output_bytes=output_bytes, reader_argv=reader_argv) + " --select ''" - # A complete scalar preview helps orientation without inventing a second - # decision owner. Oversized or structured values remain discoverable in the - # index; no prose is truncated or treated as an authorization summary. - result["overview"] = {key: value for key, value in current_document.items() - if value is None or isinstance(value, (str, bool, int, float)) - and len(json.dumps(value, ensure_ascii=False).encode("utf-8")) <= 240} - result["instruction"] = CONTEXT_CAPTURE_NAVIGATION_GUIDANCE if progressive_enabled else CONTEXT_CAPTURE_READ_GUIDANCE - if mode != "full": - result["instruction"] += (" command reads the delta; baseline_reference identifies its exact saved base when file-backed. " - "recovery.command reads current facts directly without that baseline.") - # The reference must fit even under an explicit small caller ceiling. - def fits() -> bool: - return len(json.dumps(result, ensure_ascii=False, separators=(",", ":")).encode()) + 1 <= min(output_bytes, 7900) - if first_read_requested or initial_read_pointers: - overview = result.pop("overview") - instruction = result["instruction"] - if first_read_requested: - instruction = ("If the initial selection names a Vision authoring step, select the complete vision_authoring object " - "at that step before composing a Vision. " + instruction) - elif any(pointer.endswith("/vision_authoring/rule") for pointer in (initial_read_pointers or ())): - instruction = ("Before composing a Vision, select the complete vision_authoring object " - "at the displayed step in this capture. " + instruction) - first_read = ("Read the top-level content and follow next_command if eof is false. " - "Use first_read_command only to recover missing initial context from this capture" - if page_attached else - "Start with first_read_command for current facts and source navigation from this capture") - result["instruction"] = (first_read + ", without reading an older baseline. " - "Read each complete optional attachment from its captured step before using it. " + instruction) - first_page_bytes, first_output_bytes = first_read_budget - result["first_read_command"] = context_capture_read_command(current_path, current["sha256"], - page_bytes=first_page_bytes, max_page_output_bytes=first_output_bytes, - initial_read=True, reader_argv=reader_argv) - if not fits(): - result.pop("first_read_command") # A complete optional selection or the existing generic recovery. - result["overview"] = overview - result["instruction"] = instruction - elif initial_read_reference is not None: - result["first_read_reference"] = str(initial_read_reference) - if not fits(): - result.pop("first_read_reference") # Optional cursor must not displace the original recipe. - # Prefer the concrete initial read over a broad scalar preview/root index. - # Otherwise preserve the complete root index, bound to current recovery, - # never a delta's patch keys. All deeper discovery remains available. - if field_index is not None and "first_read_command" not in result: - result["field_index"] = field_index - if not fits(): - result.pop("field_index") # Optional metadata must never displace recovery or instructions. - if not fits(): - result.pop("overview") - if not fits(): - result.pop("select_command") # --select also works on the current recovery command. - if not fits() and "baseline_reference" in result: - result["baseline_reference"].pop("command", None) # Exact path/hash remain usable. - if not fits(): - result.pop("index_command") - result["instruction"] += " Append --list or --select to recovery.command to discover or read current fields." - if not fits(): - raise QuotaDecisionTransportError("Native context reference exceeds the output limit; " - "read the saved original decision and receipt without rerunning admission.") - return result - - -def _native_output_bytes(value: dict[str, Any]) -> bytes: - """Match immutable capture publication and compact CLI JSON, including LF.""" - return (json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False) + "\n").encode("utf-8") - - -def _native_context_read_bytes(reference: dict[str, Any], raw: bytes, *, - page_bytes: int, output_bytes: int, - stop_after: int | None = None, - reader_argv: list[str] | None = None) -> int: - """Count the reference plus its explicit-file reader before publication. - - Stop once this candidate cannot win. This measures one complete wire read, - not model tokens, retention or the caller's later selective/recovery reads. - """ - from ..command_invocation import current_loopx_cli_argv - from ..control_plane.effect_runtime import MAX_REQUEST_BYTES - - if len(raw) > MAX_REQUEST_BYTES: - raise QuotaDecisionTransportError("Native context capture exceeds the existing reader size limit.") - # The reader resolves parent aliases before emitting continuation commands. - # Resolve only the parent: a candidate delta has not been published yet. - path = Path(reference["path"]).expanduser().absolute() - path = path.parent.resolve(strict=True) / path.name - total, offset = len(_native_output_bytes(reference)), 0 - reader_argv = current_loopx_cli_argv() if reader_argv is None else reader_argv - while True: - page = _capture_page(path, raw, reference["sha256"], - offset=offset, size=page_bytes, output_limit=output_bytes, instruction=False, - reader_argv=reader_argv) - total += len(_native_output_bytes(page)) - if page["eof"] or (stop_after is not None and total > stop_after): - return total - offset = page["next_offset"] - - def native_context_capture_view(projected: dict[str, Any], directory: Path, *, context_directory: str | None = None, progressive_enabled: bool = True, hook_session: str | None = None, @@ -805,7 +344,8 @@ def project_saved_context_capture(args: argparse.Namespace, *, hook_session: str activation = native_context_activation(args) saved_page = bool(getattr(args, "initial_read", False) or getattr(args, "select", None)) if saved_page and (args.format != "json" or not _context_page_output_enabled(args, activation) or args.offset != 0 - or args.reference_file or args.selection_sha256 or args.page_bytes is not None + or args.reference_file or args.selection_sha256 + or (args.page_bytes is not None and not activation["enabled"]) or (args.initial_read and args.select)): raise ValueError("Use JSON context-project at offset zero with native delivery or progressive --initial-read. " "Choose either --initial-read or --select. Use context-read for replay or continuation.") @@ -892,7 +432,19 @@ def project_saved_context_capture(args: argparse.Namespace, *, hook_session: str # into the immutable source or a second admission. Native lifecycle # still owns when the following full/delta view can be published. transport = _capture_selection_transport_template(payload, args, capture_root=root) - prepared = prepare_codex_context(payload, directory=directory, capture_directory=cache, + presentation = payload + if args.initial_read and activation["progressive_enabled"]: + from .quota_presentation import _project_quota_cli_payload + + # Match the ordinary selection guard's view while keeping the + # digest-bound original available for detail recovery. + presentation = _project_quota_cli_payload( + payload, argparse.Namespace(quota_command="should-run"), frozenset(), None, + captured_decision_path=str(path), + ) + if "host_poll_receipt" in payload: + presentation["host_poll_receipt"] = payload["host_poll_receipt"] + prepared = prepare_codex_context(presentation, directory=directory, capture_directory=cache, goal_id=args.goal_id, agent_id=args.agent_id, surface="quota_packet", capture_id=hashlib.sha256(raw).hexdigest(), continuity_generation=getattr(args, "_context_delivery_generation", None), diff --git a/loopx/cli_commands/quota_context_reader.py b/loopx/cli_commands/quota_context_reader.py new file mode 100644 index 0000000000..668305f281 --- /dev/null +++ b/loopx/cli_commands/quota_context_reader.py @@ -0,0 +1,497 @@ +"""Bounded readers and navigation for an already committed quota capture.""" +from __future__ import annotations + +import argparse +import json +import shlex +from pathlib import Path +from typing import Any + +from ..control_plane.content_digest import BARE_SHA256_PATTERN +from ..control_plane.effect_runtime import EffectRuntimeRemoteError, EffectRuntimeStartupError +from ..control_plane.quota.error_codes import QuotaDecisionTransportError +from ..control_plane.turn_driver.decision_capture import ( + CONTEXT_CAPTURE_NAVIGATION_GUIDANCE, + CONTEXT_CAPTURE_PAGE_GUIDANCE, + CONTEXT_CAPTURE_READ_GUIDANCE, + context_capture_read_command, + context_selection_binding, + context_selection_view as _context_selection_view, + read_context_capture_file as _read_capture, + read_context_read_reference, + write_context_read_reference, +) +from ..control_plane.turn_driver.native_context_profile import ( + NATIVE_CONTEXT_PAGE_BYTES, + NATIVE_CONTEXT_PAGE_OUTPUT_BYTES, + native_context_page_budget, +) + + +def _capture_page(path: Path, raw: bytes, digest: str, *, offset: int, size: int, + output_limit: int | None, instruction: bool, + presentation: dict[str, Any] | None = None, + next_page_bytes: int | None = None, + next_page_output_bytes: int | None = None, + select: list[str] | None = None, + selection_sha256: str | None = None, + list_fields: bool = False, + list_depth: int | None = None, + initial_read: bool = False, + reference_file: Path | None = None, + reader_argv: list[str] | None = None) -> dict[str, Any]: + end = min(offset + size, len(raw)) + while end < len(raw) and raw[end] & 0xC0 == 0x80: + end -= 1 + content = raw[offset:end].decode("utf-8") + + def page(text: str) -> dict[str, Any]: + cursor = offset + len(text.encode("utf-8")) + result: dict[str, Any] = {**(presentation or {}), "ok": True, "schema_version": "context_capture_page_v1", "sha256": digest, + "offset": offset, "next_offset": cursor if cursor < len(raw) else None, + "total_bytes": len(raw), "eof": cursor == len(raw), "content": text} + if select is not None: + result["selection_sha256"] = selection_sha256 + # The new initial view already names addresses in content; its + # short continuation pins that view's digest. Existing explicit + # selectors retain their original metadata and continuation. + if not initial_read: + result["selected_pointers"] = select + if list_fields: + result["list_fields"] = True + result["read_instruction"] = ("This is selected detail from the original capture, not a complete context read. " + "Offsets and EOF describe only this selected view. Follow next_command to finish it; " + "keep both digests. Detail delivery does not establish a full-context baseline or grant authority.") + elif instruction or (offset == 0 and output_limit is not None): + # A digest-bound native reference may be read in a fresh shell + # without its session lookup. Deliver paging obligations with the + # first bounded page rather than repeating them in file metadata. + # Historical unbounded readers retain their exact wire shape. + result["read_instruction"] = CONTEXT_CAPTURE_PAGE_GUIDANCE + if reference_file is not None and select is not None: + # Selected pages have never been full-input receipts. Give this + # compact transport a distinct schema so old observers cannot + # mistake an omitted digest for a full capture observation. + result["schema_version"] = "context_capture_selection_page_v1" + for key in ("sha256", "selection_sha256", "selected_pointers"): + result.pop(key, None) + result["reference_file"] = str(reference_file) + result["read_instruction"] = ("Selected detail only. Follow next_command for contiguous bytes; " + "the immutable reference pins both complete digests, which the CLI verifies on every page. " + "Offsets and EOF describe only this selected view. " + "EOF is not full-context retention, authority or freshness.") + if cursor < len(raw): + result["next_command"] = context_capture_read_command(path, digest, offset=cursor, page_bytes=next_page_bytes or size, + max_page_output_bytes=next_page_output_bytes or output_limit, + select=None if initial_read or reference_file is not None else select, + selection_sha256=selection_sha256 if reference_file is None else None, + list_fields=list_fields if reference_file is None else False, + list_depth=list_depth if reference_file is None else None, + initial_read=initial_read if reference_file is None else False, reference_file=reference_file, + reader_argv=reader_argv) + return result + + def fits(result: dict[str, Any]) -> bool: + # Match cli_runtime.print_payload exactly, including print's newline. + return output_limit is None or len(json.dumps(result, ensure_ascii=False, + separators=(",", ":")).encode("utf-8")) + 1 <= output_limit + + result = page(content) + if fits(result): + return result + # EOF drops next_command: test it above before searching the monotone + # non-EOF prefix sizes. Never return a zero-progress continuation. + low, high = 1, len(content) - (end == len(raw)) + best = None + while low <= high: + middle = (low + high) // 2 + candidate = page(content[:middle]) + if fits(candidate): + best, low = candidate, middle + 1 + else: + high = middle - 1 + if best is None: + raise ValueError("max-page-output-bytes cannot fit page metadata and one UTF-8 character; increase the limit for this same capture") + return best + + + + + + + + +def _validate_context_page_window(offset: int, size: int, output_limit: int | None) -> None: + from ..control_plane.effect_runtime import MAX_REQUEST_BYTES + + maximum = max(8192, min(output_limit or 8192, MAX_REQUEST_BYTES)) + if type(offset) is not int or offset < 0 or type(size) is not int or not 256 <= size <= maximum: + raise ValueError(f"Page offset must be nonnegative and --page-bytes must be between 256 and {maximum}; " + "larger pages require a sufficient --max-page-output-bytes within the capture limit.") + + +def read_context_capture(args: argparse.Namespace, *, presentation: dict[str, Any] | None = None, + next_page_bytes: int | None = None, + next_page_output_bytes: int | None = None, + reader_argv: list[str] | None = None) -> dict[str, Any]: + """Bounded local IO over immutable JSON, not a new context/authority owner. + + Verify the complete byte digest on every page. The cursor addresses bytes + in that capture or its separately digest-bound selection, so a replacement + cannot silently mix two decisions or detail views. + EOF describes this page, never a model's complete-consumption receipt. + """ + try: + digest = getattr(args, "capture_sha256", None) + value = getattr(args, "capture_file", None) + reference_file = getattr(args, "reference_file", None) + pinned_selection = None + if reference_file: + if value or digest or getattr(args, "codex_context_dir", None) or getattr(args, "codex_context_root", None): + raise ValueError("reference-file cannot be combined with capture or native session lookup") + if args.format != "json": + raise ValueError("reference-file requires JSON output") + reference_file, reference = read_context_read_reference(reference_file) + value, digest = reference["path"], reference["sha256"] + pinned_selection = reference.get("selection") + offset, size = args.offset, args.page_bytes + output_limit = getattr(args, "max_page_output_bytes", None) + if output_limit is not None and (type(output_limit) is not int or output_limit <= 0 + or getattr(args, "format", None) != "json"): + raise ValueError("max-page-output-bytes requires JSON output and a positive integer") + if size is None: + if args.format == "json": + if output_limit is None: + output_limit = NATIVE_CONTEXT_PAGE_OUTPUT_BYTES + size = max(8192, min(NATIVE_CONTEXT_PAGE_BYTES, output_limit)) + else: + size = 4096 + from ..control_plane.turn_driver.codex_context import codex_context_directory + + context_dir = codex_context_directory(directory=getattr(args, "codex_context_dir", None), + root=getattr(args, "codex_context_root", None)) + if context_dir: + if value or digest or offset != 0: + raise ValueError("session lookup starts at offset zero without capture-file or capture-sha256; follow next_command afterwards") + from ..control_plane.turn_driver.codex_context import read_codex_context_reference + + value, digest = read_codex_context_reference(context_dir, + goal_id=getattr(args, "goal_id", None), agent_id=getattr(args, "agent_id", None)) + if not isinstance(digest, str) or BARE_SHA256_PATTERN.fullmatch(digest) is None or not value: + raise ValueError("context-read requires the original capture file and its exact SHA-256") + _validate_context_page_window(offset, size, output_limit) + path, raw, payload = _read_capture(value, digest) + list_fields = getattr(args, "list_fields", False) + list_depth = getattr(args, "list_depth", None) + if list_depth is not None and (not list_fields or type(list_depth) is not int or not 1 <= list_depth <= 8): + raise ValueError("list-depth requires --list and an integer from 1 to 8") + selectors = getattr(args, "select", None) + initial_read = getattr(args, "initial_read", False) + selection_digest = getattr(args, "selection_sha256", None) + if pinned_selection is not None: + if selectors is not None or initial_read or list_fields or list_depth is not None or selection_digest is not None: + raise ValueError("a pinned selection reference cannot change selectors; use the original capture reference") + if (set(pinned_selection) - {"present_goal_narrative"} != + {"select", "initial_read", "list_fields", "list_depth", "sha256"} + or type(pinned_selection["initial_read"]) is not bool or type(pinned_selection["list_fields"]) is not bool + or ("present_goal_narrative" in pinned_selection and ( + pinned_selection["present_goal_narrative"] is not True + or pinned_selection["list_fields"] or pinned_selection["initial_read"])) + or not isinstance(pinned_selection["sha256"], str) + or BARE_SHA256_PATTERN.fullmatch(pinned_selection["sha256"]) is None): + raise ValueError("reader selection reference is incomplete or unsupported") + if observation := reference.get("observed_read"): + from ..control_plane.turn_driver.native_first_read import observation_current + + if not observation_current(observation, path, digest): + if offset: + return {"ok": False, "reason": "Observed first-read scope changed during paging.", + "recovery_command": context_capture_read_command(path, digest, initial_read=True, + page_bytes=size, max_page_output_bytes=output_limit, reader_argv=reader_argv)} + # Restart in the ordinary complete view of this same capture. + # Subsequent pages must pin that view even if the journal changes. + pinned_selection, initial_read = None, True + try: + reference_file = write_context_read_reference(path, digest) + except OSError: + reference_file = None + elif offset == 0: + presentation = {**(presentation or {}), "previously_delivered_fields": list(observation["fields"]), + "field_recovery": "These unchanged fields still apply. If needed or uncertain, append --select " + "and the field pointer to recovery.command for its complete current body."} + if pinned_selection is not None: + selectors, initial_read = pinned_selection["select"], pinned_selection["initial_read"] + list_fields, list_depth = pinned_selection["list_fields"], pinned_selection["list_depth"] + selection_digest = pinned_selection["sha256"] + if list_depth is not None and (not list_fields or type(list_depth) is not int or not 1 <= list_depth <= 8): + raise ValueError("reader selection list-depth must be between 1 and 8 and requires list") + if initial_read and (selectors is not None or list_fields or list_depth is not None): + raise ValueError("initial-read cannot be combined with select or list") + if list_fields and selectors is None: + selectors = [""] + if initial_read and offset > 0 and selection_digest is None: + raise ValueError("initial-read continuation requires selection-sha256; follow next_command") + if selectors is None and not initial_read and selection_digest is not None: + raise ValueError("selection-sha256 requires --select") + if selectors is not None or initial_read: + if getattr(args, "format", None) != "json": + raise ValueError("selected context-read requires JSON output") + if offset > 0 and selection_digest is None: + raise ValueError("selected continuation requires selection-sha256; follow next_command") + pinned_explicit = pinned_selection is not None and not initial_read + plan, selected_raw, expected = _context_selection_view(payload, select=selectors, + initial_read=initial_read, list_fields=list_fields, list_depth=list_depth, + expected_sha256=selection_digest, preserve_selection_order=pinned_explicit, + present_goal_narrative=(pinned_selection.get("present_goal_narrative") is True + if pinned_selection is not None else None)) + if rejection := plan.get("rejected_selection"): + # TS alone resolves the nearest container. Reuse the original + # escaped tokens, without parsing diagnostic prose or traversing + # the payload again in Python. A failed batch delivers no body. + pointer = selectors[rejection["argument_index"] - 1] + parent = "/".join(pointer.split("/")[:rejection["parent_token_count"] + 1]) + command = context_capture_read_command(path, digest, select=[parent], list_fields=True, + page_bytes=next_page_bytes or size, + max_page_output_bytes=next_page_output_bytes or output_limit, + reader_argv=reader_argv) + return {"ok": False, "schema_version": "context_capture_page_v1", + "error_kind": "request_rejected", "diagnostic_code": rejection["code"], + "reason": rejection["message"], "recovery_command": command, + "recovery": "Use recovery_command to --list the nearest verified container in the same capture, " + "then retry the corrected batch. No values were delivered and no guard, admission or spend was performed."} + if selected_raw is None or expected is None: + raise ValueError("selected view is unavailable") + raw = selected_raw + selection_digest = expected + selectors = [item["pointer"] for item in plan["selections"]] + if reference_file is not None and pinned_selection is None: + try: + # Pin the resolved addresses, not the initial navigation + # recipe. Existing explicit readers can replay these exact + # bytes after a navigation change, with both digests checked. + reference_file = write_context_read_reference(path, digest, selection=context_selection_binding( + selectors, list_fields=list_fields, list_depth=list_depth, sha256=expected, + presented=bool(plan.get("presentation_pointers")))) + except OSError: + # A verified read must not require a writable capture root. + # Legacy cursors pin the same capture, selection and digest. + reference_file = None + if offset > len(raw) or (offset < len(raw) and raw[offset] & 0xC0 == 0x80): + raise ValueError("capture offset is outside the file or not a UTF-8 boundary; use the returned next offset") + return _capture_page(path, raw, digest, offset=offset, size=size, + output_limit=output_limit, instruction=bool(context_dir), + presentation=presentation, next_page_bytes=next_page_bytes, + next_page_output_bytes=next_page_output_bytes, select=selectors, selection_sha256=selection_digest, + list_fields=list_fields, list_depth=list_depth, initial_read=initial_read, reference_file=reference_file, + reader_argv=reader_argv) + except (OSError, ValueError, RecursionError, EffectRuntimeRemoteError, EffectRuntimeStartupError) as exc: + # Recovery is presentation-only: keep failures separate from quota's + # collection error path and retain the existing receipt/capture. + result = {"ok": False, "schema_version": "context_capture_page_v1", + "reason": str(exc) if isinstance(exc, (ValueError, EffectRuntimeRemoteError, EffectRuntimeStartupError)) + else "original capture could not be read", + "recovery": "Restore the original digest-bound capture; no guard, admission or spend was performed."} + if isinstance(exc, (EffectRuntimeRemoteError, EffectRuntimeStartupError)): + result["diagnostic_code"] = exc.diagnostic_code + result["recovery"] = ("Resolve the reported runtime failure, then retry reading the same digest-bound capture; " + "no guard, admission or spend was performed.") + if isinstance(exc, EffectRuntimeRemoteError): + result["error_kind"] = exc.error_kind + if exc.error_kind == "request_rejected": + result["recovery"] = ("Correct the rejected read request; for unknown fields use --list on a known parent. " + "Keep the original capture and digest; no guard, admission or spend was performed.") + return result + + +def _capture_read_reference(value: dict[str, Any], *, page_bytes: int, + output_bytes: int, reader_argv: list[str] | None = None) -> dict[str, str]: + """Keep file identity and the budgeted reader at the transport boundary. + + Navigation lives on the outer view; complete paging obligations accompany + the actual reader response. Neither belongs in every nested file reference. + """ + return {"path": value["path"], "sha256": value["sha256"], + "command": context_capture_read_command(Path(value["path"]), value["sha256"], + page_bytes=page_bytes, max_page_output_bytes=output_bytes, reader_argv=reader_argv)} + + +def model_context_reference(view: dict[str, Any], *, reader_argv: list[str] | None = None) -> dict[str, Any]: + """Present native navigation; machine references and journal bindings stay full. + + The descriptor holds complete digests locally. Legacy explicit readers and + full-page observation receipts retain their original transport shape. + """ + def command_budget(command: str) -> tuple[int, int]: + words = shlex.split(command) + page_bytes, output_bytes = native_context_page_budget() + if "--page-bytes" in words: + page_bytes = int(words[words.index("--page-bytes") + 1]) + if "--max-page-output-bytes" in words: + output_bytes = int(words[words.index("--max-page-output-bytes") + 1]) + return page_bytes, output_bytes + + page_bytes, output_bytes = command_budget(view["command"]) + + def reference(value: dict[str, Any], *, navigation: bool = False) -> dict[str, str]: + path = Path(value["path"]) + reader = write_context_read_reference(path, value["sha256"], navigation=value if navigation else None) + return {"reference_file": str(reader), "command": context_capture_read_command(path, value["sha256"], + page_bytes=page_bytes, max_page_output_bytes=output_bytes, reference_file=reader, + reader_argv=reader_argv)} + + result = {"ok": True, "schema_version": "context_capture_navigation_v1", "mode": view["mode"], + **reference(view, navigation=True), "total_bytes": view["total_bytes"], "recovery": reference(view["recovery"]), + "instruction": view["instruction"]} + if "context_delivery" in view: + result["context_delivery"] = view["context_delivery"] + if "baseline_reference" in view: + result["baseline_reference"] = reference(view["baseline_reference"]) + result["continuity_basis"] = view["continuity_basis"] + if "first_read_command" in view: + recovery = view["recovery"] + first_page_bytes, first_output_bytes = command_budget(view["first_read_command"]) + if view.get("first_read_reference"): + # The producer already pinned this same initial selection. Keep + # that existing cursor instead of asking the reader to replan it. + result["first_read_command"] = context_capture_read_command(Path(recovery["path"]), recovery["sha256"], + page_bytes=first_page_bytes, max_page_output_bytes=first_output_bytes, + reference_file=Path(view["first_read_reference"]), reader_argv=reader_argv) + else: + result["first_read_command"] = context_capture_read_command(Path(recovery["path"]), recovery["sha256"], + page_bytes=first_page_bytes, max_page_output_bytes=first_output_bytes, initial_read=True, + reference_file=Path(result["recovery"]["reference_file"]), reader_argv=reader_argv) + result["instruction"] += (" Append --select '' or --list to recovery.command for current fields. " + "Reader references keep complete digests locally; the CLI checks them on every page. " + "A reference or digest grants no authority or freshness.") + if len(_native_output_bytes(result)) > min(output_bytes, 7900): + return view # Preserve usable legacy recovery under a very small ceiling. + return result + + +def _native_context_reference(projected: dict[str, Any], reference: dict[str, Any], *, + mode: str, current: dict[str, Any], current_document: dict[str, Any], + total_bytes: int, metadata: dict[str, Any] | None, + progressive_enabled: bool, field_index: list[dict[str, Any]] | None, + initial_read_pointers: list[str] | None, + initial_read_reference: Path | None, + page_bytes: int, output_bytes: int, + first_read_budget: tuple[int, int], + page_attached: bool = False, + first_read_requested: bool = False, + reader_argv: list[str] | None = None) -> dict[str, Any]: + """Build the bounded native reference without publishing or registering it.""" + target = projected.get("target") if projected.get("schema_version") == "context_delivery_v1" else None + reference = _capture_read_reference(reference, page_bytes=page_bytes, output_bytes=output_bytes, + reader_argv=reader_argv) + result = {"ok": True, **(metadata or {}), "mode": mode, **reference, + **({"target": target} if target else {})} + if isinstance(result.get("recovery"), dict): + result["recovery"] = _capture_read_reference(result["recovery"], + page_bytes=page_bytes, output_bytes=output_bytes, reader_argv=reader_argv) + current_path = Path(current["path"]) + result.setdefault("recovery", _capture_read_reference(current, + page_bytes=page_bytes, output_bytes=output_bytes, reader_argv=reader_argv)) + if mode != "full" and projected.get("continuity_basis") == "captured_file": + result["continuity_basis"] = "captured_file" + result["baseline_reference"] = dict(projected["baseline_reference"]) + result["total_bytes"] = total_bytes + result["index_command"] = context_capture_read_command(current_path, current["sha256"], + page_bytes=min(page_bytes, 24000), max_page_output_bytes=min(output_bytes, 24000), list_fields=True, + reader_argv=reader_argv) + result["select_command"] = context_capture_read_command(current_path, current["sha256"], + page_bytes=page_bytes, max_page_output_bytes=output_bytes, reader_argv=reader_argv) + " --select ''" + # A complete scalar preview helps orientation without inventing a second + # decision owner. Oversized or structured values remain discoverable in the + # index; no prose is truncated or treated as an authorization summary. + result["overview"] = {key: value for key, value in current_document.items() + if value is None or isinstance(value, (str, bool, int, float)) + and len(json.dumps(value, ensure_ascii=False).encode("utf-8")) <= 240} + result["instruction"] = CONTEXT_CAPTURE_NAVIGATION_GUIDANCE if progressive_enabled else CONTEXT_CAPTURE_READ_GUIDANCE + if mode != "full": + result["instruction"] += (" command reads the delta; baseline_reference identifies its exact saved base when file-backed. " + "recovery.command reads current facts directly without that baseline.") + # The reference must fit even under an explicit small caller ceiling. + def fits() -> bool: + return len(json.dumps(result, ensure_ascii=False, separators=(",", ":")).encode()) + 1 <= min(output_bytes, 7900) + if first_read_requested or initial_read_pointers: + overview = result.pop("overview") + instruction = result["instruction"] + if first_read_requested: + instruction = ("If the initial selection names a Vision authoring step, select the complete vision_authoring object " + "at that step before composing a Vision. " + instruction) + elif any(pointer.endswith("/vision_authoring/rule") for pointer in (initial_read_pointers or ())): + instruction = ("Before composing a Vision, select the complete vision_authoring object " + "at the displayed step in this capture. " + instruction) + first_read = ("Read the top-level content and follow next_command if eof is false. " + "Use first_read_command only to recover missing initial context from this capture" + if page_attached else + "Start with first_read_command for current facts and source navigation from this capture") + result["instruction"] = (first_read + ", without reading an older baseline. " + "Read each complete optional attachment from its captured step before using it. " + instruction) + first_page_bytes, first_output_bytes = first_read_budget + result["first_read_command"] = context_capture_read_command(current_path, current["sha256"], + page_bytes=first_page_bytes, max_page_output_bytes=first_output_bytes, + initial_read=True, reader_argv=reader_argv) + if not fits(): + result.pop("first_read_command") # A complete optional selection or the existing generic recovery. + result["overview"] = overview + result["instruction"] = instruction + elif initial_read_reference is not None: + result["first_read_reference"] = str(initial_read_reference) + if not fits(): + result.pop("first_read_reference") # Optional cursor must not displace the original recipe. + # Prefer the concrete initial read over a broad scalar preview/root index. + # Otherwise preserve the complete root index, bound to current recovery, + # never a delta's patch keys. All deeper discovery remains available. + if field_index is not None and "first_read_command" not in result: + result["field_index"] = field_index + if not fits(): + result.pop("field_index") # Optional metadata must never displace recovery or instructions. + if not fits(): + result.pop("overview") + if not fits(): + result.pop("select_command") # --select also works on the current recovery command. + if not fits() and "baseline_reference" in result: + result["baseline_reference"].pop("command", None) # Exact path/hash remain usable. + if not fits(): + result.pop("index_command") + result["instruction"] += " Append --list or --select to recovery.command to discover or read current fields." + if not fits(): + raise QuotaDecisionTransportError("Native context reference exceeds the output limit; " + "read the saved original decision and receipt without rerunning admission.") + return result + + +def _native_output_bytes(value: dict[str, Any]) -> bytes: + """Match immutable capture publication and compact CLI JSON, including LF.""" + return (json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False) + "\n").encode("utf-8") + + +def _native_context_read_bytes(reference: dict[str, Any], raw: bytes, *, + page_bytes: int, output_bytes: int, + stop_after: int | None = None, + reader_argv: list[str] | None = None) -> int: + """Count the reference plus its explicit-file reader before publication. + + Stop once this candidate cannot win. This measures one complete wire read, + not model tokens, retention or the caller's later selective/recovery reads. + """ + from ..command_invocation import current_loopx_cli_argv + from ..control_plane.effect_runtime import MAX_REQUEST_BYTES + + if len(raw) > MAX_REQUEST_BYTES: + raise QuotaDecisionTransportError("Native context capture exceeds the existing reader size limit.") + # The reader resolves parent aliases before emitting continuation commands. + # Resolve only the parent: a candidate delta has not been published yet. + path = Path(reference["path"]).expanduser().absolute() + path = path.parent.resolve(strict=True) / path.name + total, offset = len(_native_output_bytes(reference)), 0 + reader_argv = current_loopx_cli_argv() if reader_argv is None else reader_argv + while True: + page = _capture_page(path, raw, reference["sha256"], + offset=offset, size=page_bytes, output_limit=output_bytes, instruction=False, + reader_argv=reader_argv) + total += len(_native_output_bytes(page)) + if page["eof"] or (stop_after is not None and total > stop_after): + return total + offset = page["next_offset"] diff --git a/loopx/cli_commands/turn.py b/loopx/cli_commands/turn.py index 7a4ac7350e..a463fac6cc 100644 --- a/loopx/cli_commands/turn.py +++ b/loopx/cli_commands/turn.py @@ -3,6 +3,7 @@ import argparse import shlex from collections.abc import Callable, Mapping +from functools import partial from pathlib import Path from typing import Any @@ -49,6 +50,7 @@ def handle_turn_command( print_payload: PrintPayload, native_host_runner: Callable[..., dict[str, Any]] | None = None, native_session_binding_resolver: Callable[[Mapping[str, Any]], dict[str, Any]] | None = None, + native_execution_preparer: Callable[[Mapping[str, Any], Mapping[str, Any]], str | None] | None = None, native_completion_lease_resolver: Callable[[], Mapping[str, Any]] | None = None, native_task_validator: Callable[[Mapping[str, Any], Mapping[str, Any]], Mapping[str, Any]] | None = None, ) -> int | None: @@ -81,6 +83,8 @@ def handle_turn_command( raise ValueError("a native completion lease requires its original host connection") if native_task_validator is not None and native_host_runner is None: raise ValueError("a native task validator requires its original host connection") + if native_execution_preparer is not None and native_host_runner is None: + raise ValueError("native task preparation requires its original host connection") if getattr(args, "todo_id", None) is not None and ( getattr(args, "resume_turn_key", None) or any(getattr(args, key, None) for key in ("resume_goal_id", "resume_agent_id", "resume_todo_id")) @@ -239,7 +243,8 @@ def handle_turn_command( # from the same live status, scheduler context and capability hooks, so # this Turn takes all of them -- and its later settle-against inputs -- # from the shared decision owner instead of rebuilding them per command. - decision_owner = build_fresh_turn_decision_owner( + read_decision_owner = partial( + build_fresh_turn_decision_owner, args, registry_path=registry_path, runtime_root=runtime_root, @@ -248,6 +253,7 @@ def handle_turn_command( goal_ref=goal_ref, request_subject=delegated_context.get("request_subject") if delegated_context else None, ) + decision_owner = read_decision_owner() operator_inbox_urgency_projector = decision_owner.operator_inbox_urgency_projector scheduler_context = decision_owner.scheduler_execution_context decision = decision_owner.resolve() @@ -256,6 +262,21 @@ def handle_turn_command( decision, scheduler_execution_context=scheduler_context, ) + if native_execution_preparer is not None and args.turn_command == "run-once" and args.execute: + prepared_todo = native_execution_preparer(decision, turn_envelope) + if prepared_todo is not None: + # Host preparation changes canonical lease state. Plan and + # settle against a fresh read of the same Turn and Todo. + decision_owner = read_decision_owner() + operator_inbox_urgency_projector = decision_owner.operator_inbox_urgency_projector + scheduler_context = decision_owner.scheduler_execution_context + decision = decision_owner.resolve() + preparation = decision.get("execution_preparation") or {} + if (preparation.get("state") != "ready" or preparation.get("todo_id") != prepared_todo + or (decision.get("selected_todo") or {}).get("todo_id") != prepared_todo + or decision.get("normal_delivery_allowed") is not True): + raise ValueError("native task preparation did not make the same Todo ready") + turn_envelope = build_turn_envelope(decision, scheduler_execution_context=scheduler_context) # Operation-equipped app-server sessions retain their Todo-bound # approval/handoff contract; this option controls exec conversation reuse. session_scope = (args.session_scope if args.host in {"codex-cli", "codex-app-server"} diff --git a/loopx/control_plane/heartbeat/builder.py b/loopx/control_plane/heartbeat/builder.py index 6a7cf276cd..26a13bb084 100644 --- a/loopx/control_plane/heartbeat/builder.py +++ b/loopx/control_plane/heartbeat/builder.py @@ -557,7 +557,7 @@ def build_heartbeat_prompt( brief_prompt_command=str(commands["brief_prompt_command"]), thin_prompt_command=str(commands["thin_prompt_command"]), **({"captured_envelope": True} if turn_envelope else {}), - **({"captured_context": True} if native_goal_host and context_delivery == "on" else {}), + **({"captured_context": True} if native_goal_host and effective_context_delivery == "on" else {}), **reward_memory_rule_kwargs, ) task_body = bind_exact_turn_settlement_task_body( @@ -599,7 +599,7 @@ def build_heartbeat_prompt( "agent_profile": agent_profile_prompt_projection(agent_profile), "registered_agents": normalized_registered_agents, "runtime_profile": runtime_profile, - **({"context_delivery": activation} if context_delivery == "on" else {}), + **({"context_delivery": activation} if effective_context_delivery == "on" else {}), "reward_memory_enabled": reward_memory_enabled, "scheduler_execution_context": scheduler_execution_context, **( diff --git a/loopx/control_plane/quota/live_decision.py b/loopx/control_plane/quota/live_decision.py index 23a98ebfab..d9d664cca2 100644 --- a/loopx/control_plane/quota/live_decision.py +++ b/loopx/control_plane/quota/live_decision.py @@ -569,6 +569,29 @@ def _receipt_bound_recovery_required( return wait_kind not in ("", "monitor_changed", "todo_done") +def _project_unbound_work_fields(payload: dict[str, Any]) -> None: + interaction = payload.get("interaction_contract") + cli = interaction.get("cli_channel") if isinstance(interaction, dict) else None + if not isinstance(cli, dict) or cli.get("turn_binding_required") is not True: + return + + # Reconcile the legacy work fields after hooks and recovery have chosen + # the final interaction. The selected work stays visible but unadmitted. + reason = cli["spend_policy"] + payload["normal_delivery_allowed"] = False + payload["recommended_action"] = cli["next_cli_actions"][0] + payload.pop("agent_lane_next_action", None) + obligation = payload.get("execution_obligation") + if isinstance(obligation, dict): + obligation.update(must_attempt_work=False, delivery_allowed=False, reason=reason) + lane = payload.get("work_lane_contract") + if isinstance(lane, dict): + lane.update(must_attempt_work=False, action=payload["recommended_action"]) + recommendation = payload.get("heartbeat_recommendation") + if isinstance(recommendation, dict): + recommendation["spend_policy"] = reason + + def build_live_quota_should_run_decision( status_payload: dict[str, Any], *, @@ -834,6 +857,7 @@ def build_live_quota_should_run_decision( payload["capability_hook_dispatch"] = { key: value for key, value in hook_dispatch.items() if key != "projections" } + _project_unbound_work_fields(payload) interaction = payload.get("interaction_contract") if isinstance(interaction, dict) and goal_id and agent_id: scope = { diff --git a/loopx/control_plane/quota/progressive_context.ts b/loopx/control_plane/quota/progressive_context.ts index c0479ab174..d0c924608f 100644 --- a/loopx/control_plane/quota/progressive_context.ts +++ b/loopx/control_plane/quota/progressive_context.ts @@ -485,6 +485,10 @@ function initialContextReadGroups(context: JsonObject, decisionFields: readonly include("interaction_contract", "user_channel"); for (const field of ["primary_action", "delivery_allowed", "must_attempt", "quiet_noop_allowed"]) include("interaction_contract", "agent_channel", field); + const turnBindingRequired = object(interaction.cli_channel)?.turn_binding_required === true; + if (turnBindingRequired) + for (const field of ["turn_binding_required", "next_cli_actions", "spend_allowed_now", "spend_after_validation", "spend_policy"]) + include("interaction_contract", "cli_channel", field); include("interaction_contract", "agent_channel", "required_reads"); const work = ["interaction_contract", "agent_channel", "work_context"]; include(...work, "instruction"); @@ -582,9 +586,10 @@ function initialContextReadGroups(context: JsonObject, decisionFields: readonly && settlementSteps?.length === 2 && settlementSteps[0]?.kind === "durable_writeback" && settlementSteps[1]?.kind === "quota_spend" && settlementSteps.every(step => typeof step?.command_template === "string" && step.command_template.length > 0) && isDeepStrictEqual(cli?.next_cli_actions, settlementSteps.map(step => step?.command_template)); - for (const field of ["execution_preparation", "next_cli_actions", "spend_allowed_now", "spend_after_validation", "spend_policy", - "quota_spend_source", "delivery_workspace_causality", "vision_continuation_audit"]) - if (field !== "next_cli_actions" || !repeatsSettlement) include("interaction_contract", "cli_channel", field); + if (!turnBindingRequired) + for (const field of ["execution_preparation", "next_cli_actions", "spend_allowed_now", "spend_after_validation", "spend_policy", + "quota_spend_source", "delivery_workspace_causality", "vision_continuation_audit"]) + if (field !== "next_cli_actions" || !repeatsSettlement) include("interaction_contract", "cli_channel", field); // Explicit selection clears next_cli_actions. Deliver the owner's discovery // and same-Turn binding recipe alongside its non-exhaustive suggestions. if (cli?.selection_required === true) { diff --git a/loopx/control_plane/quota/spend_sources.py b/loopx/control_plane/quota/spend_sources.py index fbea522c56..678202f0ac 100644 --- a/loopx/control_plane/quota/spend_sources.py +++ b/loopx/control_plane/quota/spend_sources.py @@ -46,18 +46,18 @@ def quota_spend_source_for_execution_context( return DEFAULT_SLOT_SPEND_SOURCE -def host_goal_turn_reentry_action( +def host_goal_turn_reentry_required( payload: Mapping[str, Any], - settlement_plan: Mapping[str, Any] | None, scheduler_execution_context: ( Mapping[str, Any] | SchedulerExecutionContextResolution | None ), turn_instance_id: str | None, - typed_quota_guard: str, -) -> str | None: - profile = scheduler_runtime_profile_for_execution_context( - scheduler_execution_context - ) +) -> bool: + if turn_instance_id is not None or ( + scheduler_runtime_profile_for_execution_context(scheduler_execution_context) + not in VISIBLE_GOAL_SETTLEMENT_RUNTIME_PROFILES + ): + return False selected_value = payload.get("selected_todo") selected = selected_value if isinstance(selected_value, Mapping) else {} replan_value = payload.get("replan_action_packet") @@ -65,26 +65,40 @@ def host_goal_turn_reentry_action( replan_obligation_id = normalize_todo_replan_obligation_id( replan.get("obligation_id") ) - has_settlement_binding = bool( + return bool( normalize_todo_id(selected.get("todo_id")) or replan_obligation_id ) - requires_turn_reentry = profile in VISIBLE_GOAL_SETTLEMENT_RUNTIME_PROFILES - if ( - requires_turn_reentry - and has_settlement_binding - and settlement_plan is None - and turn_instance_id is None + + +def host_goal_turn_reentry_action( + payload: Mapping[str, Any], + settlement_plan: Mapping[str, Any] | None, + scheduler_execution_context: ( + Mapping[str, Any] | SchedulerExecutionContextResolution | None + ), + turn_instance_id: str | None, + typed_quota_guard: str, +) -> str | None: + if settlement_plan is not None or not host_goal_turn_reentry_required( + payload, scheduler_execution_context, turn_instance_id ): - if profile is SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE: - return f"{typed_quota_guard} --begin-turn" - # CLI/managed Goal hosts own their iteration identity, not an App - # heartbeat receipt. Re-enter before exposing any unbound settlement. - return ( - f"{typed_quota_guard} --turn-instance-id " - "''" - ) - return None + return None + return typed_quota_guard + host_goal_turn_start_args(scheduler_execution_context) + + +def host_goal_turn_start_args( + scheduler_execution_context: ( + Mapping[str, Any] | SchedulerExecutionContextResolution | None + ), +) -> str: + profile = scheduler_runtime_profile_for_execution_context(scheduler_execution_context) + if profile is SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE: + return " --begin-turn" + if profile not in VISIBLE_GOAL_SETTLEMENT_RUNTIME_PROFILES: + return "" + # CLI/managed Goal hosts own their iteration identity. + return " --turn-instance-id ''" def build_quota_spend_action( diff --git a/loopx/control_plane/quota/turn_envelope.ts b/loopx/control_plane/quota/turn_envelope.ts index 363dad69d5..fad61e397e 100644 --- a/loopx/control_plane/quota/turn_envelope.ts +++ b/loopx/control_plane/quota/turn_envelope.ts @@ -583,6 +583,7 @@ function replanReadCommands(payload: JsonObject, path = payload.replan_read_path const packet = object(payload.replan_action_packet); if (typeof path !== "string" || !path.trim() || path.includes("\0") || payload.ok !== true || payload.effective_action === EffectiveAction.GOVERNED_CAPABILITY_INTENT || + object(object(payload.interaction_contract).cli_channel).turn_binding_required === true || !packet.obligation_id || packet.settlement_only === true) return null; // Retain the renderer's executable across reads. A different ambient LoopX // may not understand this captured contract. Legacy captures keep their route. @@ -1113,11 +1114,12 @@ function actionProjection(payload: JsonObject, deferSettlement = true): JsonObje const interaction = object(payload.interaction_contract); const agentChannel = object(interaction.agent_channel); const cliChannel = object(interaction.cli_channel); + const turnBindingRequired = cliChannel.turn_binding_required === true; const capabilityIntent = payload.effective_action === EffectiveAction.GOVERNED_CAPABILITY_INTENT ? projectPendingCapabilityIntent(payload.pending_capability_intent) : null; // A governed capability action has already won the live decision. Stale // replan/host-reentry projections must not replace its exact command. - const replanPacket = capabilityIntent ? null : replanActionPacket(payload); + const replanPacket = capabilityIntent || turnBindingRequired ? null : replanActionPacket(payload); const replanSettlementOnly = object(payload.replan_action_packet).settlement_only === true; const recommendedAction = replanPacket ? replanSettlementOnly @@ -1193,6 +1195,9 @@ function actionProjection(payload: JsonObject, deferSettlement = true): JsonObje }); writeback.selection_command_ref = "full_decision.interaction_contract.cli_channel.selection_command"; + } else if (turnBindingRequired) { + writeback.turn_binding_required = true; + writeback.next_cli_actions = cliChannel.next_cli_actions; } else { // Original-Turn identities often follow an absolute runtime path. Cutting // a closeout command into display text can erase its binding or execute flag. @@ -1601,7 +1606,11 @@ export function turnEnvelopeRoute(envelope: JsonObject, historicalText?: JsonObj // Size diagnostics grant no authority and do not veto a valid contract. const action = object(envelope.action); if (envelope.should_run === true) { - if (action.must_attempt !== true) return "blocked"; + // An unbound native Goal Turn can require its binding command before + // work. That obligation cannot route a host launch, including a replan. + if (action.must_attempt !== true || object(envelope.writeback).turn_binding_required === true) { + return "blocked"; + } const name = scalarString(historicalText ? historicalText.effective_action : envelope.effective_action || "", "route effective action"); const route = Object.hasOwn(TURN_ACTION_ROUTES, name) ? TURN_ACTION_ROUTES[name as keyof typeof TURN_ACTION_ROUTES] : "ready_for_host"; diff --git a/loopx/control_plane/scheduler/execution_context.py b/loopx/control_plane/scheduler/execution_context.py index 8d92f3647d..2b04213fd2 100644 --- a/loopx/control_plane/scheduler/execution_context.py +++ b/loopx/control_plane/scheduler/execution_context.py @@ -4,7 +4,7 @@ import shlex from collections.abc import Mapping -from dataclasses import dataclass +from dataclasses import dataclass, replace from enum import Enum SCHEDULER_EXECUTION_CONTEXT_SCHEMA_VERSION = "scheduler_execution_context_v0" @@ -234,6 +234,9 @@ class SchedulerExecutionContextResolution: context: SchedulerExecutionContext | None errors: tuple[str, ...] = () supplied: Mapping[str, Any] | None = None + # Only the external Turn driver sets this in-process. A context supplied + # to quota or recovered from its serialized projection cannot assert it. + turn_driver_owned: bool = False @property def ok(self) -> bool: @@ -519,14 +522,13 @@ def scheduler_execution_context_for_turn( if host_surface in {HostSurface.GENERIC_CLI.value, HostSurface.CODEX_APP_SERVER.value} else SchedulerOwner.AGENT_CLI_LOOP.value ) - return resolve_scheduler_execution_context( - { - "host_surface": host_surface, - "scheduler_owner": owner, - "execution_mode": normalized_mode, - "source": "loopx_turn", - } - ) + resolution = resolve_scheduler_execution_context({ + "host_surface": host_surface, + "scheduler_owner": owner, + "execution_mode": normalized_mode, + "source": "loopx_turn", + }) + return replace(resolution, turn_driver_owned=True) def build_goal_runtime_continuation( diff --git a/loopx/control_plane/testing/actual_default_model_behavior_portfolio.py b/loopx/control_plane/testing/actual_default_model_behavior_portfolio.py index 20593fd21b..226084aa7d 100644 --- a/loopx/control_plane/testing/actual_default_model_behavior_portfolio.py +++ b/loopx/control_plane/testing/actual_default_model_behavior_portfolio.py @@ -1199,17 +1199,22 @@ def code(value: Any) -> str | None: steps = receipt.get("tool_call_receipts") entries = steps if isinstance(steps, list) else [] errors: dict[str, int] = {} + observed_tool_sequence: list[str] = [] for entry in entries[:64]: - if not isinstance(entry, Mapping) or not entry.get("error_code"): + if not isinstance(entry, Mapping): continue - error = code(entry["error_code"]) or "unclassified" - errors[error] = errors.get(error, 0) + 1 + if (kind := code(entry.get("kind"))) is not None: + observed_tool_sequence.append(kind) + if entry.get("error_code"): + error = code(entry["error_code"]) or "unclassified" + errors[error] = errors.get(error, 0) + 1 return { "repeat": repeat, "actor_passed": receipt.get("qualification_passed") is True, "failure_code": code(receipt.get("failure_code")), "tool_call_count": count(receipt.get("tool_call_count")), "tool_call_limit": count(receipt.get("tool_call_limit")), + "observed_tool_sequence": observed_tool_sequence, "tool_error_counts": dict(sorted(errors.items())), "tool_errors_truncated": len(entries) > 64, } diff --git a/loopx/control_plane/testing/capability_monitor_repair_tool_behavior.py b/loopx/control_plane/testing/capability_monitor_repair_tool_behavior.py index 32abb99af7..3530bac530 100644 --- a/loopx/control_plane/testing/capability_monitor_repair_tool_behavior.py +++ b/loopx/control_plane/testing/capability_monitor_repair_tool_behavior.py @@ -176,6 +176,7 @@ def _build_capability_repair_fixture(root: Path) -> _SelectedTodoToolFixture: "network", ], runtime_profile="codex_app_heartbeat", + registry_path=os.path.relpath(global_registry_path, project_root), ) quota_guard_command = str(prompt["quota_guard_command"]) if quota_guard_command not in str(prompt["task_body"]): diff --git a/loopx/control_plane/testing/cli_output_budget.py b/loopx/control_plane/testing/cli_output_budget.py index 792f691128..8f98b366bf 100644 --- a/loopx/control_plane/testing/cli_output_budget.py +++ b/loopx/control_plane/testing/cli_output_budget.py @@ -232,7 +232,10 @@ class CliOutputCommandClassification: # matched small/crowded output is 10,560/15,492 -> 10,560/21,856. # Keep 35*60 collection growth; 9,300 covers 9,196 fixed chars with # 104 headroom. Retain the earlier 5,400 failure, not a size reduction. - max_json_fixed_semantic_growth_chars=9_300, + # The validator's stride review citation limit and its two target + # fields add 136 fixed characters to crowded output. Preserve the + # measured 104 characters of headroom and the per-Todo allowance. + max_json_fixed_semantic_growth_chars=9_436, ), CliOutputBudgetSpec( surface_id="status", diff --git a/loopx/control_plane/testing/replan_semantic_action_behavior.py b/loopx/control_plane/testing/replan_semantic_action_behavior.py index ffcf500023..a1df043287 100644 --- a/loopx/control_plane/testing/replan_semantic_action_behavior.py +++ b/loopx/control_plane/testing/replan_semantic_action_behavior.py @@ -498,6 +498,7 @@ def _build_fixture( "filesystem_write", ], runtime_profile="codex_app_heartbeat", + registry_path=os.path.relpath(global_registry_path, project_root), ) quota_guard_command = str(prompt["quota_guard_command"]) if quota_guard_command not in str(prompt["task_body"]): diff --git a/loopx/control_plane/testing/replan_vision_closeout_behavior.py b/loopx/control_plane/testing/replan_vision_closeout_behavior.py index 91273bf7f1..7fc2493a27 100644 --- a/loopx/control_plane/testing/replan_vision_closeout_behavior.py +++ b/loopx/control_plane/testing/replan_vision_closeout_behavior.py @@ -33,22 +33,27 @@ def __init__(self, code: str, detail: str = "") -> None: VISION_HOST_INSTRUCTION = ( "You are an agent working in an isolated LoopX project. Follow the heartbeat " "and current control-plane packet. " - "Call exactly one shell tool per response, and wait for its output before " - "choosing another call. Never batch the quota guard with a workspace read. " - "Use the shell normally to inspect evidence, " - "author your decision and execute the real CLI writeback and settlement. " - "The ordered interaction_contract.cli_channel.next_cli_actions are templates, " - "not literal commands: first read the named source, start the decision file " - "from vision_authoring.minimal_example. Add path_delta.outcome, " - "prior_assumption, observed_reality, one retained/changed/stopped item and " - "observed evidence_refs. Keep every prose string under 80 characters; cite " - "the source instead of pasting it. Omit other optional fields unless the " - "evidence requires them, and check the projected vision_authoring limits " - "before the first refresh-state. Replace the first " - "action's path placeholder with your file path. Correct a failed action " - "using its error instead of repeating it unchanged. After a successful " - "writeback, execute the returned settlement_owed.command once in the same " - "turn and verify the settlement readback." + "Call exactly one shell tool per response and wait for its output. Run the " + "quota guard by itself. For capture navigation, select /autonomous_replan_obligation, " + "/replan_action_packet and /interaction_contract/cli_channel using the supplied reader. " + "Follow the projected review command and review.next.writeback when present. " + "Read the work source named by the current uncovered frontier. " + "The staged writeback reply supplies writeback_contract.vision_authoring.fields " + "and writeback.next_cli_actions. For inline delivery use the original action " + "packet's authoring contract and interaction_contract.cli_channel.next_cli_actions. " + "Once you have the source and contract, author one project JSON draft in one shell call. " + "Use the supplied Vision schema version and a proposed vision_patch with acceptance_summary. " + "Write path_delta using its schema, outcome, prior_assumption, observed_reality, " + "one retained, changed or stopped item, and the evidence_id or source_ref actually read. " + "If required_fields includes agent_vision.stride_decision, use stride_control.minimal_example " + "for its shape and bind independent axes and minimum_evidence to this same source. " + "Keep prose strings under 80 characters and cite the observed source. " + "Add optional fields only when the evidence requires them. " + "Fill the projected refresh-state action with the draft path and keep the original Turn and obligation binding. " + "Run that CLI action directly so its exit status and output reach you. " + "Use a failed action's returned reason to read or revise the specific missing " + "field before retrying. After a successful writeback, execute the returned " + "settlement_owed.command once in the same Turn and verify the settlement readback." ) VISION_EXEC_TOOL_DESCRIPTION = ( diff --git a/loopx/control_plane/testing/scoped_gate_successor_tool_behavior.py b/loopx/control_plane/testing/scoped_gate_successor_tool_behavior.py index ef3b4b9d95..c837f1bf1c 100644 --- a/loopx/control_plane/testing/scoped_gate_successor_tool_behavior.py +++ b/loopx/control_plane/testing/scoped_gate_successor_tool_behavior.py @@ -183,6 +183,7 @@ def _build_scoped_gate_fixture(root: Path) -> _SelectedTodoToolFixture: registered_agents=[SELECTED_TODO_TOOL_FIXTURE_AGENT_ID], available_capabilities=["shell", "filesystem_read"], runtime_profile="codex_app_heartbeat", + registry_path=os.path.relpath(global_registry_path, project_root), ) quota_guard_command = str(prompt["quota_guard_command"]) if quota_guard_command not in str(prompt["task_body"]): @@ -294,6 +295,7 @@ def _receipt( **dict(contract or {}), "observed_tool_sequence": [step["kind"] for step in steps], "tool_call_count": len(steps), + "tool_call_limit": SCOPED_GATE_SUCCESSOR_TOOL_BEHAVIOR_MAX_CALLS, "non_blocking_notice_surfaced": notice_surfaced, "selected_action_matched_todo": selected_action_executed, "tool_call_receipts": steps, @@ -403,38 +405,9 @@ def receipt(*, passed: bool, failure_code: str | None) -> dict[str, Any]: ) for _ in range(SCOPED_GATE_SUCCESSOR_TOOL_BEHAVIOR_MAX_CALLS): - if selected_action_executed: - messages.append( - { - "role": "system", - "content": SCOPED_GATE_FINALIZATION_MESSAGE, - } - ) - final_content = self._client.next_final_content(messages) - notice_surfaced = _notice_intent(final_content) - return receipt( - passed=notice_surfaced, - failure_code=( - None - if notice_surfaced - else "non_blocking_notice_not_surfaced" - ), - ) assistant_step = self._client.next_step(messages) tool_call = assistant_step.tool_call if tool_call is None: - if selected_action_executed: - notice_surfaced = _notice_intent( - assistant_step.assistant_content - ) - return receipt( - passed=notice_surfaced, - failure_code=( - None - if notice_surfaced - else "non_blocking_notice_not_surfaced" - ), - ) return receipt( passed=False, failure_code=( @@ -547,5 +520,14 @@ def receipt(*, passed: bool, failure_code: str | None) -> dict[str, Any]: }, ] ) + if selected_action_executed: + # Finalization has no tools and is owed even when the selected + # read used the last permitted tool call. + messages.append({"role": "system", "content": SCOPED_GATE_FINALIZATION_MESSAGE}) + notice_surfaced = _notice_intent(self._client.next_final_content(messages)) + return receipt( + passed=notice_surfaced, + failure_code=None if notice_surfaced else "non_blocking_notice_not_surfaced", + ) return receipt(passed=False, failure_code="tool_call_budget_exhausted") diff --git a/loopx/control_plane/testing/selected_todo_tool_behavior.py b/loopx/control_plane/testing/selected_todo_tool_behavior.py index 32dede1e7c..896bea6bc3 100644 --- a/loopx/control_plane/testing/selected_todo_tool_behavior.py +++ b/loopx/control_plane/testing/selected_todo_tool_behavior.py @@ -268,6 +268,7 @@ def _build_fixture( registered_agents=[SELECTED_TODO_TOOL_FIXTURE_AGENT_ID], available_capabilities=["shell", "filesystem_read"], runtime_profile="codex_app_heartbeat", + registry_path=os.path.relpath(global_registry_path, project_root), ) quota_guard_command = str(prompt["quota_guard_command"]) if quota_guard_command not in str(prompt["task_body"]): diff --git a/loopx/control_plane/testing/terminal_settlement_tool_behavior.py b/loopx/control_plane/testing/terminal_settlement_tool_behavior.py index 160a925f46..2515a37491 100644 --- a/loopx/control_plane/testing/terminal_settlement_tool_behavior.py +++ b/loopx/control_plane/testing/terminal_settlement_tool_behavior.py @@ -30,6 +30,7 @@ argument_value, digest_text, execute_loopx_cli, + is_context_read_command, loopx_command_tokens, snapshot_disposable_fixture, ) @@ -112,6 +113,7 @@ class _ReentryQualificationState: settled_todo_ids: list[str] = field(default_factory=list) provider_call_count: int = 0 terminal_quota_observed: bool = False + terminal_quota_context: QuotaToolContext | None = None @dataclass @@ -292,6 +294,7 @@ def _build_fixture(root: Path) -> _TerminalSettlementFixture: "filesystem_write", ], runtime_profile="codex_app_heartbeat", + registry_path=os.path.relpath(global_registry_path, project_root), ) quota_guard_command = str(prompt["quota_guard_command"]) if quota_guard_command not in str(prompt["task_body"]): @@ -814,6 +817,7 @@ def _receipt( **dict(state.contract or {}), "observed_tool_sequence": [step["kind"] for step in state.steps], "tool_call_count": len(state.steps), + "tool_call_limit": TERMINAL_SETTLEMENT_TOOL_BEHAVIOR_MAX_CALLS, "settlement_receipt_steps": list(state.receipt_steps), "terminal_order_observed": bool( passed and state.receipt_steps == _EXPECTED_RECEIPT_STEPS @@ -1085,14 +1089,24 @@ def qualify( "settlement plan, including the validation step's " "command_condition. Ordinary Todo completion validates the " "deliverable before writeback/spend; it is distinct from " - "the final --no-follow-up closeout after spend. Preserve " + "the final --no-follow-up closeout after spend. When " + "ordinary completion is due, run it before authoring " + "any durable_writeback input. Use the resulting same-Turn " + "plan to decide which inputs are required. Preserve " "the projected Goal, Agent, Todo and Turn arguments. Fill " "the writeback placeholders from the proof's typed fields " "in the current same-Turn plan's durable_writeback command; execute the complete " "projected command, not a bare command name or help lookup." - " If a vision packet is needed, use the plan's " - "vision_authoring schema and author a project draft under " - "`.scratch/`. Draft shell calls are OS-isolated: original " + " Decide from the live writeback command and precondition " + "whether a draft is needed. vision_authoring describes the " + "available syntax. For material Vision closeout, adapt its " + "minimal_example with a current acceptance_summary, a top-level " + "path_delta and an evidence reference from the proof you read. " + "A follow-up decision does not supply scope coverage. If choosing " + "typed no_followup, include the coverage fields named in the plan. " + "Write any needed draft in one tool call under the shell's existing " + "`.scratch/` directory. Correct rejected inputs using the CLI feedback. " + "Draft shell calls are OS-isolated: original " "inputs, authority stores, private host data and network " "access are protected. Run every LoopX command directly, " "one per tool call; draft shells cannot invoke LoopX." @@ -1196,7 +1210,9 @@ def qualify_reentry( "projected Todo lifecycle settlement with a concise " "public-safe note; do not repeat refresh-state, inspect the " "workspace, spend quota, or invent filler work. Then run " - "the projected quota should-run command. When it reports " + "the projected quota should-run command. If it returns a " + "context reference, use its recovery reader with " + "--select /should_run. When the tool reports " "should_run=false, return a concise final response without " "another tool call." ), @@ -1277,9 +1293,24 @@ def receipt(*, passed: bool, failure_code: str | None) -> dict[str, Any]: try: if state.terminal_quota_observed: raise _TerminalToolViolation("model_called_tool_after_terminal_quota") - if command_kind == "refresh_state": + if state.terminal_quota_context is not None: + if not is_context_read_command(tool_call.command): + raise _TerminalToolViolation("terminal_quota_read_required") + output = state.terminal_quota_context.read( + tool_call.command, source_root=fixture.source_root, + project_root=fixture.project_root, + ) + page = json.loads(output) + if not (page.get("ok") is True + and page.get("schema_version") == "context_capture_selection_page_v1" + and json.loads(page["content"]).get("selections") + == [{"pointer": "/should_run", "value": False}]): + raise _TerminalToolViolation("terminal_quota_read_incomplete") + state.terminal_quota_observed = True + kind = "context_read" + elif command_kind == "refresh_state": raise _TerminalToolViolation("repeated_refresh_state") - if command_kind == "todo" and command_action == "complete": + elif command_kind == "todo" and command_action == "complete": if not tokens: raise _TerminalToolViolation("lifecycle_settlement_command_malformed") todo_id = argument_value(tokens, "--todo-id") or "" @@ -1337,11 +1368,15 @@ def receipt(*, passed: bool, failure_code: str | None) -> dict[str, Any]: state=state, accepted_return_codes=(0, 1), ) - if payload.get("should_run") is not False: + quota_context = QuotaToolContext.from_output( + json.dumps(payload, ensure_ascii=False, sort_keys=True) + ) + if quota_context.packet.get("should_run") is not False: raise _TerminalToolViolation("quota_reentry_did_not_terminate") - state.terminal_quota_observed = True + state.terminal_quota_context = quota_context + state.terminal_quota_observed = quota_context.capture_path is None kind = "terminal_quota_reentry" - output = json.dumps(payload, ensure_ascii=False, sort_keys=True) + output = quota_context.output elif command_kind == "todo" and command_action == "add": raise _TerminalToolViolation("invented_lifecycle_successor") elif command_kind == "quota" and command_action == "spend-slot": diff --git a/loopx/control_plane/turn_driver/decision_capture.py b/loopx/control_plane/turn_driver/decision_capture.py index 75bac0a572..aeeb970ec3 100644 --- a/loopx/control_plane/turn_driver/decision_capture.py +++ b/loopx/control_plane/turn_driver/decision_capture.py @@ -656,7 +656,7 @@ def context_selection_view(payload: dict[str, Any], *, select: list[str] | None, if plan.get("rejected_selection"): return plan, None, None presentation = initial_read if present_goal_narrative is None else present_goal_narrative - def render(plan: dict[str, Any], present: bool): + def render(plan: dict[str, Any], present: bool) -> tuple[bytes, str, list[str]]: raw, displayed = _context_selection_bytes(payload, plan["selections"], list_fields=list_fields, list_depth=list_depth, present_goal_narrative=present) return raw, hashlib.sha256(raw).hexdigest(), displayed diff --git a/loopx/control_plane/work_items/execution_preparation.ts b/loopx/control_plane/work_items/execution_preparation.ts index 8105338135..1a932b809a 100644 --- a/loopx/control_plane/work_items/execution_preparation.ts +++ b/loopx/control_plane/work_items/execution_preparation.ts @@ -1,4 +1,5 @@ -import {EXECUTION_HANDOFF_MODES} from "../coordination/handoff_mode_vocabulary.ts"; +import {HANDOFF_MODES} from "../coordination/handoff_mode_vocabulary.ts"; +import {HANDOFF_MODE_RETIRED_REASON, retiredHandoffModeRejection} from "../coordination/handoff_mode_policy.ts"; import type {JsonObject} from "../effect_program.ts"; import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; import { @@ -55,7 +56,7 @@ export function projectExecutionPreparation(value: unknown): JsonObject { } const goalId = requireNonEmptyString(request.goal_id, "goal_id"); const agentId = requireNonEmptyString(request.agent_id, "agent_id"); - const handoffMode = requireStringLiteral(request.handoff_mode, EXECUTION_HANDOFF_MODES, "handoff_mode"); + const handoffMode = requireStringLiteral(request.handoff_mode, HANDOFF_MODES, "handoff_mode"); const authorityAvailable = requireBoolean(request.authority_available, "authority_available"); const selected = requireJsonObject(request.selected_todo, "selected_todo"); const todoId = requireNonEmptyString(selected.todo_id, "selected_todo.todo_id"); @@ -78,6 +79,11 @@ export function projectExecutionPreparation(value: unknown): JsonObject { reason_code: "canonical_authority_unavailable", reason: "Canonical Todo and lease authority is unavailable; do not start delivery work."}; } + const retired = retiredHandoffModeRejection(handoffMode); + if (retired) { + return {...base, state: "blocked_by_conflict", delivery_allowed: false, + reason_code: retired, reason: HANDOFF_MODE_RETIRED_REASON}; + } if (status !== "open") { return {...base, state: "blocked_by_conflict", delivery_allowed: false, reason_code: "selected_todo_not_open", diff --git a/loopx/control_plane/work_items/interaction_contract.py b/loopx/control_plane/work_items/interaction_contract.py index 5c7869c37a..0bd65fb020 100644 --- a/loopx/control_plane/work_items/interaction_contract.py +++ b/loopx/control_plane/work_items/interaction_contract.py @@ -26,6 +26,8 @@ from ..quota.spend_sources import ( build_quota_spend_action, host_goal_turn_reentry_action, + host_goal_turn_reentry_required, + host_goal_turn_start_args, quota_spend_source_for_execution_context, ) from ..scheduler.arbitration import SchedulerArbitration, _scheduler_arbitration_facts @@ -33,6 +35,7 @@ APP_HEARTBEAT_SETTLEMENT_RUNTIME_PROFILES, SchedulerExecutionContextResolution, render_scheduler_execution_args, + resolve_scheduler_execution_context, scheduler_runtime_profile_for_execution_context, ) from ..todos.contract import ( @@ -725,6 +728,8 @@ def _admitted_interaction_next_cli_actions( registry_path=registry_path, ) if selection_command_template: + if turn_instance_id is None: + selection_command_template += host_goal_turn_start_args(scheduler_execution_context) return [selection_command_template + _goal_ref_cli_arg(payload)] goal_ref_arg = _goal_ref_cli_arg(payload) typed_quota_guard = ( @@ -1304,6 +1309,7 @@ def _build_interaction_cli_channel( *, mode: str, selection_recovery_required: bool, + turn_binding_required: bool, spend_after_validation: bool, available_capabilities: Any = None, scheduler_execution_context: ( @@ -1354,6 +1360,14 @@ def _build_interaction_cli_channel( spend_after_validation=spend_after_selection, ), } + if turn_binding_required: + return { + "turn_binding_required": True, + "next_cli_actions": channel["next_cli_actions"], + "spend_allowed_now": False, + "spend_after_validation": False, + "spend_policy": "bind this Goal's work Turn through the projected guard before work or settlement", + } work_lane = ( cast(Mapping[str, Any], payload.get("work_lane_contract")) if isinstance(payload.get("work_lane_contract"), Mapping) @@ -1662,6 +1676,16 @@ def _build_interaction_contract_projection( selection_recovery_required = selection.action_selection_needs_recovery( payload, agent_must_attempt=must_attempt, agent_delivery_allowed=delivery_allowed, ) + turn_binding_required = ( + must_attempt + and not selection_recovery_required + and not selection.action_portfolio_requires_explicit_selection(payload) + # External Turn planning has its own launch and settlement gates. + and not resolve_scheduler_execution_context(scheduler_execution_context).turn_driver_owned + and host_goal_turn_reentry_required( + payload, scheduler_execution_context, turn_instance_id, + ) + ) spend_after_validation = ( _interaction_spend_after_validation(mode) and todo_lifecycle_settlement_obligation(payload) is None @@ -1723,6 +1747,7 @@ def _build_interaction_contract_projection( mode=mode, spend_after_validation=spend_after_validation, selection_recovery_required=selection_recovery_required, + turn_binding_required=turn_binding_required, available_capabilities=available_capabilities, scheduler_execution_context=scheduler_execution_context, capability_reentry=capability_reentry, @@ -1731,7 +1756,12 @@ def _build_interaction_contract_projection( registry_path=registry_path, ), } - attach_interaction_execution_preparation(contract, payload) + if turn_binding_required: + agent_channel["delivery_allowed"] = False + agent_channel["primary_action"] = contract["cli_channel"]["next_cli_actions"][0] + agent_channel.pop("next_task_action", None) + else: + attach_interaction_execution_preparation(contract, payload) response_plan = _build_interaction_response_plan( user_channel=user_channel, agent_channel=agent_channel, @@ -1742,7 +1772,8 @@ def _build_interaction_contract_projection( payload, contract, runtime_root=runtime_root, registry_path=registry_path, agent_scope_frontier_actions=agent_scope_frontier_actions) _attach_interaction_required_reads(contract, required_reads) - _attach_interaction_post_writeback_actions(contract, payload) + if not turn_binding_required: + _attach_interaction_post_writeback_actions(contract, payload) _attach_interaction_vision_continuation_audit(contract, payload) _attach_interaction_vision_wait_state(contract, payload) if _interaction_fallback_policy_required(payload, mode=mode): diff --git a/loopx/control_plane/work_items/replan_semantics.ts b/loopx/control_plane/work_items/replan_semantics.ts index e4279848a9..b1b11b102d 100644 --- a/loopx/control_plane/work_items/replan_semantics.ts +++ b/loopx/control_plane/work_items/replan_semantics.ts @@ -335,7 +335,7 @@ export function requiredSemanticOutcomes(obligation: JsonObject): SemanticOutcom ? ["fresh_vision_path_outcome", ...PROGRESS_OUTCOMES] : [...PROGRESS_OUTCOMES]; } -function writebackProjection(required: SemanticOutcome[], externalReview: boolean): JsonObject { +function writebackProjection(required: SemanticOutcome[], externalReview: boolean, strideEnabled: boolean): JsonObject { // This chooses a usable refresh path, not the only legal exit. Successor and // terminal alternatives still have their own typed transitions and gates. if (externalReview) { @@ -358,7 +358,8 @@ function writebackProjection(required: SemanticOutcome[], externalReview: boolea writeback_contract: { preferred_input: "evidence_linked_vision_path", vision_authoring: visionAuthoringContract(true), - required_fields: ["vision_patch.acceptance_summary", "path_delta.outcome", "path_delta.evidence_refs"], + required_fields: ["vision_patch.acceptance_summary", "path_delta.outcome", "path_delta.evidence_refs", + ...(strideEnabled ? ["agent_vision.stride_decision"] : [])], path_outcomes: [...FRESH_PATH_DISPOSITIONS], rule: "First reuse observed evidence valid for current source/acceptance; probe missing, stale or insufficient evidence. Write a JSON file with acceptance summary and evidence-linked path outcome, then run bound refresh and spend. An unchanged reason alone is insufficient. Explicit validation gates and other required_any_of contracts apply.", }, @@ -473,7 +474,7 @@ export function projectReplanSemantics(value: unknown): JsonObject { const externalReview = isExternalReview(obligation); if (request.operation === "requirements") { const strideControl = strideControlContract(object(obligation.replan_context), obligation.obligation_id); - const writeback = writebackProjection(required, externalReview); + const writeback = writebackProjection(required, externalReview, strideControl !== null); if (requiresStrideDecision(obligation)) { const contract = object(writeback.writeback_contract); writeback.cli_semantic_args = VISION_CLI_ARGS; diff --git a/loopx/presentation/background_services_api.py b/loopx/presentation/background_services_api.py index f9c8778339..d44b2a1793 100644 --- a/loopx/presentation/background_services_api.py +++ b/loopx/presentation/background_services_api.py @@ -39,7 +39,11 @@ def register_chat_background_services(server) -> None: def close_chat_background_services(server) -> None: - server.background_services.close() + # A failed socket bind closes the server before its background mixin finishes. + services = getattr(server, "background_services", None) + if services is None: + return + services.close() # Directly constructed HTTP fixtures/embedders can still supply old owner # attributes without registering. Close them once through the same adapter. for attribute in ("codex_account_service", "manager_return_service", "lark_goal_topic_runtime"): diff --git a/loopx/semantics/vocabulary_v0.json b/loopx/semantics/vocabulary_v0.json index a323c15546..f4e04d71cc 100644 --- a/loopx/semantics/vocabulary_v0.json +++ b/loopx/semantics/vocabulary_v0.json @@ -1191,7 +1191,7 @@ "same_runtime_forks": 1, "same_runtime_fork_definitions": 3, "conflicting_values": 16, - "conflicting_definitions": 54, + "conflicting_definitions": 52, "schema_version_same_runtime_forks": 0, "multi_value_twins": 8, "multi_value_forks": 2, diff --git a/man/loopx.1 b/man/loopx.1 index c263960141..fe854fe8d1 100644 --- a/man/loopx.1 +++ b/man/loopx.1 @@ -1,4 +1,4 @@ -.TH LOOPX 1 "" "LoopX 2.2.0" "User Commands" +.TH LOOPX 1 "" "LoopX 2.3.0" "User Commands" .SH NAME loopx \- control-plane helper for long-running agent work .SH SYNOPSIS @@ -258,18 +258,12 @@ Compose provider\-neutral bounded review packets and bind exact decisions. \fBloopx periodic\-report\fR Resolve a portable weekly profile, evaluate report triggers, and compose provider\-neutral run receipts. .TP -\fBloopx auto\-research\fR -Project public\-safe research frontiers. -.TP \fBloopx deepresearch\fR Run a bounded, citation\-auditable research ledger and report workflow. .TP \fBloopx agent\-turn\-recall\fR Prepare goal\-scoped memory guidance for one admitted autonomous agent turn. .TP -\fBloopx multi\-agent\fR -Launch visible role\-scoped Codex TUI agents. -.TP \fBloopx canary\fR Plan or run catalog\-informed smoke profiles. .TP diff --git a/pyproject.toml b/pyproject.toml index 10eaf4049f..b128a7989d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "loopx" -version = "2.2.0" +version = "2.3.0" description = "A Loop Engineering control plane for long-running agent goals (requires Node.js 22.22.3+)." readme = "README.md" requires-python = ">=3.11" diff --git a/scripts/desktop_update_feed.py b/scripts/desktop_update_feed.py index e86044d358..fee53cca1a 100644 --- a/scripts/desktop_update_feed.py +++ b/scripts/desktop_update_feed.py @@ -15,12 +15,10 @@ # Publish only platforms with a qualified signed update artifact for the # channel. Windows NSIS updates ship on the main channel first; the stable # channel keeps manual Windows installs until that rollout is approved. -# Keys follow the pinned updater's `{os}-{arch}-{installer}` lookup in -# tauri-plugin-updater 2.11.0, whose installer component comes from the -# bundle-type marker the NSIS install carries. Do not add the bare -# `windows-x86_64` fallback key until MSI installs are qualified: the updater -# consults that key for every Windows installer kind, so it would offer the -# NSIS installer to MSI installs as well. +# Keys follow the updater's `{os}-{arch}-{installer}` lookup. The installer +# component comes from the bundle-type marker the NSIS install carries. +# Publish only the NSIS-specific Windows key until MSI installs are qualified. +# The `windows-x86_64` fallback matches every Windows installer kind. QUALIFIED_PLATFORMS = { "main": ( ("darwin-aarch64", ".app.tar.gz"), diff --git a/tests/architecture/test_content_digest_single_owner.py b/tests/architecture/test_content_digest_single_owner.py index 59f425c41e..023c450263 100644 --- a/tests/architecture/test_content_digest_single_owner.py +++ b/tests/architecture/test_content_digest_single_owner.py @@ -177,10 +177,12 @@ "loopx.capabilities.periodic_report.cadence_journal", "loopx.capabilities.periodic_report.incremental", "loopx.capabilities.periodic_report.machine_defaults", + "loopx.capabilities.periodic_report.review", "loopx.capabilities.progress_review.receipt", "loopx.chat_action_normalization", + "loopx.chat_store", "loopx.capabilities.manager_context.chat_history", - "loopx.cli_commands.quota_capture", + "loopx.cli_commands.quota_context_reader", "loopx.cli_commands.update_artifact", "loopx.configuration_transaction", "loopx.control_plane.agents.execution_facts", diff --git a/tests/architecture/test_source_session_registry_denial.py b/tests/architecture/test_source_session_registry_denial.py index a6e0306716..459380b096 100644 --- a/tests/architecture/test_source_session_registry_denial.py +++ b/tests/architecture/test_source_session_registry_denial.py @@ -22,6 +22,7 @@ "loopx/bootstrap.py", "loopx/capabilities/manager_context/__init__.py", "loopx/capabilities/manager_context/roundtrip.py", + "loopx/capabilities/issue_fix/correction_settlement.py", "loopx/capabilities/native_chat/project_context.py", "loopx/claude_goal_mode/scripts/connect.py", "loopx/cli_runtime.py", @@ -53,6 +54,7 @@ "loopx/runtime.py": {"_archive_source_goal_refs"}, "loopx/capabilities/native_chat/project_context.py": {"coordination_runtime_root"}, "loopx/control_plane/goals/source_session_recreation.py": {"_canonical_runtime_root"}, + "loopx/capabilities/issue_fix/correction_settlement.py": {"_require_origin_registry"}, } diff --git a/tests/capabilities/test_anti_entropy_feedback.py b/tests/capabilities/test_anti_entropy_feedback.py index 73d5bd5b81..1e10eafdce 100644 --- a/tests/capabilities/test_anti_entropy_feedback.py +++ b/tests/capabilities/test_anti_entropy_feedback.py @@ -852,6 +852,7 @@ def test_heartbeat_should_run_routes_into_shared_turn_context( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """The heartbeat production path still calls the shared turn-context owner.""" + from loopx.capabilities import turn_context from loopx.cli_commands import quota_reward_memory captured: dict[str, object] = {} @@ -860,7 +861,7 @@ def fake_attach(payload: dict, **kwargs: object) -> None: captured["payload"] = payload captured["kwargs"] = kwargs - monkeypatch.setattr(quota_reward_memory, "attach_turn_context", fake_attach) + monkeypatch.setattr(turn_context, "attach_turn_context", fake_attach) payload = { "ok": True, "should_run": True, @@ -889,6 +890,7 @@ def test_heartbeat_without_should_run_does_not_route( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """The heartbeat routing guard is unchanged by this fix.""" + from loopx.capabilities import turn_context from loopx.cli_commands import quota_reward_memory called = False @@ -897,7 +899,7 @@ def fake_attach(payload: dict, **kwargs: object) -> None: nonlocal called called = True - monkeypatch.setattr(quota_reward_memory, "attach_turn_context", fake_attach) + monkeypatch.setattr(turn_context, "attach_turn_context", fake_attach) quota_reward_memory.attach_reward_memory_recall_after_should_run( {"ok": True, "should_run": False, "heartbeat_receipt": {"status": "committed"}}, quota_command="should-run", diff --git a/tests/capabilities/test_research_composition_gate.py b/tests/capabilities/test_research_composition_gate.py index b2b75e0ca5..f04377bb62 100644 --- a/tests/capabilities/test_research_composition_gate.py +++ b/tests/capabilities/test_research_composition_gate.py @@ -13,6 +13,7 @@ append_explore_result_event, build_explore_edge_event, build_explore_node_event, explore_result_log_path, ) from loopx.control_plane.work_items.semantic_replan_writeback import qualify_replan_writeback +from loopx.control_plane.testing.model_tool_behavior import QuotaToolContext from loopx.capabilities.explore.research_frontier import prepare_research_replan_evidence from tests.control_plane.canonical_authority_fixture import initialize_canonical_source_fixture @@ -64,6 +65,8 @@ def cli(*args: str, success: bool = True) -> dict: "--runtime-root", str(runtime), *args], capture_output=True, text=True, check=False, cwd=Path(__file__).resolve().parents[2]) assert (result.returncode == 0) is success, result.stdout + result.stderr + if args[:2] == ("quota", "should-run"): + return QuotaToolContext.from_output(result.stdout).packet return json.loads(result.stdout) return cli, log, runtime, registry, state @@ -329,6 +332,7 @@ def test_invalidated_original_turn_retires_with_no_spend_and_current_frontier_is original = cli("quota", "should-run", "--goal-id", GOAL, "--agent-id", AGENT, "--codex-app", "--turn-instance-id", "fixture-invalidated") packet = original["replan_action_packet"] + original_guard = original["heartbeat_receipt"]["semantic_replan_capability_guard"] if change == "input": append_explore_result_event(log, build_explore_node_event(goal_id=GOAL, node_id="a", title="Research a", status="open")) elif change == "scope": @@ -342,7 +346,7 @@ def test_invalidated_original_turn_retires_with_no_spend_and_current_frontier_is "--progress-result-class", "advanced", "--progress-evidence-id", "unrelated", success=False) contract = rejected["replan_transition"]["retirement_contract"] assert contract["blocking_todo_count"] == 0 - assert contract["original_guard"] == packet["capability_guard"] + assert contract["original_guard"] == original_guard progress = contract["progress_observation"] # This is a causal lifecycle exit, not a way to count progress or debit. invalid_progress = cli("refresh-state", *identity, "--classification", "fixture_false_progress", @@ -366,7 +370,7 @@ def test_invalidated_original_turn_retires_with_no_spend_and_current_frontier_is assert not any(receipt["step_kind"] == "quota_spend" for receipt in denied["settlement_result"]["receipts"]) replay = cli("quota", "should-run", "--goal-id", GOAL, "--agent-id", AGENT, "--turn-instance-id", "fixture-invalidated", "--codex-app") - assert replay["heartbeat_receipt"]["semantic_replan_capability_guard"] == packet["capability_guard"] + assert replay["heartbeat_receipt"]["semantic_replan_capability_guard"] == original_guard assert replay["effective_action"] == "heartbeat_settled_skip" fresh = cli("quota", "should-run", "--goal-id", GOAL, "--agent-id", AGENT, "--codex-app", "--turn-instance-id", "fixture-after-retirement") diff --git a/tests/cli_commands/test_quota_stall_context_reprojection.py b/tests/cli_commands/test_quota_stall_context_reprojection.py index 7803ff6b78..da10b21884 100644 --- a/tests/cli_commands/test_quota_stall_context_reprojection.py +++ b/tests/cli_commands/test_quota_stall_context_reprojection.py @@ -88,7 +88,7 @@ def produce(): monkeypatch.setattr(command, "spend_quota_slot", spend) args = build_parser().parse_args([ "--format", "json", "quota", "should-run", "--goal-id", goal, - "--turn-instance-id", turn, + "--turn-instance-id", turn, "--context-delivery", "off", ]) capture = tmp_path / "capture" capture.mkdir() diff --git a/tests/control_plane/test_acceptance_restoration.py b/tests/control_plane/test_acceptance_restoration.py index 523b027ef0..092bb81a64 100644 --- a/tests/control_plane/test_acceptance_restoration.py +++ b/tests/control_plane/test_acceptance_restoration.py @@ -12,6 +12,7 @@ read_canonical_todos_if_promoted, read_canonical_todo_fields_if_promoted, ) from loopx.control_plane.goals.goal_frontier.acceptance import acceptance_gaps_from_held_goal_binding +from loopx.control_plane.testing.model_tool_behavior import QuotaToolContext @pytest.mark.parametrize("provider", ["file", "sqlite"]) @@ -59,6 +60,8 @@ def cli(*args, expected=0): "--format", "json", *args, "--goal-id", goal], cwd=delivery, capture_output=True, text=True, timeout=60) assert proc.returncode == expected, proc.stdout + proc.stderr + if args[:2] == ("quota", "should-run"): + return QuotaToolContext.from_output(proc.stdout).packet return json.loads(proc.stdout) def inspect(): diff --git a/tests/control_plane/test_actual_default_model_behavior_portfolio.py b/tests/control_plane/test_actual_default_model_behavior_portfolio.py index 94d89b0425..c11a03573e 100644 --- a/tests/control_plane/test_actual_default_model_behavior_portfolio.py +++ b/tests/control_plane/test_actual_default_model_behavior_portfolio.py @@ -416,8 +416,8 @@ def replan_actor(run_id: str) -> dict[str, Any]: "tool_call_count": 40 if failed else 16, "tool_call_limit": 40, "tool_call_receipts": [ - {"error_code": "shell_nonzero", "raw_command": "never-publish-this-command"}, - {"error_code": "private/path", "raw_command": "never-publish-this-command"}, + {"kind": "shell", "error_code": "shell_nonzero", "raw_command": "never-publish-this-command"}, + {"kind": "private/command", "error_code": "private/path", "raw_command": "never-publish-this-command"}, ], } @@ -435,15 +435,18 @@ def unused(_: Any) -> dict[str, Any]: assert scenario["repeat_diagnostics"] == [ {"repeat": 1, "actor_passed": False, "failure_code": "tool_call_budget_exhausted", "tool_call_count": 40, "tool_call_limit": 40, + "observed_tool_sequence": ["shell", "unclassified"], "tool_error_counts": {"shell_nonzero": 1, "unclassified": 1}, "tool_errors_truncated": False}, {"repeat": 2, "actor_passed": True, "failure_code": None, "tool_call_count": 16, "tool_call_limit": 40, + "observed_tool_sequence": ["shell", "unclassified"], "tool_error_counts": {"shell_nonzero": 1, "unclassified": 1}, "tool_errors_truncated": False}, ] assert "never-publish-this-command" not in json.dumps(scenario) assert "private/path" not in json.dumps(scenario) + assert "private/command" not in json.dumps(scenario) def _capability_monitor_repair_actor(_: str) -> dict[str, Any]: diff --git a/tests/control_plane/test_advancement_frontier_classification.py b/tests/control_plane/test_advancement_frontier_classification.py index 5f0657538c..050f281474 100644 --- a/tests/control_plane/test_advancement_frontier_classification.py +++ b/tests/control_plane/test_advancement_frontier_classification.py @@ -1,6 +1,8 @@ """Read compatibility at the live Goal-frontier import boundary.""" import pytest + +from tests.control_plane.codex_checkpoint_fixture import read_cli_policy_payload import json import subprocess import sys @@ -129,7 +131,7 @@ def test_real_quota_frontier_reads_canonical_claims(tmp_path, monkeypatch, provi "--format", "json", "quota", "should-run", "--goal-id", "goal-a", "--agent-id", "agent-a", "--runtime-profile", "generic_cli"], text=True, capture_output=True, timeout=60) - packet = json.loads(result.stdout) + packet = read_cli_policy_payload(result.stdout, returncode=result.returncode) assert result.returncode == 0, (packet.get("error"), packet.get("reason"), packet.get("status_health_ok")) assert packet["goal_frontier_projection"]["remaining_advancement_frontier"] == { "current_agent_claimed_advancement_count": 1, "unclaimed_advancement_count": 1, diff --git a/tests/control_plane/test_agent_scope_frontier_contract.py b/tests/control_plane/test_agent_scope_frontier_contract.py index 35bdba024d..f2ec13adba 100644 --- a/tests/control_plane/test_agent_scope_frontier_contract.py +++ b/tests/control_plane/test_agent_scope_frontier_contract.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json from pathlib import Path import subprocess import sys @@ -23,6 +22,7 @@ quota_status_payload, quota_todo_item, ) +from loopx.control_plane.testing.model_tool_behavior import QuotaToolContext from loopx.control_plane.work_items.goal_route_hint import build_goal_route_hint from loopx.quota import build_quota_should_run, render_quota_should_run_markdown @@ -196,7 +196,7 @@ def test_cli_should_run_reads_disposable_state_and_emits_v1(tmp_path): text=True, check=True, ) - guard = json.loads(result.stdout) + guard = QuotaToolContext.from_output(result.stdout).packet assert guard["effective_action"] == "reassignment_required" assert guard["should_run"] is False assert guard["agent_scope_frontier"]["schema_version"] == "agent_scope_frontier_v1" diff --git a/tests/control_plane/test_benchmark_current_admission.py b/tests/control_plane/test_benchmark_current_admission.py index 8f42f12fce..62740ffc74 100644 --- a/tests/control_plane/test_benchmark_current_admission.py +++ b/tests/control_plane/test_benchmark_current_admission.py @@ -48,6 +48,9 @@ def _environment(tmp_path, monkeypatch, provider, *, replan=False): 'objective: "Plan one concrete task from the current objective."\n---\n\n' "# Active Goal State\n\n## Objective\n\nPlan one concrete task.\n\n" "## Agent Todo\n\n## Completed Work Archive\n") + registry = json.loads(source.read_text()) + registry["goals"][0]["status"] = "active" + source.write_text(json.dumps(registry)) elif replan == "todo": fixture._configure_selected_todo_replan_fixture(project, source) elif replan: @@ -165,23 +168,48 @@ def test_initial_benchmark_planner_can_launch_without_a_prior_heartbeat_receipt( source_code = r''' import json, os, subprocess, sys from pathlib import Path +print(json.dumps({"type":"thread.started","thread_id":"initial-planning-session"}),flush=True) packet = json.loads(sys.stdin.read().split("Host-supplied planning checkpoint:\n",1)[1]) assert packet["runnable_todo_ids"] == [] -command = [os.environ["LOOPX_CLI"],"--format","json","--registry",os.environ["LOOPX_REGISTRY"], - "--runtime-root",os.environ["LOOPX_RUNTIME_ROOT"],"todo","add","--goal-id",os.environ["LOOPX_GOAL_ID"], - "--role","agent","--claimed-by",os.environ["LOOPX_AGENT_ID"],"--text","[P1] Validate the planned inputs.", - "--task-class","advancement_task","--action-kind","validate","--execute"] -added = json.loads(subprocess.run(command,check=True,capture_output=True,text=True).stdout) +base = [os.environ["LOOPX_CLI"], "--format", "json", "--registry", os.environ["LOOPX_REGISTRY"], + "--runtime-root", os.environ["LOOPX_RUNTIME_ROOT"]] +goal = ["--goal-id", os.environ["LOOPX_GOAL_ID"]] +agent = ["--agent-id", os.environ["LOOPX_AGENT_ID"]] + +def cli(*args): + result = subprocess.run([*base, *args], capture_output=True, text=True) + assert result.returncode == 0, result.stdout or result.stderr + return json.loads(result.stdout) + +def refine(document, revision, operation): + path = Path("requirements.json").resolve() + path.write_text(json.dumps(document)) + cli("goal-acceptance", "refine", *goal, *agent, "--document", str(path), + "--expected-provider-revision", revision, "--operation-id", operation, "--execute") + +outcome = "verify-inputs" +refine({"outcome_id": outcome, "owner_agent_id": os.environ["LOOPX_AGENT_ID"], + "objective": "Validate the planned inputs.", + "requirements": [{"id": "preserve-inputs", "description": "Read the inputs without changing them."}], + "source": {"ref": "benchmark-task", "summary": "Validate the planned inputs."}}, + packet["goal_acceptance"]["provider_revision"], "declare-planned-inputs") +added = cli("todo", "add", *goal, "--role", "agent", "--claimed-by", os.environ["LOOPX_AGENT_ID"], + "--text", "[P1] Validate the planned inputs.", "--task-class", "advancement_task", + "--action-kind", "validate", "--execute") +current = cli("goal-acceptance", "inspect", *goal, *agent) +refine({"outcome_id": outcome, "todo_ids": [added["todo_id"]]}, + current["provider_revision"], "link-planned-inputs") result = {"input_digest":packet["input_digest"],"status":"ready","todo_ids":[added["todo_id"]]} Path(sys.argv[sys.argv.index("--output-last-message")+1]).write_text(json.dumps(result)) Path("initial-plan.json").write_text(json.dumps(result)) -print(json.dumps({"type":"thread.started","thread_id":"initial-planning-session"}),flush=True) ''' env["CODEX_BIN"] = str(write_python_host_fixture(project / "initial-planner", f"#!{sys.executable}\n"+source_code)) result = worker.run_once(env) - assert result["ok"] and result["planning"]["state_readback_verified"] is True, result + assert result["ok"], (Path(env["LOOPX_WAKE_LOG_DIR"]) / result["turn_id"] / "stderr.log").read_text() + assert result["planning"]["state_readback_verified"] is True planned = json.loads((project / "initial-plan.json").read_text()) assert len(planned["todo_ids"]) == 1 and result["planning"]["todo_ids"] == planned["todo_ids"] + assert result["planning"]["goal_requirements_readback"]["linked_todo_ids"] == planned["todo_ids"] assert not fixture._heartbeat_receipt_events(runtime, result["turn_id"]) assert fixture._spend_run_count(runtime) == 0 @@ -216,10 +244,13 @@ def unexpected_scan(*args, **kwargs): assert json.loads(capsys.readouterr().out)["contract"]["ok"] is True args = [*base, "quota", "should-run", "--goal-id", fixture.GOAL_ID, "--agent-id", fixture.AGENT_ID, "--runtime-profile", "generic_cli", - "--turn-instance-id", "private-workspace-turn", ] + "--context-delivery", "off", "--turn-instance-id", "private-workspace-turn", + "--todo-id", fixture.TODO_ID, + "--available-capability", "shell", "--available-capability", "filesystem_write"] assert main(args) == 0 original = json.loads(capsys.readouterr().out) events = fixture._heartbeat_receipt_events(runtime, "private-workspace-turn") + assert events and original["heartbeat_receipt"]["event_id"] == events[-1]["event_id"] for held in (None, original): current = turn_decision.current_heartbeat_host_launch_decision( registry_path=source, runtime_root=runtime, project=project, diff --git a/tests/control_plane/test_bound_owner_web_input.py b/tests/control_plane/test_bound_owner_web_input.py index b39e5d98ac..133b78477b 100644 --- a/tests/control_plane/test_bound_owner_web_input.py +++ b/tests/control_plane/test_bound_owner_web_input.py @@ -273,7 +273,7 @@ def test_http_bound_steward_keeps_web_provenance_at_coordination_root(bound_chat external_source_policy(store.root.parent, session, turn) -def test_http_web_handoff_reports_missing_provider_source_without_retargeting(bound_chat): +def test_http_web_handoff_reports_missing_mirror_receipt_without_retargeting(bound_chat): from types import SimpleNamespace from loopx.capabilities.manager_context import acknowledge from loopx.capabilities.manager_context.roundtrip import drain, report, reply_status @@ -314,7 +314,7 @@ def forbidden_provider(*_): drain(canonical, runtime.registry_path, store, LarkManagerReturnTransport(server, canonical)) delivery = reply_status(canonical, receipt)[0] assert delivery["status"] == "retry_pending" - assert delivery["error"] == "original_route_unavailable" + assert delivery["error"] == "initial_delivery_receipt_unavailable" returned = [row for row in store.messages(sid) if row.get("origin") == "manager_followup"] assert len(returned) == 1 and returned[0]["turn_id"] == turn["turn_id"] status, replay = post(session, {"message": message, "client_turn_id": "local-worker-request"}) diff --git a/tests/control_plane/test_canary_cli_transport.py b/tests/control_plane/test_canary_cli_transport.py index f63ecc835a..3f19d11328 100644 --- a/tests/control_plane/test_canary_cli_transport.py +++ b/tests/control_plane/test_canary_cli_transport.py @@ -16,17 +16,16 @@ @pytest.mark.parametrize("parent_setting", ["on", "off"]) -def test_machine_cli_child_is_inline_without_changing_parent_or_raw_helper(monkeypatch, tmp_path, parent_setting): +def test_machine_cli_children_are_inline_without_changing_parent(monkeypatch, tmp_path, parent_setting): monkeypatch.setenv("LOOPX_CONTEXT_DELIVERY", parent_setting) observed = [] def run(*args, **kwargs): - observed.append(kwargs.get("env")) + observed.append((kwargs.get("env") or {}).get("LOOPX_CONTEXT_DELIVERY")) return SimpleNamespace(stdout='{"ok":true,"unknown":null}', stderr="", returncode=0) monkeypatch.setattr(canary_harness.subprocess, "run", run) assert canary_harness.run_json_cli("probe", registry_path=tmp_path / "registry.json")["ok"] is True - assert observed[0]["LOOPX_CONTEXT_DELIVERY"] == "off" canary_harness.run_json_cli_result("probe", registry_path=tmp_path / "registry.json") - assert observed[1] is None # Raw-wire callers retain their own transport. + assert observed == ["off", "off"] assert os.environ["LOOPX_CONTEXT_DELIVERY"] == parent_setting diff --git a/tests/control_plane/test_canonical_handoff_mode.py b/tests/control_plane/test_canonical_handoff_mode.py index f071a92a1b..701b6eb990 100644 --- a/tests/control_plane/test_canonical_handoff_mode.py +++ b/tests/control_plane/test_canonical_handoff_mode.py @@ -8,6 +8,7 @@ from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.control_plane.testing.canary_harness import write_fixture_registry @pytest.fixture(params=[ @@ -288,6 +289,9 @@ def test_retired_policy_requires_release_and_reviewed_migration_before_execution from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted registry = runtime.parent / "registry.json" + write_fixture_registry(project=registry.parent, runtime_root=runtime, registry_path=registry, + goal_id="mode-goal", domain="software", adapter_kind="generic_project_goal_v0", + state_file=str(state), registered_agents=["agent-a", "agent-b"], quota_allowed_slots=None) todo = add_goal_todo(registry_path=registry, goal_id="mode-goal", role="agent", text="Preserve imported ownership", claimed_by="agent-a", task_class="advancement_task", note="Retain original metadata") @@ -328,6 +332,14 @@ def work(*args, expected_exit=0): assert inspected["active"] is False assert inspected["executor_constraint"]["reason"] == "handoff_mode_retired" assert inspected["lease"] == lease + code, guard = cli("should-run", "--agent-id", "agent-a", "--runtime-profile", "generic_cli", + "--context-delivery", "off", family="quota") + assert code == 0 and guard["status_health_ok"] is True, guard + assert guard["should_run"] is False and guard["normal_delivery_allowed"] is False + preparation = guard["execution_preparation"] + assert preparation["reason_code"] == "handoff_mode_retired" + assert preparation["handoff_mode"] == "legacy" and "claim" not in preparation + assert read() == before for args in [ ("todo", "claim", "--agent-id", "agent-a", "--claimed-by", "agent-a"), ("todo", "update", "--agent-id", "agent-a", "--note", "Unreviewed work"), diff --git a/tests/control_plane/test_canonical_todo_writer_isolation.py b/tests/control_plane/test_canonical_todo_writer_isolation.py index 5c1561c37e..1c6051c268 100644 --- a/tests/control_plane/test_canonical_todo_writer_isolation.py +++ b/tests/control_plane/test_canonical_todo_writer_isolation.py @@ -12,6 +12,8 @@ import pytest +from tests.control_plane.codex_checkpoint_fixture import read_cli_policy_payload + from canonical_authority_fixture import assert_todo_read_unchanged, isolate_sqlite_runtime, promoted_create_fixture from test_quota_authority_settlement_journey import _source import test_quota_settlement_cli as settlement @@ -301,7 +303,7 @@ def run(*args, succeeds=True): cwd=project, capture_output=True, text=True, timeout=45, ) assert (result.returncode == 0) is succeeds, result.stdout + result.stderr - return json.loads(result.stdout) + return read_cli_policy_payload(result.stdout, returncode=result.returncode) identity = ["--goal-id", settlement.GOAL_ID, "--agent-id", settlement.AGENT_ID, "--todo-id", settlement.TODO_ID] diff --git a/tests/control_plane/test_capability_gate_cli.py b/tests/control_plane/test_capability_gate_cli.py index 569bee2fe6..0fdcf92d5f 100644 --- a/tests/control_plane/test_capability_gate_cli.py +++ b/tests/control_plane/test_capability_gate_cli.py @@ -1,9 +1,6 @@ """Real quota reads preserve requirement/target semantics across authority sources.""" -import json - import pytest -from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime -from loopx.control_plane.coordination.runtime_shadow import build_runtime_shadow_source_snapshot +from canonical_authority_fixture import initialize_canonical_source_fixture, isolate_sqlite_runtime from loopx.control_plane.coordination.local_authority import CanonicalAuthorityRequired, read_canonical_todos_if_promoted from loopx.control_plane.testing.canary_harness import write_fixture_registry, run_json_cli_result @@ -28,13 +25,10 @@ def test_real_quota_capability_gate_does_not_enable_repair_outputs(tmp_path, mon domain="software", adapter_kind="generic_project_goal_v0", state_file=str(state), registered_agents=["agent-a"], quota_allowed_slots=None) if provider != "markdown": - goal = json.loads(registry.read_text())["goals"][0] - projection, _ = build_runtime_shadow_source_snapshot(goal=goal, runtime_root=runtime, - state_path=state, registry_path=registry) - initialize_canonical_authority(runtime, "goal-a", projection, state_path=state, provider=provider) + initialize_canonical_source_fixture(runtime, "goal-a", state_path=state, provider=provider) before = state.read_bytes() args = ["quota", "should-run", "--goal-id", "goal-a", "--agent-id", "agent-a", - "--include-detail", "agent-todos", ] + "--include-detail", "agent-todos", "--context-delivery", "off"] if network_available: args += ["--available-capability", "network"] if provider == "markdown": diff --git a/tests/control_plane/test_chat_collaboration_source_runtime.py b/tests/control_plane/test_chat_collaboration_source_runtime.py index b5712c8102..f9072cce74 100644 --- a/tests/control_plane/test_chat_collaboration_source_runtime.py +++ b/tests/control_plane/test_chat_collaboration_source_runtime.py @@ -93,7 +93,9 @@ def test_http_keeps_original_return_and_request_readback(returned): assert controller.coordination_runtime_root == root snapshot = read(session["session_id"]) result = next(m for m in snapshot["messages"] if m.get("origin") == "manager_followup") - assert result["text"].endswith("The checked result is ready.") + assert result["text"] == ( + f"The checked result is ready.\n\n协作回复 · analyst · 委托 {receipt['request_id'][:8]}" + ) assert result["return_delivery"]["status"] == "delivered" commitment = next(m for m in snapshot["messages"] if m.get("collaboration")) assert commitment["collaboration"]["request_id"] == receipt["request_id"] diff --git a/tests/control_plane/test_context_delivery_cli.py b/tests/control_plane/test_context_delivery_cli.py index af5808208c..b9819c3753 100644 --- a/tests/control_plane/test_context_delivery_cli.py +++ b/tests/control_plane/test_context_delivery_cli.py @@ -145,11 +145,11 @@ def test_reader_bounds_actual_json_stdout_and_preserves_every_byte(tmp_path, mon @pytest.mark.parametrize("limit,fmt", [(0, "json"), (-1, "json"), (True, "json"), (1.2, "json"), (7000, "markdown")]) def test_reader_rejects_invalid_output_budget_before_capture_io(monkeypatch, limit, fmt): import argparse - from loopx.cli_commands import quota_capture + from loopx.cli_commands import quota_context_reader def unexpected(*args, **kwargs): pytest.fail("invalid output budget must fail before capture IO") - monkeypatch.setattr(quota_capture, "_read_capture", unexpected) - result = quota_capture.read_context_capture(argparse.Namespace(capture_file="missing", capture_sha256="0" * 64, + monkeypatch.setattr(quota_context_reader, "_read_capture", unexpected) + result = quota_context_reader.read_context_capture(argparse.Namespace(capture_file="missing", capture_sha256="0" * 64, offset=0, page_bytes=8192, max_page_output_bytes=limit, format=fmt)) assert not result["ok"] and "positive integer" in result["reason"] @@ -181,9 +181,7 @@ def test_bounded_continuation_rechecks_original_capture_and_utf8_cursor(tmp_path capture = tmp_path / "original.json" raw = json.dumps({"requirements": "🙂" * 900}, ensure_ascii=False).encode() capture.write_bytes(raw) - # Complete recovery guidance and the pinned executable/path metadata already - # exceed 1600 bytes on source checkouts. Keep a bounded partial page without - # deleting guidance or changing any product/Host output limit. + # Leave room for the pinned continuation command and still require paging. limit = 2400 args = argparse.Namespace(capture_file=str(capture), capture_sha256=hashlib.sha256(raw).hexdigest(), offset=0, page_bytes=8192, max_page_output_bytes=limit, format="json") @@ -648,7 +646,7 @@ def consume(view): @pytest.mark.parametrize("native_failure", [None, "projection", "directory", "reader_reference"]) def test_native_projection_never_layers_host_projection(tmp_path, monkeypatch, native_failure): from loopx.cli import build_parser - from loopx.cli_commands import quota, quota_capture + from loopx.cli_commands import quota, quota_context_reader from loopx.control_plane.turn_driver import codex_context args = build_parser().parse_args(["--format", "json", "quota", "should-run", @@ -680,7 +678,7 @@ def unavailable_reference(*args, **kwargs): if native_failure == "directory": monkeypatch.setattr(codex_context, "codex_context_directory", unavailable_directory) elif native_failure == "reader_reference": - monkeypatch.setattr(quota_capture, "write_context_read_reference", unavailable_reference) + monkeypatch.setattr(quota_context_reader, "write_context_read_reference", unavailable_reference) printed = [] assert quota._emit_quota_result(original, args, context=None, registry_path=tmp_path / "absent.json", heartbeat_turn_id=None, usage_quota_started=0, capture_directory=tmp_path, diff --git a/tests/control_plane/test_context_first_read.py b/tests/control_plane/test_context_first_read.py index 79c617830e..5a49d8985f 100644 --- a/tests/control_plane/test_context_first_read.py +++ b/tests/control_plane/test_context_first_read.py @@ -86,6 +86,31 @@ def pinned_initial_view(payload, directory): initial_read_plan={"initial": True, "plan": plan, "capture": capture}) +def test_first_read_keeps_goal_turn_binding_ahead_of_work_context(tmp_path, decision): + interaction = decision["interaction_contract"] + action = "loopx quota should-run --runtime-profile codex_cli --turn-instance-id fixture-turn" + interaction["agent_channel"].update(primary_action=action, must_attempt=True, delivery_allowed=False) + interaction["cli_channel"] = { + "turn_binding_required": True, + "next_cli_actions": [action], + "spend_after_validation": False, + } + + view = pinned_initial_view(decision, tmp_path) + page = read_context_capture(Namespace( + capture_file=view["recovery"]["path"], capture_sha256=view["recovery"]["sha256"], + format="json", select=None, initial_read=True, offset=0, + page_bytes=64000, max_page_output_bytes=64000, + )) + assert page["ok"] and page["eof"] + values = selected_context_values(page["content"]) + assert values["/interaction_contract/cli_channel/turn_binding_required"] is True + assert values["/interaction_contract/cli_channel/next_cli_actions"] == [action] + assert list(values).index("/interaction_contract/cli_channel/next_cli_actions") < list(values).index( + "/interaction_contract/agent_channel/work_context/sources/0/content/text" + ) + + @pytest.mark.parametrize("required", [True, False]) def test_initial_read_delivers_explicit_selection_only_at_its_gate(tmp_path, decision, required): cli = decision["interaction_contract"]["cli_channel"] diff --git a/tests/control_plane/test_context_read_reference.py b/tests/control_plane/test_context_read_reference.py index 24a0b75134..02434f3e74 100644 --- a/tests/control_plane/test_context_read_reference.py +++ b/tests/control_plane/test_context_read_reference.py @@ -437,15 +437,17 @@ def test_native_selection_observer_rejects_unverified_or_discovery_only_reads(tm @pytest.mark.parametrize("selection", ["fields", "index", "initial"]) def test_selected_read_survives_unwritable_reference_metadata(tmp_path, monkeypatch, selection): import errno - from loopx.cli_commands import quota_capture + from loopx.cli_commands import quota_context_reader payload, machine, model = fixture(tmp_path) before = Path(machine["path"]).read_bytes() + blocked_paths = [] - def unavailable(*args, **kwargs): + def unavailable(path, *args, **kwargs): + blocked_paths.append(path) raise OSError(errno.ENOSPC, "synthetic metadata unavailable") - monkeypatch.setattr(quota_capture, "write_context_read_reference", unavailable) + monkeypatch.setattr(quota_context_reader, "write_context_read_reference", unavailable) options = {"fields": {"select": ["/selected_todo", "/criterion"]}, "index": {"list_fields": True}, "initial": {"initial_read": True}}[selection] page = read(model["command"], max_page_output_bytes=3500, page_bytes=3000, **options) @@ -467,6 +469,7 @@ def unavailable(*args, **kwargs): assert {item["pointer"] for item in values[0]["fields"]} == {"/" + key for key in payload} else: assert next(item["value"] for item in values if item["pointer"] == "/goal_boundary") == payload["goal_boundary"] + assert blocked_paths == [Path(machine["path"])] assert Path(machine["path"]).read_bytes() == before diff --git a/tests/control_plane/test_deferred_resume_generated_command.py b/tests/control_plane/test_deferred_resume_generated_command.py index 3183eef596..a6736f0156 100644 --- a/tests/control_plane/test_deferred_resume_generated_command.py +++ b/tests/control_plane/test_deferred_resume_generated_command.py @@ -208,6 +208,14 @@ def cli(*args: str) -> tuple[int, dict]: assert reentry_args[reentry_args.index("--turn-instance-id") + 1] == "resume-command-turn" assert reentry_args[reentry_args.index("--todo-id") + 1] == TODO_ID assert not any("refresh-state" in action or "spend-slot" in action for action in actions) + rc, waiting = cli(*reentry_args) + assert rc == 0 and not waiting["normal_delivery_allowed"], waiting + assert waiting["heartbeat_receipt"]["event_id"] == guard["heartbeat_receipt"]["event_id"] + preparation = waiting["execution_preparation"] + assert preparation["state"] == "claim_required" + rc, acquired = cli(*preparation["claim"]["cli_args"]) + assert rc == 0 and acquired["execution_managed"] is True, acquired + rc, reentry = cli(*reentry_args) assert rc == 0 and reentry["normal_delivery_allowed"], reentry assert ( @@ -229,19 +237,6 @@ def cli(*args: str) -> tuple[int, dict]: assert rc == 0 and cold["heartbeat_receipt"]["status"] == "replayed", cold assert cold["heartbeat_receipt"]["event_id"] == qualified["event_id"] assert load_rollout_events(log_path) == events - rc, acquired = cli( - "task-lease", - "acquire", - "--goal-id", - GOAL_ID, - "--todo-id", - TODO_ID, - "--owner", - AGENT_ID, - "--idempotency-key", - "fresh-after-resume", - ) - assert rc == 0 and acquired["lease"]["status"] == "active", acquired artifact = project / "verified-result.txt" artifact.write_text(str(17 * 23)) assert artifact.read_text() == "391" diff --git a/tests/control_plane/test_effect_runtime_restart.py b/tests/control_plane/test_effect_runtime_restart.py index a8af8b05e6..bb32db9c34 100644 --- a/tests/control_plane/test_effect_runtime_restart.py +++ b/tests/control_plane/test_effect_runtime_restart.py @@ -353,7 +353,7 @@ def unavailable(pid, timeout): assert effect_runtime.restart_effect_runtime()["status"] == "stopped" -@pytest.mark.skipif(os.name != "posix" or not hasattr(os, "WNOWAIT"), reason="POSIX child exit observation") +@pytest.mark.skipif(os.name != "posix", reason="POSIX child reaping") def test_namespace_shutdown_reaps_exited_child_before_disposal(tmp_path, monkeypatch): child = subprocess.Popen( [sys.executable, "-c", "import sys; sys.stdin.read()"], @@ -365,10 +365,9 @@ def test_namespace_shutdown_reaps_exited_child_before_disposal(tmp_path, monkeyp def shutdown(_info, **kwargs): requests.append(kwargs["method"]) assert effect_runtime._pid_is_alive(child.pid) + # Signal real exit without polling or reaping in the fixture. The + # shutdown caller must observe exit and reap this child itself. child.stdin.close() - # Wait for the shutdown response's real exit without reaping the child. - os.waitid(os.P_PID, child.pid, os.WEXITED | os.WNOWAIT) - assert effect_runtime._pid_is_alive(child.pid) return {} monkeypatch.setattr(effect_runtime, "_request_with_info", shutdown) diff --git a/tests/control_plane/test_effect_turn_live_quota_decision.py b/tests/control_plane/test_effect_turn_live_quota_decision.py index c70e1c7ca1..969d09b246 100644 --- a/tests/control_plane/test_effect_turn_live_quota_decision.py +++ b/tests/control_plane/test_effect_turn_live_quota_decision.py @@ -1498,6 +1498,17 @@ def test_retained_selection_reentry_refreshes_provider_todos_before_replan( } for index in range(20) ] + # The refreshed list supplies the frontier. The real canonical owner + # independently validates delivery eligibility for the selected Todo. + runtime_root = tmp_path / "runtime" + state_path = tmp_path / "ACTIVE_GOAL_STATE.md" + state_path.write_text("# Goal\n\n## Agent Todo\n\n" + "".join( + f"- [ ] {item['text']}\n" + f" \n" + for item in fresh_items + )) + initialize_canonical_source_fixture(runtime_root, GOAL_ID, state_path=state_path) from loopx.control_plane.testing.quota_fixtures import quota_todo_summary fresh_agent_todos = quota_todo_summary( @@ -1509,9 +1520,12 @@ def test_retained_selection_reentry_refreshes_provider_todos_before_replan( reads: list[str] = [] def fresh_todos(**_kwargs: object) -> dict[str, object]: + if _kwargs.get("role") == "user": + return {"ok": True, "source": "file_v0", "todos": []} reads.append("provider") return { "ok": True, + "source": "file_v0", "todos": fresh_items, "agent_todos": fresh_agent_todos, "user_todos": fresh_user_todos, @@ -1529,7 +1543,7 @@ def fresh_todos(**_kwargs: object) -> dict[str, object]: include_scheduler_detail=False, codex_app_current_rrule=None, registry_path=tmp_path / "registry.json", - runtime_root=tmp_path / "runtime", + runtime_root=runtime_root, retained_action_selection_todo_id=selected_todo_id, turn_instance_id="turn-provider-frontier-reentry", ) diff --git a/tests/control_plane/test_fallback_wait_admission.py b/tests/control_plane/test_fallback_wait_admission.py index 2c950b00ce..7695d7de07 100644 --- a/tests/control_plane/test_fallback_wait_admission.py +++ b/tests/control_plane/test_fallback_wait_admission.py @@ -18,6 +18,7 @@ from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted from loopx.control_plane.todos.todo_summary import TODO_ITEM_SCHEMA_VERSION +from tests.control_plane.codex_checkpoint_fixture import read_cli_policy_payload GOAL_ID = "fallback-wait-capacity-fixture" AGENT_ID = "worker" @@ -185,7 +186,7 @@ def test_real_cli_wait_admission_is_stable_across_compact_lane_capacity( ) assert cli_main(args) == 0 - result = json.loads(capsys.readouterr().out) + result = read_cli_policy_payload(capsys.readouterr().out, returncode=0) assert result["should_run"] is False assert result["execution_obligation"]["must_attempt_work"] is False assert "fallback_gaps" not in result["goal_frontier_projection"] @@ -214,7 +215,7 @@ def test_real_cli_missing_declared_fallback_projects_true_unresolved_gap( ) assert cli_main(args) == 0 - result = json.loads(capsys.readouterr().out) + result = read_cli_policy_payload(capsys.readouterr().out, returncode=0) gap = result["goal_frontier_projection"]["fallback_gaps"][0] assert gap["kind"] == "vision_fallback_unresolved" assert gap["unresolved_todo_ids"] == [FALLBACK_TODO_ID] @@ -239,7 +240,7 @@ def fail_authority_read(**_kwargs: object) -> dict: ) assert cli_main(args) == 0 - result = json.loads(capsys.readouterr().out) + result = read_cli_policy_payload(capsys.readouterr().out, returncode=0) gap = result["goal_frontier_projection"]["fallback_gaps"][0] assert gap["kind"] == "vision_fallback_lookup_uncertain" assert gap["lookup_uncertain_todo_ids"] == [FALLBACK_TODO_ID] @@ -257,7 +258,7 @@ def test_real_cli_uses_dependency_state_from_exact_canonical_read( ) assert cli_main(args) == 0 - result = json.loads(capsys.readouterr().out) + result = read_cli_policy_payload(capsys.readouterr().out, returncode=0) assert result["should_run"] is True assert result["selected_todo"]["todo_id"] in { PRIMARY_TODO_ID, @@ -310,7 +311,7 @@ def record_projection(method: str, params: dict) -> object: monkeypatch.setattr(fallback_source, "effect_runtime_result", record_projection) assert cli_main(args) == 0 - result = json.loads(capsys.readouterr().out) + result = read_cli_policy_payload(capsys.readouterr().out, returncode=0) assert result["should_run"] is False assert "fallback_gaps" not in result["goal_frontier_projection"] # The direct prerequisite is deferred regardless of its own dependency. @@ -335,7 +336,7 @@ def mismatched_read(**kwargs: object) -> list: monkeypatch.setattr(fallback_source, "read_fallback_source_snapshot", mismatched_read) assert cli_main(args) == 0 - result = json.loads(capsys.readouterr().out) + result = read_cli_policy_payload(capsys.readouterr().out, returncode=0) gap = result["goal_frontier_projection"]["fallback_gaps"][0] assert gap["kind"] == "vision_fallback_lookup_uncertain" assert gap["lookup_uncertain_todo_ids"] == [FALLBACK_TODO_ID] @@ -359,7 +360,7 @@ def unexpected_read(**_kwargs: object) -> dict: monkeypatch.setattr(fallback_source, "read_fallback_source_snapshot", unexpected_read) assert cli_main(args) == 0 - result = json.loads(capsys.readouterr().out) + result = read_cli_policy_payload(capsys.readouterr().out, returncode=0) assert result["should_run"] is False assert "fallback_gaps" not in result["goal_frontier_projection"] @@ -407,7 +408,7 @@ def test_real_cli_fallback_monitor_wait_requires_generation_condition( ) assert cli_main(args) == 0 - result = json.loads(capsys.readouterr().out) + result = read_cli_policy_payload(capsys.readouterr().out, returncode=0) gaps = result["goal_frontier_projection"].get("fallback_gaps", []) if expected_gap is None: assert gaps == [] @@ -491,7 +492,7 @@ def read_once(**kwargs: object) -> list[dict]: monkeypatch.setattr(fallback_source, "read_fallback_source_snapshot", read_once) code = cli_main(args) - result = json.loads(capsys.readouterr().out) + result = read_cli_policy_payload(capsys.readouterr().out, returncode=code) # Canonical Todos survive display loss; they do not replace the Goal's # intent and stop conditions. A missing Goal source must still deny work. assert code == (1 if display == "missing" else 0), result @@ -512,7 +513,7 @@ def read_once(**kwargs: object) -> list[dict]: state_file.write_bytes(goal_source) snapshots.clear() assert cli_main(args) == 0 - recovered = json.loads(capsys.readouterr().out) + recovered = read_cli_policy_payload(capsys.readouterr().out, returncode=0) assert recovered["status_health_ok"] is True assert "fallback_gaps" not in recovered["goal_frontier_projection"] assert snapshots == [len(rows)] diff --git a/tests/control_plane/test_final_outcome_diagnostics.py b/tests/control_plane/test_final_outcome_diagnostics.py index a710605d94..b73aeba4de 100644 --- a/tests/control_plane/test_final_outcome_diagnostics.py +++ b/tests/control_plane/test_final_outcome_diagnostics.py @@ -5,6 +5,8 @@ import pytest +from tests.control_plane.codex_checkpoint_fixture import read_cli_policy_payload + from loopx.cli import main as cli_main from loopx.control_plane.goals.acceptance_observation import ( build_goal_acceptance_observation, @@ -363,6 +365,7 @@ def test_public_cli_diagnostic_reentry_preserves_guard_identity_without_spending "domain": "software", "repo": str(project), "state_file": state.name, + "execution_profile": {"stride_strategy": "off"}, "adapter": { "kind": "harness_self_improvement", "status": "connected-read-only", @@ -415,7 +418,7 @@ def test_public_cli_diagnostic_reentry_preserves_guard_identity_without_spending ) == 0 ) - result = json.loads(capsys.readouterr().out) + result = read_cli_policy_payload(capsys.readouterr().out, returncode=0) assert result["goal_frontier_projection"]["replan_required"] is True assert ( result["vision_continuation_audit"]["outcome_checkpoint_diagnostics"][0][ diff --git a/tests/control_plane/test_goal_acceptance_observation.py b/tests/control_plane/test_goal_acceptance_observation.py index 979befb161..51de45ccb9 100644 --- a/tests/control_plane/test_goal_acceptance_observation.py +++ b/tests/control_plane/test_goal_acceptance_observation.py @@ -138,7 +138,7 @@ def test_unattributed_legacy_vision_is_not_assigned_to_a_retained_peer(): @pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("retired", [False, True]) def test_status_keeps_current_acceptance_when_recent_history_changes(tmp_path, provider, retired): - from canonical_authority_fixture import promoted_create_fixture + from tests.control_plane.canonical_authority_fixture import promoted_create_fixture from loopx.state_refresh import refresh_state_run registry, runtime, state = promoted_create_fixture( @@ -282,7 +282,7 @@ def collect_fixture( ) ) from loopx.state_refresh import refresh_state_run - from canonical_authority_fixture import initialize_canonical_source_fixture + from tests.control_plane.canonical_authority_fixture import initialize_canonical_source_fixture initialize_canonical_source_fixture(runtime, "acceptance-demo", state_path=state, provider="sqlite") refresh_state_run( diff --git a/tests/control_plane/test_goal_amendment_authority.py b/tests/control_plane/test_goal_amendment_authority.py index 2cefa7678a..6e4f52a8f7 100644 --- a/tests/control_plane/test_goal_amendment_authority.py +++ b/tests/control_plane/test_goal_amendment_authority.py @@ -13,6 +13,19 @@ from loopx.control_plane.projects.registry_codec import load_project_registry +def _configure_fixture_stride(runtime: Path, strategy: str) -> None: + from loopx.capabilities.machine_configuration.builtins import build_builtin_machine_configuration_registry + from loopx.capabilities.machine_configuration.store import configure_machine_configuration + + owner = build_builtin_machine_configuration_registry() + configuration = {"schema_version": "loopx_machine_configuration_v0", "namespaces": { + "todo_replan_cadence": {"schema_version": "todo_replan_cadence_machine_defaults_v2", + "count_unit": "effective_turns", "count": 6, "stride_strategy": strategy}}} + preview = configure_machine_configuration(runtime_root=runtime, registry=owner, configuration=configuration) + configure_machine_configuration(runtime_root=runtime, registry=owner, configuration=configuration, + execute=True, expected_plan_revision=preview["plan_revision"]) + + @pytest.fixture(params=["file", "sqlite"]) def source_goal(request, tmp_path, monkeypatch, capsys): if request.param == "sqlite": @@ -179,6 +192,9 @@ def delayed(method, params, **kwargs): @pytest.mark.parametrize("claim_agent", ["agent-a", "agent-b"]) def test_claimed_successor_is_consumed_by_existing_replan_owner(source_goal, tmp_path, capsys, claim_agent): f = source_goal + # This journey verifies the direct successor acknowledgement and lease + # replay. The periodic journey below covers the distinct Stride gate. + _configure_fixture_stride(f["runtime"], "off") view = _call(f, tmp_path, capsys, "inspect")[1] root = _root(view) assert _call(f, tmp_path, capsys, "configure", root, agent=None, operation_id="root", execute=True)[0] == 0 @@ -222,8 +238,6 @@ def test_claimed_successor_is_consumed_by_existing_replan_owner(source_goal, tmp @pytest.mark.parametrize("strategy", ["off", "evidence_conditioned_v1"]) def test_periodic_successor_readback_uses_live_machine_stride(source_goal, tmp_path, capsys, strategy): - from loopx.capabilities.machine_configuration.builtins import build_builtin_machine_configuration_registry - from loopx.capabilities.machine_configuration.store import configure_machine_configuration from loopx.capabilities.todo_replan_cadence.machine_defaults import resolve_todo_replan_cadence_goal f = source_goal @@ -235,13 +249,7 @@ def test_periodic_successor_readback_uses_live_machine_stride(source_goal, tmp_p row["progress_observation"].update(result_class="advanced", surface_id=f"coverage-{index}", hypothesis_id=f"hypothesis-{index}") f["runs"].write_text("".join(json.dumps(row) + "\n" for row in rows)) - owner = build_builtin_machine_configuration_registry() - configuration = {"schema_version": "loopx_machine_configuration_v0", "namespaces": { - "todo_replan_cadence": {"schema_version": "todo_replan_cadence_machine_defaults_v2", - "count_unit": "effective_turns", "count": 6, "stride_strategy": strategy}}} - preview = configure_machine_configuration(runtime_root=f["runtime"], registry=owner, configuration=configuration) - configure_machine_configuration(runtime_root=f["runtime"], registry=owner, configuration=configuration, - execute=True, expected_plan_revision=preview["plan_revision"]) + _configure_fixture_stride(f["runtime"], strategy) goal = load_project_registry(f["registry"])["goals"][0] assert "stride_strategy" not in goal.get("execution_profile", {}) effective = resolve_todo_replan_cadence_goal(goal, f["runtime"]) diff --git a/tests/control_plane/test_goal_state_context_transport.py b/tests/control_plane/test_goal_state_context_transport.py index 575d392bc2..6690e28469 100644 --- a/tests/control_plane/test_goal_state_context_transport.py +++ b/tests/control_plane/test_goal_state_context_transport.py @@ -58,7 +58,8 @@ def guard(): output = io.StringIO() with contextlib.redirect_stdout(output): code = main(["--registry", str(registry), "--runtime-root", str(runtime), "--format", "json", - "quota", "should-run", "--goal-id", goal, "--agent-id", "agent-a"]) + "quota", "should-run", "--goal-id", goal, "--agent-id", "agent-a", + "--runtime-profile", "generic_cli", "--context-delivery", "off"]) packet = json.loads(output.getvalue()) assert code == 0 and packet["should_run"] is True, packet.get("reason") return packet["interaction_contract"]["agent_channel"] diff --git a/tests/control_plane/test_host_current_admission.py b/tests/control_plane/test_host_current_admission.py index 1575d08cdc..e44ea4ca5c 100644 --- a/tests/control_plane/test_host_current_admission.py +++ b/tests/control_plane/test_host_current_admission.py @@ -18,7 +18,7 @@ def _quota(registry: Path, runtime: Path, project: Path) -> dict: "--registry", str(registry), "--runtime-root", str(runtime), "--format", "json", "quota", "should-run", "--goal-id", "loopx-turn-fixture", "--agent-id", "codex-fixture", "--host-surface", "generic_cli", "--scheduler-owner", "outer_controller", - "--execution-mode", "isolated_headless", + "--execution-mode", "isolated_headless", "--context-delivery", "off", ]) return payload diff --git a/tests/control_plane/test_local_coordination_authority.py b/tests/control_plane/test_local_coordination_authority.py index 6836fbb152..dc63981908 100644 --- a/tests/control_plane/test_local_coordination_authority.py +++ b/tests/control_plane/test_local_coordination_authority.py @@ -1124,7 +1124,7 @@ def test_todo_list_uses_provider_after_cutover_even_when_markdown_disagrees( state_file.unlink() result = list_goal_todos(registry_path=registry_path, goal_id="goal-a") - assert result["source"] == "file_authority" + assert result["source"] == "file_v0" assert [item["todo_id"] for item in result["todos"]] == ["todo_provider"] assert result["authority_read"]["legacy_fallback_used"] is False diff --git a/tests/control_plane/test_quota_memory_context.py b/tests/control_plane/test_quota_memory_context.py index 3c887d13e8..31870b12b5 100644 --- a/tests/control_plane/test_quota_memory_context.py +++ b/tests/control_plane/test_quota_memory_context.py @@ -21,6 +21,7 @@ ) from tests.capabilities.test_agent_turn_recall import RecallProvider, active_record from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime +from tests.control_plane.codex_checkpoint_fixture import read_cli_policy_payload from tests.control_plane.reward_memory_host_fixture import enable_live_memory from tests.control_plane.test_quota_settlement_cli import ( AGENT_ID, GOAL_ID, _initialize_fixture_authority, _write_fixture, @@ -32,7 +33,7 @@ def run_cli(args: list[str]) -> dict: with redirect_stdout(output): code = main(args) assert code == 0, output.getvalue()[:1200] - return json.loads(output.getvalue()) + return read_cli_policy_payload(output.getvalue(), returncode=code) def quota_fixture(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str): diff --git a/tests/control_plane/test_quota_settlement_cli.py b/tests/control_plane/test_quota_settlement_cli.py index 3e5518e800..6aff68f19d 100644 --- a/tests/control_plane/test_quota_settlement_cli.py +++ b/tests/control_plane/test_quota_settlement_cli.py @@ -4733,7 +4733,10 @@ def test_unbound_visible_goal_spend_returns_typed_mismatch_without_receipt( AGENT_ID, ) assert guard_rc == 0, guard - actions = guard["interaction_contract"]["cli_channel"]["next_cli_actions"] + interaction = guard["interaction_contract"] + agent = interaction["agent_channel"] + cli = interaction["cli_channel"] + actions = cli["next_cli_actions"] assert len(actions) == 1 if profile == "codex_app_ssh_goal": assert actions[0].endswith("--begin-turn") @@ -4741,6 +4744,18 @@ def test_unbound_visible_goal_spend_returns_typed_mismatch_without_receipt( assert "--turn-instance-id" in actions[0] assert "--begin-turn" not in actions[0] assert all("spend-slot" not in action for action in actions) + assert guard["should_run"] is True + assert guard["normal_delivery_allowed"] is False + assert guard["execution_obligation"]["delivery_allowed"] is False + assert guard["execution_obligation"]["must_attempt_work"] is False + assert guard["recommended_action"] == actions[0] == agent["primary_action"] + assert agent["must_attempt"] is True + assert agent["delivery_allowed"] is False + assert agent["quiet_noop_allowed"] is False + assert cli["turn_binding_required"] is True + assert cli["spend_after_validation"] is False + assert "settlement_plan" not in cli + assert not guard.get("heartbeat_receipt") spend_rc, spend = _run_cli( registry_path, @@ -4770,7 +4785,10 @@ def test_unbound_visible_goal_spend_returns_typed_mismatch_without_receipt( ) bound_rc, bound = _run_generated_cli(command, registry_path=registry_path) assert bound_rc == 0, bound - plan = bound["interaction_contract"]["cli_channel"]["settlement_plan"] + bound_interaction = bound["interaction_contract"] + assert bound_interaction["agent_channel"]["delivery_allowed"] is True + assert "turn_binding_required" not in bound_interaction["cli_channel"] + plan = bound_interaction["cli_channel"]["settlement_plan"] assert plan["identity"]["todo_id"] == TODO_ID assert plan["identity"]["agent_id"] == AGENT_ID if profile != "codex_app_ssh_goal": @@ -4810,6 +4828,37 @@ def test_begin_turn_rejects_a_non_receipt_runtime_profile(tmp_path: Path) -> Non ) +@pytest.mark.parametrize("profile", ["codex_app_ssh_goal", "codex_cli", "ark_managed_agent_goal"]) +def test_unbound_visible_goal_selection_binds_the_chosen_todo(tmp_path: Path, profile: str) -> None: + project, runtime, registry_path = _write_fixture(tmp_path) + _configure_selectable_alternative(project) + _initialize_fixture_authority(project, runtime) + rc, preview = _run_cli( + registry_path, runtime, "quota", "should-run", "--runtime-profile", profile, + "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + ) + assert rc == 0, preview + contract = preview["interaction_contract"] + assert contract["agent_channel"]["delivery_allowed"] is False + cli = contract["cli_channel"] + assert cli["selection_required"] is True + assert cli["spend_after_validation"] is False + assert not preview.get("heartbeat_receipt") + + selection = cli["selection_command"] + command = (selection["route_prefix"] + " " + selection["command_args_template"]).replace( + "{todo_id}", ALTERNATIVE_TODO_ID, + ).replace("", TURN_ID) + bound_rc, bound = _run_generated_cli(command, registry_path=registry_path) + assert bound_rc == 0, bound + assert bound["interaction_contract"]["agent_channel"]["delivery_allowed"] is True + identity = bound["heartbeat_receipt"]["settlement_identity"] + assert identity["todo_id"] == ALTERNATIVE_TODO_ID + assert bound["interaction_contract"]["cli_channel"]["settlement_plan"]["identity"] == identity + assert _heartbeat_receipt_count(runtime, identity["turn_instance_id"]) == 1 + assert _spend_run_count(runtime) == 0 + + @pytest.mark.parametrize("profile", ["generic_cli", "outer_controller", "codex_cli"]) def test_host_owned_turn_executes_projected_selection_and_replays_identity( tmp_path: Path, profile: str, @@ -6993,6 +7042,21 @@ def test_unbound_visible_goal_todoless_replan_reenters_through_guided_turn( assert actions[0].endswith("--begin-turn") assert "refresh-state" not in actions[0] assert "spend-slot" not in actions[0] + from loopx.control_plane.quota.turn_envelope import build_turn_envelope + + envelope = build_turn_envelope(unbound) + assert envelope["action"]["primary_action"] == actions[0] + assert envelope["action"]["must_attempt"] is True + assert envelope["action"]["delivery_allowed"] is False + assert envelope["writeback"]["next_cli_actions"] == actions + assert envelope["writeback"]["spend_after_validation"] is False + assert envelope["replan_action_packet"] is None + from loopx.control_plane.turn_driver import build_loopx_turn_plan + + plan = build_loopx_turn_plan(envelope, host="codex-cli", execution_mode="interactive-visible") + assert plan["route"]["kind"] == "blocked" + assert plan["route"]["would_invoke_host"] is False + assert not any(plan["effects"].values()) bound_rc, bound = _run_generated_cli( actions[0], diff --git a/tests/control_plane/test_request_amendment_recovery.py b/tests/control_plane/test_request_amendment_recovery.py index 4b987fc171..4913241e66 100644 --- a/tests/control_plane/test_request_amendment_recovery.py +++ b/tests/control_plane/test_request_amendment_recovery.py @@ -542,7 +542,10 @@ def agenda(agent): assert current["returns"] == [{"result_key": "conclusion", "request_revision": 1, "status": "queued", "consumed": False}] assert current["pending_return_count"] == current["unconsumed_return_count"] == 1 - assert agenda("agent-b")["items"][0]["step"] == "await_peer_result" + outgoing = agenda("agent-b")["items"][0] + assert outgoing["step"] == "consume_peer_result" + assert outgoing["request_revision"] == 2 and outgoing["receipt_state"] == "pending" + assert outgoing["returns"] == current["returns"] _control(f, rid, "cancel", {"operation_id": "agenda-cancel", **_basis(revised), "reason": "Stop current request"}) assert agenda("agent-a")["items"] == [] assert agenda("agent-b")["items"] == [] diff --git a/tests/control_plane/test_required_vision_closeout_behavior.py b/tests/control_plane/test_required_vision_closeout_behavior.py index 78f3156ac6..afb1d3b644 100644 --- a/tests/control_plane/test_required_vision_closeout_behavior.py +++ b/tests/control_plane/test_required_vision_closeout_behavior.py @@ -184,6 +184,10 @@ def test_projected_module_readers_complete_native_shell_closeout( for message in transport.requests[-1]["messages"] if message["role"] == "tool" and message["content"].startswith("{")] assert [value["stage"] for value in outputs if "stage" in value] == ["review", "writeback"] + writer = next(value for value in outputs if value.get("stage") == "writeback") + assert "agent_vision.stride_decision" in writer["writeback_contract"]["required_fields"] + assert "fields" in writer["writeback_contract"]["vision_authoring"] + assert "refresh-state" in shlex.split(writer["writeback"]["next_cli_actions"][0]) packet = _packet(transport.requests[-1]) reader = packet["autonomous_replan_obligation"]["context_read"] assert shlex.split(reader)[1:3] == ["-m", "loopx.entrypoint"] diff --git a/tests/control_plane/test_scoped_gate_successor_tool_behavior.py b/tests/control_plane/test_scoped_gate_successor_tool_behavior.py index aafa7ba825..a4a0770be6 100644 --- a/tests/control_plane/test_scoped_gate_successor_tool_behavior.py +++ b/tests/control_plane/test_scoped_gate_successor_tool_behavior.py @@ -5,6 +5,8 @@ from pathlib import Path from typing import Any +import pytest + from loopx.control_plane.testing.model_tool_behavior import ( QuotaToolContext, QUOTA_FIRST_TOOL_INSTRUCTION, @@ -13,14 +15,16 @@ ) from loopx.control_plane.testing.scoped_gate_successor_tool_behavior import ( SCOPED_GATE_FINALIZATION_MESSAGE, + SCOPED_GATE_SUCCESSOR_TOOL_BEHAVIOR_MAX_CALLS, SCOPED_GATE_SUCCESSOR_TODO_ID, DoubaoScopedGateSuccessorToolBehaviorActor, _build_scoped_gate_fixture, ) +@pytest.mark.parametrize("extra_reads", [0, SCOPED_GATE_SUCCESSOR_TOOL_BEHAVIOR_MAX_CALLS - 2]) def test_real_quota_notice_and_selected_successor_execute_in_one_tool_loop( - tmp_path: Path, + tmp_path: Path, extra_reads: int, ) -> None: fixture = _build_scoped_gate_fixture(tmp_path / "oracle") requests: list[dict[str, Any]] = [] @@ -32,6 +36,7 @@ def test_real_quota_notice_and_selected_successor_execute_in_one_tool_loop( "turn-scoped-001", ), ), + *(_tool_response(f"call-read-{index}", "pwd") for index in range(extra_reads)), _tool_response( "call-successor", "cat fixture/scoped-successor.json", @@ -66,8 +71,11 @@ def transport(**kwargs: Any) -> Mapping[str, Any]: assert receipt["quiet_noop_allowed"] is False assert receipt["non_blocking_notice_surfaced"] is True assert receipt["selected_action_matched_todo"] is True + assert receipt["tool_call_count"] == extra_reads + 2 + assert receipt["tool_call_limit"] == SCOPED_GATE_SUCCESSOR_TOOL_BEHAVIOR_MAX_CALLS assert receipt["observed_tool_sequence"] == [ "quota_should_run", + *(["workspace_read"] * extra_reads), "selected_action", ] assert receipt["boundary"] == { @@ -109,10 +117,11 @@ def transport(**kwargs: Any) -> Mapping[str, Any]: assert "After the selected Todo tool succeeds, call no more tools" in ( system_prompt ) - assert "tools" not in requests[2] - assert "tool_choice" not in requests[2] - assert requests[2]["messages"][-2]["role"] == "tool" - assert requests[2]["messages"][-1] == { + assert len(requests) == extra_reads + 3 + assert "tools" not in requests[-1] + assert "tool_choice" not in requests[-1] + assert requests[-1]["messages"][-2]["role"] == "tool" + assert requests[-1]["messages"][-1] == { "role": "system", "content": SCOPED_GATE_FINALIZATION_MESSAGE, } diff --git a/tests/control_plane/test_selected_todo_tool_behavior.py b/tests/control_plane/test_selected_todo_tool_behavior.py index d97c309baf..cfcd3d47cf 100644 --- a/tests/control_plane/test_selected_todo_tool_behavior.py +++ b/tests/control_plane/test_selected_todo_tool_behavior.py @@ -85,8 +85,12 @@ def transport(**kwargs: Any) -> Mapping[str, Any]: def test_real_tool_loop_executes_action_selected_by_real_quota( - tmp_path: Path, + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: + def reject_implicit_runtime() -> Path: + raise AssertionError("the fixture must not consult the host runtime route") + + monkeypatch.setattr("loopx.paths.select_default_runtime_root", reject_implicit_runtime) fixture = _build_fixture(tmp_path / "oracle") requests: list[dict[str, Any]] = [] commands = [ diff --git a/tests/control_plane/test_source_cli_entrypoint.py b/tests/control_plane/test_source_cli_entrypoint.py index 247e74e56c..a35d501a01 100644 --- a/tests/control_plane/test_source_cli_entrypoint.py +++ b/tests/control_plane/test_source_cli_entrypoint.py @@ -60,7 +60,7 @@ def run_entry( @pytest.mark.parametrize( "argv", - [["--version"], [], ["--help"], ["check", "--help"], ["status", "--help"], + [["--version"], ["version", "--format", "json"], [], ["--help"], ["check", "--help"], ["status", "--help"], ["diagnose", "--help"], ["review-packet", "--help"], ["quota", "--help"], ["todo", "--help"], ["delegation", "--help"], ["turn", "--help"], ["turn", "run-once", "--help"], @@ -82,7 +82,7 @@ def test_source_help_and_version_do_not_load_unrelated_owners(argv: list[str]) - @pytest.mark.parametrize( "argv", - [["version", "--format", "json"], ["status", "--unknown-option"], + [["status", "--unknown-option"], ["todo", "list"], ["quota", "unknown-command"], ["--format", "unknown", "status"], ["delegation", "inspect", "--unknown-option"], ["turn", "run-once", "--exec"], ["turn", "unknown-command"], diff --git a/tests/control_plane/test_start_goal_compact_projection.py b/tests/control_plane/test_start_goal_compact_projection.py index 108df8c6d8..309c60fc35 100644 --- a/tests/control_plane/test_start_goal_compact_projection.py +++ b/tests/control_plane/test_start_goal_compact_projection.py @@ -2042,6 +2042,7 @@ def test_cli_without_host_returns_read_only_host_selection_gate( "cursor-agent", "zcode", "agy", + "antigravity-app", "kiro-cli", "deepseek-harness", "deepseek-harness-native", diff --git a/tests/control_plane/test_terminal_settlement_tool_behavior.py b/tests/control_plane/test_terminal_settlement_tool_behavior.py index ec2a3ab1ff..18165729d4 100644 --- a/tests/control_plane/test_terminal_settlement_tool_behavior.py +++ b/tests/control_plane/test_terminal_settlement_tool_behavior.py @@ -19,6 +19,7 @@ ScriptedExecToolAction, ) from loopx.control_plane.testing.terminal_settlement_tool_behavior import ( + TERMINAL_SETTLEMENT_TOOL_BEHAVIOR_MAX_CALLS, TERMINAL_SETTLEMENT_FIXTURE_TODO_ID, DoubaoTerminalSettlementToolBehaviorActor, _build_fixture, @@ -130,6 +131,12 @@ def _reentry_quota_action(request: Mapping[str, Any]) -> ScriptedExecToolAction: return ScriptedExecToolAction(command=command) +def _reentry_quota_read(request: Mapping[str, Any]) -> ScriptedExecToolAction: + quota = json.loads(request["messages"][-1]["content"]) + assert quota["schema_version"] == "context_capture_navigation_v1" + return ScriptedExecToolAction(command=quota["recovery"]["command"] + " --select /should_run") + + @pytest.mark.parametrize("cat_options", ["", "-- "]) @pytest.mark.parametrize("batch_read", [False, True]) @pytest.mark.parametrize("ordinary_completion", [False, True]) @@ -182,6 +189,7 @@ def test_real_tool_loop_settles_final_todo_after_spend( "terminal_closeout", ] assert receipt["terminal_order_observed"] is True + assert receipt["tool_call_limit"] == TERMINAL_SETTLEMENT_TOOL_BEHAVIOR_MAX_CALLS assert receipt["selected_todo_id"] == TERMINAL_SETTLEMENT_FIXTURE_TODO_ID assert receipt["decision"] == "execute" assert receipt["boundary"] == { @@ -202,6 +210,8 @@ def test_real_tool_loop_settles_final_todo_after_spend( assert "the host validates that tool result" in system_prompt assert "validation step's command_condition" in system_prompt assert "distinct from the final --no-follow-up closeout after spend" in system_prompt + assert "run it before authoring any durable_writeback input" in system_prompt + assert "one tool call under the shell's existing `.scratch/` directory" in system_prompt assert "A receipt without command templates does not replace that plan" in system_prompt assert first["messages"][1] == {"role": "user", "content": fixture.task_body} assert TERMINAL_SETTLEMENT_FIXTURE_TODO_ID not in fixture.task_body @@ -772,14 +782,20 @@ def test_batch_read_failure_cannot_admit_terminal_writeback( assert len(transport.requests) == 2 +@pytest.mark.parametrize("progressive", [False, True]) def test_real_rejection_loop_settles_projected_todos_then_stops( - tmp_path: Path, + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, progressive: bool, ) -> None: + if progressive: + monkeypatch.delenv("LOOPX_CONTEXT_DELIVERY", raising=False) + else: + monkeypatch.setenv("LOOPX_CONTEXT_DELIVERY", "off") transport = ScriptedDoubaoExecTransport( [ _reentry_todo_action(0), _reentry_todo_action(1), _reentry_quota_action, + *([_reentry_quota_read] if progressive else []), ScriptedAssistantAction(content="Terminal lifecycle is settled."), ] ) @@ -794,8 +810,8 @@ def test_real_rejection_loop_settles_projected_todos_then_stops( assert receipt["qualification_passed"] is True, json.dumps(receipt, sort_keys=True) assert receipt["failure_code"] is None - assert receipt["provider_call_count"] == 4 - assert receipt["tool_call_count"] == 3 + assert receipt["provider_call_count"] == 4 + progressive + assert receipt["tool_call_count"] == 3 + progressive assert receipt["projected_todo_count"] == 2 assert receipt["settled_todo_count"] == 2 assert receipt["completion_identity_preserved"] is True @@ -805,6 +821,7 @@ def test_real_rejection_loop_settles_projected_todos_then_stops( "todo_lifecycle_settlement", "todo_lifecycle_settlement", "terminal_quota_reentry", + *(["context_read"] if progressive else []), ] transition = _latest_reentry_transition(transport.requests[0]) assert len(transition["triggers"]) == 2 diff --git a/tests/control_plane/test_todo_consumer_retirement.py b/tests/control_plane/test_todo_consumer_retirement.py index 4c484f37f4..03e8cb24b6 100644 --- a/tests/control_plane/test_todo_consumer_retirement.py +++ b/tests/control_plane/test_todo_consumer_retirement.py @@ -14,6 +14,8 @@ import pytest +from tests.control_plane.codex_checkpoint_fixture import read_cli_policy_payload + import test_canonical_todo_writer_isolation as journeys from test_canonical_todo_writer_isolation import ( isolated_todo_distribution, retirement_wheel, # noqa: F401 -- distribution fixtures @@ -133,7 +135,7 @@ def cli(*args, succeeds=True): cwd=tmp_path, capture_output=True, text=True, timeout=45, ) assert (result.returncode == 0) is succeeds, result.stdout + result.stderr - return json.loads(result.stdout) + return read_cli_policy_payload(result.stdout, returncode=result.returncode) def current(): return cli("todo", "list", "--goal-id", GOAL_ID) diff --git a/tests/control_plane/test_worker_observation_recovery_cli.py b/tests/control_plane/test_worker_observation_recovery_cli.py index 12f9acb740..675a6f9d45 100644 --- a/tests/control_plane/test_worker_observation_recovery_cli.py +++ b/tests/control_plane/test_worker_observation_recovery_cli.py @@ -12,6 +12,7 @@ import loopx from canonical_authority_fixture import isolate_sqlite_runtime from research_worker_readback_fixture import AGENT, GOAL, _write_authority +from tests.control_plane.codex_checkpoint_fixture import read_cli_policy_payload from loopx.control_plane.quota.host_poll_receipts import ( read_host_worker_observations, receipt_path_for_goal, @@ -49,7 +50,7 @@ def _run_worker(runtime, registry, count, host, agent): ], cwd=host.parent / "project", capture_output=True, text=True, timeout=120) -def _poll(runtime, registry, agent, *, record=False, extra=(), profile="generic_cli"): +def _poll(runtime, registry, agent, *, record=False, extra=(), profile="generic_cli", wire=False): result = subprocess.run([ sys.executable, "-m", "loopx.cli", "--registry", str(registry), "--runtime-root", str(runtime), "--format", "json", @@ -57,7 +58,10 @@ def _poll(runtime, registry, agent, *, record=False, extra=(), profile="generic_ "--runtime-profile", profile, *(["--record-host-poll"] if record else []), *extra, ], cwd=registry.parent / "project", capture_output=True, text=True, timeout=90) assert result.returncode == 0, result.stdout + result.stderr - payload = json.loads(result.stdout) + payload = ( + json.loads(result.stdout) if wire + else read_cli_policy_payload(result.stdout, returncode=result.returncode) + ) assert payload["ok"] is True, payload return payload @@ -86,7 +90,7 @@ def test_original_capture_retains_the_emitted_poll_observation( native = tmp_path / "native-context" native.mkdir(mode=0o700) options.extend(["--codex-context-dir", str(native)]) - emitted = _poll(runtime, registry, AGENT, record=observation != "off", extra=options, + emitted = _poll(runtime, registry, AGENT, record=observation != "off", extra=options, wire=True, profile="codex_app_heartbeat" if wire == "native" else "generic_cli") original = (capture / "decision.json").read_bytes() source = json.loads(original) @@ -160,10 +164,10 @@ def test_failed_capture_discloses_the_already_recorded_poll( runtime, registry, count, _ = _worker_fixture(tmp_path, monkeypatch, provider) original_capture = quota_capture.capture_decision - def failed_publication(directory, payload): + def failed_publication(directory, payload, *, args, native_context): # Only the capture publisher fails; admission and receipt IO are real. with patch.object(quota_capture.os, "replace", side_effect=OSError("synthetic capture outage")): - original_capture(directory, payload) + original_capture(directory, payload, args=args, native_context=native_context) monkeypatch.setattr(quota, "capture_decision", failed_publication) monkeypatch.chdir(tmp_path / "project") diff --git a/tests/control_plane_ts/canonical_task_lease_renew.test.ts b/tests/control_plane_ts/canonical_task_lease_renew.test.ts index 53b27dd7ce..a62b025ea1 100644 --- a/tests/control_plane_ts/canonical_task_lease_renew.test.ts +++ b/tests/control_plane_ts/canonical_task_lease_renew.test.ts @@ -427,8 +427,19 @@ for (const provider of ["file", "sqlite"] as const) { child.on("error", reject); child.on("close", code => {clearTimeout(timeout); if (code !== 0) reject(new Error(error)); else resolve(JSON.parse(output));}); }))); - assert.deepEqual(results.map(r => r.status).sort(), differentIntent ? ["applied", "failed"] : ["applied", "applied"]); - if (differentIntent) assert.equal(results.find(r => r.status === "failed")!.reason_code, "coordination_operation_identity_mismatch"); + const statuses = results.map(r => r.status).sort(); + if (differentIntent) { + assert.deepEqual(statuses, ["applied", "failed"]); + assert.equal(results.find(r => r.status === "failed")!.reason_code, "coordination_operation_identity_mismatch"); + } else { + // The other worker may reach preflight after the first commit or + // recover the same receipt through its concurrent commit attempt. + assert.equal(statuses[0], "applied"); + assert.ok(["applied", "recovered", "replayed"].includes(String(statuses[1]))); + assert.ok(results.every(result => result.renewed === true && result.original_receipt)); + assert.deepEqual(results[0]!.original_receipt, results[1]!.original_receipt); + assert.deepEqual(results[0]!.lease, results[1]!.lease); + } const head = await store.loadAuthority(); if (head.status !== "loaded") throw new Error("missing head"); assert.equal(head.cursor, "2"); assert.equal((head.head.leases as Record[])[0]!.version, 2); }); diff --git a/tests/control_plane_ts/execution_preparation.test.ts b/tests/control_plane_ts/execution_preparation.test.ts index f3812e62b9..0a29d2b801 100644 --- a/tests/control_plane_ts/execution_preparation.test.ts +++ b/tests/control_plane_ts/execution_preparation.test.ts @@ -90,11 +90,7 @@ test("malformed facts are rejected", () => { ); }); -test("the execution handoff vocabulary stays narrower than the historical one", () => { - // `legacy` still decodes stored state and receipts, but it is not an accepted - // execution-preparation input. Borrowing the shared `HANDOFF_MODES` list here - // instead of the execution subset would admit it silently, so both the - // membership and the rejection are pinned against the vocabulary owner. +test("legacy facts stay readable and an old matching lease grants no new execution", () => { for (const mode of EXECUTION_HANDOFF_MODES) { assert.doesNotThrow(() => projectExecutionPreparation(facts({handoff_mode: mode}))); } @@ -102,8 +98,12 @@ test("the execution handoff vocabulary stays narrower than the historical one", const execution: readonly string[] = EXECUTION_HANDOFF_MODES; assert.ok(historical.includes("legacy"), "legacy stays decodable for stored receipts"); assert.ok(!execution.includes("legacy"), "legacy is not a new execution target"); - assert.throws( - () => projectExecutionPreparation(facts({handoff_mode: "legacy"})), - EffectRuntimeRequestError, - ); + const retired = projectExecutionPreparation(facts({handoff_mode: "legacy", leases: [ + {status: "active", active: true, owner: "agent-a"}, + ]})); + assert.equal(retired.handoff_mode, "legacy"); + assert.equal(retired.state, "blocked_by_conflict"); + assert.equal(retired.reason_code, "handoff_mode_retired"); + assert.equal(retired.delivery_allowed, false); + assert.equal(Object.hasOwn(retired, "claim"), false); }); diff --git a/tests/control_plane_ts/stride_policy.test.ts b/tests/control_plane_ts/stride_policy.test.ts index f0413f82f9..0b597c1995 100644 --- a/tests/control_plane_ts/stride_policy.test.ts +++ b/tests/control_plane_ts/stride_policy.test.ts @@ -447,6 +447,23 @@ test("shared replan guidance retains stride-off isolation and bounded enabled au assert.ok(Number(((prepared.agent_vision as Record).vision_budget as Record).total_usage) < 1800); }); +test("fresh Vision authoring advertises the stride decision its validator requires", () => { + for (const kind of ["required_agent_vision_missing", "vision_acceptance_gap"]) { + for (const strategy of ["off", "evidence_conditioned_v1"]) { + const obligation = {obligation_id: proposal.reference_obligation_id, triggers: [{kind}], + replan_context: {stride_strategy: strategy, context_id: contextId}}; + const requirements = projectReplanSemantics({operation: "requirements", obligation}); + const fields = (requirements.writeback_contract as {required_fields: string[]}).required_fields; + const {stride_decision, ...vision} = packet; + const without = projectReplanSemantics({operation: "qualify", obligation, agent_vision: vision}); + const withDecision = projectReplanSemantics({operation: "qualify", obligation, + agent_vision: {...vision, stride_decision}}); + assert.equal(fields.includes("agent_vision.stride_decision"), without.reason_code === "stride_decision_required", kind); + assert.equal(withDecision.accepted, true, kind); + } + } +}); + test("all decision codecs reject non-string reference identities without coercion", () => { const {minimum_evidence: _, ...probe} = proposal.next_probe; const fixed = {...proposal, schema_version: "stride_decision_v1", next_probe: probe}; diff --git a/tests/presentation/test_background_services_api.py b/tests/presentation/test_background_services_api.py index 42d25da531..623b9f18d8 100644 --- a/tests/presentation/test_background_services_api.py +++ b/tests/presentation/test_background_services_api.py @@ -74,25 +74,33 @@ def test_common_read_is_passive_and_default_off_stays_isolated(host): def test_http_recheck_starts_existing_owners_once_and_preserves_disabled_features(host): server, provider, _ = host - status, _ = request(server, "POST", {"operation": "recheck"}) + status, result = request(server, "POST", {"operation": "recheck"}) assert status == 200 - first = server.manager_return_service.thread + returns = server.manager_return_service + first = returns.thread + first_execution = returns.execution_thread + manager_tasks = ("manager-returns", "manager-executions") + tasks = {item["id"]: item for item in result["tasks"]} deadline = time.monotonic() + 5 while time.monotonic() < deadline: - _, result = request(server) - tasks = {item["id"]: item for item in result["tasks"]} - if tasks["manager-returns"]["last_success_at"] is not None: + if all(tasks[key]["last_success_at"] is not None for key in manager_tasks): break time.sleep(.02) - assert tasks["manager-returns"]["state"] == "healthy" - assert tasks["manager-executions"]["state"] == "healthy" + status, result = request(server) + assert status == 200 + tasks = {item["id"]: item for item in result["tasks"]} + for key in manager_tasks: + assert tasks[key]["last_success_at"] is not None, tasks[key] + # A worker can begin its next scan before the other completes its first. + assert tasks[key]["state"] in {"healthy", "checking"}, tasks[key] assert result["supervisor"]["state"] in {"healthy", "checking"}, "Recheck must restore the host as well" request(server, "POST", {"operation": "recheck"}) - assert server.manager_return_service.thread is first + assert returns.thread is first + assert returns.execution_thread is first_execution assert not provider.root.exists() and not server.lark_goal_topic_runtime._delivery_thread server.background_services.close() request(server, "POST", {"operation": "recheck"}) - assert not first.is_alive(), "Recheck after shutdown cannot resurrect work" + assert not first.is_alive() and not first_execution.is_alive(), "Recheck after shutdown cannot resurrect work" def test_recheck_uses_the_existing_loopback_guard_and_rejects_new_settings(host): diff --git a/tests/test_cli_argument_diagnostics.py b/tests/test_cli_argument_diagnostics.py index 99fe9c636b..18e85e7f5a 100644 --- a/tests/test_cli_argument_diagnostics.py +++ b/tests/test_cli_argument_diagnostics.py @@ -214,8 +214,8 @@ def test_todo_list_validation_preserves_exact_unsupported_diagnostic() -> None: assert str(exc_info.value) == ( "todo list only accepts --goal-id, optional --role, --status, --todo-id, " - "--agent-id, --limit, --thin, --project, --state-file, --dry-run, and " - "--format; " + "--agent-id, --limit, --thin, --summary, --include-archived, --project, " + "--state-file, --dry-run, and --format; " "unsupported: --note, --evidence" ) diff --git a/tests/test_collaboration_goal_instance.py b/tests/test_collaboration_goal_instance.py index 7374e471e9..f47656cbdb 100644 --- a/tests/test_collaboration_goal_instance.py +++ b/tests/test_collaboration_goal_instance.py @@ -1462,7 +1462,7 @@ def test_exact_instance_updates_keep_original_route_and_http_readback(tmp_path): assert drain(tmp_path, registry, ChatSessionStore(tmp_path), None) == 0 snapshot = _http_snapshot(tmp_path, registry, session["session_id"]) messages = [m for m in snapshot["messages"] if m.get("origin") == "manager_followup"] - assert [m["text"].split("\n\n")[-1] for m in messages] == ["Waiting for review.", "Review complete."] + assert [m["text"].split("\n\n", 1)[0] for m in messages] == ["Waiting for review.", "Review complete."] assert messages[-1]["return_delivery"]["result_key"] == update["result_key"] assert messages[-1]["return_delivery"]["status"] == "delivered" with pytest.raises((ValueError, FileNotFoundError)): @@ -1574,7 +1574,7 @@ def unavailable_chat(*_args, **_kwargs): snapshot = _http_snapshot(tmp_path, registry, session["session_id"]) returned = [m for m in snapshot["messages"] if m.get("origin") == "manager_followup"] assert len(returned) == 1 - assert returned[0]["text"].endswith("Verified delivery findings.") + assert returned[0]["text"].split("\n\n", 1)[0] == "Verified delivery findings." assert returned[0]["return_delivery"]["status"] == "delivered" assert route_path.read_bytes() == original_route current_turn = store.load_turn(session["session_id"], original_turn["turn_id"]) diff --git a/tests/test_doctor_install_freshness.py b/tests/test_doctor_install_freshness.py index 2918e989e7..3430d63276 100644 --- a/tests/test_doctor_install_freshness.py +++ b/tests/test_doctor_install_freshness.py @@ -41,8 +41,12 @@ def read_text(self, name: str) -> str: assert name == "INSTALLER" return "pip" - def locate_file(self, _name: str) -> Path: - return self._root + def locate_file(self, name: str) -> Path: + if not name: + return self._root + item = self.files[0] + assert name == item.as_posix() + return item.locate() def _git(root: Path, *args: str) -> str: diff --git a/tests/test_host_launch_authority.py b/tests/test_host_launch_authority.py index 6ad7641067..ccfa0e6627 100644 --- a/tests/test_host_launch_authority.py +++ b/tests/test_host_launch_authority.py @@ -15,6 +15,8 @@ import pytest +from tests.control_plane.codex_checkpoint_fixture import read_cli_policy_payload + from loopx.cli import main as cli_main from loopx.cli_commands import lark_inbox, turn_decision from loopx.configure_goal import configure_goal @@ -170,7 +172,7 @@ def _cli(registry: Path, runtime: Path, *arguments: str): "--registry", str(registry), "--runtime-root", str(runtime), "--format", "json", *arguments, ]) - return code, json.loads(output.getvalue()) + return code, read_cli_policy_payload(output.getvalue(), returncode=code) def _mirror(source: Path, runtime: Path) -> Path: diff --git a/tests/test_lark_private_conversations.py b/tests/test_lark_private_conversations.py index 312f0b183c..7c3c1bfe86 100644 --- a/tests/test_lark_private_conversations.py +++ b/tests/test_lark_private_conversations.py @@ -2297,8 +2297,8 @@ def _app_projection_journey(fixture): def _drain_app_projection(transport, predicate): deadline = time.monotonic() + 10 while not predicate(): - transport.reconcile() assert time.monotonic() < deadline, transport.health() + transport.reconcile() time.sleep(.02) @@ -2451,6 +2451,9 @@ def test_app_long_return_keeps_all_parts_and_does_not_repeat_identical_parts(ord text = ("A repeated output paragraph with its complete evidence.\n" * 4000) + "UNIQUE FINAL END" store.append_message(original["session_id"], role="agent", text=text, turn_id=turns[-1], message_id="synthetic-long-result") + # Discover the committed result immediately. This case exercises delivery, + # including recovery of repeated parts, independently of scan cadence. + transport._web_projection_scan_at = 0 try: _drain_app_projection(transport, lambda: any("UNIQUE FINAL END" in t for _, t in provider.writes[before:])) # Each canonical character survives transport splitting; an identical diff --git a/tests/test_lark_private_feedback.py b/tests/test_lark_private_feedback.py index 36eb1e03be..e37ecaf900 100644 --- a/tests/test_lark_private_feedback.py +++ b/tests/test_lark_private_feedback.py @@ -396,10 +396,9 @@ def read_turn(session_id, turn_id): monkeypatch.setattr(store, 'load_turn', read_turn) transport.reconcile_app_feedback() - # No body has been mirrored yet, so this is discovery, not a reaction - # effect. One canonical input needs one Turn validation regardless of - # how many answer chunks the independent body outbox still owes. - assert reads == [(sid, turn['turn_id'])] + # Discovery rejects unmirrored inputs before loading any Turn. Answer + # chunks belong to the independent body outbox. + assert reads == [] assert (journal.read_bytes(), ordinary[4].read_bytes(), provider.writes) == before assert store.load_turn(sid, turn['turn_id'])['status'] == 'queued' finally: diff --git a/tests/test_loopx_turn_executor.py b/tests/test_loopx_turn_executor.py index ecb7edba57..2c98fa2858 100644 --- a/tests/test_loopx_turn_executor.py +++ b/tests/test_loopx_turn_executor.py @@ -4256,9 +4256,11 @@ def test_real_host_duration_is_observed_but_settlement_replay_is_not( machine = tmp_path / "machine" monkeypatch.setattr(usage_ping, "select_default_runtime_root", lambda: machine) - for name in ("CI", "DO_NOT_TRACK", "LOOPX_USAGE_PING", "LOOPX_USAGE_POLICY"): + for name in ("CI", "DO_NOT_TRACK", "LOOPX_USAGE_POLICY"): monkeypatch.delenv(name, raising=False) monkeypatch.setenv("LOOPX_USAGE_PING_ENDPOINT", "http://127.0.0.1:1/v1/ping") + # Opt in to the existing loopback qualification for temporary state. + monkeypatch.setenv("LOOPX_USAGE_PING", "1") usage_ping.control("enable") plan = _plan() host_file = tmp_path / "host.py" diff --git a/tests/test_peer_collaboration.py b/tests/test_peer_collaboration.py index 8cfa76fc4a..e9829d862d 100644 --- a/tests/test_peer_collaboration.py +++ b/tests/test_peer_collaboration.py @@ -229,7 +229,9 @@ def sender(route, source_session, source_turn, text): drain(root, registry, ChatSessionStore(root), sender) assert len(sent) == 1 assert sent[0][:3] == (session["channel_id"], session["session_id"], turn["turn_id"]) - assert sent[0][3].endswith("Stock plan delivered with the independent check.") + body, provenance = sent[0][3].split("\n\n", 1) + assert body == "Stock plan delivered with the independent check." + assert provenance == f"协作回复 · builder · 委托 {parent[:8]}" assert not query(root, registry, goal_ids=["delivery"], owner_scope=False, channel_id="manager.external.unrelated")["rows"] diff --git a/tests/test_projection_envelope.py b/tests/test_projection_envelope.py index 20607d8d36..f9214fc56a 100644 --- a/tests/test_projection_envelope.py +++ b/tests/test_projection_envelope.py @@ -6,6 +6,8 @@ import pytest +from tests.control_plane.codex_checkpoint_fixture import read_cli_policy_payload + import loopx.control_plane.projection_envelope_facts as projection_envelope import loopx.control_plane.quota.should_run as quota_should_run_module import loopx.control_plane.quota.should_run_packet as should_run_packet @@ -471,7 +473,7 @@ def test_real_cli_quota_should_run_emits_the_projection_envelope( ) assert completed.returncode == 0, completed.stdout + completed.stderr - payload = json.loads(completed.stdout) + payload = read_cli_policy_payload(completed.stdout, returncode=completed.returncode) assert payload["projection_envelope"]["projection"] == "quota_should_run" assert payload["projection_envelope"]["coverage"]["included_count"] == 1 assert payload["projection_envelope"]["upstream"][0]["projection"] == "status" @@ -519,7 +521,7 @@ def run_cli() -> dict: timeout=60, ) assert completed.returncode == 0, completed.stdout + completed.stderr - return json.loads(completed.stdout) + return read_cli_policy_payload(completed.stdout, returncode=completed.returncode) missing = _sources(run_cli()["projection_envelope"])["app_scheduler_state"] assert missing["read_status"] == "read"