From 60cb65529d938c4712242893531802048c3e7937 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:20:55 +0800 Subject: [PATCH 01/60] release: prepare LoopX 2.3.0 Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/book/chapters/00-reading-guide.md | 2 +- docs/book/en/chapters/00-reading-guide.md | 4 +- docs/book/en/index.md | 2 +- docs/book/index.md | 2 +- docs/product/releases/v2.3.0.md | 684 ++++++++++++++++++++++ loopx/__init__.py | 2 +- man/loopx.1 | 8 +- pyproject.toml | 2 +- 8 files changed, 692 insertions(+), 14 deletions(-) create mode 100644 docs/product/releases/v2.3.0.md 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/product/releases/v2.3.0.md b/docs/product/releases/v2.3.0.md new file mode 100644 index 0000000000..0d60cf3792 --- /dev/null +++ b/docs/product/releases/v2.3.0.md @@ -0,0 +1,684 @@ +# 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) + +### 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, and Issue-Fix redirect verification. [#7855](https://github.com/loopx-project/loopx/pull/7855) +- [@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. Operators who do not use these paths can stay on 2.2.0 while they plan an upgrade. 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) + +## 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, and Issue-Fix redirect verification. [#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) +- [@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) + +### 贡献者 + +- [@BigDataDZ](https://github.com/BigDataDZ) — 原生宿主发现、安装命令引用、Windows 长路径和 Doctor 身份诊断. [#7886](https://github.com/loopx-project/loopx/pull/7886) +- [@cocolord](https://github.com/cocolord) — CLI 按需加载、原生上下文与恢复夹具、Issue-Fix 重定向验证. [#7855](https://github.com/loopx-project/loopx/pull/7855) +- [@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、研究工作台或原生宿主的用户。暂时不用这些功能的部署可以继续使用 2.2.0。配置了同名自定义 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) + +### 基准与集成 + +- 可在原管家和对话配置中选择 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 重定向验证. [#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) +- [@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/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/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" From b1776d93ec5a2ade8cc677a09ab557d067fad450 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:42:29 +0800 Subject: [PATCH 02/60] ci: stop collecting retired Ark demonstration tests Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .github/workflows/ark-turn.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 From 417df3e147342cc9a1e1573cd8e86cc9b48a7678 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:42:29 +0800 Subject: [PATCH 03/60] test(workspace): align conversation smoke with the transcript contract Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../personal-workspace-contract.test.mjs | 3 ++- .../conversation-activity.mjs | 10 ++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs index 75740c6376..0dc37e5a55 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs @@ -118,7 +118,8 @@ assert.doesNotMatch(page.match(/function operationProposalFields[\s\S]*?\n\}/)?. assert.match(page, /t\("proposal\.primary\.operationGroup"\)/, "Operation confirmation routes users to the bound group"); assert.match(chatData, /result_delivery:/, "Dashboard retains operation result-delivery readback"); assert.match(chatData, /return_delivery\??:/, "Chat messages retain manager return-delivery readback"); -assert.match(dashboard, /reconcileConversationReturns\(/, "Manager return polling uses the shared conversation-return read model"); +assert.match(dashboard, /reconcileConversationHistory\(/, "Manager polling uses the shared conversation history reader"); +assert.match(conversationReturns, /reconcileConversationReturns\(updated, sessionId,/, "History reconciliation uses the same return reader for each Session"); assert.match(conversationReturns, /source\.return_delivery \?\? row\.returnDelivery/, "Manager return polling refreshes delivery state after the message arrives"); assert.match(timeline + page, /ReturnDeliveryStatus/, "Both manager conversation surfaces render return delivery state"); for (const state of ["delivered", "verification_required", "explicit_unverified"]) { diff --git a/examples/personal-workspace-browser/conversation-activity.mjs b/examples/personal-workspace-browser/conversation-activity.mjs index 2eba84897f..3410ffd3c5 100644 --- a/examples/personal-workspace-browser/conversation-activity.mjs +++ b/examples/personal-workspace-browser/conversation-activity.mjs @@ -216,7 +216,10 @@ export const conversationActivityScenario = { const answer = "检查完成,下一步已列出。"; const runtime = page.__loopxRuntime; runtime.sessions.set(next.sessionId, { ...runtime.sessions.get(next.sessionId), active_turn_id: null, status: "ready" }); - runtime.messages.get(next.sessionId).push({ message_id: `${next.turnId}-assistant`, turn_id: next.turnId, role: "assistant", text: answer }); + runtime.messages.get(next.sessionId).push({ + message_id: `${next.turnId}-assistant`, turn_id: next.turnId, role: "assistant", text: answer, + created_at: await page.evaluate(() => new Date().toISOString()), + }); response.end(event(5, "turn.completed", { response: { schema_version: "loopx_chat_agent_response_v0", message: answer, proposals: [], gate: null } })); await route.fulfill({ json: { ok: true, session_id: next.sessionId, turn_id: next.turnId, status: "completed" } }); }); @@ -246,7 +249,10 @@ export const conversationActivityScenario = { const runtime = page.__loopxRuntime; runtime.turnMessages.set(turnId, body.message); runtime.sessions.set(sessionId, {...runtime.sessions.get(sessionId), active_turn_id: turnId, status: "busy"}); - runtime.messages.get(sessionId).push({message_id: `${turnId}-user`, turn_id: turnId, role: "user", text: body.message}); + runtime.messages.get(sessionId).push({ + message_id: `${turnId}-user`, turn_id: turnId, role: "user", text: body.message, + created_at: await page.evaluate(() => new Date().toISOString()), + }); api.turnRequests.push({message: body.message, sessionId, turnId}); let holdFirstRead = true; await page.route(`**/api/chat/sessions/${sessionId}`, snapshot => { From aa6f32b294527b26b6bdaa8bfe99b4f698e4b1bd Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:17:05 +0800 Subject: [PATCH 04/60] test: read the original quota capture in public smokes Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- examples/backlog-hygiene-smoke.py | 7 ++++++- examples/blocker-push-runtime-smoke.py | 7 ++++++- examples/control_plane/peer-agent-workspace-guard-smoke.py | 7 ++++++- examples/control_plane/quota-no-git-status-smoke.py | 5 ++++- examples/control_plane/quota-spend-agent-identity-smoke.py | 5 ++++- .../shared-runtime-material-projection-smoke.py | 5 ++++- examples/project/goal-vision-closed-successor-smoke.py | 7 ++++++- examples/project/project-agent-adoption-smoke.py | 5 ++++- examples/state-migration-smoke.py | 7 ++++++- 9 files changed, 46 insertions(+), 9 deletions(-) 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/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-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/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/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/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: From 3e3c8cea7fa9f2f498dab19b883326cab82aec8f Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:17:06 +0800 Subject: [PATCH 05/60] test: retire smokes for the removed Auto Research demo Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../auto-research-artifact-receipt-smoke.py | 204 ----------------- .../auto-research-evidence-writer-smoke.py | 176 --------------- .../auto-research-knn-continuation-smoke.py | 119 ---------- .../auto-research-rollout-append-smoke.py | 206 ------------------ ...uto-research-terminal-results-cli-smoke.py | 175 --------------- 5 files changed, 880 deletions(-) delete mode 100644 examples/auto-research-artifact-receipt-smoke.py delete mode 100644 examples/auto-research-evidence-writer-smoke.py delete mode 100644 examples/auto-research-knn-continuation-smoke.py delete mode 100644 examples/auto-research-rollout-append-smoke.py delete mode 100644 examples/auto-research-terminal-results-cli-smoke.py 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()) From a2bca7082e9d286788f8b707f2a79828333e2604 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:17:06 +0800 Subject: [PATCH 06/60] fix: delegate stored digest checks to the shared owner Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/capabilities/periodic_report/review.py | 3 ++- loopx/chat_store.py | 3 ++- tests/architecture/test_content_digest_single_owner.py | 3 +++ 3 files changed, 7 insertions(+), 2 deletions(-) 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..913a4208ef 100644 --- a/loopx/chat_store.py +++ b/loopx/chat_store.py @@ -36,6 +36,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 +518,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/tests/architecture/test_content_digest_single_owner.py b/tests/architecture/test_content_digest_single_owner.py index 8aa1326f6b..41f8329f58 100644 --- a/tests/architecture/test_content_digest_single_owner.py +++ b/tests/architecture/test_content_digest_single_owner.py @@ -164,6 +164,7 @@ "loopx.capabilities.benchmark_toolkit.trajectory_assessment_store", "loopx.capabilities.content_ops.item_lifecycle", "loopx.capabilities.deep_research.runtime", + "loopx.capabilities.issue_fix.cli", "loopx.capabilities.issue_fix.outcome_projection", "loopx.capabilities.issue_fix.reviewer_notification", "loopx.capabilities.machine_configuration.store", @@ -176,8 +177,10 @@ "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.update_artifact", From 445ac862ed2d4954fbf72b711845f95fc9c0f629 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:17:06 +0800 Subject: [PATCH 07/60] chore: pin the semantic definition budget to measured use Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- examples/semantic-vocabulary-drift-smoke.py | 2 +- loopx/semantics/vocabulary_v0.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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, From 3ac7584e373d1911dc89c69acc6e64ce0145a3ea Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:17:06 +0800 Subject: [PATCH 08/60] fix: bind settlement and presentation to the same runtime root Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/cli_commands/project_lifecycle_refresh_state.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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. From 579a211d46fba93378615976b7855fc4654d2269 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:17:07 +0800 Subject: [PATCH 09/60] docs: synchronize the accepted controller contract and RFC indexes Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/architecture/rfcs/STATUS.md | 2 +- docs/architecture/rfcs/STATUS.zh-CN.md | 2 +- .../rfcs/controller-executor-unified-contract-v0.md | 6 +++--- .../rfcs/controller-executor-unified-contract-v0.zh-CN.md | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) 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`。 代码中存在机制只证明已有实现边界,不代表双会话用户旅程或收益已经验收。 | 能力 | 源码事实 / 在途工作 | 对本设计的决定 | From b3da3a162c793d3d4d05783e76462bbe0300731a Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:17:07 +0800 Subject: [PATCH 10/60] test(workspace): use current acceptance, paging and runtime fixtures Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- ...goal-acceptance-contract-browser-smoke.mjs | 23 ++++--------------- ...ashboard-goal-acceptance-browser-smoke.mjs | 11 ++++++--- .../codex-attached.mjs | 16 +++++++++++-- .../fresh-install-backend.py | 5 +++- .../goal-masthead.mjs | 7 +++++- .../runtime-readiness-backend.py | 5 +++- .../test_goal_acceptance_observation.py | 4 ++-- 7 files changed, 43 insertions(+), 28 deletions(-) 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/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/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/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/goal-masthead.mjs b/examples/personal-workspace-browser/goal-masthead.mjs index 0d7bd04ff5..4bffdfc147 100644 --- a/examples/personal-workspace-browser/goal-masthead.mjs +++ b/examples/personal-workspace-browser/goal-masthead.mjs @@ -320,6 +320,11 @@ export const goalMastheadScenario = { const source = mode === "mixed" ? mixed : mode === "complete" ? complete : mode === "mixed-complete" ? mixedComplete : mode === "optional" ? optionalComplete : native; Object.assign(queue, structuredClone(source)); + if (["mixed", "bounded", "unknown"].includes(mode)) { + // The producer can now return all 21 canonical rows. Keep the + // bounded case on the actual three-row projection from that read. + queue.user_todos.items = structuredClone(queue.project_asset.user_todos.items); + } if (mode === "empty") { queue.user_todos.items = []; queue.project_asset.user_todos.items = []; @@ -336,7 +341,7 @@ export const goalMastheadScenario = { await openGoal(ui.page, title); const needs = await vital(ui.page, locale === "en" ? "Needs you" : "等你决定"); const expected = mode === "complete" ? "21" : mode === "mixed-complete" ? "1" : mode === "optional" ? "0" : "—"; - assert.equal(needs.value, expected, "Only complete classified rows qualify a blocking total; open User Todo totals do not classify hidden rows"); + assert.equal(needs.value, expected, `${locale}/${mode}: Only complete classified rows qualify a blocking total`); const partial = !["complete", "mixed-complete", "optional"].includes(mode); const coverageHelp = partial ? await readVitalHelp(ui.page, needs.cell) : ""; if (partial) assert.match(coverageHelp, locale === "en" ? /oldest unknown/ : /最早时间未知/); 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/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( From d627226de7ceb8296a2b2e710794e4e6ad8a97f3 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:38:08 +0800 Subject: [PATCH 11/60] refactor(chat): simplify the existing allocation import Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/chat_store.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/loopx/chat_store.py b/loopx/chat_store.py index 913a4208ef..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, From cf88d53f77abe0dd6919917f43586622b7a8a00a Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:38:08 +0800 Subject: [PATCH 12/60] test(workspace): drain read-only fixture request bodies Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../personal-workspace-browser/research-history-backend.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/examples/personal-workspace-browser/research-history-backend.py b/examples/personal-workspace-browser/research-history-backend.py index 8ec5329ab2..601bbf409c 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 @@ -182,6 +183,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 From 7eaa2b3e0c3dbee8984b2911d20018814bb9a532 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:38:08 +0800 Subject: [PATCH 13/60] test(cli): cover current Todo list diagnostic options Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- tests/test_cli_argument_diagnostics.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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" ) From 69413657f1216aeda663d2df5fef3ee9d6650286 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:39:05 +0800 Subject: [PATCH 14/60] fix(workspace): use shared tokens in the report reader Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../src/features/personal-workspace/report-reviews.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/presentation/dashboard/src/features/personal-workspace/report-reviews.css b/apps/presentation/dashboard/src/features/personal-workspace/report-reviews.css index 134dc56504..a37606e34b 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/report-reviews.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/report-reviews.css @@ -10,13 +10,13 @@ .goal-report-reviews :is([data-result-catalog], [data-result-reader]):focus { outline: 2px solid var(--pw-blue-ink); outline-offset: 4px; } .report-review-tools, .report-review-tools > button, .goal-report-reviews .result-catalog-return { display: inline-flex; align-items: center; gap: 8px; } .report-review-reader { min-width: 0; margin-top: 8px; } -.report-review-masthead { padding: 8px 0; border-bottom: 1px solid var(--pw-border); } +.report-review-masthead { padding: 8px 0; border-bottom: 1px solid var(--pw-line); } .report-review-masthead h2 { margin: 8px 0; font-size: var(--text-title); line-height: var(--leading-title); overflow-wrap: anywhere; } .report-review-masthead time, .report-review-masthead span { font-size: var(--text-caption); line-height: var(--leading-caption); color: var(--pw-muted); } .report-review-actions { display: flex; flex-wrap: wrap; align-items: start; gap: 8px; padding: 8px 0 16px; } .report-review-actions .personal-decision-bar { flex: 1; } .report-review-actions .personal-compact-menu { flex: 0 0 auto; } -.report-review-reader iframe { display: block; width: 100%; height: min(1000px, 80vh); min-height: 560px; border: 1px solid var(--pw-border); border-radius: 6px; background: var(--pw-surface); } +.report-review-reader iframe { display: block; width: 100%; height: min(1000px, 80vh); min-height: 560px; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-card); } .report-review-prose { font-size: var(--text-lead); line-height: var(--leading-prose); } .report-review-reader > details { padding: 16px 0; overflow-wrap: anywhere; } @media (max-width: 640px) { .report-review-masthead > div { flex-wrap: wrap; } .report-review-reader iframe { min-height: 640px; } .report-review-actions .personal-decision-bar { flex-basis: 100%; } } From cb8ab1525315ca1dcf254b1f0c1a141192473105 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:17:48 +0800 Subject: [PATCH 15/60] Separate bounded quota context reading from capture projection Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/cli_commands/quota_capture.py | 464 +---------------- loopx/cli_commands/quota_context_reader.py | 475 ++++++++++++++++++ .../test_context_delivery_cli.py | 15 +- 3 files changed, 495 insertions(+), 459 deletions(-) create mode 100644 loopx/cli_commands/quota_context_reader.py diff --git a/loopx/cli_commands/quota_capture.py b/loopx/cli_commands/quota_capture.py index 57a075e001..68288bca8b 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -14,20 +14,26 @@ 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_bytes as _context_selection_bytes, - context_selection_view as _context_selection_view, - validate_handoff_socket, CONTEXT_CAPTURE_READ_GUIDANCE, CONTEXT_CAPTURE_PAGE_GUIDANCE, context_capture_read_command, + 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_PAGE_BYTES, NATIVE_CONTEXT_PAGE_OUTPUT_BYTES, native_context_page_budget, + NATIVE_CONTEXT_PAGE_BYTES, native_context_page_budget, +) +from .quota_context_reader import ( + _capture_read_reference, + _native_context_read_bytes, + _native_context_reference, + _native_output_bytes, + model_context_reference, + read_context_capture, ) @@ -40,454 +46,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 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") - from ..control_plane.effect_runtime import MAX_REQUEST_BYTES - - # Default JSON reads share the native large-page profile. Explicit - # smaller budgets can recover a truncated range of this same capture. - 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("context-read requires a nonnegative byte offset and page-bytes between 256 and 8192; " - "larger pages require a sufficient max-page-output-bytes within the capture 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) != {"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 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) - 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={ - "select": selectors, "initial_read": False, - "list_fields": list_fields, "list_depth": list_depth, "sha256": expected}) - 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. - """ - page_bytes, output_bytes = native_context_page_budget() - # Retain an explicitly smaller caller budget from the generated command. - words = shlex.split(view["command"]) - for option in ("--page-bytes", "--max-page-output-bytes"): - if option in words: - if option == "--page-bytes": - page_bytes = int(words[words.index(option) + 1]) - else: - output_bytes = int(words[words.index(option) + 1]) - - 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"] - 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=page_bytes, max_page_output_bytes=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=page_bytes, max_page_output_bytes=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, - 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 initial_read_pointers: - overview = result.pop("overview") - instruction = result["instruction"] - if any(pointer.endswith("/vision_authoring/rule") for pointer in initial_read_pointers): - instruction = ("Before composing a Vision, select the complete vision_authoring object " - "at the displayed step in this capture. " + instruction) - result["instruction"] = ("Start with first_read_command for current facts and source navigation from this capture, " - "without reading an older baseline. " - "Read each complete optional attachment from its captured step before using it. " + instruction) - result["first_read_command"] = context_capture_read_command(current_path, current["sha256"], - page_bytes=page_bytes, max_page_output_bytes=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, diff --git a/loopx/cli_commands/quota_context_reader.py b/loopx/cli_commands/quota_context_reader.py new file mode 100644 index 0000000000..90e043da23 --- /dev/null +++ b/loopx/cli_commands/quota_context_reader.py @@ -0,0 +1,475 @@ +"""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_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 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") + from ..control_plane.effect_runtime import MAX_REQUEST_BYTES + + # Default JSON reads share the native large-page profile. Explicit + # smaller budgets can recover a truncated range of this same capture. + 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("context-read requires a nonnegative byte offset and page-bytes between 256 and 8192; " + "larger pages require a sufficient max-page-output-bytes within the capture 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) != {"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 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) + 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={ + "select": selectors, "initial_read": False, + "list_fields": list_fields, "list_depth": list_depth, "sha256": expected}) + 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. + """ + page_bytes, output_bytes = native_context_page_budget() + # Retain an explicitly smaller caller budget from the generated command. + words = shlex.split(view["command"]) + for option in ("--page-bytes", "--max-page-output-bytes"): + if option in words: + if option == "--page-bytes": + page_bytes = int(words[words.index(option) + 1]) + else: + output_bytes = int(words[words.index(option) + 1]) + + 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"] + 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=page_bytes, max_page_output_bytes=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=page_bytes, max_page_output_bytes=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, + 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 initial_read_pointers: + overview = result.pop("overview") + instruction = result["instruction"] + if any(pointer.endswith("/vision_authoring/rule") for pointer in initial_read_pointers): + instruction = ("Before composing a Vision, select the complete vision_authoring object " + "at the displayed step in this capture. " + instruction) + result["instruction"] = ("Start with first_read_command for current facts and source navigation from this capture, " + "without reading an older baseline. " + "Read each complete optional attachment from its captured step before using it. " + instruction) + result["first_read_command"] = context_capture_read_command(current_path, current["sha256"], + page_bytes=page_bytes, max_page_output_bytes=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/tests/control_plane/test_context_delivery_cli.py b/tests/control_plane/test_context_delivery_cli.py index de5c27dfce..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,10 +181,13 @@ 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) + # 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=1600, format="json") + offset=0, page_bytes=8192, max_page_output_bytes=limit, format="json") page = read_context_capture(args) assert page["ok"] and not page["eof"] + assert len(json.dumps(page, ensure_ascii=False, separators=(",", ":")).encode()) + 1 <= limit args.offset = page["next_offset"] + 1 assert "UTF-8 boundary" in read_context_capture(args)["reason"] args.offset = page["next_offset"] @@ -643,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", @@ -675,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, From c35d49d99396e9f869e6e700976c02669a4873e2 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:17:49 +0800 Subject: [PATCH 16/60] Use the local usage evidence opt-in in the host duration test Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- tests/test_loopx_turn_executor.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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" From 395cc6e683d915c19bb123cac8e81cf439749008 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 7 Nov 2026 19:53:48 +0800 Subject: [PATCH 17/60] test(benchmark): read direct scheduler state in the budget-stop smoke (#8277) Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Co-authored-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> (cherry picked from commit bfecc925d027f3888e1b14c037834aeb40257c1a) Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- benchmark/tests/test_task_entry.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/benchmark/tests/test_task_entry.py b/benchmark/tests/test_task_entry.py index 6b58f0c3a8..799417855c 100644 --- a/benchmark/tests/test_task_entry.py +++ b/benchmark/tests/test_task_entry.py @@ -795,10 +795,12 @@ def test_real_scheduler_stops_after_one_budget_exhausted_wake(planning_env, mode "--text", "[P0] Complete synthetic task", "--execute"], env=env, check=True, capture_output=True, text=True) def quota(): + # The worker reads the direct control result, even when progressive + # context delivery is the default for agent-facing reads. result = subprocess.run([ *cli, "quota", "should-run", "--goal-id", env["LOOPX_GOAL_ID"], "--agent-id", env["LOOPX_AGENT_ID"], "--runtime-profile", "generic_cli", - "--include-detail", "scheduler", + "--context-delivery", "off", "--include-detail", "scheduler", ], env=env, text=True, capture_output=True) assert result.returncode == 0, result.stdout + result.stderr return json.loads(result.stdout) From 01382ef4ae13aa90024e651b66d323ab52755926 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:24:37 +0800 Subject: [PATCH 18/60] Keep inherited SSH Goal capture prompts within the native host budget Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/control_plane/heartbeat/builder.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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, **( From 185fc7cf337a254e4cbbffb875cbd49f2162faca Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:24:37 +0800 Subject: [PATCH 19/60] Exercise current heartbeat, cadence, and replan contracts in public smokes Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- examples/control_plane/heartbeat-prompt-smoke.py | 15 +++++++++++++++ examples/project/configure-goal-smoke.py | 6 ++++++ .../project/goal-vision-replan-contract-smoke.py | 8 ++++---- 3 files changed, 25 insertions(+), 4 deletions(-) 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/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-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, ) From 18da39e96fc1e9f4c52bbbf27c09e135a17c3858 Mon Sep 17 00:00:00 2001 From: Lusen Dong Date: Sat, 7 Nov 2026 19:54:00 +0800 Subject: [PATCH 20/60] fix(control-plane): preserve original-Turn closeout after automatic archival (#8264) * fix(control-plane): close original Turn after automatic Todo archival Signed-off-by: lusendong.6789 Co-authored-by: TRAE CLI * test(control-plane): retain Goal checks through archived Todo closeout Signed-off-by: lusendong.6789 Co-authored-by: TRAE CLI * fix(control-plane): preserve accepted evidence during archived closeout Signed-off-by: lusendong.6789 Co-authored-by: TRAE CLI --------- Signed-off-by: lusendong.6789 Co-authored-by: TRAE CLI (cherry picked from commit 172d80d630f424b0b44dd513863ba02b5f13d55c) Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../rfcs/monorepo-distribution-split-v0.md | 55 +++++ .../monorepo-distribution-split-v0.zh-CN.md | 45 +++++ .../coordination/todo_terminal_lifecycle.ts | 139 ++++++++----- .../test_quota_settlement_cli.py | 190 ++++++++++++++++++ 4 files changed, 381 insertions(+), 48 deletions(-) diff --git a/docs/architecture/rfcs/monorepo-distribution-split-v0.md b/docs/architecture/rfcs/monorepo-distribution-split-v0.md index e7dcd45329..f6c6dafbcf 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.md @@ -1619,6 +1619,61 @@ invalid Vision/workspace also remain refusals. The temporary measurement harness and raw captures stay private; this recipe describes the repeated workload and its oracles, not a distributed benchmark runner or public reproduction receipt. +### 2026-11-07 — Original-Turn closeout survives automatic Todo archival + +The complete CLI comparison in #8257 exposed a lifecycle failure with 101 +synthetic Todos. Fresh source-console reproductions on `4a33eb4b5` retain +`todo_archived` on both File and default SQLite after ordinary completion, +durable writeback and one quota debit. The frozen comparison remains unchanged; +these failed episodes are not accepted latency samples. + +The proposed repair stays in `todo_terminal_lifecycle.ts`. Its existing +same-Turn fence and exact ordinary-completion receipt verification also qualify +an archived Todo's closeout before the active-only rejection. One local helper +owns both active and archived parent checks. Original claim/lease, task digest, +validation declaration/receipt and provider CAS checks remain in force; archive +selection and retention thresholds are unchanged. This permits only the +original completion's terminal phase, without reopening archived work. + +Real File/SQLite console fixtures now complete the large-state sequence and +replay completion, writeback, quota spend and terminal closeout with one debit +and an unchanged independently verified JSON result. The durable CLI regression +uses a real validator and ordinary hard-lease claim: premature settlement, +unrelated archived work, another actor, claim or Turn, and changed completion +intent are refused. Terminal closeout retains the original passed validation +receipt, first completion time and archive state without rerunning the validator. +Existing provider conformance retains source/digest, validation, lease and replay +checks; an isolated real PostgreSQL suite qualifies its shared owner separately. + +The author proposal also integrates main `abf144202` (#8161), whose explicit +Goal-scoped checks do not gate local Todo completion. The archive regression +covers a failed Goal commitment alongside the local validator: ordinary and +terminal completion still preserve the original receipt and one debit, never +rerun the Goal checker, and leave the overall check failed. This is author-side +integration coverage; it does not replace independent review or reviewed-repair +wheel qualification. + +Independent review of #8264 at `12627eff8` found that changing only `--evidence` +could replace the archived Todo's accepted artifact while returning the old +validation receipt. All four File/SQLite × Goal-check fixtures reproduce this +failure on that head. The revision integrates target main `10f66ec5e` and rejects +different supplied evidence before archived closeout. Its new archived-closeout +receipt also retains the accepted evidence, so a changed retry cannot bypass the +check through receipt recovery. Omitted or empty evidence with a valid completion +note keeps the original value; the CLI's completion-note requirement still applies. +Historical fingerprints and older receipts remain compatible; active +completion behavior is unchanged. The regression checks canonical evidence, +original validation and completion time, and once-only validation/writeback/debit +before and after terminal replay. The earlier reviewed head remains unaccepted; +the revised head still needs independent review. + +This is a proposed lifecycle repair enabling the remaining R1/V4 journey and +large-state comparison. Independent exact-head review, reviewed-repair wheel +readback and remaining guard/parser characterization still govern the next +step. It does not upgrade an installed host or qualify interruption/restart, +split installation, full R0/V10/R1, M1/#8011, D2/D5/D6/D3, context/model, +adoption or complete-cost benefit. I3 remains at least one minor release. + ## Appendix B: Decision log | Date | Decision | Owner / approval | Alternatives | Normative sections changed | diff --git a/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md b/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md index d1ae12fed5..f1ac16c0ce 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md @@ -1223,6 +1223,51 @@ stale claim 使用普通 release 后的 lease。它们必须在 writeback/debit Vision/workspace 同样保留拒绝。临时测量 harness 和 raw capture 保持私有;这个 recipe 描述重复的 workload/oracle,不是分发的 benchmark runner 或公共复现 receipt。 +### 2026-11-07 — 原 Turn 终结在 Todo 自动归档后仍可完成 + +#8257 的完整 CLI 比较在 101 个合成 Todo 下暴露了生命周期故障。基于 +`4a33eb4b5` 的新源码 console 复现在 File 和默认 SQLite 上均保留同一 +`todo_archived`:普通完成、持久写回和一次额度扣账已提交。冻结比较保持原样, +这些失败 episode 不计入验收通过的延迟样本。 + +修复提案仍由 `todo_terminal_lifecycle.ts` 拥有。在仅限 active 的拒绝之前, +复用已有同 Turn fence 与精确普通 completion receipt 校验,验证已归档 Todo +的终结资格。一个文件内 helper 统一 active 和已归档路径的父 receipt 检查。 +原 claim/lease、任务 digest、validation 声明与 receipt、provider CAS 校验 +继续生效;归档选择与保留阈值保持原样。允许的只是原 completion 的终结阶段, +不会重新开放已归档工作。 + +真实 File/SQLite console fixture 现在可以完成大状态整轮,并回放 completion、 +写回、扣账和 terminal closeout:只有一次扣账,独立验证的 JSON 结果保持原样。 +持久 CLI 回归使用真实 validator 和普通 hard-lease claim,拒绝过早结算、 +无关归档工作、其他 actor、claim 或 Turn,以及变更后的 completion intent。 +终结保留原已通过的 validation receipt、首次完成时间和归档状态,不重复执行 +validator。既有 provider conformance 继续检查 source/digest、validation、 +lease 和 replay;隔离真实 PostgreSQL suite 单独验证其共享 owner。 + +作者提案还集成了 main `abf144202`(#8161):显式 Goal 范围的检查不约束局部 +Todo 完成。归档回归在局部 validator 之外加入未通过的整体 Goal 承诺检查: +普通完成和终结仍保留原 receipt 与一次扣账,不重复执行 Goal checker,整体 +检查继续失败。这是作者阶段的集成覆盖,不能替代独立审阅或审阅修复后的 +wheel 资格验证。 + +#8264 `12627eff8` 的独立审阅发现:只改 `--evidence` 就能替换已归档 Todo +接受的产物,却仍返回旧 validation receipt。File/SQLite × 有无整体 Goal 检查 +的四个 fixture 均在该 head 复现失败。修订集成 target main `10f66ec5e`,在归档 +终结前拒绝不同的显式 evidence;新归档 closeout receipt 同时保存已接受证据, +让变更后的重试不能通过 receipt recovery 绕过检查。带有效 completion note 时, +省略或空 evidence 保留原值;CLI 原有的完成说明要求继续适用。 +历史 fingerprint 和旧 receipt 继续兼容,active completion 行为保持原样。 +回归在 terminal replay 前后检查 canonical evidence、原 validation 和完成时间, +以及只执行一次的 validation/writeback/debit。此前被审阅的 head 仍未获验收, +修订 head 仍需独立审阅。 + +这是支持后续 R1/V4 journey 和大状态比较的生命周期修复提案。下一步仍需 +独立 exact-head review、审阅修复后的 wheel 回读,以及剩余 guard/parser +刻画。它不会升级 installed host,也不验收中断/重启、split installation、 +完整 R0/V10/R1、M1/#8011、D2/D5/D6/D3、context/model、adoption 或完整成本 +收益。I3 继续保留至少一个 minor release。 + ## 附录 B:决策日志 | 日期 | 决策 | 负责人 / 批准 | 备选 | 变更的规范章节 | diff --git a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts index c7c9fb11da..387f7e65f1 100644 --- a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts +++ b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts @@ -487,6 +487,15 @@ function terminalFailure( }; } +function completionEvidenceRejection( + input: CoordinationTodoTerminalLifecycleInput, + acceptedEvidence: unknown, +): CoordinationTodoTerminalLifecycleResult | null { + if (input.evidence === null || input.evidence === (acceptedEvidence ?? null)) return null; + return terminalFailure("terminal_completion_receipt_mismatch", + "Archived closeout must retain the evidence accepted by ordinary completion.", {}, "decision_rejection"); +} + function terminalRequestSha(input: CoordinationTodoTerminalLifecycleInput): string { const completionPolicyIdentity = input.completion_policy_request === null ? null @@ -1194,6 +1203,59 @@ function semanticDuplicate( return undefined; } +async function readSameTurnCompletionReceipt( + store: AuthorityStore, + input: ResolvedCoordinationTodoTerminalLifecycleInput, + normalized: CoordinationTodoTerminalLifecycleInput, + todo: JsonObject, +): Promise { + if (input.command !== "complete" || normalized.operation_identity.kind !== "completion_turn" || + !input.requested_no_followup) return null; + const validationRequired = todo.completion_validation_required === true; + const validationSha256 = todo.completion_validation_sha256; + let fence; + try { + fence = evaluateTodoCompletionFence({schema_version: TODO_COMPLETION_FENCE_REQUEST_SCHEMA, + projection_source: "materialized", goal_id: input.goal_id, todo_id: input.todo_id, todo, + requested_no_followup: input.requested_no_followup, + requested_completion_turn_key: input.requested_completion_turn_key, + requested_completion_identity_source: input.requested_completion_identity_source}); + } catch (error) { + return terminalFailure("invalid_todo_completion_transaction", + error instanceof Error ? error.message : "Invalid original completion identity"); + } + if (fence.outcome === "continue" && fence.reason === "same_turn_terminal_upgrade") { + const originalInput = {...input, + ...(normalized.claim_operation_id === undefined ? {} : {claim_operation_id: normalized.claim_operation_id, + lease_idempotency_key: normalized.lease_idempotency_key, lease_expected_version: normalized.lease_expected_version}), + requested_no_followup: false, + operation_id: completionTurnOperationId(input, false)}; + const original = await terminalReceipt(originalInput, terminalRequestSha(originalInput)).read(store); + if (original === null) return terminalFailure("terminal_completion_receipt_required", + "Same-Turn closeout requires the original accepted ordinary completion receipt.", {}, "decision_rejection"); + if (original.status !== "replayed") return original; + const originalValidation = original.validation_receipt as JsonObject | null; + const receiptDigest = originalValidation?.validation_declaration_sha256; + // Revision-zero receipts may omit the digest. The exact recovered + // request already binds the canonical declaration, actor and lease; + // later declaration revisions still require the receipt's own digest. + const requiresDigest = Number.isSafeInteger(todo.completion_validation_revision) && + Number(todo.completion_validation_revision) > 0; + if (original.completion_identity_key !== input.requested_completion_turn_key || + (validationRequired && (originalValidation === null || + typeof originalValidation !== "object" || originalValidation.passed !== true || + (receiptDigest != null && receiptDigest !== validationSha256) || + (requiresDigest && receiptDigest !== validationSha256))) || + (input.validation_receipt !== null && + canonicalAuthoritySha256(input.validation_receipt) !== canonicalAuthoritySha256(original.validation_receipt))) { + return terminalFailure("terminal_completion_receipt_mismatch", + "Original completion validation and identity must still match the completed Todo.", {}, "decision_rejection"); + } + return original; + } + return null; +} + /** * Execute complete/supersede as one provider-neutral CAS transaction. * @@ -1239,6 +1301,12 @@ export async function executeCoordinationTodoTerminalLifecycle( const receipt = terminalReceipt(input, requestSha); const replay = await receipt.read(store); if (replay !== null) { + // New archived closeout receipts preserve accepted evidence separately + // from the historical request fingerprint. Older receipts still replay. + if (Object.hasOwn(replay, "evidence")) { + const rejection = completionEvidenceRejection(input, replay.evidence); + if (rejection !== null) return rejection; + } // Older accepted lifecycle receipts lack the original quota binding. // Add it to this response from verified history; never rewrite the receipt. if (replay.status === "replayed" && isLifecycleCloseout(input) && @@ -1267,7 +1335,9 @@ export async function executeCoordinationTodoTerminalLifecycle( if (!await authoritySourcesCurrent()) return terminalFailure(AUTHORITY_SOURCE_CHANGED.code, AUTHORITY_SOURCE_CHANGED.reason, {}, "decision_rejection"); const observation = await receipt.observe(store); - if (observation.kind === "receipt") return observation.result; + if (observation.kind === "receipt") return Object.hasOwn(observation.result, "evidence") + ? completionEvidenceRejection(input, observation.result.evidence) ?? observation.result + : observation.result; if (observation.authority.status !== "loaded") { return { schema_version: COORDINATION_TODO_TERMINAL_LIFECYCLE_RESULT_SCHEMA, @@ -1360,13 +1430,19 @@ export async function executeCoordinationTodoTerminalLifecycle( "Todo requirements changed after Turn admission; reread the task and run a fresh Turn", {todo_id: input.todo_id}, "decision_rejection"); } + let terminalUpgradeReceipt: CoordinationTodoTerminalLifecycleResult | null = null; if (todo.archive_state !== "active") { - return terminalFailure( - "todo_archived", - "Todo terminal lifecycle requires an active Todo", - {}, - "decision_rejection", - ); + // Ordinary completion may archive this Todo before its original Turn + // settles. Only its exact accepted parent permits this second phase; + // archive alone grants no new execution or completion authority. + terminalUpgradeReceipt = await readSameTurnCompletionReceipt(store, input, normalized, todo); + if (terminalUpgradeReceipt === null) { + return terminalFailure("todo_archived", "Todo terminal lifecycle requires an active Todo", + {}, "decision_rejection"); + } + if (terminalUpgradeReceipt.status !== "replayed") return terminalUpgradeReceipt; + const rejection = completionEvidenceRejection(input, todo.evidence); + if (rejection !== null) return rejection; } const authorityTodo = todo; let edit: ReturnType | null = null; @@ -1607,50 +1683,13 @@ export async function executeCoordinationTodoTerminalLifecycle( } } let completion: ReturnType | null = null; - let terminalUpgradeReceipt: JsonObject | null = null; if (input.command === "complete") { const validationRequired = todo.completion_validation_required === true; const validationSha256 = todo.completion_validation_sha256; - if (normalized.operation_identity.kind === "completion_turn" && input.requested_no_followup) { - let fence; - try { - fence = evaluateTodoCompletionFence({schema_version: TODO_COMPLETION_FENCE_REQUEST_SCHEMA, - projection_source: "materialized", goal_id: input.goal_id, todo_id: input.todo_id, todo, - requested_no_followup: input.requested_no_followup, - requested_completion_turn_key: input.requested_completion_turn_key, - requested_completion_identity_source: input.requested_completion_identity_source}); - } catch (error) { - return terminalFailure("invalid_todo_completion_transaction", - error instanceof Error ? error.message : "Invalid original completion identity"); - } - if (fence.outcome === "continue" && fence.reason === "same_turn_terminal_upgrade") { - const originalInput = {...input, - ...(normalized.claim_operation_id === undefined ? {} : {claim_operation_id: normalized.claim_operation_id, - lease_idempotency_key: normalized.lease_idempotency_key, lease_expected_version: normalized.lease_expected_version}), - requested_no_followup: false, - operation_id: completionTurnOperationId(input, false)}; - const original = await terminalReceipt(originalInput, terminalRequestSha(originalInput)).read(store); - if (original === null) return terminalFailure("terminal_completion_receipt_required", - "Same-Turn closeout requires the original accepted ordinary completion receipt.", {}, "decision_rejection"); - if (original.status !== "replayed") return original; - const originalValidation = original.validation_receipt as JsonObject | null; - const receiptDigest = originalValidation?.validation_declaration_sha256; - // Revision-zero receipts may omit the digest. The exact recovered - // request already binds the canonical declaration, actor and lease; - // later declaration revisions still require the receipt's own digest. - const requiresDigest = Number.isSafeInteger(todo.completion_validation_revision) && - Number(todo.completion_validation_revision) > 0; - if (original.completion_identity_key !== input.requested_completion_turn_key || - (validationRequired && (originalValidation === null || - typeof originalValidation !== "object" || originalValidation.passed !== true || - (receiptDigest != null && receiptDigest !== validationSha256) || - (requiresDigest && receiptDigest !== validationSha256))) || - (input.validation_receipt !== null && - canonicalAuthoritySha256(input.validation_receipt) !== canonicalAuthoritySha256(original.validation_receipt))) { - return terminalFailure("terminal_completion_receipt_mismatch", - "Original completion validation and identity must still match the completed Todo.", {}, "decision_rejection"); - } - terminalUpgradeReceipt = original; + if (terminalUpgradeReceipt === null) { + terminalUpgradeReceipt = await readSameTurnCompletionReceipt(store, input, normalized, todo); + if (terminalUpgradeReceipt !== null && terminalUpgradeReceipt.status !== "replayed") { + return terminalUpgradeReceipt; } } if (validationRequired && !implicitMonitorNoChange && @@ -1985,6 +2024,10 @@ export async function executeCoordinationTodoTerminalLifecycle( todo_id: input.todo_id, command: input.command, changed, + // Freeze this archived phase's evidence for receipt-only recovery without + // adding a fresh authority read or changing legacy operation fingerprints. + ...(authorityTodo.archive_state !== "active" && terminalUpgradeReceipt !== null + ? {evidence: authorityTodo.evidence ?? null} : {}), ...(input.decision_outcome === null ? {} : {decision_outcome: input.decision_outcome}), ...(followthrough?.unblock_resume ? {unblock_resume: followthrough.unblock_resume} : {}), ...(followthrough?.decision_scope_resolution ? {decision_scope_resolution: followthrough.decision_scope_resolution} : {}), diff --git a/tests/control_plane/test_quota_settlement_cli.py b/tests/control_plane/test_quota_settlement_cli.py index a20fdcbab7..3e5518e800 100644 --- a/tests/control_plane/test_quota_settlement_cli.py +++ b/tests/control_plane/test_quota_settlement_cli.py @@ -8059,6 +8059,196 @@ def test_completed_todo_reentry_preserves_original_settlement_commands( assert settled["interaction_contract"]["agent_channel"]["must_attempt"] is False +@pytest.mark.parametrize("goal_commitment", [False, True]) +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_original_turn_closes_automatically_archived_todo_once( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, goal_commitment: bool, +) -> None: + from canonical_authority_fixture import isolate_sqlite_runtime + + # A real validator checks a synthetic result; its counter detects any + # repeated validation during closeout or receipt recovery. + project, runtime, registry_path = _write_fixture(tmp_path, claimed=False) + state_path = _configure_read_only_todo(project) + artifact = project / "result.json" + artifact.write_text('{"sum":45}\n', encoding="utf-8") + counter = project / "validator-count.txt" + validator = ( + "import json; from pathlib import Path; " + f"assert json.loads(Path({str(artifact)!r}).read_text()) == {{'sum':45}}; " + f"p=Path({str(counter)!r}); " + "p.write_text(str(int(p.read_text())+1 if p.exists() else 1))" + ) + argv = quote(json.dumps([sys.executable, "-c", validator]), safe="") + state_path.write_text( + state_path.read_text(encoding="utf-8").replace( + "action_kind=validate ", + f"action_kind=validate validation_command_argv={argv} ", 1, + ) + "".join( + f"\n- [x] [P2] Completed synthetic history {index}.\n" + f" \n" + for index in range(100) + ), + encoding="utf-8", + ) + if provider == "sqlite": + isolate_sqlite_runtime(tmp_path, monkeypatch) + _initialize_fixture_authority(project, runtime, provider=provider, + handoff_mode="hard_lease", leases=[]) + goal_counter = project / "goal-validator-count.txt" + if goal_commitment: + # A failed cross-task Goal check cannot replace or block the local + # validator, nor become accepted just because this Todo closes. + checks = tmp_path / "goal-checks.json" + checks.write_text(json.dumps({ + "scope": {"kind": "goal"}, "objective": "Fulfil the overall commitment", + "non_goals": [], "bindings": [], "criteria": [{ + "id": "overall-commitment", "description": "Overall work remains incomplete", + "validation_argv": [sys.executable, "-c", + "from pathlib import Path; " + f"p=Path({str(goal_counter)!r}); " + "p.write_text(str(int(p.read_text())+1 if p.exists() else 1)); " + "raise SystemExit(1)"], + }], + }), encoding="utf-8") + rc, inspected = _run_cli(registry_path, runtime, "goal-acceptance", "inspect", + "--goal-id", GOAL_ID) + assert rc == 0, inspected + rc, configured = _run_cli( + registry_path, runtime, "goal-acceptance", "configure", "--goal-id", GOAL_ID, + "--document", str(checks), "--operation-id", "overall-check", + "--expected-provider-revision", inspected["provider_revision"], "--execute", + ) + assert rc == 0, configured + rc, failed = _run_cli(registry_path, runtime, "goal-acceptance", "verify", + "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--execute") + assert rc == 1 and failed["checks_passed"] is False, failed + assert goal_counter.read_text() == "1" + claim_id = "original-archive-closeout-claim" + rc, claim = _run_cli(registry_path, runtime, "todo", "claim", + "--goal-id", GOAL_ID, "--todo-id", TODO_ID, + "--agent-id", AGENT_ID, "--claimed-by", AGENT_ID, + "--claim-operation-id", claim_id) + assert rc == 0, claim + binding = ("--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--todo-id", TODO_ID, "--turn-instance-id", TURN_ID) + rc, guard = _run_cli(registry_path, runtime, "quota", "should-run", + "--codex-app", *binding) + assert rc == 0 and guard["normal_delivery_allowed"], guard + identity = guard["heartbeat_receipt"]["settlement_identity"] + ordinary_args = ("todo", "complete", *binding, "--claim-operation-id", claim_id, + "--evidence", "artifact:verified-sum") + rc, ordinary = _run_cli(registry_path, runtime, *ordinary_args) + assert rc == 0 and ordinary["validation_receipt"]["passed"], ordinary + rc, archived = _run_cli(registry_path, runtime, "todo", "list", + "--goal-id", GOAL_ID, "--todo-id", TODO_ID, + "--include-archived") + assert rc == 0, archived + assert archived["todo"]["archive_state"] == "archive" + assert archived["todo"]["status"] == "done" + completed_at = archived["todo"]["completed_at"] + terminal_args = (*ordinary_args, "--no-follow-up") + rc, premature = _run_cli(registry_path, runtime, *terminal_args) + assert rc == 1, premature + assert "requires matching writeback and quota spend receipts" in premature["error"] + assert _spend_run_count(runtime) == 0 + + rc, written = _run_cli( + registry_path, runtime, "refresh-state", *binding, + "--classification", "archived_result_verified", + "--delivery-batch-scale", "single_surface", + "--delivery-outcome", "outcome_progress", + "--vision-state", "vision_closed", + "--vision-acceptance", "The independently verified sum is durably recorded.", + "--no-global-sync", "--suppress-external-sinks", + ) + assert rc == 0, written + spend_args = ("quota", "spend-slot", *binding, "--slots", "1", + "--source", "heartbeat", "--execute") + for _ in range(2): + rc, spent = _run_cli(registry_path, runtime, *spend_args) + assert rc == 0, spent + validation_runs = counter.read_text() + assert validation_runs == "1" + + # A reused validation receipt cannot authorize replacing the artifact that + # the archived Todo accepted. Changing only evidence must fail before the + # terminal receipt exists and after that receipt can be replayed. + replacement_args = tuple( + "artifact:unvalidated-replacement" if arg == "artifact:verified-sum" else arg + for arg in terminal_args + ) + rc, replacement = _run_cli(registry_path, runtime, *replacement_args) + assert rc == 1, replacement + assert replacement["error_code"] == "terminal_completion_receipt_mismatch", replacement + rc, preserved = _run_cli(registry_path, runtime, "todo", "list", + "--goal-id", GOAL_ID, "--todo-id", TODO_ID, + "--include-archived") + assert rc == 0, preserved + assert preserved["todo"]["evidence"] == "artifact:verified-sum" + assert counter.read_text() == validation_runs + assert _spend_run_count(runtime) == 1 + + # An unrelated archived task, another actor, and a different claim or Turn + # cannot borrow the completed task's original receipt or settlement. + attempts = [ + ("todo", "complete", "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, + "--todo-id", "todo_history_0000"), + tuple("unregistered-actor" if arg == AGENT_ID else arg for arg in terminal_args), + tuple("unrelated-claim" if arg == claim_id else arg for arg in terminal_args), + tuple("different-turn" if arg == TURN_ID else arg for arg in terminal_args), + (*terminal_args, "--clear-claim"), + ] + for attempt in attempts: + rc, refused = _run_cli(registry_path, runtime, *attempt) + assert rc == 1, refused + assert _spend_run_count(runtime) == 1 + assert counter.read_text() == validation_runs + assert refused["error_code"] == "coordination_operation_identity_mismatch", refused + + rc, terminal = _run_cli(registry_path, runtime, *terminal_args) + assert rc == 0, terminal + assert terminal["completion_continuation"] == "no_followup" + assert terminal["completion_recovery"] == "same_turn_terminal_closeout" + assert terminal["completion_identity_key"] == ordinary["completion_identity_key"] + assert terminal["validation_receipt"] == ordinary["validation_receipt"] + assert [row["step_kind"] for row in terminal["settlement_result"]["receipts"]] == [ + "validation", "durable_writeback", "quota_spend", "terminal_closeout", + ] + evidence_index = terminal_args.index("--evidence") + omitted_evidence = terminal_args[:evidence_index] + terminal_args[evidence_index + 2:] + empty_evidence = (*terminal_args[:evidence_index + 1], "", *terminal_args[evidence_index + 2:]) + for args in (omitted_evidence, empty_evidence): + rc, refused = _run_cli(registry_path, runtime, *args) + assert rc == 1 and "requires --note or --evidence" in refused["error"], refused + completion_note = ("--note", "Close the original independently verified result.") + for args in (terminal_args, ordinary_args, + (*omitted_evidence, *completion_note), (*empty_evidence, *completion_note)): + rc, replay = _run_cli(registry_path, runtime, *args) + assert rc == 0 and replay["idempotent_replay"], replay + rc, replacement = _run_cli(registry_path, runtime, *replacement_args) + assert rc == 1, replacement + assert replacement["error_code"] == "terminal_completion_receipt_mismatch", replacement + rc, final = _run_cli(registry_path, runtime, "todo", "list", + "--goal-id", GOAL_ID, "--todo-id", TODO_ID, + "--include-archived") + assert rc == 0, final + assert final["todo"]["archive_state"] == "archive" + assert final["todo"]["completed_at"] == completed_at + assert final["todo"]["evidence"] == "artifact:verified-sum" + assert final["todo"]["completion_turn_key"] == identity["effect_id"] + assert counter.read_text() == validation_runs + assert _spend_run_count(runtime) == 1 + assert _classification_count(runtime, "archived_result_verified") == 1 + if goal_commitment: + rc, overall = _run_cli(registry_path, runtime, "goal-acceptance", "inspect", + "--goal-id", GOAL_ID) + assert rc == 0, overall + assert overall["goal_acceptance_contract"]["status"] == "failed" + assert goal_counter.read_text() == "1" + + @pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_read_only_settlement_omits_non_causal_delivery_workspace( tmp_path: Path, From ff1f3155978a84667b5fab20ee158befca18fd23 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:32:58 +0800 Subject: [PATCH 21/60] Validate live authority and current lifecycle contracts in public smokes Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- ...ta-cleared-blocker-successor-gate-smoke.py | 2 +- .../control_plane/status-markdown-smoke.py | 3 ++- .../status_markdown_agent_lane_fixtures.py | 25 +++++++++++++++++-- .../todo-archive-completed-smoke.py | 23 ++++++++--------- .../control_plane/work-lane-contract-smoke.py | 19 +++++++++----- examples/dreaming-proposal-decision-smoke.py | 4 ++- ...-goal-channel-human-gate-delivery-smoke.py | 3 +++ 7 files changed, 55 insertions(+), 24 deletions(-) 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/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/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"}} From f5c6385caea9421eab079bfc3d10e2692d77719a Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:32:58 +0800 Subject: [PATCH 22/60] Control running Turn and browser clock timing in workspace smokes Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- examples/personal-workspace-browser/chat-recovery.mjs | 6 ++++++ .../conversation-activity.mjs | 10 +++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/examples/personal-workspace-browser/chat-recovery.mjs b/examples/personal-workspace-browser/chat-recovery.mjs index 5f2e3e1d4b..4070f8a1a8 100644 --- a/examples/personal-workspace-browser/chat-recovery.mjs +++ b/examples/personal-workspace-browser/chat-recovery.mjs @@ -561,6 +561,10 @@ export const chatRecoveryScenario = { // 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; @@ -605,6 +609,8 @@ 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"); 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(); From 949eb3ccdbd62e867f42c0e735fe7b7b5b1dd7b0 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:32:58 +0800 Subject: [PATCH 23/60] Resolve drive-qualified wheel records with the native path parser Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/doctor.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/loopx/doctor.py b/loopx/doctor.py index df1d6d37ec..7177dc321a 100644 --- a/loopx/doctor.py +++ b/loopx/doctor.py @@ -149,7 +149,9 @@ def python_distribution_install(module_path: Path) -> dict[str, Any]: expected = module_path.resolve() owns_module = any( item.as_posix().endswith("loopx/doctor.py") - and Path(item.locate()).resolve() == expected + # A RECORD entry is a POSIX string. Let the native path parser + # interpret an absolute Windows drive before joining the install root. + and Path(installed.locate_file(item.as_posix())).resolve() == expected for item in installed.files or () ) if not owns_module: From a14b4798d3c3fbc05498ed37aebd3e8c77780a12 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:31:57 +0800 Subject: [PATCH 24/60] fix(codex): reuse the captured managed claim after lost readiness Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> (cherry picked from commit e165096b71969f088185e14f77855ca1a8d46c5a) Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/reference/required-work-context.md | 5 +- .../codex_execution_preparation.py | 21 ++++- .../test_quota_execution_preparation.py | 83 +++++++++++++++++++ 3 files changed, 104 insertions(+), 5 deletions(-) diff --git a/docs/reference/required-work-context.md b/docs/reference/required-work-context.md index d6b7fac970..d8ef75f7d5 100644 --- a/docs/reference/required-work-context.md +++ b/docs/reference/required-work-context.md @@ -373,7 +373,10 @@ After a Host claim, the ready guard uses the private returns its writer digest with the fields needed to verify the same Todo and Turn. The hook verifies that digest when it opens the original file. If the file changed, the receipt preserves the claim and pins recovery to the original -bytes before work can continue. +bytes before work can continue. If readiness is interrupted, retrying the +same signed capture reuses its managed claim operation. The authority owner +checks the original lease on replay. A retired lease needs a fresh eligible +preparation capture. The Goal availability adapter still reads and UTF-8-validates the complete file and hashes all bytes for its revision. With native context delivery off, only the path and revision of unmarked or mixed sources cross the diff --git a/loopx/control_plane/turn_driver/codex_execution_preparation.py b/loopx/control_plane/turn_driver/codex_execution_preparation.py index 8eb671980a..dc3cdb4840 100644 --- a/loopx/control_plane/turn_driver/codex_execution_preparation.py +++ b/loopx/control_plane/turn_driver/codex_execution_preparation.py @@ -7,6 +7,7 @@ from __future__ import annotations import json +import re import subprocess from collections.abc import Callable, Mapping, Sequence from pathlib import Path @@ -189,16 +190,29 @@ def execute_codex_execution_preparation( ): raise ValueError("Codex execution preparation requires a physical workspace") invoke = runner or _default_runner - if context_directory is not None and (capture_directory is None or not turn_instance_id): - raise ValueError("Native execution preparation requires a bound captured Turn") + claim_args = expected_claim + claim_operation_id = None + if context_directory is not None: + if capture_directory is None or not turn_instance_id: + raise ValueError("Native execution preparation requires a bound captured Turn") + source_sha256 = request.get("source_sha256") + if not isinstance(source_sha256, str) or re.fullmatch(r"[0-9a-f]{64}", source_sha256) is None: + raise ValueError("Native execution preparation requires its original capture digest") + # The same capture can reappear after a lost readiness result. Let the + # managed claim owner recover the original receipt and current lease. + claim_operation_id = f"todo-claim:codex-capture:{source_sha256}" + claim_args = [*expected_claim, "--claim-operation-id", claim_operation_id] claimed = _run_json( - expected_claim, + claim_args, registry_path=registry_path, runtime_root=runtime_root, cwd=workspace, timeout=timeout / 2, runner=invoke, ) + claim_reference = claimed.get("claim_operation_id") + if claim_operation_id is not None and claim_reference != claim_operation_id: + raise RuntimeError("Native execution preparation did not recover its original managed claim") guard = [ "quota", "should-run", "--goal-id", goal_id, @@ -259,7 +273,6 @@ def execute_codex_execution_preparation( if not isinstance(digest, str) or len(digest) != 64: raise ValueError("The guard did not return its published capture digest") receipt["capture_sha256"] = digest - claim_reference = claimed.get("claim_operation_id") if isinstance(claim_reference, str) and claim_reference: receipt["claim_operation_id"] = claim_reference # The Host already paid for the captured complete decision. Its caller diff --git a/tests/control_plane/test_quota_execution_preparation.py b/tests/control_plane/test_quota_execution_preparation.py index 2473c022d9..4920c2eb38 100644 --- a/tests/control_plane/test_quota_execution_preparation.py +++ b/tests/control_plane/test_quota_execution_preparation.py @@ -384,6 +384,89 @@ def hook(): assert log.read_bytes() == after_claim +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("released", [False, True], ids=["same-execution", "released-execution"]) +def test_native_hook_replays_the_original_claim_after_guard_timeout( + tmp_path, monkeypatch, capsys, provider, released, +): + import io + from loopx.cli import main + from loopx.control_plane.turn_driver import codex_context, codex_execution_preparation + + tmp_path = tmp_path.resolve() + monkeypatch.chdir(tmp_path) + session, turn = "claim-replay-session", "claim-replay-turn" + monkeypatch.setenv("CODEX_THREAD_ID", session) + native = tmp_path / "native" + native.mkdir(mode=0o700) + event = {"session_id": session, "cwd": str(tmp_path), + "hook_event_name": "SessionStart", "source": "startup"} + assert codex_context.codex_context_hook(str(native), event) == {} + before = _run(tmp_path, monkeypatch, provider, [], "--codex-app", + "--turn-instance-id", turn, "--todo-id", TODO) + assert before["execution_preparation"]["state"] == "claim_required" + capture = tmp_path / "before-claim" + capture.mkdir(mode=0o700) + codex_context.prepare_codex_context(before, directory=str(native), capture_directory=capture, + goal_id=GOAL, agent_id=AGENT, surface="quota_packet", capture_id=turn) + + run = codex_execution_preparation._default_runner + claims, guards = [], [] + + def interrupt_first_guard(command, **kwargs): + if "--prepared-capture-reference" in command: + guards.append(command[command.index("--turn-instance-id") + 1]) + if len(guards) == 1: + raise subprocess.TimeoutExpired(command, kwargs["timeout"]) + result = run(command, **kwargs) + if "claim" in command: + claims.append(json.loads(result.stdout)) + return result + + monkeypatch.setattr(codex_execution_preparation, "_default_runner", interrupt_first_guard) + registry, runtime = tmp_path / "registry.json", tmp_path / "runtime" + command = ["--registry", str(registry), "--runtime-root", str(runtime), "--format", "json", + "quota", "context-hook", "--codex-app", "--context-delivery", "on", + "--codex-context-dir", str(native)] + + def hook(): + data = json.dumps({**event, "hook_event_name": "PostToolUse"}).encode() + monkeypatch.setattr(sys, "stdin", io.TextIOWrapper(io.BytesIO(data))) + capsys.readouterr() + assert main(command) == 0 + return json.loads(capsys.readouterr().out) + + def read(*args): + code, payload = run_json_cli_result(*args, registry_path=registry, runtime_root=runtime, cwd=tmp_path) + assert code == 0 and payload["ok"], payload + return payload + + assert hook() == {} + original_id = claims[0]["claim_operation_id"] + original = read("todo", "receipt", "--goal-id", GOAL, "--operation-id", original_id) + lease = original["receipts"][0]["result"]["lease"] + if released: + read("task-lease", "release", "--goal-id", GOAL, "--todo-id", TODO, "--owner", AGENT, + "--expected-version", str(lease["version"]), "--idempotency-key", lease["idempotency_key"]) + + result = hook() + current = read("task-lease", "inspect", "--goal-id", GOAL, "--todo-id", TODO) + assert len(claims) == 2 + if released: + assert result == {} and not claims[1]["ok"] + assert not current["active"] and current["lease"]["lease_epoch"] == lease["lease_epoch"] + assert guards == [turn] + else: + context = result["hookSpecificOutput"]["additionalContext"] + receipt = json.loads(context.rsplit("Receipt: ", 1)[1]) + assert receipt["claim_operation_id"] == claims[1]["claim_operation_id"] == original_id + assert receipt["turn_instance_id"] == turn and receipt["execution_preparation"]["state"] == "ready" + assert current["active"] and current["lease"] == lease + assert guards == [turn, turn] + readback = read("todo", "receipt", "--goal-id", GOAL, "--operation-id", original_id) + assert readback["cursor"] == original["cursor"] and readback["receipts"] == original["receipts"] + + @pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("multiple_candidates", [False, True]) def test_projected_claim_prepares_the_same_receipt_bound_turn( From c7cab275f646eb7316696cc15a664d529221f835 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:44:31 +0800 Subject: [PATCH 25/60] Require a capture-bound claim identity across native Host upgrades Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> (cherry picked from commit 8e7c9d6a92cbf2089583d279322d59de987047ad) Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/reference/required-work-context.md | 15 ++- loopx/cli_commands/quota.py | 14 ++- loopx/cli_commands/quota_capture.py | 37 +++--- .../turn_driver/codex_context.py | 5 +- .../codex_execution_preparation.py | 40 ++++-- .../test_quota_execution_preparation.py | 116 +++++++++++------- 6 files changed, 149 insertions(+), 78 deletions(-) diff --git a/docs/reference/required-work-context.md b/docs/reference/required-work-context.md index d8ef75f7d5..6d1a3587eb 100644 --- a/docs/reference/required-work-context.md +++ b/docs/reference/required-work-context.md @@ -374,9 +374,18 @@ returns its writer digest with the fields needed to verify the same Todo and Turn. The hook verifies that digest when it opens the original file. If the file changed, the receipt preserves the claim and pins recovery to the original bytes before work can continue. If readiness is interrupted, retrying the -same signed capture reuses its managed claim operation. The authority owner -checks the original lease on replay. A retired lease needs a fresh eligible -preparation capture. +same original decision capture reuses its managed claim operation. The +authority owner checks the original lease on replay. Presenting the saved +decision again preserves its claim binding. Older captures without the +binding stay readable. Preserve any original claim receipt and use +`quota should-run` with the same Goal, Agent, Todo and Turn and a fresh +capture if preparation is still required. A retired lease requires a fresh +eligible preparation capture. + +中文:Host 重试绑定原始决策 capture 的领取操作,由权威 owner 验证原租约。 +重新展示同一份快照会保留绑定。缺少绑定的旧快照仍可读,保留已有领取回执。 +仍需准备时,用原 Goal、Agent、Todo 和 Turn 运行 `quota should-run`, +生成新的 capture,再按当前准入结果准备。已退役的租约需要新的合格 capture。 The Goal availability adapter still reads and UTF-8-validates the complete file and hashes all bytes for its revision. With native context delivery off, only the path and revision of unmarked or mixed sources cross the diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index 92339b96f0..f4fb6298a3 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -386,6 +386,17 @@ def _emit_quota_result( _export("attach_host_poll_receipt")( context.status_payload, args, payload, registry_path=registry_path, ) + native_context = context_delivery_enabled and bool(getattr(args, "codex_context_dir", None) or getattr(args, "codex_context_root", None)) + claim_preparation = payload.get("execution_preparation") + bind_native_claim = (args.quota_command == "should-run" and native_context + and capture_directory is not None and payload.get("ok") is True + and isinstance(claim_preparation, Mapping) and claim_preparation.get("state") == "claim_required") + if bind_native_claim: + from ..control_plane.turn_driver.codex_execution_preparation import NATIVE_CLAIM_REPLAY_BOUND + + # Only the live guard may mark immutable bytes as eligible for a + # capture-bound claim. Reading or presenting old bytes cannot add it. + payload = {**payload, NATIVE_CLAIM_REPLAY_BOUND: True} try: captured_digest = capture_decision(capture_directory, payload) if getattr(args, "prepared_capture_reference", False) and payload.get("ok") is True: @@ -432,7 +443,6 @@ def _emit_quota_result( # Handoff releases the immutable original before any presentation. Native # actual-input observation owns its projection when explicitly selected; # never wrap one context-delivery wire in a second projection. - native_context = context_delivery_enabled and bool(getattr(args, "codex_context_dir", None) or getattr(args, "codex_context_root", None)) deferred_context = getattr(args, "defer_context_presentation", False) if (not deferred_context and args.format == "json" and succeeded and delivery_request is not None and not native_context and context_delivery_enabled): @@ -451,7 +461,7 @@ def _emit_quota_result( assert captured_digest is not None # Native publication requires the captured original. payload = project_native_quota_context(payload, args, capture_directory=capture_directory, capture_id=captured_digest, decision_identity=decision_identity, progressive_enabled=activation["progressive_enabled"], - reader_argv=reader_argv) + reader_argv=reader_argv, claim_source_sha256=captured_digest if bind_native_claim else None) except (QuotaDecisionTransportError, OSError, ValueError) as exc: if not isinstance(exc, QuotaDecisionTransportError): exc = QuotaDecisionTransportError( diff --git a/loopx/cli_commands/quota_capture.py b/loopx/cli_commands/quota_capture.py index 68288bca8b..89242cc998 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -183,7 +183,8 @@ def view(kind: str, ref: dict[str, Any], body: bytes) -> dict[str, Any]: def project_native_quota_context(payload: dict[str, Any], args: argparse.Namespace, *, capture_directory: Path, capture_id: str, decision_identity: tuple[str, str], - progressive_enabled: bool, reader_argv: list[str] | None) -> dict[str, Any]: + progressive_enabled: bool, reader_argv: list[str] | None, + claim_source_sha256: str | None = None) -> dict[str, Any]: """Prepare one already-decided native view; callers retain receipt failures.""" from ..control_plane.turn_driver.codex_context import ( NativeContextSessionUnavailable, codex_context_directory, prepare_codex_context, @@ -197,7 +198,7 @@ def project_native_quota_context(payload: dict[str, Any], args: argparse.Namespa capture_directory=capture_directory, goal_id=decision_identity[0], agent_id=decision_identity[1], surface="turn_envelope" if getattr(args, "turn_envelope", False) else "quota_packet", capture_id=capture_id, continuity_generation=getattr(args, "_context_delivery_generation", None), - progressive_enabled=progressive_enabled) + progressive_enabled=progressive_enabled, claim_source_sha256=claim_source_sha256) recovery = prepared.delivery.get("recovery") view = native_context_capture_view(prepared.delivery, capture_directory, context_directory=directory, progressive_enabled=progressive_enabled, initial_read_plan=prepared.initial_read_plan, @@ -318,6 +319,7 @@ def project_saved_context_capture(args: argparse.Namespace, *, hook_session: str pass # The complete original reference remains the fallback below. try: from ..control_plane.turn_driver.codex_context import codex_context_directory, prepare_codex_context + from ..control_plane.turn_driver.codex_execution_preparation import NATIVE_CLAIM_REPLAY_BOUND if not activation["enabled"]: raise ValueError("context delivery is off") @@ -356,7 +358,9 @@ def project_saved_context_capture(args: argparse.Namespace, *, hook_session: str 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), - progressive_enabled=activation["progressive_enabled"], hook_session=hook_session) + progressive_enabled=activation["progressive_enabled"], + claim_source_sha256=args.capture_sha256 if payload.get(NATIVE_CLAIM_REPLAY_BOUND) is True else None, + hook_session=hook_session) projected = prepared.delivery native_prepared = True if projected.get("schema_version") == "context_delivery_v1": @@ -812,6 +816,7 @@ def handle_native_context_command(args: argparse.Namespace, *, print_payload: Ca record_native_execution_preparation, ) from ..control_plane.turn_driver.codex_execution_preparation import ( + LegacyNativePreparationCapture, codex_execution_preparation_context, execute_codex_execution_preparation, ) @@ -830,11 +835,14 @@ def handle_native_context_command(args: argparse.Namespace, *, print_payload: Ca directory = codex_context_directory(directory=args.codex_context_dir, root=getattr(args, "codex_context_root", None), session=event.get("session_id"), create=True) payload = codex_context_hook(directory, event) + preparation_context = "" try: request = native_execution_preparation_request(directory, event) if request is not None: workspace = Path(event.get("cwd") or "") if workspace.is_absolute() and workspace.resolve() == Path.cwd().resolve(): + if "claim_source_sha256" not in request: + raise LegacyNativePreparationCapture() capture = ( Path(tempfile.mkdtemp(prefix="prepared-", dir=directory)) / "ready" if request.get("turn_instance_id") else None @@ -873,24 +881,21 @@ def handle_native_context_command(args: argparse.Namespace, *, print_payload: Ca receipt["context"]["recovery_command"] = context_capture_read_command( source, digest, reader_argv=current_loopx_cli_argv()) - existing = payload.get("hookSpecificOutput") - existing_context = ( - str(existing.get("additionalContext") or "") - if isinstance(existing, dict) - else "" - ) - payload["hookSpecificOutput"] = { - "hookEventName": event["hook_event_name"], - "additionalContext": " ".join(filter(None, [ - existing_context, - codex_execution_preparation_context(receipt), - ])), - } + preparation_context = codex_execution_preparation_context(receipt) + except LegacyNativePreparationCapture as error: + preparation_context = str(error) except (OSError, RuntimeError, subprocess.SubprocessError, ValueError, TypeError, RecursionError): # Preserve the original explicit action and any context # recovery output when Host-side preparation cannot prove # its postcondition. pass + if preparation_context: + existing = payload.get("hookSpecificOutput") + existing_context = str(existing.get("additionalContext") or "") if isinstance(existing, dict) else "" + payload["hookSpecificOutput"] = { + "hookEventName": event["hook_event_name"], + "additionalContext": " ".join(filter(None, [existing_context, preparation_context])), + } else: payload = {} except (OSError, RuntimeError, subprocess.SubprocessError, ValueError, TypeError, RecursionError): diff --git a/loopx/control_plane/turn_driver/codex_context.py b/loopx/control_plane/turn_driver/codex_context.py index a8f0b5ebb4..cac3def4a7 100644 --- a/loopx/control_plane/turn_driver/codex_context.py +++ b/loopx/control_plane/turn_driver/codex_context.py @@ -620,6 +620,7 @@ def prepare_codex_context(payload: dict[str, Any], *, directory: str | None, cap continuity_generation: str | None = None, include_initial_read_plan: bool = True, progressive_enabled: bool | None = None, + claim_source_sha256: str | None = None, hook_session: str | None = None) -> NativeContextPreparation: """Commit current recovery before optional projection or candidate updates. @@ -675,7 +676,9 @@ def prepare_codex_context(payload: dict[str, Any], *, directory: str | None, cap def publish_recovery(reference: dict[str, Any]) -> None: reference["command"] = context_capture_read_command(Path(reference["path"]), reference["sha256"], page_bytes=page_bytes, max_page_output_bytes=output_bytes) - preparation = compact_execution_preparation_request(payload) + preparation = compact_execution_preparation_request( + payload, claim_source_sha256=claim_source_sha256, + ) state["recovery"] = {**{key: reference[key] for key in ("path", "sha256", "command")}, "goal_id": goal_id, "agent_id": agent_id, "total_bytes": Path(reference["path"]).stat().st_size, diff --git a/loopx/control_plane/turn_driver/codex_execution_preparation.py b/loopx/control_plane/turn_driver/codex_execution_preparation.py index dc3cdb4840..949914ec96 100644 --- a/loopx/control_plane/turn_driver/codex_execution_preparation.py +++ b/loopx/control_plane/turn_driver/codex_execution_preparation.py @@ -7,20 +7,32 @@ from __future__ import annotations import json -import re import subprocess from collections.abc import Callable, Mapping, Sequence from pathlib import Path from typing import Any from ...command_invocation import current_loopx_cli_argv +from ..content_digest import BARE_SHA256_PATTERN CommandRunner = Callable[..., subprocess.CompletedProcess[str]] +NATIVE_CLAIM_REPLAY_BOUND = "native_claim_replay_bound" + + +class LegacyNativePreparationCapture(ValueError): + """The capture cannot identify any claim made by an older Host.""" + + def __init__(self) -> None: + super().__init__( + "This saved capture predates capture-bound Host claims. Preserve any existing claim receipt. " + "If preparation is still needed, run the current quota should-run for the original Goal, " + "Agent, Todo and Turn with a fresh capture and follow its preparation only if eligible." + ) def compact_execution_preparation_request( - payload: Mapping[str, Any], + payload: Mapping[str, Any], *, claim_source_sha256: str | None = None, ) -> dict[str, Any] | None: """Retain only the exact Host-executable action beside a private capture.""" @@ -63,6 +75,11 @@ def compact_execution_preparation_request( if isinstance(receipt, Mapping) else "" ) + claim_binding = {} + if turn_instance_id and claim_source_sha256 is not None: + operation_id = f"todo-claim:codex-capture:{claim_source_sha256}" + expected_claim += ["--claim-operation-id", operation_id] + claim_binding["claim_source_sha256"] = claim_source_sha256 return { "goal_id": goal_id, "agent_id": agent_id, @@ -76,6 +93,7 @@ def compact_execution_preparation_request( "runtime_root": str(runtime_root), }, **({"turn_instance_id": turn_instance_id} if turn_instance_id else {}), + **claim_binding, } @@ -174,7 +192,6 @@ def execute_codex_execution_preparation( not goal_id or not agent_id or not todo_id - or request.get("claim_args") != expected_claim or not registry_path.is_absolute() or registry_path.resolve() != registry_path or not runtime_root.is_absolute() @@ -190,20 +207,19 @@ def execute_codex_execution_preparation( ): raise ValueError("Codex execution preparation requires a physical workspace") invoke = runner or _default_runner - claim_args = expected_claim claim_operation_id = None - if context_directory is not None: - if capture_directory is None or not turn_instance_id: + if context_directory is not None or "source_sha256" in request: + if context_directory is None or capture_directory is None or not turn_instance_id: raise ValueError("Native execution preparation requires a bound captured Turn") - source_sha256 = request.get("source_sha256") - if not isinstance(source_sha256, str) or re.fullmatch(r"[0-9a-f]{64}", source_sha256) is None: + source_sha256 = request.get("claim_source_sha256") + if not isinstance(source_sha256, str) or BARE_SHA256_PATTERN.fullmatch(source_sha256) is None: raise ValueError("Native execution preparation requires its original capture digest") - # The same capture can reappear after a lost readiness result. Let the - # managed claim owner recover the original receipt and current lease. claim_operation_id = f"todo-claim:codex-capture:{source_sha256}" - claim_args = [*expected_claim, "--claim-operation-id", claim_operation_id] + expected_claim += ["--claim-operation-id", claim_operation_id] + if request.get("claim_args") != expected_claim: + raise ValueError("invalid Codex execution-preparation claim arguments") claimed = _run_json( - claim_args, + expected_claim, registry_path=registry_path, runtime_root=runtime_root, cwd=workspace, diff --git a/tests/control_plane/test_quota_execution_preparation.py b/tests/control_plane/test_quota_execution_preparation.py index 4920c2eb38..2222576bc7 100644 --- a/tests/control_plane/test_quota_execution_preparation.py +++ b/tests/control_plane/test_quota_execution_preparation.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import timedelta +import hashlib import json import os from pathlib import Path @@ -69,6 +70,8 @@ def _run( leases: list[dict[str, object]], *guard_args: str, todos: list[dict[str, object]] | None = None, + context_delivery: str = "off", + goal_suffix: str = "", ): isolate_sqlite_runtime(tmp_path, monkeypatch) runtime = tmp_path / "runtime" @@ -77,7 +80,7 @@ def _run( state.write_text( "---\nstatus: active\n---\n# Goal\n\n## Objective\n" "Deliver one validated synthetic change.\n\n## Next Action\n" - "Run the selected Todo.\n", + "Run the selected Todo.\n" + goal_suffix, encoding="utf-8", ) write_fixture_registry( @@ -106,7 +109,7 @@ def _run( ) code, packet = run_json_cli_result( "quota", "should-run", "--goal-id", GOAL, "--agent-id", AGENT, - "--context-delivery", "off", + "--context-delivery", context_delivery, *guard_args, registry_path=registry, runtime_root=runtime, ) @@ -114,6 +117,20 @@ def _run( return packet +def _present_saved_guard(tmp_path, native, capture, scenario, turn): + source = capture / "decision.json" + digest = hashlib.sha256(source.read_bytes()).hexdigest() + code, view = run_json_cli_result( + "quota", "context-project", "--runtime-profile", scenario, + "--context-delivery", "on", "--codex-context-dir", str(native), + "--goal-id", GOAL, "--agent-id", AGENT, "--turn-instance-id", turn, + "--capture-file", str(source), "--capture-sha256", digest, + registry_path=tmp_path / "registry.json", runtime_root=tmp_path / "runtime", + ) + assert code == 0 and view["ok"] is True, view + return digest + + @pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize( "leases,reason_code", @@ -202,16 +219,15 @@ def test_native_hook_returns_readable_ready_source_and_original_claim( assert codex_context.codex_context_hook(str(native), event) == {} original = {**_todo(), "text": "Deliver the complete synthetic request: 保留原文" + "。不可截断的验收条件" * 100, "note": "Preserve the original stop and once-only settlement."} + capture = tmp_path / "before-claim" before = _run(tmp_path, monkeypatch, provider, [], "--runtime-profile", scenario, - "--turn-instance-id", "preparation-turn", "--todo-id", TODO, todos=[original]) + "--turn-instance-id", "preparation-turn", "--todo-id", TODO, + "--defer-context-presentation", "--codex-context-dir", str(native), "--decision-output-dir", str(capture), + todos=[original], context_delivery="on", + goal_suffix="\n\n## Constraints\n\n" + "Preserve the complete Goal and its stop. 保留停止条件。\n" * 100) assert before["execution_preparation"]["state"] == "claim_required" - goal_file = tmp_path / "state.md" - goal_text = goal_file.read_text() + "\n\n## Constraints\n\n" + "Preserve the complete Goal and its stop. 保留停止条件。\n" * 100 - goal_file.write_text(goal_text) - capture = tmp_path / "before-claim" - capture.mkdir(mode=0o700) - codex_context.prepare_codex_context(before, directory=str(native), capture_directory=capture, - goal_id=GOAL, agent_id=AGENT, surface="quota_packet", capture_id="before-claim").delivery + goal_text = (tmp_path / "state.md").read_text() + digest = _present_saved_guard(tmp_path, native, capture, scenario, "preparation-turn") hook = [sys.executable, "-m", "loopx.entrypoint", "--registry", str(tmp_path / "registry.json"), "--runtime-root", str(tmp_path / "runtime"), "--format", "json", "quota", "context-hook", "--context-delivery", "on", "--runtime-profile", scenario, @@ -237,7 +253,7 @@ def test_native_hook_returns_readable_ready_source_and_original_claim( assert receipt["todo_id"] == TODO and receipt["turn_instance_id"] == "preparation-turn" assert receipt["execution_preparation"]["state"] == "ready" claim_reference = receipt["claim_operation_id"] - assert claim_reference.startswith("todo-claim:") + assert claim_reference == f"todo-claim:codex-capture:{digest}" ready = receipt["context"] assert ready["ok"], ready.get("reason") if location == "root": @@ -304,7 +320,6 @@ def test_native_hook_returns_readable_ready_source_and_original_claim( def test_prepared_hook_refuses_a_replaced_ready_capture_without_reclaim( tmp_path, monkeypatch, capsys, provider, replace_stage, ): - import hashlib import io from loopx.cli import main from loopx.control_plane.turn_driver import codex_context @@ -317,13 +332,10 @@ def test_prepared_hook_refuses_a_replaced_ready_capture_without_reclaim( native.mkdir(mode=0o700) event = {"session_id": session, "cwd": str(tmp_path), "hook_event_name": "SessionStart", "source": "startup"} assert codex_context.codex_context_hook(str(native), event) == {} - before = _run(tmp_path, monkeypatch, provider, [], "--codex-app", - "--turn-instance-id", "prepared-turn", "--todo-id", TODO) - assert before["execution_preparation"]["state"] == "claim_required" original = tmp_path / "preparation" - original.mkdir(mode=0o700) - codex_context.prepare_codex_context(before, directory=str(native), capture_directory=original, - goal_id=GOAL, agent_id=AGENT, surface="quota_packet", capture_id="claim-required").delivery + _run(tmp_path, monkeypatch, provider, [], "--codex-app", + "--turn-instance-id", "prepared-turn", "--todo-id", TODO, "--codex-context-dir", str(native), + "--decision-output-dir", str(original), context_delivery="on") from loopx.control_plane.turn_driver import codex_execution_preparation record = codex_context.record_native_execution_preparation run = codex_execution_preparation._default_runner @@ -385,9 +397,9 @@ def hook(): @pytest.mark.parametrize("provider", ["file", "sqlite"]) -@pytest.mark.parametrize("released", [False, True], ids=["same-execution", "released-execution"]) +@pytest.mark.parametrize("case", ["same-execution", "released-execution", "legacy-released-execution"]) def test_native_hook_replays_the_original_claim_after_guard_timeout( - tmp_path, monkeypatch, capsys, provider, released, + tmp_path, monkeypatch, capsys, provider, case, ): import io from loopx.cli import main @@ -402,13 +414,17 @@ def test_native_hook_replays_the_original_claim_after_guard_timeout( event = {"session_id": session, "cwd": str(tmp_path), "hook_event_name": "SessionStart", "source": "startup"} assert codex_context.codex_context_hook(str(native), event) == {} + capture = tmp_path / "before-claim" + legacy = case == "legacy-released-execution" + released = case != "same-execution" + native_args = () if legacy else ("--defer-context-presentation", "--codex-context-dir", str(native)) before = _run(tmp_path, monkeypatch, provider, [], "--codex-app", - "--turn-instance-id", turn, "--todo-id", TODO) + "--turn-instance-id", turn, "--todo-id", TODO, "--decision-output-dir", str(capture), + *native_args, context_delivery="off" if legacy else "on") assert before["execution_preparation"]["state"] == "claim_required" - capture = tmp_path / "before-claim" - capture.mkdir(mode=0o700) - codex_context.prepare_codex_context(before, directory=str(native), capture_directory=capture, - goal_id=GOAL, agent_id=AGENT, surface="quota_packet", capture_id=turn) + source = capture / "decision.json" + original_bytes = source.read_bytes() + digest = _present_saved_guard(tmp_path, native, capture, "codex_app_heartbeat", turn) run = codex_execution_preparation._default_runner claims, guards = [], [] @@ -441,22 +457,42 @@ def read(*args): assert code == 0 and payload["ok"], payload return payload - assert hook() == {} - original_id = claims[0]["claim_operation_id"] + if legacy: + # Before the upgrade the Host issued the ordinary claim without a + # persisted operation ID, then could lose the ready result. + legacy_claim = read(*before["execution_preparation"]["claim"]["cli_args"]) + original_id = legacy_claim["claim_operation_id"] + else: + assert hook() == {} + original_id = claims[0]["claim_operation_id"] + assert original_id == f"todo-claim:codex-capture:{digest}" original = read("todo", "receipt", "--goal-id", GOAL, "--operation-id", original_id) lease = original["receipts"][0]["result"]["lease"] if released: read("task-lease", "release", "--goal-id", GOAL, "--todo-id", TODO, "--owner", AGENT, "--expected-version", str(lease["version"]), "--idempotency-key", lease["idempotency_key"]) + if legacy: + legacy_result = hook() + guidance = legacy_result["hookSpecificOutput"]["additionalContext"] + assert "predates capture-bound Host claims" in guidance and "fresh capture" in guidance + assert "host_execution_prepared" not in guidance + assert _present_saved_guard(tmp_path, native, capture, "codex_app_heartbeat", turn) == digest result = hook() + if legacy: + assert result == legacy_result current = read("task-lease", "inspect", "--goal-id", GOAL, "--todo-id", TODO) - assert len(claims) == 2 - if released: + assert source.read_bytes() == original_bytes + if legacy: + assert not claims and not guards + assert not current["active"] and current["lease"]["lease_epoch"] == lease["lease_epoch"] + elif released: + assert len(claims) == 2 assert result == {} and not claims[1]["ok"] assert not current["active"] and current["lease"]["lease_epoch"] == lease["lease_epoch"] assert guards == [turn] else: + assert len(claims) == 2 context = result["hookSpecificOutput"]["additionalContext"] receipt = json.loads(context.rsplit("Receipt: ", 1)[1]) assert receipt["claim_operation_id"] == claims[1]["claim_operation_id"] == original_id @@ -583,22 +619,22 @@ def test_codex_lifecycle_hook_uses_captured_authority_from_another_project( ): from loopx.control_plane.turn_driver.codex_context import ( codex_context_hook, - prepare_codex_context, ) + tmp_path = tmp_path.resolve() + context_root = tmp_path / "context" + capture_root = tmp_path / "capture" + context_root.mkdir(mode=0o700) before = _run( tmp_path, monkeypatch, provider, [] if lease_state == "missing" else [_lease(expired=True)], "--runtime-profile", "codex_app_ssh_goal", - "--begin-turn", + "--begin-turn", "--defer-context-presentation", "--codex-context-dir", str(context_root), + "--decision-output-dir", str(capture_root), context_delivery="on", ) turn = before["heartbeat_receipt"]["turn_instance_id"] - context_root = tmp_path / "context" - capture_root = tmp_path / "capture" - context_root.mkdir(mode=0o700) - capture_root.mkdir(mode=0o700) foreign_workspace = tmp_path / "project-b" foreign_workspace.mkdir() monkeypatch.setenv("CODEX_THREAD_ID", "codex-hook-session") @@ -610,15 +646,7 @@ def test_codex_lifecycle_hook_uses_captured_authority_from_another_project( "source": "startup", } assert codex_context_hook(str(context_root), event) == {} - prepare_codex_context( - before, - directory=str(context_root), - capture_directory=capture_root, - goal_id=GOAL, - agent_id=AGENT, - surface="quota_packet", - capture_id="claim-required", - ).delivery + _present_saved_guard(tmp_path, context_root, capture_root, "codex_app_ssh_goal", turn) command = [ sys.executable, "-m", "loopx.entrypoint", From 15dc2f42914d5979d10e0175b2f330b91e160368 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:18:13 +0800 Subject: [PATCH 26/60] Verify captured native claims and retain the saved context delta path Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/reference/required-work-context.md | 10 ++-- loopx/cli_commands/quota.py | 14 +----- loopx/cli_commands/quota_capture.py | 32 +++++++++---- .../turn_driver/codex_context.py | 48 +++++++++++++++++-- .../codex_execution_preparation.py | 5 +- .../test_content_digest_single_owner.py | 3 +- .../test_quota_execution_preparation.py | 36 +++++++++----- 7 files changed, 103 insertions(+), 45 deletions(-) diff --git a/docs/reference/required-work-context.md b/docs/reference/required-work-context.md index 6d1a3587eb..5ffb560b56 100644 --- a/docs/reference/required-work-context.md +++ b/docs/reference/required-work-context.md @@ -375,15 +375,17 @@ Turn. The hook verifies that digest when it opens the original file. If the file changed, the receipt preserves the claim and pins recovery to the original bytes before work can continue. If readiness is interrupted, retrying the same original decision capture reuses its managed claim operation. The -authority owner checks the original lease on replay. Presenting the saved -decision again preserves its claim binding. Older captures without the -binding stay readable. Preserve any original claim receipt and use +authority owner checks the original lease on replay. The native session +keeps the latest eligible live guard digest separately from the capture. +Presenting the exact saved decision preserves that binding. Older captures +and copies with different bytes remain readable. Preserve any original claim receipt and use `quota should-run` with the same Goal, Agent, Todo and Turn and a fresh capture if preparation is still required. A retired lease requires a fresh eligible preparation capture. 中文:Host 重试绑定原始决策 capture 的领取操作,由权威 owner 验证原租约。 -重新展示同一份快照会保留绑定。缺少绑定的旧快照仍可读,保留已有领取回执。 +原生会话独立保存最新合格准入的摘要。重新展示逐字节一致的快照会保留绑定。 +旧快照和字节不同的副本仍可读,保留已有领取回执。 仍需准备时,用原 Goal、Agent、Todo 和 Turn 运行 `quota should-run`, 生成新的 capture,再按当前准入结果准备。已退役的租约需要新的合格 capture。 diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index f4fb6298a3..741e45b729 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -387,18 +387,8 @@ def _emit_quota_result( context.status_payload, args, payload, registry_path=registry_path, ) native_context = context_delivery_enabled and bool(getattr(args, "codex_context_dir", None) or getattr(args, "codex_context_root", None)) - claim_preparation = payload.get("execution_preparation") - bind_native_claim = (args.quota_command == "should-run" and native_context - and capture_directory is not None and payload.get("ok") is True - and isinstance(claim_preparation, Mapping) and claim_preparation.get("state") == "claim_required") - if bind_native_claim: - from ..control_plane.turn_driver.codex_execution_preparation import NATIVE_CLAIM_REPLAY_BOUND - - # Only the live guard may mark immutable bytes as eligible for a - # capture-bound claim. Reading or presenting old bytes cannot add it. - payload = {**payload, NATIVE_CLAIM_REPLAY_BOUND: True} try: - captured_digest = capture_decision(capture_directory, payload) + captured_digest = capture_decision(capture_directory, payload, args=args, native_context=native_context) if getattr(args, "prepared_capture_reference", False) and payload.get("ok") is True: assert captured_digest is not None # Preflight requires an explicit capture directory. from ..control_plane.turn_driver.codex_execution_preparation import prepared_capture_reference @@ -461,7 +451,7 @@ def _emit_quota_result( assert captured_digest is not None # Native publication requires the captured original. payload = project_native_quota_context(payload, args, capture_directory=capture_directory, capture_id=captured_digest, decision_identity=decision_identity, progressive_enabled=activation["progressive_enabled"], - reader_argv=reader_argv, claim_source_sha256=captured_digest if bind_native_claim else None) + reader_argv=reader_argv) except (QuotaDecisionTransportError, OSError, ValueError) as exc: if not isinstance(exc, QuotaDecisionTransportError): exc = QuotaDecisionTransportError( diff --git a/loopx/cli_commands/quota_capture.py b/loopx/cli_commands/quota_capture.py index 89242cc998..3d811fddad 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -183,8 +183,7 @@ def view(kind: str, ref: dict[str, Any], body: bytes) -> dict[str, Any]: def project_native_quota_context(payload: dict[str, Any], args: argparse.Namespace, *, capture_directory: Path, capture_id: str, decision_identity: tuple[str, str], - progressive_enabled: bool, reader_argv: list[str] | None, - claim_source_sha256: str | None = None) -> dict[str, Any]: + progressive_enabled: bool, reader_argv: list[str] | None) -> dict[str, Any]: """Prepare one already-decided native view; callers retain receipt failures.""" from ..control_plane.turn_driver.codex_context import ( NativeContextSessionUnavailable, codex_context_directory, prepare_codex_context, @@ -198,7 +197,7 @@ def project_native_quota_context(payload: dict[str, Any], args: argparse.Namespa capture_directory=capture_directory, goal_id=decision_identity[0], agent_id=decision_identity[1], surface="turn_envelope" if getattr(args, "turn_envelope", False) else "quota_packet", capture_id=capture_id, continuity_generation=getattr(args, "_context_delivery_generation", None), - progressive_enabled=progressive_enabled, claim_source_sha256=claim_source_sha256) + progressive_enabled=progressive_enabled) recovery = prepared.delivery.get("recovery") view = native_context_capture_view(prepared.delivery, capture_directory, context_directory=directory, progressive_enabled=progressive_enabled, initial_read_plan=prepared.initial_read_plan, @@ -319,7 +318,6 @@ def project_saved_context_capture(args: argparse.Namespace, *, hook_session: str pass # The complete original reference remains the fallback below. try: from ..control_plane.turn_driver.codex_context import codex_context_directory, prepare_codex_context - from ..control_plane.turn_driver.codex_execution_preparation import NATIVE_CLAIM_REPLAY_BOUND if not activation["enabled"]: raise ValueError("context delivery is off") @@ -354,12 +352,23 @@ 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. bind_capture_selection_transport(payload, args, capture_root=root) - prepared = prepare_codex_context(payload, directory=directory, capture_directory=cache, + presentation = payload + if getattr(args, "initial_read", False) 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), progressive_enabled=activation["progressive_enabled"], - claim_source_sha256=args.capture_sha256 if payload.get(NATIVE_CLAIM_REPLAY_BOUND) is True else None, hook_session=hook_session) projected = prepared.delivery native_prepared = True @@ -504,13 +513,20 @@ def prepare_decision_capture(args: argparse.Namespace) -> Path | None: return directory -def capture_decision(directory: Path | None, payload: dict[str, object]) -> str | None: +def capture_decision(directory: Path | None, payload: dict[str, object], *, + args: argparse.Namespace | None = None, native_context: bool = False) -> str | None: if directory is None: return from ..control_plane.turn_driver.decision_capture import write_decision_capture try: - return write_decision_capture(directory / "decision.json", payload, publish=os.replace) + digest = write_decision_capture(directory / "decision.json", payload, publish=os.replace) + if native_context and args is not None and args.quota_command == "should-run" and payload.get("ok") is True: + from ..control_plane.turn_driver.codex_context import record_native_claim_source + + record_native_claim_source(payload, digest, directory=args.codex_context_dir, + context_root=getattr(args, "codex_context_root", None)) + return digest except (OSError, ValueError) as exc: raise QuotaDecisionTransportError( "Decision capture failed after guard evaluation; the Turn receipt may " diff --git a/loopx/control_plane/turn_driver/codex_context.py b/loopx/control_plane/turn_driver/codex_context.py index cac3def4a7..635eb44449 100644 --- a/loopx/control_plane/turn_driver/codex_context.py +++ b/loopx/control_plane/turn_driver/codex_context.py @@ -210,6 +210,42 @@ def _save(root: Path, state: dict[str, Any]) -> None: write_decision_capture(root / "session.json", state, publish=os.replace) +def record_native_claim_source( + payload: Mapping[str, Any], digest: str, *, directory: str | None, context_root: str | None, +) -> None: + """Remember the live guard's latest eligible capture outside its JSON.""" + preparation = compact_execution_preparation_request(payload) + session = os.environ.get("CODEX_THREAD_ID") or None + if not preparation or not preparation.get("turn_instance_id") or (not session and directory is None): + return + location = codex_context_directory(directory=directory, root=context_root, session=session, create=True) + if location is None: + return + with _locked(location) as root: + if _state(root).get("session_id") in (None, session): + write_decision_capture(root / "claim-source.json", + {"session_id": session, "sha256": digest}, publish=os.replace) + + +def _original_native_claim_source( + root: Path, session: str, digest: object, *, bind_unattached: bool = False, +) -> bool: + if not isinstance(digest, str): + return False + try: + path = root / "claim-source.json" + saved = _read(path) + except (OSError, ValueError): + return False + bound = {"session_id": session, "sha256": digest} + if bind_unattached and saved == {**bound, "session_id": None}: + # An explicit private directory can receive a guard before startup. + # Adopt it once from the live session while holding its journal lock. + write_decision_capture(path, bound, publish=os.replace) + return True + return saved == bound + + def read_codex_context_reference(directory: str, *, goal_id: str | None, agent_id: str | None) -> tuple[str, str]: """Select an original capture after a lost hook; never assert retention. @@ -421,7 +457,10 @@ def native_execution_preparation_request( and previous.get("state") == "ready" ): return None - return {**request, "source_sha256": source_sha256} + action = {**request, "source_sha256": source_sha256} + if not _original_native_claim_source(root, session, action.get("claim_source_sha256")): + action.pop("claim_source_sha256", None) + return action except (OSError, ValueError, TypeError, RecursionError): return None @@ -620,7 +659,6 @@ def prepare_codex_context(payload: dict[str, Any], *, directory: str | None, cap continuity_generation: str | None = None, include_initial_read_plan: bool = True, progressive_enabled: bool | None = None, - claim_source_sha256: str | None = None, hook_session: str | None = None) -> NativeContextPreparation: """Commit current recovery before optional projection or candidate updates. @@ -672,13 +710,13 @@ def prepare_codex_context(payload: dict[str, Any], *, directory: str | None, cap state["source_recovery"] = {key: recovery[key] for key in ("path", "sha256")} state.pop("section_reads", None) # partial reads of an older source are not current coverage page_bytes, output_bytes = native_context_page_budget() + preparation = compact_execution_preparation_request(payload) + if preparation and _original_native_claim_source(root, session, capture_id, bind_unattached=True): + preparation = compact_execution_preparation_request(payload, claim_source_sha256=capture_id) def publish_recovery(reference: dict[str, Any]) -> None: reference["command"] = context_capture_read_command(Path(reference["path"]), reference["sha256"], page_bytes=page_bytes, max_page_output_bytes=output_bytes) - preparation = compact_execution_preparation_request( - payload, claim_source_sha256=claim_source_sha256, - ) state["recovery"] = {**{key: reference[key] for key in ("path", "sha256", "command")}, "goal_id": goal_id, "agent_id": agent_id, "total_bytes": Path(reference["path"]).stat().st_size, diff --git a/loopx/control_plane/turn_driver/codex_execution_preparation.py b/loopx/control_plane/turn_driver/codex_execution_preparation.py index 949914ec96..2c11461295 100644 --- a/loopx/control_plane/turn_driver/codex_execution_preparation.py +++ b/loopx/control_plane/turn_driver/codex_execution_preparation.py @@ -17,15 +17,14 @@ CommandRunner = Callable[..., subprocess.CompletedProcess[str]] -NATIVE_CLAIM_REPLAY_BOUND = "native_claim_replay_bound" class LegacyNativePreparationCapture(ValueError): - """The capture cannot identify any claim made by an older Host.""" + """The capture cannot prove a live guard bound its claim.""" def __init__(self) -> None: super().__init__( - "This saved capture predates capture-bound Host claims. Preserve any existing claim receipt. " + "This saved capture has no verified live guard binding for Host claims. Preserve any existing claim receipt. " "If preparation is still needed, run the current quota should-run for the original Goal, " "Agent, Todo and Turn with a fresh capture and follow its preparation only if eligible." ) diff --git a/tests/architecture/test_content_digest_single_owner.py b/tests/architecture/test_content_digest_single_owner.py index 41f8329f58..023c450263 100644 --- a/tests/architecture/test_content_digest_single_owner.py +++ b/tests/architecture/test_content_digest_single_owner.py @@ -182,7 +182,7 @@ "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", @@ -210,6 +210,7 @@ "loopx.control_plane.todos.completion_validation", "loopx.control_plane.todos.completion_validation_store", "loopx.control_plane.todos.machine_section_projection", + "loopx.control_plane.turn_driver.codex_execution_preparation", "loopx.control_plane.turn_driver.codex_sessions", "loopx.control_plane.turn_driver.decision_capture", "loopx.control_plane.turn_driver.operation_requests", diff --git a/tests/control_plane/test_quota_execution_preparation.py b/tests/control_plane/test_quota_execution_preparation.py index 2222576bc7..88dadc1fa1 100644 --- a/tests/control_plane/test_quota_execution_preparation.py +++ b/tests/control_plane/test_quota_execution_preparation.py @@ -397,7 +397,10 @@ def hook(): @pytest.mark.parametrize("provider", ["file", "sqlite"]) -@pytest.mark.parametrize("case", ["same-execution", "released-execution", "legacy-released-execution"]) +@pytest.mark.parametrize("case", [ + "same-execution", "released-execution", "legacy-released-execution", + "reencoded-released-execution", "legacy-marked-released-execution", +]) def test_native_hook_replays_the_original_claim_after_guard_timeout( tmp_path, monkeypatch, capsys, provider, case, ): @@ -415,7 +418,7 @@ def test_native_hook_replays_the_original_claim_after_guard_timeout( "hook_event_name": "SessionStart", "source": "startup"} assert codex_context.codex_context_hook(str(native), event) == {} capture = tmp_path / "before-claim" - legacy = case == "legacy-released-execution" + legacy = case.startswith("legacy-") released = case != "same-execution" native_args = () if legacy else ("--defer-context-presentation", "--codex-context-dir", str(native)) before = _run(tmp_path, monkeypatch, provider, [], "--codex-app", @@ -472,19 +475,27 @@ def read(*args): read("task-lease", "release", "--goal-id", GOAL, "--todo-id", TODO, "--owner", AGENT, "--expected-version", str(lease["version"]), "--idempotency-key", lease["idempotency_key"]) - if legacy: - legacy_result = hook() - guidance = legacy_result["hookSpecificOutput"]["additionalContext"] - assert "predates capture-bound Host claims" in guidance and "fresh capture" in guidance - assert "host_execution_prepared" not in guidance - assert _present_saved_guard(tmp_path, native, capture, "codex_app_heartbeat", turn) == digest + presented_capture = capture + unverified_copy = case in ("reencoded-released-execution", "legacy-marked-released-execution") + if unverified_copy: + presented_capture = tmp_path / "unverified" + presented_capture.mkdir(mode=0o700) + copy = presented_capture / "decision.json" + if legacy: + copy.write_text(json.dumps({**json.loads(original_bytes), "native_claim_replay_bound": True})) + else: + copy.write_bytes(original_bytes + b" ") + copy.chmod(0o600) + presented_digest = _present_saved_guard(tmp_path, native, presented_capture, "codex_app_heartbeat", turn) + assert (presented_digest != digest) == unverified_copy result = hook() - if legacy: - assert result == legacy_result current = read("task-lease", "inspect", "--goal-id", GOAL, "--todo-id", TODO) assert source.read_bytes() == original_bytes - if legacy: - assert not claims and not guards + if legacy or unverified_copy: + guidance = result["hookSpecificOutput"]["additionalContext"] + assert "no verified live guard binding" in guidance and "fresh capture" in guidance + assert "host_execution_prepared" not in guidance + assert len(claims) == len(guards) == (0 if legacy else 1) assert not current["active"] and current["lease"]["lease_epoch"] == lease["lease_epoch"] elif released: assert len(claims) == 2 @@ -625,6 +636,7 @@ def test_codex_lifecycle_hook_uses_captured_authority_from_another_project( context_root = tmp_path / "context" capture_root = tmp_path / "capture" context_root.mkdir(mode=0o700) + monkeypatch.delenv("CODEX_THREAD_ID", raising=False) before = _run( tmp_path, monkeypatch, From f8f7d9d4c0edcffebfde4f339d98e828e4b7fec5 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:18:13 +0800 Subject: [PATCH 27/60] Prepare native Steward work against the original Todo and lease Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../capabilities/native_chat/steward_turn.py | 108 ++++++++++++++---- loopx/cli_commands/turn.py | 23 +++- 2 files changed, 106 insertions(+), 25 deletions(-) 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/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"} From 7f8af96e72922b8a3487c84b7fb660e204daa032 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:18:13 +0800 Subject: [PATCH 28/60] Check current bound projections and preserve their recovery assertions Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- tests/control_plane/test_bound_owner_web_input.py | 4 ++-- tests/control_plane/test_start_goal_compact_projection.py | 1 + tests/test_collaboration_goal_instance.py | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) 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_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/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"]) From 972707790dd15aaf9b408ff63afe4ee4498703fb Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:18:13 +0800 Subject: [PATCH 29/60] Document the verified upgrade and native claim recovery paths Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/product/releases/v2.3.0.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/product/releases/v2.3.0.md b/docs/product/releases/v2.3.0.md index 0d60cf3792..c2e17f27e2 100644 --- a/docs/product/releases/v2.3.0.md +++ b/docs/product/releases/v2.3.0.md @@ -26,11 +26,14 @@ loopx doctor - 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, and Issue-Fix redirect verification. [#7855](https://github.com/loopx-project/loopx/pull/7855) +- [@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) @@ -91,7 +94,7 @@ loopx --format json goal-acceptance inspect --goal-id "$goal_id" ## 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, and Issue-Fix redirect verification. [#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) +- [@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) @@ -369,11 +372,14 @@ loopx doctor - 打开研究记录查看捕获的 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 重定向验证. [#7855](https://github.com/loopx-project/loopx/pull/7855) +- [@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) @@ -428,7 +434,7 @@ loopx doctor ### 社区贡献者 - [@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 重定向验证. [#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) +- [@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) From e9601c087926521d7b0352b69a19bb561dcb9891 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:23:41 +0800 Subject: [PATCH 30/60] Bind explicit Codex preflight to its existing native session Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../turn_driver/codex_context.py | 10 +++++--- .../test_quota_execution_preparation.py | 24 +++++++++++-------- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/loopx/control_plane/turn_driver/codex_context.py b/loopx/control_plane/turn_driver/codex_context.py index 635eb44449..92d88b37e4 100644 --- a/loopx/control_plane/turn_driver/codex_context.py +++ b/loopx/control_plane/turn_driver/codex_context.py @@ -222,9 +222,13 @@ def record_native_claim_source( if location is None: return with _locked(location) as root: - if _state(root).get("session_id") in (None, session): - write_decision_capture(root / "claim-source.json", - {"session_id": session, "sha256": digest}, publish=os.replace) + active_session = _state(root).get("session_id") + if session and active_session not in (None, session): + return + # A caller outside the Host can explicitly select its private directory. + # The journal identifies that session even without an ambient thread ID. + write_decision_capture(root / "claim-source.json", + {"session_id": session or active_session, "sha256": digest}, publish=os.replace) def _original_native_claim_source( diff --git a/tests/control_plane/test_quota_execution_preparation.py b/tests/control_plane/test_quota_execution_preparation.py index 88dadc1fa1..8f93c31cf8 100644 --- a/tests/control_plane/test_quota_execution_preparation.py +++ b/tests/control_plane/test_quota_execution_preparation.py @@ -625,8 +625,9 @@ def test_preparation_does_not_replace_explicit_action_selection(monkeypatch): @pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("lease_state", ["missing", "expired"]) +@pytest.mark.parametrize("startup", ["before-guard", "after-guard"]) def test_codex_lifecycle_hook_uses_captured_authority_from_another_project( - tmp_path, monkeypatch, provider, lease_state, + tmp_path, monkeypatch, provider, lease_state, startup, ): from loopx.control_plane.turn_driver.codex_context import ( codex_context_hook, @@ -637,6 +638,16 @@ def test_codex_lifecycle_hook_uses_captured_authority_from_another_project( capture_root = tmp_path / "capture" context_root.mkdir(mode=0o700) monkeypatch.delenv("CODEX_THREAD_ID", raising=False) + foreign_workspace = tmp_path / "project-b" + foreign_workspace.mkdir() + event = { + "session_id": "codex-hook-session", + "cwd": str(foreign_workspace.resolve()), + "hook_event_name": "SessionStart", + "source": "startup", + } + if startup == "before-guard": + assert codex_context_hook(str(context_root), event) == {} before = _run( tmp_path, monkeypatch, @@ -647,17 +658,10 @@ def test_codex_lifecycle_hook_uses_captured_authority_from_another_project( "--decision-output-dir", str(capture_root), context_delivery="on", ) turn = before["heartbeat_receipt"]["turn_instance_id"] - foreign_workspace = tmp_path / "project-b" - foreign_workspace.mkdir() monkeypatch.setenv("CODEX_THREAD_ID", "codex-hook-session") monkeypatch.chdir(foreign_workspace) - event = { - "session_id": "codex-hook-session", - "cwd": str(foreign_workspace.resolve()), - "hook_event_name": "SessionStart", - "source": "startup", - } - assert codex_context_hook(str(context_root), event) == {} + if startup == "after-guard": + assert codex_context_hook(str(context_root), event) == {} _present_saved_guard(tmp_path, context_root, capture_root, "codex_app_ssh_goal", turn) command = [ From 98bd24357365eb67277013250aae9178a4a243cd Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:49:03 +0800 Subject: [PATCH 31/60] Verify distribution paths before recognizing the installed Doctor owner Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/doctor.py | 14 ++++++++------ tests/test_doctor_install_freshness.py | 8 ++++++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/loopx/doctor.py b/loopx/doctor.py index 7177dc321a..eb0ccdf345 100644 --- a/loopx/doctor.py +++ b/loopx/doctor.py @@ -147,14 +147,16 @@ def python_distribution_install(module_path: Path) -> dict[str, Any]: return {"available": False} installer = (installed.read_text("INSTALLER") or "").strip() or "unknown" expected = module_path.resolve() - owns_module = any( - item.as_posix().endswith("loopx/doctor.py") + for item in installed.files or (): + record_path = item.as_posix() + if not record_path.endswith("loopx/doctor.py"): + continue # A RECORD entry is a POSIX string. Let the native path parser # interpret an absolute Windows drive before joining the install root. - and Path(installed.locate_file(item.as_posix())).resolve() == expected - for item in installed.files or () - ) - if not owns_module: + location = installed.locate_file(record_path) + if isinstance(location, (str, os.PathLike)) and Path(location).resolve() == expected: + break + else: return {"available": False} owner = resolve_python_install_owner(default_installer=installer, prefix=Path(sys.prefix)) return { 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: From 5cdfaedb99f232c91052ec2ffed69c73fb7f294a Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:58:08 +0800 Subject: [PATCH 32/60] Preserve the socket bind error during partial Chat server startup Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../personal-workspace-browser/server-startup.test.mjs | 7 ++++++- loopx/presentation/background_services_api.py | 6 +++++- tests/presentation/test_background_services_api.py | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) 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/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/tests/presentation/test_background_services_api.py b/tests/presentation/test_background_services_api.py index 42d25da531..a1d8b4b9db 100644 --- a/tests/presentation/test_background_services_api.py +++ b/tests/presentation/test_background_services_api.py @@ -81,7 +81,7 @@ def test_http_recheck_starts_existing_owners_once_and_preserves_disabled_feature 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-returns", "manager-executions")): break time.sleep(.02) assert tasks["manager-returns"]["state"] == "healthy" From a7bebac455d807bd34aa78532638be042324ff62 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 22:38:26 +0800 Subject: [PATCH 33/60] Exercise context-read metadata failure at the active writer Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- tests/control_plane/test_context_read_reference.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/control_plane/test_context_read_reference.py b/tests/control_plane/test_context_read_reference.py index b66eec7eb1..b8bdd66e48 100644 --- a/tests/control_plane/test_context_read_reference.py +++ b/tests/control_plane/test_context_read_reference.py @@ -436,15 +436,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) @@ -466,6 +468,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 From f6210765275977f8099e342561c759d3ba0a5da2 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 22:44:24 +0800 Subject: [PATCH 34/60] Keep release smoke checks on the installed backend and actual worker progress Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../durable-goal-order.mjs | 30 +++++++++++++------ .../test_background_services_api.py | 26 ++++++++++------ 2 files changed, 38 insertions(+), 18 deletions(-) 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/tests/presentation/test_background_services_api.py b/tests/presentation/test_background_services_api.py index a1d8b4b9db..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 all(tasks[key]["last_success_at"] is not None for key in ("manager-returns", "manager-executions")): + 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): From 0a1deb79a7cd2a36712e77257e1d9b0e600b5b28 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 22:53:56 +0800 Subject: [PATCH 35/60] Update the desktop updater to the patched ZIP dependency Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../loopx-control-plane/src-tauri/Cargo.lock | 455 ++++++++---------- .../loopx-control-plane/src-tauri/Cargo.toml | 2 +- docs/product/releases/v2.3.0.md | 6 +- scripts/desktop_update_feed.py | 10 +- 4 files changed, 209 insertions(+), 264 deletions(-) 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/docs/product/releases/v2.3.0.md b/docs/product/releases/v2.3.0.md index c2e17f27e2..1d899acfc5 100644 --- a/docs/product/releases/v2.3.0.md +++ b/docs/product/releases/v2.3.0.md @@ -52,7 +52,7 @@ loopx doctor ## 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. Operators who do not use these paths can stay on 2.2.0 while they plan an upgrade. An existing deployment with a custom ACP endpoint named `antigravity` should rename that endpoint before upgrading. +**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. @@ -84,6 +84,7 @@ loopx --format json goal-acceptance inspect --goal-id "$goal_id" - 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 @@ -398,7 +399,7 @@ loopx doctor ### 升级决策 -**谁需要升级:**使用受管 Goal、研究工作台或原生宿主的用户。暂时不用这些功能的部署可以继续使用 2.2.0。配置了同名自定义 ACP endpoint 的用户先将 `antigravity` 改名。 +**谁需要升级:**使用受管 Goal、研究工作台或原生宿主的用户。Windows 桌面预览版用户应手动安装新版稳定版桌面包。配置了同名自定义 ACP endpoint 的用户先将 `antigravity` 改名。 **解决了什么:**在 Goal 核对承诺与证据。在研究结果中查看原 Turn、报告和下一步的可比测量。在原会话恢复和追踪请求。 @@ -424,6 +425,7 @@ loopx doctor - 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) ### 基准与集成 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"), From 37b540c61576231db5141db6a77e6a62e24df38b Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:00:38 +0800 Subject: [PATCH 36/60] Synchronize release recovery tests with the reported turn and completed delivery Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../chat-recovery.mjs | 23 +++++++++++++++---- tests/test_lark_private_conversations.py | 5 +++- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/examples/personal-workspace-browser/chat-recovery.mjs b/examples/personal-workspace-browser/chat-recovery.mjs index 4070f8a1a8..1047eacd68 100644 --- a/examples/personal-workspace-browser/chat-recovery.mjs +++ b/examples/personal-workspace-browser/chat-recovery.mjs @@ -566,17 +566,22 @@ export const chatRecoveryScenario = { // 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; + // Expose the competing Turn at the send boundary. A poll before the + // POST must not discover it and turn this into a queue test. + page.__loopxRuntime.sessions.set(busySessionId, { ...page.__loopxRuntime.sessions.get(busySessionId), active_turn_id: controlledTurnId, status: "busy" }); await route.fulfill({ contentType: "application/json", status: 409, json: { ok: false, error: "another turn is already running for this session", active_turn_id: controlledTurnId } }); }); + 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.has("channel_id")) return route.fallback(); heldControlReads.push(route); }); const steers = []; @@ -590,12 +595,21 @@ export const chatRecoveryScenario = { 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"); + 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)}`); @@ -615,6 +629,7 @@ export const chatRecoveryScenario = { 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); 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 From 7a36802b6c4acbe77546b595a72c50c8e290cb49 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:34:29 +0800 Subject: [PATCH 37/60] Require visible Goal turn binding before delivery and settlement Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/quota-allocation.md | 10 ++++ loopx/control_plane/quota/live_decision.py | 21 ++++++++ .../quota/progressive_context.ts | 11 ++-- loopx/control_plane/quota/spend_sources.py | 53 +++++++++++-------- loopx/control_plane/quota/turn_envelope.ts | 7 ++- .../work_items/interaction_contract.py | 29 +++++++++- .../control_plane/test_context_first_read.py | 25 +++++++++ .../test_quota_settlement_cli.py | 31 ++++++++++- 8 files changed, 156 insertions(+), 31 deletions(-) diff --git a/docs/quota-allocation.md b/docs/quota-allocation.md index 3fb88f6053..f9b6ce4cc2 100644 --- a/docs/quota-allocation.md +++ b/docs/quota-allocation.md @@ -868,6 +868,16 @@ 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. + +可见 Goal 的 guard 找到需要绑定 Turn 的工作时,若还没有 Turn 身份, +`cli_channel.turn_binding_required` 表示下一步是执行它给出的 guard 命令。 +先完成这一步,再处理选中的 Todo 或重规划义务。后续准入结果才决定能否执行工作, +并提供对应的结算命令。 + 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/loopx/control_plane/quota/live_decision.py b/loopx/control_plane/quota/live_decision.py index 23a98ebfab..8fb49429e1 100644 --- a/loopx/control_plane/quota/live_decision.py +++ b/loopx/control_plane/quota/live_decision.py @@ -835,6 +835,27 @@ def build_live_quota_should_run_decision( key: value for key, value in hook_dispatch.items() if key != "projections" } interaction = payload.get("interaction_contract") + cli = interaction.get("cli_channel") if isinstance(interaction, dict) else None + if isinstance(cli, dict) and cli.get("turn_binding_required") is True: + # 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) + for key in ("execution_obligation", "work_lane_contract"): + obligation = payload.get(key) + if isinstance(obligation, dict): + obligation["must_attempt_work"] = False + obligation = payload.get("execution_obligation") + if isinstance(obligation, dict): + obligation.update(delivery_allowed=False, reason=reason) + lane = payload.get("work_lane_contract") + if isinstance(lane, dict): + lane["action"] = payload["recommended_action"] + recommendation = payload.get("heartbeat_recommendation") + if isinstance(recommendation, dict): + recommendation["spend_policy"] = reason if isinstance(interaction, dict) and goal_id and agent_id: scope = { "goal_id": goal_id, diff --git a/loopx/control_plane/quota/progressive_context.ts b/loopx/control_plane/quota/progressive_context.ts index 178b75f437..605e2ee951 100644 --- a/loopx/control_plane/quota/progressive_context.ts +++ b/loopx/control_plane/quota/progressive_context.ts @@ -430,6 +430,10 @@ export function initialContextReadPointers(context: JsonObject, decisionFields: 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"); @@ -503,9 +507,10 @@ export function initialContextReadPointers(context: JsonObject, decisionFields: include(...source, "content", "harness", "guidance"); include(...source, "content", "harness", "orchestration_gate"); }); - 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"]) - 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"]) + 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 (object(interaction.cli_channel)?.selection_required === true) { diff --git a/loopx/control_plane/quota/spend_sources.py b/loopx/control_plane/quota/spend_sources.py index fbea522c56..42e3316528 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,33 @@ 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 + profile = scheduler_runtime_profile_for_execution_context(scheduler_execution_context) + if profile is SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE: + return f"{typed_quota_guard} --begin-turn" + # CLI/managed Goal hosts own their iteration identity. + return ( + f"{typed_quota_guard} --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 52ec2676d1..5167cde86e 100644 --- a/loopx/control_plane/quota/turn_envelope.ts +++ b/loopx/control_plane/quota/turn_envelope.ts @@ -582,6 +582,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. @@ -1107,11 +1108,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 @@ -1187,6 +1189,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. diff --git a/loopx/control_plane/work_items/interaction_contract.py b/loopx/control_plane/work_items/interaction_contract.py index 5c7869c37a..250be70f73 100644 --- a/loopx/control_plane/work_items/interaction_contract.py +++ b/loopx/control_plane/work_items/interaction_contract.py @@ -26,6 +26,7 @@ from ..quota.spend_sources import ( build_quota_spend_action, host_goal_turn_reentry_action, + host_goal_turn_reentry_required, quota_spend_source_for_execution_context, ) from ..scheduler.arbitration import SchedulerArbitration, _scheduler_arbitration_facts @@ -1304,6 +1305,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 +1356,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 +1672,14 @@ 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) + 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 +1741,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 +1750,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 +1766,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/tests/control_plane/test_context_first_read.py b/tests/control_plane/test_context_first_read.py index 0530ec8909..ab1e228ea1 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_quota_settlement_cli.py b/tests/control_plane/test_quota_settlement_cli.py index 3e5518e800..e10b0fc829 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": @@ -6993,6 +7011,15 @@ 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 bound_rc, bound = _run_generated_cli( actions[0], From 56ad94668e141d6c2caa2d5ac380506217c64cb3 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:41:48 +0800 Subject: [PATCH 38/60] Bind initial visible Goal selection to the chosen work Turn Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/quota-allocation.md | 5 ++- loopx/control_plane/quota/spend_sources.py | 17 +++++++--- .../work_items/interaction_contract.py | 3 ++ .../test_quota_settlement_cli.py | 31 +++++++++++++++++++ 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/docs/quota-allocation.md b/docs/quota-allocation.md index f9b6ce4cc2..5ad2b3dab8 100644 --- a/docs/quota-allocation.md +++ b/docs/quota-allocation.md @@ -872,11 +872,14 @@ 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. 可见 Goal 的 guard 找到需要绑定 Turn 的工作时,若还没有 Turn 身份, `cli_channel.turn_binding_required` 表示下一步是执行它给出的 guard 命令。 先完成这一步,再处理选中的 Todo 或重规划义务。后续准入结果才决定能否执行工作, -并提供对应的结算命令。 +并提供对应的结算命令。若需要显式选择 Todo,选择命令会携带 Turn 参数, +在一次调用中校验选择并绑定 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 diff --git a/loopx/control_plane/quota/spend_sources.py b/loopx/control_plane/quota/spend_sources.py index 42e3316528..678202f0ac 100644 --- a/loopx/control_plane/quota/spend_sources.py +++ b/loopx/control_plane/quota/spend_sources.py @@ -84,14 +84,21 @@ def host_goal_turn_reentry_action( payload, scheduler_execution_context, turn_instance_id ): 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 f"{typed_quota_guard} --begin-turn" + return " --begin-turn" + if profile not in VISIBLE_GOAL_SETTLEMENT_RUNTIME_PROFILES: + return "" # CLI/managed Goal hosts own their iteration identity. - return ( - f"{typed_quota_guard} --turn-instance-id " - "''" - ) + return " --turn-instance-id ''" def build_quota_spend_action( diff --git a/loopx/control_plane/work_items/interaction_contract.py b/loopx/control_plane/work_items/interaction_contract.py index 250be70f73..638a859fa2 100644 --- a/loopx/control_plane/work_items/interaction_contract.py +++ b/loopx/control_plane/work_items/interaction_contract.py @@ -27,6 +27,7 @@ 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 @@ -726,6 +727,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 = ( diff --git a/tests/control_plane/test_quota_settlement_cli.py b/tests/control_plane/test_quota_settlement_cli.py index e10b0fc829..e4f7b25af9 100644 --- a/tests/control_plane/test_quota_settlement_cli.py +++ b/tests/control_plane/test_quota_settlement_cli.py @@ -4828,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, From da4582457a4140a83c7b99d676322a9bb51bfefc Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:45:40 +0800 Subject: [PATCH 39/60] Synchronize the 409 steering smoke with the replacement draft Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../chat-recovery.mjs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/examples/personal-workspace-browser/chat-recovery.mjs b/examples/personal-workspace-browser/chat-recovery.mjs index 1047eacd68..88ac0762d8 100644 --- a/examples/personal-workspace-browser/chat-recovery.mjs +++ b/examples/personal-workspace-browser/chat-recovery.mjs @@ -578,17 +578,18 @@ export const chatRecoveryScenario = { }); let holdControlReads = true; const heldControlReads = []; + const controlledChannelId = page.__loopxRuntime.sessions.get(busySessionId).channel_id; await page.route("**/api/chat/sessions?*", async (route) => { const request = route.request(); if (!holdControlReads || !controlRejectedPosts || request.method() !== "GET" - || !new URL(request.url()).searchParams.has("channel_id")) return route.fallback(); + || new URL(request.url()).searchParams.get("channel_id") !== controlledChannelId) 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; @@ -603,7 +604,13 @@ export const chatRecoveryScenario = { { 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("交接期间先核对依赖。"); + // 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 }), @@ -611,8 +618,9 @@ export const chatRecoveryScenario = { ]); 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(); From 689629ec21ca214f3a910560329692e57c4d54fb Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:24:47 +0800 Subject: [PATCH 40/60] Isolate desktop service recovery test ports from ephemeral traffic Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../src-tauri/src/service_endpoints.rs | 25 +++++++++++++++++++ .../src-tauri/src/services_tests.rs | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) 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(), ) } From e2f9fd04939f8f13198636b90d783103fb889212 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:47:33 +0800 Subject: [PATCH 41/60] Separate native Goal admission from external Turn planning Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/quota-allocation.md | 3 +++ loopx/control_plane/quota/turn_envelope.ts | 6 +++++- .../scheduler/execution_context.py | 20 ++++++++++--------- .../work_items/interaction_contract.py | 3 +++ .../test_quota_settlement_cli.py | 6 ++++++ 5 files changed, 28 insertions(+), 10 deletions(-) diff --git a/docs/quota-allocation.md b/docs/quota-allocation.md index 5ad2b3dab8..20e25c1b01 100644 --- a/docs/quota-allocation.md +++ b/docs/quota-allocation.md @@ -874,12 +874,15 @@ 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 diff --git a/loopx/control_plane/quota/turn_envelope.ts b/loopx/control_plane/quota/turn_envelope.ts index 5167cde86e..8eef2d5047 100644 --- a/loopx/control_plane/quota/turn_envelope.ts +++ b/loopx/control_plane/quota/turn_envelope.ts @@ -1600,7 +1600,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/work_items/interaction_contract.py b/loopx/control_plane/work_items/interaction_contract.py index 638a859fa2..0bd65fb020 100644 --- a/loopx/control_plane/work_items/interaction_contract.py +++ b/loopx/control_plane/work_items/interaction_contract.py @@ -35,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 ( @@ -1679,6 +1680,8 @@ def _build_interaction_contract_projection( 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, ) diff --git a/tests/control_plane/test_quota_settlement_cli.py b/tests/control_plane/test_quota_settlement_cli.py index e4f7b25af9..6aff68f19d 100644 --- a/tests/control_plane/test_quota_settlement_cli.py +++ b/tests/control_plane/test_quota_settlement_cli.py @@ -7051,6 +7051,12 @@ def test_unbound_visible_goal_todoless_replan_reenters_through_guided_turn( 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], From ff8d0a32c8fead48800da07825a4c5afc766fea8 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:56:35 +0800 Subject: [PATCH 42/60] Read issue-fix correction Todos through the canonical list owner Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/capabilities/issue_fix/correction_settlement.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/loopx/capabilities/issue_fix/correction_settlement.py b/loopx/capabilities/issue_fix/correction_settlement.py index 17b12cffd7..27d5eb0609 100644 --- a/loopx/capabilities/issue_fix/correction_settlement.py +++ b/loopx/capabilities/issue_fix/correction_settlement.py @@ -316,7 +316,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 From cb5dcc26b49838a6641ee1998bd1ff50e9d33c2c Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:56:35 +0800 Subject: [PATCH 43/60] Recover the original terminal quota decision in model qualification Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../terminal_settlement_tool_behavior.py | 35 +++++++++++++++---- .../test_terminal_settlement_tool_behavior.py | 19 ++++++++-- 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/loopx/control_plane/testing/terminal_settlement_tool_behavior.py b/loopx/control_plane/testing/terminal_settlement_tool_behavior.py index 160a925f46..35f3e676de 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 @@ -1196,7 +1198,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 +1281,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 +1356,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/tests/control_plane/test_terminal_settlement_tool_behavior.py b/tests/control_plane/test_terminal_settlement_tool_behavior.py index ec2a3ab1ff..d0563e2ef4 100644 --- a/tests/control_plane/test_terminal_settlement_tool_behavior.py +++ b/tests/control_plane/test_terminal_settlement_tool_behavior.py @@ -130,6 +130,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]) @@ -772,14 +778,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 +806,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 +817,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 From 57f4e86ddadbd8c6604dc2cf3396c0d3a56e0f33 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:56:35 +0800 Subject: [PATCH 44/60] Align release regression fixtures with current decisions and receipts Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../test_anti_entropy_feedback.py | 6 +++-- ...est_advancement_frontier_classification.py | 4 ++- .../test_canary_cli_transport.py | 7 +++-- .../test_canonical_todo_writer_isolation.py | 4 ++- .../test_effect_turn_live_quota_decision.py | 16 +++++++++++- .../test_fallback_wait_admission.py | 21 ++++++++------- .../test_final_outcome_diagnostics.py | 5 +++- .../test_goal_amendment_authority.py | 26 ++++++++++++------- .../test_local_coordination_authority.py | 2 +- .../test_quota_memory_context.py | 3 ++- .../test_request_amendment_recovery.py | 5 +++- .../test_source_cli_entrypoint.py | 4 +-- .../test_todo_consumer_retirement.py | 4 ++- .../test_worker_observation_recovery_cli.py | 14 ++++++---- tests/test_host_launch_authority.py | 4 ++- tests/test_lark_private_feedback.py | 7 +++-- tests/test_peer_collaboration.py | 4 ++- tests/test_projection_envelope.py | 6 +++-- 18 files changed, 94 insertions(+), 48 deletions(-) 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/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_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_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_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_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_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_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_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_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/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_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_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" From 58b8838151621a2c884f85cc363c376b59cb5711 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:57:20 +0800 Subject: [PATCH 45/60] Initialize quick navigation before accepting keyboard input Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../smoke/workspace-command-palette-browser-smoke.mjs | 7 +++++-- .../personal-workspace/workspace-command-palette.tsx | 5 +++-- 2 files changed, 8 insertions(+), 4 deletions(-) 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; From 40bd341f7987267965144408172cfa36832a5ce7 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:06:33 +0800 Subject: [PATCH 46/60] Bind live model qualification to its disposable registries Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../testing/capability_monitor_repair_tool_behavior.py | 1 + .../testing/replan_semantic_action_behavior.py | 1 + .../testing/scoped_gate_successor_tool_behavior.py | 1 + loopx/control_plane/testing/selected_todo_tool_behavior.py | 1 + .../testing/terminal_settlement_tool_behavior.py | 1 + tests/control_plane/test_selected_todo_tool_behavior.py | 6 +++++- 6 files changed, 10 insertions(+), 1 deletion(-) 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/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/scoped_gate_successor_tool_behavior.py b/loopx/control_plane/testing/scoped_gate_successor_tool_behavior.py index ef3b4b9d95..a9bc05c990 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"]): 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 35f3e676de..5be21648ce 100644 --- a/loopx/control_plane/testing/terminal_settlement_tool_behavior.py +++ b/loopx/control_plane/testing/terminal_settlement_tool_behavior.py @@ -294,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"]): 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 = [ From 0f700097dd74aee323f8a914b9b8bc9cdf6341bb Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:17:28 +0800 Subject: [PATCH 47/60] Bind correction returns to the verified source-session origin Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/capabilities/issue_fix/cli.py | 9 ++------ .../issue_fix/correction_settlement.py | 22 ++++++++++++++----- .../test_source_session_registry_denial.py | 2 ++ 3 files changed, 21 insertions(+), 12 deletions(-) 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 27d5eb0609..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") @@ -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/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"}, } From baa29c8652f44803dc3213496d91d0ef5837aef2 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:17:28 +0800 Subject: [PATCH 48/60] Keep unbound quota projection and recovery validation separate Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/control_plane/quota/live_decision.py | 45 ++++++++++--------- .../test_quota_stall_context_reprojection.py | 2 +- .../test_acceptance_restoration.py | 3 ++ 3 files changed, 28 insertions(+), 22 deletions(-) diff --git a/loopx/control_plane/quota/live_decision.py b/loopx/control_plane/quota/live_decision.py index 8fb49429e1..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,28 +857,8 @@ 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") - cli = interaction.get("cli_channel") if isinstance(interaction, dict) else None - if isinstance(cli, dict) and cli.get("turn_binding_required") is True: - # 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) - for key in ("execution_obligation", "work_lane_contract"): - obligation = payload.get(key) - if isinstance(obligation, dict): - obligation["must_attempt_work"] = False - obligation = payload.get("execution_obligation") - if isinstance(obligation, dict): - obligation.update(delivery_allowed=False, reason=reason) - lane = payload.get("work_lane_contract") - if isinstance(lane, dict): - lane["action"] = payload["recommended_action"] - recommendation = payload.get("heartbeat_recommendation") - if isinstance(recommendation, dict): - recommendation["spend_policy"] = reason if isinstance(interaction, dict) and goal_id and agent_id: scope = { "goal_id": goal_id, 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(): From e9a76a810fa06efdb34e84037764a61b5ad51f33 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:32:19 +0800 Subject: [PATCH 49/60] Follow the current terminal plan before drafting writeback inputs Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../testing/terminal_settlement_tool_behavior.py | 14 ++++++++++---- .../test_terminal_settlement_tool_behavior.py | 4 ++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/loopx/control_plane/testing/terminal_settlement_tool_behavior.py b/loopx/control_plane/testing/terminal_settlement_tool_behavior.py index 5be21648ce..4a8fae9602 100644 --- a/loopx/control_plane/testing/terminal_settlement_tool_behavior.py +++ b/loopx/control_plane/testing/terminal_settlement_tool_behavior.py @@ -817,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 @@ -1088,14 +1089,19 @@ 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 " + " If the current step requires a vision packet, use the " + "plan's vision_authoring schema and write the 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." diff --git a/tests/control_plane/test_terminal_settlement_tool_behavior.py b/tests/control_plane/test_terminal_settlement_tool_behavior.py index d0563e2ef4..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, @@ -188,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"] == { @@ -208,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 From 4bbd504c2608367bfd15f25dd1507dca80b7d588 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:08:21 +0800 Subject: [PATCH 50/60] Report retired handoff policy before admitting Todo execution Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../work_items/execution_preparation.ts | 10 ++++++++-- .../test_canonical_handoff_mode.py | 12 ++++++++++++ .../control_plane/test_capability_gate_cli.py | 12 +++--------- .../execution_preparation.test.ts | 18 +++++++++--------- 4 files changed, 32 insertions(+), 20 deletions(-) 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/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_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_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); }); From 86e648d613dc8cd413ffcc72529bbf8b5ee3de70 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:08:22 +0800 Subject: [PATCH 51/60] Exercise current capture, planning, and recovery contracts in release tests Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../test_research_composition_gate.py | 8 +++- .../test_agent_scope_frontier_contract.py | 4 +- .../test_benchmark_checkpoint_admission.py | 26 ++++++++-- .../test_benchmark_current_admission.py | 47 +++++++++++++++---- .../test_chat_collaboration_source_runtime.py | 4 +- .../test_deferred_resume_generated_command.py | 21 ++++----- .../test_effect_runtime_restart.py | 7 ++- .../test_goal_state_context_transport.py | 3 +- .../test_host_current_admission.py | 2 +- 9 files changed, 87 insertions(+), 35 deletions(-) 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/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_checkpoint_admission.py b/tests/control_plane/test_benchmark_checkpoint_admission.py index fc78bf4865..03f6bd00ac 100644 --- a/tests/control_plane/test_benchmark_checkpoint_admission.py +++ b/tests/control_plane/test_benchmark_checkpoint_admission.py @@ -78,13 +78,15 @@ def rendered(command): assert env.get("FIXTURE_BOUND_WORK") and guard["heartbeat_receipt"]["closeout_required"] is True Path("checkpoint-closeout-guard.json").write_text(json.dumps(guard)) else: - assert started == 1 and "resume" in sys.argv and sys.argv[-2] == "benchmark-original-session" + assert started == 1 and "resume" in sys.argv + assert sys.argv[sys.argv.index("resume") + 1:].count("benchmark-original-session") == 1 guard = json.loads(Path(env["FIXTURE_HELD_QUOTA"]).read_text()) assert env["LOOPX_TURN"] == guard["heartbeat_receipt"]["turn_instance_id"] assert env["LOOPX_TURN"] != env["FIXTURE_OUTER_TURN"] assert guard["heartbeat_receipt"]["event_id"] in body assert guard["heartbeat_receipt"]["turn_instance_id"] == env["FIXTURE_HELD_TURN"] Path("checkpoint-original-consumed.json").write_text(json.dumps(guard["heartbeat_receipt"])) +original_replan = guard.get("replan_action_packet") or {} if guard["heartbeat_receipt"].get("settlement_identity") is None: todo_id = env["FIXTURE_SELECTION_TODO"] args = ["quota","should-run","--goal-id",env["LOOPX_GOAL_ID"],"--agent-id",env["LOOPX_AGENT_ID"], @@ -99,12 +101,30 @@ def rendered(command): Path("checkpoint-business.json").write_text(json.dumps({"sum":sum(json.loads(Path("retained-input.json").read_text()))})) evidence = "checkpoint-business:" + hashlib.sha256(Path("checkpoint-business.json").read_bytes()).hexdigest() vision = Path("checkpoint-vision.json").resolve() -vision.write_text(json.dumps({"schema_version":"goal_vision_replan_contract_v0","agent_id":env["LOOPX_AGENT_ID"], +authored = {"schema_version":"goal_vision_replan_contract_v0","agent_id":env["LOOPX_AGENT_ID"], "state":"active", "vision_patch":{"acceptance_summary":"Validate the retained inputs and keep remaining validation work.", "advancement_policy":"as_needed"}, "path_delta":{"schema_version":"goal_path_delta_v0","outcome":"continue", "prior_assumption":"The retained inputs still require a concrete validation probe.", "observed_reality":"The retained inputs sum to five; this does not validate the other open tasks.", - "retained":["The remaining independent validation work"],"evidence_refs":[evidence]}})) + "retained":["The remaining independent validation work"],"evidence_refs":[evidence]}} +stride = (guard.get("replan_action_packet") or {}).get("stride_control") or original_replan.get("stride_control") +if stride: + authored["stride_decision"] = {**stride["minimal_example"], + "axes": { + "work_slice": {"plan": "Validate the retained inputs before selecting the next open task.", + "reason": "The exact retained inputs are available in this resumed Turn."}, + "route_distance": {"plan": "Keep the deterministic sum check and then inspect remaining tasks.", + "reason": "The result verifies the retained inputs without requiring another method."}, + "validation_scope": {"plan": "Read back the verified sum and leave unrelated inputs open.", + "reason": "The calculation covers only the current inputs."}, + }, + "next_probe": {"scope": "One remaining independent input check.", + "prediction": "The remaining task can be tested against its own inputs.", + "minimum_evidence": "The corresponding input list and independently calculated result.", + "adoption_criterion": "The readback agrees with the independent result.", + "cost_bound": "One input read and one deterministic computation."}, + } +vision.write_text(json.dumps(authored)) actions = guard["interaction_contract"]["cli_channel"]["next_cli_actions"] refresh = next(command for command in actions if "refresh-state" in command) for old,new in [("","retained_inputs_validated"), 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_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_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_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 From e867de4982c573f530fde4d69477791f90325aeb Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:41:02 +0800 Subject: [PATCH 52/60] Keep recovery smokes bound to their original queue and lease intents Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../chat-recovery.mjs | 81 ++++++++++++------- .../canonical_task_lease_renew.test.ts | 15 +++- 2 files changed, 67 insertions(+), 29 deletions(-) diff --git a/examples/personal-workspace-browser/chat-recovery.mjs b/examples/personal-workspace-browser/chat-recovery.mjs index 88ac0762d8..7b96186a70 100644 --- a/examples/personal-workspace-browser/chat-recovery.mjs +++ b/examples/personal-workspace-browser/chat-recovery.mjs @@ -441,17 +441,34 @@ 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 submissionRoute = `**/api/chat/sessions/${busySessionId}/turns`; + 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}"]`); 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. @@ -480,6 +497,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?*"); @@ -497,7 +538,7 @@ export const chatRecoveryScenario = { 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 @@ -506,14 +547,8 @@ export const chatRecoveryScenario = { const retriedTurnId = `turn-foreign-retry-${Date.now()}`; const retriedTurnControl = page.locator(`.personal-composer-stop[data-turn-id="${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; @@ -549,7 +584,7 @@ export const chatRecoveryScenario = { 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 @@ -567,15 +602,7 @@ export const chatRecoveryScenario = { await page.route(controlledTurnEvents, (route) => { heldControlEvents.push(route); }); page.__loopxRuntime.turnMessages.set(controlledTurnId, "交接期间可调整和中断的中断控制回合"); let controlRejectedPosts = 0; - await page.route(`**/api/chat/sessions/${busySessionId}/turns`, async (route) => { - if (route.request().method() !== "POST") return route.fallback(); - controlRejectedPosts += 1; - // Expose the competing Turn at the send boundary. A poll before the - // POST must not discover it and turn this into a queue test. - page.__loopxRuntime.sessions.set(busySessionId, { ...page.__loopxRuntime.sessions.get(busySessionId), active_turn_id: controlledTurnId, status: "busy" }); - 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 = []; const controlledChannelId = page.__loopxRuntime.sessions.get(busySessionId).channel_id; @@ -645,9 +672,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/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); }); From ebfd1e6c075792c6778ac1a8b6f6371649fb698c Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:41:59 +0800 Subject: [PATCH 53/60] Bound required Vision qualification to one current evidence and writeback sequence Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../replan_vision_closeout_behavior.py | 35 ++++++++++--------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/loopx/control_plane/testing/replan_vision_closeout_behavior.py b/loopx/control_plane/testing/replan_vision_closeout_behavior.py index 91273bf7f1..821389fb9f 100644 --- a/loopx/control_plane/testing/replan_vision_closeout_behavior.py +++ b/loopx/control_plane/testing/replan_vision_closeout_behavior.py @@ -33,22 +33,25 @@ 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. If it returns navigation, read its current capture " + "using the supplied reader. Follow any projected writeback reader and read " + "the named work source. Read each needed fact once and proceed to the draft " + "as soon as the source and authoring contract are available. " + "Use one shell call to author one project JSON draft. Start with " + "vision_authoring.minimal_example and use the projected field limits. " + "Add path_delta.outcome, prior_assumption, observed_reality, one retained, " + "changed or stopped item, and the evidence_id or source_ref you actually read " + "from the current frontier. If stride_control requires a decision, author " + "its independent axes and minimum_evidence against 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 current refresh-state template in interaction_contract.cli_channel.next_cli_actions " + "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 = ( From e7560952219435bcf99a5e777a7797efd5c3934d Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:41:02 +0800 Subject: [PATCH 54/60] Keep recovery smokes bound to their original queue and lease intents Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> (cherry picked from commit e867de4982c573f530fde4d69477791f90325aeb) Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../chat-recovery.mjs | 81 ++++++++++++------- .../canonical_task_lease_renew.test.ts | 15 +++- 2 files changed, 67 insertions(+), 29 deletions(-) diff --git a/examples/personal-workspace-browser/chat-recovery.mjs b/examples/personal-workspace-browser/chat-recovery.mjs index 88ac0762d8..7b96186a70 100644 --- a/examples/personal-workspace-browser/chat-recovery.mjs +++ b/examples/personal-workspace-browser/chat-recovery.mjs @@ -441,17 +441,34 @@ 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 submissionRoute = `**/api/chat/sessions/${busySessionId}/turns`; + 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}"]`); 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. @@ -480,6 +497,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?*"); @@ -497,7 +538,7 @@ export const chatRecoveryScenario = { 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 @@ -506,14 +547,8 @@ export const chatRecoveryScenario = { const retriedTurnId = `turn-foreign-retry-${Date.now()}`; const retriedTurnControl = page.locator(`.personal-composer-stop[data-turn-id="${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; @@ -549,7 +584,7 @@ export const chatRecoveryScenario = { 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 @@ -567,15 +602,7 @@ export const chatRecoveryScenario = { await page.route(controlledTurnEvents, (route) => { heldControlEvents.push(route); }); page.__loopxRuntime.turnMessages.set(controlledTurnId, "交接期间可调整和中断的中断控制回合"); let controlRejectedPosts = 0; - await page.route(`**/api/chat/sessions/${busySessionId}/turns`, async (route) => { - if (route.request().method() !== "POST") return route.fallback(); - controlRejectedPosts += 1; - // Expose the competing Turn at the send boundary. A poll before the - // POST must not discover it and turn this into a queue test. - page.__loopxRuntime.sessions.set(busySessionId, { ...page.__loopxRuntime.sessions.get(busySessionId), active_turn_id: controlledTurnId, status: "busy" }); - 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 = []; const controlledChannelId = page.__loopxRuntime.sessions.get(busySessionId).channel_id; @@ -645,9 +672,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/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); }); From 415862e2101397d9daebb97dfd369e611b54205b Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:47:06 +0800 Subject: [PATCH 55/60] Preserve explicit native read windows with stateless saved replan delivery Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/cli_commands/quota_capture.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/loopx/cli_commands/quota_capture.py b/loopx/cli_commands/quota_capture.py index 34bd036bbe..89aa1307ac 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -344,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.") From fab30ee5f09dddc9c5337391a8450e9a6479d96d Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:56:58 +0800 Subject: [PATCH 56/60] Account for the existing Stride citation limit in the Turn fixture Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/development/testing-and-quality.md | 16 ++++++++++++++++ loopx/control_plane/testing/cli_output_budget.py | 5 ++++- 2 files changed, 20 insertions(+), 1 deletion(-) 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/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", From 244fd4646f6629d40b91926e2d1870c153049b75 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:56:58 +0800 Subject: [PATCH 57/60] Project the required Stride decision on fresh Vision writeback Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../replan_vision_closeout_behavior.py | 26 ++++++++++--------- .../work_items/replan_semantics.ts | 7 ++--- .../test_required_vision_closeout_behavior.py | 4 +++ tests/control_plane_ts/stride_policy.test.ts | 17 ++++++++++++ 4 files changed, 39 insertions(+), 15 deletions(-) diff --git a/loopx/control_plane/testing/replan_vision_closeout_behavior.py b/loopx/control_plane/testing/replan_vision_closeout_behavior.py index 821389fb9f..7fc2493a27 100644 --- a/loopx/control_plane/testing/replan_vision_closeout_behavior.py +++ b/loopx/control_plane/testing/replan_vision_closeout_behavior.py @@ -34,20 +34,22 @@ def __init__(self, code: str, detail: str = "") -> None: "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. Run the " - "quota guard by itself. If it returns navigation, read its current capture " - "using the supplied reader. Follow any projected writeback reader and read " - "the named work source. Read each needed fact once and proceed to the draft " - "as soon as the source and authoring contract are available. " - "Use one shell call to author one project JSON draft. Start with " - "vision_authoring.minimal_example and use the projected field limits. " - "Add path_delta.outcome, prior_assumption, observed_reality, one retained, " - "changed or stopped item, and the evidence_id or source_ref you actually read " - "from the current frontier. If stride_control requires a decision, author " - "its independent axes and minimum_evidence against this same source. " + "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 current refresh-state template in interaction_contract.cli_channel.next_cli_actions " - "with the draft path and keep the original Turn and obligation binding. " + "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 " 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/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_ts/stride_policy.test.ts b/tests/control_plane_ts/stride_policy.test.ts index ae30a9dca4..425ae163d0 100644 --- a/tests/control_plane_ts/stride_policy.test.ts +++ b/tests/control_plane_ts/stride_policy.test.ts @@ -428,6 +428,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}; From 700f1b127c3b69a761b4a931cf758e8ba96ef40e Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:23:24 +0800 Subject: [PATCH 58/60] Wait for the exact Session before exercising 409 handoff Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../chat-recovery.mjs | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/examples/personal-workspace-browser/chat-recovery.mjs b/examples/personal-workspace-browser/chat-recovery.mjs index 7b96186a70..dc80c92988 100644 --- a/examples/personal-workspace-browser/chat-recovery.mjs +++ b/examples/personal-workspace-browser/chat-recovery.mjs @@ -441,7 +441,19 @@ 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(); @@ -466,6 +478,7 @@ export const chatRecoveryScenario = { }; 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, "另一页面发起的中断控制回合"); let rejectedPosts = 0; await page.route(submissionRoute, competeOnSubmit(foreignTurnId, () => { rejectedPosts += 1; })); @@ -475,18 +488,13 @@ export const chatRecoveryScenario = { 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(); @@ -535,6 +543,7 @@ 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`); @@ -546,6 +555,7 @@ 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, "读取失败后仍在运行的中断控制回合"); let retryRejectedPosts = 0; await page.route(submissionRoute, competeOnSubmit(retriedTurnId, () => { retryRejectedPosts += 1; })); @@ -553,8 +563,8 @@ export const chatRecoveryScenario = { 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" } }); @@ -581,6 +591,7 @@ 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`); @@ -592,6 +603,10 @@ 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()}`; @@ -605,11 +620,10 @@ export const chatRecoveryScenario = { await page.route(submissionRoute, competeOnSubmit(controlledTurnId, () => { controlRejectedPosts += 1; })); let holdControlReads = true; const heldControlReads = []; - const controlledChannelId = page.__loopxRuntime.sessions.get(busySessionId).channel_id; await page.route("**/api/chat/sessions?*", async (route) => { const request = route.request(); if (!holdControlReads || !controlRejectedPosts || request.method() !== "GET" - || new URL(request.url()).searchParams.get("channel_id") !== controlledChannelId) return route.fallback(); + || new URL(request.url()).searchParams.get("channel_id") !== busyChannelId) return route.fallback(); heldControlReads.push(route); }); const steers = []; From a3871f42c7e6ca03f94f48465e1c6df27375e6aa Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:29:14 +0800 Subject: [PATCH 59/60] Complete scoped tool closeout at the last allowed call Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- ...actual_default_model_behavior_portfolio.py | 11 ++++-- .../scoped_gate_successor_tool_behavior.py | 39 +++++-------------- .../terminal_settlement_tool_behavior.py | 13 +++++-- ...actual_default_model_behavior_portfolio.py | 7 +++- ...est_scoped_gate_successor_tool_behavior.py | 19 ++++++--- 5 files changed, 46 insertions(+), 43 deletions(-) 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/scoped_gate_successor_tool_behavior.py b/loopx/control_plane/testing/scoped_gate_successor_tool_behavior.py index a9bc05c990..c837f1bf1c 100644 --- a/loopx/control_plane/testing/scoped_gate_successor_tool_behavior.py +++ b/loopx/control_plane/testing/scoped_gate_successor_tool_behavior.py @@ -295,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, @@ -404,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=( @@ -548,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/terminal_settlement_tool_behavior.py b/loopx/control_plane/testing/terminal_settlement_tool_behavior.py index 4a8fae9602..2515a37491 100644 --- a/loopx/control_plane/testing/terminal_settlement_tool_behavior.py +++ b/loopx/control_plane/testing/terminal_settlement_tool_behavior.py @@ -1097,10 +1097,15 @@ def qualify( "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 the current step requires a vision packet, use the " - "plan's vision_authoring schema and write the draft in " - "one tool call under the shell's existing `.scratch/` " - "directory. Correct rejected inputs using the CLI feedback. " + " 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, " 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_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, } From a22516b48d9d542440f565892dc12b4640a7ad16 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:23:24 +0800 Subject: [PATCH 60/60] Wait for the exact Session before exercising 409 handoff Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> (cherry picked from commit 700f1b127c3b69a761b4a931cf758e8ba96ef40e) Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../chat-recovery.mjs | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/examples/personal-workspace-browser/chat-recovery.mjs b/examples/personal-workspace-browser/chat-recovery.mjs index 7b96186a70..dc80c92988 100644 --- a/examples/personal-workspace-browser/chat-recovery.mjs +++ b/examples/personal-workspace-browser/chat-recovery.mjs @@ -441,7 +441,19 @@ 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(); @@ -466,6 +478,7 @@ export const chatRecoveryScenario = { }; 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, "另一页面发起的中断控制回合"); let rejectedPosts = 0; await page.route(submissionRoute, competeOnSubmit(foreignTurnId, () => { rejectedPosts += 1; })); @@ -475,18 +488,13 @@ export const chatRecoveryScenario = { 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(); @@ -535,6 +543,7 @@ 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`); @@ -546,6 +555,7 @@ 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, "读取失败后仍在运行的中断控制回合"); let retryRejectedPosts = 0; await page.route(submissionRoute, competeOnSubmit(retriedTurnId, () => { retryRejectedPosts += 1; })); @@ -553,8 +563,8 @@ export const chatRecoveryScenario = { 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" } }); @@ -581,6 +591,7 @@ 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`); @@ -592,6 +603,10 @@ 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()}`; @@ -605,11 +620,10 @@ export const chatRecoveryScenario = { await page.route(submissionRoute, competeOnSubmit(controlledTurnId, () => { controlRejectedPosts += 1; })); let holdControlReads = true; const heldControlReads = []; - const controlledChannelId = page.__loopxRuntime.sessions.get(busySessionId).channel_id; await page.route("**/api/chat/sessions?*", async (route) => { const request = route.request(); if (!holdControlReads || !controlRejectedPosts || request.method() !== "GET" - || new URL(request.url()).searchParams.get("channel_id") !== controlledChannelId) return route.fallback(); + || new URL(request.url()).searchParams.get("channel_id") !== busyChannelId) return route.fallback(); heldControlReads.push(route); }); const steers = [];