diff --git a/docs/architecture/rfcs/monorepo-distribution-split-v0.md b/docs/architecture/rfcs/monorepo-distribution-split-v0.md index c8e7ce79f9..85a999b95e 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.md @@ -824,6 +824,267 @@ Approval needs a reviewed decision, not implementation progress or silence. installation, R1 managed minimal or D1–D7. No preset, frontend operation, permission or persisted state changes; protected execution keeps its owner. +### 2026-11-06 — Selected Turn host loading seam + +Following the generic-cli/SQLite candidate in +[#8180](https://github.com/loopx-project/loopx/pull/8180), the source proposal +`9b80cf7d6` defers DSH builders at `turn_run_once` until a caller selects DSH +or explicitly requests their established exports. Named imports, original +builder identity, static types, `dir()`, star imports and the CLI's builder +patch points remain available. The existing host/authority owners are reused; +this does not select a minimal preset or adopt D2/D5. + +- **Observed delta:** the real generic-cli/SQLite task path loads 533 LoopX + modules instead of 538 at `62e234c93`. The five removed modules are + `turn_dsh_host`, `turn_driver.host_candidate`, `dsh_goal_mode` and its + `host_failure_map`/`turn_host_adapter`. No module is added. The cold-import + oracle fails at the baseline and passes at the proposal; the existing DSH + CLI fixture also proves the public builder patch point is used. +- **Real-path parity:** the same private harness runs ordinary completion, + interruption after typed host-result persistence, original-journal resume + and committed replay on isolated SQLite fixtures at both revisions. Each + fixture ends with one host invocation/attempt, one writeback and one debit; + replay repeats none. Independent argv validators accept identical artifact + bytes. Each arm uses an isolated native broker whose shutdown and OS exit are + verified. This remains deterministic transport evidence, not model-host V4 + or normal hard-lease V2 qualification. +- **Observed costs:** one ordinary instrumented invocation per revision records + 2.962 → 3.169 seconds, parent CPU 0.737 → 0.775 seconds and parent peak RSS + 65.6 → 64.6 MiB; output remains 3,474 bytes. Waited-child CPU is + 0.567 → 0.599 seconds. Cache, run order and competing work are not controlled; + these observations establish neither a latency improvement nor a regression + threshold. Broker RSS observations and waited-child usage do not measure + complete tree peaks, full wire/context bytes or model cost. The loading delta + is small; long-horizon benefit and D6 qualification remain unproven. +- **Historical validation limits at `bc59a9ed4`:** the wider source selection records 596 passed and one + failed preview cold-import test; the baseline independently fails the same + test because `host_bridge_transport` eagerly imports + `extensions.process_runtime`. Owner-scoped mypy with `--follow-imports=skip` + reports the same 29 diagnostics at both revisions after line normalization; + it is not the full configured mypy suite. Neither failure is waived or + relabeled as passing. The process-helper seam below repairs that preview + failure in the same author proposal while retaining the type-check limitation. +- **Remaining:** independent review, required validation and exact-head merge + readiness still govern adoption. Opt-in minimal selection, D2/D5 decisions, + full R0/V10/R1, hard-lease/context/model parity and split installation remain + open; the separate #8011 status proposal retains its limits. No authority, + provider, default, installed-host or release change is introduced. + +### 2026-11-06 — Preview process helpers remain with execution + +The follow-on runtime proposal `a44e27d96` repairs the unchanged preview import +oracle. Deferring the bridge helpers first exposes a second eager import in +`doctor_git`; both adapters now resolve the existing `process_runtime` owner +at execution/cleanup or explicit import. Named/star imports, original function +identity, static aliases, `dir()` and public patch points remain compatible. +The TypeScript Host lifetime and process/permission decisions do not move. + +- **Observable result:** importing the preview command loads 337 LoopX modules + instead of 338 at `bc59a9ed4`, removing only `extensions.process_runtime`. + Successful real Git reads likewise leave cleanup unloaded. Actual generic-cli + execution still loads 533 modules in both arms because it needs those helpers. + This fixes a loading boundary; it establishes no speedup or long-horizon benefit. +- **Real paths:** the same isolated SQLite harness passes ordinary completion, + typed-result interruption, original-journal recovery and committed replay at + both immutable runtime revisions. Artifacts remain identical; each fixture + has one host invocation/attempt, writeback and debit, with no replayed effect. + Both owned brokers stop with verified OS exit. Real bridge send/cancel, + backpressure, deadline and unconfirmed-cleanup cases retain their behavior; + real Git timeout/terminal cancellation retire the query's helpers. A cleanup + error retains the unknown diagnostic through the existing patch point. +- **Limits:** seven Windows Job Object cases are skipped on macOS. One combined + validation attempt fails because the Git fixture's hook did not start within + its existing budget; the isolated Git selection subsequently passes without + changing that budget or oracle. Owner-scoped mypy checks all three touched + loading adapters and retains the same 29 diagnostics at base and proposal. + Full configured mypy, packaged/split installation, minimal selection, D2/D5, + complete R0/R1/V10, hard-lease/context/model parity and full-cost benefit remain + unqualified. Independent exact-head review and required validation still govern + adoption; the historical failures above remain historical failures. + +### 2026-11-06 — Paired cost campaign remains unqualified + +The bounded campaign at `a9485073f` versus `86d9c542d` produces useful negative +evidence, not an attributable speedup. It ends at the frozen 20-minute sampling +wall limit with 499 accepted observations: eight source groups have 30 pairs +each; wheel JSON-version has nine pairs and one unmatched candidate observation. +The other wheel groups were not sampled. The original criteria and every +observation remain retained; neither the cap nor the incomplete groups become +a passing qualification. Later integration of target `cd677182c` is outside +these immutable measurements. + +The experiment uses macOS arm64, CPython 3.13.5, Node 26.0.0 and the real +Node-owned SQLite 3.53.1 backend. Source and disposable monolithic `loopx` 2.2.0 +wheel environments have the same 57 dependency versions and verified import +provenance. The public entrypoint exercises JSON version, selected +`performance-diagnosis` help, status, quota admission and deterministic +generic-cli work on the existing `_write_live_fixture(provider="sqlite")` owner. +Order is fixed: source before wheel, that command order, cold before warm, +alternating baseline/candidate order by pair. Each task has fresh synthetic +state. Cold uses a fresh Python process and retired owned broker; warm uses a +fresh Python process and retained broker. Filesystem caches are uncontrolled +and bytecode writes are disabled; this is not a cold-disk claim. + +**Cache limitation:** existing bytecode was not normalized. A subsequent +inventory finds 7 versus 1,241 LoopX `.pyc` files and 2 versus 344 dependency +`.pyc` files in the source arms. JSON version loads the same 1,157 LoopX modules +at both revisions; its parent read-open census is 2,402 versus 1,253. This +invalidates attribution of the observed source reductions to the five-module +loading change. The numbers below describe these cache conditions only. + +| Source operation | Broker condition | Baseline seconds, median / p95 | Candidate seconds, median / p95 | +| --- | --- | --- | --- | +| JSON version | absent | 3.092 / 3.394 | 1.707 / 2.110 | +| Selected help | absent | 0.093 / 0.174 | 0.101 / 0.165 | +| Status | cold | 2.104 / 2.437 | 1.832 / 2.074 | +| Status | warm | 1.304 / 1.422 | 0.985 / 1.115 | +| Quota admission | cold | 2.481 / 2.720 | 2.029 / 2.331 | +| Quota admission | warm | 1.785 / 1.881 | 1.313 / 1.460 | +| Generic-cli task | cold | 3.910 / 4.158 | 3.446 / 3.803 | +| Generic-cli task | warm | 2.837 / 3.025 | 2.424 / 2.635 | + +- **Covered costs:** cold task CPU for the target and waited descendants is + 1.769 / 1.832 versus 1.283 / 1.388 seconds, median / p95; their maximum RSS + is 79.2 / 87.4 versus 75.5 / 78.7 MiB. Separate broker CPU is 0.720 / 0.780 + versus 0.735 / 0.800 seconds, with end RSS 126.6 / 129.7 versus + 128.3 / 133.5 MiB. These are not a concurrent tree peak or complete CPU ledger. + Outputs remain 81, 325, 21,829, 23,795 and 3,557 bytes for the five operations. + One-minute host load ranges from 5.00 to 14.42; this is a named-host observation. +- **Separate instrumentation:** task loading remains 538 → 533 in source and + wheel. Cold task parent read/write-open counts are 2,179/284 → 1,643/284 in + source and 2,184/284 → 2,173/284 in wheel. All four captures make 238 local + IPC connections; covered socket send/receive bytes are 895,335/391,794 and + 895,339/391,794 in source, 894,790/391,543 in both wheels. These cover the + observed Python sockets, not all context, transport or descendant I/O. + A separate source cold-task audit records parent/waited-child CPU + 1.234/0.560 → 0.802/0.582 seconds and maximum RSS + 88.7/75.3 → 63.4/75.4 MiB; these instrumented samples are not timing evidence. + The existing performance-diagnosis recipes inspect separate Pyinstrument + and actual Node-worker CPU profiles. Parent waits, workspace capture and + reads, and worker startup/retirement remain investigation leads; profile + elapsed/idle weights do not enter the timing table. Full JSON version uses + the full parser/export graph, while `--version` loads two LoopX modules. +- **Semantic readback:** all sampled tasks independently accept the same eight + squared integers. Source and wheel at both revisions pass ordinary committed + replay, interruption after typed-result persistence, original-journal resume + and replay. Each accepted fixture has one host attempt/invocation, writeback + and debit; replay repeats none. Dropping the last square makes all four + independent validators reject, with one dispatch and zero writebacks/debits. + Owned brokers finish with OS PID-exit readback. One seed shutdown initially + targets a different interpreter-alias fingerprint; its failure is retained + and recovery uses the verified owned locator, without touching host state. +- **Complete-cost limits and next work:** setup/orchestration is 888 seconds, + including 194 seconds of fixture preparation and a 98-second successful + preflight; unsuccessful bundle-build, invocation and preflight attempts remain + failures. Sampling target elapsed totals 1,001 seconds inside the 20-minute + wall cap. Separate semantic validation takes 39 seconds; artifact rejection + plus diagnosis/cleanup recovery takes 184 seconds; the instrumented census + takes 108 seconds, and task/version profiling 58/20 seconds. Subsequent + authoring/tool/model costs, complete native/thread work and full-tree peaks + remain incompletely covered. Both wheels contain 1,997 files and + approximately 8.9 MiB compressed; no split install is qualified. The next + cost experiment must match bytecode conditions and choose a feasible + representative source/wheel corpus from measured setup and invocation costs + before freezing it. Further loading edits await that comparison. R0/V10, + R1/minimal, D2/D5/D6, I3/D3, model/context/hard-lease and installation gaps + retain their existing owners and gates; this checkpoint closes none of them. + +### 2026-11-06 — Matched-bytecode representative cost checkpoint + +A new frozen comparison of `546c08c0d3f22358ef09b13e69f7edaa806121b8` +and `0df5ad7921faab3b9ab8ec809f2fd71060692cce` completes all six selected +source/wheel groups, each with 30 interleaved pairs: 360 accepted observations +in 948.573 seconds, within the unchanged 1,200-second sampling cap. E7 remains +an incomplete, cache-confounded experiment; these are separate observations. +The measured proposal includes target main through `546c08c0d`. Later target +integration is outside this immutable cost comparison. + +The environment remains macOS arm64, CPython 3.13.5, Node 26.0.0 and real +Node-owned SQLite 3.53.1. All four source/monolithic-wheel environments have +verified provenance and identical 57 dependency versions. Before freezing, +each arm compiles stdlib, dependencies and all 1,421 LoopX Python sources into +an owned `PYTHONPYCACHEPREFIX`; timestamp-header inventories find no missing or +invalid own/dependency caches. Relative source sets match within each +distribution. Inventories and frozen harness hashes remain identical after +sampling, recovery and profiles. Targets disable bytecode writes. Source runs +before wheel, selected help before task, cold before warm; even pairs use +baseline/candidate order and odd pairs reverse it. Every invocation has fresh +Python; cold retires the owned broker and warm retains it. Disk cache remains +uncontrolled. One-minute host load ranges from 8.35 to 88.73; retain every tail +observation, without attributing the source cold p95 difference to the code. + +| Selected operation | Broker | Baseline seconds, median / p95 | Proposal seconds, median / p95 | Median paired proposal minus baseline, seconds | +| --- | --- | --- | --- | --- | +| Source help | absent | 0.136 / 0.193 | 0.138 / 0.168 | +0.0010 | +| Source SQLite task | cold | 3.579 / 5.997 | 3.617 / 9.234 | -0.0195 | +| Source SQLite task | warm | 2.673 / 3.019 | 2.657 / 2.979 | -0.0602 | +| Wheel help | absent | 0.087 / 0.107 | 0.083 / 0.116 | -0.0033 | +| Wheel SQLite task | cold | 3.722 / 4.266 | 3.663 / 4.430 | -0.0847 | +| Wheel SQLite task | warm | 2.707 / 3.111 | 2.797 / 3.156 | -0.0457 | + +The p95 uses nearest rank. Median paired differences and differences of arm +medians are different statistics. No material latency or CPU benefit is +demonstrated: arm medians and tails have mixed directions, and task CPU remains +similar. These data qualify the representative comparison's completion and +semantic parity, not a speed threshold, full V10 or long-horizon benefit. + +| Task path | Target plus waited descendants CPU seconds, median (base → proposal) | Target/waited peak RSS MiB, median (base → proposal) | Owned broker CPU seconds, median (base → proposal) | Broker end RSS MiB, median / p95 (base → proposal) | +| --- | --- | --- | --- | --- | +| Source cold | 1.311 → 1.325 | 75.75 → 75.58 | 0.770 → 0.775 | 127.9 / 134.8 → 128.6 / 133.5 | +| Source warm | 1.235 → 1.226 | 76.15 → 76.16 | 0.180 → 0.185 | 146.5 / 148.4 → 129.8 / 148.7 | +| Wheel cold | 1.353 → 1.369 | 75.94 → 76.56 | 0.805 → 0.810 | 129.5 / 134.4 → 130.1 / 134.1 | +| Wheel warm | 1.273 → 1.260 | 76.05 → 76.69 | 0.190 → 0.180 | 100.4 / 136.3 → 101.4 / 140.2 | + +These RSS observations are not concurrent process-tree peaks; warm broker +end RSS also reflects its retained lifetime. Help target/waited median RSS is +17.56–17.85 MiB and median CPU is 0.046–0.056 seconds. Uninstrumented stdout is +325 bytes for help, 3,557 for source tasks and 3,550 for wheel tasks in both +arms; stderr is empty. Target/waited block-I/O counters do not measure all +broker/native I/O. + +Separate instrumentation confirms task loading 538 → 533 in both distributions, +with only the previously named five DSH modules removed; help stays at 12. +Parent read-open counts are source cold 1,680 → 1,675, source warm 1,269 → 1,264, +wheel cold 1,683 → 1,677 and wheel warm 1,272 → 1,267. Task write-opens stay at +284 cold and 279 warm, with 238 observed parent local socket connections. +Covered task send/receive bytes range from 893,698–894,864 / 392,608–392,859; +the source proposal removes five send bytes and wheel bytes are unchanged. +Help opens 31 files in source and 32 in wheel, with no writes or socket traffic. +Instrumented task parent CPU is 0.635–0.774 seconds and peak RSS 62.3–65.5 MiB; +waited-child CPU is 0.528–0.573 seconds and peak RSS 76.1–78.6 MiB. These single +captures are a census, separate from the repeated timing results. + +The same isolated real SQLite fixtures at all four variants pass ordinary +committed replay, interruption immediately after typed-result persistence, +original-journal recovery and committed replay. Independent squared-integer +artifact/transition oracles retain identical artifact bytes and one host +invocation/attempt, one writeback and one debit. Resume does not redispatch; +replay repeats no effect. Removing the final square produces exit 1 / failed +with one dispatch and zero writebacks/debits in every variant. All owned +brokers have shutdown/OS-exit readback; no experimental broker remains live. + +Setup/orchestration costs 1,018.786 seconds, including overlapping build, +installation, fixture and cache work; those phases must not be summed. +The separate rejection phase costs 16.056 seconds, instrumentation 39.116 and +eight parent/actual-Node profile captures 54.880. Recovery/replay target time +is recorded separately; its complete orchestration cost remains uncovered. +Existing `performance-diagnosis` recipes report workspace capture, journal +checkpoints, Git probes and quota preparation among Python observations; Node +profiles include startup, request dispatch, idle and retirement. Sampled stack +weights are neither benchmark latency nor proof of a root cause; inclusive +weights, warmup and retirement cannot be added as task savings. Full context +and process-tree/native/thread/model costs remain unknown. Monolithic wheels +remain approximately 8.9 MiB; this does not qualify split installation. + +**Evidence-conditioned next action:** retain the tested compatibility/loading +seams, and stop expanding these helpers for an unproven speedup. Prioritize a +bounded characterization of the existing full-parser/export closure or the +measured workspace/journal/bridge owner, then require a new matched real-path +comparison before adopting a performance change. This checkpoint creates no +new owner, budget, preset or default. R0's complete census, R1/minimal, +D2/D5/D6, I3/D3, hard-lease/context/model and split-install acceptance remain +open; independent exact-head review and required validation govern adoption. + ## Appendix B: Decision log | Date | Decision | Owner / approval | Alternatives | Normative sections changed | @@ -840,6 +1101,8 @@ Approval needs a reviewed decision, not implementation progress or silence. | E4 | Selected CLI loading seam | `9cae3b28939f72fa4aadab5cfe49e19bcbdab372` | Linked entrypoint/CLI in Section 4 | Source-audited | No new timing claim | | E5 | DSH composition example | Pinned upstream commit | [`minimal.patch.yml`](https://github.com/deepseek-ai/deepseek-harness/blob/f3803c96974ad4a94195fb97e5c637e9c2a3afe3/packages/bundle/web-app/presets/minimal.patch.yml), [`standard.patch.yml`](https://github.com/deepseek-ai/deepseek-harness/blob/f3803c96974ad4a94195fb97e5c637e9c2a3afe3/packages/bundle/web-app/presets/standard.patch.yml), [`sdk-minimal`](https://github.com/deepseek-ai/deepseek-harness/blob/f3803c96974ad4a94195fb97e5c637e9c2a3afe3/packages/bundle/sdk-minimal/README.md) | Source-audited | Shared preset composition and a self-contained SDK bundle; not a LoopX host qualification or permission recommendation | | E6 | Pi core/context example | Pinned upstream commit | [`agent` core](https://github.com/badlogic/pi-mono/blob/f14813e96510a7cf7562a120e809c6a134af073d/packages/agent/README.md) | Source-audited | Agent/tool/event core with `transformContext`, `convertToLlm`, `prepareRequest` and `finishTurn` seams; no LoopX authority or performance claim | +| E7 | Bounded paired cost campaign | `a9485073f` / `86d9c542d`; named environment above | Paired cost checkpoint above; existing entrypoint/SQLite fixture and performance-diagnosis owners | 499 observations; source groups complete, wheel incomplete; cache attribution unqualified | Aggregate public-safe evidence only; no speedup, full V10, split-install or model-cost qualification | +| E8 | Matched-bytecode representative comparison | `546c08c0d` / `0df5ad792`; named environment above | Matched-bytecode checkpoint above; same CLI/SQLite and performance-diagnosis owners | Six groups × 30 pairs; 360 observations; parity/rejection and cache readback pass; no demonstrated material benefit | Aggregate public-safe evidence only; later integration, full V10, minimal, split install and model costs unqualified | ## Appendix D: Rejected or superseded alternatives 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 5136da5181..266ae73948 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md @@ -599,6 +599,215 @@ R0 可以先于目录移动交付,但不代表内核合格。R3/R4 可选, 不关闭完整 R0/V10、M1、拆包安装、R1 managed minimal 或 D1–D7。不改变 preset、 前端操作、权限或持久化状态;受保护执行仍由既有 owner 所有。 +### 2026-11-06 — 所选 Turn 宿主的加载接缝 + +承接 [#8180](https://github.com/loopx-project/loopx/pull/8180) 的 +generic-cli/SQLite 候选,源码提案 `9b80cf7d6` 在 `turn_run_once` 延迟加载 DSH +builder,直到调用方选择 DSH 或显式请求既有 export。命名 import、原 builder +身份、静态类型、`dir()`、star import 与 CLI builder patch 点均保留。复用既有 +宿主/authority owner;这不选择 minimal preset,也不代表 D2/D5 已采纳。 + +- **观察到的变化:** 真实 generic-cli/SQLite 任务路径加载 533 个 LoopX 模块, + 基线 `62e234c93` 为 538。移出的五个模块是 `turn_dsh_host`、 + `turn_driver.host_candidate`、`dsh_goal_mode` 及其 `host_failure_map`/ + `turn_host_adapter`,没有新增模块。冷 import oracle 在基线失败、提案通过; + 既有 DSH CLI 夹具还证明公开 builder patch 点被使用。 +- **真实路径一致性:** 同一个私有 harness 在两个版本的隔离 SQLite 夹具上运行 + 正常完成、typed host result 持久化后中断、原 journal 恢复及 committed 重放。 + 每个夹具最终仅一次宿主调用/attempt、一次 writeback 和一次扣额,重放不重复 + 效果。独立 argv 验收器接受字节相同的产物。每个版本使用隔离 native broker, + 已核验停止与 OS 退出。这仍是确定性 transport 证据,不是模型宿主 V4 或正常 + hard-lease V2 的资格。 +- **观察成本:** 每个版本一次正常插桩调用记录耗时 2.962 → 3.169 秒,父进程 + CPU 0.737 → 0.775 秒、峰值 RSS 65.6 → 64.6 MiB;输出均为 3,474 字节。 + 已 wait 子进程的 CPU 为 0.567 → 0.599 秒。缓存、执行顺序与竞争工作未受控, + 这些观察不证明延迟改善,也不建立回归阈值。broker RSS 观察及 wait 子进程的 + usage 不覆盖完整进程树峰值、完整 wire/context 字节或模型成本。加载变化较小, + 长程收益和 D6 资格仍未证明。 +- **`bc59a9ed4` 的历史验证限制:** 更广的源码选择为 596 个通过、一个 preview 冷 import 测试失败; + 基线独立出现同一失败,原因是 `host_bridge_transport` 提前导入 + `extensions.process_runtime`。owner 范围的 mypy 使用 `--follow-imports=skip`, + 两个版本在线号归一化后均报告相同 29 个诊断,不代表完整配置的 mypy 套件。 + 两项失败均不豁免、不改写为通过。下述 process helper 接缝在同一作者提案中 + 修复 preview 失败,并保留类型检查限制。 +- **剩余:** 采纳仍受独立评审、必需验证和精确 head 的 merge readiness 治理。 + opt-in minimal 选择、D2/D5 决策、完整 R0/V10/R1、hard-lease/上下文/模型 + 一致性和拆包安装仍未关闭;独立 status 提案 #8011 保留原限制。不改变权限、 + provider、默认值、已安装宿主或发布行为。 + +### 2026-11-06 — Preview process helper 保留在执行路径 + +后续 runtime 提案 `a44e27d96` 修复未改动的 preview import oracle。先延迟 +bridge helper 暴露了 `doctor_git` 的第二条 eager import;两个 adapter 现均在 +执行/清理或显式 import 时解析既有 `process_runtime` owner。命名/star import、 +原函数身份、静态 alias、`dir()` 和公开 patch 点均保留。TypeScript Host lifetime +及进程/权限判定不迁移。 + +- **可观察结果:** 导入 preview command 加载 337 个 LoopX 模块,`bc59a9ed4` + 为 338,仅移出 `extensions.process_runtime`。真实 Git 成功读取同样不加载清理 + helper。实际 generic-cli 执行仍需要这些 helper,两个版本均加载 533 个模块。 + 本片修复加载边界,不证明提速或长程收益。 +- **真实路径:** 同一个隔离 SQLite harness 在两个不可变 runtime 版本通过正常 + 完成、typed result 后中断、原 journal 恢复及 committed 重放。产物字节相同, + 每个夹具仅一次宿主调用/attempt、writeback 和扣额,重放无重复效果。两个自有 + broker 均停止并核验 OS 退出。真实 bridge send/cancel、背压、deadline 和清理 + 未确认分支保留行为;真实 Git 超时/终端取消退役查询自有 helper。清理错误通过 + 既有 patch 点保留 unknown 诊断。 +- **限制:** macOS 跳过七个 Windows Job Object 用例。一轮组合验证因 Git 夹具 + hook 未在既有预算内启动而失败;随后隔离 Git 选择通过,未修改预算或 oracle。 + owner 范围的 mypy 检查三个受影响加载 adapter,基线与提案仍有相同 29 个诊断。 + 完整配置 mypy、打包/拆包安装、minimal 选择、D2/D5、完整 R0/R1/V10、 + hard-lease/上下文/模型一致性及完整成本收益仍未取得资格。采纳仍受独立精确 + head 评审与必需验证治理;前述历史失败仍是历史失败。 + +### 2026-11-06 — 成对成本实验仍未取得资格 + +`a9485073f` 对比 `86d9c542d` 的有界实验产生了有用的负面证据,未证明可归因的 +提速。实验在冻结的 20 分钟采样墙钟上限结束,共有 499 个通过语义断言的观察: +八个源码组各完成 30 对;wheel 的 JSON version 完成九对,另有一个未配对的候选 +观察,其余 wheel 组未采样。原标准和全部观察均保留;上限触发及未完成组不改写为 +资格通过。随后集成的目标 `cd677182c` 不在这组不可变版本的测量中。 + +环境为 macOS arm64、CPython 3.13.5、Node 26.0.0,真实 backend 是 Node owner +的 SQLite 3.53.1。源码和隔离的单体 `loopx` 2.2.0 wheel 环境具有相同的 57 个 +依赖版本,并核验实际 import 来源。公开入口依次运行 JSON version、所选 +`performance-diagnosis` help、status、quota admission 和确定性 generic-cli +任务,复用既有 `_write_live_fixture(provider="sqlite")` owner。顺序冻结为源码 +先于 wheel、上述命令顺序、冷路径先于暖路径,每对交替基线/候选顺序。每个任务 +使用新的合成状态。冷路径为新 Python 进程加已退役的自有 broker;暖路径为新 +Python 进程加保留的 broker。文件系统缓存未受控、禁止写入字节码,不声明冷磁盘。 + +**缓存限制:** 既有字节码未归一化。后续盘点发现源码组 LoopX `.pyc` 数为 +7 对 1,241,依赖 `.pyc` 数为 2 对 344。两个版本的 JSON version 均加载 +1,157 个 LoopX 模块,父进程 read-open census 为 2,402 对 1,253。因此不能把 +源码观察到的下降归因于五个模块的加载变化;下表仅描述这些缓存条件。 + +| 源码操作 | Broker 条件 | 基线秒数,中位数 / p95 | 候选秒数,中位数 / p95 | +| --- | --- | --- | --- | +| JSON version | 无 | 3.092 / 3.394 | 1.707 / 2.110 | +| 所选 help | 无 | 0.093 / 0.174 | 0.101 / 0.165 | +| Status | 冷 | 2.104 / 2.437 | 1.832 / 2.074 | +| Status | 暖 | 1.304 / 1.422 | 0.985 / 1.115 | +| Quota admission | 冷 | 2.481 / 2.720 | 2.029 / 2.331 | +| Quota admission | 暖 | 1.785 / 1.881 | 1.313 / 1.460 | +| Generic-cli 任务 | 冷 | 3.910 / 4.158 | 3.446 / 3.803 | +| Generic-cli 任务 | 暖 | 2.837 / 3.025 | 2.424 / 2.635 | + +- **覆盖的成本:** 冷任务 target 和已 wait 后代的 CPU 中位数/p95 为 + 1.769 / 1.832 对 1.283 / 1.388 秒,其最大 RSS 为 79.2 / 87.4 对 + 75.5 / 78.7 MiB。独立 broker CPU 为 0.720 / 0.780 对 0.735 / 0.800 秒, + 结束时 RSS 为 126.6 / 129.7 对 128.3 / 133.5 MiB;不代表并发进程树峰值或 + 完整 CPU 账本。五个操作的输出分别保持 81、325、21,829、23,795、3,557 字节。 + 宿主一分钟 load 范围为 5.00–14.42;这是具名环境的观察。 +- **独立 instrumentation:** 源码和 wheel 的任务加载仍为 538 → 533。 + 冷任务父进程 read/write-open 为源码 2,179/284 → 1,643/284、wheel + 2,184/284 → 2,173/284。四个 capture 各有 238 次本地 IPC 连接;覆盖的 socket + send/receive 字节为源码 895,335/391,794 与 895,339/391,794,两个 wheel 均为 + 894,790/391,543,仅覆盖已观察的 Python socket,不覆盖全部 context、transport + 或后代 I/O。独立源码冷任务 audit 的父进程/已 wait 子进程 CPU 为 + 1.234/0.560 → 0.802/0.582 秒,最大 RSS 为 88.7/75.3 → 63.4/75.4 MiB; + 这些 instrumentation 样本不作为计时证据。既有 performance-diagnosis recipe 分别检查 Pyinstrument 和实际 + Node worker CPU profile。父进程等待、workspace capture 和读取,以及 worker + 启动/退役仍是调查线索;profile elapsed/idle 权重不进入计时表。完整 JSON + version 走 full parser/export graph,`--version` 仅加载两个 LoopX 模块。 +- **语义回读:** 所有已采样任务通过独立断言,产物为相同的八个整数平方。源码和 + wheel 在两个版本均通过正常 committed 重放、typed result 落盘后中断、原 + journal 恢复及重放。每个被接受的夹具仅一次宿主 attempt/调用、writeback 和 + 扣额,重放不重复效果。移除最后一个平方值使四个独立 validator 均拒绝,只有 + 一次 dispatch、零 writeback/扣额。自有 broker 结束后核验 OS PID 退出。 + 一个 seed shutdown 起初选到不同 interpreter alias 的 fingerprint;保留该失败, + 随后通过核验过的自有 locator 恢复,未触及宿主状态。 +- **完整成本限制及下一步:** setup/编排为 888 秒,包含 194 秒夹具准备和 + 98 秒成功 preflight;失败的 bundle build、调用及 preflight 尝试仍为失败。 + 20 分钟墙钟上限内,采样 target elapsed 总计 1,001 秒。独立语义验证耗时 + 39 秒;产物拒绝及诊断/清理恢复 184 秒;instrumented census 为 108 秒, + 任务/version profiling 为 58/20 秒。后续撰写/工具/模型成本、完整 + native/thread 工作及进程树峰值仍未完整覆盖。两个 wheel 均有 + 1,997 个文件、压缩体积约 8.9 MiB,不代表拆包安装资格。下一轮成本实验必须 + 对齐字节码条件,并在冻结前根据实际准备/调用成本选定可完成的代表性 + source/wheel corpus;进一步加载修改等待该对照。R0/V10、R1/minimal、D2/D5/D6、 + I3/D3、模型/上下文/hard-lease 和安装缺口保留既有 owner/gate,本 checkpoint + 不关闭其中任何一项。 + +### 2026-11-06 — 字节码匹配的代表性成本 checkpoint + +新冻结对照比较 `546c08c0d3f22358ef09b13e69f7edaa806121b8` 与 +`0df5ad7921faab3b9ab8ec809f2fd71060692cce`。所选 source/wheel 六组各完成 +30 对交错样本:360 个有效观察,采样耗时 948.573 秒,保持原定 1,200 秒上限。 +E7 仍是不完整且缓存混杂的实验;本轮数据单独保留。测量候选包含截至 +`546c08c0d` 的目标 main;后续目标分支集成不在本次不可变成本对照内。 + +环境仍为 macOS arm64、CPython 3.13.5、Node 26.0.0 及真实 Node-owned +SQLite 3.53.1。四个源码/单体 wheel 环境的来源已核验,57 个依赖版本相同。 +冻结前,各 arm 在独占 `PYTHONPYCACHEPREFIX` 编译标准库、依赖及全部 +1,421 个 LoopX Python 源文件;时间戳 header 清点没有缺失/无效的自身或依赖缓存。 +同一发行形态的相对源码集合一致。采样、恢复和 profile 后,缓存清点与冻结 harness +哈希仍相同;目标进程禁止字节码写入。顺序固定为源码先于 wheel、help 先于 task、 +cold 先于 warm;偶数对先 baseline,奇数对先 candidate。每次调用都是新 Python +进程;cold 退休独占 broker,warm 保留 broker。磁盘缓存不受控,不代表冷磁盘。 +一分钟宿主负载范围为 8.35–88.73;保留全部尾部观察,不将源码 cold p95 差异 +归因于代码。 + +| 所选操作 | Broker | 基线秒数,中位数 / p95 | 候选秒数,中位数 / p95 | 每对候选减基线的中位数,秒 | +| --- | --- | --- | --- | --- | +| Source help | 无 | 0.136 / 0.193 | 0.138 / 0.168 | +0.0010 | +| Source SQLite task | cold | 3.579 / 5.997 | 3.617 / 9.234 | -0.0195 | +| Source SQLite task | warm | 2.673 / 3.019 | 2.657 / 2.979 | -0.0602 | +| Wheel help | 无 | 0.087 / 0.107 | 0.083 / 0.116 | -0.0033 | +| Wheel SQLite task | cold | 3.722 / 4.266 | 3.663 / 4.430 | -0.0847 | +| Wheel SQLite task | warm | 2.707 / 3.111 | 2.797 / 3.156 | -0.0457 | + +p95 使用 nearest rank。配对差的中位数与两 arm 中位数之差是不同统计量。 +本轮未证明明显延迟或 CPU 收益:arm 中位数和尾部变化方向不一致,task CPU +相近。数据证明代表性对照完整执行及语义一致,不证明速度阈值、完整 V10 或 +长程收益。 + +| Task 路径 | Target 与等待子进程 CPU 秒数,中位数(基线 → 候选) | Target/等待子进程峰值 RSS MiB,中位数(基线 → 候选) | 独占 broker CPU 秒数,中位数(基线 → 候选) | Broker 结束 RSS MiB,中位数 / p95(基线 → 候选) | +| --- | --- | --- | --- | --- | +| Source cold | 1.311 → 1.325 | 75.75 → 75.58 | 0.770 → 0.775 | 127.9 / 134.8 → 128.6 / 133.5 | +| Source warm | 1.235 → 1.226 | 76.15 → 76.16 | 0.180 → 0.185 | 146.5 / 148.4 → 129.8 / 148.7 | +| Wheel cold | 1.353 → 1.369 | 75.94 → 76.56 | 0.805 → 0.810 | 129.5 / 134.4 → 130.1 / 134.1 | +| Wheel warm | 1.273 → 1.260 | 76.05 → 76.69 | 0.190 → 0.180 | 100.4 / 136.3 → 101.4 / 140.2 | + +上述 RSS 不是并发进程树峰值;warm broker 结束 RSS 还反映其保留生命周期。 +Help 的 target/等待子进程 RSS 中位数为 17.56–17.85 MiB,CPU 中位数为 +0.046–0.056 秒。未插桩 stdout 在两 arm 均为 help 325 字节、source task +3,557 字节、wheel task 3,550 字节,stderr 为空。Target/等待子进程块 I/O +计数不覆盖全部 broker/native I/O。 + +独立插桩确认两种发行形态的 task 加载均为 538 → 533,仅移除前述五个 DSH +模块;help 保持 12 个模块。Parent read-open 为 source cold 1,680 → 1,675、 +source warm 1,269 → 1,264、wheel cold 1,683 → 1,677、wheel warm +1,272 → 1,267。Task write-open 保持 cold 284、warm 279,观测到的 parent +本地 socket 连接均为 238。覆盖的 task 发送/接收字节范围为 +893,698–894,864 / 392,608–392,859;source 候选少发送五字节,wheel 不变。 +Help 在 source 打开 31 个文件、wheel 32 个,无写入或 socket 流量。 +插桩 task parent CPU 为 0.635–0.774 秒、峰值 RSS 62.3–65.5 MiB;等待子进程 +CPU 0.528–0.573 秒、峰值 RSS 76.1–78.6 MiB。单次捕获只作清点,与重复计时分开。 + +四个版本组合的同源隔离真实 SQLite 夹具均通过普通 committed replay、typed-result +持久化后中断、原 journal 恢复及 committed replay。独立平方整数产物/转移 oracle +确认产物字节相同,host invocation/attempt、writeback、扣额各一次。Resume +不重复 dispatch,replay 不重复 effect。移除最后一个平方值时,四组均以 exit 1 / +failed 拒绝,dispatch 一次而 writeback/扣额为零。所有独占 broker 均取得 +shutdown 与 OS 退出读回,实验 broker 已全部退出。 + +准备/编排耗时 1,018.786 秒,包含有重叠的构建、安装、夹具及缓存工作,各阶段 +不能求和。独立拒绝阶段为 16.056 秒、插桩 39.116 秒、八个 parent/实际 Node +profile 捕获 54.880 秒。恢复/重放 target 耗时单独记录,其完整编排成本尚未覆盖。 +既有 `performance-diagnosis` recipe 在 Python 观察中显示 workspace capture、 +journal checkpoint、Git probe 及 quota preparation;Node profile 包含启动、 +请求 dispatch、idle 与 retirement。采样栈权重不是 benchmark 延迟或根因证明; +inclusive 权重、warmup 与 retirement 不能相加作为 task 节省。完整 context、 +进程树/native/thread/模型成本仍未知。单体 wheel 仍约 8.9 MiB,不证明拆包安装。 + +**证据驱动的下一步:** 保留已验证的兼容/加载接缝,停止为未证明的提速扩展这些 +helper。优先有界刻画既有完整 parser/export 闭包或实测 workspace/journal/bridge +owner;采纳性能变更前必须另做匹配的真实路径对照。本 checkpoint 不新增 owner、 +预算、preset 或默认行为。R0 完整清点、R1/minimal、D2/D5/D6、I3/D3、 +hard-lease/context/model 和拆包安装验收仍开放;采纳仍受独立 exact-head +评审及必需验证约束。 + ## 附录 B:决策日志 | 日期 | 决策 | 负责人 / 批准 | 备选 | 变更的规范章节 | @@ -615,6 +824,8 @@ R0 可以先于目录移动交付,但不代表内核合格。R3/R4 可选, | E4 | 所选 CLI 加载接缝 | `9cae3b28939f72fa4aadab5cfe49e19bcbdab372` | 第 4 节链接的入口/CLI | 源码审计 | 无新性能测量 | | E5 | DSH 装配范例 | 固定上游 commit | [`minimal.patch.yml`](https://github.com/deepseek-ai/deepseek-harness/blob/f3803c96974ad4a94195fb97e5c637e9c2a3afe3/packages/bundle/web-app/presets/minimal.patch.yml)、[`standard.patch.yml`](https://github.com/deepseek-ai/deepseek-harness/blob/f3803c96974ad4a94195fb97e5c637e9c2a3afe3/packages/bundle/web-app/presets/standard.patch.yml)、[`sdk-minimal`](https://github.com/deepseek-ai/deepseek-harness/blob/f3803c96974ad4a94195fb97e5c637e9c2a3afe3/packages/bundle/sdk-minimal/README.md) | 源码审计 | 共享预设装配与自包含 SDK bundle;不代表 LoopX 宿主资格或权限建议 | | E6 | Pi core/上下文范例 | 固定上游 commit | [`agent` core](https://github.com/badlogic/pi-mono/blob/f14813e96510a7cf7562a120e809c6a134af073d/packages/agent/README.md) | 源码审计 | Agent/tool/event core,以及 `transformContext`、`convertToLlm`、`prepareRequest`、`finishTurn` 接缝;无 LoopX authority 或性能声明 | +| E7 | 有界成对成本实验 | `a9485073f` / `86d9c542d`;上述具名环境 | 上述成对成本 checkpoint;既有入口/SQLite 夹具及 performance-diagnosis owner | 499 个观察;源码组完整,wheel 不完整;缓存归因未取得资格 | 仅公开安全汇总;不证明提速、完整 V10、拆包安装或模型成本 | +| E8 | 字节码匹配的代表性对照 | `546c08c0d` / `0df5ad792`;上述具名环境 | 上述匹配字节码 checkpoint;相同 CLI/SQLite 及 performance-diagnosis owner | 六组各 30 对,共 360 个观察;语义/拒绝及缓存读回通过;未证明明显收益 | 仅公开安全汇总;后续集成、完整 V10、minimal、拆包安装及模型成本未取得资格 | ## 附录 D:被拒绝或被取代的备选 diff --git a/loopx/cli_commands/turn_run_once.py b/loopx/cli_commands/turn_run_once.py index 11db4e23cf..4e82d3f611 100644 --- a/loopx/cli_commands/turn_run_once.py +++ b/loopx/cli_commands/turn_run_once.py @@ -1,8 +1,10 @@ from __future__ import annotations import argparse +import sys as _sys from collections.abc import Callable, Mapping from pathlib import Path +from typing import TYPE_CHECKING as _TYPE_CHECKING from typing import Any from ..capabilities.explore.composition_frontier import ( @@ -42,15 +44,37 @@ from .turn_cadence import managed_cadence_start from .turn_host_request import build_codex_cli_host_runner, prepare_turn_host_request from .turn_decision import collect_turn_status_payload, current_turn_host_launch_decision -from .turn_dsh_host import build_dsh_host_runner, build_dsh_operation_host_runner from .turn_rendering import build_turn_error_payload from .turn_post_writeback import drain_committed_turn_post_writeback_hooks from .turn_todo_writeback import captured_todo_text_sha256, write_turn_validated_completion +if _TYPE_CHECKING: + from .turn_dsh_host import ( + build_dsh_host_runner as build_dsh_host_runner, + build_dsh_operation_host_runner as build_dsh_operation_host_runner, + ) + EXACT_SETTLEMENT_READBACK_NOT_FOUND = ( "exact settlement readback unexpectedly returned not-found" ) +# Keep the existing import and patch seams without loading an unselected host. +_DSH_HOST_EXPORTS = ("build_dsh_host_runner", "build_dsh_operation_host_runner") + + +def __getattr__(name: str) -> Any: + if name not in _DSH_HOST_EXPORTS: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + from . import turn_dsh_host + + value = getattr(turn_dsh_host, name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(set(globals()) | set(_DSH_HOST_EXPORTS)) + def execute_turn_run_once( args: argparse.Namespace, @@ -816,7 +840,7 @@ def resolve_built_in_session_binding( session_binding_resolver = resolve_built_in_session_binding elif args.host == "dsh": if getattr(args, "dsh_operation_module", None): - host_runner = build_dsh_operation_host_runner( + host_runner = getattr(_sys.modules[__name__], "build_dsh_operation_host_runner")( args, runtime_root=runtime_root, registry_path=registry_path, workspace=project, environ=operator_environ, goal_admission=goal_admission, @@ -829,7 +853,7 @@ def resolve_dsh_operation_session(envelope: Mapping[str, Any]) -> dict[str, str] session_binding_resolver = resolve_dsh_operation_session else: - host_runner = build_dsh_host_runner( + host_runner = getattr(_sys.modules[__name__], "build_dsh_host_runner")( args, workspace=project, environ=operator_environ, ) @@ -937,3 +961,6 @@ def on_managed_start_admitted() -> None: execution_started=execution_started, journal_readback=journal_readback, ) + + +__all__ = [name for name in globals() if not name.startswith("_")] + list(_DSH_HOST_EXPORTS) diff --git a/loopx/control_plane/turn_driver/host_bridge_transport.py b/loopx/control_plane/turn_driver/host_bridge_transport.py index 1dbed9c89a..79f012086b 100644 --- a/loopx/control_plane/turn_driver/host_bridge_transport.py +++ b/loopx/control_plane/turn_driver/host_bridge_transport.py @@ -6,11 +6,35 @@ import queue import signal import subprocess +import sys as _sys import threading import time from typing import Any +from typing import TYPE_CHECKING as _TYPE_CHECKING -from ...extensions.process_runtime import force_terminate_process, write_process_stdin_before_deadline +if _TYPE_CHECKING: + from ...extensions.process_runtime import ( + force_terminate_process as force_terminate_process, + write_process_stdin_before_deadline as write_process_stdin_before_deadline, + ) + +# Preview needs the bridge type; process helpers belong to execution or an +# explicit caller import. Keep their existing identity and patch seams. +_PROCESS_EXPORTS = ("force_terminate_process", "write_process_stdin_before_deadline") + + +def __getattr__(name: str) -> Any: + if name not in _PROCESS_EXPORTS: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + from ...extensions import process_runtime + + value = getattr(process_runtime, name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(set(globals()) | set(_PROCESS_EXPORTS)) class GuardedHostBridge: @@ -61,8 +85,8 @@ def send(self, frame: str) -> None: if self.next(deadline=self.deadline) != {"kind": "ready"}: raise RuntimeError("managed Host bridge did not become ready") self.requested = True - write_process_stdin_before_deadline(self.process, frame, deadline=self.deadline, - cancel_and_reap=self.cancel) + write_stdin = getattr(_sys.modules[__name__], "write_process_stdin_before_deadline") + write_stdin(self.process, frame, deadline=self.deadline, cancel_and_reap=self.cancel) def cancel(self) -> None: if self.cancelled: @@ -73,7 +97,8 @@ def cancel(self) -> None: if not self.requested: # No request bytes have been sent, so this bridge cannot own a # Host. This also handles suspension before JS handlers exist. - force_terminate_process(self.process, process_tree_owned=True) + getattr(_sys.modules[__name__], "force_terminate_process")( + self.process, process_tree_owned=True) self.not_launched = True elif self.process.poll() is None: self.process.terminate() @@ -98,7 +123,8 @@ def cancel(self) -> None: # Killing the bridge alone cannot certify an unobserved detached # Host. Retain the launching record and fail instead of claiming it. if self.process.poll() is None: - force_terminate_process(self.process, process_tree_owned=True) + getattr(_sys.modules[__name__], "force_terminate_process")( + self.process, process_tree_owned=True) raise RuntimeError("managed Host bridge cleanup unconfirmed") from error def close_output(self) -> None: @@ -112,3 +138,6 @@ def close_output(self) -> None: self.reader.join(timeout=self.cleanup_timeout) if not self.reader.is_alive(): self.output.close() + + +__all__ = [name for name in globals() if not name.startswith("_")] + list(_PROCESS_EXPORTS) diff --git a/loopx/doctor_git.py b/loopx/doctor_git.py index df09510587..0c71ef8341 100644 --- a/loopx/doctor_git.py +++ b/loopx/doctor_git.py @@ -5,13 +5,31 @@ import os import re import subprocess +import sys as _sys from dataclasses import dataclass from enum import Enum from pathlib import Path from typing import Any +from typing import TYPE_CHECKING as _TYPE_CHECKING from urllib.parse import urlsplit -from .extensions.process_runtime import terminate_process_tree +if _TYPE_CHECKING: + from .extensions.process_runtime import terminate_process_tree as terminate_process_tree + + +# Successful read-only Git probes do not need process cleanup. Keep the former +# helper export and patch point, resolving its owner only when requested. +def __getattr__(name: str) -> Any: + if name != "terminate_process_tree": + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + from .extensions.process_runtime import terminate_process_tree + + globals()[name] = terminate_process_tree + return terminate_process_tree + + +def __dir__() -> list[str]: + return sorted(set(globals()) | {"terminate_process_tree"}) # `doctor` inspects a checkout the operator may not control, so every Git read @@ -64,7 +82,7 @@ def _retire_owned_probe(process: subprocess.Popen[str]) -> None: """ try: - terminate_process_tree(process, grace_seconds=0.2) + getattr(_sys.modules[__name__], "terminate_process_tree")(process, grace_seconds=0.2) except (OSError, RuntimeError): # The answer is already unknown; an unconfirmed cleanup must not turn a # read-only diagnostic into a traceback. @@ -324,3 +342,6 @@ def trusted_release_ref_for_root( "git_ref": f"{remote}/{expected_ref}", } return None + + +__all__ = [name for name in globals() if not name.startswith("_")] + ["terminate_process_tree"] diff --git a/tests/architecture/test_control_plane_import_boundaries.py b/tests/architecture/test_control_plane_import_boundaries.py index 1bfbdd12f4..df1d7e0321 100644 --- a/tests/architecture/test_control_plane_import_boundaries.py +++ b/tests/architecture/test_control_plane_import_boundaries.py @@ -324,6 +324,31 @@ def test_read_adapters_do_not_load_writeback_facades_for_leaf_helpers() -> None: assert completed.returncode == 0, completed.stderr +def test_turn_run_once_defers_dsh_builders_and_preserves_exports() -> None: + completed = subprocess.run( + [sys.executable, "-c", """ +import sys +import loopx.cli_commands.turn_run_once as command +assert 'loopx.cli_commands.turn_dsh_host' not in sys.modules +assert 'loopx.dsh_goal_mode.turn_host_adapter' not in sys.modules +names = {'build_dsh_host_runner', 'build_dsh_operation_host_runner'} +assert names <= set(dir(command)) +assert not hasattr(command, 'unknown_host_builder') +from loopx.cli_commands.turn_run_once import build_dsh_host_runner, build_dsh_operation_host_runner +from loopx.cli_commands import turn_dsh_host +assert build_dsh_host_runner is turn_dsh_host.build_dsh_host_runner +assert build_dsh_operation_host_runner is turn_dsh_host.build_dsh_operation_host_runner +exports = {} +exec('from loopx.cli_commands.turn_run_once import *', exports) +assert exports['build_dsh_host_runner'] is build_dsh_host_runner +assert exports['build_dsh_operation_host_runner'] is build_dsh_operation_host_runner +assert exports['execute_turn_run_once'] is command.execute_turn_run_once +"""], + cwd=REPOSITORY_ROOT, capture_output=True, check=False, text=True, + ) + assert completed.returncode == 0, completed.stderr + + def test_settlement_plan_import_does_not_load_native_capture_io() -> None: # A command template needs the native output profile, not capture binding # or the complete quota/Goal routing graph. Keep the typed kernel usable @@ -373,6 +398,54 @@ def test_turn_preview_import_does_not_select_report_delivery_or_lark_hosts() -> assert completed.returncode == 0, completed.stderr +def test_host_bridge_defers_process_helpers_and_preserves_exports() -> None: + completed = subprocess.run( + [sys.executable, "-c", """ +import sys +import loopx.control_plane.turn_driver.host_bridge_transport as bridge +assert 'loopx.extensions.process_runtime' not in sys.modules +names = {'force_terminate_process', 'write_process_stdin_before_deadline'} +assert names <= set(dir(bridge)) +assert not hasattr(bridge, 'unknown_process_helper') +from loopx.control_plane.turn_driver.host_bridge_transport import force_terminate_process, write_process_stdin_before_deadline +from loopx.extensions import process_runtime +assert force_terminate_process is process_runtime.force_terminate_process +assert write_process_stdin_before_deadline is process_runtime.write_process_stdin_before_deadline +exports = {} +exec('from loopx.control_plane.turn_driver.host_bridge_transport import *', exports) +assert exports['force_terminate_process'] is force_terminate_process +assert exports['write_process_stdin_before_deadline'] is write_process_stdin_before_deadline +assert exports['GuardedHostBridge'] is bridge.GuardedHostBridge +"""], + cwd=REPOSITORY_ROOT, capture_output=True, check=False, text=True, + ) + assert completed.returncode == 0, completed.stderr + + +def test_doctor_git_defers_cleanup_and_preserves_exports() -> None: + completed = subprocess.run( + [sys.executable, "-c", """ +import sys +from pathlib import Path +from loopx import doctor_git +assert 'loopx.extensions.process_runtime' not in sys.modules +assert doctor_git._run_git(Path.cwd(), 'rev-parse', '--show-toplevel').returncode == 0 +assert 'loopx.extensions.process_runtime' not in sys.modules +assert 'terminate_process_tree' in dir(doctor_git) +assert not hasattr(doctor_git, 'unknown_process_helper') +from loopx.doctor_git import terminate_process_tree +from loopx.extensions import process_runtime +assert terminate_process_tree is process_runtime.terminate_process_tree +exports = {} +exec('from loopx.doctor_git import *', exports) +assert exports['terminate_process_tree'] is terminate_process_tree +assert exports['git_source_metadata'] is doctor_git.git_source_metadata +"""], + cwd=REPOSITORY_ROOT, capture_output=True, check=False, text=True, + ) + assert completed.returncode == 0, completed.stderr + + def test_lazy_report_and_lark_exports_preserve_original_owner_identity() -> None: # Compatibility includes from-imports, attribute access, dir(), star # exports for report facades, and the existing Lark command patch seam. diff --git a/tests/control_plane/test_host_process.py b/tests/control_plane/test_host_process.py index 5932d7e1d2..9130796b03 100644 --- a/tests/control_plane/test_host_process.py +++ b/tests/control_plane/test_host_process.py @@ -488,6 +488,60 @@ def test_default_turn_deadline_is_optional_and_real_transport_accepts_none(tmp_p assert result["returncode"] == 0 +@pytest.mark.parametrize("operation", ["send", "cancel"]) +def test_host_bridge_preserves_process_helper_patch_points( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, operation: str, +) -> None: + from loopx.control_plane.turn_driver import host_bridge_transport as bridge_module + + calls: list[str] = [] + original_write = bridge_module.write_process_stdin_before_deadline + original_terminate = bridge_module.force_terminate_process + + def write(*args, **kwargs): + calls.append("send") + return original_write(*args, **kwargs) + + def terminate(*args, **kwargs): + assert kwargs["process_tree_owned"] is True + calls.append("cancel") + return original_terminate(*args, **kwargs) + + monkeypatch.setattr(bridge_module, "write_process_stdin_before_deadline", write) + monkeypatch.setattr(bridge_module, "force_terminate_process", terminate) + source = """ +import json, sys +print(json.dumps({"kind": "ready"}), flush=True) +assert json.loads(sys.stdin.readline()) == {"message": "bridge λ😀"} +print(json.dumps({"kind": "result", "outcome": "exited"}), flush=True) +""" + with subprocess.Popen( + [sys.executable, "-u", "-c", source], cwd=tmp_path, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, + encoding="utf-8", start_new_session=True, + ) as process: + bridge = bridge_module.GuardedHostBridge( + process, deadline=time.monotonic() + 5, cleanup_timeout=2, + ) + try: + if operation == "send": + bridge.send(json.dumps({"message": "bridge λ😀"}, ensure_ascii=False) + "\n") + process.wait(timeout=5) + bridge.cancel() + bridge.cancel() + assert calls == [operation] + assert process.poll() is not None + assert bridge.not_launched is (operation == "cancel") + if operation == "send": + assert bridge.result == {"kind": "result", "outcome": "exited"} + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + bridge.close_output() + assert not bridge.reader.is_alive() and bridge.output.closed + + @pytest.mark.parametrize("broken_observation", ["not-json", "[]"]) def test_host_bridge_parser_failure_closes_reader_without_claiming_cleanup(broken_observation): from loopx.control_plane.turn_driver.host_bridge_transport import GuardedHostBridge diff --git a/tests/test_doctor_git_probe_budget.py b/tests/test_doctor_git_probe_budget.py index c9f5ed8618..1a15a065b2 100644 --- a/tests/test_doctor_git_probe_budget.py +++ b/tests/test_doctor_git_probe_budget.py @@ -102,8 +102,9 @@ def test_doctor_finishes_when_git_blocks( @pytest.mark.skipif(os.name != "posix", reason="core.fsmonitor needs a POSIX hook") +@pytest.mark.parametrize("cleanup_error", [False, True]) def test_a_blocked_query_takes_the_helpers_it_started( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, cleanup_error: bool, ) -> None: """Git's own helpers are in its group, and the budget has to retire them. @@ -127,9 +128,20 @@ def test_a_blocked_query_takes_the_helpers_it_started( ) monkeypatch.setattr(doctor_git, "GIT_PROBE_TIMEOUT_SECONDS", 0.8) + cleanup_calls: list[float] = [] + original_terminate = doctor_git.terminate_process_tree + + def terminate(process, *, grace_seconds): + cleanup_calls.append(grace_seconds) + original_terminate(process, grace_seconds=grace_seconds) + if cleanup_error: + raise RuntimeError("fixture cleanup could not confirm retirement") + + monkeypatch.setattr(doctor_git, "terminate_process_tree", terminate) started = time.monotonic() assert doctor_git._run_git(repo, "status", "--porcelain") is None assert time.monotonic() - started < 6 + assert cleanup_calls == [0.2] retired = heartbeats.stat().st_size assert retired > 0, "the fixture hook never ran, so this proves nothing" diff --git a/tests/test_loopx_turn_driver.py b/tests/test_loopx_turn_driver.py index 3afb07ea43..e5c1047393 100644 --- a/tests/test_loopx_turn_driver.py +++ b/tests/test_loopx_turn_driver.py @@ -2432,7 +2432,16 @@ def run(extra=()): def test_turn_run_once_cli_dsh_fresh_sessions_and_iteration_failure( tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: + original_builder = turn_run_once_command.build_dsh_host_runner + builder_calls: list[Path] = [] + + def patched_builder(*args: Any, **kwargs: Any) -> Any: + builder_calls.append(kwargs["workspace"]) + return original_builder(*args, **kwargs) + + monkeypatch.setattr(turn_run_once_command, "build_dsh_host_runner", patched_builder) project, runtime, registry = _write_live_fixture(tmp_path) host_project = tmp_path / "isolated-dsh-workspace" host_project.mkdir() @@ -2507,6 +2516,7 @@ def run(turn_instance_id: str, context: str) -> dict[str, object]: run("fresh-fixture-2", "fresh") run("resume-fixture-1", "resume") run("resume-fixture-2", "resume") + assert builder_calls == [host_project] * 4 session_ids = (host_project / "dsh-session-ids.txt").read_text( encoding="utf-8"