diff --git a/docs/architecture/rfcs/monorepo-distribution-split-v0.md b/docs/architecture/rfcs/monorepo-distribution-split-v0.md index 4754501bf7..fcdcc538b2 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.md @@ -2699,6 +2699,98 @@ context/model/split-install/PostgreSQL-host/full-cost/adoption gaps remain open. The original task and independent review/readiness remain open; no main merge or installed-host upgrade is included. +### 2026-11-08 — Report construction loading at Turn composition + +The existing `periodic-report` Python provider and CLI Turn adapter now resolve +report source, preset, stage projection and request exports on demand. At runtime +revision `7858d96e83ef98174cc43870e283f8f0e6f40c0b`, disabled subscriptions do +not import those construction providers. The capability package already had lazy +exports; this change fixes its concrete composition callers. Subscription +validation remains with the same owner, including its cadence-to-profile schedule +validator. Goal overrides, live machine defaults, configuration warnings, original +export identities and patch points, hook profile digest/policy version, source +selection, receipt replay and authority are retained. It adds no capability, +configuration, UI, persisted vocabulary or decision owner. + +Against baseline `c263f7c78353fcaa914f23475d4d99d07eab7c01`, eleven identical +synthetic configuration cases preserve hook output, trigger decisions and complete +configuration error messages. The cold-process refusal oracle fails before the +change when disabled composition imports the report source. Eight focused cases +now cover absence, disabled inheritance, overrides, invalid disabled configuration, +effect-free reads and export/patch compatibility. An initial stronger prohibition +also rejected the canonical profile schedule validator; that failed probe is +retained, and this slice preserves the validator rather than bypassing it. + +Source and separately built wheel File/default-SQLite console journeys pass the +original complete 101-Todo lifecycle: unchanged fixture/artifact, four receipts, +one debit, source/lease/refusal/restart/replay readback and two owned native exits +per journey. Installed package ownership was verified before comparing 1,920 +present tracked entries; seventeen source-only or documentation/media omissions +remain explicit. Wheel test-dependency and frontend-build preparation failures +remain recorded. Isolated PostgreSQL 16 store/service suites pass 391/11 cases +with zero skips; the initial service run's missing environment declaration remains +a skipped run. This does not qualify PostgreSQL host/wheel journeys. Report suites +pass 100 cases. The broader digest inventory check has one identical baseline/head +failure on an unchanged Todo selector; that check remains failed. + +A separate report-composition probe used CPython 3.13.5 on macOS arm64, normal +bytecode in both arms, an uncounted prewarm and eight alternating pairs per +disabled/enabled case. Disabled median wall/CPU observations were 0.255/0.111 s +before and 0.246/0.107 s after; enabled observations were 0.331/0.117 s and +0.321/0.113 s. Loaded LoopX modules fell from 196 to 179/182. These small, +concurrent-test observations do not establish material speedup, sustained tail +latency or complete CLI/Node/model/attention cost benefit. The earlier frozen +20-cost, attribution and normal-bytecode campaigns were not repeated, enlarged or +relabeled. The independently reversible delta is off-state construction isolation. +Exact-head review, CI/readiness and explicit protected merge authority still govern +adoption. Preserve I3 for at least one minor release, M1/#8011, full R0/V10/R1, +D2/D5/D6/D3, context/model/split-install, full-cost/adoption gaps and all historical +failures. The existing runtime-composition work continues through these boundaries. + +### 2026-11-08 — Synthetic Host UTF-8 recovery qualification + +The Windows CPython 3.11 CI arm of #8360 fails six existing original-task +revalidation cases. Its synthetic Python Host inherits a locale decoder for a +pipe, while the existing generic Host transport sends UTF-8 JSON. A disposable +File-provider counterprobe changes only that Host decoder: CP1252 produces +`host_failure` without `task_failure`; UTF-8 reaches the independent artifact +validator and produces `validation_failed` at `task_postcondition`, with +`revalidate_cached_result`. The source macOS arm independently passes all 12 +existing revalidation cases. These observations locate a fixture mismatch; +they do not justify widening production failure classification. + +Fixture commit `a4ce613585fdb329ba3600f0ede768864214ed84`, over main +`4e949f405528fd8087f0623526c62f890c1ce982`, explicitly configures its stdin as +UTF-8 before reading the request. One new File/SQLite regression first sets +only the generated Host's decoder to CP1252, sends a Unicode brief and requires +the rejected artifact to reach task validation. After repairing the artifact, +revalidation must accept it with the original turn key and exactly one Host +invocation. The same regression fails with the immutable baseline Host on the +same CPython 3.13.5/Node 22.22.3 environment, and passes with the correction. +No transport, production owner, persisted state or protocol vocabulary changes. + +On macOS arm64 with Node 22.22.3, CPython 3.11.15 source and a newly installed, +isolated monolithic wheel each pass the new two provider cases plus all 12 +existing original-task recovery cases. Wheel imports are verified outside the +checkout before loading the source-owned fixtures; all 1,920 present packaged +tracked runtime entries match the source bytes. The same 17 source-only +exclusions remain: canary baseline, host plugin/config, fault-test helper and +documentation/media. This qualifies the existing File/SQLite +synthetic Host recovery path, including retained MCP disconnection and lock +timeout recovery cases, not model adoption, split installation or native +Windows. The failed original Windows arm remains historical evidence until a +new exact-head Windows run passes. The independent Frontstage manifest request +still returns HTTP 504 after its bounded retry. + +The original task stays open. Preserve I3 for at least one minor release, +M1/#8011, full R0/V10/R1, D2/D5/D6/D3, context/model/split-install, +PostgreSQL host/wheel, whole-path/full marginal-cost/adoption requirements and +all historical and frozen failures. No cost campaign is repeated or relabeled, +and this fixture qualification establishes no performance benefit. The report +loading proposal #8360 retains its independent review and CI gates; the next +step is exact-head review of this correction and native Windows readback. No +main merge or installed-host upgrade is included. + ## Appendix B: Decision log | Date | Decision | Owner / approval | Alternatives | Normative sections changed | @@ -2822,3 +2914,44 @@ I3/minor-release, M1/#8011, full R0/V10/R1, D2/D5/D6/D3, context/model/split-install and historical failure qualifications remain open. The next useful boundary remains report subscription/construction loading after the combined proposal's review; the original task stays open. + +### 2026-11-08 — Integrated loading and recovery qualification + +The combined proposal adopts the existing feedback/declaration-codec repair +from #8346, report construction isolation from #8360 and UTF-8 synthetic Host +fixture repair from #8365 over target main `fe36f31fd`. Runtime qualification +uses `251d7eaeff4581d3436340f68c5b6cd8c8ebf1ad`. The existing lexical +`write_scope_codec.ts` already removes the observed dependency on full lease +admission; an additional interaction decoder layer is unnecessary. Error and +vocabulary owners, declared scope spellings and retained receipts stay intact. + +On Node 22.22.3, the loading/digest checks and adjacent work-requirement, +lease and effect-program regressions pass (60 cases), as does the control-plane +typecheck. Selected-owner load failure still rejects without fallback. Four +fresh source/wheel × File/SQLite 101-Todo console correctness episodes preserve +the prior immutable fixture/artifact fingerprints, scopes, original completion +time, four receipts, one debit, refusal/restart/replay and two owned native exits +each. All 1,921 present tracked wheel entries match source bytes; seventeen +source-only, documentation and media omissions are unchanged. + +Real PostgreSQL 16.15 store/service suites pass (391/11, zero skips) on an +isolated disposable server that was stopped afterward. The first service run +omitted its dedicated environment variable and skipped one case; that result +remains a failure of qualification, separate from the corrected run. + +A browser-fixture interruption check also depended on the shared five-second +completion window and checked the request ledger before its asynchronous receipt +returned. The scenario now holds only the correction's exact Session/Turn stream, +checks that it is still active and waits for the matching interrupt response. +The original ten-second interrupted-message assertion remains intact. A delayed +receipt probe fails the original scenario; removing only the stream hold from the +candidate reproduces that message timeout, while the complete candidate passes. +This is synthetic browser qualification, not proof of a native-runtime defect or +an installed repair. Completion priority and stale-Turn guards remain unchanged; +the combined head still requires packaged validation, CI and independent review. + +These episodes qualify integration correctness. Whole-path and full marginal +cost and adoption remain unqualified. Preserve I3 for at least one minor +release, M1/#8011, full R0/V10/R1, D2/D5/D6/D3, context/model/split-install, +PostgreSQL host/wheel and every frozen failure. The original task remains open; +this checkpoint does not authorize merge or declare the minimal kernel complete. 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 086a8b0f2f..178229b8df 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md @@ -2096,6 +2096,79 @@ hard-cut 证据、I3/minor-release、M1/#8011、完整 R0/V10/R1、D2/D5/D6/D3 context/model/split-install/PostgreSQL-host/full-cost/adoption 缺口保持开放。 原 Task、独立评审/readiness 继续开放,本轮不合入 main、不升级 installed host。 +### 2026-11-08 — Turn 装配中的报告构造加载 + +既有 `periodic-report` Python provider 与 CLI Turn 适配层改为按需解析报告源、 +预设、阶段投影和请求导出。在 runtime 版本 +`7858d96e83ef98174cc43870e283f8f0e6f40c0b`,禁用订阅不会导入这些构造 provider。 +能力包已有 lazy exports,本次修复的是实际装配调用方。订阅校验仍由原 owner +负责,包括 cadence 使用 profile 中的日程校验器。Goal override、live machine +defaults、配置 warning、原导出身份与 patch 点、hook profile digest/policy version、 +source 选择、receipt replay 和权限保持;不新增 capability、配置、UI、持久化词汇 +或决策 owner。 + +与基线 `c263f7c78353fcaa914f23475d4d99d07eab7c01` 对照,11 个相同合成配置场景 +保持 hook 输出、trigger 决策及完整配置错误消息一致。冷进程拒绝 oracle 在修复前 +因禁用装配导入 report source 而失败;8 个聚焦场景现覆盖缺省、禁用继承、override、 +非法禁用配置、无副作用读取和导出/patch 兼容。最初更强的禁止规则也拒绝了原 +profile 日程校验器;该失败探针保留,本阶段保持校验器,不绕过它。 + +Source 与独立构建 wheel 的 File/default-SQLite console 路径通过原始完整 +101-Todo 生命周期:fixture/artifact 不变、4 个 receipts、一次 debit、source/lease/ +拒绝/重启/重放读回,以及每条路径 2 个自有 native 退出。先验证安装 package +归属,再比对 1,920 个实际存在的 tracked entries;17 个 source-only 或文档/媒体 +省略明确保留。Wheel 测试依赖和 frontend build 准备失败仍记录为失败。 +隔离 PostgreSQL 16 store/service 套件通过 391/11 场景,零 skip;service 首轮 +因缺少环境声明而跳过的记录仍保留。这不资格 PostgreSQL host/wheel 路径。 +报告相关套件通过 100 场景;较广的 digest inventory 检查在未改动 Todo selector +上出现一条完全相同的 baseline/head 失败,该检查仍为失败。 + +独立报告装配探针在 macOS arm64/CPython 3.13.5 下,两侧使用正常 bytecode、一次 +不计样本的预热,对禁用/启用场景各做 8 对交替观察。禁用 wall/CPU 中位数为 +修复前 0.255/0.111 s、修复后 0.246/0.107 s;启用为 0.331/0.117 s 和 +0.321/0.113 s。加载的 LoopX modules 从 196 降到 179/182。小样本与并行测试下 +的这些观察不证明明显提速、持续尾延迟或完整 CLI/Node/model/attention 成本收益。 +早先冻结的 20-cost、归因及正常 bytecode 实验未重跑、扩充或重标签。 +本阶段可独立回滚的增量是关闭态构造隔离。采纳仍需同 head review、CI/readiness +和明确的受保护合并授权。I3 至少保留一个 minor release,M1/#8011、完整 +R0/V10/R1、D2/D5/D6/D3、context/model/split-install、full-cost/adoption 缺口 +及所有历史失败均保持开放;既有 runtime composition 工作继续跨越这些边界。 + +### 2026-11-08 — 合成 Host 的 UTF-8 恢复资格 + +#8360 的 Windows CPython 3.11 CI arm 在六个既有原任务重验证用例中失败。 +合成 Python Host 对 pipe 继承 locale decoder,而原 generic Host transport +发送 UTF-8 JSON。Disposable File-provider counterprobe 只改变该 Host decoder: +CP1252 产生 `host_failure`,没有 `task_failure`;UTF-8 到达独立产物 validator, +在 `task_postcondition` 产生 `validation_failed`,并提供 +`revalidate_cached_result`。源码 macOS arm 独立通过全部12个既有重验证用例。 +这些观察定位了 fixture mismatch,不支持扩大生产 failure classification。 + +Fixture commit `a4ce613585fdb329ba3600f0ede768864214ed84` 基于 main +`4e949f405528fd8087f0623526c62f890c1ce982`,在读取请求前显式将 stdin 配为 +UTF-8。新增 File/SQLite regression 先只把生成 Host 的 decoder 设为 CP1252, +发送 Unicode brief,要求被拒绝的产物到达 task validation。修复产物后,重验证 +必须沿用原 turn key 并接受结果,Host invocation 始终为一次。同一 regression +在相同 CPython3.13.5/Node22.22.3 环境中对不可变 baseline Host 失败,修正后 +通过。不改变 transport、生产 owner、持久状态或协议词汇。 + +macOS arm64/Node22.22.3 下,CPython3.11.15 source 与新安装的隔离单体 wheel +各通过新增两个 provider 用例及全部12个既有原任务恢复用例。先在 checkout 外 +验证 wheel import 归属,再加载 source-owned fixture;1920个实际 packaged +tracked runtime entry 均与源码字节相同。原17个 source-only exclusion 保留: +canary baseline、host plugin/config、fault-test helper 和 documentation/media。 +这验收既有 File/SQLite 合成 Host 恢复路径,包括保留的 MCP +断连及 lock-timeout 恢复用例,不验收模型采纳、split installation 或原生 Windows。 +原失败 Windows arm 保留为历史证据,直到新 exact-head Windows run 通过。 +独立 Frontstage manifest 请求在有界重试后仍为 HTTP504。 + +原任务保持开放。保留 I3 至少一个 minor release、M1/#8011、完整 R0/V10/R1、 +D2/D5/D6/D3、context/model/split-install、PostgreSQL host/wheel、whole-path/ +full marginal-cost/adoption 验收及全部历史/冻结失败。不重跑或重标签成本 +campaign,本 fixture 资格不证明性能收益。报告按需加载提案 #8360 保留独立 +review/CI gate;下一步为本修正的 exact-head 评审与原生 Windows 读回。不包含 +合入 main 或 installed-host 升级。 + ## 附录 B:决策日志 | 日期 | 决策 | 负责人 / 批准 | 备选 | 变更的规范章节 | @@ -2191,3 +2264,35 @@ Whole-path 成本收益、installed adoption、PostgreSQL host/wheel,以及 I3/minor-release、M1/#8011、完整 R0/V10/R1、D2/D5/D6/D3、 context/model/split-install 与历史失败资格继续开放。组合提案评审之后,下一段 有用边界仍是报告订阅/构造加载;原 Task 保持开放。 + +### 2026-11-08 — 组合加载与恢复资格验证 + +组合提案在目标 main `fe36f31fd` 上采用 #8346 已有的 feedback/声明 codec +修复、#8360 的报告构造隔离和 #8365 的 UTF-8 合成 Host fixture 修复。 +运行时代码资格基于 `251d7eaeff4581d3436340f68c5b6cd8c8ebf1ad`。 +已有的词法 `write_scope_codec.ts` 已解除观察到的完整 lease 准入依赖,无需 +增加 interaction decoder 层。错误与词汇 owner、声明 scope 拼写和保留回执均保持。 + +Node 22.22.3 上的加载/digest 与相邻 work-requirement、lease、effect-program +回归全部通过(60 项),控制面类型检查通过。选中 owner 的加载失败仍拒绝执行, +不会回退。四次新的源码/wheel × File/SQLite 101-Todo console 正确性路径均保留 +前次不可变 fixture/artifact 指纹、scope、原完成时间、四份回执、一次扣额、 +拒绝/重启/重放,以及每条路径两个自有 native 进程退出。wheel 中现存的 1,921 个 +tracked 条目逐字节匹配源码,十七项源码专用、文档和媒体遗漏保持不变。 + +真实 PostgreSQL 16.15 store/service 在隔离临时服务上全部通过(391/11,零 +跳过),自有服务随后停止。首次 service 运行漏传专用环境变量,跳过一项; +该次资格失败单独保留,与修正后的运行区分。 + +浏览器 fixture 的中断检查还依赖共享的五秒完成窗口,并在异步回执返回前检查请求 +记录。该场景现在只保持纠偏所对应的准确 Session/Turn 事件流,确认它仍在执行, +并等待匹配的中断响应。原十秒中断消息断言完整保留。延迟回执探针使原场景失败; +仅移除候选中的流保持、保留响应等待,会复现该消息超时,而完整候选通过。 +这是合成浏览器资格验证,不证明 native runtime 存在缺陷或已安装修复。 +完成优先级与 stale-Turn 保护保持不变;组合 head 仍需 packaged 验证、CI 和 +独立评审。 + +这些路径验证组合正确性,whole-path 与完整边际成本、adoption 仍未取得资格。 +保留 I3 至少一个 minor release、M1/#8011、完整 R0/V10/R1、D2/D5/D6/D3、 +context/model/split-install、PostgreSQL host/wheel 以及每项冻结失败。 +原任务保持开放;本检查点不授权合入,也不宣告最小化内核完成。 diff --git a/examples/personal-workspace-browser/typed-actions.mjs b/examples/personal-workspace-browser/typed-actions.mjs index ed77e186c6..ff5ca77f36 100644 --- a/examples/personal-workspace-browser/typed-actions.mjs +++ b/examples/personal-workspace-browser/typed-actions.mjs @@ -421,6 +421,7 @@ export const typedActionsScenario = { const context = await openWorkspacePage(browser, url, { collectCoverage }); const { api, checkpointCoverage, errors: pageErrors, page } = context; + const interruptedCorrectionStream = Promise.withResolvers(); const failures = []; const notes = []; const pass = (criterion, note) => notes.push(`${criterion}: ${note}`); @@ -2105,8 +2106,19 @@ export const typedActionsScenario = { if (!await currentRuntimeRun.isVisible()) await page.locator(".personal-activity-summary > summary").click(); await currentRuntimeRun.click(); await page.getByRole("tab", { name: "详情与操作" }).click(); + const interruptionQuestion = "保持运行,等我检查中断控制。"; + // This assertion needs a running correction, independent of how long + // drawer navigation takes. Hold only its exact Session/Turn stream; + // completion races remain covered by conversation-activity. + await page.route("**/api/chat/sessions/*/turns/*/events", async (route) => { + const path = new URL(route.request().url()).pathname.split("/"); + const correction = api.turnRequests.find((turn) => turn.message === interruptionQuestion + && turn.sessionId === path[4] && turn.turnId === path[6]); + if (correction) await interruptedCorrectionStream.promise; + return route.fallback(); + }); const runningCorrection = page.getByLabel("输入纠偏信息"); - await runningCorrection.fill("保持运行,等我检查中断控制。 "); + await runningCorrection.fill(interruptionQuestion); await page.getByRole("button", { name: "发送纠偏" }).click(); await page.getByText("更多运行操作").click(); const interruptButton = page.getByRole("button", { name: "中断本次运行" }); @@ -2116,11 +2128,19 @@ export const typedActionsScenario = { await page.screenshot({ path: resolve(outputDir, "interrupt-state-failed.png"), fullPage: true, animations: "disabled" }); throw new Error(`${error.message}; body=${(await page.locator("body").innerText()).slice(-4000)}`); } - await interruptButton.click(); const secondCorrection = api.turnRequests.find((turn) => turn.message.includes("中断控制")); if (!secondCorrection || secondCorrection.sessionId === firstCorrection.sessionId) { throw new Error("Agent change reused the earlier Agent Session or failed to start the second correction"); } + assert.equal(page.__loopxRuntime.sessions.get(secondCorrection.sessionId)?.active_turn_id, + secondCorrection.turnId, "The correction must still be running before interruption"); + const interruptPath = `/api/chat/sessions/${secondCorrection.sessionId}/turns/${secondCorrection.turnId}/interrupt`; + // A click starts the async operation; it does not prove receipt delivery. + await Promise.all([ + page.waitForResponse((response) => + new URL(response.url()).pathname === interruptPath && response.request().method() === "POST"), + interruptButton.click(), + ]); if (!api.interrupts.some((turn) => turn.sessionId === secondCorrection.sessionId && turn.turnId === secondCorrection.turnId)) { throw new Error("Interrupt did not target the active Session and Turn"); } @@ -2183,6 +2203,7 @@ export const typedActionsScenario = { if (failures.length) throw new Error(failures.join(" | ")); } finally { + interruptedCorrectionStream.resolve(); await context.close(); } return { diff --git a/loopx/capabilities/periodic_report/post_writeback_hook.py b/loopx/capabilities/periodic_report/post_writeback_hook.py index a10e6158e6..51d451a6f7 100644 --- a/loopx/capabilities/periodic_report/post_writeback_hook.py +++ b/loopx/capabilities/periodic_report/post_writeback_hook.py @@ -1,8 +1,10 @@ from __future__ import annotations import json +import sys import warnings from collections.abc import Mapping, Sequence +from importlib import import_module from pathlib import Path from typing import Any @@ -11,26 +13,50 @@ PostWritebackHookRegistration, ) from ...control_plane.digest_envelope import enveloped_sha256, sha256_envelope -from ...control_plane.coordination.local_authority import canonical_todo_summary_fields -from ...control_plane.goals.goal_frontier import ( - build_goal_frontier_projection_from_summaries, -) -from .todo_source import read_report_source_history, read_report_todo_source -from ...control_plane.todos.quota_summary import summarize_user_todos_for_quota -from ...control_plane.todos.todo_index import MAX_TODO_INDEX_ROLLOUT_EVENTS_PER_GOAL from ...history import collect_history, load_index_snapshot, load_registry from ...paths import resolve_runtime_root from ...registry import registry_goals -from ...rollout_event_log import load_rollout_events, rollout_event_log_path -from .stage_completion import STAGE_COMPLETION_RECEIPT_SCHEMA -from .stage_completion import derive_periodic_report_stage_completion_from_runs -from .presets import build_periodic_report_preset_activation -from .project_progress_snapshot import build_project_progress_snapshot_from_fields -from .incremental import read_periodic_report_goal_publication_cursors from .machine_defaults import resolve_goal_periodic_report_subscription from .machine_store import read_periodic_report_machine_defaults -from .triggers import build_periodic_report_trigger_decision -from .request_action import REQUEST_ACTION_SCHEMA + + +# Subscription validation stays on the composition path. Construction and +# source providers are loaded only by their actual caller, with the original +# module-level aliases retained for compatibility and caller patching. +_LAZY_EXPORTS = { + "canonical_todo_summary_fields": "loopx.control_plane.coordination.local_authority", + "build_goal_frontier_projection_from_summaries": "loopx.control_plane.goals.goal_frontier", + "read_report_source_history": "loopx.capabilities.periodic_report.todo_source", + "read_report_todo_source": "loopx.capabilities.periodic_report.todo_source", + "summarize_user_todos_for_quota": "loopx.control_plane.todos.quota_summary", + "MAX_TODO_INDEX_ROLLOUT_EVENTS_PER_GOAL": "loopx.control_plane.todos.todo_index", + "load_rollout_events": "loopx.rollout_event_log", + "rollout_event_log_path": "loopx.rollout_event_log", + "STAGE_COMPLETION_RECEIPT_SCHEMA": "loopx.capabilities.periodic_report.stage_completion", + "derive_periodic_report_stage_completion_from_runs": "loopx.capabilities.periodic_report.stage_completion", + "build_periodic_report_preset_activation": "loopx.capabilities.periodic_report.presets", + "build_project_progress_snapshot_from_fields": "loopx.capabilities.periodic_report.project_progress_snapshot", + "read_periodic_report_goal_publication_cursors": "loopx.capabilities.periodic_report.incremental", + "build_periodic_report_trigger_decision": "loopx.capabilities.periodic_report.triggers", + "REQUEST_ACTION_SCHEMA": "loopx.capabilities.periodic_report.request_action", +} + + +def __getattr__(name: str) -> Any: + owner = _LAZY_EXPORTS.get(name) + if owner is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(import_module(owner), name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(set(globals()) | set(_LAZY_EXPORTS)) + + +def _report_export(name: str) -> Any: + return getattr(sys.modules[__name__], name) PERIODIC_REPORT_POST_WRITEBACK_HOOK_ID = "periodic_report.runtime_trigger" @@ -118,7 +144,7 @@ def periodic_report_post_writeback_hooks_for_goal( preset = str(subscription.get("profile_preset") or "").strip() if not preset: return () - activation = build_periodic_report_preset_activation(preset) + activation = _report_export("build_periodic_report_preset_activation")(preset) if activation.get("active") is not True: return () profile = activation.get("profile") @@ -159,9 +185,9 @@ def _frontier_projection( if isinstance(todos.get("agent_todos"), Mapping) else {} ) - user_summary = summarize_user_todos_for_quota(raw_user_summary) or {} - agent_summary = summarize_user_todos_for_quota(raw_agent_summary) or {} - projection = build_goal_frontier_projection_from_summaries( + user_summary = _report_export("summarize_user_todos_for_quota")(raw_user_summary) or {} + agent_summary = _report_export("summarize_user_todos_for_quota")(raw_agent_summary) or {} + projection = _report_export("build_goal_frontier_projection_from_summaries")( goal_id=goal_id, agent_id=agent_id, user_todo_summary=user_summary, @@ -210,21 +236,21 @@ def build_periodic_report_post_writeback_projection( if source_todos.get("status") != "loaded": raise ValueError("original canonical Todo source is unavailable") source_todos["_todo_fields"] = restore_todo_field_evaluations(readback) - source_runs = read_report_source_history( + source_runs = _report_export("read_report_source_history")( runtime_root=runtime_root, goal_id=goal_id, run=payload, ) if (source_todos is None) != (source_runs is None): raise ValueError("a committed source requires both Todo and run history") - events = [] if source_todos is not None else load_rollout_events( - rollout_event_log_path(runtime_root, goal_id), - limit=MAX_TODO_INDEX_ROLLOUT_EVENTS_PER_GOAL, + events = [] if source_todos is not None else _report_export("load_rollout_events")( + _report_export("rollout_event_log_path")(runtime_root, goal_id), + limit=_report_export("MAX_TODO_INDEX_ROLLOUT_EVENTS_PER_GOAL"), ) state = payload.get("state") state_path = ( state.get("path") if isinstance(state, Mapping) else None ) or payload.get("state_file") if source_todos is not None: - fields = source_todos.get("_todo_fields") or canonical_todo_summary_fields( + fields = source_todos.get("_todo_fields") or _report_export("canonical_todo_summary_fields")( source_todos["todos"], rollout_events=events, available_capabilities=available_capabilities, @@ -232,7 +258,7 @@ def build_periodic_report_post_writeback_projection( goal_acceptance_work_guards=source_todos.get("goal_acceptance_work_guards"), ) else: - fields, _ = read_report_todo_source( + fields, _ = _report_export("read_report_todo_source")( registry_path=registry_path, runtime_root=runtime_root, goal_id=goal_id, @@ -265,7 +291,7 @@ def build_periodic_report_post_writeback_projection( # Terminal settlement follows the current history. A successor milestone is # only claimed below, from the exact durable refresh that carries its # accepted ACK and selected Vision. - receipt = derive_periodic_report_stage_completion_from_runs( + receipt = _report_export("derive_periodic_report_stage_completion_from_runs")( latest_runs=latest_runs, agent_id=normalized_agent_id, goal_frontier_projection=projection, @@ -314,7 +340,7 @@ def build_periodic_report_post_writeback_projection( ): return {} ack["agent_id"] = attributed_agent_id - receipt = derive_periodic_report_stage_completion_from_runs( + receipt = _report_export("derive_periodic_report_stage_completion_from_runs")( latest_runs=source_refresh_runs, agent_id=normalized_agent_id, goal_frontier_projection=projection, @@ -330,14 +356,14 @@ def build_periodic_report_post_writeback_projection( if receipt is None: return {} result: dict[str, object] = {"stage_completion": receipt} - goal_cursors = read_periodic_report_goal_publication_cursors( + goal_cursors = _report_export("read_periodic_report_goal_publication_cursors")( runtime_root=runtime_root, goal_id=goal_id, ) publication_cursor = next( (c for c in goal_cursors if c["agent_id"] == normalized_agent_id), None ) - project_progress = build_project_progress_snapshot_from_fields( + project_progress = _report_export("build_project_progress_snapshot_from_fields")( fields=fields, goal_id=goal_id, agent_id=normalized_agent_id, @@ -376,7 +402,7 @@ def producer(hook_input: Mapping[str, Any]) -> dict[str, Any]: ) if ( not isinstance(stage, Mapping) - or stage.get("schema_version") != STAGE_COMPLETION_RECEIPT_SCHEMA + or stage.get("schema_version") != _report_export("STAGE_COMPLETION_RECEIPT_SCHEMA") or not isinstance(stage.get("stage_identity"), str) or not stage["stage_identity"] ): @@ -479,7 +505,7 @@ def evaluate_periodic_report_trigger_evaluation_intent( "idempotency_key" ) != "periodic-report:" + window["window_id"]: raise ValueError("cadence intent identity does not match its window") - return build_periodic_report_trigger_decision({ + return _report_export("build_periodic_report_trigger_decision")({ "schema_version": "periodic_report_trigger_request_v0", "evaluated_at": window["due_at"], "profile": {"profile_id": profile_ref.get("profile_id"), @@ -508,7 +534,7 @@ def evaluate_periodic_report_trigger_evaluation_intent( } if ( set(report_request) != expected - or report_request.get("schema_version") != REQUEST_ACTION_SCHEMA + or report_request.get("schema_version") != _report_export("REQUEST_ACTION_SCHEMA") or report_request.get("requester_kind") != "user" or report_request.get("addressing_source") not in {"provider_mention", "verified_reply"} @@ -538,7 +564,7 @@ def evaluate_periodic_report_trigger_evaluation_intent( separators=(",", ":"), ).encode() ) - return build_periodic_report_trigger_decision( + return _report_export("build_periodic_report_trigger_decision")( { "schema_version": "periodic_report_trigger_request_v0", "evaluated_at": requested_at, @@ -570,7 +596,7 @@ def evaluate_periodic_report_trigger_evaluation_intent( "completed_at", ) if ( - stage.get("schema_version") != STAGE_COMPLETION_RECEIPT_SCHEMA + stage.get("schema_version") != _report_export("STAGE_COMPLETION_RECEIPT_SCHEMA") or stage.get("acceptance") != "validated" or stage.get("outcome_checkpoint_satisfied") is not True or any( @@ -618,7 +644,7 @@ def evaluate_periodic_report_trigger_evaluation_intent( } if isinstance(payload.get("last_report"), Mapping): request["last_report"] = dict(payload["last_report"]) - return build_periodic_report_trigger_decision(request) + return _report_export("build_periodic_report_trigger_decision")(request) __all__ = [ diff --git a/loopx/cli_commands/turn_post_writeback.py b/loopx/cli_commands/turn_post_writeback.py index 9c07ebbea4..3f1ab63ac1 100644 --- a/loopx/cli_commands/turn_post_writeback.py +++ b/loopx/cli_commands/turn_post_writeback.py @@ -2,15 +2,12 @@ from __future__ import annotations +import sys from collections.abc import Mapping +from importlib import import_module from pathlib import Path from typing import Any -from ..capabilities.periodic_report.post_writeback_hook import ( - build_periodic_report_post_writeback_projection, - periodic_report_post_writeback_hooks_for_goal, -) -from ..capabilities.periodic_report.todo_source import read_report_source_history from ..control_plane.effect_runtime import ( CANONICAL_AUTHORITY_READ_TIMEOUT_SECONDS, effect_runtime_result, @@ -22,6 +19,30 @@ ) +_LAZY_EXPORTS = { + "build_periodic_report_post_writeback_projection": "loopx.capabilities.periodic_report.post_writeback_hook", + "periodic_report_post_writeback_hooks_for_goal": "loopx.capabilities.periodic_report.post_writeback_hook", + "read_report_source_history": "loopx.capabilities.periodic_report.todo_source", +} + + +def __getattr__(name: str) -> Any: + owner = _LAZY_EXPORTS.get(name) + if owner is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(import_module(owner), name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(set(globals()) | set(_LAZY_EXPORTS)) + + +def _report_export(name: str) -> Any: + return getattr(sys.modules[__name__], name) + + def drain_committed_turn_post_writeback_hooks( *, registry_path: Path, @@ -32,7 +53,7 @@ def drain_committed_turn_post_writeback_hooks( ) -> dict[str, Any]: """Drain committed sources on first return and replay without primary effects.""" try: - hooks = periodic_report_post_writeback_hooks_for_goal( + hooks = _report_export("periodic_report_post_writeback_hooks_for_goal")( registry_path=registry_path, goal_id=identity.goal_id, runtime_root=runtime_root ) except Exception: # Optional subscription reads grant no primary failure. @@ -49,7 +70,7 @@ def drain_committed_turn_post_writeback_hooks( run = readback.writeback_run if readback is not None else None if run is None: return {} - source_runs = read_report_source_history( + source_runs = _report_export("read_report_source_history")( runtime_root=runtime_root, goal_id=identity.goal_id, run=run, ) except Exception: # Optional source recovery must preserve primary settlement. @@ -83,7 +104,7 @@ def dispatch(event_kind: str, request: dict[str, Any]) -> dict[str, Any] | None: ) def projection_builder(**kwargs: Any) -> Mapping[str, object]: - return build_periodic_report_post_writeback_projection( + return _report_export("build_periodic_report_post_writeback_projection")( **kwargs, source_todos=source, source_runs=source_runs, ) @@ -122,3 +143,7 @@ def projection_builder(**kwargs: Any) -> Mapping[str, object]: if terminal is not None: result["terminal_post_writeback_hooks"] = terminal return result + + +# Preserve the previous implicit wildcard exports; explicit imports remain lazy. +__all__ = sorted({name for name in globals() if not name.startswith("_")} | set(_LAZY_EXPORTS)) diff --git a/tests/control_plane/test_periodic_report_loading.py b/tests/control_plane/test_periodic_report_loading.py new file mode 100644 index 0000000000..983d462938 --- /dev/null +++ b/tests/control_plane/test_periodic_report_loading.py @@ -0,0 +1,143 @@ +"""Off-state Turn composition must not load report construction providers.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import sys + +import pytest + + +_SOURCE = Path(__file__).resolve().parents[2] +_CONSTRUCTION_MODULES = ( + "loopx.capabilities.periodic_report.presets", + # cadence still uses profile's canonical schedule validator, including + # disabled subscriptions. This slice preserves that validation boundary. + "loopx.capabilities.periodic_report.project_progress", + "loopx.capabilities.periodic_report.project_progress_snapshot", + "loopx.capabilities.periodic_report.stage_completion", + "loopx.capabilities.periodic_report.request_action", + "loopx.capabilities.periodic_report.todo_source", +) + + +@pytest.mark.parametrize( + "override,default,warning", + [ + (None, None, None), + ({"enabled": False, "profile_preset": "weekly"}, None, None), + ({"enabled": False}, True, None), + (None, False, None), + ({"enabled": "true"}, None, "enabled must be a boolean"), + ({"enabled": False, "timezone": "unknown/timezone"}, None, "timezone is unknown"), + (None, "true", "enabled must be a boolean"), + ], +) +def test_disabled_turn_composition_keeps_validation_without_construction( + tmp_path: Path, override: object, default: object, warning: str | None +) -> None: + goal: dict[str, object] = {"id": "fixture-goal"} + if override is not None: + goal["control_plane"] = {"periodic_report": override} + registry = tmp_path / "registry.json" + registry.write_text(json.dumps({"goals": [goal]}), encoding="utf-8") + runtime = tmp_path / "runtime" + if default is not None: + store = runtime / "machine" / "configuration.json" + store.parent.mkdir(parents=True) + store.write_text(json.dumps({ + "schema_version": "loopx_machine_configuration_v0", + "namespaces": {"periodic_report": { + "schema_version": "periodic_report_machine_defaults_v0", + "enabled": default, "profile_preset": "weekly-progress", + "route_ref": "fixture-route", "timezone": "UTC", + }}, + }), encoding="utf-8") + script = f""" +import importlib.abc, os, sys, warnings +from pathlib import Path +from types import SimpleNamespace +sys.dont_write_bytecode = True +class RejectConstruction(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname in {_CONSTRUCTION_MODULES!r}: + raise AssertionError('off-state loaded construction: ' + fullname) +sys.meta_path.insert(0, RejectConstruction()) +def reject_effect(event, args): + assert event not in ('subprocess.Popen', 'os.system', 'os.posix_spawn', 'socket.connect'), event + if event == 'open': + mode, flags = args[1:3] + assert not (isinstance(mode, str) and any(c in mode for c in 'wax+')), args + assert not (isinstance(flags, int) and flags & (os.O_WRONLY | os.O_RDWR | os.O_CREAT)), args +sys.addaudithook(reject_effect) +from loopx.cli_commands.turn_post_writeback import drain_committed_turn_post_writeback_hooks +from loopx.capabilities.periodic_report.post_writeback_hook import periodic_report_post_writeback_hooks_for_goal +with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + assert periodic_report_post_writeback_hooks_for_goal( + registry_path=Path({str(registry)!r}), runtime_root=Path({str(runtime)!r}), + goal_id='fixture-goal') == () + assert drain_committed_turn_post_writeback_hooks( + registry_path=Path({str(registry)!r}), runtime_root=Path({str(runtime)!r}), + identity=SimpleNamespace(goal_id='fixture-goal'), goal_ref=None, + available_capabilities=['shell']) == {{}} +expected = {warning!r} +assert len(caught) == (2 if expected else 0), [str(w.message) for w in caught] +assert expected is None or all(expected in str(w.message) for w in caught) +assert not set({_CONSTRUCTION_MODULES!r}) & set(sys.modules) +""" + before = {p: p.read_bytes() for p in tmp_path.rglob("*") if p.is_file()} + result = subprocess.run( + [sys.executable, "-c", script], cwd=tmp_path, capture_output=True, text=True, + env={**os.environ, "PYTHONPATH": str(_SOURCE), "LOOPX_USAGE_PING": "0"}, + timeout=30, + ) + assert result.returncode == 0, result.stderr + assert {p: p.read_bytes() for p in tmp_path.rglob("*") if p.is_file()} == before + + +def test_lazy_report_exports_retain_owner_identity_and_patch_points(tmp_path, monkeypatch): + import importlib + import loopx.capabilities.periodic_report.post_writeback_hook as report + import loopx.cli_commands.turn_post_writeback as turn + + for module in (report, turn): + for name, owner in module._LAZY_EXPORTS.items(): + original = getattr(importlib.import_module(owner), name) + assert getattr(module, name) is original + assert getattr(module, name) is original + assert name in dir(module) + with pytest.raises(AttributeError): + getattr(module, "unknown_report_export") + wildcard = {} + exec("from loopx.cli_commands.turn_post_writeback import *", wildcard) + assert wildcard["read_report_source_history"] is turn.read_report_source_history + assert wildcard["periodic_report_post_writeback_hooks_for_goal"] is report.periodic_report_post_writeback_hooks_for_goal + registry = tmp_path / "registry.json" + registry.write_text(json.dumps({"goals": [{"id": "fixture-goal", "control_plane": { + "periodic_report": {"enabled": True, "profile_preset": "weekly", + "route_ref": "fixture-route"}, + }}]}), encoding="utf-8") + seen = [] + original = report.build_periodic_report_preset_activation + def activation(preset): + seen.append(preset) + return original(preset) + monkeypatch.setattr(report, "build_periodic_report_preset_activation", activation) + hooks = report.periodic_report_post_writeback_hooks_for_goal( + registry_path=registry, runtime_root=tmp_path / "runtime", goal_id="fixture-goal") + assert seen == ["weekly"] + assert len(hooks) == 1 and hooks[0].hook_id == "periodic_report.runtime_trigger" + expected = original("weekly") + assert hooks[0].policy_version == "weekly-" + expected["profile_digest"][-16:] + sentinel = object() + monkeypatch.setattr(turn, "periodic_report_post_writeback_hooks_for_goal", lambda **_: ()) + from types import SimpleNamespace + monkeypatch.setattr(turn, "read_heartbeat_settlement", lambda *a, **k: sentinel) + assert turn.drain_committed_turn_post_writeback_hooks( + registry_path=registry, runtime_root=tmp_path / "runtime", + identity=SimpleNamespace(goal_id="fixture-goal"), goal_ref=None, + available_capabilities=None) == {} diff --git a/tests/test_local_delegation.py b/tests/test_local_delegation.py index d76b1ae942..e23b5dd6ff 100644 --- a/tests/test_local_delegation.py +++ b/tests/test_local_delegation.py @@ -38,6 +38,9 @@ from loopx.control_plane.quota.turn_envelope import turn_host_lineage from loopx.control_plane.collaboration.inbox import acknowledge from loopx.control_plane.collaboration.peers import return_result +# The generic Host protocol sends UTF-8 over a pipe, whose Python 3.11 +# Windows default is a locale codec rather than the console's UTF-8 mode. +sys.stdin.reconfigure(encoding='utf-8') request = json.load(sys.stdin) workspace = Path.cwd() root = Path(sys.argv[1]) @@ -98,6 +101,35 @@ def brief(): "return_requirement": "Return the independently checked artifact"} +def test_fixture_host_revalidates_utf8_input_under_a_non_utf8_pipe(service): + """The fixture must reach task validation with the original UTF-8 identity.""" + root, runner = service + host = root / "fixture-host.py" + # Model the Windows Python 3.11 pipe default without changing the parent + # transport or requiring a platform-dependent locale on the test runner. + host.write_text("import sys; sys.stdin.reconfigure(encoding='cp1252')\n" + + host.read_text(encoding="utf-8"), encoding="utf-8") + output = root / "analyst/initial/output.json" + saved = output.read_bytes() + output.write_text("{}", encoding="utf-8") + request = {**brief(), "context": "Read the synthetic résumé: 报告 € → evidence."} + runner.start("analysis", "analysis-1", request) + rejected = wait(runner) + assert rejected["status"] == "rejected", rejected + assert "task_failure" in rejected, rejected + assert rejected["task_failure"]["resume_mode"] == "revalidate_cached_result" + turn_key = rejected["task_failure"]["turn_key"] + assert (root / "analyst/initial/host-invocations").read_text() == "1" + + output.write_bytes(saved) + runner.revalidate("analysis-1") + accepted = wait(runner) + assert accepted["status"] == "accepted", accepted + assert "task_failure" not in accepted + assert json.loads(runner.path("analysis-1").read_text())["turn_key"] == turn_key + assert (root / "analyst/initial/host-invocations").read_text() == "1" + + @pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_delegation_fixture_isolates_cached_and_child_runtime_routes(tmp_path, monkeypatch, provider): """A warmed parent must use the same private Effect server as its CLI."""